하이러닝 성능 최적화 및 운영 안정화, 2024.01 ~ 2025.10 트러블슈팅
끝나지 않던 수동 트랜잭션 경로를 닫고 readOnly 적용 단위를 API 호출 경로로 정함
선언적 트랜잭션 대상에서 빠진 나의 수업 메뉴 수동 트랜잭션의 종료 경로에 commit·rollback을 넣어 커넥션 풀 고갈 위험을 줄이고, Replica 라우팅을 위한 readOnly 적용 기준을 API 호출 경로 전체로 정함
이슈 상황
- 포털은 공통 AOP 설정으로 API·서비스 클래스의 메서드에 트랜잭션을 걸었습니다. 일부 API는 이와 별도로 코드에서 트랜잭션을 직접 열었습니다(수동 트랜잭션).
- AOP 트랜잭션의 적용 대상이 줄면서, 일부 수동 트랜잭션은 참여할 바깥 트랜잭션 없이 실행돼 트랜잭션을 직접 시작하고 끝내야 하는 쪽이 됐습니다.
- 읽기·쓰기 DB 연결을 나눈 구조에서 조회 부하를 Replica로 넘기려면, 어떤 트랜잭션을 readOnly로 둘지 정해야 했습니다.
원인 분석
끝나지 않던 수동 트랜잭션
- 수동 트랜잭션은
PlatformTransactionManager.getTransaction()에 전파 속성REQUIRED만 지정해 열었습니다. - 바깥 AOP 트랜잭션이 있을 때는 참여만 했고 commit은 AOP가 해서, 코드에 commit이 없어도 드러나지 않았습니다. AOP 적용 대상이 준 뒤에는 정상 반환 경로와 처리하지 않는 요청 분기에서 트랜잭션이 끝나지 않았습니다.
- Spring은 트랜잭션이 끝날 때까지 DB 연결을 요청 스레드에 묶어 둡니다. 트랜잭션이 끝나지 않으면 연결이 커넥션 풀로 돌아가지 않습니다.
- 당시 커넥션 풀 설정으로는 반납되지 않은 연결이 쌓이면 이후 요청이 끝없이 기다릴 수 있었습니다.
| 커넥션 풀 설정 (DBCP 1.x) | 값 | 뜻 |
|---|---|---|
maxActive | 50 | 동시에 빌려줄 수 있는 연결 수 |
maxWait | -1 | 연결이 없을 때 기다리는 시간. -1은 제한 없음 |
removeAbandoned | 설정 없음 | 오래 반납되지 않은 연결을 풀이 회수하지 않음 |
메서드 단위 readOnly의 문제
- 프로젝트는
AbstractRoutingDataSource로 현재 트랜잭션이 readOnly이면 Replica, 아니면 Primary 연결을 골랐습니다. 실제 연결은LazyConnectionDataSourceProxy가 첫 쿼리 때까지 미뤄, 트랜잭션 속성이 정해진 뒤에 고르게 했습니다. - readOnly를 메서드 단위로 붙이면 문제가 생길 수 있었습니다. readOnly 트랜잭션 안에서 기본 전파(
REQUIRED)로 쓰기 메서드를 부르면, 쓰기 메서드는 바깥 트랜잭션에 참여해 Replica 연결을 그대로 씁니다. 그러면 쓰기가 실패하거나, 실패하지 않더라도 복제 지연 때문에 방금 쓴 데이터가 보이지 않을 수 있습니다.
readOnly 설정 방식 보기
<!-- 메서드 이름으로 readOnly를 지정하는 방식 (예시) -->
<tx:advice id="txAdvice">
<tx:attributes>
<tx:method name="selectXxxList" read-only="true"/>
<tx:method name="*" read-only="false" rollback-for="Exception"/>
</tx:attributes>
</tx:advice>
- 수동 트랜잭션은 readOnly를 지정하지 않아, 조회만 하는 상세 API도 Primary 연결을 썼습니다.
개선
수동 트랜잭션의 종료 경로 닫기
수동 트랜잭션 코드 모양 보기 (commit·rollback 추가 위치)
// 수동 트랜잭션 (이름은 일반화)
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED); // readOnly, timeout 지정 없음
TransactionStatus status = transactionManager.getTransaction(def);
try {
if (!isSupported(request)) {
transactionManager.rollback(status); // 추가
return badRequest();
}
Result result = process(request);
transactionManager.commit(status); // 추가
return ok(result);
} catch (Exception e) {
// 공통 예외 처리기가 아직 끝나지 않은 트랜잭션을 rollback (원래 있던 경로)
throw wrap(e, status);
}
- 정상 반환 직전에는 commit을, 처리하지 않는 요청 분기에는 rollback을 넣었습니다. 대상은 수업 활동 등록·상세 조회·수정, 알림장 댓글 등록, 과목 학생 삭제입니다.
- 수정 뒤 해당 API를 호출하고, DB 로그에서 트랜잭션이 commit 또는 rollback으로 끝나는지 확인했습니다.
readOnly 적용 단위를 API 호출 경로로
- 쓰기가 섞인 트랜잭션이 Replica로 가지 않도록, 판단 단위를 메서드가 아니라 API와 그 아래 호출 경로 전체로 정했습니다. 호출 경로 전체가 조회인 API에만 readOnly를 적용하는 기준을 세웠습니다.
결과
- 나의 수업 메뉴(수업 활동, 알림장 댓글, 과목 학생 삭제)의 수동 트랜잭션에서 commit이 빠진 경로를 닫아 커넥션 풀 고갈 위험을 줄였습니다.
- 데이터 변경이 섞인 트랜잭션이 Replica로 가지 않는 readOnly 적용 기준을 세워, 조회 전용 API의 부하를 Replica로 넘길 수 있게 했습니다.
- 이 기준으로 조회 API 28개의 부하를 Replica로 분산했습니다.