티스토리 뷰
[개념] Service 계층 메소드에서 @Transactional, @Transactional(readOnly = true), 트랜잭션 어노테이션 없음 간 동작방식의 차이
다음김 2023. 1. 17. 14:20
김영한님의 스프링 강의를 들어오면서 CUD에는 @Transactional
을 R에는 아무 어노테이션을 붙여오지 않았다. 이게 정석인 줄 알았는데 같이 프로젝트를 진행한 팀원이 service 계층의 모든 메소드에 해당 어노테이션을 붙이는 것을 보고 관련 내용들을 찾아보니 이 3개가 엄연히 말하면 모두 다른 것이었다. 이번 글에서는 @Transactional
, @Transactional(readOnly = true)
, 트랜잭션 어노테이션 없음 간 동작방식의 차이에 대해서 자세히 알아보고자 한다.
public class Service {
@Transactional
public void createOrUpdateOrDeleteSomething() {}
@Transactional(readOnly = true)
public void readSomething() {}
public void doNothingRelatedToDB() {}
}
3가지 경우의 차이를 비교하기 전 각 어노테이션들의 의미를 간단히 살펴보자.
@Transactional(readOnly = true)
- DB에서 최초로 조회 시 변경감지를 위한 snapshot을 저장하지 않는다.
- MySQL의 경우, master/slave로 나누어져 있다면 slave DB(읽기 전용)를 호출함으로써 서버 부하를 줄인다.
- 트랜잭션을 commit 하기 전 flush 하지 않아 의도치 않게 데이터가 변경되는 것을 막아준다.
*스프링 프레임워크가 하이버네이트 세션 플러시 모드를 MANUAL로 설정하기 때문에 강제로 flush를 호출하지 않는 한 flush가 발생하지 않는다.
*flush: 영속성 컨텍스트의 변경내용을 데이터베이스에 동기화하는 작업 (변경내용에 대한 SQL을 DB에 날림)
@Transactional
- CUD하는 경우 사용, 즉 DB 데이터를 변경할 때
- DB에서 최초로 조회 시 변경감지를 위한 snapshot을 저장한다.
- MySQL의 경우, master DB를 호출한다.
- 트랜잭션 commit 직전 flush를 수행함으로써 영속성 컨텍스트의 변경내용을 데이터베이스에 동기화한다.
트랜잭션 어노테이션 안붙임
- 트랜잭션이 시작되지 않아 DB에 쿼리를 보낼 수 없다.
일단 @Transactional
, @Transactional(readOnly = true)
간의 차이를 예시를 통해 자세히 알아보자.
먼저 예시를 위한 Team
클래스는 다음과 같다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Team {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "team_id")
private Long id;
@Column(nullable = false)
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
public Team(String name) {
this.name = name;
}
public void setName(String name) {
this.name = name;
}
}
새로운 Team
을 생성하는 api에서 호출되는 service 메소드는 다음과 같다.
@RequiredArgsConstructor
@Service
public class TeamService {
private final TeamRepository teamRepository;
@Transactional
public void saveTeam(String name) {
Team team = new Team(name);
teamRepository.save(team);
}
}
이 경우에는 @Transactional
어노테이션이 붙어 다음과 같이 insert 쿼리가 나간다.
Hibernate:
insert
into
team
(team_id, name)
values
(default, ?)
해당 메소드에 @Transactional(readOnly = true)
를 붙이게 되면 어떻게 될까? 문제는 여기서 발생하는데... 예상과 다르게 insert 쿼리가 날라가는 것이었다. readOnly 트랜잭션이라면 트랜잭션 커밋 시점에 flush가 수행되지 않아 아무리 해당 엔티티가 영속성 컨텍스트에 영속화(persist)되었다고 하더라도 db에 변경사항이 적용되어서는 안된다. 그러나 teamRepository.save(team)
메소드를 호출하면 바로 insert 쿼리가 나가는 것이었다. 심지어 @Transactional
어노테이션이 붙은 경우에도 트랜잭션이 커밋되기 바로 직전이 아닌 save 메소드 호출 시 바로 db에 쿼리가 나가는 것이었다. 왜 그럴까...?를 한참이고 생각하던 중 기억 한 켠에 자리잡고 있던 영속성 컨텍스트의 엔티티 관리 방식이 떠올랐다. 아래 내용은 참고이므로 바쁜 분들은 스킵하셔도 된다.
@Transactional(readOnly = true)임에도 insert 쿼리가 발생하는 경우
위 내용을 한 문장으로 정리하자면 엔티티의 키 매핑 전략이 IDENTITY이기 때문에, 엔티티를 영속화했을 때 읽기 전용 트랜잭션임에도 불구하고 insert 쿼리가 실행되는 것이다.
다음으로 각 CUD 동작에 대한 @Transactional
과 @Transactional(readOnly = true)
의 차이를 조금 만 더 살펴보자.
스프링 데이터 JPA에서 제공하는 JpaRepository
를 상속받은 레포지토리로 Create, Delete를 수행하기 위해 각각 save
, delete
메소드를 호출할 때, 영속성 컨텍스트에서는 해당 엔티티의 생명주기를 바꾼다. 실제 JpaRepository
의 구현체인 SimpleJpaRepository
의 코드를 보면 save
메소드에서는 em.persist(...)
를 호출함으로써 해당 엔티티를 영속화하고, delete
메소드에서는 em.remove(...)
를 호출함으로써 해당 엔티티를 삭제 상태로 바꾼다.
이는 최종적으로 flush를 수행함으로써 변경내용이 DB에 동기화된다. (즉 insert, delete 쿼리가 실행된다.)
반면 엔티티에 대한 변경은 dirty checking(변경 감지)을 통해 이뤄지는데, DB에서 읽어온 최초 시점의 상태를 저장해놓은 snapshot을 트랜잭션 커밋 시점에 영속성 컨텍스트의 엔티티와 일일이 비교하고 변경된 내용에 대해 update 쿼리가 실행된다.
읽기 전용 트랜잭션에서는 트랜잭션 커밋 전 flush 하지 않으므로 해당 엔티티가 persist, remove되었다 한들 이것이 DB에 반영되지 않으며, 아무리 DB에서 조회해온 기존의 엔티티가 변경되었다고 한들 비교 대상인 snapshot이 존재하지 않아 update 쿼리가 생성되지 않고 변경내용은 무시된다.
위에서 @Transactional(reaOnly = true)
는 '스프링 프레임워크가 하이버네이트 세션 플러시 모드를 MANUAL로 설정하기 때문에 강제로 flush를 호출하지 않은 한 flush가 발생하지 않는다.' 라고 언급하였다. 정말 그러한지 코드로 확인해보자.
여기서 플러시 모드는 EntityManager
의 FlushModeType
이 아닌 Session
의 FlushMode
를 의미한다. EntityManager
와 Session
의 차이는 EntityManager
는 JPA 표준, Session
은 Hibernate 표준이라는 것이다. 참고로 Hibernate는 JPA의 구현체이며 스프링 데이터 JPA는 Hibernate를 기반으로 한다. 그냥 간단히 말해서 EntityManager
의 구현체가 Session
인 것이다. (더 자세한 내용은 링크 참고)
@RequiredArgsConstructor
@Service
public class TransactionalService {
private final EntityManager em;
@Transactional
public void transactionalTest() {
Session session = em.unwrap(Session.class);
FlushMode flushMode = session.getHibernateFlushMode();
System.out.println("flushMode = " + flushMode); //AUTO
}
@Transactional(readOnly = true)
public void readOnlyTransactionalTest() {
Session session = em.unwrap(Session.class);
FlushMode flushMode = session.getHibernateFlushMode();
System.out.println("flushMode = " + flushMode); //MANUAL
}
}
EntityManager
를 service 내부에서 주입받아 unwrap
메소드를 통해 구현체인 Session
객체를 얻을 수 있다. 위 코드를 실행해보면 @Transactional
에서는 flushMode가 AUTO, @Transactional(readOnly = true)
에서는 MANUAL인 것을 확인할 수 있다. 따라서 아무리 읽기 전용 트랜잭션이라 하더라도 직접 em.flush()
를 호출하면 현재까지의 영속성 컨텍스트에서의 변경사항이 DB에 동기화된다.
글이 너무 길어졌는데 여기서 글을 끊고 가기에는 애매해서 다음 내용을 짧게 이어나가려고 한다.
사실 그동안 내가 Read 동작에 대해서 @Transactional(readOnly = true)
를 붙이지 않았던 가장 큰 이유는 그래도 쿼리가 나갔고 예상대로 잘 동작했기 때문이다. 사실 service 레벨에 트랜잭션 어노테이션을 붙이지 않아도 DB에 대한 CRUD 작업이 가능했던 이유는 스프링 데이터 JPA의 JpaRepository
의 구현체인 SimpleJpaRepository
내부에 트랜잭션 어노테이션이 붙어있기 때문이다.
그러나 service 레벨에서 @Transactional(readOnly = true)
과 트랜잭션 어노테이션 없음의 차이는 OSIV를 껐을 때 극명하게 드러나는데 OSIV의 기본 설정이 ON이기 때문에 그동안 차이를 별로 느끼지 못했던 것이다. (OSIV에 대한 자세한 내용은 링크 참고)
코드를 보기에 앞서 말로 조금 설명하자면 OSIV를 껐을 때 영속성 컨텍스트의 수명은 트랜잭션에 묶인다. 즉 트랜잭션이 커밋되어 종료되면 영속성 컨텍스트도 종료된다. 따라서 OSIV를 껐을 때 하나의 service 메소드 내에서 동일한 엔티티를 id로 2번 조회하는 경우 이를 트랜잭션으로 묶어주지 않으면 DB에 대한 select 쿼리가 2번 실행된다. 아래 코드를 통해 이를 확인해보자.
spring.jpa.open-in-view=false
위 설정으로 OSIV를 끈다.
@RequiredArgsConstructor
@Service
public class TeamService {
private final TeamRepository teamRepository;
@Transactional(readOnly = true)
public Team getTeam(Long teamId) {
Team team = teamRepository.findById(teamId).orElseThrow(RuntimeException::new);
teamRepository.findById(teamId).orElseThrow(RuntimeException::new);
return team;
}
}
위와 같이 트랜잭션 어노테이션이 존재하는 경우에는 쿼리가 한번만 나가지만 (영속성 컨텍스트가 getTeam
메소드 반환 시까지 살아있기 때문) 만약 해당 어노테이션이 없다면 2번의 쿼리가 나간다. 그 이유는 첫번째로 조회해온 team
객체가 영속성 컨텍스트에서 관리되지 않기 때문에 두번째 조회 또한 영속성 컨텍스트가 아닌 DB에서 결과를 조회해오기 때문이다.
마지막으로 OSIV를 껐을 때 아래의 메소드가 @Transactional
, @Transactional(readOnly = true)
, 트랜잭션 어노테이션 없음의 3가지 경우 각각에 대해 어떻게 동작하는지를 살펴보고 글을 끝마치려 한다.
//@Transactional | @Transactional(readOnly = true) | nothing
public Team updateTeam(Long teamId, String name) {
Team team = teamRepository.findById(teamId).orElseThrow(RuntimeException::new);
team.setName(name);
System.out.println("em.contains(team) = " + em.contains(team));
return teamRepository.findById(teamId).orElseThrow(RuntimeException::new);
}
@Transactional의 경우
Hibernate:
select
team0_.team_id as team_id1_1_0_,
team0_.name as name2_1_0_
from
team team0_
where
team0_.team_id=?
em.contains(team) = true
Hibernate:
update
team
set
name=?
where
team_id=?
updateTeam
메소드 내부에서는 트랜잭션이 아직 종료되지 않았기 때문에 영속성 컨텍스트는 살아있으며 앞서 조회해온 team
객체는 영속성 컨텍스트 내부에서 관리된다. 따라서 두번째 조회는 DB가 아닌 영속성 컨텍스트에서 결과를 조회해온다. (select 쿼리 1번) 또한 트랜잭션 커밋 직전 변경 감지를 통해 update 쿼리가 나간 것을 확인할 수 있다.
@Transactional(readOnly = true)의 경우
Hibernate:
select
team0_.team_id as team_id1_1_0_,
team0_.name as name2_1_0_
from
team team0_
where
team0_.team_id=?
em.contains(team) = true
@Transactional
과 비슷하게 동작했지만 처음 DB에서 조회 시 스냅샷을 저장하지 않았기 때문에 변경 감지가 발생하지 않아 update 쿼리는 나가지 않았다.
트랜잭션 어노테이션 없음의 경우
Hibernate:
select
team0_.team_id as team_id1_1_0_,
team0_.name as name2_1_0_
from
team team0_
where
team0_.team_id=?
em.contains(team) = false
Hibernate:
select
team0_.team_id as team_id1_1_0_,
team0_.name as name2_1_0_
from
team team0_
where
team0_.team_id=?
OSIV가 꺼져있고 service 레벨에서 별도의 트랜잭션을 시작하지 않았기 때문에 teamRepository.findById(...)
메소드 단위로 트랜잭션이 열리고 닫힌다. 즉 updateTeam
메소드 내부에서는 트랜잭션이 총 2번 열리고 닫히는 것이다. 따라서 첫번째 조회 결과인 team
객체는 영속성 컨텍스트에서 관리되지 않아 두번째 조회에서도 DB에 대한 select 쿼리가 실행된다.
또한 변경 감지는 스냅샷 뿐만 아니라 엔티티 자체가 영속성 컨텍스트에서 관리되어야 발생하기 때문에 update 쿼리는 나가지 않은 것을 볼 수 있다.
내용을 정리하자면 OSIV를 키든 끄든 '하나의 작업 단위' 라고 생각된다면 @Transactional
또는 @Transactional(readOnly = true)
어노테이션을 붙여야 하며, DB에 대한 CUD 포함 작업에는 @Transactional
을, READ만을 원한다면 성능상 이점을 위해 @Transactional(readOnly = true)
을 사용하면 된다.
위와 같이 간단히 3줄이면 될? 내용을 코드로 보이려고 하다보니 글이 길어졌지만 예제가 기억에 조금 더 도움이 될 거라고 생각한다.
끝.
'Spring > Spring' 카테고리의 다른 글
[Querydsl] Spring Boot 3.x with Gradle 설정 (1) | 2023.10.20 |
---|---|
[개념] FrontController 패턴 (with 스프링) (0) | 2023.09.05 |
Web Server vs WAS vs Web Container(Servlet Container) (0) | 2023.09.02 |
[개념] @Transactional(readOnly = true)임에도 insert 쿼리가 발생하는 경우 (0) | 2023.01.29 |
[개념] Transaction, DB connection, OSIV 간의 관계 (1) | 2023.01.09 |
- Total
- Today
- Yesterday
- FrontController
- Assertions
- junit5
- servlet filter
- 모두의 리눅스
- spring aop
- 전략 패턴
- 단위 테스트
- Java
- spring
- Front Controller
- rest api
- JPA
- QueryDSL
- 서블릿 컨테이너
- 디자인 패턴
- Transaction
- ParameterizedTest
- facade 패턴
- vscode
- C++
- github
- mockito
- Linux
- SSE
- Gitflow
- Spring Security
- spring boot
- Git
- 템플릿 콜백 패턴
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |