이전 글에서 spring retry로 간단한 동시성을 해결했지만 완벽한 해결방법이 아니다.
단일 서버가 아닌 다중 서버 환경에서는 동일한 데이터에 동시에 접근할 때 발생하는 동시성 문제는 낙관적 락과 spring retry로 해결하기에는 무리다.
그래서 이번에는 분산락을 통한 동시성 해결을 확인해보자
서로 다른 프로세스나 서버들이 동시에 동일한 자원에 접근할 때 오직 하나의 프로세스만이 해당 자원을 변경할 수 있게 Lock을 거는 메커니즘이다.
단일 서버 내부에서는 synchronized
나 ReentrantLock
을 사용해 스레드 동기화를 처리할 수 있지만 여러 서버가 동시에 접근하는 환경에서는 네트워크상의 어떤 공통된 시스템을 통해 누가 Lock을 가지고 있는지
를 판단해야 한다. 이게 분산락의 핵심이다.
Redis로 분산락을 구현하는 방법중에 Redisson 라이브러리를 사용하는 방법이 있다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.35.0'
위와 같이 redis와 redisson에 대한 의존성을 먼저 추가해준다.
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
private static final String REDISSON_HOST_PREFIX = "redis://";
@Bean
public RedissonClient redissonClient() {
RedissonClient redisson = null;
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
redisson = Redisson.create(config);
return redisson;
}
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Message {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member author;
@Column(name = "likes", nullable = false)
private Long likes;
public Message(String content, Member author, Long likes) {
this.content = content;
this.author = author;
this.likes = likes;
}
public void like() {
this.likes = likes + 1;
}
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MessageLike {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "message_like_id")
private Long id;
@Column(name = "member_id")
private Long memberId;
@Column(name = "message_id")
private Long messageId;
public MessageLike(Long memberId, Long messageId) {
this.memberId = memberId;
this.messageId = messageId;
}
}
도메인 로직은 이전 글과 동일하다.
@Service
@RequiredArgsConstructor
public class MessageLikeService {
private final MessageRepository messageRepository;
private final MessageLikeRepository messageLikeRepository;
private final RedissonClient redissonClient;
@DistributedLock(key = "#messageId")
public MessageLikeResponseDto likeMessage(Long memberId, Long messageId) {
String lockKey = "LOCK:" + messageId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 2) 락 획득 시도
boolean available = lock.tryLock(5, 3, TimeUnit.SECONDS);
if (!available) {
throw new RuntimeException("Lock 획득 실패: 다른 프로세스가 사용 중입니다.");
}
// 3) 실제 DB 로직
final Message message = messageRepository.findById(messageId)
.orElseThrow(IllegalArgumentException::new);
message.like();
messageLikeRepository.save(new MessageLike(memberId, messageId));
return new MessageLikeResponseDto(message.getLikes(), true);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Lock 대기 중 인터럽트가 발생했습니다.", e);
} finally {
// 4) 락 해제
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Service 로직에서 분산락을 적용한 모습이다. 코드를 간단히 살펴보면 아래와 같다.
tryLock
과 unlock()
을 해준다.lock.tryLock(5, 3, TimeUnit.SECONDS)
private static final Integer parallelism = 5;
private final ExecutorService executorService = Executors.newFixedThreadPool(parallelism);
@Test
void 좋아요_정합성_테스트() throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
long userId = i + 1;
executorService.submit(() -> {
try {
messageLikeService.likeMessage(userId, 1L);
} finally {
countDownLatch.countDown();
}
});
}
countDownLatch.await();
assertThat(messageRepository.findById(1L).get().getLikes()).isEqualTo(100L);
}
해당 테스트를 실행시키면 문제 없이 성공하는 걸 확인할 수 있다.
그렇다면 이러한 분산락을 필요한 곳에 일일히 저렇게 코드를 작성하는 건 비효율적이다.
Spring의 AOP를 활용하면 더 손쉽게 적용이 가능하다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
// SpEL을 통해 락 키를 생성하는 값
String key();
TimeUnit timeUnit() default TimeUnit.SECONDS;
// 락을 획득하기 위해 대기하는 시간
long waitTime() default 5L;
// 락을 획득 후 자동으로 락이 해제되기까지의 시간
long leaseTime() default 3L;
}
DistributedLock 어노테이션의 파라미터는 key는 필수, 나머지 값들은 커스텀 하게 설정할 수 있게 했다.
위의 lock.tryLock(5, 3, TimeUnit.SECONDS);
이 부분에 들어가는 값들이다.
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
private static final String REDISSON_LOCK_PREFIX = "LOCK:";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(DistributedLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);
// SpEL 파싱하여 실제 key 값 생성
String key = REDISSON_LOCK_PREFIX + CustomELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
RLock rLock = redissonClient.getLock(key);
try {
// waitTime, leaseTime 등을 어노테이션에서 가져옴
boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
if (!available) {
log.info("락을 획들 할 수 없습니다. method: {} key: {}", method.getName(), key);
return false;
}
// // 핵심 로직 실행 -> 별도 트랜잭션 (REQUIRES_NEW)로 처리
return aopForTransaction.proceed(joinPoint);
} catch (InterruptedException e) {
throw new InterruptedException();
} finally {
// 락 해제
try {
if (rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
} catch (Exception e) {
log.error("락이 해제하는 동안 오류가 발생했습니다. method: {} key: {}", method.getName(), key, e);
}
}
}
}
이 부분은 @DistributedLock
어노테이션 선언 시 수행되는 aop 클래스다.
기존 MessageLikeService
의 likeMessage
부분의 락을 획득하고 해제하는 역할을 이 aop가 수행하는 것이다.
메서드에 @DistributedLock
어노테이션을 선언하기만 하면된다.
SpEL로 #messageId 등을 파싱해 LOCK: 형태의 키를 만들고 어노테이션에 지정한 waitTime, leaseTime 등을 활용해 tryLock()을 수행한다.
락 해제 시점도 AOP가 알아서 처리하므로, 비즈니스 로직(Service 클래스)에서는 단순히 @DistributedLock만 달면 된다.
여기서 새롭게 추가된 부분은 AopForTransaction
과 CustomELParser
클래스다.
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
를 통해 부모 트랜잭션과 분리된 새로운 트랜잭션에서 핵심 로직을 실행한다.public class CustomELParser {
private CustomELParser() {
}
public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
// SpEL Parser를 통해 #messageId 등 파라미터를 치환
ExpressionParser 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);
}
}
@DistributedLock(key="#messageId")
같은 식을 해석하여 런타임 파라미터를 얻기 위해 사용한다.이전 글에서 작성한 낙관적 락 + Spring retry는 간단한 상황에서는 사용할만 하지만 서버가 여러대이고 트래픽이 높을 때는 충돌이 빈번해져 오히려 과부하가 발생한다.
Redis 기반의 분산락을 사용하면 다중 서버환경에서도 자원에 대한 원자적 접근을 안전하게 보장할 수 있다.
그리고 추가적으로 AOP를 적용해 어노테이션만 달아도 락을 획득/해제하도록 만들면 비즈니스 로직과 동시성 제어 로직을 깔끔하게 분리할 수 있었다.
다중 서버 동시성 해결에 Redis의 분산락이 하나의 해결책이 될 수 있다.
작성된 예제코드는 여기서 확인이 가능하다.
Spring Assert클래스는 왜 Supplier를 활용하고 있을까? (0) | 2024.06.05 |
---|---|
Spring 프로젝트 컨트리뷰터 된 후기 (ThreadLocal.set(null) vs ThreadLocal.remove() ) (0) | 2024.05.24 |
Spring Retry를 활용한 메시지 좋아요 기능의 동시성 문제 해결 방안 (0) | 2024.04.11 |