Blog / 블로그
백엔드 · 인프라 · 커리어에 대한 1년차의 기록
슬로우 쿼리 1위를 고치기 전에는 2위가 보이지 않는다
Aurora 슬로우 로그를 CloudWatch 비용 없이 켜고, 튜닝→배포→재진단을 반복해 후보가 마를 때까지 갔습니다
장애의 부하원을 찾으려면 슬로우 쿼리 로그가 필요한데, CloudWatch 로그 export 는 과금 대상입니다. Aurora 자체 슬로우 로그를 파일로만 남기고 RDS API 로 내려받으면 추가 비용 없이 같은 걸 얻습니다. 더 중요한 건 진단 방식이었습니다. 슬로우 시간은 소수 쿼리에 극단적으로 쏠려서, 1위(전체의 97%)를 고쳐 배포하기 전에는 2위가 로그에 드러나지 않았습니다. 튜닝하고 배포하고 로그를 다시 뜨는 반복을 네 바퀴 돌아 서비스 쿼리의 슬로우 후보가 없어질 때까지 간 과정을 정리했습니다.
오픈하지 않은 기능이 트래픽의 97%가 되어 결제를 막았다
커넥션 풀 고갈의 범인이 풀 크기가 아닐 때가 있습니다. writer 는 1.8ms 로 멀쩡했고 reader CPU 가 99%였습니다
프로모션 트래픽이 몰린 날, 결제는 됐는데 예약이 안 되는 고객이 나오기 시작했습니다. 로그에는 커넥션 풀 타임아웃이 4시간에 9,400건. 그런데 포인트 기능은 아직 오픈도 안 했는데, pay 트래픽의 97%가 포인트 조회였습니다. 프론트가 헤더 잔액 표시용으로 전 페이지에서 호출하고 있었고, 무거운 내역 쿼리가 reader CPU 를 99%로 고정시키면서 조회 하나가 커넥션을 30초씩 잡았습니다. 풀 고갈이라는 증상에서 풀 크기를 늘리는 게 왜 오답이었는지, 인과 사슬을 어떻게 추적했는지 정리했습니다.
회원 한 명 조회가 전체 예약 41,135건을 집계하고 있었다
WHERE 1=1 파생테이블은 바깥 필터와 무관하게 테이블 전체를 materialize 합니다. 필터를 안으로 밀어 넣었습니다
마이페이지 예약목록이 회당 3.8초씩 걸리며 슬로우 로그의 97%를 차지하고 있었습니다. EXPLAIN ANALYZE 를 떠 보니 파생테이블이 조회 회원과 무관하게 전체 예약 41,135건을 GROUP BY 하고, 회원 필터는 그 바깥에서 나중에 걸리고 있었습니다. 바깥의 동적 필터를 파생테이블 안으로 그대로 밀어 넣는 재작성으로 materialize 를 41,135행에서 87행으로 줄였고, 같은 모양의 쿼리 세 개에 같은 기법을 적용해 13배~1,400배가 됐습니다. 인덱스만 먼저 걸었을 때는 거의 효과가 없었습니다.
트랜잭션 안의 느린 외부 호출이 중복검사를 무력화할 때
커밋되지 않은 INSERT 는 다음 요청의 SELECT 에 보이지 않습니다
트래픽이 몰린 날, 소셜 가입에서 한 사용자에게 계정이 4개 생겼습니다. 가입 코드에는 중복검사가 분명히 있었고, 동시성 race 도 아니었습니다. 제출 간격은 30~40초였는데도 전부 통과했습니다. 원인은 가입 트랜잭션 안에서 호출하던 외부 API 가 58초 만에 실패하면서 트랜잭션이 80초 동안 커밋되지 않았고, 커밋된 행만 보는 중복검사가 그동안 계속 0건을 반환한 것이었습니다. 외부 호출을 커밋 이후로 분리해 해결한 과정과, DB UNIQUE 를 걸 수 없었던 이유를 정리했습니다.
항공권 조회 봇을 브라우저 증명(PoW) 토큰으로 걸러낸 이야기
조회 대비 예약 비율(look-to-book)이 기준을 넘으면 과금되는 구조라, 조회만 부풀리는 봇이 문제였습니다. 토큰 게이트로 걸러내고 효과를 로그와 DB 로 검증했습니다.
항공권 조회는 조회 대비 실제 예약 비율(look-to-book)이 기준을 넘으면 과금되는 구조입니다. 어느 날부터 봇이 조회만 정상의 100~200배로 부풀리기 시작했는데, 헤더는 얼마든지 위조하니 헤더 검사로는 못 막았습니다. 값이 아니라 계산을 시켜 진짜 브라우저인지 증명하게 하는 PoW 토큰 게이트로 봇을 걸러내고, 호출량과 캐시 hit율로 효과를 검증한 이야기입니다.

모든 예외를 RuntimeException 으로 감쌌더니 '매진' 이 '알 수 없는 오류' 로 나갔다
의미 있는 에러 코드를 generic 예외로 재포장하면, 호출자는 재시도할지 포기할지 판단할 정보를 잃습니다
외부 파트너가 예약 API 에서 '관리자에게 문의하세요(UNKNOWN_ERROR)' 만 받고 같은 요청을 반복하던 문제. 진짜 원인은 좌석 매진이었는데, 컨트롤러가 모든 예외를 RuntimeException 으로 감싸는 바람에 의미 있는 에러 코드가 공통 핸들러의 catch-all 로 뭉개졌습니다. 예외를 감싸는 건 실패가 아니라 실패의 이유를 감춘다는 이야기.

배포 직후 5분만 멀쩡하고 빈 페이지가 되던 Amplify SSR
콘솔 환경변수는 빌드 시점에만 주입되고 SSR 런타임에는 안 들어옵니다
Next.js + Amplify Hosting SSR 사이트가 "배포 직후 5분 정상, 5분 후 빈 페이지"를 반복했습니다. server-only 환경변수가 빌드 시점엔 있지만 SSR Lambda 런타임의 process.env 엔 없어서, ISR 백그라운드 갱신이 빈 결과를 캐시에 덮어쓰던 거였습니다. 빌드 시점과 런타임이 서로 다른 실행 맥락이라는 걸 env 한 줄로 드러낸 사례입니다.

모듈 싱글톤 커넥션 풀이 서버리스에서 DB 커넥션을 고갈시킬 때
Amplify SSR 런타임은 배포당 하나가 아니라 인스턴스마다 풀을 새로 만듭니다
운영 RDS 가 간헐적으로 커넥션 부족을 냈는데, 누수가 아니라 idle 연결이 총량을 잠식하는 형태였습니다. mysql2 풀을 모듈 싱글톤으로 만들어 뒀지만, Amplify Hosting 의 SSR 런타임에서 그 싱글톤은 배포 전체에 하나가 아니라 런타임 인스턴스마다 하나라, connectionLimit 이 인스턴스 수만큼 곱해집니다. 서버리스에서 "전역 싱글톤" 이라는 가정이 무너지는 사례입니다.

Claude 로 개인 블로그를 만든 과정
Claude Design 과 Claude Code 로 디자인부터 배포까지
디자인 시안부터 미니 PC 배포까지, 코드를 직접 짜지 않고 만든 사이드 프로젝트 기록입니다. AI 가 코드를 짜기 시작한 뒤 사람이 하는 일은 무엇인지 정리했습니다.

mysql2 의 timezone 과 dateStrings 가 같이 켜져 있으면 저장할 때마다 9 시간이 쌓인다
두 옵션이 따로는 합리적인데 합치면 round-trip 마다 +9h. KST 환경의 Node 백엔드에서 자주 마주치는 케이스
사용자가 18:01 을 입력해 저장하면 새로고침에 18:10, 다시 저장하면 19:10. 매 저장 round-trip 마다 9 시간이 쌓이는 원인을 찾고 보니 mysql2 풀의 timezone + dateStrings 두 옵션의 상호작용이었습니다.

.dockerignore 한 줄이 mvn test 를 통째로 사라지게 했다
CodeBuild 에서 "No tests were executed!" 가 나오면 의존성·JUnit 보다 먼저 봐야 할 곳
로컬 mvn test 도 통과하고 의존성도 정상인데 CodeBuild 만 "No tests were executed!" 로 실패. 컴파일·surefire·JUnit 가설을 한 시간씩 잡고 뒤지다, 결국 원인은 .dockerignore 한 줄이었던 기록입니다.

Lettuce cluster 의 SCAN cursor 는 값이 아니라 인스턴스였다
cursor 객체를 새로 만들면 라우팅 정보가 통째로 사라져 첫 shard 만 보입니다
Redis cluster 에서 SCAN 루프가 dev 에선 멀쩡한데 prod 에서만 매칭 0건. spring-data-redis 를 우회해 native Lettuce 로 내려간 코드에서 cursor 를 새 인스턴스로 복사한 한 줄이 만든, 한 픽스가 다른 문제를 만든 케이스.

팀 지식 저장소를 중앙화한 이야기
AI 에게 일을 잘 시키기 위한 팀 공유 컨텍스트
흩어져 있던 팀의 암묵지를 한 곳에 모으고, Claude Code 가 작업 시작과 커밋 시점 양쪽에서 자동으로 참조·기록하도록 강제하는 사내 공유 저장소를 만들었습니다.

Valkey Serverless의 SCAN cursor가 Java long 범위를 넘어서 NPE까지 가는 길
spring-data-redis가 cursor를 long으로 다루는 한 줄이 만든 결제·주문 분기 사고
캐시 무효화에 쓰던 SCAN의 cursor 값이 Java long 범위를 넘으면서 NumberFormatException으로 깨졌고, scanKeys가 null을 반환하면서 호출자의 addAll(null)이 NPE로 이어졌습니다. 결제는 떨어졌는데 주문이 안 잡히는 사고로 보였던 한 줄짜리 버그와, 두 겹으로 막은 핫픽스, 그리고 Redis SCAN cursor의 정체를 정리했습니다.

JVM 배치 서비스의 14일 메모리 누수, JSch SFTP가 Session을 닫지 않고 있었다
heap dump 분석으로 connection thread 누적을 찾기까지의 진단 보고서
운영 배치 컨테이너의 메모리가 14일 동안 +351 MiB 단조 증가하다 OOM Kill로 끝났습니다. CloudWatch 메트릭, ECS Exec, jcmd heap dump, Eclipse MAT 분석으로 따라간 끝에, JSch SFTPUtils.destroy()가 Channel만 닫고 Session(과 그 connection thread)을 살려둔 한 줄짜리 누수를 찾았습니다. 부수적으로 AspectJ ShadowMatchCache와 JVM reflection inflation도 같이 부풀고 있었습니다.

항공권 검색 캐시 전략: 무엇을 캐시하고, 언제 비우는가
외부 GDS 호출 비용을 줄이면서 stale 좌석 정보는 피하는 무효화 설계
항공권 조회는 외부 GDS·NDC 호출 단가가 비싸서 검색 결과를 Valkey에 캐시해 재사용합니다. 다만 좌석은 수시로 바뀌는 데이터라 캐시가 stale해지면 곧장 사용자 불만으로 돌아옵니다. 캐시 키를 어떻게 설계했고, 어떤 트리거로 어떤 범위를 비우는지, 그리고 무효화 단계를 본 흐름과 묶을지 떼어낼지를 정리했습니다.
