기본적으로 웹 환경에서는 동시에 여러 요청들이 들어올 수 있고, 스프링 같은 멀티스레드 환경에서는 여러 스레드가 한 자원을 공유할 수 있어 데이터 정합성 문제가 발생할 수 있다.
- 친구요청 (A->B)
친구요청 건(A) 사용자 조회 -> 친구 요청을 받은(B) 사용자 조회 -> 친구 테이블에서 A->B가 친구인지 / 친구 요청을 걸었는지 조회 -> 친구 테이블에서 B->A가 건 요청이 있는지 조회 -> 모든 예외에 걸리지 않으면 친구 테이블에 A->B에게 요청을 건 정보를 저장.
ex) B->A요청이 진행되고 있는 상태에서 A->B 요청이 실행되면 전자 요청이 없는 것으로 조회가 되기 때문에 A->B요청이 정상적인게 되어버림.
Redis를 활용한 분산락을 사용해 동시성 제어를 할 수 있다.
분산락
: 분산된 서버 또는 DB환경에서도 동시성을 제어할 수 있는 방법.
반드시 Redis를 활용해 분산락을 구현할 필요는 없다. MySQL의 네임드 락을 통해도 구현할 수 있긴 하다.
후자가 메모리 자원을 추가로 사용할 필요가 없는 장점도 가지고 있다.
그러나 기본적으로 디스크를 사용하는 DB보다 메모리를 사용하는 Redis가 더 빠르게 락 취득 및 해제 할 수 있다.
Lettuce vs Redisson
Redis 클라이언트로 Lettuce와 Redisson이 존재한다.
Lettuce
Spring Data Redis를 사용하면 기본적으로 지원하는 클라이언트는 Lettuce라 좀 더 사용하기 편함.
하지만 Lettuce로 분산락을 구현하려면 스핀락의 형태로 구현해야하는 단점이 있음.
락이 이미 사용중이면 락을 획득하기 위해 계속해서 SETNX(SET if Not eXists)라는 명령으로 Redis에 요청을 보낸다.
*SETNX : 특정 키값이 존재하지 않을 경우에 set하라는 명령어. 특정 키에 대해 SETNX명령을 사용하면 값이 없을 때만 세팅하는 , 락을 획득하는 효과를 낼 수 있음.
단점
1. 당연히 Redis에 많은 부하를 가하게 한다. 이를 방지하기 위해 Thread.sleep을 통해 락 획득 요청 사이마다 부하를 줄여줘야하고 sleep을 통해 줄여줘도 많은 부하가 감.
2. 스핀락으로 구현하게 될 경우 락의 타임아웃 구현이 자체적으로 존재하지 않기 때문에 코드상에서 직접 구현해야한다.
-> 구현 안 해주면 락을 영원히 반환 안 할 수도, 락을 획득하지 못해 무한루프를 돌 수도 있음.
Redisson
Redisson은 Lettuce, Jedis와 달리 RLock이라는 Lock 전용 객체 제공.
Redisson은 Lock에 타임아웃을 명시하여 무한정 대기상태로 빠질 수 있는 위험이 없음.
또한 스핀락(Spin Lock)을 사용하지 않고 pub sub 기능을 사용.
- 락이 해제되면 락을 subscribe(구독)하는 클라이언트들에게 채널로 락이 해제되었다는 신호를 보냄
- 따라서 락을 subscribe하는 클라이언트들은 락 획득 요청을 redis로 보내지 않고 해제 신호를 받을 때 락을 시도함. 따라서 별도의 retry 로직이 필요없음.
Redisson을 이용하면 Lettuce의 단점이었던 부하와 타임아웃을 해결할 수 있다.
Redis는 메세지 브로커의 역할을 할 수 있다. (메세지에 대한 publish와 subscribe기능을 지원)
Redisson은 이 기능을 통해 락을 획득 / 해제 하는 로직을 구현하고 있다.
*주의 : leaseTime을 잘못 잡으면 작업 도중 Lock이 해제될 수도 있음. => IllgalMonitorStateException
* 동작 원리 : 동시에 5개의 스레드가 락 획득을 위해 경합하는 상황
1. 스레드 1이 락을 획득하고 로직을 처리.
2. 나머지 2,3,4,5 스레드는 락 획득을 위해 특정 채널을 subscribe하고 있음.
3. 스레드 1의 로직 처리가 완료되면 락 해제.
4. 해제되었다는 메세지를 대기 스레드들이 subscribe하고있는 채널에 publish.
5. 대기 스레드 2,3,4,5 중 하나가 락을 획득하고 위 과정 반복.
-> 이를 통해 키 획득을 위해 계속해서 요청을 보내지 않는 부하 측면에서의 장점이 있음.
또, Redisson에서 제공하는 lock 관련 기능은 락의 타임아웃도 구현해놨음.
유일한 단점은 Lettuce보다 사용이 어렵다는 것. 그래도 해보겠음
Redisson을 활용한 분산 락 구현
1. 의존성 설정
이미 나는 refresh token에 레디스를 사용하고 있어서 있지만 Spring Data Redis는 기본 클라이언트로 Lettuce를 사용하기 때문에 Redisson 추가적인 의존성을 추가해줘야함.
기존 있던 거
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
변경 redisson-spring-boot-starter은 Spring Data Redis의 기능들을 포함하고 있기 때문에 위에 거 없애도 됨.
https://github.com/redisson/redisson/blob/master/redisson-spring-data/README.md
위 링크를 참조해 버전을 맞춰준다.
//Redisson
implementation("org.redisson:redisson-spring-boot-starter:3.27.2")
2. RedissonClient 빈 등록
@Configuration
public class RedissonRepositoryConfig {
@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() {
Config config = new Config();
config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
return Redisson.create(config);
}
}
락을 사용하는 부분들은 락 획득 - 비즈니스 로직 - 락 해제가 반복됨.
getLock(); //락 획득
try {
//비즈니스 로직
} finally {
//락 해제
}
그래서 이 부분은 AOP의 @Around로 해결하면 좋을 것 같음 !
Around는 ProceedingJoinPoint.proceed()를 제공해서 proceed 실행 전 / 후에 동작할 로직을 작성할 수 있기 때문이다 !
분산락 관련 기능은 AOP로 작성 !
3. 커스텀 어노테이션 RedissonLock 생성
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RedissonLock {
long waitTime() default 5000; // Lock 획득을 시도하는 최대시간(ms)
long leaseTime() default 5000L; //Lock 획득 후 점유하는 최대시간(ms)
}
4. AOP가 적용될 FriendController의 friendRequest() 메소드에 커스텀 어노테이션 붙여줌
@PostMapping("/request")
@RedissonLock
public ResponseEntity<FriendRequest.Response> friendRequest(
@RequestBody FriendRequest.Request request,
@AuthenticationPrincipal UserAdapter userAdapter) {
return ResponseEntity.ok(friendService.request(request, userAdapter));
}
5. LockService 클래스 생성 (lock(), unlock() 메소드)
@Slf4j
@Service
@RequiredArgsConstructor
public class LockService {
private final RedissonClient redissonClient;
public void lock(String email) {
RLock lock = redissonClient.getLock(email);
log.debug("Trying lock for email : {}", email);
try {
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
if (!isLock) {
log.error("==== Lock acquisition failed ====");
throw new FriendException(ErrorType.FRIEND_REQUEST_LOCK);
}
} catch (FriendException e) {
throw e;
} catch (Exception e) {
log.error("Redis lock failed", e);
}
}
public void unlock(String email) {
log.debug("Unlock for email : {}", email);
redissonClient.getLock(email).unlock();
}
}
- Redis에 존재하는 RLock 객체로 락을 컨트롤한다.
RLock을 얻기 위해서는 RedissonClient.getLock() 메소드를 호출해야함.
+ RedissonClient는 getSpinLock을 통해 Lettuce에서 나왔던 스핀락을 얻을 수도 있음.
재시도 로직은 Redisson에서 알아서 처리하므로 직접 구현해 줄 필요 없다.
Lock 획득 시도, Lock 획득 성공 후 처리, Lock 획득 실패 시 로직만 구현해주면 된다.
* 여기서 Lock의 키 값을 뭘로 해줘야 할 지에 대해 고민이 생겼다.
FriendRequest.Request에는 email 필드가 있고 이 이메일은 요청을 걸 대상 (A->B 의 경우 B)의 이메일이 넘어온다. 이 값을 키 값으로 했을 경우 B->A 요청에서 A의 이메일이 넘어왔을 때는 락을 얻지 못 해도 아무런 제약이 없을 것이다.
락의 키 값으로 구분해서 락을 얻는 건 줄 ? 알았는데 흠 . . 그냥 그 메소드에 걸려있는 거니까 상관이 없나 ????
6. LockAopAspect 클래스 생성
@Slf4j
@RequiredArgsConstructor
@Aspect
@Component
public class LockAopAspect {
private final RedissonClient redissonClient;
private final LockService lockService;
@Around("@annotation(com.soeun.GiftFunding.aop.RedissonLock) && args(request)")
public Object aroundMethod(
ProceedingJoinPoint pjp,
FriendRequest.Request request
) throws Throwable {
lockService.lock(request.getEmail());
try {
return pjp.proceed();
} finally {
lockService.unlock(request.getEmail());
}
}
}
어노테이션이 붙은 friendRequest() 메소드 실행 전/후에 락을 획득하고 해제하는 로직이 @Around를 통해 가능해진다.
@Aspect : Aspect 역할을 할 클래스 선언
@Component : 해당 Aspect를 스프링 Bean으로 등록해서 사용하기 위해 선언
@Around : Advice 어노테이션으로 괄호 안의 전 후에 처리할 로직을 만들어줘야 함.
=> 어떤 지점에서 Aspect를 발생시킬 것인지 Pointcut을 지정해줘야한다.
* Around Advice는 ProceedingJoinPoint를 파라미터로 받아 실행할 메소드의 시점을 조절하거나, 각종 정보들을 받아올 수 있음.
- @annotation(~~) : Around 괄호 안에 만든 어노테이션을 패키지 이름까지 넣어줌.
커스텀 어노테이션이 붙은 메소드의 전, 후에 @Around 가 붙은 메소드가 실행됨.
- args(request) : request를 인자로 가져와서 UseBalance, CancelBalance의 request에서 email을 가져올 수 있도록 함.
pjp.proceed() : AOP를 걸어줬던 부분 (@RedissonLock이 걸린 부분) 을 동작시킴.
finally로 AOP 걸어준 부분이 성공하든 실패하든 Lock을 해제해서 반납하도록 함.
'프로젝트' 카테고리의 다른 글
GitHub Actions vs Jenkins (0) | 2024.04.23 |
---|---|
GiftFunding) TroubleShooting - Member API 컨트롤러 테스트 중 401 에러 (1) | 2024.04.18 |
GiftFunding) Service 테스트 코드 작성하기 (0) | 2024.04.17 |
GiftFunding) RestDocs + Swagger 적용하기(feat. Controller 테스트 코드 작성) (0) | 2024.04.13 |
GiftFunding) 인증이 필요한 컨트롤러 메소드에 대해 @WithMockUser로 테스트 (1) | 2024.04.08 |