중첩을 멈춰라: 콜백을 “평평하게” 만드는 법
콜백 기반 비동기 API는 Promise로 감싸고, 순차 작업은 async/await + 루프로 펼치면 유지보수가 쉬워진다.
중첩을 멈춰라: 콜백을 Promise/async로 “평평하게” 만드는 법
요약
- 한 문장 결론: 콜백 기반 비동기 API는 Promise로 감싸고, 순차 작업은
async/await + 루프로 펼치면 유지보수가 쉬워진다. - Electron에서는 “어디서 실행되는지(메인/렌더러/프리로드)”가 곧 안전성과 구조다.
- 메인 프로세스의 레거시 콜백 코드를 Promise로 바꾸면, 렌더러(UI)에서 동기처럼 읽히는 흐름을 만들 수 있다.
- 반복되는 “폴더를 내려가며 찾기”는 복붙 대신 루프로 일반화하는 게 핵심이다.
배경/문제
비동기 처리는 “나중에 결과가 오는 작업”을 다룰 때 필수다. 문제는 콜백만으로 순차 작업을 이어 붙이면 코드가 쉽게 안쪽으로 파고든다는 점이다. 요구가 깊어질수록 들여쓰기가 늘고, 흐름을 따라가기가 어렵다.
Electron에서는 여기에 한 가지가 더 붙는다. 메인 프로세스(백엔드 역할)와 렌더러(UI)가 분리되어 있기 때문에, 콜백 중첩이 UI 코드까지 번지면 디버깅과 구조가 더 빠르게 무너진다.
공식 보안 가이드는 원격/렌더링 컨텍스트에서 Node.js 통합을 켜기보다는, preload + contextBridge로 필요한 API만 노출하는 방식을 권장한다. (Electron Docs: Security)
핵심 개념
- 메인 프로세스(Main process): OS/파일/네이티브 기능을 다루는 “앱의 백엔드”
- 렌더러(Renderer process): 화면(UI)을 그리는 “웹 페이지”
- 프리로드(Preload): 메인과 렌더러 사이에서, 필요한 기능만 안전하게 연결하는 브리지
ipcRenderer.invoke+ipcMain.handle: 렌더러에서 “결과를 기다리는” RPC 형태의 IPC 패턴 (Electron Docs: ipcRenderer, Electron Docs: ipcMain)- Promise / async/await: 콜백을 “반환값”으로 바꿔 흐름을 평평하게 만든다. (MDN Web Docs: Promise, MDN Web Docs: async function)
해결 접근
아래 예시는 “데모 폴더 트리”를 앱 실행 시 자동으로 만들어 둔 뒤,
- 메인 프로세스에 콜백 기반
getDirLegacy를 만들고 - 이를 Promise로 래핑한 다음
- 렌더러(UI)에서
async/await + 루프로 “깊게 내려가며 폴더 찾기”를 구현한다.
또한 Electron 권장 구조에 맞춰 렌더러는 window.dirApi만 사용하고, Node.js/파일 시스템 접근은 메인에 둔다. (Electron Docs: Security)
getDirLegacy만 쓰면 생기는 문제
getDirLegacy(dirPath, callback) 형태는 “끝나면 콜백을 부른다”는 규칙만 가진다. 이 방식만으로 순차 작업을 이어가면 아래 문제가 빠르게 드러난다.
- 중첩이 깊어진다: 다음 단계가 이전 단계 결과에 의존할수록 들여쓰기가 계속 늘어난다.
- 에러 처리가 흩어진다: 단계마다
if (err) return ...이 반복되고, 중간 실패 시 정리 로직도 분산되기 쉽다. - 조합이 어렵다: “결과를 반환”하지 않으니, 다른 비동기 흐름(
await,.then(),Promise.all)에 끼워 넣기 어렵다. - Electron IPC와 결이 안 맞는다: 렌더러에서
ipcRenderer.invoke()로 호출하면 Promise가 오길 기대하는데, 콜백은 반환값이 없어 그대로는 연결되지 않는다.
(예시) 콜백 중첩 + 에러 처리 반복
getDirLegacy(basePath, (err, folders1) => {
if (err) return console.error("step1 error", err)
const found1 = folders1.find((name) => name === "Etc")
if (!found1) return console.log("Etc not found")
getDirLegacy(path.join(basePath, found1), (err2, folders2) => {
if (err2) return console.error("step2 error", err2)
const found2 = folders2.find((name) => name === "Download")
if (!found2) return console.log("Download not found")
getDirLegacy(path.join(basePath, found1, found2), (err3, folders3) => {
if (err3) return console.error("step3 error", err3)
console.log("folders3", folders3)
})
})
})기대 결과/무엇이 달라졌는지
- 동작은 가능하지만, 단계가 늘수록 들여쓰기와 에러 분기가 함께 늘어나 흐름을 읽기 어려워진다.
(예시) ipcMain.handle에서 콜백을 그대로 쓰면 결과가 끊긴다
ipcMain.handle("get-dir-legacy", (_event, dirPath) => {
// ❌ 반환값이 없어서 renderer의 invoke()와 연결되지 않는다.
getDirLegacy(dirPath, (err, folders) => {
if (err) console.error(err)
// 여기서 folders를 만들어도 renderer로 자동 전달되지 않는다.
})
})기대 결과/무엇이 달라졌는지
- 렌더러에서 await ipcRenderer.invoke("get-dir-legacy", ...)를 호출해도, 핸들러가 Promise를 반환하지 않으므로 결과가 자연스럽게 이어지지 않는다.
- 그래서 다음 단계로 가기 위해 Promise 래핑(또는 Promise 기반 API) 가 필요해진다.
⚠️ 보안/설계 주의
구현(코드)
파일 구성
main.js(메인 프로세스)preload.js(브리지)index.html(UI)renderer.js(렌더러 스크립트)
1) main.js: 콜백 API + Promise 래핑 + IPC 핸들러
// main.js
const { app, BrowserWindow, ipcMain } = require("electron")
const path = require("node:path")
const fs = require("node:fs")
function ensureDir(p) {
fs.mkdirSync(p, { recursive: true })
}
// 1) 레거시 콜백 스타일 (err-first)
function getDirLegacy(dirPath, callback) {
fs.readdir(dirPath, { withFileTypes: true }, (err, entries) => {
if (err) return callback(err)
const folders = entries.filter((e) => e.isDirectory()).map((e) => e.name)
callback(null, folders)
})
}
// 2) Promise로 래핑
function getDir(dirPath) {
return new Promise((resolve, reject) => {
getDirLegacy(dirPath, (err, folders) => {
if (err) return reject(err)
resolve(folders)
})
})
}
function createDemoTree(root) {
// demo-root/
// Documents/
// Etc/Download/Download/Download/
ensureDir(path.join(root, "Documents"))
ensureDir(path.join(root, "Etc", "Download", "Download", "Download"))
}
function createWindow(preloadPath) {
const win = new BrowserWindow({
width: 900,
height: 650,
webPreferences: {
preload: preloadPath,
contextIsolation: true,
nodeIntegration: false,
},
})
win.loadFile("index.html")
}
app.whenReady().then(() => {
const demoRoot = path.join(app.getPath("userData"), "demo-tree")
createDemoTree(demoRoot)
// invoke/handle 패턴: renderer -> main (결과 반환)
ipcMain.handle("demo-root", async () => demoRoot)
ipcMain.handle("get-dir", async (_event, dirPath) => {
return await getDir(dirPath)
})
createWindow(path.join(__dirname, "preload.js"))
})기대 결과/무엇이 달라졌는지
- 콜백 기반 getDirLegacy를 유지하면서도, getDir()가 Promise를 반환해 “기다릴 수 있는 API”가 된다.
- 렌더러는 파일 시스템을 직접 건드리지 않고 IPC로 요청만 하게 된다. (invoke/handle 패턴)
- contextIsolation: true, nodeIntegration: false 기반으로 preload 브리지를 쓰는 구조가 된다. (Electron Docs: Security)
2) preload.js: 필요한 API만 contextBridge로 노출
// preload.js
const { contextBridge, ipcRenderer } = require("electron")
const path = require("node:path")
contextBridge.exposeInMainWorld("dirApi", {
demoRoot: () => ipcRenderer.invoke("demo-root"),
getDir: (dirPath) => ipcRenderer.invoke("get-dir", dirPath),
join: (...parts) => path.join(...parts),
})기대 결과/무엇이 달라졌는지
- 렌더러는 window.dirApi.getDir() 같은 “안전한 표면”만 쓴다. (Electron Docs: contextBridge)
- ipcRenderer.invoke로 요청하면 Promise가 돌아오므로 await가 가능해진다. (Electron Docs: ipcRenderer)
- join을 노출한 이유는, 렌더러에서 경로 조립을 window.dirApi 안에서만 처리해 “직접적인 Node API 접근”을 늘리지 않기 위함이다.
3) index.html: 최소 UI
<!-- index.html -->
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>Async Directory Demo</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; padding: 16px; }
input, button { padding: 8px; font-size: 14px; }
pre { background: #f6f6f6; padding: 12px; border-radius: 8px; overflow: auto; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
</style>
</head>
<body>
<h1>폴더 탐색: async/await로 평평하게</h1>
<div class="row">
<label>Targets:</label>
<input id="targets" size="40" value="Etc/Download/Download/Download"/>
<button id="run">Find</button>
</div>
<p id="base"></p>
<pre id="out">{}</pre>
<script src="./renderer.js"></script>
</body>
</html>기대 결과/무엇이 달라졌는지
- 입력값(예: Etc/Download/...)을 바꿔서 “얼마나 깊게 내려갈지”를 UI에서 바로 테스트할 수 있다.
4) renderer.js: async/await + 루프로 깊은 순차 작업 처리
// renderer.js
const $targets = document.querySelector("#targets")
const $run = document.querySelector("#run")
const $out = document.querySelector("#out")
const $base = document.querySelector("#base")
function toTargets(str) {
return str.split("/").map((s) => s.trim()).filter(Boolean)
}
async function findNestedPath(basePath, targets) {
let current = basePath
for (const target of targets) {
const folders = await window.dirApi.getDir(current)
const found = folders.find((name) => name === target)
if (!found) return null
current = window.dirApi.join(current, found)
}
return current
}
async function run() {
const basePath = await window.dirApi.demoRoot()
$base.textContent = `demoRoot:${basePath}`
const targets = toTargets($targets.value)
const result = await findNestedPath(basePath, targets)
$out.textContent = JSON.stringify({ targets, result }, null, 2)
}
$run.addEventListener("click", () => {
run().catch((err) => {
$out.textContent = JSON.stringify(
{ error: String(err?.message ?? err) },
null,
2
)
})
})
// 첫 로드 시 한 번 실행
run()기대 결과/무엇이 달라졌는지
- 콜백 중첩 없이, “현재 경로 → 폴더 목록 조회 → 다음 경로로 이동” 흐름이 위에서 아래로 읽힌다.
- 깊이가 늘어나도 코드는 그대로고, 입력(targets)만 바뀐다.
대안/비교
1) util.promisify로 래핑하기
콜백 함수가 err-first 규칙을 잘 지키고 있다면 래핑 코드가 더 짧아진다. (Node.js Docs: util.promisify)
단, callback(err, result) 형태(에러-퍼스트 콜백)일 때만 안전하게 적용할 수 있다.
const { promisify } = require("node:util")
const getDir = promisify(getDirLegacy)기대 결과/무엇이 달라졌는지
- new Promise(...)를 직접 쓰지 않고도 콜백 → Promise 변환이 간결해진다.
2) 처음부터 Promise API 쓰기 (fs.promises)
레거시 콜백이 아니라면, 애초에 Promise 기반 API를 쓰는 게 가장 깔끔하다. (Node.js Docs: fs/promises)
const fs = require("node:fs/promises")
const entries = await fs.readdir(dirPath, { withFileTypes: true })기대 결과/무엇이 달라졌는지
- “래핑” 자체가 필요 없어지고, await 흐름으로 바로 들어간다.
검증 방법(체크리스트)
contextBridge로 노출된 API만 쓰는가? (Electron Docs: contextBridge)ipcRenderer.invoke ↔︎ ipcMain.handle로 “결과를 기다리는” IPC가 구성되어 있는가? (Electron Docs: IPC)null)와 “오류”가 구분되어 처리되는가?nodeIntegration: false, contextIsolation: true 기반으로 preload 브리지를 사용하고 있는가? (Electron Docs: Security)흔한 실수/FAQ
Q1. async/await면 비동기가 동기로 바뀌나요?
아니다. 실행은 비동기지만, 코드가 동기처럼 “읽히는 형태”로 정리되는 것이다. 렌더러에서 invoke()가 Promise를 반환하기 때문에 await가 가능해진다. (Electron Docs: ipcRenderer)
Q2. 왜 렌더러에서 바로 fs를 쓰지 않나요?
렌더러는 UI 컨텍스트라서 외부 입력(XSS 등)과 맞닿아 있다. 공식 보안 가이드는 Node.js 통합을 켜는 것을 피하고, preload에서 필요한 기능만 노출하는 방식을 권장한다. (Electron Docs: Security)
Q3. 깊게 내려가며 찾는 로직이 길어지면 어떻게 하나요?
“단계 수가 늘어난다”는 건 반복 패턴이다. targets만 늘리고, 로직은 루프로 유지하는 게 포인트다. 필요하면 “최대 깊이 제한”이나 “검색 실패 시 fallback” 같은 정책을 함수 시그니처로 끌어올리면 된다.
결론
Electron에서 비동기 흐름이 복잡해질 때는, 콜백 중첩을 UI까지 끌고 가지 않는 게 핵심이다. 메인에 레거시 콜백을 두더라도 Promise로 감싸고, 렌더러에서는 async/await + 루프로 순차 작업을 평평하게 만들면 코드가 안정적으로 유지된다.
참고(공식 문서 링크)
- Electron Docs
- Electron Docs: Security
- Electron Docs: Context Isolation
- Electron Docs: contextBridge
- Electron Docs: IPC
- Electron Docs: ipcRenderer
- Electron Docs: ipcMain
- Electron Docs: app.getPath
- MDN Web Docs: Promise
- MDN Web Docs: async function
- MDN Web Docs: Array.prototype.find
- Node.js Docs: util.promisify
- Node.js Docs: fs/promises
- web.dev