레슨 5: 에러 처리와 재시도 전략
학습 목표:
- 일시적 에러와 영구적 에러 구분하기
- 재시도 전략과 백오프 알고리즘 익히기
- 보상 동작과 롤백 메커니즘 설계법 배우기
전제: 레슨 4: 상태 관리와 컨텍스트 전달 | 다음: 레슨 6 >>
워크플로에서 에러는 일상이다
워크플로가 열 번 완벽하게 돌았습니다. 열한 번째, 8단계에서 API가 503을 반환합니다. 워크플로가 죽습니다.
그래서 try-catch를 넣고, 에러를 잡고, 로그를 남기고, 계속 진행하게 만듭니다. 열두 번째 실행에서는 데이터베이스 연결이 타임아웃됩니다. 워크플로는 계속 돌지만 쓰기는 실패했고, 이제 데이터가 어긋나 있습니다.
에러 처리는 "try-catch를 넣는다"만큼 단순하지 않습니다.
워크플로에서 에러 처리는 세 가지 질문에 답해야 합니다.1
- 이 에러는 일시적인가, 영구적인가?(네트워크 흔들림 vs 권한 없음)
- 재시도해야 하나, 건너뛰어야 하나, 중단해야 하나?(재시도가 해결할 수도 있고, 재시도가 상황을 악화시킬 수도 있다)
- 중단한다면 이미 끝난 단계는 어떻게 정리하나?(데이터베이스 롤백 vs 취소 통지 발송)
실패한 단계가 선택적인 것이라면(예를 들어 알림 발송), 건너뛰고 계속 가면 됩니다. 치명적이지 않은 실패가 전체 실행을 무너뜨리지 않게 하는 것을 우아한 성능 저하(graceful degradation)라고 부릅니다. 하지만 실패한 단계가 핵심적인 것이라면, 건너뛸 경우 상태가 어긋난 채로 남으므로 대신 중단해야 합니다.
답이 없으면 워크플로는 너무 약하거나(작은 에러 하나에 통째로 쓰러진다) 너무 위험합니다(에러를 무시하고 계속 돌면서 어긋난 상태를 남긴다).2
에러 분류: 일시적 vs 영구적
일시적 에러는 한때의 문제이며, 재시도하면 성공할 수 있습니다.3
흔한 일시적 에러:
- 네트워크 타임아웃
- 서비스 일시 중단(503 Service Unavailable)
- 레이트 리밋(429 Too Many Requests)
- 데이터베이스 커넥션 풀 고갈
- 일시적인 락 충돌
공통점: 보통 리소스 경합, 네트워크 변동, 일시적 과부하에서 오며, 잠시 기다렸다가 재시도하면 대체로 통합니다.
영구적 에러는 재시도해도 성공하지 않습니다. 코드나 설정을 고쳐야 합니다.2
흔한 영구적 에러:
- 권한 없음(401 Unauthorized, 403 Forbidden)
- 리소스를 찾을 수 없음(404 Not Found)
- 잘못된 형식의 입력(400 Bad Request)
- 비즈니스 로직 에러(잔액 부족, 재고 없음)
- 코드 버그(널 포인터, 0으로 나누기)
공통점: 잘못된 설정, 코드 버그, 비즈니스 규칙 위반에서 오며, 재시도는 리소스만 낭비합니다.
둘을 구분하는 법:
재시도 전략
일시적 에러라면 재시도가 첫 수입니다. 하지만 재시도에는 보이는 것보다 많은 것이 걸려 있습니다.3
전략 1: 고정 지연 재시도
문제: 에러의 원인이 과부하된 서비스라면, 모든 클라이언트가 동시에 재시도하면서 과부하가 더 심해집니다(thundering herd 현상).
전략 2: 지수 백오프
장점: 재시도할 때마다 간격이 두 배가 되므로, 서비스를 두들기는 대신 회복할 시간을 더 줍니다.3
전략 3: 지수 백오프 + 지터
장점: 지터는 여러 클라이언트가 정확히 같은 순간에 재시도하는 것을 막아 부하를 흩뜨립니다.3
프로덕션에서 권장하는 전략이 이것입니다.4
전략 4: 선별적 재시도
핵심 발상: 일시적 에러만 재시도합니다. 영구적 에러는 즉시 던져서, 소용없는 재시도에 시간을 태우지 않습니다.5
서킷 브레이커 패턴
문제: 어떤 서비스가 계속 실패하는데(예를 들어 죽어 버린 데이터베이스) 모든 요청이 세 번씩 재시도한다면, 아무 소득 없이 리소스만 태우고 워크플로 전체를 끌어내립니다. 게다가 그 서비스가 다른 서비스의 의존 대상이라면, 실패는 사슬을 타고 번져 연쇄 장애가 됩니다.
서킷 브레이커: 에러율이 임계치를 넘으면 실패하는 서비스 호출을 일시적으로 멈추고 빠르게 실패시켜, 리소스 낭비를 막습니다.1
세 가지 상태
Closed: 정상 동작. 요청이 통과하고, 브레이커는 에러율을 추적합니다.
Open: 서비스를 사용할 수 없다고 봅니다. 요청은 호출 없이 즉시 실패합니다.
Half-open: 타임아웃이 지나면 시험 삼아 몇 건의 요청을 통과시킵니다. 성공하면 브레이커는 closed로 돌아가고, 그렇지 않으면 open으로 남습니다.
구현
언제 쓰나: 외부 서비스, 데이터베이스, 파일 시스템처럼 한꺼번에 실패할 수 있는 의존 대상을 호출할 때.1
보상 동작과 롤백
문제: 워크플로가 쓰기 작업을 세 번 했는데(데이터베이스에 쓰기, 이메일 발송, 캐시 갱신) 4단계가 실패했습니다. 앞의 셋을 어떻게 되돌릴까요?2
패턴 1: 트랜잭션 작업
언제 맞나: 모든 작업이 트랜잭션을 지원하는 같은 데이터베이스 안에 있을 때.
한계: 시스템을 넘어설 수 없습니다(예를 들어 데이터베이스 + 파일 시스템 + API 호출).
패턴 2: 보상 동작(Saga 패턴)
발상: 각 작업마다 보상 동작을 정의해 두고, 실패하면 보상을 실행해 이미 끝난 단계를 되돌립니다.4
핵심:
- 모든 단계에
forward(수행할 동작)와 compensate(되돌리기)가 있습니다.
- 실패하면 완료된 단계의 보상을 역순으로 실행합니다.
- 보상 자체도 실패할 수 있습니다. 로그를 남기고 사람이 볼 수 있게 표시합니다.4
패턴 3: 멱등 설계
멱등: N번 실행해도 한 번 실행한 것과 같은 효과를 냅니다.2
장점: 네트워크가 한 번 딸꾹질해서 어떤 단계가 두 번 실행되더라도(첫 시도가 타임아웃됐지만 실제로는 성공했던 경우), 멱등성은 부수 효과가 중복되지 않도록 보장합니다.2
에러 처리의 계층
좋은 워크플로는 세 계층에서 에러를 처리합니다.
계층 1: 개별 작업
계층 2: 워크플로 단계
이 계층은 모든 실패를 단계 이름, 에러 메시지, 타임스탬프와 함께 workflowState.errors에 적어 둡니다. 그것이 여러분의 에러 로그이고, 디버깅할 때는 기억이 아니라 이 기록에 기댑니다.
계층 3: 워크플로 전체
세 겹의 방어: 작업 계층에서 재시도하고, 단계 계층에서 기록하고, 워크플로 계층에서 복구하고 알립니다.
다음: 레슨 6: 실전 워크플로 사례 — 지금까지의 것을 모두 합쳐 프로덕션 수준의 워크플로 세 개를 만듭니다. 코드 리팩터링, 문서 생성, 테스트 자동화