[order] 재고 차감에서 갱신 손실을 해결하며 이해한 트랜잭션 락
왜 동시성 문제에 관심을 가졌는가?
기존 프로젝트에서는 트랜잭션이 내부적으로 어떻게 동작하는지 깊이 이해하지 못한 채, 하나의 작업을 원자적으로 처리하기 위한 용도로만 @Transactional을 사용해 왔다. 그래서 여러 트랜잭션이 동시에 동일한 데이터를 수정할 때도 의도한 대로 데이터 무결성이 지켜지는지 확신할 수 없었다.
그래서 이번 주문 프로젝트에서는 재고 차감 로직에서 의도적으로 동시성 문제(갱신 손실)를 발생시켜 보고, 이를 다양한 락(Lock) 메커니즘을 통해 해결해 나가는 과정을 경험해 보기로 했다.
갱신 손실이 발생했다.
현재 재고가 10개인 상품에 대해 스레드 A와 B가 동시에 접근하여 1개씩 차감을 시도한다고 가정해 보자. 두 스레드는 모두 DB에서 재고 10개를 읽어온다. 이후 각각 1개를 차감하여 9개를 DB에 덮어쓴다. 결과적으로 2개의 주문이 들어왔지만, 최종 재고는 8개가 아닌 9개가 되는 갱신 손실 문제가 발생하게 된다.
실제로 락을 적용하지 않은 상태에서 재고 100개인 상품에 100개의 동시 요청을 보내는 테스트를 진행했다. 그 결과 갱신 손실로 인해 100개의 요청이 모두 정상 처리된 것처럼 응답했음에도, 실제 DB에는 10개의 재고만 차감되어 90개가 남아있는 데이터 불일치를 확인했다.
갱신 손실은 대표적인 동시성 문제이며, 이를 해결하기 위한 방법 중 하나가 데이터베이스의 트랜잭션 락이다. 이번 글에서는 동일한 재고 차감 로직에 비관적 락과 낙관적 락을 각각 적용하며 어떤 차이가 있는지 확인했다.
비관적 락을 적용했다.
가장 먼저 고려한 방식은 데이터베이스 수준에서 락을 거는 비관적 락이었다. 처음에는 재고 차감 기능은 동일한 상품에 대한 요청이 자주 몰릴 것이라고 예상했다. 그래서 비관적 락을 적용하여 테스트 코드를 작성했었다.
비관적 락 테스트 결과(멀티 스레드): 재고 1000개, 스레드 1000개로 설정하여 테스트를 진행했다.
낙관적 락 테스트 결과(멀티 스레드): 재고 1000개, 스레드 1000개
결과적으로 두 방식의 락 모두 갱신 손실 문제 없이 정확하게 재고가 차감되는 것을 확인했다. 하지만 낙관적 락을 적용한 멀티 스레드의 경우, 재시도(50ms) 로직으로 인해 요청 처리 시간이 늘어나고 재시도 횟수(50회)의 부족으로 요청에 실패한 데이터가 있다. 초기 테스트에서는 비관적 락이 안정적으로 동작했기 때문에 우선 비관적 락을 선택했다.
알고보니 충돌 해결이 우선순위가 아니었다.
하지만 여기서 새로운 고민이 생겼다. 충돌이 많이 발생하는 상황에서 비관적 락은 확실하지만, DB의 Row 자체에 락을 걸기 때문에 다른 트랜잭션의 대기 시간이 길어져 시스템 전체의 성능 저하를 유발할 수 있었다.
그리고 일반적인 쇼핑몰의 재고 차감 로직은 선착순 이벤트처럼 극단적으로 동시 접속자가 몰리는 상황이 아니라고 판단했다.
또한, 재고가 충분한 상황이라면 일부 충돌은 재시도를 통해 해결하는 전략도 충분히 고려할 수 있다고 판단했다. 그리고 재고가 0 이하로 내려갈 때의 예외 처리만 확실히 해주면 된다고 생각했다.
이러한 상황에서는 DB에 락을 걸어 정합성을 유지하는 비관적 락보다 낙관적 락 이 성능상 훨씬 유리하다고 판단하여 구조를 변경했다.
하지만 테스트 결과는 속도의 차이가 유의미하지 않았다.
아래 사진은 상품 중 무작위로 하나를 선택하여 상품 구매 후 재고 감소를 테스트한 결과이다. 이때 각 상품의 재고는 1000개이며, 스레드풀은 32이다.
극단적으로 충돌이 많은 환경뿐 아니라, 실제 서비스와 유사한 충돌 빈도에서도 차이를 확인하기 위해 테스트 조건을 변경하며 진행했다.
상품의 수를 100개, 한 번에 요청하는 횟수인 요청 스레드는 10000으로 설정한 후 테스트해보았다.
비관적 락 테스트 결과
낙관적 락 테스트 결과
이번엔 가상의 소규모 쇼핑몰을 생각하여 상품을 500개로, 요청 스레드는 10으로 변경하여 시도해보았다.
비관적 락 테스트 결과
낙관적 락 테스트 결과
일반적으로 낙관적 락은 충돌이 적은 환경에서 높은 처리량을 기대할 수 있고, 비관적 락은 충돌이 잦은 환경에서 안정적인 처리가 가능하다고 알고 있었다.
실제로 위의 결과로 충돌이 적은 환경에서는 낙관적 락이 속도가 빠르지만 실제 환경을 생각해보면 일반적인 주문 서비스에서는 60ms의 차이가 낙관적 락을 선택할 만큼의 이점으로 보이지 않는다.
결국 비관적 락으로 다시 변경했다.
이러한 문제들을 실무에서는 다양한 전략들을 활용해서 문제를 해결한다는 내용을 접했다. 하지만 신입의 입장에서 위의 기술들을 사용하여 문제를 해결하는 것은 제대로 알지 못하고 사용하는 상황이 될 것이라 생각했다. 그리고 현재 order-system에서는 성능 차이가 생각보다 크지 않았고, 현재 프로젝트에서는 재시도 정책을 충분히 설계하지 않은 상태였다. 따라서 구현 복잡도가 낮고 즉시 데이터 정합성을 보장하는 비관적 락을 선택했다.
Q&A
락(Lock)을 공부하면서 트랜잭션의 기본 개념에 대해서 궁금한 점이 있어 찾아보았다.
Q1. 트랜잭션은 격리성(Isolation)을 보장하는데, 왜
Read Uncommitted같이 커밋되지 않은 데이터를 읽는 것을 허용하는 격리 수준이 필요한 걸까? 트랜잭션을 사용하는 의미가 퇴색되는 것은 아닌가?
A. 데이터의 완벽한 정합성(Consistency)과 시스템의 처리 성능 사이의 트레이드오프 때문이다. 격리 수준을 높일수록 정합성은 완벽해지지만 대기 시간이 길어져 성능이 크게 저하된다. 정확성이 중요한 곳은 엄격한 격리 수준이 필요하지만, 대략적인 조회수나 통계 데이터를 보여주는 서비스에서는 약간의 오차가 발생하더라도 빠른 응답 속도와 시스템 안정이 훨씬 중요하기 때문에 Read Uncommitted 같은 낮은 격리 수준을 의도적으로 사용하기도 한다.
Q2. 그렇다면 정합성이 중요한 재고 수정 같은 경우엔 낙관적 락을 사용하는 환경에서
Read Uncommitted를 적용하면 어떻게 될까?
치명적인 더티 리드 (Dirty Read) 발생: Read Uncommitted는 다른 트랜잭션이 아직 커밋하지 않은(언제든 롤백될 수 있는) 가짜 데이터를 읽어오는 것을 허용한다. 예를 들어, 현재 재고가 0개인데 트랜잭션 A가 주문을 취소하며 재고를 1개로 늘렸지만 아직 커밋하지 않았다고 가정해 보자. 이때 트랜잭션 B가 조회하면 Read Uncommitted 상태이므로 재고가 1개인 것으로 읽힌다. 트랜잭션 B는 “재고가 있네”라고 판단하여 결제를 진행하지만, 만약 트랜잭션 A에서 에러가 발생해 롤백(다시 0개)해 버린다면 결과적으로 트랜잭션 B는 없는 재고를 팔아버린 꼴이 된다.
낙관적 락의 기본 전제 위배: 낙관적 락은 “내가 조회를 시작했을 때의 확정된(Committed) 버전이 업데이트 시점까지 변경되지 않았다면 수정을 반영하겠다”는 개념이다. 하지만 Read Uncommitted를 쓰게 되면, 애초에 조회를 시작할 때 커밋되지 않은 데이터를 기반으로 비즈니스 로직을 수행하게 된다. 잘못된 기반 데이터로 비즈니스 로직(예: 재고가 0보다 큰지 검증)을 수행하게 되므로 락 메커니즘이 의도대로 동작하지 않거나 오류가 발생한다.
6. 회고 및 배운 점
이전에는 맹목적으로 코드를 가져다 쓰기 바빴다. 처음엔 맹목적으로 강력한 비관적 락을 선택했지만, 서비스 특성을 고려해 낙관적 락으로 전환하고 예외 처리 로직을 조정하는 과정에서 백엔드 개발자로서 가장 중요한 점이 현재 서비스의 성격과 트래픽 규모에 맞는 기술이 무엇인가를 고민하고 사용하는 것이라고 생각했다. 또한 기술의 장단점은 절대적인 것이 아니라 서비스 특성과 트래픽 환경에 따라 달라질 수 있으며, 직접 테스트와 검증을 거쳐 선택해야 한다는 점을 배웠다.






