📚 엔지니어 · 세션 · 실전 노트 (수정중) · L01

L23 — 디버깅 케이스 ① 403의 진짜 범인 찾기: CSRF인 줄 알았는데 BasicAuth였다 (수정중)

작성일자 2026-06-28  ·  수정일자 2026-06-28  ·  강의차수 L01  ·  예상소요 30분

목표 이 강의가 끝나면 학습자가 "증상으로 원인을 단정하지 않고, 실제 서버 로그와 응답 본문으로 진짜 원인을 좁히는" 디버깅 순서를 따라갈 수 있다.

이건 디버깅 시리즈의 첫 편이다. 앞으로 나올 디버깅 케이스는 모두 같은 골격으로 쓴다 — 증상 → 가정과 헛고생 → 전환점 → 증거 기반 진단 → 진짜 원인 → 일반화 체크리스트.

이번 사례는 2026-06-28 education prod(education.popupstudio.ai)에서 실제로 일어난 일이고, 고친 사람(Claude)이 두 번 헛다리를 짚은 과정까지 그대로 담는다. 헛고생의 모양을 알아야 다음에 안 빠진다.


1. 증상 — "비밀번호 인증 이메일이 안 보내져요"

사용자가 education.popupstudio.ai/forgot-password/ 에서 이메일을 넣고 "인증코드 발송"을 누르면 "인증코드 발송에 실패했습니다" 가 떴다. 프론트(static/js/accounts/forgot_password.js)는 API 응답이 성공(code === '0')이 아니면 이 문구를 띄운다. 즉 화면 문구는 "이메일 발송 실패"지만, 진짜 정보는 API가 무슨 상태코드를 돌려줬는가 에 있다.

첫 교훈: 사용자가 보는 문구 ≠ 실제 원인. 프론트 alert 한 줄 뒤의 HTTP 응답을 봐야 한다.


2. 첫 번째 함정 — "403이면 CSRF지"

서버 로그를 보니 Forbidden: /api/accounts/email-auth/ (HTTP 403)이 찍혀 있었다. prod 설정을 열어보니 진짜 문제가 줄줄이 나왔다 — DEBUG=True, CSRF_TRUSTED_ORIGINS=[], SECURE_PROXY_SSL_HEADER=None. 리버스 프록시(Apache) 뒤 Django가 요청을 http로 인식해서 CSRF Origin 검사가 깨지는, 교과서적인 진짜 버그였다.

그래서 그걸 고쳤다(docs/issues/1_prod-csrf-403-forbidden.md). 그리고 "해결됐다"고 선언했다. ← 여기가 함정이다.

발견한 문제가 진짜 문제인 것과, 그게 이번 증상의 원인인 것은 별개다. 그럴듯한 버그를 찾으면 거기서 멈추고 "이거다" 하고 싶어진다. 그 유혹이 디버깅을 망친다.


3. 거짓 신호 — curl은 되는데 사용자는 안 된다

매 수정마다 나는 curl 로 재현해서 201 OK 를 확인하고 안심했다.

curl -X POST https://education.popupstudio.ai/api/accounts/email-auth/ ...
# {"code":"0","message":"OK"}  [HTTP 201]

그런데 사용자는 계속 실패했다. "curl은 통과(201)하는데 사용자 브라우저는 실패(403)" — 이 모순이 가장 큰 단서였는데, 나는 이걸 "고쳐졌나 보다"로 읽었다.

핵심 교훈: curl 재현이 통과한다고 버그가 고쳐진 게 아니다. 내 curl과 사용자 브라우저가 다른 요청 을 보내고 있다면, 내 201은 실패 조건을 한 번도 재현하지 못했다는 뜻이다. 통과하는 재현은 "고쳤다"의 증거가 아니라 "나는 아직 실패를 재현 못 했다"의 증거다.


4. 두 번째 함정 — 가정 위에 가정 쌓기

사용자가 "여전히 실패한다"고 하자, 나는 또 CSRF 틀 안에서 생각했다. "로그인된 세션만 CSRF가 강제되니, 토큰이 누락되나 보다" → @ensure_csrf_cookie + JS 보강(docs/issues/2_csrf-token-missing-ensure-cookie.md). 또 curl 201을 확인하고, "하드 리프레시 하세요"라고 안내했다.

틀린 가정(403=CSRF) 위에 또 가정을 쌓았다. 한 번 빗나갔으면 가정을 버려야 하는데, 같은 틀에서 더 정교한 설명을 만들고 있었다.


5. 전환점 — "일단 서버 로그는 확인하고 있니?"

사용자가 끼어들었다(원문 그대로):

"하드 리프레쉬하고 했는데, 또 실패하는데?? 원인을 다른 각도에서 분석해봐야 하는거 아니야?? 일단 서버 로그는 확인하고 있니??"

이 한마디가 디버깅의 방향을 바꿨다. 나는 그제서야 Apache access 로그(요청이 실제로 받은 상태코드가 그대로 찍히는 곳 — L19 참고)를 봤다.

125.176.11.232 ... "POST /api/accounts/email-auth/" 403 748 "...forgot-password/" "...Chrome..."   ← 사용자
43.200.38.93   ... "POST /api/accounts/email-auth/" 201 5167 "..." "curl/7.81.0"                    ← 내 재현

사용자의 403은 응답 748 bytes, 내가 재현한 CSRF 403은 5209 bytes 였다. 크기가 다르다 = 서로 다른 종류의 응답 = 나는 사용자의 실패를 한 번도 재현한 적이 없다.

전환점의 본질: 추측을 멈추고 실제 로그로 돌아간 것. 그리고 access 로그의 응답 크기 같은 객관적 수치가 "내 재현은 가짜였다"를 증명했다.


6. 증거 잡기 — 비간섭 계측

추측 대신 사용자의 실제 요청과 응답 본문을 잡기로 했다. 임시 미들웨어를 prod에 넣어(/api/accounts/email-auth/ POST일 때만) 요청 헤더·쿠키 유무와 실제 응답 본문을 로깅했다.

주의 — 처음 만든 진단은 CSRF 검사를 직접 호출해서 요청 상태를 바꿔버리는 버그가 있었다. 그래서 아무것도 건드리지 않고 보기만 하는(비간섭) 버전으로 다시 만들었다.

계측의 철칙: 관측이 대상을 바꾸면 안 된다. 진단 코드가 흐름에 개입하면, 잡은 값이 진짜 운영 상태인지 알 수 없다.

사용자가 한 번 더 누르자 진짜 정보가 찍혔다:

CSRFDIAG_IN  cookies=['csrftoken'] has_session=False is_secure=True xcsrf_len=64 ...
CSRFDIAG_OUT status=403 body=b'{"detail":"Invalid username/password."}'

7. 진짜 원인 — 응답 본문 한 줄이 범인을 지목한다

세 가지가 한꺼번에 드러났다.

  • CSRF 검사는 통과(csrf_check_reason='PASS')였다 — CSRF가 전혀 아니었다.
  • 사용자는 로그인도 안 된(has_session=False) 익명 상태였다.
  • 403 응답 본문이 {"detail":"Invalid username/password."} — 이건 DRF BasicAuthentication 만 내는 메시지다.

accounts/api/views.py:58 의 EmailAuthCreateAPIView(APIView) 는 authentication_classes 를 지정하지 않아 DRF 기본값(SessionAuthentication + BasicAuthentication)을 썼다. 사용자 브라우저(Chrome)는 과거 어느 시점 HTTP Basic 인증에서 캐시한 Authorization: Basic ... 헤더를 매 요청에 보내고 있었고, BasicAuthentication 이 그 stale 자격증명을 Django 유저로 검증하려다 실패 → 403.

curl이 계속 통과한 이유도 이걸로 완벽히 설명된다 — curl은 그 Authorization 헤더를 안 보냈다.

# 가설 확정: 헤더 하나로 갈린다
Authorization 없음                → 201 OK
Authorization: Basic <아무거나>   → 403 {"detail":"Invalid username/password."}

핵심 교훈: 403의 실제 응답 본문을 봤어야 했다. {"detail":...} 한 줄이 처음부터 BasicAuth를 가리키고 있었는데, "403=CSRF"라는 선입견이 그 줄을 못 보게 했다.


8. 수정과 검증

세션 기반 웹앱이므로 DRF 인증에서 BasicAuthentication 을 뺐다 — education/settings/base.py:

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.SessionAuthentication',
    ],
}

이제 stale Authorization: Basic 헤더는 무시되고, 익명 요청이 정상 통과한다. 검증은 사용자의 실패 조건 그대로 했다 — Authorization: Basic 헤더를 달고 요청해서 201 이 나오는지 확인(수정 전엔 403). 상세: docs/issues/3_drf-basicauth-stale-authorization-403.md.

수정 검증은 실패를 재현하던 조건에서 한다. "그냥 curl"로 201을 보는 건 3절에서 이미 거짓 신호였다. 범인을 재현한 요청(= Authorization 헤더 포함)으로 통과를 봐야 진짜 검증이다.


9. 일반화 — 디버깅 체크리스트 (시리즈 공통)

이번 사례에서 뽑은, 다음 디버깅에 그대로 쓰는 순서다.

  1. 증상 ≠ 원인. 화면 문구 뒤의 실제 신호(HTTP 상태코드·응답 본문)부터 본다.
  2. 증상으로 원인을 단정하지 않는다. "403이면 CSRF", "500이면 코드 버그" 같은 반사적 결론을 의심한다.
  3. 그럴듯한 진짜 버그를 찾아도 멈추지 않는다. 그게 이 증상의 원인인지는 따로 증명한다.
  4. 재현이 통과하면 의심한다. "curl은 되는데 사용자는 안 된다" = 코드가 아니라 내 재현이 실패 조건과 다르다는 신호.
  5. "브라우저는 실패, curl은 통과"면 브라우저만 보내는 것을 의심한다 — 쿠키, Authorization(캐시된 Basic), Accept, Referer.
  6. 실제 서버 로그를 본다. Apache access 로그(상태코드·응답 크기), error 로그, journalctl -u {svc} — L19·L20.
  7. 추측이 막히면 비간섭 계측으로 실제 요청·응답을 잡는다. 단, 관측이 대상을 바꾸지 않게.
  8. 수정 검증은 실패를 재현하던 조건에서 한다.

그리고 가장 메타적인 교훈 — 사용자가 "다른 각도에서 봐라, 로그는 보고 있냐"고 하면, 그건 방향이 틀렸다는 신호다. 같은 틀에서 더 정교한 설명을 만들지 말고, 틀을 버리고 증거로 돌아간다.


오늘 정리 + 다음

  • 정리: prod 비번찾기 이메일 403의 진짜 원인은 CSRF가 아니라 브라우저가 캐시한 Authorization: Basic 헤더 + DRF 기본 BasicAuthentication 이었다. 두 번의 헛다리(프록시/CSRF, ensure_csrf_cookie)는 매번 통과하는 curl 재현 때문에 "고쳤다"고 오판한 결과다. 전환점은 "실제 서버 로그를 봐라"였고, 결정타는 응답 본문(Invalid username/password.) 한 줄이었다.
  • 흔한 함정: 통과하는 재현(curl 201)을 "수정 성공"의 증거로 쓰는 것. 실패 조건을 재현하지 못한 통과는 아무것도 증명하지 못한다.
  • 다음 시간: 디버깅 케이스 ② — (다음 사례 추가 예정). 같은 골격(증상→가정→전환점→증거→원인→체크리스트)으로 잇는다.
  • 자습 권장: 이번 사례의 세 이슈 문서 — docs/issues/1_prod-csrf-403-forbidden.md, docs/issues/2_csrf-token-missing-ensure-cookie.md, docs/issues/3_drf-basicauth-stale-authorization-403.md. 그리고 리버스 프록시 뒤 CSRF/scheme 함정은 팀 wiki 의 lessons/deploy/reverse-proxy-https-csrf-403.md 참고(자습용).

부록 — 실제 대화 (픽스 요청 → 성공)

위 분석은 전부 아래 실제 대화에서 나왔다. 헛다리와 되돌이를 지우지 않고 그대로 둔다 — 디버깅이 실제로 어떻게 꼬이는지 가 이 강의의 핵심이기 때문이다. (사용자 발언은 원문 그대로. Claude 쪽은 한 일을 요지로 줄였다.)

① 사용자

"비번 찾기 인증 코드 발송에 실패했다는데 education.popupstudio.ai 야"

② Claude — 서버 로그 에이전트 + 코드 탐색 에이전트를 병렬로 띄움. 발송은 SendGrid 구조, 로그엔 traceback 없음. dev에서 재현하니 201 OK + 실제 메일 발송 → "백엔드는 정상"으로 판단.

③ Claude — 도메인이 prod 박스(52.79.132.206) 임을 확인하고 SSH 접속. prod 설정에서 DEBUG=True, CSRF_TRUSTED_ORIGINS=[], SECURE_PROXY_SSL_HEADER=None 을 발견 → "리버스 프록시 뒤 CSRF Origin 문제"로 진단. 프록시/CSRF 3종 + Apache 헤더를 수정(#1)하고 curl 재현 201 을 확인 → "해결됐다"고 선언.

④ 사용자

"방금 테스트 해봤는데 여전히 메일 발송에 실패했다고 하는데"

⑤ Claude — 또 curl 201. 로그상 로그인 세션만 403인 것을 보고 "CSRF 토큰 누락"으로 재진단 → @ensure_csrf_cookie + JS 보강(#2). 또 curl 201 을 확인하고 "하드 리프레시 하세요" 라고 안내.

⑥ 사용자 ← 🔑 전환점

"하드 리프레쉬하고 했는데, 또 실패하는데?? 원인을 다른 각도에서 분석해봐야 하는거 아니야?? 일단 서버 로그는 확인하고 있니??"

⑦ Claude — 그제서야 Apache access 로그를 봄. 사용자의 403 응답은 748 bytes, 내가 재현한 403은 5209 bytes → "내 재현은 사용자의 실패와 다른 응답이었다"(= 실패를 한 번도 재현 못 함)를 자각. 추측을 멈추고 비간섭 진단 미들웨어로 사용자의 실제 요청·응답을 잡기로 함.

⑧ 사용자 (진단을 다듬는 동안 여러 번 재시도해 줌)

"인증코드 발송 눌렀어"

⑨ Claude — 로그 확인: CSRF는 PASS, 익명 상태, 응답 본문 = {"detail":"Invalid username/password."}. → CSRF가 아니라 DRF BasicAuthentication 이었다. 브라우저가 캐시한 Authorization: Basic 헤더가 범인 — 그 헤더를 달면 403, 없으면 201 로 100% 재현. DRF 기본 인증에서 BasicAuthentication 을 제거(#3)하고 검증 201.

⑩ 사용자

"테스트 해봤고, 이메일 인증 성공적으로 되었어."


이 대화에서 배울 것: ②③⑤에서 Claude는 진짜 버그(프록시/CSRF 설정)를 찾았지만 그게 이 증상의 원인은 아니었고, 매번 통과하는 curl 때문에 "고쳤다"고 오판했다. ⑥ 사용자의 한마디("로그는 보고 있냐")가 방향을 틀었고, ⑦에서 실제 로그의 응답 크기 불일치가, ⑨에서 실제 응답 본문 한 줄이 진짜 원인을 드러냈다. 헛다리를 줄이는 건 더 똑똑한 추측이 아니라, 더 빨리 실제 증거(로그·응답 본문)로 돌아가는 것이다.

이 강의를 학습하셨나요?