스프링부트/JPA

[Spring]@Transactional 어노테이션 제대로 알고 쓰자(+JPA, MyBatis)

삼록이 2025. 12. 2. 23:35

나는 Service Layer 클래스 전체에 습관적으로 @Transactional 어노테이션을 붙여 사용하고 있었다.

@Transactional 어노테이션은 메서드를 시작할 때 메서드 내에 코드를 정상적으로 실행 후 종료하면 트랜잭션을 commit하고,

문제가 발생하면 rollback을 하게 해준다. 

이로 인해, 비정상적 종료가 발생했을 때 트랜잭션의 일부 작업만 데이터베이스에 반영되는 것을 방지해 데이터의 일관성을 유지해주는 이점을 가지고 있다.

그래서 클래스 전체에 붙이고 있었는데, 사실 Transactional 어노테이션은 클래스 전체에 붙이는 것보다 정말 트랜잭션이 필요한 부분에만 붙여야한다.

 


DB커넥션? DB커넥션 풀?

먼저 스프링의 @Transactional 어노테이션이 붙은 메서드가 호출되면

스프링은 커넥션 풀에서 DB 커넥션을 하나 가져온다.

 

여기서 DB 커넥션(Connection) 이란?

애플리케이션(서버(여기서 스프링))이 DB에게 SQL을 보내려면 네트워크 연결을 하나 만들어야한다.

이 연결을 DB 커넥션이라고 부른다.

 

예를 들어, 아래와 같은 쿼리를 스프링이 DB에 날린다 생각해보자.

SELECT * FROM user WHERE id = 1

 

그러면 

  • DB 서버(IP:PORT)에 TCP 소켓을 연다
  • 로그인(사용자/비밀번호 인증)
  • SQL 전송
  • 결과 수신
  • 연결 종료

 이와 같은 절차를 진행하고 이 전체적인 절차가 DB커넥션을 1개 만든 것이다.

그런데 이렇게 요청이 들어올 때마다 스프링이 DB커넥션을 매번 만들면 DB가 감당하지 못하게 된다.

그래서 등장한 것이

DB 커넥션 풀(DB Connection Pool)이다.

서버가 시작될 때 DB 커넥션을 미리 여러개 만들어 두고, 요청이 들어오면 이 커넥션 중 하나를 빌려쓰고 다 쓰면 연결을 종료하는 것이 아니라 커넥션풀에 반납만 해놓게 된다.

이 커넥션 풀을 관리하는 라이브러리가 대표적으로 Hikari CP(스프링 부트 기본 탑재)다.

DB 커넥션 풀은 마치 스프링의 스레드 풀과 비슷한 개념인 듯하다.


 

다시 돌아와서, DB에게 요청을 날려야하는 메서드는 당연히 DB커넥션을 하나 가져온다.

그런데 @Transactional이 붙는 메서드는 DB커넥션 한개를 점유하는 시간이 길어진다.

 

예를 들어 DB에 SELECT를 요청하는 스프링 메서드가 있다고 하자.

트랜잭션이 없다면

  • DB 커넥션 1개를 빌리고
  • SELECT를 실행하고
  • 결과를 받고 바로 커넥션을 커넥션 풀에 반납한다.

그런데 @Transactional이 붙었다면

  • 트랜잭션 시작(AutoCommit =false모드)
  • SELECT 실행
  • 메서드가 끝날때 까지 커넥션 점유
  • 메서드가 끝에서 commit 실행
  • 커넥션 반납

그래서 트랜잭션이 길어지면 커넥션 점유가 길어지고,

사용자가 많은 서비스라면 전체 DB 커넥션 풀이 고갈되어있는 시간이 생길 수 있다. 이는 전체적인 성능 저하로 이어진다.


그래서 습관적으로 Service Layer계층에 @Transactional 어노테이션을 붙이는 것보다

정말 필요한 때에만 붙이는게 좋다.

예를 들어, SELECT문이 한번 일어나는 단건 조회같은 경우에는 트랜잭션이 불필요하니까 굳이 Transactional어노테이션을 붙이지 않는 것이 낫다.

단! MyBatis가 아닌 JPA환경이라면 조금 더 신경써야한다!!

 


Jpa 환경에서 트랜잭션

jpa환경에서는 조회 메서드에도 @Transactional 어노테이션을 붙이는 경우가 많다.

바로 Lazy Loading 때문이다.

(그러나 @Transactional(readOnly = true) 와 같이 readOnly = true옵션을 붙인다.

readOnly = true옵션으로 인해 MySQL이나 PostgreSQL,Oracle과 같은 대부분의 DB는 읽기 전용에 대해서는 DB수준에서 최적화하기 때문에 그냥 @Transactional을 붙이는 것보다 성능적으로 뛰어나다.)

User user = em.find(User.class, id);  // User는 가져왔지만 Orders는 Proxy
user.getOrders().size();              // 여기서 LAZY SELECT(Lazy Loading) 발생

 

JPA 환경에서는 연관관계로 인해 Lazy Loading이 일어나는 경우가 많다.

이 Lazy Loading은 트랜잭션과 DB 커넥션이 모두 살아있어야 동작하는데, Transactional어노테이션이 없으면 트랜잭션이 없기 때문에 문제가 발생하기 때문이다.

(Lazy Loading이 일어나지 않게 fetch join을 했다던가 쿼리 DSL로 DTO로 바로 결과를 매핑해서 받는 경우에는 역시 @Transactional 어노테이션을 붙이지 않아도 된다)

 


@Transactional( readOnly = true) 옵션

 

왜 readOnly= true옵션을 붙이면 그냥 Transactional 어노테이션을 붙이는 것보다 성능적으로 뛰어날까?

 

JPA는 DB에서 엔티티(객체)를 가지고 올 때,

DB조회 - >엔티티 생성 -> 영속성 컨텍스트에 저장 .  이러한 단계를 거친다.

그리고 이 영속성 컨텍스트는 1차 캐시 역할을 한다.

DB에서 가져온 엔티티의 원본상태를 메모리에  복사해서 저장한다. 마치 스냅샷을 저장하듯이.

그리고 변경이 감지되면 자동으로 UPDATE 쿼리를 날린다. 이를 우리는 dirty checking이라고 불렀다.

 

그런데 단순한 조회쿼리에서는 이러한 dirty checking이 필요없다.

@Transactional은 

DB에서 엔티티 가져오면 스냅샷을 저장하고 혹시 모를 변경에 대비해 dirty checking을 준비한다.

 

그런데 @Trnasactional(readOnly = true)는 이 트랜잭션은 완전히 읽기 전용이라는 것을 알려줌으로써

스냅샷을 저장하지 않고 dirty checking도 완전 OFF해버린다.

트랜잭션과 영속성 컨텍스트만 유지하여  Lazy Loading과 1차캐시 기능만 남기는 것이다. 

따라서 readOnly = true옵션은 단순조회에 최적화된 트랜잭션인 것이다.

 

**JPA 영속성 컨텍스트의 1차 캐시 기능이란?

Member m1 = em.find(Member.class, 1L);
Member m2 = em.find(Member.class, 1L);

System.out.println(m1 == m2); // true

이 때 DB를 두번 조회하지 않고 영속성 컨텍스트의 1차 캐시에서 재사용한다.

em.find가 낯설다면 repository.findById()메서드라고 봐도 무방하다.

 

**Dirty Checking이란?

@Transactional
public void updateMember(Long id) {

    // 1) 엔티티 조회
    Member member = em.find(Member.class, id);
    // 여기서 영속성 컨텍스트에 들어가면서 스냅샷이 생성됨
    // 스냅샷 예: { id=1, name="Tom", age=20 }

    // 2) 엔티티 값 변경 (UPDATE 쿼리 직접 쓰지 않음)
    member.setName("Jerry"); // 값만 바꿈
    member.setAge(30);

    // 3) 메서드가 끝나고 트랜잭션 종료 시점에 flush 발생
    // 스냅샷과 현재 member의 값을 비교해서 UPDATE SQL 자동 생성
}

JPA가 스냅샷과 비교하여 자동으로 UPDATE를 수행하는 것을 dirty checking이라한다. 

 

 

 


 

결론

  • @Transactional 어노테이션은 DB 커넥션 풀 하나를 잡아먹어서 남발하면 안된다.
  • 그런데 JPA환경에서는 조회 API라도 Lazy Loading 이 발생할 여지가 조금이라도 있다면 @Transactional 어노테이션을 사용하는 것이 권장된다.(단 @Transactional(readOnly = true)로)
  • MyBatis에서는 그냥 조회 API라면 @Transactional 어노테이션을 사용안해도 된다.