본문으로 건너뛰기
JSL107's Tech Note
돌아가기

장시간 승인 작업의 접수와 실행 상태 분리

10분 분량이 글 고치기
목차 · 6

관리 콘솔의 승인 버튼은 누르면 바로 끝나는 작업처럼 보이기 쉬워요. 하지만 승인 뒤 여러 외부 작업을 차례로 실행해야 해서 처리 시간이 10분에서 30분 이상 걸린다면 HTTP 응답을 기다리는 사이 사용자가 버튼을 다시 누르거나 서버가 재시작될 수 있어요.

이 글은 세 개의 변경 기록(PR #634, PR #636, PR #643)과 2026년 9월 23일 로컬 데이터베이스에서 확인한 장애 기록을 바탕으로 썼어요. 변경 후 처리 시간이나 중복 발생률을 따로 측정한 자료는 없어요.

문제는 느린 작업보다 잘못된 상태 표현이었다

승인 대상 중 하나인 저녁 경력 반영 작업은 PR 묶음마다 회고 작업을 실행했어요. 묶음을 병렬로 처리하면 프로필 병합 결과가 서로 덮어쓰일 수 있어서 순서대로 실행해야 했고 묶음이 늘어날수록 처리 시간도 길어질 수밖에 없었어요.

실제로 해당 preview_action에는 PR 묶음 네 개가 들어 있었어요. 같은 날 확인한 agent_run 기록에는 833,210밀리초, 약 13.9분 동안 실행해 성공한 작업과 438,837밀리초, 약 7.3분 동안 실행해 성공한 작업이 있었어요. 전체 승인이 30분을 넘긴 경우도 있었고요.

순차 처리를 병렬화하면 응답 시간을 줄일 수 있지만 이 사례에서는 결과가 서로 덮어쓰일 위험이 더 컸어요. 병렬화할 수 없다면 사용자가 그 긴 시간 동안 HTTP 응답 하나를 기다려야 하는지 살펴봐야 했어요.

초기에는 승인 API가 실제 반영 작업이 모두 끝날 때까지 await했어요. 백엔드 작업이 정상적으로 실행 중이어도 콘솔이 먼저 대기를 포기하면 화면에는 실패로 표시됐죠. 서버에서 실제로 일어나는 일과 사용자가 보는 상태가 어긋난 거예요.

UI도 문제였어요. 승인 버튼을 눌러도 카드와 버튼이 그대로 남아 있어서 한 사용자는 반응이 없다고 생각하고 3초 만에 다시 눌렀어요. 서버는 두 번째 요청을 “이미 처리 중”이라며 거절했지만 앱에는 “이미 처리됐거나 만료된 요청”이라고 나왔어요. 첫 요청은 정상적으로 실행 중인데 화면은 실패했다고 말한 셈이에요.

완료 응답 대신 접수 응답을 반환한다

먼저 승인 API가 어디까지 맡을지 바꿨어요. 서버는 승인 요청을 받으면 지금 실행할 수 있는 요청인지 확인하고 202 Accepted로 접수 결과를 바로 반환해요. 실제 반영 작업은 응답을 보낸 뒤에도 계속 실행하고요.

승인 클릭
│
▼
승인 가능 여부 확인
│
├─ 접수 불가 ──▶ 즉시 오류 응답
│
└─ 접수 가능 ──▶ 202 Accepted
│
▼
백그라운드 반영
│
▼
SSE 완료 이벤트 전송

여기서 202는 처리가 끝났다는 뜻이 아니에요. 유효한 요청을 접수했고 처리를 시작할 수 있다는 의미예요. 콘솔은 HTTP 응답만 보고 최종 결과를 판단하지 않고 기존 SSE의 approval.resolved 이벤트로 완료 여부를 전달받아요.

클라이언트 타임아웃을 40분이나 60분으로 늘리면 일부 작업은 끝까지 기다릴 수 있어요. 그래도 연결이 끊기거나 작업이 더 길어지면 같은 문제가 생겨요. 긴 HTTP 연결을 유지하는 한, 작업이 끝나는 시간과 사용자가 접수를 확인하는 시간이 계속 묶여 있거든요.

접수와 완료를 분리하면 HTTP 요청으로 빠르게 검증하고 접수한 뒤, 오래 걸리는 작업의 상태 변화는 SSE로 전할 수 있어요. 이 프로젝트의 지시 경로인 POST /v1/console/command는 이미 그렇게 동작했고 승인 경로만 작업이 끝날 때까지 동기 응답을 기다리고 있었어요.

SSE는 결과를 전하는 통로일 뿐, 작업 실행까지 보장하지는 않아요. 브라우저 연결이 끊기거나 서버가 재시작돼도 실행 상태를 복구하려면 별도로 상태를 영속 저장해야 해요.

낙관적 UI로 재클릭 경로를 닫는다

API를 비동기로 바꿔도 승인 카드가 화면에 남아 있으면 사용자가 버튼을 다시 누를 수 있어요. 그래서 두 번째 변경에서는 클릭하자마자 카드를 목록에서 감췄어요. 처리 중인 대상을 다시 조작할 수 없게 해, 중복 요청이 들어오는 가장 가까운 경로를 막은 거예요.

클릭 전:  [승인 카드] [승인 버튼]
클릭 직후: 카드 숨김
성공 시:  숨김 상태 유지
실패 시:  카드와 버튼 복원

카드를 다시 보여 줄 때도 오류에 따라 다르게 처리했어요. 실행에는 실패했지만 재시도할 수 있으면 카드를 되살리고, 이미 처리됐거나 만료된 요청은 다시 보여 주지 않아요. 모든 오류에서 카드를 되살리면 실행할 수 없는 버튼까지 화면에 돌아오니까요.

낙관적 UI라고 부를 수 있는 패턴이지만 이 사례에서는 처리 중인 상태를 화면에 반영해 반복 클릭을 막는 게 중요했어요. 서버가 요청을 접수하지 못하면 숨긴 카드를 복원해요. 접수한 뒤 오래 걸리는 작업에서 실패했다면 실패 통지를 받고 재시도할 수 있는 상태로 되돌려야 하고요.

UI에서 버튼을 막는 것만으로 멱등성을 보장할 수는 없어요. 사용자는 다른 탭이나 클라이언트에서 같은 요청을 보낼 수 있고 프로세스가 재시작되면 UI에서 숨긴 상태도 다시 구성해야 해요.

메모리의 처리 중 상태는 재시작을 견디지 못한다

비동기 처리로 전환하고 나니 더 위험한 실패가 드러났어요. 당시에는 처리 중이라는 상태를 ApplyPreviewUsecase.applying이라는 인메모리 Set에만 저장하고 있었어요.

프로세스가 살아 있는 동안에는 이 집합으로 중복 실행을 막을 수 있어요. 하지만 서버가 재시작되면 집합은 사라져요. 성공해야 APPLIED로 바뀌는 preview_action.status는 여전히 PENDING이라서 다음 스냅샷에 승인 카드가 다시 나타나요. 화면에서는 “처리하다 중단된 요청”과 “아직 한 번도 누르지 않은 요청”을 구분할 수 없었어요.

2026년 9월 23일에는 이 조건 때문에 실제로 중복 반영이 일어났어요.

회차agent_run 흐름결과
1차09:02 → 09:15 → 09:2209:24 재시작으로 중단, 통지 없음
2차09:59 → 10:12 → 10:20 → 10:2610:33 APPLIED

대상은 앞서 말한 같은 preview_action으로, 네 개의 PR 묶음이 들어 있었어요. 1차 실행에서 이미 성공한 두 묶음이 2차 실행에서 다시 반영됐어요. 작업 전체가 유실된 게 아니라, 일부 작업이 성공한 뒤 실행 상태만 사라진 거예요.

여러 단계로 이루어진 작업은 시작했다는 사실만 기록해서는 부족해요. 어디까지 마쳤는지 알 수 없으면 재시작 후 처음부터 다시 실행하거나 작업 전체를 실패로 끝낼 수밖에 없어요.

세 번째 변경에서는 진행 흔적을 영속 원장에 남기고, 재시작하면 그 기록을 바탕으로 작업을 이어서 실행하도록 바꿨어요. 실패 사실을 사용자에게 알리는 경로도 추가했고요. 기존 ConsoleEvent에는 approval.opened와 approval.resolved만 있어서 실행 중단을 나타낼 이벤트가 없었거든요.

세 계층이 맡는 실패 지점

이 사례에서는 멱등 키 하나만 추가해서 중복 방지를 끝내지 않았어요. 서로 다른 실패 지점을 세 계층이 나눠 맡았어요.

계층막으려는 문제적용한 접근
UI반응이 없어 보일 때 발생하는 반복 클릭클릭 즉시 카드 숨김, 재시도 가능한 실패만 복원
요청 처리장시간 작업 때문에 발생하는 HTTP 타임아웃과 잘못된 실패 표시실행 가능 여부 확인 후 202 반환
작업 실행프로세스 재시작 뒤 완료 단계를 잊고 처음부터 재실행진행 원장을 남기고 복구 시 이어서 처리

UI만 바꾸면 다른 클라이언트에서 보낸 요청이나 재시작으로 생기는 문제를 막지 못해요. 202만 반환하면 응답 타임아웃은 피할 수 있지만 서버가 종료될 때 작업 상태도 함께 사라져요. 원장만 추가하고 화면을 그대로 두면 사용자는 여전히 버튼을 여러 번 눌러 불필요한 요청을 만들고요.

적용 범위와 남은 검증

모든 승인 작업을 비동기로 바꿀 필요는 없어요. 작업이 짧고 같은 요청 안에서 실행 결과를 확정해야 하며 실패 시 즉시 트랜잭션을 롤백할 수 있다면 동기 응답이 더 단순해요. 비동기 구조를 도입하면 작업 상태 저장, SSE 재연결, 실패 통지, 복구 정책을 운영하는 비용도 들어요.

반대로 다음 조건이 겹친다면 비동기 접수와 영속 진행 기록을 검토할 만해요.

SSE만 쓸 수 있는 건 아니에요. 클라이언트 폴링은 구현이 단순하고 재연결한 뒤 상태를 다시 조회하기 쉽지만 상태가 드물게 바뀌는 장시간 작업에서는 불필요한 요청이 생겨요. WebSocket은 양방향 통신이 필요할 때 적합하지만 완료 알림만 필요하다면 운영이 복잡해질 수 있어요. 이 사례에는 이미 SSE 기반 콘솔 이벤트가 있어서 기존 경로를 재사용하는 편이 자연스러웠어요.

이 구조를 검증하려면 정상적으로 끝나는 경우뿐 아니라 클릭, 실패, 재시작도 각각 시험해야 해요.

  1. 승인을 클릭한 직후 카드가 사라지고 같은 화면에서 다시 클릭할 수 없는지 확인한다.
  2. 서버가 접수할 수 있는 요청에 202를 반환하는지, 해당 응답이 실제 반영 완료 전에 오는지 확인한다.
  3. 정상적으로 완료된 뒤 SSE 완료 이벤트를 받고 카드가 사라진 상태를 유지하는지 확인한다.
  4. 재시도할 수 있는 실패가 발생하면 카드와 버튼을 복원하는지 확인한다.
  5. 이미 처리됐거나 만료된 요청은 오류가 발생해도 복원하지 않는지 확인한다.
  6. 여러 단계 중 일부가 성공한 시점에 서버를 재시작한 뒤 완료한 단계를 중복 반영하지 않고 이어서 처리하는지 확인한다.
  7. 복구할 수 없는 실패를 사용자에게 알리고 무기한 처리 중인 상태로 남겨 두지 않는지 확인한다.
  8. SSE 연결이 끊겼다가 다시 이어졌을 때 서버의 영속 상태를 바탕으로 화면을 재구성할 수 있는지 확인한다.

제공된 변경 기록에는 원장의 테이블 구조, 체크포인트 저장 단위, 재시도 횟수 같은 세부 구현이나 위 항목 전체를 변경 후 검증한 결과가 담겨 있지 않아요. 특히 SSE 재연결 때 누락된 이벤트를 어떻게 보완하는지, 같은 요청이 여러 프로세스에 동시에 배정되면 어떤 잠금이나 조건부 갱신을 쓰는지는 추가로 확인해야 해요.


이 글 고치기
이 글 공유하기:

이전 글
MCP Stateless Core에서 상태를 드러내는 방법