[COGO] 동시성 이슈-Redis 분산 락, Lettuce

hwee·2024년 7월 22일
0

COGO개발과정

목록 보기
10/12
post-thumbnail
post-custom-banner

세줄 요약

  1. 여러개의 스레드가 동시에 한 데이터에 접근할 때 동시성 이슈가 발생하였다.
  2. Redis로 간단한 분산 락을 구현하여 동시성 이슈를 해결하였다.
  3. Redis는 싱글 스레드로 동작하기 때문에, 동시성 이슈를 해결하기 좋다.

상황

멘토가 등록해놓은 시간(possibleDate 이하 date)에 멘티가 커피챗을 신청하면 커피챗(application 이하 app)이 생성되는 로직에서 동시성 이슈가 있다.
app이 성사되지 않은 상태의 date 즉, 신청 가능한 date는 active상태로, isActive 필드가 true인 상태로 DB에 존재하고, active한 date로만 app을 성사시킬 수 있으며, 성사되면 isActive는 false로 변경돼 검색할 수도 없다.
하지만 여러명의 멘티가 한 멘토의 date에 신청을 동시에 진행한다면 여러 트랜잭션이 동시에 isActive필드에 접근하므로 false로 바뀌기 전 모든 트랜잭션이 실행되어 App이 한 Date에서 여러개 생성될 수 있다.

즉, 위의 그림의 1번과 4번처럼 순서대로 트랜잭션이 처리된다면 문제가 없는 상황이지만,

이 그림처럼 멘티1과 멘티2가 동시에 Date1에 접근한다면, 동시성 문제가 생길 수 있다.
즉, 한 시간대에 2명의 멘티와 커피챗(App)이 성사되는 문제가 생기는 것이다.

동시성 이슈 테스트

2개의 스레드가 각자 다른 트랜잭션을 통해 한 Date로 App을 생성하는 테스트 코드를 작성하였다.
서비스 로직

//동시성 테스트용
	private static final Logger logger = LoggerFactory.getLogger(ApplicationService.class);
	private static final AtomicInteger transactionCounter = new AtomicInteger(0);  //트랜잭션마다 ID부여
	@Transactional
	public Application createApplicationIfPossible(Long possibleDateId, Mentor mentor, Mentee mentee) throws Exception {
		int transactionId = transactionCounter.incrementAndGet();  //트랜잭션 ID 1씩 증가하며 부여
		MDC.put("transactionId", String.valueOf(transactionId));  //로그에 트랜잭션ID 띄우기
		MDC.put("threadId", String.valueOf(Thread.currentThread().getId())); //로그에 스레드ID 띄우기

		try {
			logger.info("aaa트랜잭션 시작");

			PossibleDate possibleDate = em.find(PossibleDate.class, possibleDateId);

			if (possibleDate != null && possibleDate.isActive()) {  //Active상태면, Application생성
				possibleDate.setActive(false);  //중요! active상태를 false로 변경
				em.merge(possibleDate);

				Application application = Application.builder()
						.mentor(mentor)
						.mentee(mentee)
						.date(possibleDate.getDate())
						.startTime(possibleDate.getStartTime())
						.endTime(possibleDate.getEndTime())
						.accept(ApplicationStatus.UNMATCHED)
						.build();
				em.persist(application);

				logger.info("aaaApplication 생성: {}", application);
				return application;
			} else {
				logger.error("aaaAplication 생성 실패-Active하지 않음.");
				throw new Exception("The PossibleDate is already booked or does not exist.");
			}
		} catch (Exception e) {
			logger.error("aaaAplication 생성중 에러: ", e);
			throw e;
		} finally {
			logger.info("aaa트랜잭션 종료");
			MDC.clear();
		}
	}

로그에 트랜잭션과 스레드의 id를 찍게끔 하여 2개의 스레드가 각자 다른 트랜잭션을 수행하는지 체크하였고,
active가 true라면, 해당 date의 active를 false로 변경하고 app을 생성하는 로직이다.

테스트코드

@Test
    public void testConcurrency() throws InterruptedException {
        Long possibleDateId = 2001L; // 테스트할 PossibleDate ID
        Mentor mentor = mentorRepository.findById(1L).orElseThrow(); // 테스트할 Mentor
        Mentee mentee = menteeRepository.findById(1L).orElseThrow(); // 테스트할 Mentee

        // 사전 데이터 세팅
        setupTestData();

        Thread thread1 = new Thread(() -> {
            try {
                Application application = applicationService.createApplicationIfPossible(possibleDateId, mentor, mentee);
                System.out.println("Thread 1: " + application);
            } catch (Exception e) {
                System.out.println("Thread 1: " + e.getMessage());
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                Application application = applicationService.createApplicationIfPossible(possibleDateId, mentor, mentee);
                System.out.println("Thread 2: " + application);
            } catch (Exception e) {
                System.out.println("Thread 2: " + e.getMessage());
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();
    }

    @Transactional
    public void setupTestData() {
        PossibleDate possibleDate = PossibleDate.builder()
                .id(2001L)
                .mentor(Mentor.builder().id(1L).build()) // 가상의 Mentor 객체를 생성하여 설정
                .date(LocalDate.of(2024, 6, 25))
                .startTime(LocalTime.of(10, 0))
                .endTime(LocalTime.of(11, 0))
                .isActive(true)
                .build();
        possibleDateRepository.save(possibleDate);
    }

2001번 ID를 가진 date를 생성하고, 해당 date로 2개의 스레드에서 동시에 app을 생성하도록 테스트 코드를 작성하였다.

테스트 결과


먼저, 동시에 1번 스레드와 2번 스레드가 각자 다른 트랜잭션을 수행하기 시작한 것을 확인할 수 있다.

다음으로, 2개의 스레드가 각자 다른 application을 생성하였다.

최종 로그까지 살펴보면, 같은 시간(date)에 둘 다 active가 false로 변한 상태로 저장된 것을 볼 수 있다.
즉, 동시성 문제가 발생한다는 것을 확인하였다.

해결 방법

Redis로 분산 락을 구현할 것인데, Lettuce 방식을 사용할 것이다.
reference를 참고해 공부하였다.
수정된 서비스 로직을 살펴보면

@Transactional
	public ApplicationCreateResponse createApplication(ApplicationCreateRequest request, String userName) throws Exception {
		String lockKey = "lock:" + request.getMentorId() + ":" +request.getDate()+":"+ request.getStartTime();
		ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();

		boolean isLockAcquired = valueOperations.setIfAbsent(lockKey, "locked", 10, TimeUnit.SECONDS);
		if (!isLockAcquired) {
			throw new ResponseStatusException(HttpStatus.CONFLICT, "Lock을 획득하지 못하였습니다.");  //409반환
		}

		try {
			System.out.println("mentorid: " + request.getMentorId() + ", " + request.getDate() + ", " + request.getStartTime() + ", " + request.getEndTime());
			User findMentorUser = userRepository.findByMentorIdWithFetch(request.getMentorId());
			Mentor findMentor = findMentorUser.getMentor();
			User findMenteeUser = userRepository.findByUsername(userName);
			Mentee findMentee = findMenteeUser.getMentee();

			LocalTime startTime = request.getStartTime();
			LocalDate date = request.getDate();

			// possibleDate 불러오는 JPQL
			TypedQuery<PossibleDate> query = em.createQuery(
					"SELECT p FROM PossibleDate p JOIN p.mentor m WHERE m.id = :mentorId AND p.startTime = :startTime AND p.date = :date",
					PossibleDate.class);
			query.setParameter("mentorId", request.getMentorId());
			query.setParameter("startTime", startTime);
			query.setParameter("date", date);

			Optional<PossibleDate> possibleDateOpt = query.getResultList().stream().findFirst();

			if (possibleDateOpt.isPresent()) {
				PossibleDate possibleDate = possibleDateOpt.get();
				if (!possibleDate.isActive()) {
					throw new ResponseStatusException(HttpStatus.GONE, "이미 신청된 시간입니다.");  //410 반환
				}
				System.out.println("possibleDate.getId() = " + possibleDate.getId());
				possibleDate.setActive(false);
				possibleDateRepository.save(possibleDate);
			} else {
				throw new Exception("NOT FOUND");
			}

			Application savedApplication = applicationRepository.save(request.toEntity(findMentor, findMentee));

			ApplicationService proxy = applicationContext.getBean(ApplicationService.class);
			proxy.sendApplicationMatchedEmailAsync(findMenteeUser.getEmail(), findMentorUser.getName(),
					findMenteeUser.getName(), savedApplication.getDate(), savedApplication.getStartTime(),
					savedApplication.getEndTime());

			return ApplicationCreateResponse.from(savedApplication);
		} finally {
			redisTemplate.delete(lockKey);
		}
	}

1. Lock 획득 -> 획득 실패시 409반환
2. Date 획득 -> 획득 실패시(active가 false) 410반환
3. 전부 획득 성공시 App 생성 후 active를 false로 변경후 Lock을 반납(Redis에서 삭제)
로직이 실행된다.

그리고 반환되는 exception handler를 작성하였다.

@ControllerAdvice(assignableTypes = ApplicationController.class)
public class ApplicationExceptionHandler {

    @ExceptionHandler(ResponseStatusException.class)
    public ResponseEntity<String> handleResponseStatusException(ResponseStatusException ex) {
        return ResponseEntity.status(ex.getStatusCode()).body(ex.getReason());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<String> handleException(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("An error occurred");
    }
}

이제 테스트 코드로 아까와 같이 2개의 스레드가 동시에 각자의 트랜잭션으로 위의 변경된 서비스 로직으로 app을 생성하도록 테스트해보았다.
새롭게 작성한 테스트 코드는 생략하고 결과만 보면,

1번 스레드

2번 스레드

이처럼 동시에 스레드를 진행해도 Lock을 획득하지 못하고 409 에러를 반환하며,

Lock을 획득해도, 이미 신청 트랜잭션이 실행되어 Active하지 않은 Date에 신청을 한 상황이면, 410에러가 발생한다.

추가적으로, Redis에 Lock이 잘 저장되는지 확인하기 위하여 트랜잭션이 종료될 때 Lock을 반납하는 로직을 잠시 제거한 후 테스트 해보았다.

잘 저장되고 있다.
해당 로직의 응답시간은 매우 짧은 관계로 TTL도 10초정도만 설정해 두었다.

결과

Lettuce는 Spin Lock 방식으로 구현되어 계속 락을 획득하려고 시도하는 문제가 존재하지만, 애초에 현 로직에는 Lock이 존재한다면 바로 에러를 반환하는 상황이기 때문에 별 문제가 없었다.
추후 조금 더 복잡한 Lock을 사용할 기회가 생긴다면 Redisson방식으로 구현해보고 싶다.

profile
https://fuzzy-hose-356.notion.site/1ee34212ee2d42bdbb3c4a258a672612
post-custom-banner

0개의 댓글