PHpullh
개발자 면접 준비/언어 기초 인터뷰

언어 기초 인터뷰

동시성·비동기 면접: 스레드부터 async/await까지

동시성과 병렬성, 스레드, 이벤트 루프, async/await, 취소와 오류 전파를 설명하는 개발자 인터뷰 가이드입니다.

비동기 질문의 진짜 과녁

비동기를 묻는 질문은 문법을 아는지 확인하려는 것이 아닙니다. async와 await는 며칠이면 씁니다. 면접에서 보는 것은 “기다린다”는 말을 정확히 어떤 의미로 쓰고 있는가입니다. 스레드가 멈춰 있는 것인지, 실행 흐름만 양보하고 스레드는 다른 일을 하는 것인지, 재개는 누가 시켜 주는 것인지를 구분하지 못한 채 답하면 곧바로 드러납니다.

그리고 이 영역은 실무 사고와 직결됩니다. 순서를 잘못 이해해서 아직 채워지지 않은 값을 읽거나, 취소를 설계하지 않아 사용자가 떠난 뒤에도 자원을 잡고 있거나, 동시 요청 수를 제한하지 않아 외부 서비스를 밀어붙이는 사고는 전부 이 개념의 구멍에서 나옵니다. 언어별 문법 차이는 언어별 비동기 비교에서 확인할 수 있습니다.

먼저 세 층을 분리합니다

혼란의 대부분은 세 가지를 한 단어로 부르기 때문에 생깁니다. 동시성은 여러 작업이 겹친 시간 안에서 진행되는 구조를 말합니다. 병렬성은 물리적으로 같은 순간에 실행되는 것을 말하며 코어가 여러 개여야 가능합니다. 비동기는 호출 결과를 지금 받지 않고 나중에 받는 호출 방식입니다. 단일 스레드에서도 비동기와 동시성은 성립하고, 병렬성만 성립하지 않습니다.

이 구분이 서면 “async를 쓰면 빨라지나요”라는 질문에 조건을 붙여 답할 수 있습니다. 기다림이 대부분 I/O라면 그 시간에 다른 작업을 진행시킬 수 있으니 처리량이 늡니다. 반대로 CPU를 계속 쓰는 계산이라면 비동기로 감싸 봐야 같은 코어를 나눠 쓸 뿐이라 총 시간은 줄지 않고 오히려 전환 비용만 붙습니다.

연습 문제: 이 코드의 출력 순서를 설명하세요

자주 나오는 형태입니다. 코드를 주고 출력 순서를 물은 뒤, 왜 그런지 설명하라고 합니다.

JAVASCRIPT · 실행 순서

console.log('1 sync start');

setTimeout(() => console.log('2 timeout'), 0);

Promise.resolve().then(() => console.log('3 microtask'));

(async () => {
  console.log('4 async body runs sync');
  await null;                       // 여기서 함수가 중단되고 나머지는 마이크로태스크로
  console.log('5 after await');
})();

console.log('6 sync end');

// 출력:
// 1 sync start
// 4 async body runs sync
// 6 sync end
// 3 microtask
// 5 after await
// 2 timeout

약한 답

“async 함수는 비동기니까 나중에 실행되고, setTimeout은 0밀리초라 바로 실행됩니다. 프로미스가 먼저고 타임아웃이 나중입니다. 이벤트 루프 때문입니다.”

강한 답

“먼저 이 코드가 단일 스레드에서 돈다는 전제를 확인하겠습니다. 그러면 순서는 어떤 큐에 들어가느냐로 결정됩니다.

동기 코드가 먼저 전부 실행됩니다. 여기서 중요한 점은 async 함수의 본문이 호출 즉시 동기적으로 시작한다는 것입니다. 그래서 4번이 6번보다 먼저 찍힙니다. await를 만나는 순간 함수는 중단되고 나머지 부분이 마이크로태스크로 예약되며 제어권이 호출자에게 돌아옵니다. 그래서 6번이 5번보다 먼저입니다.

동기 실행이 끝나면 마이크로태스크 큐를 비웁니다. 프로미스 콜백인 3번이 먼저 예약되었고 await 이후인 5번이 뒤에 예약되었으므로 그 순서로 나옵니다. 마이크로태스크 큐가 완전히 빈 다음에야 매크로태스크인 타이머가 실행되어 2번이 마지막입니다.

제가 세운 전제는 두 가지입니다. 마이크로태스크가 매크로태스크보다 우선한다는 것, 그리고 마이크로태스크 큐를 비우는 도중 새 마이크로태스크가 추가되면 그것까지 처리한 뒤에 타이머로 넘어간다는 것입니다. 후자 때문에 마이크로태스크가 스스로를 계속 예약하면 타이머가 영원히 밀리는 기아 상태가 만들어질 수 있습니다.”

차이는 어디에서 왔는가

강한 답이 나은 이유는 용어를 더 써서가 아닙니다. 첫째, 실행 모델의 전제를 먼저 고정했습니다. 단일 스레드라는 전제가 없으면 순서 논의 자체가 성립하지 않습니다. 둘째, 각 출력의 위치를 개별적으로 정당화하지 않고 하나의 규칙에서 전부 도출했습니다. 셋째, 그 규칙의 위험한 귀결인 기아 상태까지 스스로 언급했습니다. 약한 답은 결론이 우연히 맞았을 뿐, async 본문이 동기로 시작한다는 사실을 모르고 있고 그래서 4번과 6번의 순서를 설명하지 못합니다. 면접관은 결론이 아니라 이 지점을 봅니다.

TIP

“이벤트 루프 때문입니다”는 설명이 아니라 이름표입니다. 큐가 몇 종류이고, 어느 큐가 언제 비워지며, 지금 이 콜백은 어느 큐에 들어가는지를 말해야 설명이 됩니다. 세 문장으로 정리해 두고 연습하시기 바랍니다.

런타임이 다르면 무엇이 달라지는가

위 설명은 단일 스레드 이벤트 루프 모델입니다. 다른 모델도 같은 어휘를 쓰지만 동작이 다릅니다.

스레드 풀 기반 언어에서는 비동기 작업이 실제로 다른 스레드에서 실행될 수 있습니다. 그래서 콜백 안에서 공유 상태를 만지면 경쟁 상태가 생기고, 단일 스레드 모델에서는 필요 없던 잠금이 필요해집니다. 반대로 코루틴 기반 모델에서는 중단점이 명시적으로 표시되므로, 중단점이 없는 구간은 원자적으로 실행된다고 가정할 수 있습니다. 경량 스레드 모델에서는 채널로 값을 주고받는 방식이 기본이라 공유 상태 자체를 줄이는 방향으로 설계가 유도됩니다.

면접에서 이 차이를 다룰 때는 “제 답은 단일 스레드 이벤트 루프 기준입니다. 스레드 풀 모델이면 여기서 잠금이 추가로 필요합니다”처럼 모델을 명시하는 것이 안전합니다. 언어별 구체적인 사용법은 자바스크립트 비동기, 파이썬 비동기, 자바 비동기, 고루틴 가이드에서 볼 수 있습니다.

순차 대기와 동시 대기

실무에서 가장 자주 나오는 실수이자 면접에서도 자주 확인하는 지점입니다. 독립적인 세 요청을 await로 한 줄씩 쓰면 총 시간은 세 요청의 합이 됩니다. 서로 의존하지 않는다면 먼저 전부 시작해 놓고 한꺼번에 기다려야 가장 느린 하나의 시간으로 끝납니다.

JAVASCRIPT · 순차 vs 동시, 그리고 동시 실행 수 제한

// 나쁨: 서로 독립인데 순차로 기다림 → a + b + c
const a = await fetchUser(id);
const b = await fetchOrders(id);
const c = await fetchCoupons(id);

// 좋음: 먼저 시작하고 한꺼번에 대기 → max(a, b, c)
const [user, orders, coupons] = await Promise.all([
  fetchUser(id), fetchOrders(id), fetchCoupons(id),
]);

// 주의: 하나라도 실패하면 전체가 실패한다.
// 부분 실패를 허용하려면 결과와 오류를 함께 받는 형태를 쓴다.
const results = await Promise.allSettled([...]);

// 항목이 수천 개라면 전부 동시에 던지면 안 된다. 동시 실행 수를 제한한다.
async function mapWithLimit(items, limit, worker) {
  const out = new Array(items.length);
  let cursor = 0;
  const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
    while (cursor < items.length) {
      const i = cursor++;              // 단일 스레드라 이 증가는 원자적
      out[i] = await worker(items[i]);
    }
  });
  await Promise.all(runners);
  return out;
}

마지막 함수에서 cursor++가 안전한 이유를 설명할 수 있어야 합니다. 단일 스레드 이벤트 루프에서는 await 사이의 동기 구간이 중단되지 않으므로 인덱스를 집어 가는 동작이 겹치지 않습니다. 같은 코드를 진짜 스레드가 도는 환경에 옮기면 이 가정이 깨지고 원자적 증가가 필요해집니다. 이런 “이 코드가 안전한 이유는 언어의 실행 모델 때문이다”라는 설명이 나오면 이해 수준이 확실히 드러납니다.

타임아웃, 취소, 재시도

비동기 코드를 설계 관점에서 물으면 거의 항상 이 셋으로 이어집니다. 외부 호출에는 반드시 마감 시간이 있어야 합니다. 마감이 없으면 상대가 느려질 때 이쪽의 연결과 메모리가 무한히 쌓이고, 결국 상대의 장애가 이쪽 장애로 번집니다.

취소는 마감의 짝입니다. 마감이 지났는데 작업이 계속 돌면 자원만 낭비합니다. 취소 신호가 하위 호출까지 전파되어야 하고, 취소된 뒤 정리 코드가 반드시 실행되어야 하며, 이미 부분적으로 쓰기가 일어났다면 그 상태를 어떻게 할지 정해야 합니다. “취소하면 됩니다”가 아니라 “취소 신호를 아래로 넘기고, 정리 블록에서 연결을 반납하고, 부분 쓰기는 이 작업이 멱등이라 재시도로 덮입니다”까지 말해야 합니다.

재시도는 가장 조심해야 하는 부분입니다. 실패한 요청을 모든 클라이언트가 같은 간격으로 재시도하면 회복 중인 서버를 다시 무너뜨립니다. 지수적으로 간격을 늘리고 무작위 지연을 섞어야 합니다. 그리고 재시도해도 되는 요청인지 먼저 판단해야 합니다. 멱등하지 않은 쓰기를 재시도하면 중복이 생깁니다.

WARNING

기다리지 않는 비동기 호출을 만들어 놓고 방치하면 그 안에서 난 예외는 아무 데도 보고되지 않습니다. 로그에 아무것도 안 남고 결과만 조용히 틀립니다. 시작한 작업은 반드시 어딘가에서 결과를 회수하거나, 회수하지 않기로 명시적으로 결정하고 그 안에서 예외를 직접 처리해야 합니다.

면접관이 이어서 묻는 것들

스레드를 늘리는 것과 비동기로 바꾸는 것 중 무엇이 낫나요?

병목이 무엇인지에 달렸다고 답하고 근거를 붙입니다. 대기가 I/O라면 비동기가 스레드당 메모리와 전환 비용을 아껴 줍니다. 대기가 CPU 계산이라면 비동기는 도움이 안 되고 실제 병렬 실행이 필요합니다. “측정해서 대기 시간의 대부분이 어디인지부터 보겠습니다”가 앞에 붙으면 더 좋습니다.

await 안에서 예외가 나면 어디로 가나요?

기다리는 쪽으로 전파되어 일반적인 예외 처리로 잡힙니다. 다만 여러 작업을 동시에 기다릴 때 첫 실패가 전체를 실패시키는 함수와, 각 결과를 개별로 돌려주는 함수는 동작이 다릅니다. 어느 쪽을 쓸지는 부분 실패를 허용할지에 대한 제품 결정입니다.

데드락은 비동기에서도 생기나요?

생깁니다. 형태만 다릅니다. 서로의 완료를 기다리는 두 작업, 크기가 제한된 풀에서 작업이 같은 풀의 다른 작업을 기다리는 경우, 재진입 불가능한 잠금을 중단점 너머로 들고 가는 경우가 대표적입니다. 잠금을 잡은 채 await하지 않는다는 규칙 하나만 지켜도 상당수가 예방됩니다.

비동기 코드는 어떻게 테스트하나요?

실제 시간을 기다리게 만들면 느리고 불안정해집니다. 시간을 주입 가능한 값으로 다루고 가상 시계를 쓰거나, 순서를 검증해야 한다면 완료 시점을 테스트가 직접 제어할 수 있는 형태로 만듭니다. 무작위로 실패하는 테스트가 있으면 그건 테스트 문제가 아니라 대개 코드의 순서 가정이 잘못된 것입니다.

동시 실행 수는 어떻게 정하나요?

임의로 정하지 않습니다. 상대 서비스가 허용하는 한도, 이쪽 연결 풀 크기, 그리고 지연 시간이 급격히 나빠지기 시작하는 지점을 측정해서 정합니다. 숫자를 상수로 박아 두더라도 그 숫자가 어디서 나왔는지 주석에 남기는 것이 맞습니다.

이 영역에서 흔한 오해

첫째, “비동기는 빠르다”는 오해입니다. 비동기는 대기 시간을 다른 일에 쓰게 해 줄 뿐 계산을 빠르게 하지 않습니다. CPU 바운드 작업을 비동기로 감싸면 오히려 느려질 수 있습니다.

둘째, async 함수를 호출하면 나중에 실행된다는 오해입니다. 본문은 즉시 시작하고 첫 중단점에서 멈춥니다. 이 오해가 있으면 위 출력 순서 문제를 절대 못 맞힙니다.

셋째, 단일 스레드니까 동시성 버그가 없다는 오해입니다. 중단점 사이에 다른 작업이 끼어들 수 있으므로 “읽고 검사하고 쓰는” 구간이 중단점을 가로지르면 그대로 경쟁 상태입니다. 잠금은 필요 없어도 순서에 대한 사고는 필요합니다.

넷째, 콜백을 async로 선언해 놓고 그것을 기다리지 않는 API에 넘기는 실수입니다. 배열 순회 함수 중 다수는 반환된 프로미스를 무시하므로, 순회가 끝난 뒤에도 안쪽 작업은 끝나 있지 않습니다. 이 조합이 실무에서 만드는 버그가 상당히 많습니다.

답하기 전 점검

  • 동시성·병렬성·비동기를 각각 한 문장으로 구분해 말할 수 있는가
  • 지금 말하는 실행 모델이 무엇인지 먼저 밝혔는가
  • 병목이 CPU인지 I/O인지 구분하고 답을 시작했는가
  • 독립 작업을 동시에 시작하고 한꺼번에 기다리는 형태를 알고 있는가
  • 동시 실행 수 제한이 왜 필요한지 설명할 수 있는가
  • 타임아웃·취소·정리 코드를 한 묶음으로 말했는가
  • 재시도를 말할 때 멱등성과 지연 분산을 함께 언급했는가
  • 방치된 비동기 작업의 예외가 사라진다는 점을 알고 있는가

연습 방법

출력 순서 문제를 직접 만들어 보는 것이 가장 빠릅니다. 위 코드에 await를 하나 더 넣거나, 프로미스 체인을 두 단계로 늘리거나, 타이머 안에서 다시 프로미스를 만들어 놓고 출력 순서를 먼저 예측한 뒤 실행해 확인하십시오. 예측이 틀린 지점이 정확히 자신의 모델에 뚫린 구멍입니다.

그다음에는 설계 쪽으로 넘어갑니다. 외부 API 세 곳을 호출해 하나의 응답으로 합치는 함수를 쓴다고 하고, 타임아웃, 부분 실패 허용 여부, 동시 실행 제한, 재시도 정책을 각각 어떻게 정할지 말로 설명해 보십시오. 이어서 볼 주제는 메모리와 참조이고, 언어별 실습은 자바스크립트 학습 라이브러리에서 이어 갈 수 있습니다.

관련 언어 학습으로 복습하기