성공을 반환했지만 아무것도 삭제하지 않은 버튼

8월 24, 2026 · 노이반
현장의 기록: AI Engineer / Forward Deployed Engineer — 8편
한 줄 요약

친절하고 법적 문구까지 담긴 200 성공 응답은 실제 작업이 성공했다는 증거가 아니다 — 응답을 생성하는 코드 경로가 실행됐다는 증거일 뿐이며, 그 사이에 광범위한 예외 처리기가 있다면 완전히 다른 주장이 된다.

배경

App Nimbus(6편에서 다룬 개인화 일일 인사이트 소비자 앱)에는 "회원탈퇴" 엔드포인트가 있었다. 사용자 요청 시 개인정보를 지체 없이 삭제하도록 요구하는 개인정보보호 규정에 따라 필요한 기능이었다. 이미 구현돼 있었고, 대략적으로 테스트도 거쳤고, 배포도 돼 있었다. 실제 서비스 개시를 검토하기 전에 받은 질문은 단도직입적이었다: "이게 정말로 실제 사용자와 실제 법적 의무를 감당할 준비가 됐어, 아니면 그냥 기능으로서 존재할 뿐이야?"

겉만 봤다면 이렇게 보였을 것

이 엔드포인트를 호출하면 정확히 원하는 모습이 돌아왔다.

HTTP 200
{
  "message": "탈퇴 처리가 완료되었습니다.",
  "legal": "관련 법령에 따라 개인식별정보를 즉시 익명화 처리했습니다."
}

200 상태 코드, 안심되는 메시지, 심지어 응답 본문에 명시적인 법적 근거 문구까지. 여기서 멈췄다면 이걸 준수 완료로 승인했을 것이다.

실제로 한 것

응답을 신뢰하지 않았다. 실제 운영 데이터베이스를 대상으로 전체 왕복 테스트를 실행했다.

  1. 실제와 같은 형태의 개인정보(닉네임, 이메일, 생년월일, 성별)를 가진 테스트 유저를 DB에 직접 삽입했다.
  2. 그 유저에게 실제 인증 토큰을 발급하고, 그걸로 실서버의 탈퇴 엔드포인트를 호출했다.
  3. 위와 같은 200과 안심되는 메시지를 받았다.
  4. 같은 유저를 대상으로 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 상태를 확인하는 것으로 감사하지 말라는 것이다 — 실제 상태를 삽입하고, 실제 엔드포인트를 통해 실제 동작을 트리거하고, 실제 데이터베이스를 다시 조회해야 한다. 그 외의 모든 확인은 코드가 맞아 보이는지를 테스트하는 것이고, 이것만이 코드가 실제로 맞는지를 테스트하는 유일한 방법이다.

Advertisement

첫 댓글을 남겨보세요