Backend 26

@Transactional과 readOnly 옵션: DB 일관성과 성능 최적화

DB 작업을 순서대로 실행하면 되는데 왜 트랜잭션으로 묶어야 하는지 모르겠다.

근거 · 교안 p169-p172

@Transactional과 readOnly 옵션: DB 일관성과 성능 최적화 대표 이미지

1장: 동시성 속의 혼돈: DB 불일치 사례

솔라는 노트북 화면에 떠 있는 한 문장에서 눈을 떼지 못했다. 개발자 커뮤니티에서 본 글이었다. 내용은 간단했지만, 어딘가 석연치 않았다.

트랜잭션은 데이터 일관성을 보장하기 위해 모두 성공 또는 모두 실패를 만든다.

옆에서 조용히 책을 읽던 언니 루나에게 솔라가 불쑥 말을 걸었다.

“언니, 이거 좀 이상해. DB 작업을 순서대로 쭉 실행하면 되는 거 아니야? 예를 들어 1번 작업하고, 2번 작업하고. 순서만 잘 지키면 데이터가 틀어질 일이 없잖아. 왜 굳이 ‘모두 성공 아니면 모두 실패’ 같은 복잡한 개념이 필요한 거지?”

솔라의 목소리에는 당연한 사실에 대한 불필요한 설명이라는 듯한 뉘앙스가 섞여 있었다. DB 작업은 코드를 짠 순서대로 실행되는 것, 그 이상도 이하도 아니라고 생각했다.

루나는 책에서 눈을 떼고 솔라를 바라보았다. 잠시 생각에 잠긴 듯하던 루나가 말했다.

“음, 그럴듯하게 들리네. 그럼 우리, 딱 하나 남은 한정판 운동화를 파는 온라인 상점을 상상해볼까?”

루나는 테이블 위에 놓인 메모지와 펜을 가져왔다. 메모지 중앙에 큼직하게 이렇게 썼다.

[운동화 재고: 1]

“이게 우리 데이터베이스야. 지금 딱 하나 남았어. 그리고 지금 솔라 너랑 나, 두 사람이 동시에 이 운동화를 발견하고 구매 버튼을 누르려고 해.”

솔라는 자신만만하게 시나리오를 이어갔다.

“그야 간단하지. 내가 먼저 구매 버튼을 눌렀다고 치자. 그럼 시스템은 제일 먼저 재고를 확인하겠지? ‘재고: 1’이니까 구매 가능. 그다음 내 주문을 처리하고 재고를 ‘0’으로 바꿔. 그 직후에 언니가 구매 버튼을 누르면, 시스템은 재고를 확인하고 ‘재고: 0’이니까 ‘품절입니다’라고 알려줄 거야. 아무 문제 없는데?”

솔라는 자신의 논리가 완벽하다고 생각했다. 작업들이 순서대로 일어난다면 문제는 발생할 여지가 없었다.

“거기까진 맞아.”

루나가 고개를 끄덕였다.

“하지만 솔라, 컴퓨터의 ‘동시’는 우리가 생각하는 것과 조금 달라. 두 개의 요청이 시스템에 거의 같은 순간에 도착했을 때, 시스템은 두 요청의 작업들을 아주 잘게 쪼개서 번갈아 처리할 수도 있어. 마치 카드 덱 두 개를 섞는 것처럼 말이야.”

루나는 새로운 메모지 두 장을 꺼내 ‘A의 요청’, ‘B의 요청’이라고 적었다. 그리고 각 요청이 수행할 작업을 작게 나누어 적었다.

[A의 요청]

  1. 재고를 확인한다. (결과: 1)
  2. 재고가 있으면, 주문을 생성한다.
  3. 재고를 1 감소시킨다.

[B의 요청]

  1. 재고를 확인한다. (결과: 1)
  2. 재고가 있으면, 주문을 생성한다.
  3. 재고를 1 감소시킨다.

“자, 이제 내가 데이터베이스 시스템 역할을 할게. 솔라 네가 A와 B의 요청을 순서대로 말해봐. 단, 내가 지시하는 아주 미세한 타이밍에 맞춰서.”

루나가 테이블 중앙에 놓인 [운동화 재고: 1] 메모지를 톡톡 쳤다.

“시작해봐. 먼저 A가 재고를 확인해.”

“A 요청: 재고 확인!” 솔라가 외쳤다.

“좋아. 재고는 1개. 구매 가능.” 루나가 대답했다. “자, 바로 이 순간, A가 주문을 생성하기 직전에 B의 요청이 끼어들었어. B도 재고를 확인해봐.”

“B 요청: 재고 확인!”

“응. 재고는 여전히 1개야. 아직 A가 재고를 줄이지 않았으니까. B도 구매 가능.”

그 말을 듣는 순간, 솔라의 표정이 굳어졌다. “아…”

루나는 아무 말 없이 다음 단계를 기다렸다.

“계속해봐, 솔라. 이제 A는 어떻게 해야 하지? 재고가 있다는 걸 확인했잖아.”

“A 요청: 주문을 생성하고, 재고를 1 감소시켜.” 솔라가 마지못해 말했다.

루나는 [운동화 재고: 1] 메모지 위 숫자 ‘1’에 줄을 긋고 ‘0’이라고 고쳐 썼다. “좋아. A의 주문은 성공적으로 처리됐고, 재고는 이제 0이야. 다음은?”

”…B도 재고가 있다고 확인했으니까… 주문을 생성하고 재고를 1 감소시켜.”

솔라의 목소리는 점점 작아졌다. 루나는 이미 ‘0’이 된 재고 숫자를 다시 한번 쳐다보더니, 그 위에 또 줄을 긋고 ‘-1’이라고 적었다. 혹은, 시스템 로직에 따라 그냥 ‘0’으로 덮어쓸 수도 있었다. 중요한 것은 결과였다.

“자, 어떻게 됐지?” 루나가 물었다.

솔라는 테이블 위에 놓인 메모지들을 망연히 바라보았다.

“재고는 하나뿐이었는데… 주문은 두 개가 들어갔어. 한 명은 운동화를 못 받을 거고, 데이터베이스 재고는 엉망이 됐어. 이게… 데이터 불일치구나.”

솔라는 자신이 놓쳤던 부분을 깨달았다. 문제는 ‘순서’ 자체가 아니었다. 하나의 논리적인 묶음으로 취급되어야 할 작업들—‘재고 확인’과 ‘재고 감소’—사이에 다른 작업이 끼어들 수 있다는 사실이 문제였다. 각 작업이 순서대로 실행된다고 믿었지만, 여러 요청이 동시에 몰리면 그 순서는 뒤죽박죽 섞일 수 있었다.

솔라는 고개를 들어 처음 자신을 혼란스럽게 했던 문장을 떠올렸다. 트랜잭션은 데이터 일관성을 보장하기 위해 모두 성공 또는 모두 실패를 만든다.

“그럼… 트랜잭션이라는 게, 방금 그 ‘재고 확인하고 줄이기’ 같은 작업을 하나의 거대한 덩어리로 만들어서, 누구도 중간에 끼어들지 못하게 막아준다는 뜻인가? A의 작업 묶음이 완전히 성공하거나, 뭔가 문제가 생기면 아예 없던 일처럼 실패하게 만들어서 B가 혼란을 겪지 않게 하는 건가?”

솔라의 눈빛이 바뀌었다. 더 이상 ‘왜 필요하지?‘라는 의심이 아니었다. ‘어떻게 그게 가능하지?‘라는 호기심이었다. 방금 눈앞에서 벌어진 혼돈을 막을 수 있는 열쇠가 바로 그 문장 안에 있는 것 같았다.

2장: 모 아니면 도: @Transactional의 약속

솔라는 어제 사용했던 메모지들을 한쪽으로 밀어두고, 깨끗한 새 페이지를 펼쳤다. ‘재고 문제’보다 더 복잡한 상황을 그려보고 싶었다. 그녀는 펜을 들어 두 개의 상자를 그리고 각각 ‘솔라의 계좌’, ‘언니의 계좌’라고 적었다. 그리곤 그 아래에 잔액을 표시했다.

솔라 계좌: 50,000원 언니 계좌: 20,000원

어제의 혼돈을 겪고 난 뒤, 솔라의 머릿속은 온통 ‘어떻게 그게 가능하지?’라는 질문으로 가득 차 있었다. 그녀는 노트북을 열어 얼마 전 작성했던 서비스 클래스 코드를 띄웠다. 상품 구매를 처리하는 purchase 메서드 위에, 마치 부적처럼 붙어있는 @Transactional 어노테이션이 눈에 들어왔다. 전에는 그저 여러 DB 작업을 묶어주는 편리한 기능 정도로 생각했었다.

솔라가 입을 열었다. “그럼 언니, @Transactional이라는 게 일종의 울타리 같은 거네. 어제처럼 ‘재고 확인’과 ‘재고 감소’ 작업이 있다면, 이 울타리가 다른 요청이 중간에 끼어들지 못하게 막아주는 거지?”

루나는 솔라가 새로 그린 계좌 이체 그림을 흥미롭게 바라보았다. “울타리라, 좋은 비유네. 다른 요청으로부터 작업을 보호하는 것. 확실히 중요한 역할이지.”

루나는 잠시 말을 멈추고 솔라의 그림을 손가락으로 가리켰다. “그런데 그 울타리는 외부의 침입자만 막아줄까? 만약 울타리 안에서 문제가 생기면 어떡하지? 예를 들어 솔라 네가 내게 10,000원을 송금하는 상황을 생각해 보자.”

루나는 솔라의 그림 옆에 작업 순서를 적어 내려갔다.

[계좌 이체 작업]

  1. 솔라의 계좌에서 10,000원을 뺀다.
  2. 언니의 계좌에 10,000원을 더한다.

“아주 간단한 두 단계야. 솔라 네 말대로라면, 울타리가 쳐져 있으니 다른 사람은 이 과정에 끼어들 수 없어. 자, 이제 작업을 시작해 볼까?” 루나가 말했다.

솔라는 자신 있게 고개를 끄덕였다. “응. 먼저 내 계좌에서 10,000원을 빼. 그럼 내 잔액은 40,000원이 돼.”

루나는 ‘솔라의 계좌’ 아래에 적힌 ‘50,000원’에 줄을 긋고 ‘40,000원’이라고 수정했다. “좋아. 첫 번째 단계는 성공. 이제 두 번째 단계를 실행하려던 바로 그 순간…”

루나가 갑자기 ‘펑!’ 하는 소리를 냈다.

“시스템에 예측하지 못한 에러가 발생해서 서버가 다운됐어. 예를 들면 정전 같은 거.”

솔라는 순간 할 말을 잃었다. 테이블 위에는 솔라의 잔액만 바뀐 채 멈춰버린 그림이 놓여 있었다.

“내 돈 10,000원은 빠져나갔는데… 언니 계좌에는 들어오지 않았어. 돈이 공중에서 사라졌네.”

“바로 그거야.” 루나가 말했다. “솔라 네가 말한 ‘울타리’는 외부의 침입은 막아줬지만, 내부에서 발생한 사고까지는 해결해주지 못했어. 결국 데이터는 또다시 틀어졌지. 우리 둘의 돈을 합친 총액이 10,000원이나 줄었으니까.”

솔라는 @Transactional이 단순히 여러 작업을 순서대로 실행되게 묶어주는 편리한 기능이 아니라는 것을 깨달았다. 외부의 간섭을 막는 것만으로는 부족했다.

“그럼 @Transactional은 대체 뭘 하는 건데?”

“그건 단순한 울타리가 아니라, 아주 엄격한 규칙을 가진 ‘격리된 작업실’에 가까워.” 루나가 설명했다. “그 작업실의 첫 번째 규칙은 바로 ‘모 아니면 도’야. 작업실 안의 모든 일이 완벽하게 끝나거나, 중간에 문제가 생기면 모든 걸 시작 전 상태로 되돌려. 아무 일도 없었던 것처럼.”

루나는 테이블 위의 메모지를 원래대로 되돌렸다. 솔라의 계좌는 다시 50,000원이 되었다.

“다시 해보자. 이번엔 ‘@Transactional 작업실’ 안에서야. 첫 번째 단계, 네 계좌에서 10,000원을 빼는 작업을 시스템이 기록해. 아직 확정된 건 아니야. 그리고 두 번째 단계로 넘어가려는데… 아까처럼 ‘펑!’하고 서버가 멎었어.”

“그럼…?” 솔라가 조심스럽게 물었다.

“작업실의 규칙이 발동돼. ‘모든 일이 완벽하게 끝나지 않았으므로, 모든 걸 무효로 한다.’ 서버가 다시 켜졌을 때, 시스템은 네 계좌에서 10,000원을 빼려던 기록을 폐기하고 모든 것을 원상 복구시켜. 네 계좌 잔액은 여전히 50,000원, 내 계좌도 그대로 20,000원. 돈은 사라지지 않았어. 이체는 실패했지만, 데이터의 정합성은 지켜낸 거지. 이걸 **롤백(Rollback)**이라고 불러.”

솔라는 비로소 ‘모두 성공 또는 모두 실패’라는 문장의 진짜 무게를 실감했다. 그것은 외부의 간섭을 막는 ‘고립’의 약속일 뿐만 아니라, 작업 자체의 ‘완전함’을 보장하는 약속이었다. 하나로 묶인 작업들은 분리할 수 없는 한 덩어리, 즉 원자(Atom)와 같아야 했다. 중간에 깨질 수 없는 하나의 단위.

“알겠다. @Transactional은 그냥 ‘묶음’이 아니라 ‘약속’이구나. ‘이 안의 일들은 전부 성공시키거나, 아니면 아예 없던 일로 만들겠다’는 약속.”

솔라는 노트북의 @Transactional 어노테이션을 다시 보았다. 이제 그것은 단순한 코드가 아니라, 데이터의 일관성을 지키는 굳건한 방패처럼 보였다. 수많은 동시 요청과 예측 불가능한 오류 속에서도 데이터의 질서를 지켜내는 핵심적인 장치였다.

문득 솔라는 새로운 궁금증이 생겼다.

“그러면 상품 재고를 변경하거나, 이렇게 계좌 이체를 하는 것처럼 데이터를 바꾸는 작업에는 @Transactional이 필수라는 건 확실히 알겠어. 그런데… 그냥 데이터를 조회만 하는, 예를 들어 상품 목록을 쭉 보여주기만 하는 메서드에도 이걸 붙여야 할까? 데이터를 바꾸는 것도 아닌데, ‘모두 성공 또는 모두 실패’라는 약속이 필요할까?”

솔라의 질문에 루나는 대답 대신 희미한 미소를 지었다. 혼돈 속에서 질서를 지키는 법을 하나 배우자, 숨겨진 비효율성에 대한 의문이 자연스럽게 고개를 들고 있었다.

3장: 똑똑한 조회: readOnly=true의 최적화

솔라의 손가락이 키보드 위에서 잠시 멈칫했다. 방금 전까지 계좌 이체처럼 복잡한 데이터 변경 로직에 @Transactional을 붙이며 데이터의 일관성을 지키는 든든함에 감탄하던 참이었다. 하지만 지금 그녀가 마주한 코드는 훨씬 단순했다.

public List<Product> getAllProducts() {
    // DB에서 모든 상품 목록을 조회해서 반환한다.
    return productRepository.findAll();
}

이 메서드는 데이터를 변경하지 않는다. 그저 있는 데이터를 읽어와 보여줄 뿐이다. 그런데도 습관처럼 @Transactional 어노테이션을 붙여야 할까? ‘모 아니면 도’라는 강력한 약속이, 단순히 목록을 조회하는 이 작업에도 정말 필요할까? 마치 무거운 갑옷을 입고 산책에 나서는 듯한 위화감이 들었다.

솔라는 노트북 화면에서 고개를 들지 않은 채 옆에 있는 루나에게 물었다.

“언니, 데이터를 바꾸는 작업에는 @Transactional이 필수라는 건 알겠어. 롤백이 필요하니까. 그런데 이렇게 조회만 하는 메서드는 어때? 데이터를 바꾸지도 않는데, 굳이 트랜잭션으로 묶는 게 의미가 있어? 오히려 불필요한 일을 시키는 것 같아서.”

루나는 솔라의 화면을 잠시 들여다보더니, 테이블 위에 놓여있던 계좌 이체 메모 대신 새로운 비유를 꺼냈다.

“도서관에 갔다고 상상해 보자. 책을 빌리고, 밑줄을 긋고, 심지어 페이지 순서를 바꿀 수 있는 ‘편집자용 출입증’이 있어. 반면에 오직 책을 열람만 할 수 있는 ‘열람용 출입증’도 있고.”

솔라는 고개를 끄덕였다.

“일반적인 @Transactional은 ‘편집자용 출입증’과 비슷해. 네가 그 출입증을 가지고 도서관에 들어가면, 사서는 네가 언제든 책을 수정할 수 있다고 가정해. 그래서 네가 작업하는 동안 다른 사람이 같은 책을 함부로 바꾸지 못하도록 하거나, 네가 실수했을 때를 대비해 원래 상태를 따로 기억해두는 등 여러 준비를 하지. 네가 실제로 책을 수정하든 안 하든 상관없이 말이야.”

솔라의 눈이 살짝 커졌다. “내가 그냥 읽기만 할 건데도?”

“응. 출입증 자체가 그런 권한을 갖고 있으니까. 사서 입장에서는 만약을 대비해야 하거든.” 루나가 말을 이었다. “데이터베이스도 마찬가지야. 우리가 그냥 @Transactional을 걸면, 데이터베이스는 이 작업 안에서 데이터 변경이 일어날 수 있다고 가정하고 여러 준비를 해. 변경 내역을 추적할 준비, 원래 값으로 되돌릴(롤백) 준비 같은 것들 말이야. 조회만 하는 작업인데도 말이지.”

그제야 솔라는 자신이 느꼈던 위화감의 정체를 깨달았다. @Transactional은 단순히 코드를 묶는 것이 아니라, 데이터베이스에 ‘이제부터 중요한 작업을 시작하니, 만반의 준비를 하라’는 신호를 보내는 것이었다. 그리고 그 준비에는 비용이 따른다.

“그럼 readOnly=true 옵션은… ‘열람용 출입증’ 같은 거구나!”

솔라가 외쳤다.

“맞아. @Transactional(readOnly = true)는 데이터베이스에게 보내는 명확한 신호야. ‘나는 이번 작업에서 절대로 데이터를 바꾸지 않을 거야. 오직 읽기만 할 거니, 무거운 준비는 하지 않아도 돼.’ 이 신호를 받은 데이터베이스는 불필요한 변경 추적 기능이나, 복잡한 잠금(Lock) 메커니즘을 생략할 수 있어. 도서관 사서가 ‘아, 그냥 읽기만 하시는군요. 그럼 편하게 보세요.‘라고 하는 것처럼 말이야.”

루나는 솔라의 코드 화면을 가리켰다. “결과적으로 어떻게 될까? 데이터베이스의 부담이 줄어드니 성능이 향상되지. 특히 수많은 사용자가 동시에 상품 목록을 조회하는 상황이라면, 이 작은 최적화가 전체 시스템의 응답 속도에 큰 차이를 만들 수 있어.”

솔라는 더 이상 망설이지 않았다. 그녀는 키보드 위로 손을 가져가 코드를 수정하기 시작했다. 단순한 정보 표기가 아니었다. 방금 깨달은 원리를 적용하는 명확한 판단의 결과였다.

@Transactional(readOnly = true)
public List<Product> getAllProducts() {
    return productRepository.findAll();
}

한 줄의 코드를 추가했을 뿐인데, 이전과는 전혀 다른 의미로 보였다. 이것은 ‘읽기 전용 작업이니 최적화해달라’는 똑똑한 요청이었다.

솔라는 거기서 멈추지 않았다. 자신의 서비스 클래스 전체를 스크롤하며 다른 메서드들을 점검하기 시작했다.

  • updateStock(...) : 데이터를 변경한다. 이건 readOnly 없이 그냥 @Transactional이 맞다. ‘모 아니면 도’의 약속이 반드시 필요하다.
  • findProductById(...) : 특정 상품 하나를 조회한다. 데이터를 바꾸지 않는다. @Transactional(readOnly = true)를 추가했다.
  • createOrder(...) : 주문을 생성하고 재고를 줄인다. 복잡한 쓰기 작업이다. @Transactional은 필수다.

그녀는 마치 숙련된 기술자처럼 각 메서드의 역할을 구분하고 그에 맞는 트랜잭션 전략을 적용했다. 더 이상 모든 서비스 메서드에 무심코 @Transactional을 붙이던 초보 개발자가 아니었다. 데이터의 일관성을 보장하는 강력한 보호막과, 불필요한 비용을 줄이는 성능 최적화 사이에서 현명한 선택을 내릴 수 있게 된 것이다.

솔라는 수정된 코드를 보며 만족스럽게 중얼거렸다.

“데이터를 지킬 곳에서는 굳건한 방패를, 속도가 필요한 곳에서는 가벼운 날개를.”

이제 그녀에게 @Transactional 어노테이션은 더 이상 혼란스러운 규칙이 아니었다. 데이터베이스와 소통하며 일관성과 성능, 두 마리 토끼를 모두 잡는 섬세하고 강력한 도구였다.