본문 바로가기
Spring/Spring Boot

@Tracsactional 없이 save()가 동작한다?

by yoon_seon 2024. 8. 24.

개요

최근 코드리뷰를 하게 되던 중, 비즈니스 로직에 @Transactional 어노테이션이 누락되어 있는 코드를 보게 되었습니다.
 
저는 매번 Service를 구현할 때 외부에서 호출하는 public 메인 비즈니스 로직에 아래와 같이 습관처럼 @Transactional을 명시하고 사용했었습니다.

  • 쓰기용 SQL 질의인 Insert, Delete, Update를 하나라도 포함한다면 @Transactional 명시한다.
  • 조회용 SQL인 Select만 수행하는 메서드에는 @Transactional(readOnly=true)를 명시한다.

 
위와 같이 습관처럼 @Transactional을 사용하다 보니, 트랜잭션이 명시되지 않은 경우 save() 메서드를 호출했을 때 데이터가 왜 저장되는지, 읽기 전용 트랜잭션에서 save()를 호출하면 어떻게 동작하는지 실제로 경험해 본 적이 없어서 이를 직접 확인하고 테스트해보려 합니다.
 

  1. @Transactional 없이 save()를 호출하는 경우
  2. @Transactional(readOnly=true)에서 save()를 호출하는 경우
  3. @Transactional에서 save()를 호출하는 경우

위 3가지의 경우를 아래의 코드와 테스트 코드로 성공 여부를 확인해 보겠습니다.
 
DTO

public record CreateMemberRequest(
    String name,
    String email
){}

 
Entity

@Entity
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String email;
    private LocalDateTime joinDate;

    public Member(String name, String email) {
        this.name = name;
        this.email = email;
        this.joinDate = LocalDateTime.now();
    }

    protected Member() {
    }

    // Getter...
}

 
테스트 코드

@SpringBootTest
class MemberServiceTest {
    @Autowired
    private MemberRepository memberRepository;
    @Autowired
    private MemberService memberService;

    @Test
    @DisplayName("회원 등록이 올바르게 수행된다.")
    void register_member_successfully() throws Exception {
        //given
        var request = new CreateMemberRequest("홍길동", "hong@test.com");

        //when
        memberService.createMember(request);

        //then
        var members = memberRepository.findAll();
        assertThat(members).hasSize(1);
        var member = members.get(0);
        assertThat(member.getName()).isEqualTo("홍길동");
    }
}

 

1. @Transactional 없이 save()를 호출하는 경우

@Service
public class MemberService {
    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public void createMember(CreateMemberRequest request) {
        if (existEmail(request)) {
            throw new IllegalArgumentException("존재하는 이메일");
        }

        var initMember = new Member(request.name(), request.email());
        
        save(initMember);
    }
    
    private void save(Member initMember) {
        memberRepository.save(initMember);
    }

    private boolean existEmail(CreateMemberRequest request) {
        return memberRepository.existsByEmail(request.email());
    }
}

외부에서 request를 받아서 member를 저장하는 간단한 로직이며 아무런 @Transactional 어노테이션을 명시하지 않았습니다.
 
이 코드에서는 DB 질의를 2번 하게 될겁니다.
 

  1. 해당 이메일을 가지고 있는 회원이 존재유무 확인을 위한 SELECT 질의
  2. 회원을 저장하기 위한 INSERT 질의

 

테스트

테스트가 성공한 것을 확인할 수 있습니다.
 
사실 @Transactional을 명시하지 않아도 데이터가 저장되는 이유는 플리케이션에 트랜잭션을 명시하지 않아도 DB가 자체적으로 각 SQL 질의를 커밋하기 때문인데요. 이 말은 즉, 각각의 SQL 질의가 독립된 트랜잭션으로 실행된다는 것입니다.
 
하지만 실제로 우리가 작성하는 비즈니스 로직은 여러 작업을 하나의 트랜잭션으로 묶어 데이터의 일관성을 보장하는 것이 중요하므로, 명시적으로 @Transactional을 사용하여 트랜잭션을 설정하게 되는 것입니다.
 

사실 Spring Data Jpa를 사용하면 어플리케이션에서도 트랜잭션을 걸고 있다.

Spring Data Jpa가 제공하는 JpaRepository 인터페이스의 findById(), save(), saveAll(), delete() 등을 제공하는 구현체인 SimpleJpaRepository를 확인해 보면 @Transactional 어노테이션이 적용되어 있는 것을 알 수 있습니다.

  • SimpleJpaRepository 클래스 전역에 @Transactional(readOnly = true)가 설정되어 findById는 읽기 전용으로 동작
  • save(), saveAll() 등의 메서드는 명시적으로 @Transactional이 지정되어 쓰기 전용으로 동작

그러면 트랜잭션이 명시되지 않아도 DB레벨에서 기본적으로 각각의 질의를 커밋하는데 왜 굳이 위와 같이 Spring Data Jpa가 제공하는 기본 쿼리 메서드에 @Transactional이 명시되어 있는 것일까요?!
 
저에게는 아래의 두 가지 키워드가 가장 이해가 잘 되는 예시였습니다.
 

  1. 트랜잭션의 원자성 보장과 롤백처리
  2. SELECT를 위한 쿼리 메서드는 읽기 전용 트랜잭션으로 성능 최적화

 

트랜잭션의 원자성 보장과 롤백 처리

예를 들어 saveAll() 같이 Iterable을 인자로 받는 메서드들은 JPA 스펙상 벌크형으로 데이터가 저장되는 것이 아니라 루프를 돌면서 각각의 row들이 개별적으로 save()를 호출하게 됩니다.

// Spring Data JPA의 saveAll() 구현
@Transactional
public <S extends T> List<S> saveAll(Iterable<S> entities) {
    Assert.notNull(entities, "Entities must not be null");
    List<S> result = new ArrayList();
    Iterator var4 = entities.iterator();

    while(var4.hasNext()) {
        S entity = (Object)var4.next();
        result.add(this.save(entity));
    }

    return result;
}

 
이에 따라 모든 Iterable의 size에 만큼 수행되는 save()가 하나의 트랜잭션 내에서 수행되고 저장되어야 하며,
하나의 save()에서 에러가 발생하면 모든 작업이 일괄적으로 롤백되어야 합니다.
 

SELECT를 위한 쿼리메서드는 읽기 전용 트랜잭션으로 성능 최적화

읽기 전용 트랜잭션은 데이터 변경이 없기 때문에 데이터베이스는 일반적으로 쓰기 전용 트랜잭션에서 수행되는 행 잠금이나 테이블 잠금이 사용하지 않아 그 비용을 감수할 수 있습니다.
또한, DB에서 읽기 전용 트랜잭션이 데이터를 변경하지 않는다는 정보를 기반으로 쿼리 실행 계획을 조정할 수 있습니다.
이 밖에도 다양한 readOnly의 장점이 있어서 이를 활용하여 데이터를 비용이 적게 조회할 수 있습니다.
 

2. @Transactional(readOnly=true)에서 save()를 호출하는 경우

@Service
public class MemberService {
    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Transactional(readOnly = true)
    public void createMember(CreateMemberRequest request) {
        if (existEmail(request)) {
            throw new IllegalArgumentException("존재하는 이메일");
        }

        var initMember = new Member(request.name(), request.email());

        save(initMember);
    }

    private void save(Member initMember) {
        memberRepository.save(initMember); // 읽기전용 트랜잭션에서 save 호출
    }

    private boolean existEmail(CreateMemberRequest request) {
        return memberRepository.existsByEmail(request.email());
    }
}
  • createMember 메서드에 @Transactional(readOnly = true)를 설정
  • 따라서 save()는 읽기 전용 트랜잭션에서 처리되게 됩니다.

 

테스트

이 부분은 프로젝트가 어떤 DB를 연결하고 있느냐에 따라 결과가 다르게 동작합니다!!

MySQL

MySQL로 프로젝트를 연결했을 때 테스트가 실패하고 아래와 같은 에러 메시지가 출력됩니다.
 

org.springframework.orm.jpa.JpaSystemException: could not execute statement [Connection is read-only. Queries leading to data modification are not allowed] [insert into member (email, join_date, name) values (?,?,?)]

읽기 전용으로 DB와 연결을 맺고 있는데 데이터 조작으로 이어지는 쿼리는 허용되지 않다는 에러 메시지를 확인할 수 있습니다.
 

H2

MySQL의 경우는 읽기 전용 트랜잭션에서 데이터의 조작이 이루어질 경우 REJECT 시키는 반면, DB만 H2로 변경했을 뿐인데 읽기 전용 트랜잭션에서 save()가 정상 동작합니다..!
위 테스트에서 알 수 있는 핵심사항은 어떤 DB와 드라이버를 사용하는지에 따라 동작이 달라진다는 것을 이해하고 있어야 한다는 것입니다.
 

중요한 것은 readOnly 옵션이 트랜잭션의 변경 여부에 대한 검사 기능이 아닌, 성능 최적화를 위한 힌트로 제공된다는 것이다. 이는 JDBC 스펙이므로 구현체에 따라 달라질 수 있음을 명시하도록 하자.
- [망나니 개발자] 블로그

 
위 문제를 다룬 망나니 개발자님의 좋은 게시글이 있어서 링크 첨부합니다!
 

[Java] H2 데이터베이스에서 @Transactional(readOnly=true)일때  save를 호출하는 경우

1. H2 데이터베이스에서 @Transactional(readOnly=true)일때 save를 호출하는 경우[ H2 데이터베이스에서 @Transactional(readOnly=true)일때 save를 호출하는 경우 ]다음과 같이 트랜잭션을 시작하고 데이터를 저장

mangkyu.tistory.com

 

3. @Transactional에서 save()를 호출하는 경우

@Service
public class MemberService {
    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Transactional
    public void createMember(CreateMemberRequest request) {
        if (existEmail(request)) {
            throw new IllegalArgumentException("존재하는 이메일");
        }

        var initMember = new Member(request.name(), request.email());

        save(initMember);
    }

    private void save(Member initMember) {
        memberRepository.save(initMember); // 트랜잭션 전파에 따라 @Transactional 로 동작
    }

    private boolean existEmail(CreateMemberRequest request) {
        return memberRepository.existsByEmail(request.email());
    }
}
  • createMember() 메서드에서 @Transactional 어노테이션으로 쓰기용 트랜잭션을 명시
  • save() 메서드는 호출하는 메서드의 트랜잭션의 전파레벨에 따라 동작하게 됨

 

테스트

 
평소 습관처럼 사용하던 이번 테스트는 성공합니다!
@Transaction 어노테이션이 명시된 메서드는 기본적으로 쓰기 트랜잭션으로 동작하며, 메서드 호출 시 전파 규칙에 따라 트랜잭션이 전파되어 save() 또한 쓰기 전용 트랜잭션에서 수행되게 됩니다.
 

정리

이번 테스트를 통해 @Transactional 어노테이션의 중요성과 다양한 동작 방식을 확인해보았고,
각 경우의 동작을 정리하면 다음과 같습니다.
 

  1. @Transactional 없이 save()를 호출하는 경우
    • 트랜잭션이 명시되지 않더라도 데이터베이스는 각 SQL 질의를 독립적으로 커밋한다.
    • Spring Data JPA가 제공하는 기본 쿼리 메서드는 기본적으로 트랜잭션 어노테이션이 명시되어 있고, 원자성을 보장하며 롤백 처리를 수행한다
    • 비즈니스 로직은 여러 작업을 하나의 트랜잭션으로 묶어 데이터의 일관성을 보장하는 것이 중요하므로 @Transactional을 사용하여 트랜잭션을 설정하는 것이 좋다.
  2. @Transactional(readOnly=true)에서 save()를 호출하는 경우
    • 읽기 전용 트랜잭션에서는 데이터 조작이 금지되는 경우가 있으며, DB에 따라 다르게 동작한다.
      • MySQL에서는 읽기 전용 트랜잭션에서 데이터 조작을 시도할 경우 에러가 발생한다.
      • H2에서는 읽기 전용 트랜잭션에서도 데이터 조작이 가능하다.
    • readOnly 속성은 성능 최적화의 힌트로 제공되며, JDBC 스펙과 DB 구현체에 따라 다르게 동작할 수 있다.
  3. @Transactional에서 save()를 호출하는 경우
    • @Transactional이 명시된 메서드는 기본적으로 쓰기 트랜잭션으로 동작하며, 메서드 호출 시 전파 규칙에 따라 트랜잭션이 전파된다.
    • 모든 작업이 하나의 트랜잭션 내에서 수행되며, 에러 발생 시 롤백 처리를 보장한다.

 

결론

트랜잭션이 어플리케이션 레벨에서 어떻게 작동하는지, 그리고 데이터베이스와의 상호작용에서 발생할 수 있는 다양한 시나리오를 경험하면서, 트랜잭션 설정의 중요성을 다시 한번 느끼게 되었습니다.
 
특히, 읽기 전용 트랜잭션이 단순한 성능 최적화에 그치지 않고, 데이터베이스에 힌트를 제공하여 쿼리 실행 계획을 조정하는 데 영향을 미친다는 점을 처음으로 알게 되었습니다.
 
앞으로도 비즈니스 로직을 구현할 때 @Transactional을 올바르게 활용하고 데이터베이스와의 상호작용을 잘 알아둔다면, 다른 문제가 발생했을 때 조금 더 유연하게 대처할 수 있을 것 같습니다 :)

댓글