Home > AI > Backend > 비동기 API가 무조건 200을 반환하고 있었다

비동기 API가 무조건 200을 반환하고 있었다
AI Claude Code API설계 동시성

문제: “삭제 성공”이라는 거짓말

블록체인 네트워크를 만드는 데는 몇 분이 걸린다. Kubernetes에 리소스를 올리고 pod가 뜨기를 기다리고 채널을 만들고 peer를 조인시킨다. 그래서 생성도 삭제도 비동기로 처리한다. 컨트롤러는 작업을 등록만 하고 바로 응답을 준다.

문제는 그 응답이 무조건 200 success였다는 것이다.

사용자: 네트워크 생성 클릭
        (설치가 3분째 진행 중)
사용자: 아 잘못 만들었다, 삭제 클릭
서버:   200 success
화면:   "삭제되었습니다"
실제:   아무 일도 일어나지 않음. 설치는 계속 진행됨.

컨트롤러는 비동기 작업을 등록하는 데 성공했으니 200을 준 것이다. 기술적으로는 거짓말이 아니다. 하지만 사용자가 읽는 의미는 “내 네트워크가 삭제됐다”이고, 그건 사실이 아니었다. 이미 삭제 중인 네트워크에 삭제를 또 눌러도 마찬가지로 성공이었다.

어떻게 고쳤나

1. 컨트롤러 진입부에 사전 검증

비동기로 미룰 수 없는 판단이 있다. “지금 이 요청이 애초에 말이 되는가”는 즉시 알 수 있고, 즉시 알려줘야 한다.

컨트롤러 진입부에 assertDeletable()을 넣었다.

현재 상태 응답
status = CREATED 또는 INSTALLING 409 NETWORK_INSTALL_IN_PROGRESS
networkStatus = DELETING 409 NETWORK_ALREADY_DELETING
그 외 비동기 등록 후 진행

409 Conflict를 고른 이유는 이게 “잘못된 요청”이 아니라 “지금은 안 되는 요청”이기 때문이다. 같은 요청을 나중에 보내면 성공한다. 400은 요청 자체가 틀렸다는 뜻이라 의미가 다르다.

2. 비동기 진입부에도 같은 가드

컨트롤러에서 검증하고 나서 비동기 작업이 실제로 시작될 때까지 시간 간격이 있다. 그 사이에 상태가 바뀔 수 있다. 그래서 processDeleteNetworkAsync 진입부에도 같은 가드를 한 번 더 넣었다.

[요청 A] 컨트롤러 검증 통과 → (여기) → async 실행
[요청 B]        컨트롤러 검증 통과 → async 실행

두 요청이 거의 동시에 오면 둘 다 컨트롤러 검증을 통과한다. 검증 시점과 사용 시점 사이의 틈 — 이 프로젝트에서 SFTP 디렉토리 생성 때 만났던 것과 같은 TOCTOU다.

3. 사라지던 예외를 살렸다

비동기 task 안에서 터진 예외가 조용히 사라지고 있었다. @Async 메서드가 void를 반환하면 예외를 받아갈 곳이 없다. 별도 핸들러를 지정하지 않으면 그대로 묻힌다.

삭제가 실패했는데 아무 로그도 안 남는 상황이었다는 뜻이다. 예외 로깅을 명시적으로 강화했다.

같은 검증을 두 군데 두는 건 중복인가

이 질문은 짚고 갈 만하다. DRY 원칙만 보면 명백한 중복이다.

하지만 두 검증은 목적이 다르다.

위치 목적 실패 시
컨트롤러 사용자에게 즉시 피드백 409 응답
async 진입부 상태 무결성 보호 로그 남기고 조용히 종료

컨트롤러 검증을 지우면 사용자 경험이 망가진다. async 검증을 지우면 동시 요청에서 데이터가 깨진다. 어느 쪽도 다른 쪽을 대신하지 못한다.

같은 조건을 검사한다고 해서 같은 검증은 아니다. 중복 판단의 기준은 “코드가 닮았는가”가 아니라 “하나를 지웠을 때 다른 하나가 그 역할을 대신하는가”여야 한다. 여기서는 대신하지 못하므로 중복이 아니라 계층별 방어다.

다만 솔직히 말하면 여기에도 비용은 있다. 두 곳의 조건이 나중에 어긋날 수 있다. 삭제 가능 조건이 하나 추가됐을 때 한쪽만 고치면 이상하게 동작한다. 이걸 줄이려면 조건 자체를 도메인 객체의 메서드 하나로 뽑아 양쪽이 그걸 부르게 하는 게 낫다.

202 Accepted를 안 쓴 부채

더 근본적인 문제는 응답 코드 설계다. 이 API가 하는 일은 “요청을 접수했다”이지 “작업을 완료했다”가 아니다. HTTP에는 정확히 그걸 위한 코드가 있다.

202 Accepted
Location: /api/networks/{id}/status

202를 쓰면 클라이언트가 “이건 아직 안 끝났다”는 걸 프로토콜 수준에서 알 수 있다. 200을 주면 클라이언트 개발자는 자연스럽게 완료로 해석하고, 화면에는 “삭제되었습니다”가 뜬다. 응답 코드가 거짓말을 하면 그 위의 모든 UI가 같이 거짓말을 하게 된다.

이번 수정은 사전 검증으로 잘못된 요청을 걸러낸 것이지, 성공 응답의 의미를 바로잡은 건 아니다. 검증을 통과한 요청은 여전히 200을 받고, 여전히 그 삭제가 실제로 끝났는지는 알려주지 않는다. 진짜 해결은 202 + 상태 폴링 엔드포인트이고, 이건 프론트엔드까지 함께 바꿔야 하는 일이라 남겨뒀다.

남는 교훈

fire-and-forget API에서 “성공”이 무엇의 성공인지 명시해야 한다. 등록의 성공과 작업의 성공은 다르고, 200은 그 차이를 표현하지 못한다.

비동기로 미룰 수 없는 판단이 있다. 지금 상태로 이 요청이 유효한지는 즉시 알 수 있는 정보다. 즉시 알 수 있는 걸 비동기로 미루면 사용자는 틀린 정보를 보고 다음 행동을 결정한다.

조용히 사라지는 예외는 없는 것보다 나쁘다. 최소한 실패를 알면 조사할 수 있지만, 아무것도 안 남으면 재현부터 다시 해야 한다.