분산락이란 무엇일까?
자바 스프링 기반의 웹 애플리케이션은 기본적으로 멀티 쓰레드 환경에서 구동한다. 따라서 여러 쓰레드가 함께 접근할 수 있는 공유자원에 대해서 경쟁 상태가 발생하지 않도록 별도의 처리가 필요하다.
예를 들어, 사용자가 게시물 좋아요를 누를 수 있는 기능이 있다고 가정해보자. 만약 사용자 수가 엄청나게 증가하여 5명의 유저가 한 게시물에 동시에 좋아요를 누르게 된다면 그 게시물의 좋아요는 0개에서 몇개가 될까? “5명이 눌렀으니 당연히 5개가 될거야” 라고 생각할 수 있지만 예상과 달리 좋아요 수는 1개가 될 것이다.
말 그대로 사용자 5명이 동시에 좋아요 기능을 사용했으니 5명의 사용자 모두 좋아요 0개 라는 현재 상태를 바라보고 있기 때문이다.
자바는 이러한 문제를 해결할 수 있도록 synchronized
라는 키워드를 언어 차원에서 제공하고 있다.
모니터 기반으로 상호 배제 기능(mutual exclusion) 을 제공한다.
하지만, 이 매커니즘은 같은 프로세스에서만 상호 배제를 보장한다. 웹 애플리케이션 프로세스를 단 하나만 사용하는 서비스라면 상관 없지만, 일반적으로는 서버를 다중화하여 부하 분산하고 있을 것이다. (AWS 에서 EC2를 여러개.. 등)
이런 분산 환경에서 상호 배제를 구현하여 동시성 문제를 다루기 위해 등장한 방법이 바로 분산 락이다. 분산 락은 데이터베이스에서 제공하는 락의 종류(갭, S-lock, x-lock 이런것들) 이 아니다. 일반적으로 웹 애플리케이션에서 공유 자원으로 데이터베이스를 가장 많이 사용하다보니, 데이터베이스에 대한 동시성 문제를 분산 락으로 풀어내는 사례가 많을 뿐이다. 물론 데이터베이스의 락 기능을 활용 하여 분산 락을 구현할 수는 있지만 , 직접적으로 연관있다고 보기는 어렵다고한다.
분산 락을 구현하기 위해 락에 대한 정보를 ‘어딘가’ 에 공통적으로 보관하고 있어야 한다. 그리고 분산 환경에서 여러대의 서버들은 공통된 ‘어딘가’ 를 바라보며, 자신이 임계 영역에 접근 할 수 있는지 확인 한다. 이렇게 분산 환경에서 원자성을 보장할 수 있게 된다. 그리고 그 ‘어딘가’로 활용되는 기술은 MySQL 의 네임드 락 , Redis, Zookeeper 등이 있다. 그리고 이번 포스팅에서는 가상으로 동시성 이슈가 발생할만한 애플리케이션을 만들어보고 , Redis 를 활용하여 분산락을 구현해보도록 하자.
애플리케이션 요구 사항
동시성 이슈에 민감한 도메인 중 가장 쉽게 떠올릴 수 있는건 ‘선착순 신청’ 시스템 일 것이다. 우리는 어떤 오프라인 행사가 열리면 그 행사에 참여를 신청할 수 있는 웹서비스를 만든다. 그리고 그 애플리케이션에서 동시성 이슈를 직접 겪어보고, Redis를 활용한 두 가지 방법으로 이를 해결해보자.
인프라
- 웹 애플리케이션이 2개의 인스턴스에서 구동되는 다중화 환경
- 데이터베이스 1대
요구사항
- 각 행사는 제한 인원이 존재하며 제한인원보다 많은 사람이 참여 신청할 수 없다.
선착순 행사 참여 애플리케이션 만들기
Getter, ToString , 기본 생성자 , DTO 코드 등 중요하지 않은 부분은 생략
사용된 기술
- Spring boot
- Spring MVC
- Spring Data JPA
- Lombok
Event
행사 도메인이다. ticketLimit
필드로 최대 참여수를 제한할 수 있다. isClosed()
를 통해 행사 참여 가능 여부를 확인할 수 있다.
@Getter
@Entity
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long ticketLimit;
@OneToMany(mappedBy = "event")
private List<EventTicket> eventTickets;
// ...
public boolean isClosed() {
return eventTickets.size() >= ticketLimit;
}
EventTicket
행사 티켓 도메인이다. 행사에 참여 신청시 생성된다.
한 Event
당 Event
의 ticketLimit
만큼 생성될 수 있다. 예를 들어 Event
의 ticketLimit
이 25라면, EventTicket
은 최대 25개만 데이터베이스에 생성될 수 있다.
EventRepository 와 EventTicketRepository
public interface EventRepository extends JpaRepository<Event, Long> {
}
public interface EventTicketRepository extends JpaRepository<EventTicket, Long> {
}
EventService
@Service
public class EventService {
private final EventRepository eventRepository;
private final EventTicketRepository eventTicketRepository;
public EventService(final EventRepository eventRepository, final EventTicketRepository eventTicketRepository) {
this.eventRepository = eventRepository;
this.eventTicketRepository = eventTicketRepository;
}
@Transactional
public EventResponse createEvent(final Long ticketLimit) {
Event savedEvent = eventRepository.save(new Event(ticketLimit));
return new EventResponse(savedEvent.getId(), savedEvent.getTicketLimit());
}
@Transactional
public EventTicketResponse createEventTicket(final Long eventId) {
Event event = eventRepository.findById(eventId).orElseThrow();
if (event.isClosed()) {
throw new RuntimeException("마감 되었습니다.");
}
EventTicket savedEventTicket = eventTicketRepository.save(new EventTicket(event));
return new EventTicketResponse(savedEventTicket.getId(), savedEventTicket.getEvent().getId());
}
}
createEventTicket()
메소드에서 이벤트 티켓을 발행한다. event.isClosed()
를 통해 마감 여부를 검증하고, 마감 되었다면 EventTicket
이 생성될 수 없도록 제한한다.
EventController
@RestController
@RequestMapping("/events")
public class EventController {
private EventService eventService;
public EventController(final EventService eventService) {
this.eventService = eventService;
}
@PostMapping
public ResponseEntity<EventResponse> createEvent(@RequestBody final EventRequest request) {
Long ticketLimit = request.getTicketLimit();
EventResponse response = eventService.createEvent(ticketLimit);
return ResponseEntity
.created(URI.create("/events/" + response.getId()))
.body(response);
}
@PostMapping("/{eventId}/tickets")
public ResponseEntity<EventTicketResponse> createEventTicket(@PathVariable final Long eventId) {
EventTicketResponse response = eventService.createEventTicket(eventId);
return ResponseEntity
.created(URI.create("/events/" + response.getEventId() + "/" + response.getId()))
.body(response);
}
}
동시성 이슈 발생
모든 테스트는 실제 분산 환경과 동일하게 만들기 위해, 2개의 포트로 스프링 애플리케이션을 띄우고 JMeter를 사용하여 두 프로세스에 동시에 요청한다. 최대 25명이 참여할 수 있는 행사를 생성하고, 100개의 쓰레드를 사용하여 두 프로세스에 동시에 참여 요청(EventTicket 생성 요청) 을 보낸다.
분명히 25명으로 참여 제한을 걸어두었는데도, 데이터베이스를 확인해보니 실제로는 event_ticket
테이블에 30행의 데이터가 추가 된 것을 확인할 수 있다.
동시성 이슈가 발생한 것이다.
대표적인 Check-Then-Act 패턴으로, 동시성으로 인해 검증(Check) 시점의 정보가 행위(Act) 시점에 더 이상 유효하지 않아서 발생한 오류이다.
앞서 말햇지만 이는 synchronized
로도 해결 할 수 없다. 두 애플리케이션이 별개의 프로세스로 동작하고 있기 때문이다.
방법 1 - Redis SETNX 활용
Redis에는 SETNX
라는 명령이 존재한다. ”SET if Not eXists” 의 줄임말로, 말 그대로 특정 Key에 Value 가 존재하지 않을 때만 값을 설정할 수 있다는 의미이다.
127.0.0.1:6379> setnx 1 lock
(integer) 1
127.0.0.1:6379> setnx 1 lock
(integer) 0
127.0.0.1:6379> del 1
(integer) 1
127.0.0.1:6379> setnx 1 lock
(integer) 1
위는 SETNX
명령을 사용한 모습인데,
Key 1
에 lock
이라는 Value를 설정하는 모습이다.
최초에는 Key 에 아무 값도 설정되어 있지 않기 때문에 1
을 반환하며 성공한 모습이다.
이후에 동일한 명령을 하지만, 이미 해당 Key 에 Value 가 존재하기 때문에 0
을 반환하며 실패한 모습이다.
이후 DEL
명령을 통해 Key 1
의 데이터를 제거하고 , 동일하게 SETNX 명렁을 실행하니 다시 1
을 반환하며 성공한다.
이 방식을 통해 애플리케이션에서 스핀 락(spin lock) 을 구현할 수 있다. 스핀 락이란, 락을 사용할 수 있을 때까지 지속적으로 확인하며 기다리는 방식을 말한다.
즉, 레디스 서버에 지속적으로 SETNX
명령을 보내어 임계 영역 진입 여부를 확인하는 메커니즘 이다.
REDIS Client 로는 Lettuce 를 활용한다. build.gradle
의 dependencies
에 아래와 같은 의존성을 추가하자.
자세한 Redis 연결 설정은 생략한다.
implementation 'org.springframework.boot:spring-boot-starter-cache'
RedisLockRepository
@Component
public class RedisLockRepository {
private RedisTemplate<String, String> redisTemplate;
public RedisLockRepository(final RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public Boolean lock(final Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(String.valueOf(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unlock(final Long key) {
return redisTemplate.delete(String.valueOf(key));
}
}
RedisTemplate
을 주입 받아서 락을 관리하는 RedisLockRepository
를 구현한다.
lock()
메소드는 setIfAbsent()
를 사용하여 SETNX
를 실행한다. 이때, Key는 Event
엔티티의 ID로, Value 는 lock
으로 설정한다. 세번째 파라미터는 Timeout 설정이다.
unlock()
메소드는 Key에 대해 DEL
명령을 실행한다. 이를 통해 락을 release 할 수 있다.
EventService
당연히, EventService에서 redisLockRepository 를 주입 받아야 한다
아래는 기존의 createEventTicket()
메소드에 스핀 락을 적용한 소스코드이다. while 문을 활용해 락을 흭득할 때 까지
무한 반복을 돈다. 레디스 서버에 부하를 덜기 위해 반복마다 100ms 쉬어준다.
임계 영역에 진입한 후 비즈니스 로직을 처리하고 나서는 finally 블럭을 사용해 락을 해제해준다. 락을 해제 해주지 않으면 다른 쓰레드에서 임계영역에 진입하지 못하므로 주의하자.
@Transactional
public EventTicketResponse createEventTicket(final Long eventId) throws InterruptedException {
while (!redisLockRepository.lock(eventId)) {
Thread.sleep(100);
} // 락을 획득하기 위해 대기
try {
Event event = eventRepository.findById(eventId).orElseThrow();
if (event.isClosed()) {
throw new RuntimeException("마감 되었습니다.");
}
EventTicket savedEventTicket = eventTicketRepository.save(new EventTicket(event));
return new EventTicketResponse(savedEventTicket.getId(), savedEventTicket.getEvent().getId());
} finally {
redisLockRepository.unlock(eventId);
// 락 해제
}
}
장점과 단점
이렇게 SETNX
를 사용한 스핀 락 방식으로 분산 락을 구현해 보았다. 장점과 단점을 요약해본다면 아래와 같다.
- 장점 : 기본 제공되는 Redis Client인 Lettuce 만으로 간단히 구현할 수 있다.
- 단점 : 스핀 락 방식으로 사용하여, 레디스 서버에 부하를 줄 수 있다. ( 스핀 락은 락을 얻을 때까지 계속해서 시도하는 방법이기 때문에 )
방법2 - Redis 의 Message Broker 활용
이번에는 Redis가 제공하는 Message Broker 기능을 사용하여 분산 락을 구현해보자.
Redis 에서 SUBSCRIBE 명령으로 특정 채널을 구독할 수 있으며, PUBLISH
명령으로 특정 채널에 메시지를 발행 할 수 있다.
직접 해보자. 아래는 ch1 이라는 채널을 구독한 모습이다.
127.0.0.1:6379> subscribe ch1
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "ch1"
3) (integer) 1
레디스 터미널 창을 하나 더 띄워 아래와 같이 메시지를 발행해보자
127.0.0.1:6379> publish ch1 helloworld
(integer) 1
전에 띄워놓은 터미널로 돌아가보면 아래와 같이 수신한 메시지를 확인할 수 있다.
1) "message"
2) "ch1"
3) "helloworld"
이 방식을 사용하면 끊임없이 레디스 서버에 확인하는 스핀 락을 사용하지 않아도 된다. 락을 해제하는 측이 락을 대기하는 프로세스에게 ‘락 흭득 시도를 해도 된다’ 라는 메시지를 발행하는 방식으로 동작한다.
이 메커니즘은 사실 우리가 직접 구현하지 않아도 된다. Redisson 이라는 라이브러리가 Redis의 메시지 브로커 기능을 활용하여 분산 락 매커니즘을 구현하였다. 이 라이브러리는 또 타임아웃을 구현해놓아서 일정 시간동안 락을 흭득하지 못하면 예외를 발생시킬 수 있다.
아래 내용을 build.gradle
에 추가하자.
// Redisson 의존성
implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'
EventService
@Service
public class EventService {
private final EventRepository eventRepository;
private final EventTicketRepository eventTicketRepository;
private final RedissonClient redissonClient;
public EventService(final EventRepository eventRepository, final EventTicketRepository eventTicketRepository, final RedissonClient redissonClient) {
this.eventRepository = eventRepository;
this.eventTicketRepository = eventTicketRepository;
this.redissonClient = redissonClient;
}
// ...
일단 위와 같이 RedissonClient
를 주입받자.
@Transactional
public EventTicketResponse createEventTicket(final Long eventId) {
RLock lock = c(String.valueOf(eventId));
try {
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS);
if (!available) {
throw new RuntimeException("Lock을 획득하지 못했습니다.");
}
/* 비즈니스 로직 */
Event event = eventRepository.findById(eventId).orElseThrow();
if (event.isClosed()) {
throw new RuntimeException("마감 되었습니다.");
}
EventTicket savedEventTicket = eventTicketRepository.save(new EventTicket(event));
return new EventTicketResponse(savedEventTicket.getId(), savedEventTicket.getEvent().getId());
/* 비즈니스 로직 */
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
redissonClient.getLock()
을 통해 락을 획득하고, tryLock()
을 통해 락 획득을 시도한다. 락 획득을 성공하면 임계 영역에 진입하여 비즈니스 로직을 실행하고, finally 블럭에서 unlock()
한다.
락 흭득을 실패하였으면 , 끊임없이 레디스 서버에 재확인하는 것이 아니라 대기 상태로 들어가 메시지가 오기를 기다린다.
비즈니스 위 아래로 비즈니스와 관련없는 코드가 존재하는 문제는 나중에 Facade나 AOP 로 해결 할 수도 있지 않을까 싶다.
장점과 단점
- 장점 : 스핀 락 방식이 아니므로 쓸데없는 트래픽이 발생하지 않는다.
- 단점 : 별도의 라이브러리를 사용해야한다. 이로 인한 사용법 공부가 필요하다.
낙관 락 / 비관 락 차이점
이렇게 Redis 를 사용하여 분산 락을 구현해보았다. 하지만 공를 하면서 많이 헷갈렸던 부분이 낙관 락/ 비관 락 으로 해결할 수 있는 문제와 분산 락으로 해결할 수 있는 문제가 많이 겹쳐보였다. 내가 느낀 이 둘의 차이점을 간략히 정리해본다.
낙관 락/ 비관 락의 관심사는 특정 엔티티에 대한 동시 접근 제어이다. 즉, 동시성을 제어하기 위한 엔티티가 존재해야한다. 하지만, 앞서 살펴본 예제는 엔티티의 생성 개수 제한에서 문제가 발생하였다. 이는 이미 존재하는 엔티티에 동시 접근하는 상황이 아니다. 따라서 낙관 락/ 비관 락 으로 해결할 수 없다.
물론 ‘남은 자리 수’ 등의 정보를 엔티티로 별도 관리한다면 충분히 낙관 락/비관 락으로 해결할 수 있다. 따라서 지금 상황에 가장 적합한 해결 방법을 선택하는 것이 중요하다.