Spring

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

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

블록체인 네트워크 생성에 걸리는 시간 — 몇 분

  • 수행 작업 — 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을 쓰지 않은 이유 — 400은 요청 자체가 틀렸다는 뜻이라 의미가 다름

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

  • 틈이 생기는 지점 — 컨트롤러 검증 후 비동기 작업이 실제로 시작될 때까지의 시간 간격
  • 그 사이 가능한 일 — 상태 변경
  • 조치 — processDeleteNetworkAsync 진입부에도 같은 가드 추가
[요청 A] 컨트롤러 검증 통과 → (여기) → async 실행
[요청 B]        컨트롤러 검증 통과 → async 실행
  • 두 요청이 거의 동시 도착 시 — 둘 다 컨트롤러 검증 통과
  • 검증 시점과 사용 시점 사이의 틈 — 이 프로젝트에서 SFTP 디렉토리 생성 때 만났던 것과 동일한 TOCTOU

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

  • 증상 — 비동기 task 안에서 터진 예외가 조용히 소멸
  • 원인 — @Async 메서드가 void를 반환하면 예외를 받아갈 곳이 없음
  • 별도 핸들러 미지정 시 — 그대로 묻힘
  • 실제 상황 — 삭제가 실패했는데 아무 로그도 안 남음
  • 조치 — 예외 로깅 명시적 강화

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

  • 짚고 갈 만한 질문
  • DRY 원칙만 보면 — 명백한 중복

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

위치 목적 실패 시
컨트롤러 사용자에게 즉시 피드백 409 응답
async 진입부 상태 무결성 보호 로그 남기고 조용히 종료
  • 컨트롤러 검증 삭제 시 — 사용자 경험 붕괴
  • async 검증 삭제 시 — 동시 요청에서 데이터 파손
  • 즉 어느 쪽도 다른 쪽을 대체 불가

같은 조건을 검사한다고 해서 같은 검증은 아니다.

  • 잘못된 중복 판단 기준 — “코드가 닮았는가” 여부
  • 올바른 기준 — “하나를 지웠을 때 다른 하나가 그 역할을 대신하는가” 여부
  • 이 사례 — 대신 불가 → 중복 아닌 계층별 방어

다만 여기에도 비용 존재

  • 위험 — 두 곳의 조건이 나중에 어긋날 수 있음
  • 시나리오 — 삭제 가능 조건 추가 시 한쪽만 고치면 이상 동작
  • 완화책 — 조건 자체를 도메인 객체의 메서드 하나로 추출해 양쪽이 호출

202 Accepted를 안 쓴 부채

더 근본적인 문제 — 응답 코드 설계

  • 이 API가 실제로 하는 일 — 요청 접수
  • 이 API가 하지 않는 일 — 작업 완료
  • HTTP에 이미 있는 정확한 코드
202 Accepted
Location: /api/networks/{id}/status
  • 202 사용 시 — 클라이언트가 “이건 아직 안 끝났다”를 프로토콜 수준에서 인지
  • 200 사용 시 — 클라이언트 개발자는 자연스럽게 완료로 해석, 화면에 “삭제되었습니다” 표시

응답 코드가 거짓말을 하면 그 위의 모든 UI가 같이 거짓말을 하게 된다.

이번 수정의 범위

  • 한 것 — 사전 검증으로 잘못된 요청 필터링
  • 안 한 것 — 성공 응답의 의미 교정
  • 현재 — 검증을 통과한 요청은 여전히 200을 받고, 삭제 완료 여부는 여전히 미통보
  • 진짜 해결 — 202 + 상태 폴링 엔드포인트
  • 보류 이유 — 프론트엔드까지 함께 바꿔야 하는 작업

남는 교훈

fire-and-forget API에서 “성공”이 무엇의 성공인지 명시해야 한다.

  • 등록의 성공 ≠ 작업의 성공
  • 200 — 그 차이를 표현할 수단 없음

비동기로 미룰 수 없는 판단이 있다.

  • 현재 상태 기준 요청 유효성 — 즉시 판정 가능한 정보
  • 즉답 가능한 걸 비동기로 미룬 결과 — 사용자가 틀린 정보로 다음 행동 결정

조용히 사라지는 예외는 없는 것보다 나쁘다.

  • 실패를 인지한 경우 — 조사 착수 가능
  • 아무것도 안 남은 경우 — 재현부터 다시