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 이미지가 어떻게 화면에 뜨는지부터 코드를 역추적했다. 구조는 이랬다.

  1. 인증창 HTML은 <img> 태그의 초기값으로 디자인용 플레이스홀더 이미지를 넣어둔다
  2. 페이지 로드 시 /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를 거치며 얻은 가장 큰 성과라고 생각한다.

TL;DR — VM 2대가 동시에 재부팅된 후 WildFly 도메인 컨트롤러가 자동 기동되지 않았다. 증상은 슬레이브 서버의 반복 종료였지만, 근본 원인은 systemd unit 파일의 WantedBy를 wantedBy로 쓴 오타 한 글자였다. 원인 추적부터 복구까지 약 40분. 이 글은 그 40분의 기록과, 거기서 건진 교훈에 대한 이야기다.

배경: WildFly Domain Mode

담당하는 인증 서비스의 개발 환경은 WildFly를 도메인 모드로 운영한다. 서버는 두 대다.

  • 도메인 컨트롤러(마스터, 10.0.0.10) — 원본 WAR 배포본과 서버 그룹 설정을 보관하고, 슬레이브에 설정을 배포한다. 관리 포트는 9990.
  • 호스트 컨트롤러(슬레이브, 10.0.0.11) — 마스터로부터 설정과 WAR를 받아 실제 서비스 JVM 인스턴스를 기동한다. 실제 코드가 돌아가는 서버다.

여러 모듈의 JVM 인스턴스를 서버 그룹 단위로 묶어 관리할 수 있다는 게 도메인 모드의 장점이지만, 뒤집어 말하면 마스터가 죽으면 슬레이브는 기동조차 못 하는 구조다. 이 사실이 이번 장애의 복선이 된다.

장애: 표준창이 열리지 않는다

어느 오후, 개발망의 인증 표준창이 HTTP 503을 반환하기 시작했다. 슬레이브 서버에 들어가 보니 이상한 패턴이 보였다. WildFly 프로세스가 기동되고 약 33초 만에 종료되기를 반복하고 있었다. 재시작해도 똑같았다.

먼저 서버에 무슨 일이 있었는지부터 확인했다.

last -x reboot shutdown | head -5
reboot   system boot  3.10.0-1160.el7. Wed ... 15:16
reboot   system boot  3.10.0-1160.el7. (약 4개월 전)
reboot   system boot  3.10.0-1160.el7. (약 9개월 전)

마스터와 슬레이브 두 VM이 같은 시각에 재부팅되어 있었다. 재부팅 이력을 거슬러 올라가 보니 수개월 간격으로 같은 패턴이 반복된 흔적도 있었다. 즉, 이건 오늘 처음 생긴 문제가 아니라 재부팅될 때마다 터질 수 있는 문제가 잠복해 있다가 이번에 드러난 것이었다.

추적: 증상이 있는 곳에 원인이 있는 건 아니다

반복 종료하는 건 슬레이브니까 처음엔 슬레이브 설정을 의심했다. 그런데 로그를 열어보니 이야기가 달랐다.

INFO  [org.jboss.as.host.controller] WFLYHC0148: Connecting to master host controller remote://10.0.0.10:9990
ERROR [org.jboss.as.host.controller] (Controller Boot Thread) WFLYHC0052: Could not connect to master. Aborting.
      Error was: java.net.ConnectException: WFLYPRT0053: Could not connect to remote://10.0.0.10:9990. The connection failed
INFO  [org.jboss.as.process] WFLYPC0009: Process terminated, exit code 99

슬레이브는 자기 잘못으로 죽는 게 아니었다. 마스터에 연결이 안 돼서 30초 동안 11회 재시도한 뒤 스스로 종료(exit code 99)하고 있었다. 아까 관찰한 "33초 만에 종료"는 재시도 30초 + 종료 처리 3초였던 것이다.

시선을 마스터로 옮겼다. 혹시 네트워크 문제인가 싶어 슬레이브에서 확인해 보았다.

timeout 3 bash -c 'echo > /dev/tcp/10.0.0.10/9990' && echo OPEN || echo CLOSED
# → CLOSED (connection refused)
ping -c 2 10.0.0.10
# → 정상 응답

ping은 되는데 포트는 refused. 즉 장비는 살아 있고 WildFly 프로세스만 안 떠 있는 상태다. 네트워크 단절 가설은 여기서 배제됐다. 재부팅 후 슬레이브는 자동 기동됐는데 왜 마스터는 안 떴을까?

근본 원인: 대문자 W 한 글자

마스터의 systemd 설정을 확인했다.

systemctl is-enabled wildfly
# → static
grep -i wantedby /etc/systemd/system/wildfly.service
# → wantedBy=multi-user.target

WantedBy가 아니라 wantedBy. 소문자 w 하나가 문제의 전부였다.

systemd는 unit 파일의 키 이름을 대소문자까지 정확히 구분한다. 그리고 모르는 키는 에러를 내는 대신 조용히 무시한다. [Install] 섹션에 유효한 WantedBy가 없으니 systemctl enable을 해도 multi-user.target 아래에 심볼릭 링크가 생기지 않고, 상태는 static으로 표시되며, 부팅 시 자동 시작 대상에서 조용히 빠진다.

무서운 건 이 오타가 평소엔 아무 증상도 만들지 않는다는 점이다. 수동으로 systemctl start를 하면 멀셟하게 잘 뚜다. 재부팅이라는 조건이 충족될 때만 발현되는 잠복 결함이었고, 그래서 수개월 간 아무도 모르고 있었다.

복구: 순서가 전부다

복구 자체는 단순하다. 다만 도메인 모드에서는 기동 순서가 중요하다. 마스터가 없는 채로 슬레이브를 먼저 올리면 33초 뒤 또 죽는다.

  1. 마스터 기동 → 관리 포트 9990이 열릴 때까지 대기 (실측 약 20~30초)
  2. 슬레이브 기동
  3. 서비스 JVM 인스턴스 기동 확인

여기서 한 가지를 덩붙였다. 슬레이브 기동이 성공했는지를 언제 판단할 수 있을까? 연결 실패 시 33초 만에 종료된다는 실측치가 있으니, 여유를 더해 "기동 후 40초가 지나도 active (running)이면 성공"이라는 판정 기준을 런북에 명시했다. "좀 기다려보고 괜찮으면 된 거에요" 같은 감으로는 다음 사람이 복구할 수 없다. 런북의 판정 기준은 숫자여야 한다.

장애 인지부터 서비스 정상화까지 약 40분이 걸렸다.

남은 숙제

  • 오타 수정 — unit 파일 수정 권한이 운영 주체와 분리되어 있어 담당 부서에 수정을 요청한 상태다. 수정 전까지는 재부팅 시 동일 장애가 재발한다. 그래서 런북이 더 중요해졌다.
  • VM 2대 동시 재부팅의 원인 — 수개월 간격으로 반복된 패턴이라 하이퍼바이저나 전원 이벤트 쪽을 의심하고 있지만 아직 확인되지 않았다.
  • 헬스체크 알림 — 이번에는 사용자가 503을 보고 나서야 인지했다. 관리 포트 헬스체크 기반 알림을 붙이면 재부팅 직후 미기동을 먼저 탐지할 수 있다.

배운 것

  1. 증상이 보이는 서버가 범인이 아닐 수 있다. 반복 종료는 슬레이브에서 일어났지만 원인은 마스터에 있었다. 분산 구조에서는 "이 서버가 의존하는 것"까지 의심 범위에 넣어야 한다.
  2. 조용히 실패하는 설정이 제일 무석다. systemd는 모르는 키를 에러 없이 무시한다. 설정이 "적용된 것 같은 상태"와 "실제 적용된 상태"는 다르다. systemctl is-enabled 같은 명령으로 적용 상태를 검증하는 습관이 필요하다.
  3. 재부팅은 가장 싸고 확실한 카오스 테스트다. 재부팅 후 서비스가 스스로 돌아오는지는 평소엔 절대 검증되지 않는 경로다. 이번 잠복 결함은 그 경로에 수개월간 숨어 있었다.

본 글의 IP·시각 등은 예시로 대체하였습니다.

문제

https://school.programmers.co.kr/learn/courses/30/lessons/42842

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

 


 

정답 코드

첫 번째 제출
def solution(brown, yellow):
    answer = []
    yellow_wh = (brown-4)/2
    i=1
    while i <=  yellow_wh//2:
        if i * (yellow_wh-i) == yellow:
            answer.append((yellow_wh-i)+2)
            answer.append(i+2)
            break
        i=i+1
    return answer
  • yellow_wh: yellow의 가로 길이+세로 길이
    1. brown = (yellow 가로 길이+세로 길이) * 2 + 4 이므로 중간 합을 기준으로 식을 변형하면 위 코드처럼 됨
  • yellow_wh//2 까지 i 를 1씩 더하면서 반복문 돌리기: yellow = i + (yellow_wh-i) 조건에 맞는 쌍을 찾기 위함
  • yellow 가로>=세로 길이 이므로 애초에 큰 숫자(yellow_wh - i)를 먼저 append 하면 정답 나옴 

 

두 번째 제출 (다른 사람 풀이 참고)
def solution(brown, yellow):
    yellow_wh = (brown-4)/2
    i=1
    while i <=  yellow_wh//2:
        if i * (yellow_wh-i) == yellow:
            return [(yellow_wh-i)+2, i+2]
        i=i+1
  • answer 배열에 append 하지 않고, 바로 배열 형태로 return 하면 코드가 더욱 짧아짐
  •  

문제

https://school.programmers.co.kr/learn/courses/30/lessons/132201

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

 


 

정답 코드

-- COALESCE() 함수 사용
SELECT PT_NAME, PT_NO, GEND_CD, AGE, COALESCE(TLNO, 'NONE') AS TLNO
FROM patient
WHERE AGE <=12 AND GEND_CD = 'W' 
ORDER BY AGE DESC, PT_NAME ASC;

-- CASE 문 사용
SELECT PT_NAME, PT_NO, GEND_CD, AGE, 
    CASE 
        WHEN TLNO IS NULL THEN 'NONE'
        ELSE TLNO
    END AS TLNO
FROM patient
WHERE AGE <=12 AND GEND_CD = 'W' 
ORDER BY AGE DESC, PT_NAME ASC;

 


 

배운 점

 

1. COALESCE() 함수

  • '합치다' 라는 의미 
  • 여러 개의 인수를 받아 첫 번째로 NULL이 아닌 인수를 반환
    = 따라서 COALESCE(TLNO, 'NONE') 는 NULL 을 'NONE' 을 반환하고, 그 외는 반환 
  • IS NULL 을 사용 & 조건문 일 때, 간단하게 표현할 수 있는 코드

 

2. CASE 문

  • 문법
CASE 
    WHEN TLNO IS NULL THEN 'NONE'
    ELSE TLNO
END AS TLNO
  • IF ~ ELSE 문을 SQL 식으로 직관적으로 표현한 것 
  • 꼭 IS NULL 이 아니더라도 다양한 상황에서 활용 가능 
  • WHERE 문이 아니라 SELECT 문에서 사용해야 함을 주의

'Algorithm & SQL > Oracle' 카테고리의 다른 글

[SELECT] 타입이 DATE 일 때 처리 방법 | 정렬  (0) 2024.01.09

문제

https://school.programmers.co.kr/learn/courses/30/lessons/144853

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

 


 

정답 코드

SELECT BOOK_ID, TO_CHAR(PUBLISHED_DATE, 'YYYY-MM-DD')
FROM book
WHERE CATEGORY = '인문' AND TO_CHAR(PUBLISHED_DATE, 'YYYY') = '2021'
ORDER BY PUBLISHED_DATE ASC;

 


 

배운 점

 

1. 타입이 DATE 일 때 처리 방법

  • 타입이 DATE = CHAR로 타입을 바꾸는 척 해야 처리 가능! 
    • 출력되는 형식 변경: TO_CHAR(PUBLISHED_DATE, 'YYYY-MM-DD')
    • 값 중 일부 글자가 특정 글자에 해당하는 값만 필터링: TO_CHAR(PUBLISHED_DATE, 'YYYY') = '2021'

 

2. 정렬

  • ORDER BY 까먹지 말자. 오름차순은 ASC, 내림차순은 DESC
    • ORDER (X), ARRANGE(X), SORT(X) 

'Algorithm & SQL > Oracle' 카테고리의 다른 글

[Select] COALESCE() 함수 | CASE 문  (1) 2024.01.09

문제 및 레퍼런스

https://school.programmers.co.kr/learn/courses/30/lessons/42862

https://namhandong.tistory.com/152

https://iambeginnerdeveloper.tistory.com/107


 

정답 코드

def solution(n, lost, reserve):
    
    answer = 0  #1번
    new_reserve = set(reserve)-set(lost)  #2번
    new_lost = set(lost)-set(reserve) 
    
    for i in new_reserve:  #2번
        if i-1 in new_lost: 
            new_lost.remove(i-1)  #3번
        elif i+1 in new_lost: 
            new_lost.remove(i+1) 
            
    answer = n - len(new_lost)  #1번
    
    return answer

 


 

배운 점

0. 변수 설정

  • 문풀 전: 내가 정한 규칙에 따라 입력값을 항상 재설정 
    • 문제점: 레퍼런스를 보게 될 경우, 변수 달라 항상 고생. 또한 대문자로 변수 썼으므로 고속으로 코딩하기에 불편
  • 결론: 프로그래머스처럼 입력값 변수가 정해져 있는 경우 그냥 그거 쓰자 

 

1. 리턴 값 초기화의 중요성 

  • 문풀 전: 리턴 값을 초기화 하지 않고, 바로 answer = n - len(new_lost) 식으로 값 할당 
    • 문제점: 코드를 맞게 작성하였는데도, 심지어 정답 코드들과 초기화 부분을 제외하고 코드가 동일한데도  계속 일부 3-5개 케이스를 통과하지 못함 
    • 원인: 이전에 문풀 했을 때 할당되었던 answer이 누적될 수 있음
  • 결론: 항상 리턴 값은 초기화하고 시작하는 것을 습관화하자

 

2. 배열 or 집합 값의 직접 탐색 

  1. 직접 탐색
    • 문풀 전: 항상 for i in range(len(lost)) 식으로 탐색하고 싶은 배열의 길이의 범위를 지정하여 탐색
      • 문제점: lost[i] 식으로 한 번 더 그 값을 지정해주어야 하는 번거로움 발생. 또한 이번 문제처럼 lost 도 탐색해야 하는 경우 무조건 이중 for문 사용하여 배열 값에 접근해야함 
    • 결론: 배열 값에 바로 접근하자. for i in lost
  2. 집합 값 탐색
    • 문풀 전: 집합을 생성하고 집합을 탐색하는 것을 몰랐음
      • 문제점: 이번 문제처럼 공통 원소를 제거하는 set(lost) - set(reserve) 같은 코드를 사용할 수 없음
    • 결론: 배열, 집합 양자 왔다 갔다 하자
      • 배열을 집합으로 만들기: set(lost) ( 결과: {2, 4} )
      • 차집합 (= 공통 원소 제거): set(lost) - set(reserve)
      • 집합도 len(lost), lost.remove(i) 처럼 배열에서 사용하는 메서드 사용 가능

 

3. remove() 메서드

  • 문풀 전: 배열에서 원소 제거할 때 무조건 pop() 메서드 사용
    • 문제점: 메서드 특성 상 스택큐 문제에서 뒤의 원소부터 제거한다는 느낌이 강함 (실제 특정 값만 제거할 수 있는데도). 따라서 부담스러움
  • 결론
    • remove() 메서드를 사용하자.
    • 다만 배열 or 집합 순회하면서 remove() 메서드를 사용할 경우, 순회하는 대상이 달라져 원하는 결과가 나오지 않을 가능성 있음 
      -> lost[:] 식으로 리스트의 전체를 슬라이싱하는 기법 사용하자. 이를 통해 리스트의 모든 요소를 복사하여 새로운 리스트를 생성하고, 이를 순회하면서 각 요소에 접근 가능 

개요

  • 이번부터 쓰는 글은 Tensorflow-models 중 hand-pose-detection 을 자사 제품 내에서 구현한 후, 그와 관련된 AI 이론을 정리한 내용임
  • 이번 글 출처: 학부 강의 내용, 필기, 구글링, 필요하다면 이하에서 출처 명시했음

 


 

1. 서

 

의미

  • hidden layer가 2개 이상인 이유
    : 풀고자 하는 문제를 세부 문제로 나누고, 그 문제를 다시 세세부 문제로 나누기 때문
    (ex) 사람의 얼굴인가 → 오른쪽 위에 눈이 있는가 → 위에 눈썹이 있는가, 가운데에 눈동자가 있는가, 아래에 속눈썹이 있는가

 

Support vector machine과의 비교
  • SVM (Support Vector Machine)
    • 머신러닝 분야 중 하나로 ①패턴 인식, 자료 분석을 위한 지도 학습 모델 분류와 ②회귀 분석을 위해 사용됨
    • SVM의 패턴 인식 순서
      1. 2개 카테고리 중 어느 하나에 속한 데이터 집합이 주어졌을 때,
      2. 그것을 바탕으로 비확률적 이진 선형분류모델을 만듦. 이 모델은 새로운 데이터가 둘 중 어떤 카테고리에 속하는지 판단함
      3. 해당 모델은 데이터가 분포된 공간에서 선을 그어 경계를 표현함. 그리고 SVM 알고리즘은 그 중 가장 큰 폭을 가진 경계를 찾음
    • 예시 : 이진 분류를 한다면 밑 사진처럼 직선(linear decision boundary)을 그어 구분하는 것
      -> 출처: https://sanghyu.tistory.com/7

  • 비교
  SVM (D)NN
공통점 - hidden feature 를 사용
- hidden space 위에서 linear decision boundary를 이용하여 분류
 
차이점
: 학습 대상
feature mapping은 고정,
linear decision boundary만 학습
linear decision boundary + feature mapping 도 함께 학습

 


 

2. 모형 

 

모수의 학습 방법 
: 목적 함수 및 함수 구조를 기반으로 최적화 알고리즘 설명
  1. 기울기 강화 알고리즘 (Gradient Descent algorithm; GD)
  • 특정 목적 함수 L(θ)를 최소화하는 θ을 한 번에 찾기 힘든 경우에 사용하는 대표적인 반복 알고리즘.
  • 목적 함수 의미

  • GD의 아이디어

  • 구체적인 알고리즘 내용

 


 

   2. 역전파 알고리즘 (Back propagation algorithm)

  • 의미
    • 미분값이 위에서 아래로 계산되어짐 (Back propagation)
    • DNN 모수들의 (b, w) gradient를 구하는 알고리즘
    • 목적 함수 L(θ)을 최소화하기 위해 gradient descent algorithm을 사용한 결과,
      NN의 특수한 형태 때문에 ∂L(θ) / ∂θ(l+1)의 계산에 필요한 값을 알고 있으면, ∂L(θ) / ∂θ(l)가 자동적으로 계산됨.
      (여기서 θ(l)은 l층에서의 모수)
  • 수식으로 의미 이해

  • 예제
    • 문제: Calculate gradient 에 있는 수식을 도출하는게 목적!
     

 


 

  3. Stochastic gradient descent method (SGD)

  • 기본 용어 정리
    • Batch : 학습 데이터 전체
    • Mini-batch : 학습 데이터의 일부, 즉 batch에서 sampling 한 것
    • Epoch : 반복적인 학습 알고리즘을 사용할 때 모든 학습 데이터를 한 번 씩 사용하는 것을 의미
      (ex: 10000개의 학습 데이터가 존재하고, 매번 50개의 데이터(i.e. 50개의 mini-batch)를 이용하여 모수를 학습할 때, 이 알고리즘을 200번을 반복하면 1 epoch, 400번을 반복하면 2 epochs라고 함.)
      → 즉 1 에폭당 학습 데이터 10000개를 한 바퀴 도는 느낌
  • 의미
    • Stochastic gredient descent algorithm을 사용한 method
    • 아이디어 및 업데이트 방법

  • 장점
    • 계산량↓: 업데이트 할 때 모든 batch를 사용할 때보다 적은 계산을 필요로 함
    • 정확성↑: 수렴성이 이론적으로 보장 (Bottou, 1998 and Murata, 1998)
  • gredient descent algorithm과의 차이
    • 핵심
      • Gradient Descent (GD): mini batch 사용하지 않고 학습 → 에폭이 1인 SGD 느낌
      • Stochastic Gradient Descent (SGD): mini batch 를 샘플링하여 학습 → 파생적으로 에폭의 개념 나오게 됨
    • 수식
          - GD

                  - SGD 

  •     그림
                 

 


 

2. 학습률 (learning rate) 의 선택

의미
  • 목적 함수의 최댓값 또는 최솟값을 향해 이동하면서 각 반복에서 단계 크기를 결정하는 스칼라
  • 학습률은 머신러닝 및 통계학에서 사용되는 용어
  • 최적화 알고리즘의 조정 매개변수
  • εt∶ t 시점의 학습률

 

특징
  • 학습률이 지나치게 크거나 작으면 좋은 추정값을 얻을 수 없음. 목적 함수가 목적지로 발산 or 도달하지 못하기 때문임
  • 따라서 학습이 진행될수록 비교적 큰 학습률 → t가 증가함에 따라 학습률을 줄여나가는 것이 바람직함
  • 이론적으로는 εt ∝1/t이면 (비례하다면), 역전파 알고리즘을 사용하였을 때 손실 함수가 국소적인 최소값으로 수렴한다는 사실이 알려져 있음. (Bottou et al., 2018)

 


 

3. 한계

Vanishing gradients problem
  • 모수에 대한 gradient 값이 아래층으로 내려갈수록 작아지는 현상.
    = 즉, hidden layer 쌓을수록 추정 값 정확도↓ (=성능↓)
  • 그 결과 아래층의 모수가 초기 값과 크게 다르지 않은 값을 가진 채 학습이 종료.
    = 즉, 안 좋은 모수를 갖는 추정 값으로 수렴함
  • 따라서, 때때로 DNN이 NN 보다 성능이 나쁜 경우 발생함
  • 원인: 역전파 알고리즘이 좋지 않은 추정값을 제공 (bad local minima).

 

Running time
  • 일반적으로 DNN은 1개의 hidden layer를 가지는 NN보다 더 많은 수의 모수를 필요로 함
  • 더 많은 수의 모수를 추정하기 위해 보다 더 많은 시간 소요됨

 

보완책: 사전 학습 (Pre-training)
  • 2006년 G.E.Hinton 교수가 처음으로 제안: Hinton and Salakhutdinov (2006), Hinton et al (2006)
  • 입력 변수만 사용하여 모수를 추정하는 기법 (Pre-training) → 추청된 모수를 학습 시 초기값으로 사용 (Training)
    → 이 방법이 이전 방법과 다른 이유: 기존 training은 입력 값, 출력 값을 모두 이용하여 학습
  • 효과: 역전파 알고리즘의 한계 해결
    • 결과가 해의 초기값에 의존
      = 즉 아래층에 있는 모수들은 초기 값과 크게 다르지 않음
    • 그러나 좋은 해의 초기값을 찾기 어려움
  • 예시: Erhan et al., 2010 and Larochelle et al., 2007
    • 왼쪽 그래프: hidden layer 1층, 오른쪽 그래프: hidden layer 4층
    • hidden layer의 개수가 늘어날수록 pre-training 한 layer의 효과가 더 커짐
      = 즉 데이터의 test error 가 작아짐

 


 

4. Advanced of deep learning

하드웨어의 발전: GPU의 사용
  • 딥러닝에서 필요한 계산들을 대부분 병렬처리 가능
  • 따라서 CPU로 계산할 때보다 훨씬 빠른 계산 가능
    + 훨씬 많은 hidden layer와 모수들을 가지는 복잡한 모형 설계하여 활용 가능

 

새로운 활성 함수의 개발: ReLU (Rectified Linear Units; Nair and Hinton (2010))
  • 우수성
    • 기존의 활성함수 (예: sigmoid or tanh)는 그 특성상 모수를 추정하는데 어려움
      (어려움: vanishing gradient)
    • Piece-wise 선형 함수를 활성함수로 사용
      → vanishing gradient 문제 해결, 속도 향상, 사전 학습 불필요해짐
  • sigmoid 함수와 비교

  • 이후에 ReLU를 응용하여 LeakyReLU (Mass et al., 2013), PReLU (He et al., 2015), ELU (Clevert et al., 2015) 등 많은 활성 함수가 제안됨.

 

Regularization method 개발
  1. Drop out
    • 매번 역전파를 할 때마다 노드의 절반을 끄고 (=drop out) 학습
      → 최종 결과를 낼 떄에는 각 층 결과값에 1/2를 곱하는 방법
    • 효과: 노드 간 상관관계↓, 독립성↑
    • 원인: fully connected neural network을 학습할 때, 같은 층의 노드간에 높은 상관관계 발생
      → 입력값이 조금만 바뀌어도 모든 네트워크에 큰 변화가 생길 수 있음
  2. Batch normalization
    • 매번 mini batch를 이용하여 학습할 떄마다 노드들을 정규화함
    • 효과: 적은 에폭 학습을 하여도 기존보다 좋은 성능 (ex. 보다 높은 accuracy)를 얻을 수 있게 됨
    • 원인: 은닉 노드 각각의 분포 및 scale이 상이 → 성능이 좋지 않음
  3. Data augmentation (데이터 증강)
    • Random crop, RGB perturbation, image reflection 등으로 학습 데이터를 대량 확보하는 것
    • 심지어 Sequential data에서도 data의 순서를 바꾸어 학습 데이터를 늘림.
    • 효과: 모델이 정확한 예측을 하기 위한 재료가 다수 확보됨
      → 보다 정확한 예측 가능
    • 원인: 학습 대상 데이터의 양이 지나치게 적은 경우, 학습이 제대로 되기 어려움
  4. Residual learning architecture (잔차 학습 아키텍처)
    • Networks에서 이웃하지 않은 layer 사이에도 connection을 추가
    • 효과: Vanishing problem 해결

 


 

5. Advanced of learning algorithms

SGD 방법의 단점
  1. 학습률 εk의 scale에 따라 성능이 크게 좌우됨.
    = 손실 함수들마다 좋은 성능을 보장하는 학습률 scale이 달라 선택이 어려움

  2. 이전 gradient 정보들은 무시하고 현 시점의 gradient만을 사용하여 업데이트
      → 수렴 속도가 느릴 수 있음

  3. 시점 k에서 모든 gradient에 미리 정해 놓은 학습률 εk를 곱해주어 업데이트.
      → 손실함수가 특정 모수들의 방향에 대해서 민감하게 변할 경우에 수렴하지 않을 수 있음.
      → 안장점*에 빠질 가능성이 높음.

      * 안장점 (Saddle point)
      - 다변수 함수에서 특정한 형태의 극값.
        = 한 쪽 방향에서 보면 극댓값, 다른 쪽 방향에서 보면 극솟값
      - 예시 : 이차항이 마이너스인 이차함수
        -> 안장점 (이자 극댓값)에서 한 방향에서는 곡면이 상승, 다른 방향에서는 하강

        - 머신러닝에서의 안장점 
          > 신경망 모형(NN)을 학습시킬 때 최적화 알고리즘이 안장점에 도달한 경우,
            여기서 멈추지 않고 전역 최소점 (global minimum) 또는 국소 최소점 (local minimum) 에 도달할 수 있도록 설계해야함
            ( ∵ 최적화 알고리즘의 목표 = 목적함수(손실함수) 최소화)
          > 따라서 이것이 머신러닝 또는 딥러닝의 성능을 결정지음

 

SGD with momentum
  • 현 시점의 gradient 업데이트 방향이 이전 시점의 gradient 정보에 영향을 받는 방법
    = 즉 현재 및 과거 시점의 gradient 크기에(g) 영향을 받아서 모수들(θ) 각각의 학습률이(ε) 결정됨
  • Goodfellow, I., et al. (2016) Deep Learning : 모멘텀을 수식으로 증명

  • 이는 “이전 gradient 정보들은 무시하고 현 시점의 gradient만을 사용하여 업데이트” 하는 SGD의 1번 단점을 보완한 것

 

adam
  • ①accumulated squared gradient를 weighted average로 업데이트 함 (두 번째 빨간 박스)
    + ②현 시점의 gradient 업데이트 방향이 이전 시점의 gradient 정보에 영향을 받아 결정됨 (첫 번째 빨간 박스)
    = ①RMSProp* + ②momentum
    • RMSProp (Tieleman and Hinton, 2012)
      • AdaGrad*와는 다르게 accumulated squared gradient를 누적 합으로 업데이트 하지 않고, weighted average로 업데이트 함.
        = 즉 AdaGrad의 단점 보완
      • Goodfellow, I., et al. (2016) Deep Learning : RMSProp을 수식으로 증명
        : 빨간 박스 수식 = weighted average

               * AdaGrad
                 : 현재 및 과거 시점의 gradient의 크기에 영향을 받아서 (첫 번째 빨간 박스)
                → 모수들 각각의 학습률이 결정됨 ( i.e. adaptive learning rate) (두 번째 빨간 박스)

                  - 학습률이 미리 정해져 있지 않고, 합리적인 방법을 통해 결정된다는 점에서 SGD의 단점 중 1번, 3번이 해결됨 
                  - Goodfellow, I., et al. (2016) Deep Learning : AdaGrad을 수식으로 증명
                  - 한계 - 지속적인 업데이트가 진행됨에 따라 accumulated squared gradient의 r의 값이 너무 커져서 업데이트가 몇 차례 진행 되지 않아 모수의 추정값이 도중에 수렴해 버림

  • Goodfellow, I., et al. (2016) Deep Learning : Adam을 수식으로 증명
    • 현재까지 나온 최적화 방법론들 중 일반적으로 빠르게 좋은 추정 값을 찾아 주는 알고리즘.
    • Convex loss function 뿐만 아니라 non-convex loss function (예: 딥러닝 모델에서의 손실함수) 에서 매우 좋은 성능을 제공.

개요

  • handpose, face, pose - detection의 model type은 모두 CNN
  • = 즉 모델의 전반적인 구조, 학습하여 결과를 도출하는 방식이 모두 CNN을 근본으로 하고, 모델 아키텍처도 advanced CNN 의 양상을 띄고 있음
  • 따라서 이번 글에서는 CNN이 포함된 큰 카테고리인 DNN을 설명하기 위해 NN (Neural Network)을 먼저 설명하고, 다음 글에서 DNN에 대해 설명할 것임
  • 구체적인 CNN에 대해서는 face-detection 시리즈에서 설명할 예정임.

 


 

1. 의미

  • 인공 신경망 모형 (Artificial Neural Network): 일반적으로 artificial은 떼고 말함
  • 뇌 구조 (신경망)을 모방하여 만든 수학적 모형
    = 즉 인간의 뇌가 문제를 해결하는 방식과 유사하게 구현
  • 머신러닝의 한 분야

 


 

2. 함수 구조

수식

그림

 

용어 설명
  • 활성 함수 (activation function) 
    • 인공 신경망에서 입력 신호의 가중치 합을 출력 신호로 변환하는 함수 
    • 특징: 비선형성 (non-linearity) -> 입력에 대한 비선형 변환을 통해 신경망이 다양한 종류의 복잡한 함수를 학습
    • 활성 함수의 형태

  • 은닉층 (hidden layer)
    : 입력층 (input layer)과 출력층 (output layer) 사이에 위치하는 모든 층
  • 완전 연결 계층 (fully connected layer)
    : 한 층(layer)의 모든 뉴런이 그 다음 층(layer)의 모든 뉴런과 연결된 상태

 


 

3. 이론적 성질

논문 원문
  • Universal approximator (Cybenko, 1989 and Homik et al, 1989)
  • Theorem 2 of Cybenko (1989)

 

해석

N을 충분히 키울 수 있으면 (여기서는 무한대로 표현됨), 모든 계층의 모든 x에 대하여 f(x)는 실제 함수의 합인 G(x)와 정밀도 ϵ (0보다 큰 아주 작은 값)만큼 가깝게 표현 가능
<-> N을 충분히 키울 수 있으면, Neural network를 표현한 f(x)는 다양한 함수 형태로 표현될 수 있음 
<-> t가 +-무한대로 가면, 활성함수 (여기서는 sigmoid)가 +-1로 감 

개요

 


 

1. 모델 설명 | Used to

모델 설명
  • 모델명: MediaPipe BlazePose GHUM 3D
  • 구체적인 설명
Lite (3MB size), Full (6 MB size) and Heavy (26 MB size) models, to estimate the full 3D body pose of an individual in videos captured by a smartphone or web camera.
Optimized for on-device, real-time fitness applications: Lite model runs ~44 FPS on a CPU via XNNPack TFLite and ~49 FPS via TFLite GPU on a Pixel 3. Full model runs ~18 FPS on a CPU via XNNPack TFLite and ~40 FPS via TFLite GPU on a Pixel 3.
Heavy model runs ~4FPS on a CPU via XNNPack TFLite and ~19 FPS via TFLite GPU on a Pixel 3.

 

Used to
Applications
3D full body pose estimation for single-person videos on mobile, desktop and in browser.

Domain & Users
 - Augmented reality
 - 3D Pose and gesture recognition
 - Fitness and repetition counting
 - 3D pose measurements (angles / distances)

Out-of-scope applications
Multiple people in an image.
 - People too far away from the camera (e.g. further than 14 feet/4 meters)
 - Head is not visible Applications requiring metric accurate depth
 - Any form of surveillance or identity recognition is explicitly out of scope and not enabled by this technology

 


 

2. Model type 및 Model architecture

모델 카드 인용
Convolutional Neural Network: MobileNetV2-like with customized blocks for real-time performance.

 

Model type: CNN

 

Model architecture : MobileNetV2

 


 

3. input, output

Inputs
Regions in the video frames where a person has been detected.
Represented as a 256x256x3 array with aligned human full body part, centered by mid-hip in vertical body pose and rotation distortion of (-10, 10) . Channels order: RGB with values in [0.0, 1.0].

Output(s)
33x5 array corresponding to (x, y, z, visibility, presence).
 - X, Y coordinates are local to the region of interest and range from [0.0, 255.0].
 - Z coordinate is measured in "image pixels" like the X and Y coordinates and represents the distance relative to the plane of the subject's hips, which is the origin of the Z axis. Negative values are between the hips and the camera; positive values are behind the hips.
Z coordinate scale is similar with X, Y scales but has different nature as obtained not via human annotation, by fitting synthetic data (GHUM model) to the 2D annotation. Note, that Z is not metric but up to scale.
 - Visibility is in the range of [min_float, max_float] and after user-applied sigmoid denotes the probability that a keypoint is located within the frame and not occluded by another bigger body part or another object.
 - Presence is in the range of [min_float, max_float] and after user-applied sigmoid denotes the probability that a keypoint is located within the frame.
[
  {
    score: 0.8,
    keypoints: [
      {x: 230, y: 220, score: 0.9, score: 0.99, name: "nose"},
      {x: 212, y: 190, score: 0.8, score: 0.91, name: "left_eye"},
      ...
    ],
    keypoints3D: [
      {x: 0.65, y: 0.11, z: 0.05, score: 0.99, name: "nose"},
      ...
    ],
    segmentation: {
      maskValueToLabel: (maskValue: number) => { return 'person' },
      mask: {
        toCanvasImageSource(): ...
        toImageData(): ...
        toTensor(): ...
        getUnderlyingType(): ...
      }
    }
  }
]

 


 

4. evaluation metric

pose의 metric : PDJ
  • average Percentage of Detected Joints: 감지된 부위의 평균 비율 오차
We consider a keypoint to be correctly detected if predicted visibility for it matches ground truth and the absolute 2D Euclidean error between the reference and target keypoint normalized by the 2D torso diameter projection
is smaller than 20%.
This value was determined during development as the maximum value that does not degrade accuracy in classifying pose / asana based solely on the key points without perceiving the original RGB image. The model is providing 3D coordinates, but the z-coordinate is obtained from synthetic data, so for a fair comparison with human annotations, only 2D coordinates are employed.

 

evaluation results
  • geographical
Evaluation across 14 regions of heavy, full and lite models on smartphone back-facing camera photos dataset results an average performance of 94.2% +/- 1.3% stdev with a range of [91.4%, 96.2%] across regions for the heavy model, an average performance of 91.8% +/- 1.4% stdev with a range of [89.2%, 94.0%] across regions for the full model and an average performance of 87.0% +/-2.0% stdev with a range of [83.2%, 89.7%] across regions for the lite model.
Comparison with our fairness criteria yields a maximum discrepancy between average and worst performing regions of 4.8% for the heavy, 4.8% for the full and 6.5% for the light model.
  • skin tone and gender
Evaluation on smartphone back-facing camera photos dataset results in an average performance of 93.6% with a range of [89.3%, 95.0%] across all skin tones for the heavy model, an average performance of 91.1% with a range of [85.9%, 92.9%] across all skin tones for the full model and an average performance of 86.4% with a range of [80.5%, 87.8%] across regions for the lite model. The maximum discrepancy between worst and best performing categories is 5.7% for the heavy model, 7.0% for the full model and 7.3% for the lite model.
Evaluation across gender yields an average performance of 94.8% with a range of [94.2%, 95.3%] for the heavy model, an average performance of 92.3% with a range of [91.2%, 93.4%] for the full model, and an average of 83.7% with a range of [86.0%, 89.1%] for the lite model.
The maximum discrepancy is 1.1% for the heavy model, 2.2% for the full model and 3.1% for the lite model.

개요

 


 

1. 모델 설명 | Used to

모델 설명
  • 모델명: MediaPipe Attention Mesh
  • 구체적인 설명
A lightweight model for real-time prediction of 3D facial surface landmarks from video captured by a front-facing smartphone camera.
Designed for applications like AR makeup, eye tracking and AR puppeteering that rely on highly accurate landmarks for eye (+ iris) and lips regions predicted by the model. Runs at over 50 FPS on Pixel 2 phone.

 

Used to
  • 특정 신원에 대한 얼굴 또는 얼굴 특징에 대해서는 저장할 수 없음에 주의
Applications
 - Detection of human facial surface landmarks from monocular video.
 - Optimized for videos captured on front-facing cameras of smartphones.
 - Well suitable for mobile AR (augmented reality) applications.

Domain & Users
 - The primary intended application is AR entertainment.
 - Intended users are people who use augmented reality for entertainment purposes.

Out-of-scope applications
Not appropriate for:
 - This model is not intended for human life-critical decisions.
 - Predicted face landmarks do not provide facial recognition or identification and do not store any unique face representation.

 


 

2. Model type 및 Model architecture

모델 카드 인용
MobileNetV2 - like with customized blocks for real-time performance and an attention mechanism to refine lips and eye regions and to predict irises.

 

Model type: CNN

 

Model architecture
  • face-landmarks-detection 에서는 detector-model과 landmark-model이라고 하지 않고, 각각을 sub model이라고 칭함
  • 입, 눈, 눈동자를 제외한 박스 안에 있는 얼굴 부위 탐지 및 좌표 예측: MobileNetV2
  • 입, 눈, 눈동자의 탐지 및 좌표 예측: attention mechanism
    • 특히 이 메커니즘에서 spatial transformer 모듈이 사용됨
    • 이 모듈은 아핀 변환 행렬(affine transformation matrix)에 의해 조절되고, 사용자가 박스 안의 keypoint를 당기고 회전하고 바꾸고 왜곡할 수 있게 함
attention mechanism
An attention mechanism pulls out visual features of a given region of interest by sampling a grid of 2D points in the feature space and extracting the features under the sampled points. This allows to train architectures end-to-end and to enrich the features that are used by the attention mechanism.

spatial transformer
Specifically, we use a spatial transformer module which is controlled by an affine transformation matrix and allows us to zoom, rotate, translate, and skew the sampled grid of points.

 


 

3. input, output

Inputs
Image of cropped face with 25% margin on each side and size 192x192 px.

Output(s)
 - Facial surface represented as 468 3D landmarks flatened into a 1D tensor: (x1, y1, z1), (x2, y2, z2), ... x- and y-coordinates follow the image pixel coordinates;
z-coordinates are relative to the face center of mass and are scaled proportionally to the face width.
 - Lips refined region surface represented as 80 2D landmarks (inner and outer contours and an intermediate line) flattened into a 1D tensor.
 - Eye with eyebrow refined region surface (x2) represented as 71 2D landmarks (eye and eyebrow contours with surrounding areas) flattened into a 1D tensor.
 - Iris refined region surface (x2) represented as 5 2D landmarks (1 for pupil center and 4 for iris contour) flattened into a 1D tensor.
 - Face flag indicating the likelihood of the face being present in the input image. Used in tracking mode to detect that the face was lost and the face detector should be applied to obtain a new face position. Face probability threshold is set at 0.5 by default and can be adjusted.
[
  {
    box: {
      xMin: 304.6476503248806,
      xMax: 502.5079975897382,
      yMin: 102.16298762367356,
      yMax: 349.035215984403,
      width: 197.86034726485758,
      height: 246.87222836072945
    },
    keypoints: [
      {x: 406.53152857172876, y: 256.8054528661723, z: 10.2, name: "lips"},
      {x: 406.544237446397, y: 230.06933367750395, z: 8},
      ...
    ],
  }
]

 


 

4. evaluation metric

face의 metric : IOD MAE
  • 양쪽 눈 사이의 거리의 평균 오차
  • IOD (Normalization by Interocular Distance)
Normalization by interocular distance (IOD) is applied to unify the scale of the samples.
IOD is calculated as the distance between the eye centers (which are estimated as the centers of segments connecting eye corners) and is taken as 100%.
To accommodate head rotations, 3D IOD from the ground truth is employed.
  • MAE (Mean Absolute Error normalized by interocular distance) : handpose와 동일
Mean absolute error is calculated as the pixel distance between ground truth and predicted face mesh.
The model provides 3D coordinates, but as the z screen coordinates as well as metric world coordinates are obtained from synthetic data, so for a fair comparison with human annotations, only 2D screen coordinates MNAE are employed.

 

evaluation results
  • 구체적인 내용
Comparison with fairness goal of 2.56% IOD MAE discrepancy across 17 regions:
 - Tracking mode: from 2.73% to 3.95% (difference of 1.22%)
 - Reacquisition mode: from 3.01% to 4.28% (difference of 1.27%)

Comparison with our fairness criteria yields a maximum discrepancy between best and worst performing regions of 1.22% for the tracking mode and 1.27% for the reacquisition mode.
We therefore consider the models performing well across groups.
  • Tracking mode and Reacquisition mode
  Tracking mode Reacquisition mode
발동 상황 메인 모드
 = 대부분의 얼굴 감지 가능한 일반적인 상황
 = 이전 프레임에서 높은 정확도의 얼굴 정보를 얻을 수 있을 때
이전 프레임에서 얼굴 정보를 얻을 수 없을 때
 = 첫 번째 프레임 or 얼굴 추적 정보가 없어졌을 때
이용하는 툴 the Mdiapipe Python Solution API for face mesh BlazeFace Detector
Tracking mode
The main mode that takes place most of the time and is based on obtaining a highly accurate face crop from the prediction on the previous frame (frames 2, 3, ... on the image below). Underneath we utilize the MediaPipe Python Solution API for Face Mesh and run the pipeline for several frames on the same image before measuring the tracking accuracy (thus the crop region is determined from the model predictions as in a video stream).

Reacquisition mode
Takes place when there is no information about the face from previous frames. It happens either on the first frame (image below) or on the frames where the face tracking is lost. In this case, an external face detector is being run over the whole frame. We used BlazeFace Detector for the evaluation of the reacquisition mode.

 

+ Recent posts