← 블로그 목록

Developer Knowledge

개발자가 알아야 할 지식: 구조적 동시성, 고아 작업을 남기지 않는 비동기 설계

비동기 작업을 시작만 하고 수명·취소·오류 전파를 놓치는 문제를 구조적 동시성 관점에서 설명하고, 실무에서 더 안전한 동시성 코드를 설계하는 방법을 정리한다.

OpenJDK
  • 개발자가 알아야 할 지식
  • Software Engineering
  • Concurrency
  • Reliability
  • Maintainability

왜 개발자가 알아야 하나

동시성 코드는 대개 “빨리 끝내기 위해” 시작된다. 사용자 프로필 화면을 만들 때 회원 정보, 주문 요약, 추천 목록을 동시에 가져오면 전체 응답 시간이 줄어든다. 파일 업로드 후 바이러스 검사와 썸네일 생성을 병렬로 돌리면 처리량이 좋아진다. 문제는 작업을 동시에 시작하는 순간, 그 작업들이 언제 끝나야 하는지, 누가 취소해야 하는지, 하나가 실패했을 때 나머지는 어떻게 해야 하는지가 함께 따라온다는 점이다. 이 질문을 코드 구조가 강제하지 않으면 동시성은 금방 “어딘가에서 아직 돌고 있는 일”이 된다.

실무에서 자주 보이는 버그는 고아 작업(orphan task)이다. HTTP 요청 처리는 이미 실패했는데 백그라운드로 시작한 작업은 계속 외부 API를 호출한다. 사용자가 화면을 떠났는데 모바일 앱의 코루틴은 여전히 네트워크 응답을 기다린다. 부모 작업은 예외를 던지고 종료됐지만 자식 작업은 로그도 없이 살아 있다가 나중에 공유 상태를 바꾼다. 이런 버그는 재현이 어렵고, 장애가 난 뒤에도 원인 추적이 힘들다. 스레드 덤프나 트레이스에서 “이 작업이 어떤 요청 때문에 시작됐는가?”를 알 수 없기 때문이다.

구조적 동시성(structured concurrency)은 이 문제를 코드 모양으로 다루는 사고방식이다. 핵심은 동시에 실행되는 하위 작업들을 하나의 작업 단위로 묶고, 그 묶음의 수명을 부모 코드 블록 안에 가두는 것이다. 함수가 반환될 때 지역 변수가 사라지듯, 동시 작업도 해당 스코프를 벗어나기 전에 성공, 실패, 취소 중 하나로 정리되어야 한다. OpenJDK의 JEP 453도 구조적 동시성을 “관련된 여러 작업을 하나의 작업 단위로 취급해 오류 처리와 취소를 단순화하고, 신뢰성과 관찰 가능성을 높이는 접근”으로 설명한다.

개발자가 이 개념을 알아야 하는 이유는 특정 언어나 라이브러리의 문법 때문이 아니다. Java의 StructuredTaskScope, Kotlin 코루틴의 coroutineScope, Python Trio의 nursery, Swift의 task group처럼 이름은 달라도 문제의식은 같다. 비동기 작업을 “발사하고 잊는” 방식으로 늘리면 로컬에서는 빨라 보이지만 운영 환경에서는 취소 지연, 리소스 누수, 꼬인 예외, 불완전한 로그라는 비용을 낸다. 구조적 동시성은 성능 최적화보다 먼저 수명과 책임을 명확히 하자는 설계 원칙이다.

핵심 개념

구조적 동시성을 이해하려면 먼저 비구조적 동시성을 떠올리면 된다. 어떤 함수 안에서 스레드, 태스크, 코루틴을 시작한 뒤 핸들을 저장하지 않거나, 저장하더라도 모든 경로에서 join·취소·예외 처리를 보장하지 않는 방식이다. 코드는 짧다. 하지만 시작한 작업의 생명주기가 호출한 함수보다 길어질 수 있다. 함수 호출이라는 경계가 더 이상 “이 안에서 일어난 일은 이 함수가 책임진다”는 의미를 갖지 못한다.

구조적 동시성은 이 경계를 되살린다. 부모 작업은 명시적인 스코프 안에서 자식 작업을 시작한다. 그 스코프는 종료되기 전에 자식 작업들이 모두 끝났는지 확인한다. 하나가 실패하면 정책에 따라 나머지를 취소하고 실패를 부모에게 전파한다. 부모가 취소되면 자식도 함께 취소된다. 그래서 호출자는 함수가 반환된 뒤에 “그 안에서 시작된 일이 아직 남아 있을까?”를 걱정하지 않아도 된다.

이 원칙은 구조적 프로그래밍이 goto를 줄이고 if, for, 함수 호출 같은 블록 구조로 제어 흐름을 이해 가능하게 만든 것과 닮았다. Nathaniel J. Smith는 Trio의 nursery를 설명하며 비동기 작업 시작도 임의 점프로 흩어지게 두지 말고, 들여쓰기된 블록 안에서 시작하고 블록 끝에서 정리되도록 만들어야 한다고 주장했다. 비유가 완벽하진 않지만 실무 감각에는 잘 맞는다. 블록을 벗어나면 그 블록이 만든 동시 작업도 정리되어 있어야 코드를 읽는 사람이 믿고 추론할 수 있다.

중요한 구성요소는 세 가지다. 첫째, 부모-자식 관계다. 하위 작업은 아무 데서나 떠도는 전역 작업이 아니라 특정 요청, 배치 단계, UI 화면, 명령 실행 같은 상위 작업에 속한다. 둘째, 완료 대기다. 부모 스코프는 자식들이 끝나기 전까지 완료되지 않는다. 셋째, 취소와 오류 전파다. 한 자식의 실패가 전체 결과를 실패로 만들어야 하는지, 일부 실패를 허용하고 나머지 결과만 쓸 것인지를 스코프 정책으로 표현한다.

예를 들어 서버가 /dashboard 요청을 처리하면서 findUser, fetchOrders, loadRecommendations를 동시에 호출한다고 하자. 세 결과가 모두 필요하다면 하나가 실패했을 때 나머지 두 호출을 계속 붙잡고 있을 이유가 적다. 구조적 동시성에서는 세 호출을 하나의 스코프에 넣고, 실패 시 남은 호출을 취소한 뒤 부모 요청을 실패로 끝낸다. 반대로 추천 목록은 선택 사항이라면 그 작업만 별도 정책으로 감싸 기본값을 반환하게 만들 수 있다. 차이는 “동시에 실행한다”가 아니라 “동시에 실행되는 작업들의 관계를 선언한다”는 데 있다.

관찰 가능성도 좋아진다. 동시 작업이 부모 스코프에 묶이면 로그, 트레이스, 취소 신호, 타임아웃 예산을 함께 전달하기 쉽다. 어떤 하위 작업이 느린지, 어떤 요청의 자식인지, 부모가 취소됐는데 왜 아직 실행 중인지 같은 질문에 답하기 쉬워진다. JEP 453이 구조적 동시성의 목표로 신뢰성과 관찰 가능성을 함께 언급하는 이유도 여기에 있다. 운영에서 동시성 문제는 성능 문제인 동시에 설명 가능성의 문제다.

작은 예시 또는 체크리스트

다음은 구조적 동시성으로 사고하는 대시보드 API의 의사 코드다. 문법은 특정 언어에 고정하지 않고, 흐름만 보여준다.

handleDashboard(request):
  with task_scope(timeout = request.remaining_time) as scope:
    userTask = scope.fork(() => findUser(request.userId))
    ordersTask = scope.fork(() => fetchRecentOrders(request.userId))
    alertsTask = scope.fork(() => fetchSecurityAlerts(request.userId))

    scope.join_all_or_cancel_on_failure()

    return Dashboard(
      user = userTask.result(),
      orders = ordersTask.result(),
      alerts = alertsTask.result()
    )

이 코드에서 중요한 점은 fork 자체가 아니다. with task_scope 블록이 작업의 울타리라는 점이다. 블록이 정상 종료되려면 자식 작업이 모두 완료되어야 한다. 요청 타임아웃이 오면 스코프 전체가 취소된다. fetchRecentOrders가 실패해 대시보드를 만들 수 없다면 findUserfetchSecurityAlerts도 더 이상 의미 없는 일을 계속하지 않는다. 실패는 부모 요청의 실패로 전파된다.

실무 체크리스트로 바꾸면 다음 질문을 코드 리뷰에서 던져볼 수 있다.

  • 이 함수가 시작한 비동기 작업은 함수가 끝나기 전에 모두 완료되거나 취소되는가?
  • 부모 요청, 화면, 배치 단계가 취소될 때 하위 작업도 같은 취소 신호를 받는가?
  • 하위 작업 하나가 실패했을 때 나머지를 계속 실행해야 하는지, 취소해야 하는지 정책이 명확한가?
  • 타임아웃 예산을 각 하위 호출이 따로 만들고 있지는 않은가, 아니면 부모의 남은 시간을 공유하는가?
  • 로그와 트레이스에서 하위 작업이 어떤 부모 작업에 속했는지 확인할 수 있는가?
  • 정말 장기 실행 백그라운드 작업이라면 요청 스코프가 아니라 별도의 큐, 워커, 소유권, 재시도 정책을 갖고 있는가?

실무에서 자주 생기는 오해

  • “백그라운드로 돌리면 사용자 응답이 빨라지니 좋은 것 아닌가?” 빠른 응답 자체는 좋지만, 책임 없는 백그라운드 작업은 위험하다. 요청의 성공 여부와 무관하게 반드시 처리되어야 하는 일이라면 메시지 큐에 내구적으로 기록하고 워커가 처리해야 한다. 그냥 태스크를 띄워두는 것은 배포, 프로세스 종료, 장애, 중복 실행에 약하다.

  • “구조적 동시성은 모든 작업을 부모가 기다리라는 뜻인가?” 아니다. 핵심은 기다림 자체가 아니라 소유권이다. 요청 안에서 결과가 필요한 하위 작업은 요청 스코프가 책임진다. 장기 실행 작업은 애초에 다른 소유자, 예를 들어 잡 큐나 스케줄러의 스코프로 넘겨야 한다. 어느 쪽이든 주인 없는 작업을 만들지 않는 것이 목표다.

  • “하나가 실패하면 항상 모두 취소해야 하는가?” 그렇지 않다. 검색 결과 페이지에서 광고 추천이 실패해도 핵심 검색 결과는 보여줄 수 있다. 다만 그 선택을 코드에 드러내야 한다. 필수 작업은 fail-fast로 묶고, 선택 작업은 기본값·부분 실패·격리된 타임아웃을 적용하는 식으로 관계를 명확히 해야 한다.

  • “언어가 지원하지 않으면 못 쓰는 개념인가?” 언어와 런타임 지원이 있으면 훨씬 안전하지만, 사고방식은 지금도 적용할 수 있다. Promise.all을 쓸 때 abort signal을 연결하고, Go에서 context.Contexterrgroup으로 부모 취소를 전파하고, Node.js에서 요청 종료 시 하위 fetch를 취소하는 것도 같은 방향이다. 다만 라이브러리가 강제하지 않는 언어에서는 코드 리뷰와 규칙이 더 중요하다.

  • “동시성을 구조화하면 느려지지 않나?” 구조적 동시성은 병렬 실행을 막지 않는다. 오히려 불필요하게 계속 도는 작업을 빨리 취소해 리소스를 아낄 수 있다. 성능 문제는 보통 스코프 때문이 아니라 너무 많은 하위 작업을 한꺼번에 만들거나, 외부 의존성의 용량을 고려하지 않거나, 취소 불가능한 블로킹 호출을 섞을 때 생긴다.

  • “취소는 그냥 예외 처리와 같은 것 아닌가?” 취소는 실패와 비슷하게 보일 수 있지만 의미가 다르다. 실패는 작업이 의도한 결과를 만들지 못한 것이고, 취소는 더 이상 결과가 필요 없거나 시간 예산이 끝난 것이다. 둘을 구분해야 불필요한 에러 알림을 줄이고, 사용자 취소와 시스템 장애를 다르게 관찰할 수 있다.

오늘 바로 적용해보기

가장 먼저 코드베이스에서 fire-and-forget 패턴을 찾아보자. go, launch, createTask, executor.submit, setTimeout, 이벤트 콜백 등록처럼 작업을 시작하는 지점마다 “누가 기다리는가?”를 물으면 된다. 답이 “아무도 없다”라면 버그 후보일 가능성이 높다. 정말 잊어도 되는 작업인지, 실패해도 되는지, 프로세스가 죽어도 되는지 확인해야 한다.

두 번째로 요청 단위 타임아웃을 하위 호출에 전달하자. 각 함수가 독립적으로 3초 타임아웃을 새로 만들면, 부모 요청의 전체 예산은 쉽게 깨진다. 부모의 남은 시간, 취소 토큰, trace context를 하위 작업에 넘기면 장애 상황에서 회복이 빨라진다. 사용자가 연결을 끊었는데도 데이터베이스와 외부 API를 계속 두드리는 일을 줄일 수 있다.

세 번째로 실패 정책을 이름 붙여라. “모두 필요”, “일부 실패 허용”, “가장 빠른 응답 사용”, “첫 성공 사용”, “선택 기능은 200ms 안에만 시도”처럼 정책을 코드와 함수명에 드러내면 리뷰가 쉬워진다. 단순히 병렬화를 위해 작업을 흩뿌리는 코드보다, 관계를 표현한 코드가 유지보수에 강하다.

네 번째로 장기 실행 작업을 분리하자. 이메일 발송, 리포트 생성, 이미지 처리처럼 요청보다 오래 살아야 하는 일은 구조적 동시성 스코프 안에 억지로 붙잡아두기보다 큐와 워커로 넘기는 편이 낫다. 이때도 구조는 필요하다. 워커의 한 잡 안에서 파생되는 하위 작업들은 잡 스코프에 묶고, 잡 실패 시 어떤 하위 작업을 취소할지 정해야 한다.

마지막으로 관찰 가능성을 점검하자. 스레드 덤프, 태스크 목록, 트레이스, 로그에서 부모-자식 관계가 보이는지 확인해보면 좋다. 운영 중 “이 느린 작업은 어디서 시작됐지?”라는 질문에 답할 수 없다면, 동시성 구조가 아직 충분히 드러나지 않은 것이다.

더 알아보기

오늘의 takeaway

동시 작업을 시작할 때는 “어떻게 띄울까?”보다 먼저 “누가 끝까지 책임질까?”를 코드 구조로 답하자.