iOS에서만 CAPTCHA 숫자가 항상 고정 숫자로 고정되고, 새로고침도 먹통이 되는 버그를 만났다. 그런데 정작 서버에도 프론트에도 실패했다는 로그가 단 한 줄도 없었다. 이 글은 "어디서 왜 실패하는지 아무도 모르는" 상태를, 버그를 고치기 전에 관측 가능한 상태로 먼저 바꾸고, 그렇게 확보한 로그로 실제 근본 원인까지 규명한 과정을 정리한 기록이다.
TL;DR
- 증상: 담당 서비스의 인증 팝업에서 CAPTCHA 보안문자가 항상 고정 숫자로만 노출되고, 새로고침 버튼이 동작하지 않는 것처럼 보임
- 함정: 사용자가 화면에 보이는 고정 숫자를 입력하면 "인증번호 불일치"가 뜬다 → 그런데 어디서도 실패 로그가 안 남는다
- 판단: 원인 후보가 5개 이상이고 재현도 안 되는 상황에서 추측으로 코드부터 고치지 않았다. 대신 실패 지점 8곳에 구조화 로그를 심어 "관측 가능한 문제"로 먼저 전환했다 (Phase 1)
- 결말: 확보한 운영 로그를 근거로, "iOS 단말 문제"로 여겨지던 이 이슈가 사실은 프론트 HTML에 하드코딩된 정적 더미 이미지가 요청 실패 시 그대로 노출되는 구조적 결함이라는 것을 규명했다 (Phase 2). 1년 가까이 원인 미상으로 남아있던 이슈였다.
1. 문제 발견
담당 서비스의 인증 팝업(웹뷰 기반 인증 페이지)에서 CAPTCHA 입력 단계 불만이 CS로 들어오기 시작했다. 증상을 재정리하면 이랬다.
- iOS 환경에서 진행 시 CAPTCHA 보안문자 이미지가 항상 고정 숫자로만 보인다
- 새로고침 버튼을 눌러도 다른 숫자로 바뀌지 않는다 (동작하지 않는 것처럼 보임)
- 화면에 보이는 고정 숫자를 그대로 입력하면 "인증번호가 일치하지 않습니다" 메시지가 뜨고 인증이 끝나지 않는다
- 심지어 기기를 재부팅하거나 앱을 재설치해도 동일한 증상이 반복된다는 CS도 있었다
숫자가 "고정"되어 보인다는 것부터가 이상했다. CAPTCHA는 매 요청마다 서버가 새로 발급해서 Redis에 저장하는 구조였기 때문에, 정상적으로 동작한다면 같은 값이 반복해서 뜰 이유가 없었다.
원인 후보를 팀 논의에서 여러 개 올렸다.
- 프론트 캐싱 문제 (이미지가 캐시되어 안 바뀌는 것 아닌가)
- 랜덤 시드 문제 (서버 쪽 난수 생성이 고장난 것 아닌가)
- 세션/쿠키 문제 (iOS WebView의 쿠키 정책 때문 아닌가)
- 레이스 컨디션 (요청이 겹쳐서 이전 값이 남는 것 아닌가)
- 네트워크/WebView 자체 문제 (iOS 인앱 브라우저 특유의 제약 아닌가)
이 시점에서 나온 후보만 5개. 그런데 이걸 하나씩 검증할 방법이 없었다.
2. 재현 시도가 막힌 이유
가장 먼저 한 일은 당연히 재현이었다. 그런데 스테이징에서도, 로컬에서도 이 증상이 재현되지 않았다. iOS 특정 버전 + 특정 인앱 브라우저(네이버 앱 등) 조합에서만 산발적으로 발생하는 것으로 추정됐는데, 사내에서 그 조합의 실 기기를 확보하기도 어려웠고, 확보해도 실패가 매번 재현되는 게 아니었다.
더 큰 문제는 따로 있었다. 서버에도 프론트에도 이 흐름에 대한 실패 로그가 단 한 줄도 없었다. 서비스운영파트에 문의해도 "그냥 새로고침이 안 된다"는 사용자 진술 이상의 정보가 없었다. 즉:
- 네트워크 / 서버 응답 / Blob 변환 / 이미지 반영 중 어느 단계에서 실패했는지 알 수 없었다
- 왜 iOS에서만 발생하는지 알 수 없었다
- 애초에 요청이 서버까지 도달은 했는지조차 알 수 없었다
재현도 안 되고, 로그도 없고, 원인 후보는 5개. 여기서 흔히 하는 실수가 "그럴듯한 후보 하나를 골라서 고치고 배포해보는 것"이다. 나는 그렇게 하지 않기로 했다.
3. 원인 후보 배제 과정
먼저 CAPTCHA 이미지가 어떻게 화면에 뜨는지부터 코드를 역추적했다. 구조는 이랬다.
- 인증창 HTML은
<img>태그의 초기값으로 디자인용 플레이스홀더 이미지를 넣어둔다 - 페이지 로드 시
/gui/popup/captcha/image로 요청을 보내고, 성공하면 응답으로 받은 이미지로<img>의src를 교체한다
여기까지 보고 나서 의심 후보 중 "캐싱", "레이스 컨디션" 두 개는 코드상 자연스럽게 배제됐다 — 애초에 캐시 제어 헤더나 동시 요청을 막는 로직 자체가 없어서, 캐싱/레이스 컨디션이 원인이라면 오히려 더 다양한 값이 보였어야 했다.
그리고 진짜 문제를 찾았다. 이미지 교체 로직 자체에 실패를 감지하는 코드가 없었다.
// 문제 코드: process.js
requestImage: function (url, data, element) {
var request = new XMLHttpRequest();
request.open("POST", url, true);
request.responseType = "blob";
request.onload = function () { // HTTP 상태 코드 미검사
var reader = new FileReader();
reader.onloadend = function () { // onerror 미정의
element.src = reader.result;
}
reader.readAsDataURL(this.response);
}
// onerror / onabort / ontimeout 전부 없음
request.send(JSON.stringify(data))
}
이 함수에는 다음이 전부 빠져 있었다.
- HTTP 상태 코드 확인 (
onload는 4xx/5xx가 와도 그냥 실행된다) onerror,onabort,ontimeout핸들러- 응답 MIME 타입·크기 검증
- 실패 시 UI 표시나 재시도 로직
- 중복 요청/응답 순서 제어
즉, iOS에서 XHR이 실패하거나 중단되면 아무 표시도 없이 초기 플레이스홀더 이미지가 그대로 남는 구조였다. 새로고침 버튼도 같은 요청을 다시 보내고 똑같이 실패를 무시하니 "버튼이 안 먹는다"처럼 보인 것도 자연스럽게 설명됐다. 그리고 서버는 실제 정답을 Redis에 별도로 저장해두는 구조였으니, 사용자가 화면의 고정 숫자를 입력하면 당연히 "불일치"가 날 수밖에 없었다. 버그가 아니라 버그의 증상만 보고 있었던 것.
여기까지 오면 "그럼 이 코드만 고치면 되는 거 아니야?" 라는 생각이 들 수 있다. 실제로 나도 그랬다. 하지만 한 가지가 걸렸다.
왜 iOS에서만 XHR이 실패하는지는 여전히 알 수 없었다. 코드를 고쳐도, 그게 "진짜 원인"을 없앤 건지 "증상 하나"만 가린 건지 검증할 방법이 없었다.
4. Phase 1 — "고치기" 전에 "보이게 하기"
이게 이 이슈에서 가장 중요한 판단이었다고 생각한다. 원인 후보가 여러 개고 재현도 안 되는 상태에서 코드를 추측성으로 고치면, 고쳐졌는지조차 확인할 수 없다. 그래서 운영팀에는 이렇게 명확히 전달했다.
현재 소스 코드 상 해당 과정에서 에러 내용과 원인을 기록하는 로직이 없어, 과거 사례의 구체적인 실패 원인과 iOS에서만 발생한 이유는 현시점에서 확인하기 어렵습니다. 따라서 현재 할 수 있는 것은 구체적인 에러 내용과 원인을 알 수 있는 로직을 추가하여 배포하는 것입니다.
그래서 대응을 2단계(Phase)로 나눴다.
| Phase | 목표 | 상태 |
|---|---|---|
| Phase 1 | 실패 지점별 진단 로그 확보 (관측 가능성 확보) | 완료 |
| Phase 2 | 확보한 로그 분석 → 원인 확정 → 대응 개발 | 원인 확정 완료, 코드 수정은 CS 재인입 시 진행 |
CAPTCHA 처리 흐름을 다시 그려보면 실패가 날 수 있는 지점이 서버 4곳, 프론트 4곳, 총 8곳이었다.

그림 1. iOS WebView → auth-api-server → Redis 로 이어지는 요청 흐름에서 실패가 발생할 수 있는 8개 지점. Phase 1은 이 8곳 각각에 구조화 로그를 심는 작업이었다.
4-1. 백엔드 — 서버 쪽 4개 실패 지점
공통 접두어 [CAPTCHA] 와 phase= 키를 쓰는 구조화 로그 포맷을 정했다.
phase=request-failed # 요청 처리 실패 (예외 종류, 응답 커밋 여부, 소요 시간)
phase=image-write-failed # 응답 스트림 이미지 쓰기 실패 (Broken pipe 등)
phase=redis-save-failed # 생성한 정답의 Redis 저장 실패
phase=validation-failed # 입력값 검증 실패 (Redis 조회 성공 여부 포함)
까다로웠던 건 image-write-failed 쪽이었다. 응답 스트림에 이미지를 쓰다가 나는 IOException (클라이언트가 연결을 끊는 등)은 일반적인 컨트롤러 레벨 try-catch로는 잡히지 않는다. 그래서 ServletOutputStream을 위임(delegate) 패턴으로 감싸서, 실제 write() 호출 시점의 예외를 가로채는 래퍼(CaptchaResponseTracker)를 만들었다.
// 응답 스트림에 실제로 쓰는 시점의 실패를 잡는 래퍼
@Override
public void write(byte[] value, int offset, int length) throws IOException {
try {
delegate.write(value, offset, length);
} catch (IOException exception) {
tracker.recordWriteFailure(exception);
throw exception;
}
}
4-2. 프론트 — 클라이언트 쪽 4개 실패 지점
requestImage()에 상태 코드 검사와 실패 핸들러를 추가하고, 실패 유형을 4가지 에러 코드로 분류했다.
CAPTCHA_IMAGE_HTTP_FAILED # 404, 500, 502, 503 등 HTTP 응답 오류
CAPTCHA_IMAGE_READ_FAILED # 응답은 받았으나 FileReader가 Blob 읽기 실패
CAPTCHA_IMAGE_REQUEST_FAILED # 서버 연결 불가, DNS/CORS/TLS/네트워크 단절
CAPTCHA_IMAGE_REQUEST_ABORTED # 페이지 이동, 인증창 종료, abort() 호출
4-3. 클라이언트 로그를 서버로 끌어오기
브라우저에서 난 실패는 당연히 서버 로그에 안 남는다. 그래서 클라이언트 오류를 서버 로그로 전달하는 엔드포인트를 하나 신설했다.
@PostMapping("/popup/log/front-error")
public String logFrontError(@RequestBody String request) {
FrontErrorLogRequest req = GsonUtil.GSON.fromJson(request, FrontErrorLogRequest.class);
log.warn("[CAPTCHA_CLIENT] {}", MaskingUtil.doMasking(GsonUtil.GSON.toJson(req)));
return "{\"resultCode\":\"2000\",\"resultMsg\":\"success\"}";
}
전송하는 값은 requestId, captchaId, HTTP status, readyState, User-Agent 정도로 한정했다. CAPTCHA 정답이나 사용자가 입력한 값은 절대 로그에 남기지 않았고, 서버에 기록할 때도 MaskingUtil로 마스킹을 거치게 했다. 담당 서비스 특성상 로그 자체가 또 다른 위험이 될 수 있어서다.
4-4. 서버 로그와 클라이언트 로그를 하나로 연결하기 (Correlation)
서버가 요청마다 발급하는 requestId(UUID)와, Redis 키로 쓰이는 captchaId를 클라이언트 로그에도 그대로 실어 보내게 했다. 이렇게 하면 "브라우저에서 시작된 실패"를 서버 로그 한 곳에서 requestId 하나로 추적할 수 있다.
실제로 스테이징 배포 후 운영 로그에서 잡힌 실패 사례는 이런 모습이었다 (아래 IP는 예시로 대체함).
# 이미지 요청 자체가 실패한 케이스 검색
grep -i "CAPTCHA" /app/wildfly/wildfly/domain/servers/auth-was0*/log/server.log* \
| grep "phase=image-request-failed"
# 결과 예시
[CAPTCHA_CLIENT] clientIp=203.0.113.10 payload={
"errorCode":"CAPTCHA_IMAGE_REQUEST_FAILED",
"pageUrl":"https://cert.example-auth.com/auth_popup.html",
"userAgent":"Mozilla/5.0 (iPhone; CPU iPhone OS 26_6 like Mac OS X) ... NAVER(inapp; search; ...)",
"message":"[CAPTCHA] phase=image-request-failed requestId=38da7f64-... requestUri=/gui/popup/captcha/image status=0 readyState=4",
"timestamp":"2026-08-17T06:06:08.093Z"
}
status=0, readyState=4 조합은 브라우저가 서버에 요청을 아예 보내지 못했거나 응답을 못 받았을 때 나오는 전형적인 패턴이다. User-Agent에 찍힌 NAVER(inapp; ...) 문자열까지 더해서, "iOS 인앱 WebView 환경에서 네트워크 요청 자체가 실패한다" 는 가설이 처음으로 데이터로 뒷받침되는 순간이었다.
5. 재현이 안 되는 버그, 어떻게 테스트했나
Phase 1 개발 자체를 검증하는 것도 문제였다. 8개 실패 지점을 "일부러" 발생시켜서 로그가 제대로 찍히는지 봐야 했는데, 운영 코드는 건드리지 않는다는 제약이 있었다. 실패 유형별로 재현 방법을 다르게 설계했다.
| 실패 유형 | 재현 방법 |
|---|---|
request-failed |
Postman으로 형식이 깨진 토큰(not-json) 전송 |
validation-failed |
인증 요청을 cURL로 복사해 재전송 (CAPTCHA는 1회 검증 후 Redis에서 삭제되는 점을 이용) |
image-http-failed |
Requestly 확장으로 500 응답 Mock (DevTools의 요청 차단은 status=0만 만들어서 부적합) |
image-request-failed |
Chrome DevTools의 Network request blocking 사용 |
image-read-failed |
DevTools Snippets에서 FileReader를 일시적으로 가로채 NotReadableError 강제 발생 |
image-request-aborted |
숨긴 iframe에서 네이티브 XMLHttpRequest를 확보해 send() 직후 abort() 호출 |
redis-save-failed |
스테이징·운영에서 재현 불가 → 단위 테스트 코드로 검증 |
image-request-aborted 케이스가 특히 까다로웠다. 페이지에 이미 프록시로 감싸둔 XMLHttpRequest가 있어서, 그걸 그대로 쓰면 로그 전송용 요청까지 같이 오염됐다. 그래서 숨긴 iframe의 contentWindow에서 원본(프록시 안 걸린) XMLHttpRequest를 꺼내 쓰는 방식으로 우회했다.
6. Phase 2 — 근본 원인 확정
Phase 1에서 로그를 심어두고 몇 주간 운영 로그를 쌓은 뒤, 이 데이터를 근거로 Phase 2(원인 분석)를 진행했다.
확정된 근본 원인
프론트 저장소 코드를 다시 훑다가 결정적인 단서를 찾았다. 인증 팝업 HTML의 CAPTCHA <img> 태그 초기 src가 정적 파일(더미 이미지)로 하드코딩되어 있었고, 그 파일을 직접 열어보니 표기값이 화면에서 보던 고정 숫자와 정확히 일치했다.
즉, 사용자가 보던 그 숫자는 서버가 생성한 CAPTCHA 정답이 아니라, 퍼블리싱 단계에서 넣어둔 더미 이미지였다. 이 더미 이미지는 페이지가 로드되는 순간부터 모든 사용자에게 노출되고, 정상적인 경우엔 XHR 응답으로 곧바로 교체되어 아무도 보지 못한다. 문제는 그 요청이 끝까지 도달하지 못하면 더미 이미지가 그대로 화면에 남는다는 것이었다.
새로고침·재부팅·재설치로 해결되지 않는 이유
로그와 코드를 같이 보니 CS에서 반복되던 세 가지 진술이 전부 코드로 설명됐다.
- "새로고침을 눌러도 그대로예요" — 새로고침 버튼은 페이지를 다시 불러오는 게 아니라, 동일한 이미지 요청 함수를 한 번 더 호출할 뿐이었다. 요청이 실패하는 조건 자체가 없어지지 않으면 결과도 똑같았다.
- "버튼을 눌러도 화면이 안 바뀌어요" —
requestImage()의 모든 실패 경로(onerror,onabort, reader 실패)는 로그만 남기고element.src를 전혀 건드리지 않는다. 사용자 입장에선 버튼이 반응이 없는 것처럼 보일 수밖에 없었다. - "재부팅·재설치해도 똑같아요" — 더미 이미지는 단말기 캐시가 아니라 서버가 내려주는 HTML 안에 정적 리소스로 포함되어 있었다. 기기를 초기화해도 페이지 자체가 항상 그 이미지로 시작하니 증상이 반복될 수밖에 없었다.
Phase 1에서 확보해둔 validation-failed (redisFound=true) 로그의 의미도 이 시점에 완전히 확정됐다. Redis에는 진짜 정답이 정상적으로 저장돼 있고, 사용자는 화면의 더미 값을 입력하니 값만 불일치하는 것이었다.
여기에 더해, requestImage()의 HTTP 오류 처리 코드에 return문이 빠져 있어서 503/504 같은 오류 응답을 받고도 그 오류 응답 본문을 그대로 이미지로 설정하려 시도하는 추가 결함도 발견했다.
운영 로그로 본 실제 분포
3주간(2026-09-01 ~ 09-21) 누적된 클라이언트 실패 로그를 집계해봤다.
| 구분 | 건수 |
|---|---|
| 전체 오류 | 54 |
| iOS (iPhone/iPad) | 34 (63%) |
| 비iOS | 20 (37%) |
image-request-failed |
36 |
image-http-failed |
15 |
image-request-aborted |
2 |
image-read-failed |
1 |

가장 흥미로웠던 지점은 비iOS 비중이 37%나 된다는 것이었다. 그동안 "iOS에서만 발생하는 문제"로 인식돼 왔는데, 실제로는 iOS 인앱 WebView(네이버 인앱, 카카오톡 인앱 브라우저 등)가 화면 전환이나 백그라운드 진입 시 진행 중인 XHR을 더 자주 끊어버리는 환경적 특성 때문에 노출 빈도가 높았던 것뿐이었다. 결함 자체는 플랫폼을 가리지 않는 구조적 문제였다.
또 하나 눈에 띈 건 clientIp가 단 두 개의 값으로만 집계된다는 점이었다. 확인해보니 이건 사용자의 실제 IP가 아니라 WAS 앞단 프록시/L4 장비의 IP가 찍히고 있었던 것이었다. 사용자 식별에는 쓸 수 없는 값이라, 이건 X-Forwarded-For 헤더 미반영이라는 별도 개선 과제로 분리했다.
Phase 1 설계 단계에서는 "이론상 있을 수 있는 실패"로만 분류해뒀던 image-read-failed도 실제로 1건 관측됐다. 설계할 때는 확신이 없던 실패 경로가 데이터로 증명되는 순간이었다.
대응 방향 — 최소 변경 원칙
원인이 명확해진 만큼, 변경 범위가 가장 작은 안부터 정리했다.
즉시 적용 가능 (변경량 3줄 수준, 서버·API 변경 없음)
- 더미 이미지
src를 제거하고, 로드 실패 시 "보안문자를 불러오지 못했습니다. 새로고침을 눌러주세요" 안내 문구를 노출 requestImage()의 HTTP 오류 처리부에 누락된return추가
이 두 가지만 반영해도 "틀린 값을 입력해서 인증이 막히는" CS는 원천 차단된다. 증상도 "인증 실패"에서 "보안문자 미표시"로 바뀌어서, 앞으로 비슷한 문제가 생겨도 원인 추적이 훨씬 쉬워진다.
추가 검토 항목
- 화면이 포그라운드로 돌아올 때 이미지 재요청 (
visibilitychange이벤트) — 변경량 대비 효과가 가장 클 것으로 예상 - XHR 타임아웃 설정 (504 상황에서 무한 대기 방지)
- 새로고침 시 인증 토큰 자체를 갱신하는 방식 검토 (인증 플로우 전반에 영향이 있어 별도 검토 필요)
FileReader+readAsDataURL대신createObjectURL또는 base64 JSON 응답으로 전환, 동기 XHR 제거, 자동 재시도 도입
병행 과제
- 서버 측 503/504 발생 원인 확인 (게이트웨이 타임아웃, 이미지 생성 소요 시간)
- 서버 로그에 User-Agent·clientIp(정확한 값) 추가
- 배포 시 프론트 난독화 재빌드 필요 여부 확인
의사결정
실제 코드 수정은 CS가 다시 재인입되는 시점에 진행하기로 팀과 합의했다. 원인이 확정되고 최소 변경 대응안까지 나온 상태에서, 발생 빈도(3주간 54건, 그마저도 대부분 실제 서비스 장애로는 이어지지 않는 수준)와 회귀 위험을 함께 고려한 결정이었다. 문제를 이해하지 못해서 미루는 것과, 문제를 완전히 이해한 상태에서 우선순위를 조정하는 것은 전혀 다른 이야기라고 생각한다.
7. 기술적 의사결정에서 신경 쓴 것들
돌아보면 로그 몇 줄 추가하는 작업처럼 보이지만, 판단이 필요한 지점이 꽤 많았다.
① "고치기"보다 "보이게 하기"를 먼저 선택했다. 원인 후보가 5개 이상이고 재현이 안 되는 상태에서 추측 수정은 검증 불가능한 수정이다. 관측 가능성 확보를 선행 과제로 명확히 분리해서 팀에 설명했다.
② 로깅 API에 기존 Command/Factory 계층을 태우지 않았다. 이 프로젝트는 요청마다 Command → Factory를 거치는 구조였지만, 단순 로그 수집에 같은 계층을 씌우는 건 오버엔지니어링이라고 판단했다. 새 컨트롤러 대신 기존 AuthPopupController에 메서드 1개만 추가해서 변경 범위를 최소화했고, 같은 presentation 패키지에 둬서 기존 ApiExceptionHandler의 예외 처리를 그대로 물려받는 이점도 챙겼다.
③ 전역 window.onerror 훅은 도입하지 않았다. 전역 훅을 걸면 CAPTCHA와 무관한 오류까지 서버로 들어와서 신호 대 잡음비가 나빠진다. 명시적으로 식별한 실패 지점에서만 리포트하도록 범위를 제한했다.
④ 보안을 우선했다. 담당 서비스라 로그 자체가 위험이 될 수 있다. 진단에 필요한 최소 정보(requestId, captchaId, 상태 코드, readyState, User-Agent)만 보내고, CAPTCHA 정답·사용자 입력값은 절대 로그에 남기지 않았다.
⑤ 성공 로그는 2차 배포에서 뺐다. 처음엔 request-start, image-generated 같은 성공 경로 로그도 넣었는데, 트래픽이 큰 인증 서비스에서 전 요청을 로깅하면 로그량·성능 부담이 커진다고 판단해서 실패 로그만 남기도록 축소했다.
⑥ 로그를 "찾을 수 있게" 설계했다. 로그를 남기는 것과 운영팀이 실제로 찾을 수 있는 것은 다른 문제다. 모든 로그에 [CAPTCHA] 접두어와 phase= 키를 통일해서 grep 한 줄로 검색 가능하게 했고, 검색 키워드와 로그 경로를 별도 문서로 정리해서 전달했다.
grep -i "CAPTCHA" /app/wildfly/.../server.log* | grep "phase=validation-failed"
8. 성과
- 근본 원인 확정: Phase 1에서 구축한 로그를 근거로, 1년 가까이 재현 불가로 미해결이던 이슈의 원인이 프론트 HTML에 하드코딩된 정적 더미 이미지임을 규명했다. "iOS 단말 문제"로 여겨지던 장애를 플랫폼 공통의 구조적 결함으로 재정의하고, 최소 변경 대응안까지 도출했다.
- 관측 불가 영역 해소: 실패 기록이 전무했던 CAPTCHA 처리 흐름에 서버 4종 + 클라이언트 4종, 총 8개 실패 지점의 진단 로그를 확보했다. "로그 없이 추정만 반복"하던 상태를 데이터 기반으로 분석 가능한 상태로 바꿨다.
- 실제 원인 단서 포착: 운영 로그에서 실제 실패 사례가 잡혔다.
image-request-failed— iOS 인앱 WebView에서status=0, readyState=4로 네트워크 요청 자체가 실패image-request-aborted— 요청 중단 사례validation-failed(redisFound=true) — Redis 조회는 성공했지만 값이 불일치한 케이스request-failed— 요청 처리 단계 예외
- 재현 불가 이슈의 테스트 절차 확립: 운영 코드 무수정 원칙 하에 6가지 실패 시나리오의 재현 방법을 정립하고 문서화했다.
- 서버-클라이언트 로그 상관관계 확보:
requestId/captchaId기반으로, 브라우저에서 시작된 실패를 서버 로그 한 곳에서 추적할 수 있게 됐다. - 협업: 서비스운영파트·개발파트와 2단계 대응 계획을 합의하고, 스테이징 배포 요청부터 테스트, 로그 가이드 전달까지 주도했다.
9. 회고
이 이슈를 하면서 가장 크게 느낀 건, "버그를 고치는 능력"과 "고칠 수 있는 상태로 만드는 능력"은 다른 능력이라는 점이었다. 원인 후보가 여러 개고 재현조차 안 되는 상황에서는, 아무리 실력이 좋아도 "감"으로 고치는 수밖에 없다. 그 상태에서 한 발 물러나 "지금 우리에게 정말 필요한 건 수정이 아니라 관측이다"라고 팀에 설명하고 합의를 끌어낸 과정 자체가, 코드 몇 줄 짜는 것보다 오히려 더 어려운 일이었다.
그리고 실제로 Phase 2에서 확인했듯, 관측이 먼저였기 때문에 원인을 "감"이 아니라 데이터로 확정할 수 있었다. "iOS에서만 생기는 문제"라는 처음의 전제 자체가, 운영 로그를 쌓고 나서야 "노출 빈도가 높았을 뿐인 플랫폼 공통 결함"으로 뒤집혔다. 만약 Phase 1을 건너뛰고 "iOS WebView 이슈니까 iOS 쪽만 방어 코드를 넣자"는 식으로 대응했다면, 비iOS에서도 반복되는 37%의 사례는 여전히 미해결로 남았을 것이다. 첫 가설을 의심하지 않고 그대로 밀어붙였다면 얻지 못했을 결론이었다.
또 하나는 "무엇을 로그로 남길지"를 정하는 것도 결국 설계라는 점이다. 너무 적게 남기면 여전히 못 찾고, 너무 많이 남기면 성능·보안·검색성이 다 나빠진다. 로그 설계도 API 설계나 스키마 설계처럼 트레이드오프를 저울질하는 작업이라는 걸 이번에 제대로 체감했다.
마지막으로, 원인을 다 알아낸 뒤에도 "지금 바로 고칠지, 다음 CS 재인입 때 고칠지"를 팀과 함께 판단해야 했다. 문제를 이해하는 것과 그걸 언제 반영할지 우선순위를 정하는 것은 별개의 의사결정이라는 것도 이번에 배운 부분이다.
부록: 예상 질문 정리 (인터뷰 대비용 메모)
Q. 왜 바로 코드를 고치지 않고 로그부터 넣었나요?
원인 후보가 다수였고 재현이 안 되는 상황이었다. 추측으로 수정하면 고쳐졌는지 검증할 수단이 없다. 관측 가능성 확보가 선행되어야 수정의 효과를 측정할 수 있다고 판단했고, 실제로 로그 배포 후 확보한 데이터를 근거로 원인을 완전히 확정할 수 있었다.
Q. 재현이 안 되는 오류는 어떻게 테스트했나요?
운영 코드를 건드리지 않는다는 제약 하에 실패 유형별로 재현 수단을 나눴다. 서버 예외는 잘못된 파라미터로 유도했고, HTTP 오류는 Requestly로 응답을 Mock했다(DevTools 요청 차단은 status=0만 만들어서 부적합했다). 브라우저 API 실패는 DevTools Snippets에서 FileReader/XMLHttpRequest를 일시적으로 가로채서 재현했고, 프록시 오염 문제는 iframe에서 네이티브 구현체를 꺼내 우회했다. 인프라 장애(Redis)는 재현이 불가능하다고 판단해 테스트 코드로 대체했다.
Q. 로그 설계에서 신경 쓴 점은?
상관관계(requestId + captchaId로 서버·클라이언트 로그 연결), 검색성([CAPTCHA] 접두어 + phase= 키 통일), 보안(토큰·개인정보·CAPTCHA 정답 미기록, 서버 저장 시 마스킹), 비용(성공 로그는 2차 배포에서 제거) 네 가지를 같이 고려했다.
Q. 이 이슈는 완전히 해결됐나요?
솔직히 말하면 단계적으로 진행 중이다. Phase 1(진단 체계 구축)은 완료됐고 실제로 운영 로그에서 실패 사례를 확보했다. Phase 2에서는 근본 원인(하드코딩된 정적 더미 이미지)을 완전히 확정했고, 새로고침·재부팅으로도 회복되지 않는 이유까지 코드 근거로 설명할 수 있게 됐다. 최소 변경 대응안까지 마련했지만, 실제 코드 반영은 발생 빈도와 회귀 위험을 고려해 CS가 재인입되는 시점에 진행하기로 팀 차원에서 결정했다. 로그가 없었다면 계속 "iOS 단말 이슈"로 남아있었을 문제를 구조적 결함으로 규명한 것이 Phase 1과 2를 거치며 얻은 가장 큰 성과라고 생각한다.






























