일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- bean
- 후기
- 보따리
- 글또
- 프로그래머스
- dto projection
- 테오의 스프린트
- 티스토리챌린지
- 구슬
- jscode
- 북극곰
- 체험
- 코드트리
- Spring
- 부꾸
- 모의면접
- 오블완
- 사이드 프로젝트
- jooq
- redis
- 눈송이
- SQL
- DI
- 글또 #다짐
- open contribution jam
- 부꾸러미
- Database
- spring context
- 동적 SQL
- 트러블슈팅
- Today
- Total
벤티의 개발 로그
[Spring] @Transactional 집중 탐구 본문
이 포스트는 글또 10기 내 독서 모임에 참여하여 읽기 시작한 '스프링 교과서'를 읽고 정리한 내용을 바탕으로 작성했습니다.
"@Transactional의 readOnly 속성을 써보는 것은 어떨까요?"
예전에 진행한 사이드 프로젝트에서 코드 리뷰 시스템을 도입한 후로, 우리 팀은 PR를 통해 서로의 코드를 읽어보고 보완할 점이나 같이 공유하고 싶은 점을 댓글로 작성했다. 그렇게 시간이 흐른 어느 날, 한 팀원이 스크린샷처럼 'readOnly 속성을 쓰는 것은 어떨까요?' 라며 의견을 내주셨다.
이전까지 나는 '@Transactional이란 Service 클래스에 관례로 달아주는 속성'이라는 생각만 했기 때문에, 그 이상 깊이 생각해 본 적은 없었다. 마침 스프링 교과서에 이 내용에 대한 설명이 있고, 저 의견을 보고 (결과적으로 일부 메서드에 readOnly 속성을 설정했다.) 지금까지 제대로 공부해 본 적은 없어서 이 글을 쓰게 되었다.
Transaction이란?
"데이터베이스 저장된 데이터, 혹은 데이터베이스의 상태를 변경할 수 있는 하나의 논리적 연산의 단위로, 여러 물리적 연산의 집합으로 이루어질 수 있다."
기술 면접을 준비하며 스스로 정리한 트랜잭션의 정의이다. 그리고 아래와 같이 4가지 중요한 원칙(ACID 원칙)을 지킨다.
1. Atomicity (원자성)
- 트랜잭션 내의 작업은 하나의 단위로 실행되며, 모두 성공하거나 모두 실패해야 한다. ("All or Nothing")
- COMMIT(커밋), ROLLBACK(롤백) 연산과 관련있다.
- 예시: 계좌 이체 중 출금이 성공했음에도 불구하고 그사이에 다른 사용자의 거래로 인해 잔고가 사라지는 상황이 발생하면 전체 작업이 취소된다.
2. Consistency (일관성)
- 트랜잭션이 성공적으로 수행되면 데이터는 항상 일관된 상태를 유지해야 한다.
- 예시: 데이터의 무결성 제약 조건은 항상 충족되어야 한다.
3. Isolation (고립성)
- 하나의 트랜잭션이 실행 중일 때 다른 트랜잭션이 끼어들지 않도록 해야 한다.
- 여러 트랜잭션이 동시에 실행될 때 충돌을 방지하여, 데이터 정합성을 유지할 수 있어야 한다.
4. Durability (지속성)
- 트랜잭션이 성공적으로 완료되면 그 결과는 시스템 다운 등의 돌발 상황이 발생해도 유실되지 않고 영구적으로 저장되어야 한다.
Spring에서는 어떻게 작동할까?
일반적으로 '@Transactional'은 Service에 추가한다. 그리고 Controller에서 클라이언트의 요청에 따라 알맞은 Service 메서드를 호출하게 되는데, 이 사이에 Aspect가 끼어든다. @Transactional을 추가하는 것만으로 Aspect에 특정 메서드를 가로채도록 지시할 수 있다. 그리고 이렇게 되면 Spring이 Aspect를 구성 설정하고 해당 메서드가 실행하는 작업에 트랜잭션 로직을 적용한다.
또, Aspect 내부에는 RuntimeExcpetion을 던지는 로직이 존재하는데, 가로챈 메서드가 어떤 런타임 예외라도 발생시키면 Aspect는 트랜잭션을 롤백한다. 만약 런타임 예외를 던지지 않는다면 트랜잭션은 커밋된다.
하지만, Service 메서드 안에 RuntimeExcpetion을 작성하는 것은 주의해야 한다고 한다. 왜냐하면 Service 메서드에서 예외를 던지더라도 그 안에 존재하는 catch 문에서 예외를 처리할 경우, 예외가 Aspect에 도달하지 못해 Aspect가 트랜잭션을 커밋한다는 문제점이 있다고 한다.
따라서 일반적으로 Service 메서드에서 예외를 처리할 때는 try catch finally 블록을 쓰는 것보다는 throws를 이용해 체크 예외(Checked Exception)을 던지는 것이 좋다고 한다.
'그러면 그냥 @Transactional 쓰면 되는 거 아닌가?'
일단 설명에 앞서 중요한 2가지 전제가 있다.
1. 우리 팀은 Spring Data JPA와 MySQL 8.0을 사용했다.
2. 그리고 내가 작성한 코드 중에서는 유난히 '조회', 즉 Read 하는 메서드가 많았고, 쿼리도 조회 관련 쿼리가 많았다.
readOnly 속성이란 무엇일까?
@Transactional 어노테이션의 속성으로, 해당 트랜잭션이 읽기 전용, 즉 읽기 작업만 수행한다는 것을 명시할 수 있다. 기본값은 false이며, 아래 코드(필요한 부분만 첨부했다.)처럼 'readOnly = true'를 사용하면 읽기 전용으로 설정할 수 있다. 따라서, 이 속성을 사용하면 트랜잭션의 성능을 최적화할 수 있고, 불필요한 데이터 변경을 방지할 수 있다.
@Slf4j
@Service
@RequiredArgsConstructor
public class PostService {
private static final int MAX_IMAGE_SIZE = 4;
private static final int POST_PAGES = 10;
private static final int ALBUM_PAGES = 30;
// 생략
/**
* getPostsOfDate
* 특정 일 최신 post 조회
* @return List<SinglePostRes>
*/
@Transactional(readOnly = true)
public List<SinglePostRes> getPostsOfDate(User user, long familyId, int year, int month, int day) {
LocalDate date = LocalDate.of(year, month, day);
LocalTime dummy = LocalTime.MIDNIGHT; // LocalDateTime 변수 생성을 위한 dummy 값
LocalDateTime dateTime = LocalDateTime.of(date, dummy);
Pageable pageable = PageRequest.of(0, POST_PAGES);
List<SinglePostRes> posts = getCombinedPostsByDate(user, familyId, dateTime, pageable);
return posts;
}
/**
* getCombinedPostsByDate
* Paging 기능이 포함된 API 중 날짜 정보가 필요한 메서드에서 사용
* 로직은 getCombinedPosts 메서드와 유사
* @return List<SinglePostRes>
*/
@Transactional(readOnly = true)
private List<SinglePostRes> getCombinedPostsByDate(User user, long familyId, LocalDateTime dateTime, Pageable pageable) {
List<Post> filteredPosts = postRepository.findByFamilyIdAndCreatedAtDesc(familyId, dateTime, pageable);
// 생략
return posts;
}
}
위 코드는 게시물 중 특정 날짜에 작성된 최신 게시물들의 목록을, 페이지네이션을 적용해 불러오는 메서드이다. 메서드와 API의 목적상 조회 이외에 다른 연산은 필요하지 않으므로, 두 메서드 모두 readOnly 속성에 true를 적용했다.
그래서 readOnly 속성을 쓰면 어떤 효과가 있을까?
이렇게 @Transactional을 메서드 레벨로 사용하면 예외를 메서드 별로 자유롭게 던질 수 있기 때문에, 앞서 설명한 '일반적으로 Service 메서드에서 예외를 처리할 때는 try catch finally 블록을 쓰는 것보다는 throws를 이용해 체크 예외(Checked Exception)을 던지는 것이 좋다'도 잘 지킬 수 있다.
또, 가독성도 좋아진다. readOnly라는 단어를 직역하면 '읽기만 해'라는 뜻인데, 이렇게 작은 속성 하나를 추가하는 것만으로 내 코드를 읽는 다른 팀원들, 그리고 미래의 내가 이해하는 데 많은 도움이 될 것이다.
하지만 가장 중요한 것은 트랜잭션의 오버헤드가 줄어든다는 것이다. 앞서 readOnly 속성의 설명에서 이런 표현이 있었다.
트랜잭션의 성능을 최적화할 수 있고, 불필요한 데이터 변경을 방지할 수 있다.
이 표현을 뜯어보자.
'트랜잭션의 성능을 최적화할 수 있다.'
일반적인 트랜잭션은 변경 감지를 위해 엔티티를 영속성 컨텍스트에 저장하고, 트랜잭션이 종료될 때 변경 사항을 DB에 반영(Flush)한다. 하지만, readOnly를 true로 설정하면 영속성 컨텍스트가 엔티티 변경 사항을 추적하지 않으므로 성능이 향상된다.
특히 이 프로젝트처럼 JPA를 사용하는 경우, readOnly를 true로 설정한 트랜잭션(혹은 그 메서드)에 대해서는 JPA Dirty Checking이 일어나지 않아 Hibernate가 엔티티의 변경 이력을 추적하지 않는다. 따라서, DB에 별도의 스냅샷도 저장되지 않기 때문에 메모리 상의 이점도 있다.
또, 데이터베이스도 '아, 이 트랜잭션은 쓰기 연산이 필요 없구나'라는 사실을 인지할 수 있어, 더 빠르게 쿼리를 실행할 수 있다.
'불필요한 데이터 변경을 방지할 수 있다.'
readOnly를 true로 설정하면 그 트랜잭션이나 메서드에서 SELECT 이외의 DML 연산(INSERT, UPDATE, DELETE)를 실행할 경우, 예외가 발생할 수 있다고 한다. 다만, 이 예외는 DB의 종류에 따라 발생하지 않을 수도 있다고 하며, MySQL에서는 버전에 따라 관련 로직이 다름을 알 수 있었다. (출처: MySQL 버전에 따른 @Transactional(readOnly=true)의 동작 과정)
따라서, 트랜잭션을 읽기 전용(readOnly = true)으로 설정하면 변경에 대한 수행을 하지 않아, 실수로 데이터를 수정해 ACID 원칙 중 하나인 일관성을 위배할 가능성이 낮아진다.
결론
1. @Transactional을 쓸 때는 냅다 Service 클래스에 사용해서 클래스 레벨로 쓰는 것보단, 메서드의 목적과 그 안에서 사용하는 쿼리의 종류를 고려해 메서드 레벨로 쓰는 것이 좋다!
2. 특히 읽기(조회) 연산만 있는 메서드의 경우 readOnly=true를 적극적으로 활용하자!
CS 공부를 위해 예전에 공부했던 정처기 필기 수험서를 빠르게 스캔하던 중 본 '트랜잭션'이라는 용어와, 이후에 Spring으로 개발하면서 쓴 @Transactional 사이의 연관성을 더 자세하게 알고 싶었고, 같은 용어지만 현재 바라봤을 때 과거에는 안 보이던 내용이 보여서 글을 쓰게 됐다.
스프링 교과서 책에 있는 트랜잭션 관련 내용도 많은 도움이 되었는데, 역시 스프링은 진짜 공부할 게 많구나라는 것을 다시 한 번 느낄 수 있었다.😊