티스토리 뷰

 

김영한님의 스프링 강의를 들어오면서 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(...)를 호출함으로써 해당 엔티티를 삭제 상태로 바꾼다.

 

SimpleJpaRepository의 save 메소드
SimpleJpaRepository의 delete 메소드



이는 최종적으로 flush를 수행함으로써 변경내용이 DB에 동기화된다. (즉 insert, delete 쿼리가 실행된다.)

엔티티의 생명주기

 

반면 엔티티에 대한 변경은 dirty checking(변경 감지)을 통해 이뤄지는데, DB에서 읽어온 최초 시점의 상태를 저장해놓은 snapshot을 트랜잭션 커밋 시점에 영속성 컨텍스트의 엔티티와 일일이 비교하고 변경된 내용에 대해 update 쿼리가 실행된다.

 

읽기 전용 트랜잭션에서는 트랜잭션 커밋 전 flush 하지 않으므로 해당 엔티티가 persist, remove되었다 한들 이것이 DB에 반영되지 않으며, 아무리 DB에서 조회해온 기존의 엔티티가 변경되었다고 한들 비교 대상인 snapshot이 존재하지 않아 update 쿼리가 생성되지 않고 변경내용은 무시된다.




위에서 @Transactional(reaOnly = true)'스프링 프레임워크가 하이버네이트 세션 플러시 모드를 MANUAL로 설정하기 때문에 강제로 flush를 호출하지 않은 한 flush가 발생하지 않는다.' 라고 언급하였다. 정말 그러한지 코드로 확인해보자.

 

여기서 플러시 모드는 EntityManagerFlushModeType이 아닌 SessionFlushMode를 의미한다. EntityManagerSession의 차이는 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 내부에 트랜잭션 어노테이션이 붙어있기 때문이다.

 

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줄이면 될? 내용을 코드로 보이려고 하다보니 글이 길어졌지만 예제가 기억에 조금 더 도움이 될 거라고 생각한다.





끝.




공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/04   »
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
글 보관함