2026년 10월 12일, 정산 관리 API를 서비스하는 admin 서버가 새벽 4시 46분부터 오후 1시 50분까지 약 9시간 응답하지 않았다. 고객사 문의로 알았고, 원인은 힙 포화였다. 장애 공유 문서는 정산 화면의 5중 조인 쿼리가 주문 데이터를 대량으로 올려 힙을 채웠다고 적었다. 개발팀에 온 요청도 정산 조회 쿼리 최적화였고, 나도 쿼리 쪽에서 원인을 찾기 시작했다
다음 날 서버 앱 로그를 열자 원인은 다른 곳에 있었다. 5중 조인은 존재하지 않는 컬럼 때문에 데이터를 한 건도 읽기 전에 실패하고 있었다. 힙을 채운 것은 04:38:11에 기간 조건 없이 들어온 정산 엑셀 다운로드 요청 한 건이었다. 이 요청은 8분 30초 동안 끝나지 않다가 OutOfMemoryError로 500을 냈다
수정 전 코드로 운영 규모 합성 데이터(64,389행)의 엑셀을 만들려면 로컬에서 최소 힙 2,048MB가 필요했다. 필요한 컬럼만 조회하고 엑셀을 SXSSF와 MySQL 스트리밍으로 바꾼 뒤에는 6개월치 13.8만 행을 로컬 힙 64MB로 만들었다. 운영에서는 6개월 139,154행이 20.1초에 200으로 내려왔다
원인 조사와 코드 수정은 Claude Code와 함께 진행했다. 운영 DB 조회와 DDL, 서버 설정, 배포는 내가 직접 실행했다. 아래에서 행위 주체가 중요한 곳은 따로 밝힌다
장애 공유 문서는 5중 조인을 지목했다
장애 공유 문서에 적힌 그날의 흐름은 이렇다
| 시각 | 내용 |
|---|---|
| 04:32~04:46 | 관리자 웹서버에서 정산 조회·다운로드 요청이 연속으로 들어옴 |
| 04:37 | jOOQ BadSqlGrammarException 발생, 일부 500 |
| 04:40 | JVM 메모리 99%, Full GC 폭주, CPU 사용률 경고 문자 발송 |
| 04:46:41 | admin 마지막 정상 응답. 이후 로그 0줄 |
| 04:46~13:50 | 응답 불능. CLOSE-WAIT 소켓 2,259개, FD 2,495개 |
| 13:50 | 재시작으로 정상화 |
문서는 settlement 5중 조인 쿼리의 반복을 원인으로 지목했다. 코드에서 이 조인에 해당하는 것은 주문취소 목록 쿼리다. settlement, settlement_order, order, brand, brand_store 다섯 테이블을 조인하고 약 260컬럼을 조회하는 쿼리다. 개발팀 요청 사항도 “정산 조회 쿼리 최적화”였다.
10월 12일에는 운영 서버 로그를 볼 수 없다고 판단했다. 그래서 운영 DB에서 읽기 전용 조회로 우회했다. Claude Code가 쿼리를 만들어 로컬 MySQL에 같은 스키마를 올려 문법을 먼저 확인했고, 내가 운영 DB에서 실행해 결과를 넘겼다.
코드만 보고 세운 가설은 운영 DB 통계에서 기각됐다
BadSqlGrammarException은 jOOQ가 SQL 문법·스키마 오류를 감싸는 예외라 원인 후보가 여럿이다. 처음에는 코드를 읽고 그럴듯한 후보를 골랐다. 그중 1242와 1055는 로컬에서 재현까지 됐다. 운영에서는 전부 아니었다
| 가설 | 코드에서 본 근거 | 운영 DB에서 기각한 근거 |
|---|---|---|
| 음수 LIMIT/OFFSET (오류 1064) | (page-1)*limit이 int 오버플로하면 음수 OFFSET | 1064는 9/28 1건뿐이고 다른 서버에서 발생 |
| 스칼라 서브쿼리 2행 이상 (1242) | 일별 조회의 서브쿼리 대상 테이블에 유니크 키가 없음. 로컬에서 재현 | 1242 기록 없음, 중복 데이터 0건 |
ONLY_FULL_GROUP_BY (1055) | GROUP BY에 없는 컬럼을 SELECT. 로컬에서 재현 | 운영 sql_mode에 해당 모드 없음 |
| 5중 조인이 order를 대량 로딩 | 주문취소 목록이 약 260컬럼 조회 | 아래 1054 때문에 실행 준비 단계에서 실패. 화면도 한 번에 20건만 조회 |
코드만 보고 세운 1순위 가설이 두 번 바뀌었다. 오류 번호별 통계를 처음부터 봤다면 바로 답으로 갔을 것이다. MySQL Performance Schema의 오류 요약 테이블은 오류 번호별·접속 호스트별로 발생 횟수(SUM_ERROR_RAISED)와 마지막 발생 시각(LAST_SEEN)을 누적한다. 여기에 답이 있었다.
-- 접속 호스트별 오류 누적을 보는 예시 쿼리 (읽기 전용) SELECT HOST, ERROR_NUMBER, ERROR_NAME, SUM_ERROR_RAISED, LAST_SEEN FROM performance_schema.events_errors_summary_by_host_by_error WHERE SUM_ERROR_RAISED > 0 ORDER BY LAST_SEEN DESC;
admin 서버로 판단한 IP에서 1054(Unknown column)가 9/28 이후 9건 있었고, 마지막 발생은 04:37:47이었다. 그 IP를 admin 서버로 본 근거는 오류 건수 대조뿐이라 추정이다. 쿼리 유형별 오류 건수(order 전체 컬럼 조회 8건 + settlement 전체 컬럼 조회 1건)를 더하면 9건으로 정확히 맞는다
존재하지 않는 컬럼의 정체는 스키마 비교로 나왔다. admin이 쓰는 jOOQ 생성 코드의 order 테이블은 93컬럼, 운영 order는 92컬럼이었다. 빠진 것은 외부 연동용 partner_order_uid 하나였다. 개발 DB에는 있고 운영 DB에는 없었다. jOOQ 코드는 개발 DB를 기준으로 생성되고, 이 컬럼은 2026년 3월 생성 코드 모듈의 main에 들어와 있었다
컬럼 하나가 매번 실패를 만든 이유는 jOOQ가 SQL을 만드는 방식에 있다. select().from(ORDER)는 SELECT *를 보내지 않는다. 생성 코드가 알고 있는 컬럼 이름을 전부 나열한다. 다음 날 확인한 앱 로그에 남은 실패 SQL도 그 모양이었다
BadSqlGrammarException ... [select `order`.`id`, `order`.`type`, ...
운영에 없는 컬럼이 SELECT 목록에 들어가므로, 데이터와 무관하게 실행 준비 단계에서 매번 1054가 난다. 주문취소 목록은 9/28 이후 운영에서 늘 깨져 있었다. 건수 쿼리는 성공하고 목록 쿼리만 500이었다. 통계 화면의 주문 목록도 select().from(ORDER)라 같은 이유로 실패했다고 판단했다
여기서 근본 원인이 하나 정해졌다. 개발 DB 스키마 변경이 운영으로 관리되지 않는 한, 컬럼을 맞춰도 다음 개발 컬럼에서 같은 일이 생긴다. 그래서 재발 방지책은 컬럼을 맞추는 것이 아니라 필요한 컬럼만 조회하는 것으로 잡았다
그러면 힙은 누가 채웠나. 이 시점의 근거는 정황뿐이었다. 쿼리 유형별 통계(events_statements_summary_by_digest)에서 settlement 전체 컬럼 조회는 356회 실행에 누적 약 26만 행을 반환했다. settlement_order 전체가 64,389행이니 약 4배다. 화면 조회는 한 번에 20건이라 356회를 다 합쳐도 약 7천 행이다. 나머지는 행 수 제한이 없는 엑셀 다운로드에서 나왔다고 봤다
서버 로그를 열자 원인이 한 줄로 나왔다
10월 13일 서버 접속이 가능해졌다. 내가 앱 로그(log.2026-10-12.log)에서 04:32~04:46의 정산·다운로드 요청과 예외를 뽑았다. 요청 줄에는 파라미터가, 응답 줄에는 상태 코드가 남는다
| 시각 | 요청 | 결과 |
|---|---|---|
| 04:32:22 / 04:32:25 | GET /api/settlement/daily page=0&limit=1 | 401 → 200 |
| 04:33:38, 04:33:41 | (order 전체 컬럼 조회, 통계 주문 목록으로 판단) | BadSqlGrammarException |
| 04:33:44 / 04:33:47 | GET /api/download/settlement/daily page=0&limit=1 (기간 없음) | 401 → 200 (3.9초) |
| 04:37:42 / 04:37:45 | GET /api/settlement/order/cancels page=0&limit=1 | 401 → 500 (04:37:46.987 BadSqlGrammarException) |
| 04:38:08 / 04:38:11 | GET /api/download/settlement/orders page=0&limit=1 (기간 없음) | 401 → 8분 30초 무응답 → 04:46:41 500 |
| 04:41:11 | GET /api/settlement/summary | 37초 뒤 200 (직전 호출은 0.4초) |
| 04:41:38 | GET /api/settlement/amount/daily | 5분 뒤 200 |
다섯 번째 줄의 500에 붙은 예외는 다음과 같았다
ServletException: Handler dispatch failed: java.lang.OutOfMemoryError: Java heap space
04:38:11의 기간 없는 주문 엑셀이 힙을 채운 요청이다. 기간 조건이 없으니 정산 주문 전체 약 6.4만 행을 166컬럼으로 읽고, 영업이익 약 11만 행을 더 읽어 엑셀로 만들다가 8분 30초 뒤 OOM이 났다. 그동안 다른 요청은 느려졌다. summary가 37초, amount/daily가 5분 걸렸다. 힙이 차면서 GC가 늘어난 탓으로 본다. 직접 확인하지 못해 추정이다
5중 조인은 04:37:45에 한 번 호출되어 04:37:46에 바로 실패했다. 로그 메시지가 잘려 오류 번호는 보이지 않지만, 운영 DB의 1054 마지막 발생 시각(04:37:47)과 맞아 1054로 판단했다. 장애 공유 문서의 “5중 조인 쿼리 반복”과는 다른 그림이다
요청 패턴도 이상했다. 엔드포인트마다 토큰 없이 한 번 호출해 401을 받고, 약 3초 뒤 토큰을 붙여 다시 호출했다. 파라미터는 page=0&limit=1이었다. 백오피스 화면은 page 1부터 보내고 limit은 10/20/50/100만 쓴다. 기간 없는 주문 엑셀 요청도 만들지 않는다. 화면의 기본 기간이 오늘~오늘이기 때문이다. 사람이 화면을 쓴 흔적으로 보기 어렵다. 다만 백오피스가 서버에서 API를 중계한다면 사람의 요청도 같은 출발지로 보일 수 있어, 그 시각에 무엇이 호출했는지는 확인하지 못했다
호출한 쪽이 누구든 원인은 같다. 주문 엑셀 API를 기간 없이 호출할 수 있었고, 서버가 그 요청을 제한 없이 처리했다
로그에서 하나 더 눈에 띈 것은 OOM 뒤의 침묵이다. 당시 서비스 설정에는 -Xmx도 ExitOnOutOfMemoryError도 없었다. OOM은 그 요청 하나만 실패시켰고 JVM은 죽지 않았다. 장애 공유 문서 기준으로 이후 13:50 재시작까지 로그가 한 줄도 없다. 힙이 거의 찬 채 GC만 반복한 것으로 본다
이 “살아서 멈춘” 상태는 자동 재시작으로도 못 잡는다. 내가 장애 직후 넣은 systemd 설정은 다음과 같다
[Service] Environment="JAVA_TOOL_OPTIONS=-Xms512m -Xmx2g -XX:MaxMetaspaceSize=512m -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=<APP_HOME>/dumps" Restart=on-failure
ExitOnOutOfMemoryError와 Restart=on-failure 조합은 OOM이 실제로 던져질 때만 프로세스를 내리고 다시 띄운다. 힙이 찬 채 GC만 도는 상태에서는 프로세스가 살아 있어서 재시작이 일어나지 않는다. 시간 대부분을 GC에 쓰면 OOM을 던지는 GC overhead limit은 Parallel 컬렉터의 기능으로 문서화돼 있고, 이 서버(2코어)에서 JDK 17이 쓰는 기본 컬렉터는 G1이다. 헬스체크 감시가 따로 필요한 이유다
요청 하나가 힙을 다 쓰는 구조였다
수정 전 주문 엑셀(/api/download/settlement/orders)은 다섯 단계로 동작했다
1. 기간 조건 선택 사항, 행 수 제한 없음 2. settlement + settlement_order + brand + brand_store 166컬럼을 jOOQ fetch()로 전부 메모리에 3. 영업이익을 주문 ID 전체를 IN (...)에 넣어 한 번 더 조회 (7만 건이면 바인드 7만 개, SQL 문자열 약 210KB) 4. XSSFWorkbook으로 엑셀 전체를 메모리에서 생성 5. ByteArrayOutputStream에 쓰고 toByteArray()로 한 번 더 복사해 응답
요청 1건이 드라이버 결과, jOOQ Result, 영업이익 Map, XSSF 객체 트리, 바이트 배열 두 벌을 동시에 힙에 올린다. 앞 단계의 객체가 뒤 단계가 끝날 때까지 살아 있다
Claude Code가 운영 규모(64,389행) 합성 데이터를 로컬 MySQL 8.0에 넣고 이 다운로드를 한 번 돌렸다. 힙을 줄여 가며 재 보니 1,536MB까지는 OOM, 2,048MB에서 성공했다. 운영에 새로 넣은 -Xmx2g에서 이 요청 한 번이 힙을 거의 다 쓴다는 뜻이다. 절대값은 합성 데이터 기준이라 수정 전후 비율로 읽어야 한다
첫 처방은 컬럼을 줄이고 엑셀을 SXSSF로 바꾸는 것이었다
10월 12일에는 운영 DB에 빠진 컬럼부터 넣었다. 내가 실행한 DDL이다. 문자셋·주석·위치 지정은 생략했다
SET SESSION lock_wait_timeout = 5; ALTER TABLE `order` ADD COLUMN `partner_order_uid` VARCHAR(64) NULL DEFAULT NULL, ALGORITHM = INSTANT;
order는 주문 출력 서비스가 끊임없이 조회하는 테이블이다. DDL이 메타데이터 락을 기다리는 동안 뒤따르는 조회가 모두 줄을 설 수 있다. lock_wait_timeout을 5초로 낮춰, 락을 못 잡으면 아무것도 바꾸지 않고 실패하게 했다. 이번 컬럼 추가는 ALGORITHM = INSTANT라 테이블 재작성 없이 끝난다
이 DDL로 주문취소 목록의 1054는 배포 없이 풀렸을 것이다. DDL 직후 화면을 확인한 기록은 없다. 같은 날 개발 DB와 맞추자는 이유로 이 컬럼의 인덱스도 내가 추가했다. 운영에서 그 인덱스를 쓰는 쿼리는 없었다. 이후 인덱스는 EXPLAIN 근거가 있을 때만 추가하기로 원칙을 정했다
코드는 커밋 세 개로 나눴다. 첫 커밋은 목록 조회의 page/limit 보정이다. (page-1)*limit을 long으로 계산해 음수 OFFSET을 막고, 정산 목록 6종만 limit을 최대 100건으로 묶었다. 같은 요청 클래스를 쓰는 고객사 연동 API는 외부 호출자의 페이징이 틀어질 수 있어 상한을 두지 않았다
두 번째 커밋의 중심은 조회 컬럼 축소다
| API | 수정 전 조회 컬럼 | 수정 후 |
|---|---|---|
| 주문취소 목록 | 259 | 10 |
| 정산 주문 목록·엑셀 | 166 | 26 (+엑셀은 영업이익 2) |
| 통계 주문 목록 | 226 | 13 |
컬럼 수는 admin이 쓰는 jOOQ 생성 코드 기준이다. 여기에 엑셀 4종을 SXSSF로 바꾸고, 주문 엑셀에 기간 필수·최대 3개월 검증을 넣었다
조회 컬럼을 줄이다 into() 대상의 PK를 빼먹었다
필요한 컬럼만 고르는 것은 단순해 보였다. 처음 구현에서 SETTLEMENT.ID를 목록에서 뺐다
그런데 결과 행을 record.into(SETTLEMENT)로 변환하고 있었다. jOOQ는 SELECT 결과에서 이름만으로 id를 찾았고, 후보가 settlement_order·brand·brand_store의 id 셋이라 Ambiguous match found INFO 로그를 남겼다. 테스트 출력에서는 3건이 각각 약 98줄짜리 스택트레이스와 함께 찍혔다. 운영 로그 레벨이 info라 그대로 나갔다면 엑셀 행 수만큼 찍혔을 것이다. 커밋 전 리뷰에서 잡아 PK를 넣었고, 테스트에 “into 대상 PK 포함” 단언을 추가했다. 운영에 나간 적은 없다
// SettlementOrderListRepository.makeSelectFields() 발췌 fields.add(SETTLEMENT.ID); // record.into(SETTLEMENT)를 하므로 PK 필수 fields.add(SETTLEMENT.BRAND_STORE_ID); ... fields.add(SETTLEMENT_ORDER.ID); fields.add(BRAND.ID); fields.add(BRAND_STORE.ID);
더 위험한 쪽은 경고가 안 나는 경우다. jOOQ 소스를 보면 같은 이름의 컬럼이 다른 테이블에 하나만 남아 있으면 그 값을 조용히 대신 넣는 것으로 보인다. 예를 들어 BRAND.ID를 빼면 settlement_order.id가 들어갈 수 있다. 직접 재현한 것은 아니다. 그래서 “컬럼이 빠지면 NPE로 드러난다”는 가정을 버리고, 컬럼마다 다른 값을 넣은 데이터로 수정 전후의 JSON을 비교했다
SXSSF로 바꿨더니 3만 행에서 두 배 느려졌다
SXSSF는 메모리에 최근 N행만 두고 나머지 행을 시트마다 임시 파일로 내린다. 창 크기는 100으로 잡았다
private static final int ROW_ACCESS_WINDOW_SIZE = 100; // 메모리에는 최근 100행만
private Workbook makeWorkbook(Consumer<SXSSFWorkbook> writer) {
final SXSSFWorkbook workbook = new SXSSFWorkbook(ROW_ACCESS_WINDOW_SIZE);
try {
writer.accept(workbook);
return workbook;
} catch (RuntimeException | Error e) { // OOM 같은 Error에도 임시 파일 정리
try {
workbook.close();
} catch (IOException closeException) {
e.addSuppressed(closeException);
}
throw e;
}
}
실패 경로에서 close()를 부르는 이유는 임시 파일이다. POI 5.3.0의 SXSSFWorkbook.close()는 소스 기준 내부에서 dispose()를 불러 임시 파일을 지운다. 반대로 임시 파일의 deleteOnExit는 poi.delete.tmp.files.on.exit 시스템 프로퍼티가 있을 때만 걸린다(DefaultTempFileCreationStrategy). 실패한 엑셀의 임시 파일은 JVM을 재시작해도 남는다
메모리는 줄었다. 64,389행 기준 최소 힙이 2,048MB에서 256MB로, 생성 시간이 약 48~55초에서 약 4초로 내려갔다. 그런데 리뷰에서 3만 행을 재 보니 기존 XSSF보다 느렸다. 11.5초·9.1초이던 측정값이 19.0초·18.4초가 됐다
원인은 열 너비 자동 맞춤이었다. SXSSF는 디스크로 내린 행의 너비를 나중에 계산할 수 없어서 trackAllColumnsForAutoSizing()으로 행을 쓸 때마다 너비를 기록한다. 이 추적이 셀마다 너비 계산 비용을 더했다. 앞 1,000행까지만 추적하고 해제하도록 바꿨더니 3만 행이 2.8초가 됐다. 대신 1,001행 이후의 더 긴 값은 잘려 보일 수 있다. 현실 분포를 흉내 낸 시뮬레이션에서 가맹점명 열 최대 1.4%의 행이 최대 약 4자 부족했다. 실제 Excel 화면으로는 보지 않았다
가드 조건 하나가 작은 엑셀의 열 너비를 통째로 껐다
열 너비를 이미 맞춘 시트를 건너뛰려고 가드 조건을 넣었다. 처음에는 getTrackedColumnsForAutoSizing().isEmpty()로 확인했다. 추적 중인 열 집합이 비어 있으면 이미 해제한 시트라고 본 것이다
그게 아니었다. 재검증에서 행 수별로 값을 찍어 보니 이 집합은 첫 flush(100행 초과) 전까지 비어 있었다
totalRows= 3 isColumnTracked(0)=true getTrackedColumnsForAutoSizing()=[] totalRows=100 isColumnTracked(0)=true getTrackedColumnsForAutoSizing()=[] totalRows=101 isColumnTracked(0)=true getTrackedColumnsForAutoSizing()=[0]
100행 미만 엑셀은 추적 중인데도 “이미 맞춤”으로 판정돼 너비 맞춤이 통째로 꺼졌다. 일별·월별 엑셀이 대부분 이 크기다. 가드를 isColumnTrackedForAutoSizing(0)으로 바꾸고 작은 시트의 열 너비 테스트를 추가했다
오류 JSON이 정산내역.xlsx로 저장되고 있었다
기간 검증을 넣자 다른 문제가 보였다. 전역 @RestControllerAdvice는 업무 예외를 HTTP 200 + 오류 JSON으로 응답한다. 화면은 엑셀을 blob으로 받기 때문에, 200이 오면 오류 JSON이 정산내역.xlsx라는 이름으로 저장된다. 사용자는 깨진 엑셀 파일을 받는다
전역 규칙을 바꾸면 다른 화면이 영향을 받는다. 그래서 다운로드 컨트롤러에만 로컬 핸들러를 뒀다. Spring MVC는 컨트롤러의 로컬 @ExceptionHandler를 @ControllerAdvice의 것보다 먼저 적용한다
// DownloadController
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> onCustomException(CustomException e) {
return ResponseEntity.status(e.getStatus())
.contentType(MediaType.APPLICATION_JSON)
.body(new ErrorResponse(e.getStatus(), e.getMessage()));
}
이제 기간 오류는 400, 뒤에 넣은 동시 제한은 429로 나가고, 프론트엔드가 message를 팝업으로 띄운다. MockMvc로 Accept가 */*, xlsx MIME, octet-stream인 경우를 확인했고, 셋 다 400 + JSON이었다
같은 커밋에서 기존 결함 둘도 고쳤다. 영업이익 데이터가 없거나 금액이 null이면 NPE나 NoSuchElementException으로 엑셀 전체가 실패하던 것을 해당 셀만 빈 칸으로 바꿨다. 주문취소 목록의 주문번호 검색 조건이 getOrderNo() 대신 getStoreName()을 쓰고 있어서, 매장명이 비면 LIKE '%%'로 주문번호 필터가 무시되던 것도 고쳤다
1년치를 재 보고 6개월과 DB 스트리밍으로 바꿨다
두 번째 커밋에서는 기간을 최대 3개월로 묶고, DB 스트리밍은 “측정 후 결정”으로 보류했다. 구조가 크게 바뀌고 DB 커넥션을 오래 잡는 위험이 있어서다
그런데 SXSSF만으로는 DB 결과를 전부 메모리에 받는 단계가 남는다. 스트리밍 전 구조로 1년치(약 28만 행)를 로컬에서 재 보니 최소 힙 768MB(512MB에서 OOM), 약 32초였다. 운영 힙 2GB에서 몇 번 연달아 받으면 다시 한도에 닿는다
이 측정을 보고 내가 범위를 정했다. 1년은 필요 없지만 6개월은 업무상 필요할 수 있다. 그래서 6개월 + DB 스트리밍으로 가되, 사이드 이펙트와 구조 변경을 먼저 검토하기로 했다
MySQL 드라이버는 기본적으로 결과 전체를 메모리에 받는다
Connector/J 문서는 “기본적으로 ResultSet은 완전히 받아서 메모리에 저장된다”고 적는다. forward-only, read-only 결과셋에 fetch size Integer.MIN_VALUE를 주면 그때만 한 행씩 스트리밍한다
jOOQ에서는 fetchSize와 fetchLazy()를 같이 쓴다. fetchLazy()는 Result를 만들지 않고 열린 ResultSet을 감싼 Cursor<Record>를 돌려준다
// SettlementOrderListRepository
public Cursor<Record> findExcelCursor(SettlementOrderListRequest param) {
final SelectJoinStep<Record> query =
dsl.select(makeSelectFields())
.select(makeIncomeField(SettlementIncomeTarget.PLATFORM).as("income_platform"),
makeIncomeField(SettlementIncomeTarget.PARTNER).as("income_partner"))
.from(SETTLEMENT);
query.join(SETTLEMENT_ORDER).on(SETTLEMENT.ID.eq(SETTLEMENT_ORDER.SETTLEMENT_ID));
query.join(BRAND).on(SETTLEMENT.BRAND_ID.eq(BRAND.ID));
query.join(BRAND_STORE).on(SETTLEMENT.BRAND_STORE_ID.eq(BRAND_STORE.ID));
query.where(makeConditions(param));
query.orderBy(SETTLEMENT.ID.desc(), SETTLEMENT_ORDER.ID.desc());
return query.fetchSize(Integer.MIN_VALUE).fetchLazy();
}
useCursorFetch=true와 양수 fetch size로 서버 측 커서를 쓰는 방법도 있다. 접속 URL 설정을 바꿔야 해서 다른 쿼리 전체에 영향이 갈 수 있다고 보고 쓰지 않았다
이 프로젝트는 JDBC 앞에 p6spy 래퍼가 있다. 래퍼를 거쳐도 fetch size가 드라이버까지 가는지 실제 MySQL로 확인했다. 스트림이 열린 동안 같은 커넥션으로 다른 쿼리를 보내면 jOOQ와 JPA 모두 거부됐다
Streaming result set ... is still active
드라이버가 실제로 스트리밍 중이라는 증거다. general log로 보면 드라이버는 스트리밍을 시작할 때 SET net_write_timeout=600을 보내고, 커서를 닫을 때 60으로 되돌렸다
영업이익을 상관 서브쿼리로 합쳤다
스트리밍에는 대가가 있다. 문서에 적힌 대로 결과셋을 다 읽거나 닫기 전에는 같은 커넥션으로 다른 쿼리를 보낼 수 없다. 기존처럼 “본 쿼리를 다 받은 뒤 영업이익을 IN으로 조회”하는 흐름이 불가능해진다
그래서 행마다 상관 서브쿼리로 영업이익을 함께 가져온다
private Field<Integer> makeIncomeField(SettlementIncomeTarget target) {
return select(SETTLEMENT_ORDER_INCOME.INCOME_AMOUNT)
.from(SETTLEMENT_ORDER_INCOME)
.where(SETTLEMENT_ORDER_INCOME.SETTLEMENT_ORDER_ID.eq(SETTLEMENT_ORDER.ID))
.and(SETTLEMENT_ORDER_INCOME.INCOME_TARGET.eq(target.getCode()))
.orderBy(SETTLEMENT_ORDER_INCOME.ID)
.limit(1)
.asField();
}
같은 대상이 중복이면 id가 가장 작은 행을 쓴다. 기존 코드는 ORDER BY 없이 첫 건을 써서 인덱스 순서에 기대고 있었다. 이제 순서를 명시했다. 운영 중복 데이터는 0건이었다. 서브쿼리는 기존 settlement_order_id_index로 행당 약 2행만 읽는다(로컬 EXPLAIN)
비교 방식도 하나 바뀌었다. 기존 Java의 equalsIgnoreCase가 SQL =가 됐다. 로컬의 utf8mb4_0900_ai_ci에서는 대소문자 구분 없이 매칭됐다. 운영 컬럼의 collation은 확인하지 못했지만, 값을 쓰는 쪽이 항상 소문자로 저장해서 실제 영향은 거의 없다고 봤다
커서는 트랜잭션 안에서만 읽을 수 있다
커서는 커넥션이 살아 있는 동안만 읽을 수 있다. 그래서 조회와 엑셀 작성을 같은 트랜잭션 메서드 안에서 끝냈다
// SettlementOrderListService
@Transactional(readOnly = true)
public Workbook makeExcel(SettlementOrderListRequest param) {
try (Cursor<Record> cursor = settlementOrderListRepository.findExcelCursor(param)) {
return settlementDownloadService.makeOrders(cursor); // 한 행 읽고 한 행 쓰기
}
}
정산 주문 서비스가 엑셀 서비스를 직접 호출하게 된 것이 이번 변경에서 가장 큰 구조 변화다. try-with-resources로 커서를 반드시 닫는다. 중간에 실패해서 닫으면 드라이버가 남은 행을 끝까지 읽어 버린다. 13.8만 행 중 10행째에서 실패시켰을 때 닫기까지 179ms가 걸렸다
JpaTransactionManager 환경에서 jOOQ와 Hibernate가 같은 커넥션을 쓰는지, 끝난 뒤 세션 상태(read only, autocommit, net_write_timeout)가 복구되는지도 실제 MySQL에서 확인했다
엑셀 작성 쪽은 Iterable<Record>를 받아 for 문으로 소비한다. 메모리에 남는 것은 현재 행 하나와 SXSSF 창 100행, 스타일 정도다. 로컬 측정에서 6개월 13.8만 행의 힙이 64MB였고, 생성 시간은 7~8.6초, 결과 파일은 스트리밍 전과 바이트 단위로 같았다
거절할 요청이 커넥션을 먼저 빌리고 있었다
스트리밍하는 동안 DB 커넥션 하나를 계속 잡는다. HikariCP의 기본 풀은 10개라, 다운로드가 몰리면 다른 API가 커넥션을 기다린다. 그래서 동시 작성을 2건으로 제한했다
@Component
public class SettlementExcelLimiter {
private static final int MAX_CONCURRENT = 2;
private final Semaphore permits = new Semaphore(MAX_CONCURRENT);
public <T> T execute(Supplier<T> task) {
if (!permits.tryAcquire()) { // 기다리지 않고 바로 거절
throw new CustomException(DOWNLOAD_BUSY); // 429
}
try {
return task.get();
} finally {
permits.release(); // 예외·Error에도 반환
}
}
}
처음에는 @Transactional 메서드 안에서 tryAcquire를 했다. 리뷰에서 문제가 나왔다. 거절될 요청도 트랜잭션을 시작하면서 커넥션을 먼저 빌린다. READ ONLY 설정, autocommit 해제, ROLLBACK까지 왕복 5번이다
로컬에서 풀을 2개, connectionTimeout을 3초로 줄이고 재현했다. 풀이 차 있으면 429가 아니라 3초를 기다린 뒤 CannotCreateTransactionException이 났다. 이 경로에서는 404로 나갔다. Hikari 기본 connectionTimeout은 30초라, 운영 기본값이면 최대 30초를 기다렸을 것이다
제한을 컨트롤러로, 트랜잭션 밖으로 옮겼다. 이제 거절되는 요청은 커넥션을 한 번도 빌리지 않는다
GET /api/download/settlement/orders
├ JWT 확인 (DB 안 씀) 실패 → 401
├ 기간 검증 (DB 안 씀) 실패 → 400
└ 동시 제한 (트랜잭션 밖) 초과 → 429
└ makeExcel @Transactional(readOnly = true)
커서로 한 행 읽고 한 행 쓰기 → 커서 닫기 → 200
순서도 의도했다. 2건이 작성 중이어도 기간이 잘못된 요청에는 400이 먼저 나간다. 제한은 JVM 단위라 인스턴스가 N대면 2×N이다
기간 검증은 정확히 6개월까지 허용한다. 4/12~10/12는 되고 4/12~10/13은 안 된다. 리뷰에서 극단 날짜(+999999999-12-31)를 넣자 plusMonths가 DateTimeException을 던져 500이 났다. 개월 수를 ChronoUnit.MONTHS.between으로 먼저 비교해 오버플로를 피하도록 바꿨다
테스트가 통과해도 결함은 살아 있었다
단위 테스트는 DB 없이 돌렸다. jOOQ MockDataProvider로 실행된 SQL의 SELECT 목록만 보고 결과 행을 만들어, 컬럼을 빠뜨리면 모델 생성에서 드러나게 했다
전부 통과했다. 그다음 일부러 결함을 넣고 테스트가 실패하는지 봤다. 변이 테스트다
| 변이 | 의미 | 처음 테스트 |
|---|---|---|
fetchSize(Integer.MIN_VALUE) 제거 | 스트리밍 해제 → 원래 메모리 문제 | 살아남음 |
| PLATFORM/PARTNER 서브쿼리 대상 교체 | 영업이익 열 뒤바뀜 | 살아남음 |
income_target 조건 / ORDER BY id 제거 | 아무 행이나 선택 | 살아남음 |
| 예외 시 permit 미반환 | 결국 영구 429 | 살아남음 |
| 커서 미종료 | 스트리밍 결과셋 누수 | 살아남음 |
tryAcquire를 try 안으로 | 거절 요청도 release → 허용 수 증가 | 살아남음 |
makeExcel을 제한 밖에서 호출 | 제한이 사실상 사라짐 | 살아남음 |
스트리밍과 동시 제한의 핵심 결함이 전부 초록불을 받았다. MockConnection은 fetch size를 무시했고, Mock 값은 서브쿼리 내용이 아니라 별칭 이름으로 정해졌다. 예외 경로 테스트와 컨트롤러 테스트는 아예 없었다
렌더링된 SQL을 직접 단언하는 테스트, JDBC Statement를 java.lang.reflect.Proxy로 감싸 setFetchSize 호출과 close를 기록하는 테스트, 예외 뒤 허용 수 복구 테스트, 컨트롤러 단위 테스트를 추가했다. 최종적으로 위 변이가 모두 테스트 실패로 잡혔다. 최종 단위 테스트는 44개다
초록불은 테스트가 실제로 검사한 범위만 보장한다. Mock이 무시하는 설정은 Mock 위에서 아무리 돌려도 검증되지 않는다.
리뷰도 단계를 나눴다. 작성과 검수를 분리했고, 수정 전 jar와 수정 후를 같은 시나리오로 비교하는 교차검증을 했다. SQL, 목록 JSON, 엑셀의 셀·스타일·병합까지 비교해 의도하지 않은 차이는 없었다. 빠진 컬럼을 지운 DB에서는 수정 전 코드만 Unknown column으로 실패했다. 기존 통합 테스트는 공유 개발 DB에 직접 붙는 구조라 실행하지 않았다
다음 날, 확인하려던 요청이 운영을 세 번 죽였다
10월 13일 오전, dev 서버에 스트리밍 커밋을 배포한 뒤 Claude Code에 dev 확인을 맡겼다. Claude Code는 Playwright로 dev 백오피스에 로그인하고, 엑셀 다운로드 요청의 기간 파라미터를 바꿔 보냈다. 6개월, 6개월+1일, 시작일 > 종료일, 기간 없음이었다
배포된 dev 백오피스는 운영 API를 호출하고 있었다. 프론트엔드의 API 주소는 빌드할 때 NEXT_PUBLIC_API_BASE_URL로 고정되는데, dev 도메인에 운영 값으로 빌드된 결과물이 올라가 있었던 것으로 본다. package.json 스크립트 기준의 추정이다. 운영은 아직 기간 제한이 없는 수정 전 코드였다
앱 로그에 Request가 남은, 즉 앱에 실제로 도달한 요청은 넷이다
| 앱 로그 시각 | 주문 엑셀 요청 | 결과 |
|---|---|---|
| 11:02:50 | 결제일 4/13~10/13 (6개월) | 응답 없음 → 1회차 OOM 11:04:57 |
| 11:04:09 | 4/12~10/13 | 응답 없음 → 1회차 OOM (앞 요청과 동시 진행) |
| 11:07:01 | 4/12~10/13 | 응답 없음 → 2회차 OOM 11:09:10 (이 요청 1건, 2분 9초) |
| 11:11:41 | 4/12~10/13 | 응답 없음 → 3회차 OOM 11:13:57 (이 요청 1건, 2분 16초) |
수정 전 코드에 6개월+1일짜리 요청 한 건만 들어가도 -Xmx2g에서 2분 남짓 만에 OOM이 났다. 전날 원인을 운영에서 다시 증명한 셈이 됐다. 원하지 않은 방식이었다. 기간 없는 요청은 로그에 남지 않았다. 앱이 이미 응답할 수 없는 상태였던 것으로 본다
이번에는 전날 넣은 설정이 동작했다. journalctl로 본 재시작 기록이다
| 회차 | 첫 OOM | 프로세스 종료 | 기동 완료 |
|---|---|---|---|
| 1 | 11:04:57 | 11:05:16 | 11:05:38 |
| 2 | 11:09:10 | 11:09:30 | 11:09:50 |
| 3 | 11:13:57 | 11:18:39 | 11:19:00 |
수동 조치 없이 세 번 모두 자동으로 다시 떴다. 다만 OOM에서 종료까지의 시간이 문제였다. 이 구간은 힙 덤프를 쓰는 시간이었다. 덤프 하나가 약 3.5GB였고, 1·2회차는 19.3초, 3회차는 281.5초가 걸렸다. 3회차가 왜 15배 느렸는지는 확인하지 못했다. 3회차의 4분 42초 사이, 11:17~11:18에 보낸 토큰 없는 요청도 15초 무응답이나 502였다
HeapDumpOnOutOfMemoryError는 원인 분석용 덤프를 남기지만, 그만큼 복구를 늦춘다. OOM이 반복되면 덤프가 PID마다 새로 쌓여 디스크를 채울 수도 있다. 이 트레이드오프는 인프라 담당으로서 내가 따로 판단할 문제로 남겼다
같은 작업 중에 사고가 하나 더 있었다. Playwright route.fetch의 TimeoutError가 호출 로그에 요청 헤더까지 출력해, 운영 로그인 토큰(유효 24시간)이 로컬 작업 대화 기록에 남았다. 이후 스크립트는 예외를 잡아 오류 메시지 첫 줄만 출력하고 Bearer ***로 가리도록 바꿨다
놓친 신호는 여러 번 있었다
이 사고를 되짚어 정리한 원인은 다섯 가지였다
- README와
.env.example의 dev 주소를 믿고 실제 요청 호스트를 확인하지 않았다 - 첫 단계 로그를 dev API 호스트로만 걸러 찍었는데 로그가 비어 있었다. 요청이 다른 호스트로 갔다는 신호였다
- 다운로드 로그에 호스트를 빼고 경로만 찍었다
- 원인을 찾으면서
curl로 dev API를 직접 호출해 보니 정상이라, 서버 코드 문제로 잘못 짚었다 - “확인만” 하는 작업이었는데 대상 서버의 코드 버전을 확인하기 전에 무거운 요청을 보냈다
필터 결과가 비었다면 요청이 없다는 뜻일 수도, 필터가 틀렸다는 뜻일 수도 있다. 이 사고에서는 필터가 틀렸다
이후 절차를 바꿨다. 화면을 거쳐 확인하기 전에 배포된 JS 번들에 들어간 API 주소부터 본다. 로그인 전에 정적 파일만 받아도 된다. 요청 로그는 호스트까지 찍고, 스크립트에는 허용한 호스트가 아니면 요청을 보내지 않고 멈추는 가드를 넣었다. dev 백오피스가 운영 API를 보던 문제는 프론트엔드 팀이 수정했다. 재빌드된 dev 번들(로그인 페이지 JS 17개)에 dev API 주소만 있고 운영 주소는 없는 것을 확인했다
배포 확인은 기존 코드에서도 가벼운 요청으로 했다
같은 날 저녁 운영에 배포했다. dev 브랜치는 main보다 367커밋 앞서 있어서, fix 브랜치의 커밋 3개만 main에 병합했다. 병합 결과 트리가 테스트한 커밋의 트리와 같은 것을 확인했다
오전 사고에서 배운 것은 확인 순서였다. 배포가 반영됐는지는 “기존 코드에서도 가벼운 요청”으로 먼저 본다. 시작일 > 종료일 요청은 새 코드에서는 즉시 400이고, 기존 코드였다면 0건 엑셀이라 부담이 없다. 반대로 기간 초과나 기간 없는 요청을 반영 확인용으로 쓰면, 반영이 안 됐을 때 그대로 장애가 된다
| 순서 | 요청 | 결과 |
|---|---|---|
| 1 | 결제일 시작 > 종료 (10/13~10/01) | 400 “시작일은 종료일보다 늦을 수 없습니다.” → 새 코드 반영 확인 (서버 처리 10ms) |
| 2 | 화면 기본값 정산일 10/13 | 200, 946행, 4.4초 → 재요청 1.9초 |
| 3 | 주문 목록·주문취소·정산내역·가맹점별 매출·정산달력 | 모두 200 |
| 4 | 6개월 초과 (4/12~10/13) | 400, 112ms (서버 처리 3ms) |
| 5 | 기간 없음 | 400, 43ms (서버 처리 3ms) |
| 6 | 6개월 (4/13~10/13) | 200, 20.1초, 14.6MB, 139,154행, CPU 알림 없음 |
| 7 | 다운로드 직후 목록 | 200 |
4·5·6번은 Claude Code 자동 모드의 분류기가 “운영 작업 방해”로 막았다. 같은 스크립트(호스트 가드 포함)를 내가 직접 실행했다.
주문취소 목록이 200이라는 것 자체가 스키마 정합성 근거다. 1054는 실행 준비 단계 오류라 결과가 0건이어도 발생한다. 6개월 엑셀은 모든 행이 23열이었고 영업이익 빈 칸이 없었다. 각 요청은 한 번씩 잰 값이다.
운영 20.1초가 로컬 7~8초보다 느린 이유는 운영 DB 왕복과 같은 서버의 다른 앱으로 추정한다. 오전 사고 때 프록시가 60초에 502를 돌려준 것으로 보아 프록시 타임아웃은 60초 안팎이고, 20초는 그 안이다.
실행 계획은 수정 전과 같았다
장애 직후 슬랙으로 인덱스 세 개를 추가하자는 문의가 왔다. settlement.payout_date, settlement(payment_date, status), brand.head_office_id다. 쿼리 최적화 요청이었으니 자연스러운 제안이다
내가 운영 DB에서 수정 전후 쿼리의 EXPLAIN을 떴다. EXPLAIN ANALYZE는 쿼리를 실제로 실행하므로 쓰지 않았다. 수정 후 정산 주문 목록(26컬럼, 10건, 결제일 6개월 조건)의 결과다
| id | select_type | table | type | possible_keys | key | ref | rows | filtered | Extra | |----|-------------|------------------|--------|-----------------------------------------------|---------------------|---------------------------|------|----------|----------------------------------------------| | 1 | SIMPLE | settlement | ALL | PRIMARY, brand_id_index, brand_store_id_index | | | 5953 | 1.11 | Using where; Using temporary; Using filesort | | 1 | SIMPLE | brand | eq_ref | PRIMARY | PRIMARY | settlement.brand_id | 1 | 100 | | | 1 | SIMPLE | brand_store | eq_ref | PRIMARY | PRIMARY | settlement.brand_store_id | 1 | 100 | | | 1 | SIMPLE | settlement_order | ref | settlement_id_index | settlement_id_index | settlement.id | 12 | 100 | |
수정 전 쿼리와 접근 방식, 사용 인덱스, 조인 순서가 모두 같았다. SELECT 컬럼 수는 실행 계획을 바꾸지 않는다. 개선은 읽지 않는 것에서 나왔다. 1054를 낼 컬럼을 나열하지 않게 됐고, 정렬과 임시 테이블에 올라가는 행이 166컬럼에서 26컬럼으로 좁아졌다. 행의 폭은 EXPLAIN에 나타나지 않는다. 디스크 임시 테이블이 실제로 줄었는지는 아직 재지 않았다
모든 쿼리가 settlement 전체 스캔(5,953행)에서 시작하고, 나머지는 PK·FK 인덱스 조인이다. 정산 쿼리 유형 대부분의 누적 평균이 0.01~0.09초였다. DB는 병목이 아니었다
문의된 인덱스 세 개는 모두 추가하지 않았다
| 인덱스 | 판단 근거 |
|---|---|
brand.head_office_id | 이 컬럼으로 조회·조인하는 쿼리가 없음. brand 422행 |
settlement.payout_date | settlement 5,953행이라 전체 스캔 비용이 작음 |
settlement(payment_date, status) | 결제일이 범위 조건이라 뒤의 status가 범위를 거의 좁히지 못함. status는 화면의 선택 조건 |
이번 장애는 인덱스로 막을 수 있는 문제가 아니었다. 쓰이지 않는 인덱스는 쓰기 비용만 더한다. 엑셀 서브쿼리의 Using filesort는 (settlement_order_id, income_target, id) 복합 인덱스로 없앨 수 있지만, 6개월 전체가 20.1초로 끝나고 쓰기가 계속 일어나는 테이블이라 넣지 않았다. 슬로우 로그에 settlement 기간 조회가 나타나거나 settlement가 수십만 행 규모가 되면 그때 EXPLAIN을 근거로 다시 본다. 지금은 월 약 1,900행씩 는다
스트리밍이 남긴 비용
스트리밍은 힙 문제를 풀었지만 다른 자원을 오래 잡는다
| 항목 | 상태 |
|---|---|
| 커넥션·트랜잭션 | 작성하는 동안(운영 6개월 약 20초) 커넥션 1개와 읽기 트랜잭션 유지. 동시 2건 제한으로 관리 |
| OSIV | spring.jpa.open-in-view를 설정하지 않아 기본으로 켜져 있다. permit을 반환한 뒤에도 응답 전송이 끝날 때까지 커넥션을 잡으므로 “동시 커넥션 2개”는 엄밀하지 않다. 기존 동작이다 |
| 메타데이터 락 | 작성 중 5개 테이블에 공유 메타데이터 락 유지. 이 사이 같은 테이블 DDL은 대기하고 뒤따르는 쿼리까지 막힐 수 있어, 운영 DDL은 다운로드가 없는 시간에 한다 |
| 응답 버퍼 | ByteArrayOutputStream + toByteArray()로 응답 크기(6개월 약 14.6MB)의 2~3배를 잠깐 힙에 잡는다. 기존 패턴 |
| 임시 디스크 | 압축 안 된 시트 XML이 동시 요청 수만큼 java.io.tmpdir/poifiles에 생긴다 |
| 첫 행까지 지연 | ORDER BY가 filesort라 서버가 정렬을 끝낸 뒤 첫 행을 보낸다 (로컬 첫 10행 1.09초) |
범위 밖으로 둔 위험도 있다. 이번에 고친 세 API 외에도 selectFrom으로 전체 컬럼을 조회하는 곳이 여러 군데 남아 있다. 개발 전용 컬럼이 다시 생기면 같은 1054가 난다. 통계 주문 목록은 호출하는 화면이 작업 공간에 없어서 dev·운영에서 수정 후 동작을 확인하지 못했다. 단위 테스트와 교차검증만 거쳤다. admin에는 actuator가 없다. 헬스체크 감시는 인프라 쪽에서 내가 따로 진행하고, 토큰 없이 아무 API나 호출해 N초 안에 응답(401이라도)이 오는지로 판단할 수 있다. 토큰 없는 요청은 DB를 거치지 않고 약 0.03초에 401을 받는다
남은 판단
이 이틀 동안 원인을 두 번 잘못 짚었다. 첫날은 코드만 보고 오류 번호를 추측했고, 운영 DB의 오류 통계를 열고 나서야 1054로 갔다. 장애 공유 문서의 5중 조인 가설도 로그로 확인되지 않은 판단이었다. 로그를 열자 그 쿼리는 데이터를 읽기도 전에 죽고 있었다. 둘째 날은 dev를 확인하려던 무거운 요청이 운영으로 나갔다
두 번 다 관측보다 해석이 앞섰다. 요청받은 처방은 쿼리 최적화였지만, 실제로 장애를 막은 처방은 서버가 감당할 수 없는 요청을 받지 않는 것이었다
메모리 장애에서 쿼리 최적화를 요청받았다면, 인덱스를 보기 전에 앱 로그에서 OOM 직전의 요청 파라미터와 오류 번호별 통계부터 확인한다. 그 요청이 상한 없이 받아들여지고 있었다면 먼저 할 일은 입력 제한과 스트리밍이다
용어 정리
OOM(OutOfMemoryError) — JVM이 객체를 할당할 힙 공간을 확보하지 못했을 때 던지는 오류다. 오류를 던진 요청만 실패하고 프로세스는 계속 살아 있을 수 있다. -XX:+ExitOnOutOfMemoryError를 주면 OOM이 나는 즉시 JVM이 종료된다. 힙 덤프 옵션을 함께 주면 덤프를 다 쓴 뒤에 종료된다
jOOQ 생성 코드 — DB 스키마를 읽어 테이블·컬럼을 Java 클래스로 만들어 두는 jOOQ의 코드 생성 결과다. 생성 기준이 된 DB와 실제로 접속하는 DB의 스키마가 다르면 컴파일은 되지만 실행 시점에 컬럼 오류가 난다
XSSF와 SXSSF — Apache POI의 xlsx 구현이다. XSSF는 시트 전체를 객체 트리로 메모리에 들고, SXSSF는 최근 N행만 메모리에 두고 나머지를 임시 파일로 내린다. SXSSF에서 내린 행은 다시 읽을 수 없다
MySQL 스트리밍 결과셋 — Connector/J가 결과 전체를 받지 않고 한 행씩 읽는 모드다. fetch size Integer.MIN_VALUE로 켠다. 결과셋을 닫기 전까지 같은 커넥션으로 다른 쿼리를 보낼 수 없다
OSIV(Open Session/EntityManager In View) — 웹 요청이 끝날 때까지 JPA EntityManager와 커넥션을 열어 두는 패턴이다. Spring Boot에서는 기본으로 켜져 있어, 서비스 메서드가 끝난 뒤에도 응답을 보내는 동안 커넥션을 잡을 수 있다
변이 테스트 — 코드에 일부러 결함을 넣고 테스트가 실패하는지 확인하는 방법이다. 결함을 넣었는데 테스트가 통과하면(살아남으면) 그 테스트는 해당 결함을 검사하지 않는 것이다