문제: “삭제 성공”이라는 거짓말
블록체인 네트워크 생성에 걸리는 시간 — 몇 분
- 수행 작업 — 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 — 그 차이를 표현할 수단 없음
비동기로 미룰 수 없는 판단이 있다.
- 현재 상태 기준 요청 유효성 — 즉시 판정 가능한 정보
- 즉답 가능한 걸 비동기로 미룬 결과 — 사용자가 틀린 정보로 다음 행동 결정
조용히 사라지는 예외는 없는 것보다 나쁘다.
- 실패를 인지한 경우 — 조사 착수 가능
- 아무것도 안 남은 경우 — 재현부터 다시