리액트 컴포넌트가 두 번 렌더링되면서 거의 동시에 서버로 두 개의 요청이 도착했다. 그러나 나의 멀티스레드 서버는 이를 처리하지 못해 회원가입이 두 번 발생하는 문제가 생겼다. 싱글 스레드로 변경하면 서버 한 대로 충분한 성능이 보장될지 의문이 들었다. 그래서 찾아낸 해결책은 레디스(Redis) 분산 락이었다. 이제 분산 락에 대해 알아보자.
간단히 말하자면, 분산 락은 여러 서버에서 공유된 데이터를 관리하기 위해 사용되는 기술이다. 일반적으로 마이크로서비스 아키텍처(MSA)에서 하나의 자원에 대한 접근을 제어하기 위해 동시성 제어를 위한 락이 사용된다. 예를 들어, 서버 1과 서버 2가 동일한 재고에 접근하려 할 때, 서버 1이 락을 성공적으로 얻으면 서버 2는 락이 해제될 때까지 접근할 수 없는 방식으로 작동한다.
멀티스레드 환경에서 사용되는 방법으로, 공유된 자원에 대한 접근을 제한하는 방식이다. 이 방식은 한 번에 하나의 자원만 접근이 허용된다. 하지만 이 방법은 하나의 프로세스 내에서만 작동하므로 MSA 서버에서는 사용할 수 없다.
스핀락을 제공하는데, 스핀락은 락 점유에 실패하면 계속해서 점유 시도를 반복하는 방식이다. 이 방식은 부하를 유발하기 때문에 선호되지 않는다.
메시지 브로커(Message Broker) 기능을 사용하여 특정 채널에 구독(Subscribe)하고 있다. 따라서 락이 해제되었다는 메시지가 발행되면 즉시 락 점유를 시도하도록 하여 스핀락의 단점을 보완한다 (부하 감소). 이러한 기능은 레디스(Redis)에 이미 구현되어 있다.
// redis
implementation 'org.redisson:redisson-spring-boot-starter:3.16.3'
@DisplayName("회원가입 중복요청 테스트")
@SpringBootTest
public class RedisServiceTest extends TestSupport {
@Autowired
private AuthService authService;
@Autowired
private UserRepository userRepository;
@Test
void 같은_socialId_요청() throws Exception {
// given
int threadCount = 2;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
// when
IntStream.range(0, threadCount).forEach(e -> executorService.submit(() -> {
try {
authService.loginUser("deviceToken", "tempSocialId", SocialType.KAKAO);
} catch (Exception ex) {
System.out.println(ex);
} finally {
countDownLatch.countDown();
}
}
));
countDownLatch.await();
// then
int count = userRepository.findAll().size();
assertThat(count).isEqualTo(1);
// 테스트 후처리
List<User> all = userRepository.findAll();
for(User u : all) {
System.out.println("existing user " + u.getUserId());
}
userRepository.deleteAll();
}
@Test
void 다른_socialId_요청() throws Exception {
// given
int threadCount = 2;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);
// when
IntStream.range(0, threadCount).forEach(e -> executorService.submit(() -> {
try {
authService.loginUser("deviceToken", "tempSocialId"+e, SocialType.KAKAO);
} catch (Exception ex) {
System.out.println(ex);
} finally {
countDownLatch.countDown();
}
}
));
countDownLatch.await();
// then
int count = userRepository.findAll().size();
assertThat(count).isEqualTo(threadCount);
// 테스트 후처리
List<User> all = userRepository.findAll();
for(User u : all) {
System.out.println("existing user " + u.getUserId());
}
userRepository.deleteAll();
}
}
사용자 요청이 오면 사용자 고유 번호로 redis에 락을 걸어둔다.
락이 걸려진 상태에서 요청이 처리되는 사이에 같은 사용자의 요청이 오면 그 요청은 대기하다가 락 실패가 되게 한다. 즉, leaseTime(락 임대 시간) > waitTime(락 대기 시간) 로 설정한다.
@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;
}
}
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributeLockAop {
private static final String REDISSON_KEY_PREFIX = "RLOCK_";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(cmc.utils.redisson.DistributeLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributeLock distributeLock = method.getAnnotation(DistributeLock.class); // (1)
String key = REDISSON_KEY_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributeLock.key()); // (2)
RLock rLock = redissonClient.getLock(key); // (3)
try {
boolean available = rLock.tryLock(distributeLock.waitTime(), distributeLock.leaseTime(), distributeLock.timeUnit()); // (4)
if (!available) {
return false;
}
log.info("get lock success {}" , key);
return aopForTransaction.proceed(joinPoint); // (5)
} catch (Exception e) {
log.warn("get lock fail {}" , key);
Thread.currentThread().interrupt();
throw new InterruptedException();
} finally {
rLock.unlock(); // (6)
}
}
}
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributeLockAop {
private static final String REDISSON_KEY_PREFIX = "RLOCK_";
private final RedissonClient redissonClient;
private final AopForTransaction aopForTransaction;
@Around("@annotation(cmc.utils.redisson.DistributeLock)")
public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
DistributeLock distributeLock = method.getAnnotation(DistributeLock.class); // (1)
String key = REDISSON_KEY_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributeLock.key()); // (2)
RLock rLock = redissonClient.getLock(key); // (3)
try {
boolean available = rLock.tryLock(distributeLock.waitTime(), distributeLock.leaseTime(), distributeLock.timeUnit()); // (4)
if (!available) {
return false;
}
log.info("get lock success {}" , key);
return aopForTransaction.proceed(joinPoint); // (5)
} catch (Exception e) {
log.warn("get lock fail {}" , key);
Thread.currentThread().interrupt();
throw new InterruptedException();
} finally {
rLock.unlock(); // (6)
}
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributeLock {
String key(); // (1)
TimeUnit timeUnit() default TimeUnit.MILLISECONDS; // (2)
long waitTime() default 300L; // (3)
long leaseTime() default 500L; // (4)
}
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
public class CustomSpringELParser {
public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
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);
}
}
락의 임대 시간(leaseTime)을 0.5초로 임의로 설정했다. 그러나 중복 요청이 아닌, 실제로 유의미한 두 개의 요청이 들어온다면 어떨까? 서버에서는 이를 구별할 방법이 없다. 현재 서버는 0.5초 이내에 두 개 이상의 요청이 들어오면 중복 요청으로 판단하고 있다. 이를 구별할 수 없는 상황이므로, 정상 작동을 위해 일단 분산 락 코드를 제거한 상태다. 추후 다시 시도해보고 수정할 필요가 있다.