아래의 게시글에서 마지막 부분에 분산락을 처리하기 위하여 @Transactional에 propagation 속성을 REQUIRES_NEW로 설정했었습니다.
하지만 위의 방식은 커넥션을 추가로 필요로 하고 이 때문에 많은 요청이 한 번에 오면 커넥션을 얻지 못하여 데드락이 발생하는 상황도 발생했습니다.
위의 상황을 막기 위해서 커넥션 풀을 분리하면 다른 메서드들이 동작할 때 동시에 오는 요청을 그만큼 처리하지 못할 것이라 생각하여 낙관적 락으로 데드락을 처리하였습니다.
하지만 정말로 이 방식이 최선인가라는 의구심이 들었습니다.
낙관적 락은 한 번에 많은 충돌이 생기면 이를 복구하기 위한 작업이 많이 들어가 좋지 않아 분산락을 많이 사용하는데 아무리 단일 DB, 단일 서버라도 분산락을 적용해 보는 것이 낫지 않나라는 생각이 들어 인프라를 추가하지 않고 MySQL의 분산락을 사용할 수 있는 방법을 한번 더 생각해 보았습니다.
위의 게시글에서 REQUIRES_NEW를 사용한 이유는 트랜잭션이 커밋하기 전에 Lock을 release 하면 트랜잭션이 커밋하기 전의 시점과 Lock이 release 한 이후 시점에서 다른 쓰레드가 해당 Lock을 얻어 가면 DB에서 변경 이전의 row를 읽어 lost update가 발생할 수 있다는 문제가 있었기 때문입니다.
이에 대한 그림은 아래와 같습니다.
따라서 저는 아래와 같은 상황을 만들어 주었습니다.
하지만 이 방식은 커넥션이 더 필요했는데 이 그림을 보고 고민하던 중 그러면 원래 트랜잭션에서 트랜잭션이 시작하기 전에 Lock을 얻게 하고 트랜잭션이 끝나면 Lock을 해제하면 되지 않나?라는 생각을 하게 되었습니다.
Spring AOP를 사용하여 스프링 빈으로 @Transactional이 붙은 빈을 등록하기 전에 빈 후처리기에서 Lock을 걸고 회수하는 Aspect를 추가하면 핵심 관심사와 횡당 관심사를 분리하는 효과까지 얻을 수 있을 것이라 기대하였습니다.
따라서 저는 Annotation을 만들어서 분산락 적용이 필요한 메서드들에 AOP를 적용해 보는 것을 시도하기로 하였습니다.
AOP 적용
우선 저는 AOP를 적용하기 위해서 annotation을 만들었습니다.
분산락을 적용할 포인트가 특정 클래스에 모여 있지 않아 annotation을 이용하여 필요한 메서드별로 이를 적용해 주는 게 효율적이라 생각하였기 때문입니다.
@Target(value = ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LockAndUnlock {
String lockName();
}
위의 annotation을 만든 후 이 annotation이 붙어 있는 메서드들에 적용할 advisor도 생성해 주었습니다.
package soongsil.kidbean.server.quizsolve.util;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.AfterThrowing;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import soongsil.kidbean.server.member.repository.MemberRepository;
@Order(1)
@Aspect
@Slf4j
@RequiredArgsConstructor
@Component
public class LockUnLockAspect {
private final MemberRepository memberRepository;
// Lock 관리 로직
private void acquireLock(String lockName, Long memberId) {
String lockKey = lockName + memberId;
memberRepository.getLock(lockKey);
}
private void releaseLock(String lockName, Long memberId) {
String lockKey = lockName + memberId;
memberRepository.releaseLock(lockKey);
}
@Before("@annotation(soongsil.kidbean.server.quizsolve.util.LockAndUnlock)")
public void beforeTransaction(JoinPoint joinPoint) {
LockAndUnlock lockAndUnlock = getLockAndUnlockAnnotation(joinPoint);
if (lockAndUnlock != null) {
acquireLock(lockAndUnlock.lockName(), getMemberIdFromArgs(joinPoint));
}
}
@AfterReturning("@annotation(soongsil.kidbean.server.quizsolve.util.LockAndUnlock)")
public void afterSuccessfulTransaction(JoinPoint joinPoint) {
LockAndUnlock lockAndUnlock = getLockAndUnlockAnnotation(joinPoint);
if (lockAndUnlock != null) {
releaseLock(lockAndUnlock.lockName(), getMemberIdFromArgs(joinPoint));
}
}
@AfterThrowing("@annotation(soongsil.kidbean.server.quizsolve.util.LockAndUnlock)")
public void afterFailedTransaction(JoinPoint joinPoint) {
LockAndUnlock lockAndUnlock = getLockAndUnlockAnnotation(joinPoint);
if (lockAndUnlock != null) {
releaseLock(lockAndUnlock.lockName(), getMemberIdFromArgs(joinPoint));
}
}
private Long getMemberIdFromArgs(JoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
String[] parameterNames = methodSignature.getParameterNames();
for (int i = 0; i < parameterNames.length; i++) {
if ("memberId".equals(parameterNames[i])) {
return (Long) args[i];
}
}
throw new IllegalArgumentException("memberId 파라미터를 찾을 수 없습니다.");
}
private LockAndUnlock getLockAndUnlockAnnotation(JoinPoint joinPoint) {
return ((MethodSignature) joinPoint.getSignature()).getMethod().getAnnotation(LockAndUnlock.class);
}
}
저는 @Aspect를 이용하여 advisor라는 것을 명시하고 @Component를 이용하여 ComponentScan의 대상이 되어 스프링 빈을 등록할 때 빈 후처리기가 이를 적용할 수 있게 해 주었습니다.
@Before, @AfterReturning, @AfterThrowing을 이용하여 JoinPoint 메서드가 실행되기 전, return 후, exception이 발생했을 때 각각 락을 얻고, 락을 release 하는 로직을 넣었습니다.
위와 같이 상황을 분리한 이유는 MySQL의 Named Lock은 트랜잭션이 종료되더라도 Lock을 스스로 해제하지 않기 때문에 이에 대한 처리가 필요하였기 때문입니다.
또한 getMemberIdFromAgrs 메서드를 이용해서 메서드의 parameter로 전달된 memberId를 추출해 와서 Lock을 얻을 때 이를 이용하였습니다.
이제부터 변경된 코드를 보여드리도록 하겠습니다.
ImageQuizController
@Operation(summary = "ImageQuiz 문제 풀기", description = "푼 ImageQuiz 문제를 제출")
@PostMapping("/solve")
public ResponseEntity<ResponseTemplate<Object>> solveImageQuizzes(
@AuthenticationPrincipal AuthUser user,
@Valid @RequestBody QuizSolvedListRequest request) {
ImageQuizSolveScoreResponse score = imageQuizServiceFacade.solveImageQuizzes(request, user.memberId());
return ResponseEntity
.status(HttpStatus.OK)
.body(ResponseTemplate.from(score));
}
ImageQuizController에서 Facade 객체를 사용한 이유는 critical section을 최대한 줄여서 동시성을 최대한 높이기 위해서입니다.
ImageQuizServiceFacade
@Slf4j
@RequiredArgsConstructor
@Component
public class ImageQuizServiceFacade {
private final ImageQuizService imageQuizService;
private final MemberScoreUpdateStrategy memberScoreUpdateStrategy;
private final EntityManager em;
public ImageQuizSolveScoreResponse solveImageQuizzes(QuizSolvedListRequest request, Long memberId) {
ImageQuizSolveScoreResponse score =
imageQuizService.solveImageQuizzes(request.quizSolvedRequestList(), memberId);
if (score.score() != 0) {
em.clear();
memberScoreUpdateStrategy.updateUserScore(memberId, score.score());
}
return score;
}
}
위 코드에서는 ImageQuizService를 호출하여 문제를 푸는 부분, member의 점수를 높이는 부분으로 나누었습니다.
ImageQuizService
@LockAndUnlock(lockName = "MEMBER_LOCK")
@Transactional
public ImageQuizSolveScoreResponse solveImageQuizzes(
List<QuizSolvedRequest> quizSolvedRequestList, Long memberId
) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND));
return ImageQuizSolveScoreResponse.scoreFrom(
quizSolvedService.solveQuizzes(quizSolvedRequestList, member, IMAGE_QUIZ));
}
이제 제작했던 annotation을 사용합니다.
트랜잭션이 시작하기 전에 락을 얻고, 해당 메서드가 끝나거나 exception이 발생하면 락을 반납하게 만들었습니다.
위와 같이 lock을 건 이유는 내부에서 호출되는 함수중 하나가 데드락이 발생할 수 있기 때문입니다. 데드락이 발생할 수 있는 부분은 아래와 같습니다.
@Service
@RequiredArgsConstructor
public class ImageQuizScorer implements QuizScorer {
private final QuizScoreRepository quizScoreRepository;
@Transactional
@Override
public Long addPerQuizScore(SolvedQuizInfo solvedQuizInfo, Member member) {
QuizScore quizScore = quizScoreRepository.findByMemberAndQuizCategory(member, solvedQuizInfo.category())
.orElseThrow(() -> new IllegalArgumentException("해당 멤버의 퀴즈 카테고리가 존재하지 않습니다."));
quizScore.addScore(solvedQuizInfo.score());
return solvedQuizInfo.score();
}
}
QuizScore 객체를 가져와서 해당 객체의 점수를 높이기 때문에 데드락이 발생할 수 있습니다.
이제 다시 ImageQuizServiceFacade에서 member의 점수를 수정하는 부분으로 이동하겠습니다.
이때 아래의 코드가 이상하다고 생각하실 수도 있습니다.
if (score.score() != 0) {
em.clear();
memberScoreUpdateStrategy.updateUserScore(memberId, score.score());
}
위와 같이 em.clear를 호출해 준 이유는 ImageQuizService에서 퀴즈를 풀 때 Member 객체를 이미 DB에서 가져와 영속성 컨텍스트에서 관리하고 있었기 때문입니다.
이때 영속성 컨텍스트는 트랜잭션과 생명 주기를 같이 하지 않나??라는 의문을 가지실 수 있습니다.
위와 같은 문제가 발생한 이유는 OSIV 옵션을 끄지 않았기 때문입니다.
OSIV란 아래와 같이 영속성 컨텍스트가 해당 요청에서 계속 살아서 동작하게 해주는 옵션입니다.
이는 Controller 단에서 엔티티 그래프 등을 사용할 때 편리한 옵션입니다.
하지만 OSIV 옵션을 끄지 않는다면 지금과 같이 트랜잭션이 끝났는데도 영속성 컨텍스트가 초기화되지 않고, 이전의 상태를 기억하는 문제가 발생합니다.
실제로 em.clear()를 호출하지 않으면 기존에 가지고 있던 member 객체를 그대로 사용하기 때문에 lost update가 발생합니다. 위 문제가 발생하는 화면은 아래와 같습니다.
이러한 문제 때문에 em.clear()를 해도 되지만 저희 프로젝트에서는 Controller 단에서 엔티티 그래프를 사용하지 않았고 위와 같은 문제가 계속하여 발생할 수 있으니 OSIV를 끄도록 하겠습니다.
build.gradle에서 spring.jpa.open-in-view=false로 하면 이 옵션을 끌 수 있고, 이렇게 하면 em.clear()가 없더라도 lost update가 발생하지 않습니다.
마지막으로 아래와 같이 또 데드락이 발생할 수 있는 부분에 기존에 만든 annotation을 달아서 Lock을 획득, 제거하여 주었습니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class MemberScoreUpdateStrategy {
private final MemberRepository memberRepository;
@LockAndUnlock(lockName = "MEMBER_LOCK")
@Transactional
public void updateUserScore(Long memberId, Long score) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND));
log.info("Member {}'s score is updated. Current score: {}, Added score: {}", member.getMemberId(),
member.getScore(), score);
member.updateScore(member.getScore() + score);
}
}
이제 테스트 실행 결과를 보도록 하겠습니다. 테스트 코드는 이전 게시글과 같은 코드를 사용하였습니다.
2024-07-28T15:54:02.652+09:00 INFO 14028 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : ===================결과 출력부===================
2024-07-28T15:54:02.750+09:00 INFO 14028 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : 데이터 수: 200 개
2024-07-28T15:54:02.750+09:00 INFO 14028 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : 반복 횟수: 100 회
2024-07-28T15:54:02.750+09:00 INFO 14028 --- [ Test worker] s.k.s.tuning.ImageQuizConcurrentTest : 총 소요 시간: 3500 ms
실행 시간은 3500ms로 낙관적 락이 3230ms인 데에 비해서 큰 차이가 없습니다.
위의 사진들을 보시면 200개의 문제를 풀었는데 맞은 문제가 100개에 각각 1점의 점수를 정상적으로 얻으신 걸 확인하실 수 있습니다.
하지만 낙관적 락은 데드락을 회복하기 위한 로직을 핵심 관심사에 추가하여야 하였습니다.
저는 성능에서 큰 차이가 나지 않는다면 핵심 관심사와 횡단 관심사를 분리하는 것이 유지보수를 쉽게 할 수 있다고 생각하였습니다. 또한 다른 부분에 lock을 걸어야 한다면 annotation의 lockName을 다른 것으로 지정하면 쉽게 데드락 처리가 가능할 것이라 생각하여 분산락(named lock)을 적용하였습니다.
결과적으로 추가적으로 커넥션이 필요하지 않아 데드락에서 안전한 코드를 완성할 수 있었습니다.
이렇게 인프라에 변화를 주지 않고 다양한 방식을 시도하여 데드락을 처리해 보면서 주어진 환경에서 최대한의 효과를 내기 위한 고민을 해보는 경험을 할 수 있었습니다.
'CS > 스프링' 카테고리의 다른 글
Offset을 사용하지 않는 무한 페이징 기능 구현 (1) | 2024.09.30 |
---|---|
synchnonized, 비관적 락, 낙관적 락, 분산락(named lock)을 사용한 데드락 처리 (0) | 2024.07.26 |
QueryDSL을 활용한 동적 쿼리 처리 (0) | 2024.07.17 |
join 제거, dto projection, covering index를 활용한 성능 최적화 (0) | 2024.07.04 |
spring 프로젝트에서 더미 데이터 사용 & 테스트용 fixture 사용하기 (0) | 2024.06.16 |
댓글