MyB

@Transactional의 진실과 오해

2024.09.07

이 글을 작성하는 이유에 대해 말해보면 레디스에 키 밸류가 어떻게 저장되는지 찾아보다가 레디스가 싱글 스레드로 동작한다는 것을 알게 되었다. 그런데 내가 봤던 프로젝트에서 레디스의 동시성 이슈로 인해 분산락을 사용한다라고 파악하던 프로젝트가 있었는데 의문이 들었다. 싱글 스레드인데 동시성 이슈를 뭐 하러 잡지?라는 의문이 들었고 코드를 다시 한번 읽어보니 레디스의 동시성을 잡는 것이 아니라 레디스를 활용하여 프로젝트의 동시성 이슈를 제어하는 역할을 하고 있었다. 그래서 이렇게 오해를 바로 잡을 수가 있었는데 여기서 한 가지 의문이 더 생겼다. 어차피 @Transactional로 동시성을 제어하는데 추가적으로 redisson을 활용하여 분산락을 걸어주는 이유가 뭐지?라는 의문이 생겼고 그에 대해 알아보면서 배우게 된 여러 가지 들에 대해서 적어보려고 한다.


이 글은 위와 같은 생각을 하고 찾아본 내용들에 대한 첫 번째 글이다.

함부로 사용하면 안되는 @Transactional...

나는 지금까지 코드를 짤 때 서비스단 전체에 @Transactional(read-only = true)를 걸고 데이터베이스 변경이 필요한 메서드에 @Transactional 어노테이션을 걸어주었다. 서버에 대해서 막 배울 시절 @Transactional(read-only = true) 를 사용하면 성능상 이점을 얻을 수 있다는 단순한 말만 듣고 사용했는데 여러 가지 의문이 들어 다시 한번 찾아보게 되었고 @Transactional을 사용할 시 발생할 수 있는 다른 단점들이 존재한다는 것을 알게 되었다. 우선 Transactional에 대해서 알아보고 Read-only에 대해서 추가적으로 알아보자.

@Transactional? 트랜잭션? 이 뭐고

@Transactional은 트랜잭션을 적용시키는 어노테이션이다. 그런 트랜잭션은 무엇인가 하면, 애플리케이션에서 발생하는 여러 데이터베이스 수정들을 하나의 논리적인 단위로 묶는 방법이다. 해당 트랜잭션이 성공하면 커밋하고 실패하면 롤백하는 원리로 작동한다.

@Transactional
    public void changePassword(PasswordChangeRequest request) {
        Member member = memberFinder.checkEmpty(request.email());
        checkPassword(request.password(), request.passwordChecker());
        member.resetPassword(passwordEncoder.encode(request.password()));
    }

위 코드는 내가 프로젝트를 진행하면서 서비스단에서 사용한 메서드이다. 보다시피 @Transactional이 적용되어 있는데 이 코드는 비효율적인 코드이다. 그 이유는 메서드의 플로우를 보면 알 수 있다.

  1. email로 멤버를 찾는다.
  2. 요청이 들어온 패스워드와 찾은 멤버의 비밀번호가 일치하는지 확인한다.
  3. 일치한다면 멤버의 비밀번호를 수정한다.

위와 같은 플로우로 진행되고 있는데, 여기서 멤버를 수정하는 부분은 오직 3번 뿐이다. 1번과 2번에서 오류가 발생한다고 하더라고 데이터의 수정이 없기 때문에 데이터의 정합성을 보장할 필요가 없다. 따라서 3번에서 멤버를 수정하는 부분에만 Transactional을 적용시키면 된다. 하지만 우리는 JPA를 사용하고 있었고 JPA의 메서드에는 기본적으로 Transactional이 적용되어 있기 때문에 추가적으로 서비스단 메서드에서 Transactional을 달고 있을 필요가 없다.

써서 안좋은게 뭔데??

그럼 이런 의문이 들 수 있다. 그냥 쓰면 안되는겨? 안 좋을 게 있나?

있다.

트랜잭션은 오래갈 수록 오버헤드가 발생한다.  트랜잭션이 어떻게 동작하는지 보면 그 이유를 알 수 있다.

클라이언트에서 요청이 들어오면 EntityManagerFactory는 각각의 요청에 EntityManager를 할당해 준다. 엔티티 매니저가 해당 요청에 대한 영속성 컨텍스트를 생성하고 이 엔티티 매니저는 DB에 접근하기 위해서는 커넥션을 점유해야 접근이 가능하다. 이 커넥션은 커넥션 풀에서 꺼내서 사용하는 것으로 커넥션 풀이란 데이터베이스와 미리 연결되어 있는 객체를 담아놓는 풀이고 이 객체가 커넥션이다.

이런 커넥션은 수량이 정해져 있기 때문에 스레드가 사용하고 나면 빠르게 반납을 해야 한다. 그런데 이 커넥션을 특정 요청이 오랫동안 필요도 없는데 점유하고 있다고 생각해보면 점유하고 있는 시간 동안 다른 요청에서 해당 커넥션을 사용하지 못하는 것이고, 이는 자원을 낭비하고 있는 것이다. 물론 당연히 필요한 부분에 대해서는 적용해야 하지만 쓸데없는 부분까지 Transactional을 사용하는 것은 곧 자원의 낭비가 된다는 것이다.

그렇기 때문에 트랜잭션이 필요하지 않는 부분에 대해서는 Transaction을 적용할 필요가 없다.

그럼 @Transactional(readOnly=true)는 뭐가 다른가?

그러면 이번엔 @Transactional(readOnly = true)에 대해 이야기해 보면

앞에서 이야기 한 것처럼 나는 단순히 @Transactional(readOnly = true)가 읽기의 성능을 증가시켜 주고 혹여나 데이터의 변경이 없다는 것을 보장시킬 수 있다는 이유로 사용했다. 이는 틀린 말은 아니지만 그렇다고 해서 맞는 것은 아니다.

결론부터 이야기하면 현재 내가 진행하고 있는 프로젝트에서는 @Transactional(readOnly = true)를 사용할 필요가 없다.

그 이유에 대해 정확히 알기 위해서 @Transactional(readOnly = true)가 갖는 장점에 대해 알아보고 그러한 장점에도 불구하고 사용할 필요가 없는 이유에 대해서 이야기할 예정이다.

@Transactional 장점

나는 맛있는 음식은 나중에 먹는걸 좋아하니 메인은 후순위로 설명하고 간단한 것부터 보자

첫 번째로 가독성 향상이다. 해당 어노테이션이 붙은 것을 보고 이 메서드는 데이터를 수정하지 않는다는 것을 빠르게 알아차릴 수 있다. 하지만 메서드 명을 보면 어느 정도 파악이 가능하기 때문에 미미한 효과라고 생각한다.

두 번째로, 데이터의 무결성 보장이다. 해당 어노테이션을 붙인다면 코드적으로 데이터의 변경이 없다는 것을 확인하기 때문에 실수로 변경하는 부분이 들어갔다고 해도 오류가 발생한다.

세 번째로, 부하 분산이다. 현업에서는 데이터베이스의 장애를 빠르게 복구하고, 트래픽을 분산하기 위해 실시간 복제본 데이터베이스를 운용하는 Replication방식을 사용할 수 있는데, 메인 DB와 서브 DB로 나눠지게 되고, 메인 DB에서 장애 발생 시 서브 DB를 메인 DB로 승격시켜 빠르게 장애를 복구할 수 있으며 데이터베이스의 과부하를 방지하기 위해 빠르게 작업을 처리해야 하는 읽기 쓰기 작업을 메인 DB에서 진행하고 읽기 전용 작업은 서브 DB에서 진행하게 된다. 이런 데이터베이스 구조를 가질 때 @Transactional(readOnly = true)가 설정되어 있는 메서드의 경우 서브 DB에서 데이터를 가져오도록 동작하게 하여 애플리케이션의 목적에 맞게 트래픽을 분산시킬 수 있다는 장점이 있다.

마지막으로 메인인 성능 최적화이다. @Transactional(readOnly = true)를 사용할 경우 Hibernate 세션의 flush 모드를 MANUAL로 설정한다. 이것이 무슨 소리인가 하면, JPA의 영속성 컨텍스트가 수행하는 Dirty Checking(변경 감지)와 관련이 있다. 영속성 컨텍스트는 엔티티를 조회할 때 초기상태에 대한 snapshot을 저장한다. 트랜잭션이 커밋될 때 초기 상태의 정보를 가지는 snapshot과 비교하여 변경된 내용에 대해 update query를 생성해 결과를 반영한다. 이런 과정이 엔티티 객체의 상태 변화를 추적하여 트랜잭션이 커밋되는 시점에 데이터베이스에 자동으로 반영한다.

하지만 flush모드가 MANUAL인 경우 트랜잭션 내에서 사용자가 수동으로 flush를 호출하지 않으면 flush가 자동적으로 수행되지 않는다. 그렇기 때문에 트랜잭션 내에서 강제로 flush를 호출하지 않는 한, 수정 내역이 데이터베이스에 반영되지 않는다. 이로 인해 조회용으로 가져온 엔티티의 예상치 못한 변경을 방지할 수 있고, JPA가 (readOnly = true) 라는 옵션을 보고 해당 트랜잭션 내에서 조회하는 엔티티를 조회용으로 인식하여 snapshot을 저장하지 않아 메모리가 절약되는 이점이 있고 DirtyChecking을 생략함으로써 DB에 쓰기 작업을 발생시키지 않게 된다.

트랜잭션 알고 쓰자!!

이렇게 장점에 대해 나열해 보았다. 장점으로써 크게 느껴지는 부분은 마지막 성능최적화적인 부분인데 트랜잭션을 사용할 경우 발생하는 오버헤드와 비교하여 트랜잭션을 사용해야 한다.

@Transactional의 경우 AOP를 기반으로 하기 때문에 타켓 메서드의 로직이 모두 종료되고 commit이 실행되는데 이 말은 즉슨 해당 메서드가 모두 종료되어야 DB 연결이 끊어지게 되고 이는 DB를 사용하지 않을 때도 DB에 연결하고 있을 수 있다는 것을 나타낸다. 이렇게 제때제때 반환해주지 않으면 데드락 상황이 발생할 수 있기에 트랜잭션을 사용할 때의 트레이드오프를 잘 고려하여 사용해야 한다.

자주 사용되는 메서드의 경우 @Transactional을 사용하게 될 경우 시스템이 데드락에 걸리는 상황을 고려해야 하는데 추가적으로 고려할 점이 한가지 더 있다.

@Transactional을 제거할 시 Lazy Load 하는 부분에서 오류가 발생할 수 있다. 그 이유는 간단하게 트랜잭션을 적용시켜주지 않았기 때문에 영속성 컨텍스트가 해당 엔티티를 관리하지 않았고 View레이어처럼 영속성 컨텍스트의 관리를 받지 않는 곳에서 Lazy Load를 할 경우 예외가 발생하게 된다. 따라서 레이지 로드가 사용되는지 안되는지 잘 판단하고 트래픽이 몰릴 가능성까지 생각하여 Transactional을 적용해야 할지 말아야 할지 판단해야 한다.

결록적으로 나는 모든 읽기 메서드에 @Transactional(readOnly = true)를 적용하고 있었으니 이는 매우 비효율적인 방법은로 판단된다.

정말 마지막으로 spring.jpa.open-in-view : true 에 대해서 언급하고 넘어가야 하는데 spring에서는 해당 속성은 true가 디폴트로 설정되어 있다. 해당 속성이 하는 역할은 JPA를 사용할 때 DB 커넥션 시작 시점부터 API응답이 끝날 때까지 영속성 컨텍스트와 DB커넥션을 유지하도록 만드는 역할을 한다. JPA에서 레이지 로드를 많이 사용하기 때문에 Service레이어를 벗어난 곳에서도 DB커넥션이 필요해 해당 설정이 디폴트로 되어 있다. 따라서 우리가 기존 메서드에서 @Transactional을 제거한다고 하더라고 해당 속성을 false로 변경해 주지 않는다면 API응답이 끝날 때까지 DB에 연결되어 있는 것이다.

출처

https://studyandwrite.tistory.com/573

https://woonys.tistory.com/238

https://hungseong.tistory.com/74

최근 글