서론
Spring Boot 프로젝트를 하면서 궁금했던 Transactional의 readOnly와 그와 자주 언급되는 OSIV에 대해서 알아보는 시간을 가지겠습니다.
Dirty Checking
개념
Transactional과 OSIV에 대해서 이야기 전에 가장 먼저 알아야할 개념입니다.
JPA는 특정 Entitiy가 영속성 컨텍스트에 로드될 때 초기 상태를 스냅샷을 찍고, 트랜잭션이 종료될 떄 찍은 스냅샷과 비교합니다. 만약, 스냅샷과 다른 상태라면 자동으로 업데이트를 해주는 것을 말합니다.
스냅샷 (SnapShot)
사진을 찍는다의 의미로, JPA에서는 Entity 특정 시점의 원본 상태를 저장해둔 복사본을 의미한다.
이러한, 스냅샷은 메모리에서만 존재하는 특징을 가진다.
코드
// 트랜잭션 시작
@Transactional
public void updateUserName(Long userId, String userName) {
// Repository에서 Entity를 가져옴과 동시에 영속성 컨텍스트에 로드
AppUser appUser = appUserRepository.findByUserId(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
// 변경 사항 발생
appUser.setNickname(userName);
// 명시적인 save() 호출이 필요 없음
// 트랜잭션 종료 시 JPA가 변경사항을 감지하고 자동으로 UPDATE 쿼리 실행
}
@Transactional
Transaction이란?
데이터베이스에서 상태를 변화시키기 위한 최소의 작업 단위
쉽게 말해, 여러 개의 쿼리 문을 하나의 작업 단위로 묶고, 성공 여부에 따라 적용 또는 롤백을 하는 것이라고 보면 된다.
용도
- updateUserName에 메소드가 시작됨과 동시에, @Transactional로 인해 트랜잭션이 시작됩니다.
- setNickname을 통해 변경 사항이 발생됩니다.
- IllegalArgumentException을 발생시킵니다.
- JPA는 메소드가 비정상적으로 종료되었음을 깨닫고, rollback이 발생됩니다.
// 트랜잭션 시작
@Transactional
public void updateUserName(Long userId, String userName) {
// Repository에서 Entity를 가져옴과 동시에 영속성 컨텍스트에 로드
AppUser appUser = appUserRepository.findByUserId(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
// 변경 사항 발생
appUser.setNickname(userName);
// 에러가 발생되면서 Rollback을 진행
throw new IllegalArgumentException("Error !");
}
readonly 속성
트랜잭션 어노테이션에 대하여 찾아보는 도중, readonly라는 속성을 봤습니다. 대부분 블로그에서는 조회하는 메소드에는 readonly를 사용하는 것을 권장한다고 합니다. 하지만, 무엇 때문에 권장하고 왜 사용하는지에 대해서 알아보겠습니다.
조회만 가능
readonly라는 이름에서 유추할 수 있듯이, 조회에만 사용하는 트랜잭션입니다.
이 덕분에 해당 메소드는 조회만 하는 메소드임을 명시적으로 나타내는 어노테이션의 역할도 합니다.
@Transactional(readOnly = true)
public void updateUserName(Long userId, String userName) {
AppUser appUser = appUserRepository.findByUserId(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
appUser.setNickname(userName);
appUserRepository.saveAndFlush(appUser);
}
Snapshot + Dirty Checking 제거
이 때, 위에서 언급한 거와 같이 Entity가 영속성 컨텍스트에 로드될 때 스냅샷을 남기고, 트랜잭션이 끝날 때 Dirty Checking을 통해 변경 사항을 감지하는 역할을 했었는데, 이게 사라진다고 보면 됩니다.
스냅샷을 통해, 저장될 복제본이 없어졌기 떄문에 메모리 공간 또한 확보가 됩니다.
OSIV (Open-Session-In-View)
여기서, 의문점이 발생하실겁니다.
그래서 readonly 특성과 OSIV랑은 무슨 관계인데?
OSIV의 대해서 알아보고, 바로 어떠한 관계를 가지는지 바로 알려드리겠습니다.
개념
OSIV는 영속성 컨텍스트를 View까지 열어두는 속성 중 하나입니다. 이를 다시 말하면, DB의 커넥션이 Http 요청이 끝날 떄까지 커넥션을 유지시키는 의미가 됩니다.
public Board getBoard() {
Board board = boardRepository.findByBoardId(1L)
.orElseThrow(() -> new IllegalArgumentException("Board not found"));
int activeConnections = hikariDataSource.getHikariPoolMXBean().getActiveConnections();
int totalConnections = hikariDataSource.getHikariPoolMXBean().getTotalConnections();
int idleConnections = hikariDataSource.getHikariPoolMXBean().getIdleConnections();
log.info("Active Connections: {}", activeConnections);
log.info("Total Connections: {}", totalConnections);
log.info("Idle Connections: {}", idleConnections);
return board;
}
위와 같은 예제를 보면 쿼리 문이 종료되었음에도 불구하고, Hikari의 Connection은 유지되는 걸 볼 수 있습니다.
이와 같이 트래픽이 몰리는 상황에서 Connection을 사용하지 않는 Controller, View 단에서도 Connection을 물고 있으면, Connection이 부족한 현상까지 올 수 있습니다.
비활성화 시
다음과 같이 OSIV를 비활성화 할 수 있습니다.
OSIV가 비활성화 되면 Http 요청이 끝나야 커넥션을 반환하는 것이 아닌, 쿼리 사용 후 바로 커넥션을 반환하기 시작합니다.
spring.jpa.open-in-view=false
Connection 확인
public Board getBoard() {
Board board = boardRepository.findByBoardId(1L)
.orElseThrow(() -> new IllegalArgumentException("Board not found"));
int activeConnections = hikariDataSource.getHikariPoolMXBean().getActiveConnections();
int totalConnections = hikariDataSource.getHikariPoolMXBean().getTotalConnections();
int idleConnections = hikariDataSource.getHikariPoolMXBean().getIdleConnections();
log.info("Active Connections: {}", activeConnections);
log.info("Total Connections: {}", totalConnections);
log.info("Idle Connections: {}", idleConnections);
return board;
}
쿼리가 종료됨과 동시에 Connection을 반환하는 것을 알 수 있습니다.
Lazy Loading
저는 BoardEntity의 user_id를 Lazy Loading 설정해놨습니다.
public Board getBoard() {
Board board = boardRepository.findByBoardId(1L)
.orElseThrow(() -> new IllegalArgumentException("Board not found"));
String uploader = board.getUser().getNickname();
log.info("uploader: {}", uploader);
int activeConnections = hikariDataSource.getHikariPoolMXBean().getActiveConnections();
int totalConnections = hikariDataSource.getHikariPoolMXBean().getTotalConnections();
int idleConnections = hikariDataSource.getHikariPoolMXBean().getIdleConnections();
log.info("Active Connections: {}", activeConnections);
log.info("Total Connections: {}", totalConnections);
log.info("Idle Connections: {}", idleConnections);
return board;
}
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", columnDefinition = "INT UNSIGNED", nullable = false)
private AppUser user;
외래키 연관 관계에 대한 Fetch 전략을 간단하게만 설명하겠습니다.
EAGER | 해당 Entity를 조회할 떄 연관 Entity를 추가 Select하여 한 번에 가져옵니다. |
LAZY | 연관 Entity에 접근할 때 추가 Select를 하여 가져옵니다. Entity를 가져올 때마다 연관된 Entity를 가져오는 것은 매우 비효율적이므로, 대부분 Lazy Loading을 설정합니다. |
기본적으로, Fetch 전략은 EAGER가 기본값입니다.
비활성화를 하고, 위 코드를 돌려보면 다음과 같은 에러를 발견하실 수 있습니다.
LazyInitializationException이 발생하면서 에러가 발생합니다.
Lazy Loading은 Entity가 영속성 컨텍스트에서 관리되고 있어야 동작하는 기능 중 하나입니다.
하지만, OSIV를 비활성화 하면서 쿼리가 끝나자마자 커넥션을 반납하기 떄문에 영속성 컨텍스트에 관리되지 못해 Lazy Loading을 실패하는 겁니다.
해결 방법
결국, 저희가 원하는 마지막 주제에 도달했습니다.
이렇게 Lazy Loading과 같은 상황이 발생한다면 @Transactional을 통해 영속성 컨텍스트를 메소드 단위로 유지시킬 수 있습니다.
@Transactional(readOnly = true)
public Board getBoard() {
...
}
@Transactional을 통해 getBoard 내에서 영속성 컨텍스트가 유지됨과 동시에 Lazy Loading까지 성공합니다.
또한, readonly를 통해 스냅샷, Dirty Checking을 비활성화 하면서 성능의 이점도 가져올 수 있게 되었습니다.
'Project 하면서 알아가는 것들' 카테고리의 다른 글
MultipleBagException의 발생 원인과 해결 방법 (0) | 2025.03.25 |
---|---|
JPA의 N + 1 문제를 알아보고 해결하자 (0) | 2025.03.23 |
[Spring Boot + Python] Kafka를 사용해보자 (4) | 2024.09.30 |
Kafka와 RabbitMQ를 알아보자 (0) | 2024.08.20 |
[Nextjs] Tiptap 사용법과 커스텀마이징 기능 구현 (1) | 2024.02.28 |