안녕하세요, 도토리입니다.
회사에서 다른 시스템 A와 연계를 위해서 A 시스템에 API를 만들어 달라고 했으며, 운영 배포를 진행했습니다.
그러나, 배포 후 A 시스템이 죽어버렸고(EC2 Down) 이후에 Roll back을 진행하였습니다.
A 시스템 담당자와 문제되었던 commit의 코드를 리뷰하였고, 문제로 의심되는 부분은 기존에 @Transaction을 걸어두었던 부분을 담당자가 이 메서드는 단순히 db에서 조회해와서 return하는것이니 readonly=true라는 옵션을 추가해준것이였습니다.
예시 코드 (단순 예시이며, @Transactional부분과 메서드의 단순 db 조회 정도만 동일합니다.)
// 기존의 소스
@Transactional
public IntlWbl readonly(String id) {
return intlWblRepository.findById(id).orElse(new IntlWbl());
}
// 변경한 소스
@Transactional(readOnly = true)
public IntlWbl readonly(String id) {
return intlWblRepository.findById(id).orElse(new IntlWbl());
}
그래서, roll back 이후 구글링과 여러 자료를 찾아보면서 readonly option을 추가하면 entity가 수정 불가능하다는 글을 보게 되었고, 변경하였던 메서드를 다른곳에서 많이 불러쓰니 문제가 될것이라고 추측하였으며, 담당자에게 경각심을 불어넣어 주었었습니다.
그 후,,, ec2 down 된 이유를 찾던 도중.. 서버 쪽 세팅에서 ebs volume이 95%가 되면 ec2를 down 하도록 세팅이 되어있었다는 걸 발견하였습니다. (어이없죠?)
그렇습니다. 정말 제 동료는 우연히도 94% 정도 차있던 시점에 배포를 진행하여 로그가 쌓여 ebs volume이 95%가 되어서 ec2가 down 된 것이었습니다..
마치 넘치기 일보직전인 물이 가득한 잔에 한 방울을 떨어트려 물 잔이 넘치게 된 것이죠.
서버가 죽은 이유가 밝혀지기 전, 저는 Transactional(readonly=true)가 문제라고 생각했었었습니다.
그러나, 서버 down의 사유도 아니었으며 깊게 알지 못해서 왜 문제인것인가! 라고 말을 못하고 문제일껄?!? 이라고 답을 하는 제 자신이 부끄러워 관련하여 이번기회에 공부하고 테스트해보았습니다.
또한, 공부하면서 발견한 내용이 readonly=true로 해두었어도 데이터 변경이 일어날 수 있는 상황이 있으며, 모른채 사용한다면 문제가 될 소지를 발견하여서 이러한 과정을 회사에서도 공유하고, 모두에게도 공유하자는 목적으로 글을 썼습니다.
Transaction with JPA!
What is Transaction?!?
먼저, Transaction에 대해서 Propagation, Isolation 등 알아야하는 개념들은 많지만.. 간단하게 설명하면, DB에 작업을 매 순간에 반영하지않고, 한번에 반영하고 중간에 잘못되면 Rollback하도록 해주는 것입니다.
Transaction은 한국어로 직역하면 거래입니다.
거래라고 하니, 거래를 예시로 들어서 이해하기 쉽게 설명한다면,
제가 동료에게 미개봉 아이폰을 구매하였는데 집에가서 오픈해보니 아이폰이 가품이였던것입니다.
이러면 동료에게 다시 가품 아이폰을 환불받고 저는 다시 제 돈을 돌려받는 원래의 상태로 돌리고 싶을것입니다.
아래의 Transaction이 아닌 상황의 코드를 참고하여 설명하겠습니다.
public void deal() throws Exception {
// buyer setting
Buyer buyer = Buyer.builder()
.name("buyer")
.money(new BigDecimal(100))
.product(null)
.build();
// seller setting
Seller seller = Seller.builder()
.name("seller")
.money(new BigDecimal(100))
.product("fake iPhone")
.build();
// 거래 성사
String prd = seller.getProduct();
buyer.setProduct(prd); // db 저장
seller.setProduct(null); // db 저장
// moeny Exchange
BigDecimal buyerMoney = buyer.getMoney(); // db 저장
BigDecimal sellerMoney = seller.getMoney(); // db 저장
seller.setMoney(sellerMoney.add(buyerMoney)); // db 저장
buyer.setMoney(null); // db 저장
// 물건확인
this.validateProduct(buyer.getProduct()); // boom! fraud detected!!
}
private void validateProduct(String prd) throws Exception {
if ("fake iPhone".equals(prd)) {
throw new Exception("fraud transaction!!");
}
}
seller는 fake iPhone을 가지고 있고 이들의 거래가 성사될때마다 db에 update 쿼리를 수행한다고 가정합니다.
(물론 JPA에서는 Transactional인 상황이 아니라 Entity에서 값을 변경하는 setXxxxx로는 update가 발생하지 않습니다.)
그리고 거래가 이루어지고, validateProduct로 물건확인을 하였을때.. fake iPhone이므로 Exception이 발생할 것입니다.
Transaction인 상태가 아니어서.. service는 exception이 발생하지만 db에는 이미 모든 값들이 다 저장되어 있어서 돌려 받을 수가 없는 상황입니다.
구매자의 돈은 구매자에게, 판매자는 다시 fake iPhone을 돌려받는 상황은 @Transactional만 걸어준다면 가능합니다.
단순하게 이야기를 한다면, Transactional이 걸려있다면 JPA에서는 Transaction이 종료되는 시점에 변경된 부분을 감지하여 db에 한꺼번에 update 쿼리를 수행합니다. 위와 같은 상황이라면 중간에 Exception이 발생하여 update 쿼리는 수행조차 안되었을 것입니다.
즉, 데이터베이스를 다룰 때 트랜잭션을 적용하면 데이터 추가, 갱신, 삭제 등으로 이루어진 작업을 처리하던 중 오류가 발생했을 때 모든 작업들을 원상태로 되돌릴 수 있습니다. 모든 작업들이 성공해야만 최종적으로 데이터베이스에 반영된다는 것입니다.
ReadOnly 먼저..!
많은 개발자들이 @Transactional이라는 annotation을 사용하며 서비스 개발을 합니다.
모든것이 잘 동작해야지 db에 마지막에 추가, 갱신, 삭제 등이 이루어지고.. 중간에 잘못되어도 DB에 잘못된 값이 반영되거나 그러지 않으니 너무나 좋은 기능으로써 사용하고 있었습니다.
그렇습니다. 저는 이 정도까지만 알고 있었으며, 위의 사태에서 좀 더 실무적인 상황에서 왜 readonly가 잘못되었는지도 완벽하게 알고 있지 않았습니다. (저와 비슷한 사람들에게 도움이 되었으면 좋겠습니다.)
먼저 코드를 보고 설명을 하겠습니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class FooService {
private final FooRepository fooRepository;
@Transactional(readOnly = true)
public String updateFooNo(String id, String second) {
Foo readOnlyFoo = this.readOnly(id);
Foo readWriteFoo = this.readWrite(second);
readOnlyFoo.setFooNo(123);
readWriteFoo.setFooNo(456);
return "success";
}
@Transactional
public String updateFooName(String id) {
Foo readOnlyFoo = this.readOnly(id);
readOnlyFoo.setFooName("김업데이트");
return "success";
}
@Transactional(readOnly = true)
public Foo readOnly(String id) {
return fooRepository.findById(id).orElse(new Foo());
}
@Transactional
public Foo readWrite(String id) {
return fooRepository.findById(id).orElse(new Foo());
}
}
위의 코드는 간단한 Service 코드입니다.
예를 들어 Controller에서 updateFooNo와 updateFooName을 순서대로 호출하여 db에 update 하는 로직이라고 생각해 봅시다. (이건 다 테스트를 위한 것이니 컨트롤러에 로직이 있니,, 뭐 이런 것은 양해해주십시요!)
지금 이걸 읽고 계신다면, updateFooNo와 updateFooName이 저장이 db에 어떻게 될 것이라고 생각하시나요?
참고로, Transactional(readOnly=true) 옵션도 잘 고려해서 생각해 보시면 좋을것 같습니다.
생각을 해보시고 아래 주석이 추가된 코드를 보시죠!
.
..
…
....
.....
저도 물론 처음에 당연하게도 readonly로 조회해왔으니 updateFooNo에서는 setFooNo는 안될것이고,
fooName만 new Foo Name으로 업데이트 되겠네요..
어..? 그러나 이것도 readonly로 조회해왔으니.. 업데이트 안되나?.. 이렇게 헷갈리는 상황이였습니다.
그래도 결국 아무런 업데이트 안되겠네~ … 라고 생각했었습니다…
// .. 생략
@Transactional(readOnly = true) // readonly!!
public String updateFooNo(String id, String second) {
Foo readOnlyFoo = this.readOnly(id);
Foo readWriteFoo = this.readWrite(second);
readOnlyFoo.setFooNo(123);
readWriteFoo.setFooNo(456);
// 이 method(updateFooNo)에 readonly가 true이니 당연히 업데이트 안되겠지~~
return "success";
}
@Transactional
public String updateFooName(String id) {
Foo readOnlyFoo = this.readOnly(id);
readOnlyFoo.setFooName("new Foo Name");
// 이 메서드는 readonly가 안켜져 있지만, readOnly로 조회해온 Entity를 수정하는거니 얘도 업데이트 안되겠지~~
return "success";
}
@Transactional(readOnly = true) // readOnly!!!!!!
public Foo readOnly(String id) {
return fooRepository.findById(id).orElse(new Foo());
}
@Transactional
public Foo readWrite(String id) {
return fooRepository.findById(id).orElse(new Foo());
}
// .. 생략
그러나, db에는 모든 FooNo와 모든 FooName이 업데이트되어 있었습니다.
자..! 여기서 저의 호기심을 더욱더 자극시켰습니다. 왜 안될것 같았던 업데이트들이 다 수행이 되었는지..
아래에 처음부터 하나하나 설명하겠습니다.
updateFooNo는 readOnly가 걸려있습니다.
그러면 해당 Transaction은 readonly로 설정이 되며 readonly 옵션은 제일 상위 transaction의 세팅에 따라가게 되어있습니다.
즉, updateFooNo 메서드는 DB 작업이 이루어 지지 못하죠.
왜냐하면 Transactional(readOnly = true)옵션은 EntityManager에 flush mode를 MANUAL로 바꾸기 때문입니다.
*참고*
EntityManager의 flush가 일어나면 DB에 처음 조회해왔던 Entity와 flush 시점의 Entity를 서로 비교하여(dirtyChecking) 변경 부분에 대해서 update 쿼리를 수행하는 것입니다. 하지만 flush mode가 MANUAL이기때문에, 자동으로 이루어 지지 않으며 이로 인해 update가 일어나지 않는 것입니다.
그러면 EntityManager에는 flush mode가 manual인 상태로 readOnlyFoo, readWriteFoo라는 Foo entity가 두개가 존재하겠죠? 그리고 처음 Transaction인 readonly transaction은 종료가 됩니다.
그리고.. 다음 순서로 updateFooName 메서드가 새로운 transaction으로 수행됩니다. 이는 readonly 옵션의 default 값인 false로 생성됩니다.
그리고 redaonlyFoo entity를 조회해와서(entity manager에 1차캐싱되어있으니 실제 db에 조회 쿼리는 수행하지 않음!)
fooName을 업데이트 합니다.
이때, transaction이 종료되면서 entity manager에서 dirthChecking을 진행합니다.
여기서 entity manager에는 readOnlyFoo와 readWriteFoo 둘다 가지고 있죠? 게다가 처음에 db에서 조회해올때 떠놓은 스냅샷과 비교를 하는데 둘다 다르니.. drithChecking에 의해서 두 entity 모두 update 쿼리가 수행됩니다.
이러한 이유로 첫번째 메서드에서 Readonly를 true를 걸어도 데이터 수정이 일어날 수 있습니다.
아래는 수행 로그를 찍은것들인데 참고하시면 이해가 더 빠를듯 합니다.
(이럴 일은 없겠지만 readOnly로 조회해 와서 데이터를 수정하고 싶다면 EntityManager의 flush를 수행하면 데이터를 수정할 수 있습니다.)
자 하나하나 코드에서 로그 찍어가며 설명을 하자면 아래와 같습니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class FooService {
private final FooRepository fooRepository;
private final EntityManager em;
@Transactional(readOnly = true)
public String updateFooNo(String id, String second) {
log.info("updateFooNo // transaction active? {}", TransactionSynchronizationManager.isActualTransactionActive()); // true
log.info("updateFooNo // transaction readonly? {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly());// true
Foo readOnlyFoo = this.readOnly(id);
Foo readWriteFoo = this.readWrite(second);
readOnlyFoo.setFooNo(123);
readWriteFoo.setFooNo(456);
log.info("Foo Entity Manager contains? {}", em.contains(readOnlyFoo));
log.info("Foo Entity Manager contains? {}", em.contains(readWriteFoo));
log.info("updateFooNo 2 // transaction active? {}", TransactionSynchronizationManager.isActualTransactionActive());// true
log.info("updateFooNo 2 // transaction readonly? {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly());// true
return "success";
}
@Transactional
public String updateFooName(String id) {
log.info("updateFooName // transaction active? {}", TransactionSynchronizationManager.isActualTransactionActive());// true
log.info("updateFooName // transaction readonly? {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly());// true
Foo readOnlyFoo = this.readOnly(id);
readOnlyFoo.setFooName("new Foo Name");
log.info("updateFooName 2 // transaction active? {}", TransactionSynchronizationManager.isActualTransactionActive());// true
log.info("updateFooName 2 // transaction readonly? {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly());// true
return "success";
}
@Transactional(readOnly = true)
public Foo readOnly(String id) {
log.info("readonly // transaction active? {}", TransactionSynchronizationManager.isActualTransactionActive());// true
log.info("readonly // transaction readonly? {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly());// true
return fooRepository.findById(id).orElse(new Foo());
}
@Transactional
public Foo readWrite(String id) {
log.info("readWrite // transaction active? {}", TransactionSynchronizationManager.isActualTransactionActive()); // true
log.info("readWrite // transaction readonly? {}", TransactionSynchronizationManager.isCurrentTransactionReadOnly()); //false
return fooRepository.findById(id).orElse(new Foo());
}
로그에서 보면 처음에 updateFooNo 메서드에 들어오고 나서는 (초록 박스)
transaction active = true / transaction readonly = true 인 상황이고,
readonly에서 조회해 와도 둘 다 true, readWrite에서 조회해 오더라도 둘 다 true입니다.
Entity Manager에서는 둘 다 가지고 있습니다.
그리고 각각 select가 일어난 쿼리도 보입니다.
transaction이 종료되었음에도 readonly 옵션이 true이다 보니 entity manager에서 flush가 manual이라
업데이트 쿼리가 수행되지 않음도 확인할 수 있습니다.
다음으로 updateFooName 메서드로 넘어오면.. (주황 박스)
transaction active = true / transaction readonly = false 인 상황이고,
readonly에서 조회해 오더라도 상위 transaction의 옵션값을 따라가다 보니 readonly가 여전히 false인 것을 확인할 수 있습니다.
그리고 readOnly false이다 보니, transaction이 종료되는 시점에 transaction에서 commit이 일어나고,
entity manager에 flush가 일어나고 위에서 설명한 dirtyChecking이 일어남으로 두 entity에 대해서 모두 update 쿼리가 날아감을 확인할 수 있습니다. (update 2개)
그리고 db에는 readonly에서 update 한 데이터도 변경이 되었습니다.
반대로 ReadOnly를 나중에 한다면...?!?
위의 예시처럼 한 스레드 내에서 readOnly가 먼저 수행되면 Entity manager가 나중의 Transactional의 flush 모드의 영향을 받아서 데이터를 수정이 되었습니다.
그럼 반대로 readonly를 나중에 수행한다면 첫 transaction 이후에 update 쿼리는 중간에 수행될까요?
직접 로그와 코드를 보시죠!
코드는 위치만 변경한 것이라 생략하겠습니다.
먼저 updateFooName(readonly false) 메서드가 수행되고 select 쿼리를 수행해서 조회해오고.. transaction이 종료되는 시점에 update 쿼리가 수행된것을 확인할 수 있습니다.
두번째로 updateFooNo(readonly true) 메서드가 수행되고 select 쿼리를 수행해서 조회해온뒤에 update 쿼리는 수행되지 않았음을 확인할 수 있습니다.
즉, 예상한 대로 첫 번째 Transaction은 readOnly가 false이므로 commit이 일어난 이후에 entity manager에서 flush가 일어나고 DB에 변경된 값이 반영이 되었습니다.
물론 두 번째 메서드는 readOnly ture이므로 commit이 일어나더라도 entity manager에서 flush가 일어나지 않고, 이게 결국 DB에 업데이트되지 않았습니다.
마치며..
처음에.. readOnly가 문제일 것이라고 생각했을 때 블로그를 많이 뒤져봤지만.. 다들 개념들만 설명하고 뭔가.. 로그를 찍어가면서 설명해주고 하는 블로그가 별로 없더라구여..
너무 아쉬운 마음에 이거 공부하면서 블로그에 글을 써야겠다는 생각을 하게 되었었습니다..ㅋㅋㅋ
또한, Transaction readOnly가 배포한 A 시스템의 장애의 원인이라고 오해하면서.. 좀 더 깊게 공부하는 계기가 되었습니다.
잘 모르고 Ctrl + C로 복사해서 사용한다면, 의도치 않게 데이터가 수정될 수 있는 위험이 있다는것을 좀더 다양한 사람들에게 이야기할 수 있어서 좋았습니다.
transaction과 readonly에 대해서 어떻게 돌아가고, 왜 이렇게 수행되는지 등등 여러 정보를 알 수 있게 되었고 propagation을 추가해서 중첩으로 Transaction을 만들어서 더 다양하게 test 해보았었지만 글로 풀이를 하기엔 너무 길어지는것 같네요.
댓글 남겨주시면 공유드리겠습니다! 🫡
(화이트보드만 있다면 그리면서 설명하면 2시간은 후딱 갈듯 합니다 ㅎㅎㅎ)
그래도 이 글을 통해서라도 transaction과 readonly에 대해서 어떻게 돌아가고 등등 여러 정보를 알 수 있을 것 같으며,
Spring 공식 문서를 읽어보면 정말 많은 정보들을 알 수 있습니다..
이해가 안 되거나 잘못된 부분이 있다면 언제든지 피드백 환영합니다! 🤗
'JAVA > Spring-boot' 카테고리의 다른 글
[Java] 내가 짠 Java코드를 컴퓨터가 어떻게 읽을까? (feat. JIT compiler) (1) | 2024.02.18 |
---|---|
[Spring boot] 3.xx 버전에서 2.xx버전으로 낮추기 (0) | 2024.01.26 |
[Spring Boot] Spring과 Spring Boot의 차이?!? (1) | 2023.10.10 |
[Spring boot] AOP 적용 사례 (feat. AOP로 회원 인증을 구현해보자!) (0) | 2023.08.29 |