💻문제점
현재 동시성 제어를 비관적 락을 통해 해결하고 있다.
기존 코드
public Mono<DonationDto> usePoint(DonationDto donationDto) {
return memberRepository.findByNickname(donationDto.getStreamer())
.switchIfEmpty(Mono.error(() -> new NoSuchElementException("존재하지 않는 사용자입니다.")))
.flatMap(streamer -> pointRepository.findByNickname(donationDto.getNickname())
.switchIfEmpty(Mono.error(() -> new NoSuchElementException("존재하지 않는 사용자입니다.")))
.flatMap(memberPoints -> {
if (memberPoints.getPoints() - donationDto.getPoints() < 0) {
return Mono.error(() -> new NoSuchElementException("포인트가 부족합니다."));
}
return databaseClient.sql("SELECT * FROM points WHERE user_id = :userId FOR UPDATE")
.bind("userId", streamer.getUserId())
.fetch()
.one()
.flatMap(row -> {
Points updatedStreamerPoints = new Points(
(Long) row.get("id"),
(String) row.get("user_id"),
(int) row.get("points")
);
updatedStreamerPoints.addPoints(donationDto.getPoints());
return pointRepository.save(memberPoints.usePoints(donationDto.getPoints()))
.flatMap(savedMemberPoints -> {
return pointRepository.save(updatedStreamerPoints)
.flatMap(res -> sendDonation(donationDto))
.thenReturn(donationDto);
});
});
}));
}
비관적 락은 동시에 다수의 사용자가 후원 요청을 하면 요청마다 트랜잭션이 발생한다. 이는 트랜잭션 관리 오버헤드를 증가 시키며, 트랜잭션 풀을 빠르게 소모하여 자원 부족으로 이어질 수 있다. 결과적으로, 새로운 요청에 대한 처리가 지연되어 전체 시스템 성능이 저하된다.
레디스는 메모리 기반의 데이터 저장소로 락을 획득하고 해제하는게 데이터베이스에 비해 빠르다. 레디스 분산 락을 사용하면 트랜잭션을 사용하지 않고 동시성을 관리할 수있어 DB 부하를 줄이고 자원을 효율적으로 사용할 수 있다.
이제 레디스 분산 락으로 변경해보자.
build.gradle에 redission 의존성을 추가해주었다.
implementation 'org.redisson:redisson:3.16.2'
분산 락을 사용하도록 변경해보았다.
스트리머 닉네임으로 고유한 RLock 객체를 생성한다.
try문 내부에서는 5초 동안 락을 획득하려고 시도하고, 락을 획득하면 후원 작업이 진행된다. 만약 락을 획득하지 못하면 IllgalArgumentException이 발생하며 락이 중단된다.
doFinally문을 통해 작업이 끝난 후 lock.unlock()를 호출하여 락을 해제한다.
public Mono<DonationResponseDto> usePoint(DonationDto donationDto) {
String lockKey = "POINT_LOCK_" + donationDto.getStreamer();
final RLock lock = redissonClient.getLock(lockKey);
try {
if (!lock.tryLock(5, 30, TimeUnit.SECONDS)) {
throw new IllegalArgumentException("락을 얻지 못했습니다.");
}
if (lock.isLocked()) {
log.info("lock 획득!!!!");
}
return memberRepository.findByNickname(donationDto.getStreamer())
.switchIfEmpty(Mono.error(() -> new NoSuchElementException("존재하지 않는 사용자입니다.")))
.flatMap(streamer -> pointRepository.findByNickname(donationDto.getNickname())
.switchIfEmpty(Mono.error(new NoSuchElementException("포인트가 부족합니다.")))
.flatMap(memberPoints -> {
if (donationDto.getStreamer().equals(donationDto.getNickname())) {
return Mono.error(() -> new IllegalArgumentException("스트리머는 자신의 방송에 후원할 수 없습니다. 다른 스트리머를 응원해보세요!"));
}
if (donationDto.getPoints() <= 0) {
return Mono.error(() -> new IllegalArgumentException("0원 이하는 후원할 수 없습니다."));
}
if (memberPoints.getPoints() - donationDto.getPoints() < 0) {
return Mono.error(() -> new NoSuchElementException("포인트가 부족합니다."));
}
return pointRepository.save(memberPoints.usePoints(donationDto.getPoints()))
.flatMap(savedMemberPoints -> pointRepository.findByUserId(streamer.getUserId())
.flatMap(streamerPoints -> {
Points updatedStreamerPoints = streamerPoints.addPoints(donationDto.getPoints());
return pointRepository.save(updatedStreamerPoints)
.thenReturn(DonationResponseDto.builder()
.type(donationDto.getType())
.nickname(donationDto.getNickname())
.points(donationDto.getPoints())
.remainPoints(savedMemberPoints.getPoints())
.message(donationDto.getMessage())
.build());
}));
}))
.doFinally(signalType -> {
if (lock.isLocked()) {
log.info("doFinally: lock 해제");
lock.unlock();
}
});
} catch (Exception e) {
throw new IllegalArgumentException(e);
} finally {
if (lock.isLocked()){
log.info("finally: lock 해제");
lock.unlock();
}
}
}
바로 RSocket을 적용해버리면 테스트가 쉽지 않아서(K6는 WebSocket만 지원하고, Jmeter는 RSocket 플러그인이 있으나 사용이 쉽지 않았다) POST요청으로 API를 만들어 테스트 하고 RSocket을 적용시켰다.
RSocket 적용 전, 분산 락을 사용한 코드이다.
스트리머 닉네임으로 고유한 RLock 객체를 생성한다.
try문 내부에서는 5초 동안 락을 획득하려고 시도하고, 락을 획득하면 후원 작업이 진행된다. 만약 락을 획득하지 못하면 IllgalArgumentException이 발생하며 락이 중단된다.
doFinally문을 통해 작업이 끝난 후 lock.unlock()를 호출하여 락을 해제한다.
public Mono<ResponseEntity<Integer>> usePoint(Member member, DonationDto donationDto) {
String lockKey = "POINT_LOCK_" + donationDto.getStreamer();
final RLock lock = redissonClient.getLock(lockKey);
try {
if (!lock.tryLock(5, 30, TimeUnit.SECONDS)) {
throw new IllegalArgumentException("락을 얻지 못했습니다.");
}
return memberRepository.findByNickname(donationDto.getStreamer())
.switchIfEmpty(Mono.error(() -> new NoSuchElementException("존재하지 않는 사용자입니다.")))
.flatMap(streamer -> pointRepository.findByUserId(member.getUserId())
.switchIfEmpty(Mono.error(() -> new NoSuchElementException("존재하지 않는 사용자입니다.")))
.flatMap(memberPoints -> {
if (donationDto.getStreamer().equals(donationDto.getNickname())) {
return Mono.error(() -> new IllegalArgumentException("스트리머는 자신의 방송에 후원할 수 없습니다. 다른 스트리머를 응원해보세요!"));
}
if (donationDto.getPoints() <= 0) {
return Mono.error(() -> new IllegalArgumentException("0원 이하는 후원할 수 없습니다."));
}
if (memberPoints.getPoints() - donationDto.getPoints() < 0) {
return Mono.error(() -> new NoSuchElementException("포인트가 부족합니다."));
}
return pointRepository.save(memberPoints.usePoints(donationDto.getPoints()))
.flatMap(savedMemberPoints -> pointRepository.findByUserId(streamer.getUserId())
.flatMap(streamerPoints -> {
Points updatedStreamerPoints = streamerPoints.addPoints(donationDto.getPoints());
return pointRepository.save(updatedStreamerPoints)
.map(savedStreamerPoints -> ResponseEntity.ok(savedMemberPoints.getPoints()));
}));
}))
.doFinally(signalType -> {
if (lock.isLocked()) {
// 락 해제
lock.unlock();
}
});
} catch (Exception e) {
e.printStackTrace();
return Mono.error(e);
}
}
위 코드를, K6를 통해 동시성 제어가 되는지 테스트 후 RSocket을 통해 후원이 가능하도록 변경했다.
public Mono<DonationResponseDto> usePoint(DonationDto donationDto) {
String lockKey = "POINT_LOCK_" + donationDto.getStreamer();
final RLock lock = redissonClient.getLock(lockKey);
try {
if (!lock.tryLock(5, 30, TimeUnit.SECONDS)) {
throw new IllegalArgumentException("락을 얻지 못했습니다.");
}
if (lock.isLocked()) {
log.info("lock 획득!!!!");
}
return memberRepository.findByNickname(donationDto.getStreamer())
.switchIfEmpty(Mono.error(() -> new NoSuchElementException("존재하지 않는 사용자입니다.")))
.flatMap(streamer -> pointRepository.findByNickname(donationDto.getNickname())
.switchIfEmpty(Mono.error(new NoSuchElementException("포인트가 부족합니다.")))
.flatMap(memberPoints -> {
if (donationDto.getStreamer().equals(donationDto.getNickname())) {
return Mono.error(() -> new IllegalArgumentException("스트리머는 자신의 방송에 후원할 수 없습니다. 다른 스트리머를 응원해보세요!"));
}
if (donationDto.getPoints() <= 0) {
return Mono.error(() -> new IllegalArgumentException("0원 이하는 후원할 수 없습니다."));
}
if (memberPoints.getPoints() - donationDto.getPoints() < 0) {
return Mono.error(() -> new NoSuchElementException("포인트가 부족합니다."));
}
return pointRepository.save(memberPoints.usePoints(donationDto.getPoints()))
.flatMap(savedMemberPoints -> pointRepository.findByUserId(streamer.getUserId())
.flatMap(streamerPoints -> {
Points updatedStreamerPoints = streamerPoints.addPoints(donationDto.getPoints());
return pointRepository.save(updatedStreamerPoints)
.thenReturn(DonationResponseDto.builder()
.type(donationDto.getType())
.nickname(donationDto.getNickname())
.points(donationDto.getPoints())
.remainPoints(savedMemberPoints.getPoints())
.message(donationDto.getMessage())
.build());
}));
}))
.doFinally(signalType -> {
if (lock.isLocked()) {
log.info("doFinally: lock 해제");
lock.unlock();
}
});
} catch (Exception e) {
throw new IllegalArgumentException(e);
} finally {
if (lock.isLocked()){
log.info("finally: lock 해제");
lock.unlock();
}
}
}
클라이언트에서 RSocket을 통해 후원을 요청하면 해당 메서드가 수행된다.
그랬더니 다음과 같은 에러가 발생
lock이 되어있지 않은데 unlock을 시도한다고 한다.
java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id: 52b55d9d-94e0-4c38-8d35-965f7d1a4984 thread-id: 74
🔍해결
lock.isHeldByCurrentThread() 를 호출해서 현재 스레드에 대해 lock을 가지고 있는지 확인하고 unlock하도록 했다.
if (lock.isLocked() && lock.isHeldByCurrentThread()){
lock.unlock();
}
'TIL' 카테고리의 다른 글
[커뮤 프로젝트] 영상 파일 삭제 로직 (동기, 비동기 처리) (0) | 2023.07.04 |
---|---|
[커뮤 프로젝트] 서버 아키텍처 변경을 통한 성능 개선 (0) | 2023.07.02 |
[TIL - 20230623] rsocket 채팅창에 후원 내역 띄우기 (0) | 2023.06.24 |
[TIL - 20230622] K6 성능 테스트 시나리오 작성 (0) | 2023.06.23 |
[TIL - 20230621] Webflux R2DBC 후원 동시성 문제 해결 (0) | 2023.06.22 |