📌 글에서 사용한 코드 : 깃헙
이번 글에서는 지난 글에 이어서 동시성 문제를 해결하는 방법에 대해서 이야기해보겠다.
지난 글에서는 Pessimistic Lock, Optimistic Lock을 사용하는 방법에 대해서 알아보았고, 각각이 갖는 한계점에 대해서도 알아보았다.
그래서 이번에는 위 방법들의 한계를 해결할 수 있는 Redis를 이용한 분산락을 구현해보도록 하겠다.
Redis에 접근할 수 있는 다양한 클라이언트들이 존재한다. 대표적으로 Jedis, Lettuce, Redisson등이 있다. 나는 그 중에서도 Redisson을 사용해서 분산락을 구현해보도록 하겠다.
Redisson은 비교적 합리적인 방식으로 Lock 획득 재시도 기능이 구현되어 있다.
Lettuce는 '스핀락'이라고 불리는 일종의 폴링 기법을 활용해서 Lock 획득을 재시도하는 한편 Redisson은 Redis의 Pub/sub 기능을 사용해서 Lock 획득을 재시도한다.
즉, Lock획득에 실패하면, Redisson은 특정 채널을 구독하고, Lock이 다시 획득할 수 있는 상태가 됐다는 이벤트를 받았을 때, 다시 Lock획득을 시도하는 것이다. 이는 Lock이 획득될 때까지 계속 Lock획득 요청하는 Lettuce보다 효율적이고, Redis서버에도 부하를 덜 주는 방법이라고 볼 수 있다.
자 이제 Redisson으로 분산락을 구현해보도록 하자.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.redisson:redisson-spring-boot-starter:3.27.0' // ⭐ Redisson
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
}
다른 패키지들은 참고용으로 정리하였고, redisson 스타터 라이브러리를 설치해주자.
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + "localhost:6379");
return Redisson.create(config);
}
}
Redis서버가 localhost:6379
에 떠 있다는 가정하에 위와 같이 RedissonClient
를 Bean으로 등록해주자.
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Entity
@NoArgsConstructor
public class Ticket {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Setter
private Long quantity;
public static Ticket create(Long quantity) {
Ticket entity = new Ticket();
entity.setQuantity(quantity);
return entity;
}
public void decrease(Long quantity) {
long q = this.quantity - quantity;
this.quantity = q < 0 ? 0L : q;
}
}
공연 티켓 또는 영화 티켓과 같은 표를 의미하는 Ticket 엔티티를 만들어주었다.
import org.springframework.data.jpa.repository.JpaRepository;
public interface TicketRepository extends JpaRepository<Ticket, Long> {}
JpaRepository를 상속받은 기본적인 기능을 갖고 있는 Repository를 만들어주었다.
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import study.springredislock.common.RedissonLock;
@Slf4j
@Transactional
@Service
@RequiredArgsConstructor
public class TicketService {
private final TicketRepository ticketRepository;
public void ticketing(Long ticketId, Long quantity) {
Ticket ticket = ticketRepository.findById(ticketId).orElseThrow();
ticket.decrease(quantity);
ticketRepository.saveAndFlush(ticket);
}
public void redissonTicketing(Long ticketId, Long quantity) {
Ticket ticket = ticketRepository.findById(ticketId).orElseThrow();
ticket.decrease(quantity);
ticketRepository.saveAndFlush(ticket);
}
}
티켓의 수량을 감소하는 역할을 하는 메서드를 갖고있는 서비스를 만들어주었다.
redissonTicketing
메서드는 분산락과 관련된 세팅이 완료되면 아래에서 수정해주겠다.
분산락과 관련된 기능은 AOP로 구현한다. (선택사항)
import java.lang.annotation.*;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedissonLock {
String value(); // Lock의 이름 (고유값)
long waitTime() default 5000L; // Lock획득을 시도하는 최대 시간 (ms)
long leaseTime() default 2000L; // 락을 획득한 후, 점유하는 최대 시간 (ms)
}
먼저, 분산락의 사용여부 또는 부가 설정을 할 수 있는 어노테이션을 만들어주자.
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RedissonLockAspect {
private final RedissonClient redissonClient;
@Around("@annotation(study.springredislock.common.RedissonLock)")
public void redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RedissonLock annotation = method.getAnnotation(RedissonLock.class);
String lockKey = method.getName() + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), annotation.value());
RLock lock = redissonClient.getLock(lockKey);
try {
boolean lockable = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS);
if (!lockable) {
log.info("Lock 획득 실패={}", lockKey);
return;
}
log.info("로직 수행");
joinPoint.proceed();
} catch (InterruptedException e) {
log.info("에러 발생");
throw e;
} finally {
log.info("락 해제");
lock.unlock();
}
}
}
재시도 로직은 Redisson에서 알아서 처리하므로 직접 구현해줄 필요 없다. Lock 획득 시도, Lock 획득 성공 후 처리, Lock 획득 실패 시 로직만 구현해주면 된다.
Lock의 고유 키 값(lockKey
)은 메서드 이름{ticketId}
로 하였다.
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
public class CustomSpringELParser {
public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
SpelExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < parameterNames.length; i++) {
context.setVariable(parameterNames[i], args[i]);
}
return parser.parseExpression(key).getValue(context, Object.class);
}
}
어노테이션에서 Spring 표현식을 좀 더 디테일하게 사용할 수 있도록 커스텀 파서를 만들어주었다.
특별히 커스텀할 필요가 없다면, 기본적으로 제공되는 SpelExpressionParser
를 통해서 스프링 표현식을 파싱해도 무방하다.
@RedissonLock(value = "#ticketId")
public void redissonTicketing(Long ticketId, Long quantity) {
Ticket ticket = ticketRepository.findById(ticketId).orElseThrow();
ticket.decrease(quantity);
ticketRepository.saveAndFlush(ticket);
}
위에서 만들어준 Service의 메서드 중, redissonTicketing
에 분산락을 적용할 수 있는 어노테이션을 붙여주자.
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
@Slf4j
@SpringBootTest
class TicketServiceTest {
@Autowired
TicketService ticketService;
@Autowired
TicketRepository ticketRepository;
private Long TICKET_ID = null;
private final Integer CONCURRENT_COUNT = 100;
@BeforeEach
public void before() {
log.info("1000개의 티켓 생성");
Ticket ticket = Ticket.create(1000L);
Ticket saved = ticketRepository.saveAndFlush(ticket);
TICKET_ID = saved.getId();
}
@AfterEach
public void after() {
ticketRepository.deleteAll();
}
private void ticketingTest(Consumer<Void> action) throws InterruptedException {
Long originQuantity = ticketRepository.findById(TICKET_ID).orElseThrow().getQuantity();
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(CONCURRENT_COUNT);
for (int i = 0; i < CONCURRENT_COUNT; i++) {
executorService.submit(() -> {
try {
action.accept(null);
} finally {
latch.countDown();
}
});
}
latch.await();
Ticket ticket = ticketRepository.findById(TICKET_ID).orElseThrow();
assertEquals(originQuantity - CONCURRENT_COUNT, ticket.getQuantity());
}
@Test
@DisplayName("동시에 100명의 티켓팅 : 동시성 이슈")
public void badTicketingTest() throws Exception {
ticketingTest((_no) -> ticketService.ticketing(TICKET_ID, 1L));
}
@Test
@DisplayName("동시에 100명의 티켓팅 : 분산락")
public void redissonTicketingTest() throws Exception {
ticketingTest((_no) -> ticketService.redissonTicketing(TICKET_ID, 1L));
}
}
1000개의 티켓을 만들고, 100개의 요청이 동시에 주어졌을 때 100개의 티켓이 잘 감소됐는지를 확인하는 테스트이다.
분산락을 적용하지 않은 테스트 코드에서는 동시성 문제에 의해서 예상한 결과가 나오지 않았고, 분산락을 적용한 테스트 코드는 당연하게도 예상한 대로 잘 티켓이 감소된 것을 확인할 수 있다.
또, 서로 다른 쓰레드가 Lock을 주고 받는(?) 모습 또한 확인할 수 있다.
이번 글에서는 Redis를 활용해서 분산락을 구현해보았다.
컬리에서 매우 매우 좋은 글을 써줘서 사실상 거의 그대로 따라해보면서 구현해보았다. ㅎㅎ
컬리, 감사하모니카 🫡