문제 상황
Spring Boot + Spring Data JPA 환경에서 다음과 같은 코드가 있었다
@Transactional(readOnly = true)
public Post readById(Long postId, Long userId) {
PostEntity post = postRepository.findById(postId).orElseThrow(() -> throw new RuntimeException("..."))
viewCountService.increase(post.getPostId, userId);
...
}
클라이언트가 게시글을 조회할 때 조회수가 증가하는 로직이다. 해당 메서드에서는 읽기 전용 @Transactional 이 발생한다.
그리고 increase 메서드 내부
viewCountBackUpService.backUp(postId, viewCount);
조회수는 Redis를 통해서 조회해오는 상황이다. Redis에서의 조회와 더불어 특정 조회수에 도달하게 되면 DB에 백업을 하기 위해서 update 및 insert 쿼리를 발생시키도록 로직을 작성했다.
@Transactional
public void backUp(Long postId, Long viewCount) {
repository.save(new ViewCountEntity(postId, viewCount)); // ❌ INSERT 쿼리 미발생
}
}
하지만 현재 save 쿼리메서드 호출 시 insert 쿼리가 발생하지 않는 문제가 발생했다.
문제 원인
해당 현상은 외부 메서드인 readById 메서드에서 읽기 전용 트랜잭션인 @Transactional(readOnly = true)가 발생했고 내부에서는 쓰기 트랜잭션이 발생했다.
이는 외부 메서드 readById에서 발생한 트랜잭션이 내부 메서드인 backUp까지 전파 되면서 쓰기 작업 트랜잭션이 발생하지 않은 것이다.
스프링의 트랜잭션 전파 기본 전략은 하나의 메서드에서 이미 발생한 트랜잭션이 존재하다면 해당 트랜잭션에 참여하는 것이 기본 전략인데, 이 때 외부 메서드의 읽기 전용 트랜잭션에 내부 메서드가 참여하게 되면서 해당 로직의 전체는 읽기 전용 트랜잭션에 참여하게 되면서 쓰기 트랜잭션이 작동하지 않은 것이다.

해결 방법
트랜잭션 전파 전략을 REQUIRES_NEW 로 설정함으로써 외부 트랜잭션과는 별개의 트랜잭션을 생성하여 참여 해줄 수 있다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void backUp(Long postId, Long viewCount) {
repository.save(new ViewCountEntity(postId, viewCount)); // INSERT 쿼리 발생
}
}
이렇게 하면 외부 메서드에서 호출 된 읽기 트랜잭션과는 별개의 트랜잭션을 생성함으로써 문제를 해결 할 수 있다.
해당 방식은 트랜잭션을 분리해줌으로써 외부 메서드의 트랜잭션과는 별개로 커밋과 롤백을 관리해준다.
즉 외부 메서드에서 에러가 발생해도 내부 트랜잭션까지는 전파가 안되므로 내부 트랜잭션은 마치 별도의 트랜잭션 처럼 작동하며 커밋과 롤백을 보장해준다는 장점이 있다.
단점으로는 커넥션 풀 부족 및 커넥션의 누수를 야기시킬 수 있다. 하나의 요청에 동시에 많은 트랜잭션이 발생하면 커넥션 풀이 부족해질 수 있으며, 트랜잭션이 끝나지 않고 있다면 커넥션 풀에 반환되지 않기 때문에 누수를 야기할 수 있다.
새로운 트랜잭션을 만든다는 것은 새로운 DB 커넥션을 커넥션 풀에 꺼내서 사용하게 되는 것이고, 기존 외부 메서드에서 발생한 트랜잭션을 보류가 되기 때문에 누수가 될 수 있다. 하나의 요청안에서 추가적인 N개의 트랜잭션을 새롭게 발생하게 되면 N개의 커넥션을 사용하게 되는 것이다.
이는 대량 요청시 병목 현상이 발생하고 전체 성능이 저하된다.
그래서 대용량 트래픽 환경에서는 해당 방식 보다는 트랜잭션을 분리해서 우회하는 방식을 통해 해결하는 방식이 더 바람직한 방법이 될 것 같다.
'프로젝트 이슈 및 몰랐던점 정리 > CommunityAPI' 카테고리의 다른 글
| [학습 포인트] 💡Redis 단위 테스트 작성하기 feat.ValueOperations, Operations (0) | 2025.03.30 |
|---|---|
| [트러블 슈팅] ⚠️A component required a bean named 에러 원인 (0) | 2025.03.30 |
| [트러블 슈팅] ⚠️ Spring Boot LocalDateTime 변환 에러 해결법 (@JsonFormat vs @DateTimeFormat) (0) | 2025.03.09 |
| [트러블 슈팅] ⚠️ @RequestBody로 MultipartFile을 받을 수 없는 이유와 해결법(@RequestPart) (0) | 2025.03.09 |
| [학습 포인트] 💡 JPA에서 update 시 @Modifying 애노테이션이 필요한 이유 (0) | 2025.03.09 |