동시성 이슈를 해결하기 위해 다음과 같은 방식으로 순차적으로 적용해보겠다.
- synchronized
- redis lock
- mysql lock
각 방식을 적용함으로써 장단점도 같이 알아보겠다.
동시성 이슈 해결 - synchronized
들어가기 앞서 synchronized
가 무엇인지 알아보자.
스레드 동기화란?
스레드 동기화는 멀티스레드 환경에서 여러 스레드가 하나의 공유자원에 동시에 접근하지 못하도록 막는것을 말한다.
공유데이터가 사용 되어 동기화가 필요한 부분을 임계영역(critical section)이라고 부르며, 자바에서는 이 임계영역에 synchronized
키워드를 사용하여 여러 스레드가 동시에 접근하는 것을 금지함으로써 동기화를 할 수 있다.
synchronized 사용 방법
1) 메소드에 synchronized 사용하기
int cnt = 0;
synchronized void increase() {
cnt++;
callMethod();
System.out.println(cnt);
}
이렇게 메서드 전체에 임계영역을 설정하면 효율이 떨어지므로, 꼭 필요한 부분에만 임계영역을 설정하는 것이 좋다.
2) 코드블록에 synchronized 사용하기
int cnt = 0;
void increase() {
synchronized(this) {
cnt++;
}
callMethod();
System.out.println(cnt);
}
****
synchronized(this)로 지정하게 되면 참조변수(this) 객체의 lock을 사용하게 됩니다.
이를 우리 코드에 적용해보겠다.
- CouponIssueService
// mycouponcore.service/CouponIssueService.java
package com.example.mycouponcore.service;
import com.example.mycouponcore.exception.CouponIssueException;
import com.example.mycouponcore.model.Coupon;
import com.example.mycouponcore.model.CouponIssue;
import com.example.mycouponcore.repository.mysql.CouponIssueJpaRepository;
import com.example.mycouponcore.repository.mysql.CouponJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import static com.example.mycouponcore.exception.ErrorCode.COUPON_NOT_EXIST;
import static com.example.mycouponcore.exception.ErrorCode.DUPLICATED_COUPON_ISSUE;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CouponIssueService {
private final CouponIssueJpaRepository couponIssueJpaRepository;
private final CouponJpaRepository couponJpaRepository;
@Transactional
public void issue(long couponId, long userId) {
// synchronized 키워드 사용
synchronized (this) {
Coupon coupon = findCoupon(couponId);
coupon.issue();
saveCouponIssue(couponId, userId);
}
}
...
}
Locust를 사용하여 결과를 한번 확인해보자.
첫번째 그래프의 최대 RPS가 1000 이 안되는 것을 볼 수 있다. 처리량이 이전(2000)과 비교하여 낮아진 것을 볼 수 있다.
부하를 주는 워커노드의 CPU 사용량도 그전보다 줄어든 것을 볼 수 있다.
마지막으로 mysql 에 부하도 감소한 것을 볼 수 있다.
RPS가 줄더라도 우리가 생각한데로 딱 500개의 쿠폰만 발행이 된다면 괜찮지 않을까?
하지만, 500개 보다 더 많은 쿠폰이 발행된 것을 볼 수 있다.
왜 우리가 생각한데로 500개만 발행된 것이 아닐까?
@Transactional
public void issue(long couponId, long userId) {
synchronized (this) {
Coupon coupon = findCoupon(couponId);
coupon.issue();
saveCouponIssue(couponId, userId);
}
}
이 메서드를 실행하는 워크플로우를 한번 살펴보면 다음과 같다.
User1의 요청과 User2의 요청이 거의 동시에 일어났다고 생각해보자.
- User1의 Transaction1이 실행된다.
- User2의 Transaction2가 실행된다.
- User1이 LOCK을 얻는다.
- User2가 LOCK을 얻기 위해 대기한다.
- User1이 findCoupon을 통해 issuedQuantity를 조회한다.
- User1이 쿠폰을 발행한다.(issuedQuantity의 갯수를 하나 증가, but 아직 db에 반영 X)
- User1이 LOCK을 반납한다.
- User2가 LOCK을 얻는다.
- User2가 findCoupon을 통해 issuedQuantity를 조회한다.
- User1의 Transaction1이 커밋된다.
- User2가 쿠폰을 발행한다.
- User2의 Transaction2이 커밋된다.
결국 문제는 아직 Transaction이 끝나지 않았는데 다른 User가 Lock을 얻어 데이터를 조회해서이다!!
따라서 이걸 해결하려면 간단하다. Transaction Lifecycle과 Lock Lifecycle간의 싱크를 맞춰주면 된다.
이부분을 다시 원복하고
- CouponIssueService
// mycouponcore/service/CouponIssueService.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CouponIssueService {
private final CouponIssueJpaRepository couponIssueJpaRepository;
private final CouponJpaRepository couponJpaRepository;
@Transactional
public void issue(long couponId, long userId) {
Coupon coupon = findCoupon(couponId);
coupon.issue();
saveCouponIssue(couponId, userId);
}
- CouponIssueRequestService
// mycouponapi/service/CouponIssueRequestService
package com.example.mycouponapi.service;
import com.example.mycouponapi.dto.CouponIssueRequestDto;
import com.example.mycouponcore.service.CouponIssueService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class CouponIssueRequestService {
private final CouponIssueService couponIssueService;
public void issueRequestV1(CouponIssueRequestDto requestDto) {
synchronized (this) {
couponIssueService.issue(requestDto.couponId(), requestDto.userId());
}
log.info("쿠폰 발급 완료. couponId: %s, userId: %s"
.formatted(requestDto.couponId(), requestDto.userId()));
}
}
이렇게 하면 LOCK을 먼저 얻고 Transaction을 시작하기 때문에 Trancation을 commit하고 LOCK을 반납한다.
그럼 이미 DB에 쿠폰 발급이 반영되고 다시 조회를 하기때문에 아까와 같은 이슈는 발생하지 않는다.
- Locust 테스트
최대 RPS가 500이 안되는 것을 볼 수 있다. 처리량이 이전(1000)과 비교하여 절반으로 줄어들었다.
결과적으로는 부하도 적고, 우리가 원하는 500개의 쿠폰만 발급되었지만, 성능이 너무 낮아졌다.
동시성 이슈 해결 - redis lock
안정성 외에도 또 큰 이슈가 있다. 바로 synchronized
자바에 종속적이기 때문에 서버가 scale out 되는 순간 lock을 우리 마음대로 제어할 수 없다는 점이다.
그래서 우리는 분산 Lock 구현이 필요하다.
분산 Lock을 구현한다면 다음과 같이 scale out 된 상태에도 Lock을 redis에서 획득하여 프로세스를 진행하기 때문에 우리가 원하는데로 제어할 수 있다.
분산 Lock 구현을 위해 우리는 Redisson 을 이용하여 구현하겠다.
물론, 우리가 Java에서 Redis 구현체로 알고 있는 Lettuce
를 사용해서 Lock을 구현할 수도 있다.
Lettuce
의 Lock은 setnx
메서드를 이용해 사용자가 직접 스핀락 형태로 구성하게 되어 있다.
만약 Lock 점유시도를 실패할 경우 계속 Lock 점유 시도를 하게되고, 이게 반복적으로 쌓이다 보면 레디스에 부하가 쌓여 응답시간이 지연되게 된다.
또한 만료시간을 제공하고 있지 않아서 Lock을 점유한 서버가 장애가 생기면 다른 서버들도 lock을 점유할 수 없는 상황이 생긴다.
Redisson 은 ttl을 설정할 수 있기 때문에 내가 원하는 시간만큼 점유시간을 설정할 수 있다.
그럼 설정과 구현을 해보자.
- build.gradle
// mycouponcore.build.gradle
bootJar { enabled = false }
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation "org.redisson:redisson-spring-boot-starter:3.16.4" //redis lock 구현을 위해 추가
}
tasks.named('test') {
useJUnitPlatform()
}
- DistributeLockExecutor
// mycouponcore/component/DistributeLockExecutor
package com.example.mycouponcore.component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import java.util.concurrent.TimeUnit;
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributeLockExecutor {
private final RedissonClient redissonClient;
public void execute(String lockName, long waitMillSecond, long leaseMillSecond, Runnable logic) {
RLock lock = redissonClient.getLock(lockName);
try {
boolean isLocked = lock.tryLock(waitMillSecond, leaseMillSecond, TimeUnit.MILLISECONDS);
if (!isLocked) {
throw new IllegalStateException("[" + lockName + "] lock 획득 실패");
}
logic.run();
} catch (InterruptedException e) {
log.error(e.getMessage(), e);
throw new RuntimeException(e);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
logic.run();
}
}
- CouponIssueRequestService
// mycouponapi/service/CouponIssueRequestService
package com.example.mycouponapi.service;
import com.example.mycouponapi.dto.CouponIssueRequestDto;
import com.example.mycouponcore.component.DistributeLockExecutor;
import com.example.mycouponcore.service.CouponIssueService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class CouponIssueRequestService {
private final CouponIssueService couponIssueService;
private final DistributeLockExecutor distributeLockExecutor;
public void issueRequestV1(CouponIssueRequestDto requestDto) {
distributeLockExecutor.execute("lock_" + requestDto.couponId(), 10000, 10000, () -> {
couponIssueService.issue(requestDto.couponId(), requestDto.userId());
});
// synchronized (this) {
// couponIssueService.issue(requestDto.couponId(), requestDto.userId());
// }
log.info("쿠폰 발급 완료. couponId: %s, userId: %s"
.formatted(requestDto.couponId(), requestDto.userId()));
}
}
DistributeLockExecutor
컴퍼넌트로 빼두면 언제든 우리가 분산락이 필요할 때 가져다 쓸 수 있다.
- Locust 테스트
- 최대 RPS : 200
동시성 이슈 해결 - mysql lock
mysql에 XLock 을 걸어서 동시성 이슈를 해결 할 수 있다.
이와 같은 상황에서 select 쿼리 뒤에 for update
를 붙여주면 XLock이 걸린다.
그럼 user1의 트랜잭션이 끝날때까지 user2는 대기를 하다가, user1의 트랜잭션이 끝나면 그때서야 select 쿼리를 통해 coupons 에 내용을 조회할 수 있다.
이것을 Exclusive(배타적) Lock 혹은 줄여서 XLock 이라고 한다.
그럼 한번 XLock을 구현해보자.
- CouponJpaRepository
// mycouponcore/repository/mysql/CouponJpaRepository
package com.example.mycouponcore.repository.mysql;
import com.example.mycouponcore.model.Coupon;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import java.util.Optional;
public interface CouponJpaRepository extends JpaRepository<Coupon, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Coupon c where c.id = :id")
Optional<Coupon> findCouponWithLock(long id);
}
- CouponIssueService
// mycouponcore/service/CouponIssueService
package com.example.mycouponcore.service;
import com.example.mycouponcore.exception.CouponIssueException;
import com.example.mycouponcore.model.Coupon;
import com.example.mycouponcore.model.CouponIssue;
import com.example.mycouponcore.repository.mysql.CouponIssueJpaRepository;
import com.example.mycouponcore.repository.mysql.CouponJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import static com.example.mycouponcore.exception.ErrorCode.COUPON_NOT_EXIST;
import static com.example.mycouponcore.exception.ErrorCode.DUPLICATED_COUPON_ISSUE;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CouponIssueService {
private final CouponIssueJpaRepository couponIssueJpaRepository;
private final CouponJpaRepository couponJpaRepository;
@Transactional
public void issue(long couponId, long userId) {
/** 기본 **/
// Coupon coupon = findCoupon(couponId);
// coupon.issue();
// saveCouponIssue(couponId, userId);
/** synchronized 키워드 사용 **/
// synchronized (this) {
// Coupon coupon = findCoupon(couponId);
// coupon.issue();
// saveCouponIssue(couponId, userId);
// }
/** mysql lock 사용 **/
Coupon coupon = findCouponWithLock(couponId);
coupon.issue();
saveCouponIssue(couponId, userId);
}
...
public Coupon findCouponWithLock(long couponId) {
return couponJpaRepository
.findCouponWithLock(couponId)
.orElseThrow(() -> {
throw new CouponIssueException(COUPON_NOT_EXIST, "쿠폰이 존재하지 않습니다. %s".formatted(couponId));
});
}
...
}
- CouponIssueRequestService
// mycouponapi/service/CouponIssueRequestService
package com.example.mycouponapi.service;
import com.example.mycouponapi.dto.CouponIssueRequestDto;
import com.example.mycouponcore.component.DistributeLockExecutor;
import com.example.mycouponcore.service.CouponIssueService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class CouponIssueRequestService {
private final CouponIssueService couponIssueService;
private final DistributeLockExecutor distributeLockExecutor;
public void issueRequestV1(CouponIssueRequestDto requestDto) {
// synchronized (this) {
// couponIssueService.issue(requestDto.couponId(), requestDto.userId());
// }
// distributeLockExecutor.execute("lock_" + requestDto.couponId(), 10000, 10000, () -> {
// couponIssueService.issue(requestDto.couponId(), requestDto.userId());
// });
couponIssueService.issue(requestDto.couponId(), requestDto.userId());
log.info("쿠폰 발급 완료. couponId: %s, userId: %s"
.formatted(requestDto.couponId(), requestDto.userId()));
}
}
- Locust 테스트
- 최대 RPS: 1000정도
아까보다 성능이 거의 5배정도 향상된 것을 볼 수 있다.
다만 mysql에 cpu 사용량이 64%까지 오른 것을 볼 수 있는데, mysql에 병목이 생긴다고 볼 수 있다.
결국 부하가 지속되면 처리량에 문제가 생길 수 있는 여지가 있다는 것이다.
이를 앞으로 어떻게 해결할지 생각해보자.
'토이프로젝트 > 선착순 이벤트 쿠폰 시스템' 카테고리의 다른 글
Mysql 기반 선착순 쿠폰 발급 기능 개발 (4) (0) | 2024.04.30 |
---|---|
Mysql 기반 선착순 쿠폰 발급 기능 개발 (3) (0) | 2024.04.18 |
Mysql 기반 선착순 쿠폰 발급 기능 개발 (2) (0) | 2024.04.16 |
Mysql 기반 선착순 쿠폰 발급 기능 개발 (1) (0) | 2024.04.08 |
2. 프로젝트 환경 설정 (0) | 2024.04.06 |