개발자는 기록이 답이다
쿠폰 발급에 대한 동시성 처리 (1) - synchronized, pessimistic Lock, optimistic Lock 본문
쿠폰 발급에 대한 동시성 처리 (1) - synchronized, pessimistic Lock, optimistic Lock
slow-walker 2024. 2. 29. 19:50
1. 데이터 모델링
쿠폰 발행 기능을 구현하기 위해 Coupon 테이블에 지금까지 발행된 쿠폰 개수(issued_quantity)를 반정규화하는 방식을 사용했습니다.
CREATE TABLE coupon
(
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '쿠폰 식별자',
event_id BIGINT UNSIGNED NOT NULL COMMENT '이벤트 식별자',
discount_type VARCHAR(40) NOT NULL COMMENT '정액, 정률 등',
discount_rate BIGINT UNSIGNED NULL COMMENT '정률 할인',
discount_price BIGINT UNSIGNED NULL COMMENT '정액 할인',
coupon_type VARCHAR(50) NOT NULL COMMENT '선착순 쿠폰, 회원가입 쿠폰 등..',
max_quantity BIGINT UNSIGNED NULL COMMENT '무제한 발행일 경우 NULL',
issued_quantity BIGINT UNSIGNED NULL COMMENT '무제한 발행일 경우 NULL',
validate_start_date DATETIME NOT NULL COMMENT '모든 쿠폰은 유효 시간이 있어야한다는 제약 사항 존재',
validate_end_date DATETIME NOT NULL COMMENT '모든 쿠폰은 유효 시간이 있어야한다는 제약 사항 존재',
created_at DATETIME NOT NULL COMMENT '데이터 생성일',
updated_at DATETIME NOT NULL COMMENT '데이터 변경일'
);
쿠폰 발급 히스토리 (coupon_issue) 테이블에서 count하는 방법으로도 '지금까지 발행된 쿠폰 개수'를 구할 수 있겠지만, 반정규화를 하는 것이 count쿼리를 날리는 것보다 성능이 더 좋을 것이라고 생각했기 때문입니다.
반정규화를 했을때의 문제점은 정합성이 떨어진다는 것입니다. 이와 관련해서 동시성 이슈를 해결하는 방법에 대해 알아보고자 합니다.
2. 선착순 쿠폰 이벤트 요구 사항
- 이벤트 기간내에 (ex 2024-02-20 ~ 2024-02-25 까지, 매일 오후 1시부터 오후 3시까지) 발급
- 선착순 이벤트는 기간 동안 하루에 유저 1명당 1개의 쿠폰만 발급 가능하다
- 선착순 쿠폰의 최대 쿠폰 발급 수량 설정
- 쿠폰의 수량은 이벤트 기간 동안 매일 갱신된다.
3. 쿠폰 발행에서 발생하는 부정합 문제
위의 CouponIssueService는 쿠폰 발행을 담당하는 비즈니스 로직입니다.
유저의 쿠폰 발급 요청이 들어오면 아래와 같은 순서대로 동작합니다.
- 요청한 일자가 이벤트 기간 범위 내에 있는지 확인
- 요청한 시간이 이벤트 기간 내에 쿠폰 발행하는 시간 범위 내에 있는지 확인
- 쿠폰 잔여 수량 확인 -> issued_quantity 증가
- 중복 발급 제한 -> 쿠폰 발급 히스토리 삽입
Postman으로 쿠폰 발급 API를 한번 요청했을때 제대로 발급 되는 것을 확인할 수 있습니다.
그러면 다중 요청이 들어왔을때 어떻게 부정합이 발생하는지 동시성 테스트를 진행해보겠습니다.
위에 작성한 테스트 코드는 100개의 스레드가 동시에 쿠폰 발급을 요청하는 것입니다.
예상대로라면 100개의 요청이 모두 완료된 후 쿠폰의 잔여 수량을 조회했을 때 기존 500개에서 100개 제외한 400개가 되어야 합니다.
하지만 예상과 다르게 10개만 발급되고 총 490개의 잔여수량이 남아있는 것을 확인할 수 있습니다.
반면에 SELECT COUNT(*) 쿼리로 확인해보면 쿠폰 히스토리는 100개가 삽입된 것을 알 수 있습니다.
쿠폰 발급은 100개가 되었다고 이력이 남았는데, 잔여 수량이 490개나 남아있다면 이건 비즈니스적으로도 큰 문제가 될 수 있습니다.
이러면 선착순 쿠폰 이벤트가 아니게 되겠죠.
먼저 이러한 동시성 이슈가 왜 발생하는지에 대해 이해가 필요합니다.
동시성 이슈가 발생하는 이유로 먼저 공유된 자원이라는게 전제되어 있어야 합니다.
멀티 스레딩 환경에서 해당 공유 자원에 대해서 2가지 이상의 액션을 여러 개의 스레드에서 할 때 발생하게 됩니다.
쿠폰의 발급 개수를 증가시키는 로직인 increaseIssuedCouponQuantity 메소드를 다시 한번 살펴보겠습니다.
1. 현재 DB에 저장되어있는 쿠폰을 불러온다
2. issued_quantity 를 한 개 증가 시킨다.
3. 증가시킨 쿠폰을 DB에 업데이트한다.
쿠폰 테이블 중 issued_quantity라는 공유 자원에 읽기과 쓰기 작업이 존재하는 상황입니다.
4. synchronized
synchronized 키워드를 사용하는 방법은 2가지가 있습니다.
1. 메소드 선언부에 사용
2. synchronized 블록 사용
synchronized를 사용하면 해당 부분이 임계 영역이 되서 Lock이 거리고 다른 스레드들이 접근하지 못하도록 막아줍니다.
synchronized방식이 동시성을 제어할때 추천하는 방법은 아닙니다. 왜냐하면 멀티 스레딩이라는 것 자체가 병렬적으로 실행되서 효율성이 높은 것인데, 락을 거는 순간 해당 부분에 하나의 스레드 밖에 접근하지 못하게 되서 느리기 때문입니다.
그렇다면 조금이라도 임계영역을 줄여줄 수 있도록 increaseIssuedCouponQuantity메소드 내부에 synchronized 블록을 적용해보겠습니다.
하지만 여전히 동시성 문제를 해결해주지 못하는 것을 발견할 수 있습니다. 왜 이런 문제가 발생하는 걸까요? 그건 바로 메서드 상단에 작성된 @Transactional 어노테이션 때문입니다.
@Transactional 어노테이션을 붙여주면 트랜잭션이 시작되고 난 후에 해당 메소드를 호출하면, 메소드 내부의 synchronized의 임계영역으로 락이 획득됩니다.
그래서 락을 반납하고 트랜잭션을 커밋하기 전에(증가된 issued_quantity가 DB에 반영되기 전에) 다른 트랜잭션 요청이 들어와서 반납된 락을 획득하고 다시 로직(쿠폰조회, 업데이트)를 시작하게 되므로 문제가 발생하는것입니다.
@Transactional
public void increaseIssuedCouponQuantity(long couponId) {
**트랜잭션 시작**
**락 획득**
Coupon existingCoupon = findCoupon(couponId);
Coupon updatecoupon = existingCoupon.increaseIssuedQuantity(existingCoupon);
couponRepository.increaseIssuedQuantity(updatecoupon);
**락 반납**
**트랜잭션 커밋**
}
}
이러한 문제는 트랜잭션과 락의 순서를 바꿔주면 해결됩니다.
**락 획득**
**트랜잭션 시작**
로직
**트랜잭션 커밋**
**락 반납**
순서를 바꿀때 주의할 점이 있습니다. increaseIssuedCouponQuantity메소드에 걸린 트랜잭션은 issueCoupon메소드에 있는 부모 트랜잭션에 포함되기 때문에 issueCoupon메소드 바깥으로 락을 걸어줘야 합니다. 따라서 컨트롤러에서 issueCoupon 메소드를 호출하기 전에 synchronized를 적용해보겠습니다.
이렇게 적용하면 테스트 코드에서 초록불이 들어오고, issued_quantity에 100개가 발행된 것을 알 수 있습니다.
100개의 스레드를 전부 처리하는데 소요된 시간은 2sec 203ms입니다.
하지만 synchronized의 경우에는 자바 애플리케이션에 종속이 되기 때문에 결국 여러 서버로 확장할 때 문제가 발생할 수 있습니다. 여러 서버 간에 락을 제대로 관리하는 것이 어렵기 때문입니다.
예를 들어, 서버1에서 13:00에 쿠폰 발급 요청이 들어와서 로직이 13:05분에 끝난다고 가정해보겠습니다. 그러면 서버2에서는 13시에서 13시 5분 사이에 갱신되지 않은 issued_quantity를 가져가서 새로운 값으로 갱신하게 됩니다. synchronized키워드는 하나의 프로세스 안에서만 보장이 되기 때문에 결국 여러 스레드에서 동시 접근을 할 수 있게 되서 경합이 발생하게 됩니다. 실제 운영중인 서비스는 대부분 2대 이상을 사용하기 때문에 synchronized는 거의 사용하지 않습니다.
또한 스레드 개수를 1000개로 설정했을때 테스트 소요시간은 13sec 840ms가 걸립니다. MySQL에서 발생하는 부하를 확인한 결과 CPU 점유율이 최대 28.79%까지 상승하는 것을 확인했습으며, 간단하게 Jmeter로 1000개의 요청을 보냈을때 TPS가 113.1/sec이 나오는것을 확인했습니다
5. Pessmistic Lock
비관적락은 실제로 데이터에 락을 걸어서 정합성을 맞추는 방법입니다. 비관적 락을 설정하면 다른 트랜잭션은 BLOCKING되고 커밋될 때 까지 대기하게 됩니다. 이로 인해 동시성을 제어해서 데이터의 무결성을 보장할 수 있습니다.
SELECT... FOR UPDATE 를 통해 사용하며, 비관적 락을 설정할때에는 TIMEOUT 설정을 꼭 해야 합니다. 설정하지 않으면 데이터베이스에 따라서 설정된 lock timeout 시간 동안 대기하게 됩니다.
timeout을 설정해야 하는 이유?
비관적 락은 동시성 이슈를 방지하기 위해 사용되지만, 2개 이상의 트랜잭션이 서로가 가진 락을 대기하면서 무한 대기 상태에 빠지며 데드락을 발생할 수 있습니다. 이처럼 데드락이 발생할 경우, timeout을 설정하면 트랜잭션을 롤백하고 다시 시도할 수 있습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="5000")})
Coupon findById(String id)
쿠폰을 조회하는 쿼리문에 pessimistic을 적용해보겠습니다.
그리고 다시 issueCoupon 메소드의 동시성 테스트를 진행하면 초록불이 들어오고 데이터 정합성이 맞는 것을 확인할 수 있습니다.
100개의 스레드를 전부 처리하는데 소요된 시간은 2sec 132ms입니다.
이어서 스레드 개수를 1000개로 설정하면 테스트 소요시간은 8sec 763ms입니다.
MySQL에서 발생하는 부하도 확인해보면 아래와 같습니다.
데이터베이스에 직접 Lock을 거는 방식이라 synchronized보다 CPU 점유율이 더 올라간 것을 볼 수 있습니다.
41.61%면 꽤나 높은 수치 같습니다. Jmeter에서는 초당 162.8개로 synchronized보다 낮은 처리량을 갖고 있는것을 확인했습니다.
정리하자면
SELECT... FOR UPDATE 쿼리는 해당 레코드의 X lock을 획득합니다.
X Lock = Exclusive Lock = 쓰기 락 = 배타적 락
비관적 락은 Exclusive Lock을 사용하는 것이기 때문에, 다른 트랜잭션에서는 락이 해제되기 전까지 데이터를 가져갈 수 없게 됩니다.
예를 들어, 서버 여러 대가 있을때 서버 1이 락을 걸고 데이터를 가져가게 되면, 서버 2~5는 서버 1이 락을 해제하기 전까지 데이터를 가져갈 수 없습니다. 데이터에는 락을 가진 스레드만 접근할 수 있기 때문에 정합성 문제를 해결할 수 있습니다.
하지만 요청이 BLOCKING 되어 서비스 성능이 저하될수 있고, 남용하게 되면 다양한 테이블에 BLOCKING 되어 데드락이 발생할 수 있습니다. 현재는 하나의 API만 사용했지만, 다른 API랑 같이 사용할때 데드락이 걸릴 수 있기 때문에 주의해서 사용해야 합니다.
- 트랜잭션 A가 테이블1의 1번 데이터에 lock을 획득
- 트랜잭션 B가 테이블2의 1번 데이터에 lock을 획득
- 트랜잭션 A가 테이블2의 1번 데이터에 lock 획득 시도(실패 - 대기)
- 트랜잭션 B가 테이블1의 1번 데이터에 lock 획득 시도(실패 - 대기)
6. Optimistic Lock
낙관적락은 실제로 데이터베이스의 락을 이용하는게 아니라 버전을 이용함으로써 정합성을 맞추는 방법입니다.
먼저 데이터를 읽은 후에 update를 수행할때 현재 읽은 버전에 맞는지 확인한 뒤 업데이트를 하는데,
읽은 버전이 변경되었을 경우 application에서다시 읽은 후에 작업을 수행해야 합니다.
JPA에서 설정하는 방법
@Entity객체에 javax.persistence @Version필드를 추가해서 버저닝을 할 수 있습니다.
@Lock(LockMode.OPTIMISTIC)
Coupon findById(String id)
저는 프로젝트에서 Mybatis를 사용했기 때문에 직접 구현해보겠습니다.
increaseIssuedCouponQuantity 대신 version필드를 가져오고 version까지 업데이트 시키는 로직으로 변경했습니다.
업데이트가 실패했을때 변경된 row(s)의 개수가 0개일 경우 Exception을 발생시키고, 트랜잭션이 롤백되서 다시 재시도하도록 만들어주기 위해 별도로 Facade클래스를 만들어주겠습니다.
OptimisticLockFacade 는 재시도를 위한 클래스이기 때문에 트랜잭션이 별도로 필요 없고,
만일 @Transactional 을 걸게 되면 계속해서 동일한 데이터를 가져오기 때문에 갱신된 데이터를 가져오도록 했습니다.
동시성테스트 로직에서는 OptimisticLockFacade 클래스의 issueCoupon메소드를 호출하도록 설정하고, 스레드 100개를 요청해보겠습니다.
하지만 테스트의 소요시간 4sec 486ms가 걸렸음에도 불구하고 69개의 쿠폰만 발급된 것을 확인할 수 있습니다.
여기서 Thread.sleep(1000)으로 코드를 수정하고, 쿠폰 발급 시도 중에 예외가 발생한 후 1초 동안 잠시 대기한 뒤 재시도를 수행하도록 변경해보겠습니다.
while (true) {
try {
couponIssueService.issueCoupon(currentDateTime, eventId, couponId, memberId);
break;
} catch (Exception e) {
// retry
Thread.sleep(1000);
} finally {
retryCount++;
if (retryCount > 10) {
throw new RuntimeException("쿠폰발급 실패");
}
}
}
낙관적락을 적용해도 계속 정합성이 맞지 않는 상황이 발생하고 있습니다.
스레드의 개수를 10개로 확 줄이고, Thread.sleep(50)으로 유지하고 다시 시도해보면 드디어 초록불이 뜨는 것을 확인할 수 있습니다.
이처럼 부정합이 왜 발생하는 지에 대해 고민해봤습니다. 우선 낙관적락은 충돌이 일어나지 않을 것을 가정해서 사용한다고 합니다.
JPA로는 테스트를 안해봐서 모르겠지만, 저는 이번 테스트를 통해 낙관적락이 과연 동시성을 제어할 수 있는가에 대한 의문점을 갖게 되었습니다.
이전의 다른 방법들과 동일하게 스레드 1000개를 요청해봤을때에는 500개가 전부 발급된 것을 확인했습니다.아마 1000개 중에 일부는 실패하고, 일부는 성공해서 500개 전부 다 발급된 상황이라고 예상되기도 하고, 이렇게 일부만 성공이 되는 상황이라면 사용자 이용 관점에서 차별한다고 느낄 수도 있는 상황입니다.
또한 예외가 발생했을 때 일정 시간 간격으로 재시도 로직을 수행한다는 점에서도 이전 방법들 보다 시간이 많이 소요됩니다. Optimictis Lock의 테스트 소요시간은 아래 사진 처럼 25sec 943ms이 걸렸는데, 이는 Pessimistic Lock보다 테스트 소요시간에 비해 3배 더 깁니다.
레퍼런스로 봤던 강의에서 낙관적 락은 비관적 락과 달리 데이터베이스에 별도의 락을 잡지 않으므로 성능상 이점이 있다고 언급했는데, 이 부분에 대해서도 의문이 들었습니다. '성능상 이점' 이라는 것이 무엇일까? 스레드 1000개를 돌렸을때, Mysql에 생기는 부하는 최대 73.26%까지였습니다. 재시도 로직으로 인해 I/O작업이 비관적 락보다 더 많이 발생하기 때문에 부하 더 높게 발생했다고 판단했습니다.
TPS도 초당 25.9개로 현저히 낮은 처리량을 보여주고 있습니다.
(성능 상 이점과 충돌이 기준이 어떤건지 궁금해서 질문을 했는데 추후에 답변이 달린다면 추가하겠습니다.)
결론적으로 전 충돌이 많은 저의 프로젝트 특성 상 Optimistic Lock은 동시성을 제어하는데 적절하지 않은 방식이라고 생각이 들었습니다.
낙관적 락의 장점
- 낙관적 락을 사용한다면, 아무것도 적용하지 않은 로직보다 정합성은 맞춰집니다.
- 낙관적 락을 사용한다면, 업데이트 실패 시 트랜잭션 롤백으로 인해 총 발급 수량 issued_quantity의 수량과 쿠폰 발급 history(coupon_issue)의 개수가 맞춰집니다.
낙관적 락의 단점
- 업데이트가 실패했을때 재시도 로직을 개발자가 직접 작성해야 한다는 번거로움이 존재합니다.
- 충돌이 빈번하게 일어날 때 꽤 많은 실패(부정합)를 만들어냅니다.
- 단순 업데이트문 만으로 동시성을 잡을 수 없습니다.
- JPA에서도 DB에 반영이 되는지 모르겠지만, Mybatis기준으로 version이라는 필드가 DB에 반영된다는게 맘에들지 않습니다..
다음 시간에는 MySQL의 네임드락과 Redis를 이용한 분산락을 이용해서 비교해보겠습니다.
참고 블로그
Select쿼리는 S락이 아니다 (X락과 S락의 차이)
인프런 강의 재고시스템으로 알아보는 동시성이슈 해결방법
'Spring > 트러블 슈팅' 카테고리의 다른 글
트러블 슈팅 - private 메소드를 테스트하려 했지만, 문제는 테스트 코드 로직이었다. (0) | 2024.03.13 |
---|---|
쿠폰 발급에 대한 동시성 처리 (2) - MySQL의 NamedLock, Redis의 분산락(Lettuce, Redisson) (0) | 2024.03.02 |
DATETIME vs TIMESTAMP 둘 중 어느것이 더 나을까? (0) | 2024.02.11 |
@RequestBody는 어떻게 바인딩 되는걸까? (with. 디버깅 과정) (0) | 2024.02.09 |
Request에 대한 Validation과 Exception 처리에 대한 고찰 (0) | 2024.02.09 |