조회 API가 매번 write 트랜잭션을 만들고 있었다
- 발단 — 대시보드 조회 API 응답 지연
- 확인 결과 — eco-view의 배출원 조회 경로가
lcaStepService.updateStepOnly(write)를 부수효과로 호출 - 즉 대시보드를 열 때마다 write 트랜잭션 생성
조치:
- 조회 전용 메서드
getGwpTotalForView신규 -
계산 로직 공통화로 부수효과 분리
- 여기까지는 평범한 수정
- 진짜 문제: 이런 걸 어떻게 다시 안 생기게 하느냐
먼저 실패한 시도 — readOnly=true를 붙였다 떼기
-
시간을 조금 앞으로 돌리면, 2026-05-29에 이미 한 번 실패
- 대상 —
getCutOffIoList·getFinalIoList두 메서드에@Transactional(readOnly = true)부착 - 충돌 — 이 메서드들이 내부적으로 호출하는
updateStepAndSubStepOnly가 쓰기 트랜잭션 - 결과 —
readOnly = true를 도로 제거하는 커밋
“조회 메서드니까 readOnly를 붙이면 되겠지”가 통하지 않았던 것이다.
- 실태: 조회 메서드 안에서 상태를 갱신하는 GET-in-write 패턴이 여러 곳 잔존
-
애노테이션 하나로 걷어낼 수 있는 범위 밖
- 해당 메서드에는 구조 설명 + TODO 주석 잔류
- 내용 — Read Replica 도입 시 별도 엔드포인트 분리가 선행돼야 함
즉 문제는 두 층:
- 조회 경로에 write 혼입 (개별 수정 대상)
- 혼입 여부를 자동으로 알 방법 부재 (구조적 문제)
가드레일을 만들었다
- 두 번째 시도에서
ReadOnlyTxGuardConfig추가 (약 200줄) - 목적 — readOnly 트랜잭션 안에서 write SQL 실행 시 테스트 실패
동작 방식:
-
BeanPostProcessor로DataSource빈을 가로채 JDK 동적 프록시로 래핑 - Statement 실행 직전
TransactionSynchronizationManager.isCurrentTransactionReadOnly()확인 - readOnly인데 SQL이 INSERT / UPDATE / DELETE / MERGE 중 하나면
AssertionError
if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()
&& isWriteStatement(sql)) {
throw new AssertionError(...);
}
설계에서 신경 쓴 것
테스트 전용
-
@TestConfiguration선언 → 프로덕션 컨텍스트 미적재 - 이유 — 운영 트래픽에 SQL 문자열 검사를 끼워 넣는 건 비용도 위험도 큼
선택적 활성화
- 전역 적용 아님.
@Import(ReadOnlyTxGuardConfig.class)를 붙인 테스트에서만 활성화 - 이유 — GET-in-write 패턴 잔존 상태라 전부 켜면 기존 테스트 대량 실패
- 방식 — 정리된 영역부터 하나씩 확대
기존 래핑과 공존
- 이 프로젝트의
DataSource는 이미 P6Spy(SQL 로깅)·HikariCP(커넥션 풀)로 다중 래핑 상태 -
JDK 동적 프록시로 인터페이스 레벨에서 감싸는 방식 채택 → 그 계층들과 충돌 없이 적재
- 상속·구체 클래스 프록시 채택 시 이 조합에서 파손 예상
왜 SQL 레벨인가
readOnly 위반 감지 후보 — JPA 레벨 dirty checking 감시, AOP로 서비스 메서드 검사 등.
SQL 레벨을 택한 건 거기가 마지막 관문이기 때문이다.
- JPA 우회 native query, QueryDSL 벌크 연산, JdbcTemplate 직접 호출 — 어떤 경로든 결국
Statement통과 - 상위 계층 감지 시 우회로 발생
| 감지 위치 | 잡히는 범위 | 우회 가능성 |
|---|---|---|
| 서비스 메서드 AOP | 애노테이션 붙은 호출 | 내부 호출·native 우회 |
| JPA dirty checking | 엔티티 변경 | 벌크·native 우회 |
| Statement 실행 직전 | 모든 SQL | 없음 |
남는 교훈
-
이 작업의 핵심: 같은 문제를 두 번 다르게 처리
- 1차 시도(5월 29일) —
readOnly = true를 붙여 “선언”. 되돌려짐 - 2차 시도 — 위반을 검출하는 쪽으로 방향 전환
선언은 코드베이스가 이미 그 규약을 지키고 있을 때만 통한다. 지키지 않는 코드가 남아 있는 상태에서 선언부터 붙이면 런타임에 터진다. 검출 장치는 반대다 — 지키지 않는 곳이 어디인지 먼저 알려주고, 정리된 영역부터 규약을 확대할 수 있게 해준다.
- “조회 API가 write를 유발한다” — 코드 정독으로 알아내기 어려운 종류
- 호출 체인 서너 단계 아래의 부수효과는 육안 식별 불가
- 노출 조건: 실행 시점에 SQL을 붙잡는 장치