친절하고 법적 문구까지 담긴 200 성공 응답은 실제 작업이 성공했다는 증거가 아니다 — 응답을 생성하는 코드 경로가 실행됐다는 증거일 뿐이며, 그 사이에 광범위한 예외 처리기가 있다면 완전히 다른 주장이 된다.
배경
App Nimbus(6편에서 다룬 개인화 일일 인사이트 소비자 앱)에는 "회원탈퇴" 엔드포인트가 있었다. 사용자 요청 시 개인정보를 지체 없이 삭제하도록 요구하는 개인정보보호 규정에 따라 필요한 기능이었다. 이미 구현돼 있었고, 대략적으로 테스트도 거쳤고, 배포도 돼 있었다. 실제 서비스 개시를 검토하기 전에 받은 질문은 단도직입적이었다: "이게 정말로 실제 사용자와 실제 법적 의무를 감당할 준비가 됐어, 아니면 그냥 기능으로서 존재할 뿐이야?"
겉만 봤다면 이렇게 보였을 것
이 엔드포인트를 호출하면 정확히 원하는 모습이 돌아왔다.
HTTP 200
{
"message": "탈퇴 처리가 완료되었습니다.",
"legal": "관련 법령에 따라 개인식별정보를 즉시 익명화 처리했습니다."
}
200 상태 코드, 안심되는 메시지, 심지어 응답 본문에 명시적인 법적 근거 문구까지. 여기서 멈췄다면 이걸 준수 완료로 승인했을 것이다.
실제로 한 것
응답을 신뢰하지 않았다. 실제 운영 데이터베이스를 대상으로 전체 왕복 테스트를 실행했다.
- 실제와 같은 형태의 개인정보(닉네임, 이메일, 생년월일, 성별)를 가진 테스트 유저를 DB에 직접 삽입했다.
- 그 유저에게 실제 인증 토큰을 발급하고, 그걸로 실서버의 탈퇴 엔드포인트를 호출했다.
- 위와 같은 200과 안심되는 메시지를 받았다.
- 같은 유저를 대상으로 DB를 다시 조회했다.
닉네임, 이메일, 생년월일, 성별이 전혀 바뀌지 않았다 — 1단계에서 넣은 값 그대로, 평문 형태로 여전히 그 자리에 있었다.
삭제 코드는 실제와 다른 스키마를 대상으로 작성돼 있었다. 실제로 마이그레이션된 데이터베이스 스키마에는 존재한 적 없는 컬럼(그리고 존재한 적 없는 별도 테이블 전체)을 대상으로 UPDATE를 실행하고 있었다 — 실제 스키마는 완전히 다른 이름의 컬럼을 쓰고 있었는데, 삭제 코드가 그 이전 마이그레이션 이후로 한 번도 갱신되지 않은 채 남아있었던 것으로 보인다.
# 수정 전 — 실제 스키마에 존재하지 않는 컬럼을 대상으로 함
await db.execute(
"UPDATE users SET name = NULL, phone = NULL, "
"birth_date = NULL, is_deleted = TRUE WHERE id = :id",
{"id": user_id},
)이걸 실제 스키마에 실행하면 UndefinedColumn: column "name" of relation "users" does not exist가 발생한다. 이건 명확한 실패로 처리됐어야 했다. 그런데 실제로는 이랬다.
try:
await db.execute(delete_query, {"id": user_id})
except Exception as e:
logger.error(f"deletion error: {e}")
# 뭐가 됐든 실행은 그대로 성공 응답으로 흘러감
return {"message": "탈퇴 처리가 완료되었습니다.", "legal": "..."}넓은 범위의 except Exception이 데이터베이스 오류를 삼키고, 아무도 적극적으로 모니터링하지 않는 레벨로 로그를 남긴 뒤, 실행을 실제 삭제와 똑같은 "성공" 응답으로 그대로 흘려보내고 있었다. 이 엔드포인트는 어떤 사용자에 대해서도 실제로 뭔가를 삭제한 적이 단 한 번도 없었고, 사람이든 자동화 테스트든 모든 호출자는 내부 동작이 성공했든 안 했든 정확히 같은 안심되는 200을 받았다.
이게 일반적인 버그보다 더 중요한 이유
이건 UX 버그도 성능 회귀도 아니다. 개인정보 삭제라는 법적 권리를 행사한 사용자에게, API 응답에 명시적으로 박힌 법적 근거 문구와 함께 삭제가 일어났다고 알려주고 있었지만 — 실제로는 생년월일 등 민감한 필드(특히 이런 개인화 인사이트 제품에서는 더욱 민감한)가 완전히 그대로 남아 조회 가능한 상태였다. 만약 이게 실제 사용자에게 배포된 상태로 외부에서 발견됐다면 — 사용자가 자기 데이터를 확인하다가, 혹은 규제 당국에 의해 — 의도와 무관하게 허위 준수 주장과 구분이 안 됐을 것이다.
# 수정 후 — 실제 마이그레이션된 스키마를 대상으로 함; 실패는 조용하지 않고 명확함
result = await db.execute(
"UPDATE users SET nickname = NULL, email = NULL, "
"birth_year = NULL, birth_month = NULL, birth_day = NULL, "
"gender = NULL WHERE id = :id",
{"id": user_id},
)
if result.rowcount == 0:
raise HTTPException(status_code=500, detail="deletion failed")
return {"message": "탈퇴 처리가 완료되었습니다."}
검증
정확히 같은 3단계 재현(실제와 같은 테스트 데이터 삽입 → 실서버 엔드포인트 호출 → 재조회)을 수정된 코드로 다시 실행했다. 이번엔 재조회 결과 대상 필드 전부가 실제로 지워져 있었다. 이전 상태를 직접 쿼리로 잡아냈던 것과 같은 방식으로 이후 상태를 직접 확인하기 전까지는 고쳤다고 간주하지 않았다 — 수정은 diff를 읽는 것으로 검증되는 게 아니라, 버그를 찾아냈던 바로 그 재현 절차를 다시 실행하는 것으로 검증된다.
친절하고 심지어 법적 문구까지 담긴 200 응답은 내부 동작이 성공했다는 증거가 아니다 — 응답을 생성하는 코드 경로가 실행됐다는 증거일 뿐이며, 둘 사이에 넓은 예외 처리기가 끼어있는 순간 이건 완전히 다른 주장이 된다. 여기서 일반화할 만한 습관은: 보안이나 준수와 관련된 무엇이든(삭제, 접근 철회, 데이터 내보내기) 코드를 읽거나 HTTP 상태를 확인하는 것으로 감사하지 말라는 것이다 — 실제 상태를 삽입하고, 실제 엔드포인트를 통해 실제 동작을 트리거하고, 실제 데이터베이스를 다시 조회해야 한다. 그 외의 모든 확인은 코드가 맞아 보이는지를 테스트하는 것이고, 이것만이 코드가 실제로 맞는지를 테스트하는 유일한 방법이다.
첫 댓글을 남겨보세요