강형준

구름 DeepDive 부트캠프 · 2024.09 – 2025.04 · 백엔드

마패 (MaskPass)

얼굴 인식으로 행사 출입을 관리하는 서비스

JavaSpring BootRedisAWS RekognitionSSENginxDocker

Overview

행사장에서 얼굴 인식으로 참가자를 인증하고, 운영진 대시보드에 실시간(SSE)으로 인증 현황을 보여주는 서비스입니다. 실시간 연결의 수명 관리, 비동기 처리의 정합성 같은 백엔드의 고전적인 난제들을 정면으로 다뤘습니다.

담당 업무

  • 얼굴 인식 인증 플로우와 실시간 알림(SSE) 백엔드 구현
  • 인증 후처리 비동기화와 데이터 정합성 보장 설계
  • Nginx 리버스 프록시 구성과 커넥션 수명 관리

Trouble Shooting

SSE 좀비 소켓 누적으로 인한 OOM — 3단 방어로 해결

1. 상황 및 문제

운영진 대시보드가 SSE로 실시간 인증 현황을 받는데, 브라우저 새로고침이 반복되면 서버에 FIN_WAIT2·CLOSE_WAIT 상태의 좀비 소켓이 누적되었고, 결국 메모리 부족(OOM)으로 이어졌습니다.

2. 원인 분석

클라이언트가 끊겨도 서버의 SseEmitter와 그 뒤의 커넥션이 즉시 정리되지 않는 것이 원인이었습니다. Nginx의 proxy_read_timeout이 3600초로 설정되어 있어, 죽은 커넥션이 한 시간 동안 유지되는 구조였습니다.

3. 해결 단계

  1. Step 1. completeWithError()로 끊긴 연결 즉시 강제 종료

    전송 실패가 감지되면 completeWithError()로 emitter를 즉시 종료해, 끊긴 연결이 서버에 남지 않게 했습니다.

  2. Step 2. 45초 주기 Heartbeat로 죽은 연결 조기 감지

    주기적으로 heartbeat 이벤트를 보내, 이미 끊긴 연결에서 전송 예외가 빨리 발생하도록 만들었습니다. 감지가 빨라지면 정리도 빨라집니다.

  3. Step 3. Nginx proxy_read_timeout 3600s → 80s

    Heartbeat 주기(45초)보다 여유 있게 80초로 줄여, 정상 연결은 유지되면서 죽은 커넥션은 Nginx 레벨에서도 빠르게 회수되게 했습니다.

    location /sse {
        proxy_pass http://app;
        proxy_read_timeout 80s;  # heartbeat 45s + 여유
        proxy_set_header Connection '';
        proxy_http_version 1.1;
    }
    heartbeat 주기에 맞춘 타임아웃 — 죽은 커넥션을 Nginx가 회수

4. 검증 및 결과

새로고침을 반복해도 FIN_WAIT2·CLOSE_WAIT 소켓이 누적되지 않는 것을 확인했고, OOM이 재발하지 않았습니다. 애플리케이션·프록시 양쪽에서 커넥션 수명이 관리되는 구조가 되었습니다.

인증 후처리 동기 결합으로 인한 응답 지연 — @Async 분리

1. 상황 및 문제

얼굴 인식 인증이 성공하면 Redis 카운트 갱신, RDB 기록, SSE 알림이 이어지는데, 이 후처리가 인증 응답과 동기로 묶여 있어 참가자가 몰리는 시간대에 인증 응답이 느려졌습니다.

2. 원인 분석

참가자에게 중요한 것은 '인증 성공' 응답뿐이고, 후처리는 응답과 분리해도 되는 작업이었습니다. 문제는 후처리를 비동기로 빼면 로그의 traceId가 끊겨 장애 추적이 어려워진다는 점이었습니다.

3. 해결 단계

  1. Step 1. 후처리를 @Async 이벤트로 분리

    인증 트랜잭션은 응답까지만 책임지고, Redis·RDB·SSE 후처리는 이벤트 발행 후 @Async 리스너가 처리하도록 분리했습니다.

  2. Step 2. TaskDecorator로 MDC traceId 전파

    비동기 스레드로 넘어갈 때 MDC 컨텍스트가 유실되지 않도록 TaskDecorator로 traceId를 복사해, 하나의 인증 요청을 동기·비동기 구간에 걸쳐 추적할 수 있게 했습니다.

    public class MdcTaskDecorator implements TaskDecorator {
        @Override
        public Runnable decorate(Runnable runnable) {
            Map<String, String> context = MDC.getCopyOfContextMap();
            return () -> {
                if (context != null) MDC.setContextMap(context);
                try {
                    runnable.run();
                } finally {
                    MDC.clear();
                }
            };
        }
    }
    비동기 경계를 넘어 traceId를 전파하는 TaskDecorator

4. 검증 및 결과

인증 응답이 후처리 소요 시간과 무관해져 피크 시간대에도 응답이 안정화되었고, traceId로 비동기 후처리까지 이어지는 로그 추적이 가능해졌습니다.

비동기 전환 후 원자성 붕괴 — 보상 트랜잭션과 실패 큐

1. 상황 및 문제

후처리를 비동기로 전환한 뒤, 일부 요청에서 Redis 카운트만 증가하고 RDB 기록이 누락되는 정합성 문제가 발견되었습니다. 동기일 때는 한 트랜잭션이 지켜주던 원자성이 사라진 것입니다.

2. 원인 분석

@Async 메서드에서 발생한 예외는 호출자에게 전파되지 않아 실패가 조용히 삼켜지고 있었습니다. 실패를 일단 보이게 만들고, 부분 실패를 복구하는 장치가 필요했습니다.

3. 해결 단계

  1. Step 1. AsyncUncaughtExceptionHandler로 실패 가시성 확보

    비동기 예외를 잡아 로깅하는 핸들러를 등록해, 어떤 후처리가 어떤 이유로 실패하는지 먼저 보이게 만들었습니다.

  2. Step 2. 보상 트랜잭션으로 부분 실패 롤백

    RDB 기록이 실패하면 이미 증가시킨 Redis 카운트를 되돌리는 보상 로직을 넣어, 두 저장소가 어긋난 채 남지 않게 했습니다.

  3. Step 3. Redis List 실패 큐 + @Scheduled 재처리

    보상까지 실패한 건은 Redis List 실패 큐에 적재하고, 스케줄러가 주기적으로 재처리하도록 해 유실 없이 최종 정합성에 수렴하게 했습니다.

4. 검증 및 결과

Redis와 RDB의 카운트 불일치가 재발하지 않았고, 실패한 후처리도 큐를 통해 재처리되어 유실이 사라졌습니다. '실패를 보이게 만들고 → 되돌리고 → 다시 시도한다'는 3단 구조로 정리했습니다.