개요
팀 프로젝트에 Redisson 분산 락을 적용해 보기 전에 간단하게 Redisson 사용방법 및 테스트를 진행할 것이다.
Redis Redisson 라이브러리
Redisson은 ava 기반의 Redis 클라이언트 라이브러리로, Redis를 단순한 캐시나 데이터 저장소로 사용하는 것을 넘어 분산 데이터 구조, 분산 락, 분산 서비스 관리 등의 기능을 손쉽게 구현할 수 있도록 도와준다.
Redisson은 Redis의 다양한 기능을 추상화하여 Java 개발자가 쉽게 사용할 수 있는 API를 제공함.
주요 특징
- 분산 데이터 구조 지원
- Redisson은 Redis 기반으로 Java 기본 데이터 구조를 확장하여 분산 환경에서 사용할 수 있도록 지원
- 제공되는 분산 데이터 구조
- Map, Set, List, Queue, Deque
- ScoredSortedSet
- BloomFilter
- BitSet 등
- 분산 락 및 동기화 메커니즘
- Redisson은 분산 환경에서 안전한 락을 제공하며, 이를 통해 분산 락, 공유 락, 읽기/쓰기 락, Semaphore, CountDownLatch 등을 지원
- 분산 서비스 관리
- Executor Service: 분산 환경에서 작업을 스케줄링하거나 실행할 수 있음
- Scheduler: 예약 작업을 지원
- Remote Service: 원격 서비스 호출을 간단히 구현할 수 있음
- Pub/Sub 및 이벤트 시스템
- Redis의 Pub/Sub 기능을 활용하여 메시징 시스템을 구현할 수 있음
- RTopic을 사용하여 이벤트를 발행하고 구독할 수 있음
- Spring과의 통합
- Spring Boot와 손쉽게 통합할 수 있는 모듈을 제공
- Spring Cache, Spring Transaction, Spring Data Redis 등과 연동 가능
- 다양한 Redis 배포 환경 지원
- 단일 인스턴스
- 클러스터
- Sentinel
- Master-Slave
- 간단한 설정 및 사용
- Redisson은 YAML 또는 Java 기반 설정으로 쉽게 초기화할 수 있음
Redisson을 사용하는 이유
- 편의성: Java API로 Redis의 기능을 간편하게 사용할 수 있다.
- 확장성: Redis의 기능을 확장하여 분산 환경에서 필요한 다양한 기능을 제공한다.
- 생산성 향상: 분산 락, 분산 데이터 구조 등 복잡한 분산 시스템 구현을 간소화한다.
- 통합성: Spring Boot와의 통합을 통해 애플리케이션 개발 속도를 높일 수 있다.
일반적으로 많이 쓰이는 Lettuce에 비해 Redisson의 이점
- Lock interface 지원: Lettuce는 분산락 사용을 위해 setnx, setex 등을 이용해 분산락을 직접 구현해야 함, Redisson은 별로의 Lock interface를 지원해 락을 보다 안전하게 사용할 수 있다.
- 락 획득 방식: Lettuce는 분산락 구현 시 지속적으로 Redis에게 락이 해제 되었는지 요청을 보내는 스핀락 방식으로 동작해서 요청이 많을수록 Redis가 받는 부하가 커짐, Redisson은 Pub/Sub 방식을 이용해 락이 해제되면 subscribe하는 클라이언트는 락이 해제되었다는 신호를 받고 락 획득을 시도한다.
Lettuce와 Redisson 비교
간단한 Redisson 분산 락 구현 및 동시성 테스트
build.gradle
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'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
- Redis와 Redisson 라이브러리 의존성을 추가한다.
application.yml
spring:
application:
name: redisson
h2:
console:
enabled: true
path: /h2
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:mem:lock;LOCK_TIMEOUT=10000
username: test
password:
jpa:
hibernate:
ddl-auto: create
open-in-view: false
server:
port: 8080
RedissonConfig
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 REDIS_HOST = "redis://";
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress(REDIS_HOST + "localhost:6379");
return Redisson.create(config);
}
}
- RedissonClient를 Bean으로 등록
Entity
import jakarta.persistence.*;
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;
@Version
private Long version;
public static Ticket create(Long quantity) {
Ticket ticket = new Ticket();
ticket.setQuantity(quantity);
return ticket;
}
public void decrease(Long quantity) {
long q = this.quantity - quantity;
this.quantity = q < 0 ? 0L : q;
}
}
- 간단한 테스트를 위한 Ticket 엔티티
Repository
public interface TicketRepository extends JpaRepository<Ticket, Long> {
}
- Jpa를 사용하기 위한 Repository 생성
Service
import com.example.lock.redisson.common.RedissonLock;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@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);
}
@RedissonLock(value = "#ticketId")
public void redissonTicketing(Long ticketId, Long quantity) {
Ticket ticket = ticketRepository.findById(ticketId).orElseThrow();
ticket.decrease(quantity);
ticketRepository.saveAndFlush(ticket);
}
}
- 티켓 수량을 감소시키는 메서드를 설정 분산 락을 사용하는 메서드와 사용하지 않는 메서드를 분리
분산 락 AOP
@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(com.example.lock.redisson.common.RedissonLock)")
public void redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
RedissonLock annotation = method.getAnnotation(RedissonLock.class);
String lockKey = method.getName() + CustomSpringELParser
.getDynamicValue(methodSignature.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 획득 실패 시 로직을 구현
Custom Spring 표현식 Parser
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 표현식을 디테일하게 사용할 수 있도록 구현
- 특별히 커스텀이 필요 없을 경우, SpealExpressionParser를 통해서 스프링 표현식을 파싱해도 됨
테스트 코드
import com.example.lock.redisson.domain.Ticket;
import com.example.lock.redisson.domain.TicketRepository;
import com.example.lock.redisson.domain.TicketService;
import org.junit.jupiter.api.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.assertEquals;
@SpringBootTest
public class TicketServiceTest {
private static final Logger logger = LoggerFactory.getLogger(TicketServiceTest.class);
@Autowired
TicketService ticketService;
@Autowired
TicketRepository ticketRepository;
private Long TICKET_ID = null;
private final Integer CONCURRENT_COUNT = 100;
@BeforeEach
public void before() {
logger.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
@Disabled
@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개의 티켓이 감소되는지 확인하는 테스트
분산 락 미 사용으로 인한 요청 실패 테스트 결과
2024-12-27T16:19:22.210+09:00 INFO 39848 --- [redisson] [ Test worker] o.h.e.t.j.p.i.JtaPlatformInitiator : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
2024-12-27T16:19:22.268+09:00 INFO 39848 --- [redisson] [ Test worker] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2024-12-27T16:19:22.501+09:00 INFO 39848 --- [redisson] [ Test worker] org.redisson.Version : Redisson 3.27.0
2024-12-27T16:19:23.204+09:00 INFO 39848 --- [redisson] [isson-netty-1-5] o.redisson.connection.ConnectionsHolder : 1 connections initialized for localhost/127.0.0.1:6379
2024-12-27T16:19:23.277+09:00 INFO 39848 --- [redisson] [sson-netty-1-20] o.redisson.connection.ConnectionsHolder : 24 connections initialized for localhost/127.0.0.1:6379
2024-12-27T16:19:23.962+09:00 INFO 39848 --- [redisson] [ Test worker] o.s.d.j.r.query.QueryEnhancerFactory : Hibernate is in classpath; If applicable, HQL parser will be used.
2024-12-27T16:19:27.125+09:00 INFO 39848 --- [redisson] [ Test worker] o.s.b.a.h2.H2ConsoleAutoConfiguration : H2 console available at '/h2'. Database available at 'jdbc:h2:mem:lock'
2024-12-27T16:19:27.208+09:00 INFO 39848 --- [redisson] [ Test worker] o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
2024-12-27T16:19:27.385+09:00 INFO 39848 --- [redisson] [ Test worker] c.e.lock.redisson.TicketServiceTest : Started TicketServiceTest in 11.796 seconds (process running for 13.576)
2024-12-27T16:19:28.042+09:00 INFO 39848 --- [redisson] [ Test worker] c.e.lock.redisson.TicketServiceTest : 1000개의 티켓 생성
Expected :900
Actual :984
<Click to see difference>
org.opentest4j.AssertionFailedError: expected: <900> but was: <984>
at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
at org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)
at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:182)
at org.junit.jupiter.api.AssertEquals.assertEquals(AssertEquals.java:177)
at org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:639)
at com.example.lock.redisson.TicketServiceTest.ticketingTest(TicketServiceTest.java:65)
at com.example.lock.redisson.TicketServiceTest.badTicketingTest(TicketServiceTest.java:71)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
Java HotSpot(TM) 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
2024-12-27T16:19:28.559+09:00 INFO 39848 --- [redisson] [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2024-12-27T16:19:28.566+09:00 INFO 39848 --- [redisson] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
> Task :test
TicketServiceTest > 동시에 100명의 티켓팅 : 동시성 이슈 FAILED
org.opentest4j.AssertionFailedError at TicketServiceTest.java:65
2024-12-27T16:19:28.573+09:00 INFO 39848 --- [redisson] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
> Task :test FAILED
1 test completed, 1 failed
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: file:///C:/Users/eleun/Desktop/Spring/locking/com.example.lock.redisson/build/reports/tests/test/index.html
* Try:
> Run with --scan to get full insights.
BUILD FAILED in 34s
4 actionable tasks: 4 executed
Redisson 분산락 적용 후 요청 성공 테스트
2024-12-27T16:20:27.101+09:00 INFO 33196 --- [redisson] [ Test worker] o.h.e.t.j.p.i.JtaPlatformInitiator : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
2024-12-27T16:20:27.158+09:00 INFO 33196 --- [redisson] [ Test worker] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2024-12-27T16:20:27.386+09:00 INFO 33196 --- [redisson] [ Test worker] org.redisson.Version : Redisson 3.27.0
2024-12-27T16:20:28.032+09:00 INFO 33196 --- [redisson] [isson-netty-1-5] o.redisson.connection.ConnectionsHolder : 1 connections initialized for localhost/127.0.0.1:6379
2024-12-27T16:20:28.107+09:00 INFO 33196 --- [redisson] [sson-netty-1-20] o.redisson.connection.ConnectionsHolder : 24 connections initialized for localhost/127.0.0.1:6379
2024-12-27T16:20:28.767+09:00 INFO 33196 --- [redisson] [ Test worker] o.s.d.j.r.query.QueryEnhancerFactory : Hibernate is in classpath; If applicable, HQL parser will be used.
2024-12-27T16:20:31.761+09:00 INFO 33196 --- [redisson] [ Test worker] o.s.b.a.h2.H2ConsoleAutoConfiguration : H2 console available at '/h2'. Database available at 'jdbc:h2:mem:lock'
2024-12-27T16:20:31.816+09:00 INFO 33196 --- [redisson] [ Test worker] o.s.b.a.e.web.EndpointLinksResolver : Exposing 1 endpoint beneath base path '/actuator'
2024-12-27T16:20:31.950+09:00 INFO 33196 --- [redisson] [ Test worker] c.e.lock.redisson.TicketServiceTest : Started TicketServiceTest in 10.991 seconds (process running for 12.731)
2024-12-27T16:20:32.515+09:00 INFO 33196 --- [redisson] [ Test worker] c.e.lock.redisson.TicketServiceTest : 1000개의 티켓 생성
2024-12-27T16:20:32.809+09:00 INFO 33196 --- [redisson] [pool-3-thread-1] c.e.l.r.common.RedissonLockAspect : 로직 수행
2024-12-27T16:20:32.830+09:00 INFO 33196 --- [redisson] [pool-3-thread-1] c.e.l.r.common.RedissonLockAspect : 락 해제
2024-12-27T16:20:32.842+09:00 INFO 33196 --- [redisson] [pool-3-thread-4] c.e.l.r.common.RedissonLockAspect : 로직 수행
2024-12-27T16:20:32.846+09:00 INFO 33196 --- [redisson] [pool-3-thread-4] c.e.l.r.common.RedissonLockAspect : 락 해제
...
(중략)
...
Java HotSpot(TM) 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
2024-12-27T16:20:33.405+09:00 INFO 33196 --- [redisson] [ionShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
2024-12-27T16:20:33.409+09:00 INFO 33196 --- [redisson] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown initiated...
2024-12-27T16:20:33.414+09:00 INFO 33196 --- [redisson] [ionShutdownHook] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Shutdown completed.
> Task :test
BUILD SUCCESSFUL in 16s
4 actionable tasks: 1 executed, 3 up-to-date
PM 4:20:33: Execution finished ':test --tests "com.example.lock.redisson.TicketServiceTest.redissonTicketingTest"'.
- 분산 락을 사용해 작업을 수행할 때 락을 걸고 작업이 끝난 후 락을 해제하고 다음 작업들은 락이 있는 동안 대기 후 락을 획득하고 작업을 수행해 동시성 문제가 발생하지 않고 모든 작업이 수행되는 것을 확인할 수 있다.
정리
- Redis를 활용해 DB에서 Lock을 걸지 않고 분산 락을 통해 동시성을 제어하는 것으로 성능적 이점을 가질 수 있다.
- Redisson 분산락을 구현하는 간단한 방법을 배웠고, 프로젝트에 적용할 때 적절하게 수정해서 사용하는 것으로 효과적으로 동시성 문제를 해결할 수 있도록 해야 겠다.
참조
'자바 심화 > TIL' 카테고리의 다른 글
Kafka - 비동기 처리 (1) | 2024.12.31 |
---|---|
동시성 제어 시점과 데이터 일관성 유지 관점 (1) | 2024.12.30 |
DB Lock (1) | 2024.12.26 |
장애 대응 (1) | 2024.12.24 |
시큐어 코딩(Secure Coding) (0) | 2024.12.23 |