🌀
f1v3-log
  • Welcome
  • 개발
    • SecurityContext를 새로 만들어야 할까?
    • OAuth2AuthorizationRequestResolver 커스터마이징
    • 동시성 문제를 해결해보자
    • MySQL은 어떻게 ID 값을 순차적으로 넣어주는 것일까? (Feat. Auto Increment Lock)
    • 외부 API 호출에 대한 고찰
      • HTTP Clients in Spring Boot
      • I/O와 트랜잭션 분리하기
      • 처리율 제한 장치 (Rate Limiter) 도입
      • 외부 API 의존성을 줄여보자
      • 캐시 레이어를 구성해보자 (Local Cache)
    • JPA Deep Dive
      • 결제 및 정산 시스템 기능 요구사항 분석
      • 글로벌 서비스를 고려할 때, 타임존 이슈를 어떻게 처리해야 할까?
      • Spring Data JPA - ID 생성 전략과 채번은 어떻게 되는걸까?
  • 회고
    • NHN Academy 인증과정 회고
    • DND 11기 회고
  • 독서
    • Effective Java 3/E
      • Item 1. 생성자 대신 정적 팩터리 메서드를 고려하라
      • Item 2. 생성자에 매개변수가 많다면 빌더를 고려하라
      • Item 3. private 생성자나 열거 타입으로 싱글턴임을 보증하라
    • 객체지향의 사실과 오해
      • 1장. 협력하는 객체들의 공동체
      • 2장. 이상한 나라의 객체
      • 3장. 타입과 추상화
      • 4장. 역할, 책임, 협력
      • 5장. 책임과 메시지
      • 6장. 객체 지도
      • 7장. 함께 모으기
  • Real MySQL 8.0
    • 04. 아키텍처
    • 05. 트랜잭션과 잠금
    • 08. 인덱스
    • 09. 옵티마이저와 힌트
  • 생각정리
    • 기술에 매몰되지 말자.
  • 공부
    • 객체지향 5원칙(SOLID)
      • SRP (Single Responsibility Principle)
      • OCP (Open Closed Principle)
Powered by GitBook
On this page
  • 문제 원인
  • 일정이 공유자원이 된다.
  • Race Condition 테스트
  • 해결 방안
  • 1. MySQL - Named Lock
  • 2. Redis (Lettuce)

Was this helpful?

  1. 개발

동시성 문제를 해결해보자

PreviousOAuth2AuthorizationRequestResolver 커스터마이징NextMySQL은 어떻게 ID 값을 순차적으로 넣어주는 것일까? (Feat. Auto Increment Lock)

Last updated 6 months ago

Was this helpful?

째깍 프로젝트를 진행하면서 발생했던 문제점에 대해 의논하고 해결한 경험을 작성합니다.


문제 원인

째깍 프로젝트에서 다음과 같은 문제점이 존재했다.

  1. 모임을 생성할 때 미리 모임의 인원 수 만큼 일정을 만든다.

  2. 사용자(회원/비회원)가 일정을 할당할 때 미리 만들어진 일정을 업데이트 하는 방식을 사용한다.

ScheduleService.java

Schedule schedule = scheduleRepository.findNotAssignedScheduleByMeetingUuid(meetingUuid)
        .orElseThrow(ScheduleNotFoundException::new);

위의 코드에서 발생할 수 있는 문제는 무엇이 있을까?

일정이 공유자원이 된다.

일정(Schedule)에 여러 스레드가 동시에 접근하여 조회를 하게 될 경우를 생각해보자.

이러한 상황에서 동시성 문제가 발생하여, 각각 다른 스레드가 하나의 일정을 가져오고, 서로의 정보로 갱신하여 Lost Update 현상이 발생하게 된다.

위와 같이 Race Condition이 발생하여 예상했던 흐름대로 진행이 되지 않는다.

정말 문제가 발생하는지 한 번 테스트를 해보자.

Race Condition 테스트

1. 모임 생성

동시성 테스트용 모임을 1개 생성한다. 해당 모임에서는 5명의 인원만 일정을 할당할 수 있다.

2. 사용자의 일정 할당

동시에 100명의 사용자가 일정을 한다고 가정을 해보고 JMeter를 활용하여 테스트를 진행해봤다.

정상적으로 원했던 방식과는 다른 오류가 발생했다.

  • 5명의 인원까지만 할당되고 나머지 인원은 할당이 되지 않아야 함.

  • 또한, 요청을 보낸 순서대로 처리가 되지 않음.

  • 테스트 결과 5명의 인원만 할당 되어야 하지만, 10개의 요청에 응답을 해버림.

위에서 예상했던대로 동시성 문제로 인해서 문제가 발생하는 것을 확인했다. 그러면 어떻게 해결할 수 있을까?

해결 방안

여러 방안들이 있었지만, 두 가지의 해결법을 찾아냈었다. MySQL의 Named Lock 기법과 Redis를 활용하여 분산 락을 사용하는 방법이었다.

1. MySQL - Named Lock

네임드 락은 말 그대로 이름을 통해서 락을 거는 기법을 말한다.

  • SELECT GET_LOCK('lock_name', timeout) 을 통해 락 획득

  • SELECT RELEASE_LOCK('lock_name')을 통해 락 해제

네임드 락을 실무에서 사용할 경우에는, DataSource를 분리하는 것을 추천한다고 한다.비즈니스 로직과 동일한 DataSource를 사용하게 될 경우 커넥션 풀이 부족하여 서비스에 영향이 미칠 수 있다니 조심하도록 하자.

간단하게 네임드 락을 사용할 때의 흐름을 생각해보면,

  1. 실제 비즈니스 로직(Service)을 수행하기 전, 락을 획득한다.

  2. 정상적으로 락을 획득했을 경우, 비즈니스 로직을 수행한다.

  3. 마지막으로 락을 해제한다.

그렇다면, 우리 서비스에서는 어떻게 적용을 해야할까? 간단하게 예시로 살펴보자.

NamedLockRepository.java

public interface NamedLockRepository extends JpaRepository<Stock, Long> {

    @Query(value = "select get_lock(:key, 1000)", nativeQuery = true)
    void getLock(String key);

    @Query(value = "select release_lock(:key)", nativeQuery = true)
    void releaseLock(String key);
}
  • getLock: Lock 획득을 위한 메서드

  • releaseLock: Lock 해제를 위한 메서드

ScheduleFacade.java

@Component
@RequiredArgsConstructor
public class ScheduleFacade {
    
    private final NamedLockRepository namedLockRepository;
    private final ScheduleService scheduleService;
    
    public void lockAndAssignSchedule(String meetingUuid, ScheduleAssignRequestDto request) {
        String key = "meeting-" + meetingUuid;
        try {
            namedLockRepository.getLock(key);
            scheduleService.assign(meetingUuid, request);
        } finally {
            namedLockRepository.releseLock(key);
        }
    }
}

위와 같은 방식으로 간단하게 구현할 수 있으며, 분산 락(Distributed Lock)을 구현할 때 사용한다. 하지만, 다음과 같은 유의점이 있다.

  • 자동으로 락이 해제되지 않아 철저한 관리가 필요

  • 커넥션 풀을 사용하기 때문에 DB 부하가 늘어날 수 있음.

  • 락과 비즈니스 로직의 트랜잭션 분리가 필요

이러한 문제점을 파악하고, 현재 프로젝트에 레디스가 도입이 되어있는 상황이었기 때문에 레디스를 사용하여 분산 락을 구현하면 어떨까? 라는 생각이 들었다.

물론, 레디스를 사용하지 않는 상황이었더라면 인프라 구축을 해야한다는 점을 고려하여 진행하지 않았을 것 같다

2. Redis (Lettuce)

위에서 진행했던 방식과 레디스 락도 크게 다른 점은 없다.

  • 기존: Controller -> Service

  • 변경: Controller -> Facade (Redis Lock) -> Service

레디스의 명령어 중 SETNX 를 통해 다음과 같은 시나리오를 생각할 수 있다.

  1. 키가 존재하지 않는 경우에만 락을 획득

  2. 이미 다른 스레드가 락을 획득한 경우에는 대기

  3. 작업이 끝난 스레드는 DEL 명령어를 통해 락 해제

참고로, SETNX 명령어는 Deprecated 되었으며, SET의 NX 옵션으로 사용해야 한다.

Spring Data Redis의 RedisTemplate에서는 setIfAbsent() 메서드로 추상화하여 제공한다.

@Override
public Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit) {
    byte[] rawKey = rawKey(key);
    byte[] rawValue = rawValue(value);
    Expiration expiration = Expiration.from(timeout, unit);
    return execute(connection -> connection.set(rawKey, rawValue, expiration, SetOption.ifAbsent()), true);
}

위의 내용을 바탕으로 RedisRepository를 정의해보자.

RedisRepository.java

@Repository
@RequiredArgsConstructor
public class RedisRepository {

    private final RedisTemplate<String, String> redisTemplate;

    public String findByKey(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    public void save(String key, String value, Duration expiration) {
        redisTemplate.opsForValue()
            .set(key, value, expiration);
    }

    public void delete(String key) {
        redisTemplate.delete(key);
    }

    public Boolean lock(String key) {
        return redisTemplate.opsForValue()
                .setIfAbsent(key, "lock", Duration.ofSeconds(5));
    }
}

ScheduleFacade.java

@Component
@RequiredArgsConstructor
public class ScheduleFacade {    

    private final ScheduleService scheduleService;
    private final RedisRepository redisRepository;

    public ScheduleAssignResponseDto assignScheduleToGuest(String meetingUuid, ScheduleAssignRequestDto requestDto) {
        String key = "meeting-" + meetingUuid;
        while (!redisRepository.lock(key)) {
            log.info("락 획득 실패");
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }

        try {
            return scheduleService.assignScheduleToGuest(meetingUuid, requestDto);
        } finally {
            redisRepository.delete(key);
        }
    }
}

코드를 보면, while 문을 통해 락을 얻어올 떄 까지 요청을 보내는 Spin Lock 방식을 사용하였습니다.

Redis(Lettuce) Lock 테스트

테스트 환경은 위와 동일하게 설정하였습니다.

우리가 원했던 방식대로 동시성 문제를 해결하였습니다. 락을 획득한 스레드만 일정을 할당할 수 있게 진행되어 5명의 인원만 모임에 일정을 할당할 수 있는 결과를 얻었습니다.

하지만, Lettuce Lock은 위에서 언급했던 것 처럼 Spin Lock 방식을 사용한다. 락을 획득할 때까지 계속해서 레디스한테 '락을 획득하고 싶어' 라고 요청을 보내게 된다. 싱글 스레드인 레디스에 계속해서 이러한 요청은 부하를 줄 수 있다고 한다.

그렇기에 정말 많은 사용자가 동시에 요청한다면 이러한 방식은 피해야 할 것으로 보인다.


더 좋은 방법으로 Redisson 을 사용하여 pub/sub 방식을 통해 개선할 수 있다고 한다. 아래는 컬리에서 Redisson을 사용하여 문제를 해결한 방식이라고 한다. 시간이 될 때 읽어보고 별도의 프로젝트에서 진행해봐도 좋을 것 같다.

우리 서비스에 동시성 문제는 없을까요? · dnd-side-project dnd-11th-7-backend · Discussion #129GitHub
풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson
Logo
Logo
https://docs.spring.io/spring-data/redis/docs/current/api/org/springframework/data/redis/core/ValueOperations.html