말로만 듣던 동시성 문제 따닥을 직접 마주할 줄이야. 아래는 해결과정을 글로 남겨보았다.
분산락 Code
BackEnd/src/main/java/com/api/ttoklip/domain/aop/filtering/SignupDistributedLockAspect.java at main · ttoklip/BackEnd
사회초년생의 똑! 부러지는 독립을 위한 커뮤니티 플랫폼, 똑립 Backend 레포 🌳. Contribute to ttoklip/BackEnd development by creating an account on GitHub.
github.com
회원이 로그인이 안된다는 제보를 받았다.
데이터를 확인해보니 같은 회원이 0.000007초 차이로 저장된 것을 확인할 수 있다.
우리는 email로 회원을 구별하는데 같은 이메일을 가진 회원이 있어 예외가 발생함을 알았다.
같은 이메일로 존재하는지 아래와 같이 validation을 하고 있는데 어떻게 중복으로 저장이 된걸까?
public OAuthLoginResponse authenticate(final OAuthLoginRequest request) {
String provider = request.getProvider();
String accessToken = request.getAccessToken();
OAuth2UserInfo userInfo = oAuth2UserInfoFactory.getUserInfo(provider, accessToken);
String email = userInfo.getEmail();
Optional<Member> memberOptional = memberService.findByEmailOptional(email);
if (memberOptional.isPresent()) {
// 이미 우리 회원일 때
Member member = memberOptional.get();
return alreadyOurUser(member);
}
Member member = registerNewMember(userInfo, provider);
return getLoginResponse(member, true);
}
정말 동시에 들어온다면? 얘기가 달라진다. validation이 동작하기도 전에 서로 다른 스레드에서 회원가입을 동시에 진행할 수도 있다.
멀티스레드 환경이나 분산 시스템에서 동시성 이슈가 발생할 수 있으며 동일한 데이터가 거의 동시에 저장되는 상황이 나타날 수있다.
이를 해결하기 위해 몇가지 방법이 존재한다.
- DB에 Email을 UNIQUE key를 사용하여 해결한다.
- 낙관적 락을 사용한다.
- 트랜잭션 격리수준을 조절한다.
- 비관적 락을 사용한다.
- 분산락을 사용한다.
등등..
위 방법 중 난 자원을 가장 덜 소모하는 5번 분산 락을 선택했다.
자원이 가장 덜 소모되기도 하지만 DB에서 이러한 로직들에 관여하는 현상을 빼고 싶었다.
- UNIQUE KEY를 사용하지 않는 이유:
- 예외 처리 비용: UNIQUE 제약 위반 시 발생하는 예외를 처리하는 과정에서 불필요한 데이터베이스 자원 소모가 발생하며 성능 저하를 일으킬 수 있다.
- 비즈니스 로직의 복잡성: 중복 요청을 처리하기 위한 예외 로직을 추가하면 코드가 복잡해지고 유지보수가 어려워질 수 있다.
- 낙관적 락을 사용하지 않은 이유:
- Version 필드를 추가해야 하고 DB와의 커넥션까지 엮이는 것을 피하고 싶었다.
- 트랜잭션 격리 수준 조절을 사용하지 않은 이유:
- 트랜잭션 격리 수준을 높이면 동시성 문제는 해결되지만 성능이 저하되고 트랜잭션 처리 시간이 늘어나 전체 시스템의 효율성이 떨어질 수 있다.
- 비관적 락을 사용하지 않은 이유:
- 비관적 락은 자원 잠금을 통해 동시성 문제를 해결하지만 락을 걸고 해제하는 과정에서 성능이 저하되고 특히 대기 시간이 길어질 수 있어 시스템 자원에 부하를 줄 수 있다.
그래서 분산락을 처음에 아래와 같이 적용해보았다.
문제 1. 락 대기시간으로 인한 분산락 적용 안됨
현재 회원가입 로직에서 동시성 문제를 해결하기 위해 분산 락을 적용하였다.
동일한 이메일로 여러 회원가입 요청이 거의 동시에 들어오는 경우 중복 처리가 발생할 수 있기 때문에 이를 방지하기 위해 이메일을 기반으로 한 락 키를 생성하여 Redisson을 사용한 분산 락을 도입했다.
위 코드에서 볼 수 있듯이 AuthController는 email 자체를 로그인할 때 받지만 OAuthController는 안드로이드로부터 OAuth accessToken SDK를 갖고 있는 OAuthLoginRequest를 인자로 받기 때문에 아래와 같이 작성했다.
@Aspect
@Component
@RequiredArgsConstructor
public class SignupDistributedLockAspect {
private final RedissonClient redissonClient;
private static final Logger logger = LoggerFactory.getLogger(DistributedLockAspect.class);
private static final String LOCAL_SIGNUP_KEY_PREFIX = "local_signup:";
private static final String OAUTH_SIGNUP_KEY_PREFIX = "oauth_signup:";
@Pointcut("execution(* com.api.ttoklip.global.security.auth.controller.AuthController.signup(..))")
private void localSignupMethodPointcut() {
}
@Pointcut("execution(* com.api.ttoklip.global.security.oauth2.controller.OAuthController.login(..))")
private void oauthSignupMethodPointcut() {
}
@Around("localSignupMethodPointcut() || oauthSignupMethodPointcut()")
public Object lockSignupMethod(ProceedingJoinPoint joinPoint) throws Throwable {
// 회원가입 시 이메일을 기반으로 락 키 생성
String lockKey = generateLockKey(joinPoint.getArgs());
RLock lock = redissonClient.getLock(lockKey);
boolean locked = false;
try {
logger.info("flag 0.1. Trying to lock for key: {}", lockKey);
locked = lock.tryLock(40, 20, TimeUnit.SECONDS);
logger.info("flag 0.2. Lock acquired: {}", locked);
if (!locked) {
logger.warn("flag1. 락이 걸려있어요. 락 키: {}", lockKey);
throw new ApiException(ErrorType.DUPLICATED_LOCAL_SIGNUP_REQUEST);
}
return joinPoint.proceed();
} finally {
releaseLockIfHeld(lock, locked);
}
}
// 락 키 생성 메서드 (Email을 기반으로 Prefix 추가)
private String generateLockKey(Object[] args) {
return Arrays.stream(args)
.map(arg -> {
if (arg instanceof AuthRequest) {
String email = ((AuthRequest) arg).getEmail();
return getLockKey(email, LOCAL_SIGNUP_KEY_PREFIX);
}
if (arg instanceof OAuthLoginRequest) {
// OAuth2UserInfo에서 AccessToken을 가져와 20글자로 줄이고 암호화
String accessToken = ((OAuthLoginRequest) arg).getAccessToken()
String encryptedToken = encryptToken(accessToken);
return getLockKey(encryptedToken, OAUTH_SIGNUP_KEY_PREFIX);
}
})
.filter(Objects::nonNull)
.findFirst()
.orElseThrow(() -> new ApiException(ErrorType.INVALID_METHOD));
}
private String getLockKey(String uniqueKey, String prefix) {
if (uniqueKey == null) {
throw new ApiException(ErrorType.INVALID_UNIQUE_KEY_TYPE);
}
return prefix + uniqueKey;
}
private String encryptToken(String token) {
// SHA-256 해시 적용하여 20글자로 자르기
String hashedToken = TokenHasher.hashToken(token, 20);
log.info("encryptToken() hashedToken = {}", hashedToken);
return hashedToken;
}
// 락 해제 메서드
private void releaseLockIfHeld(RLock lock, boolean lockAcquired) {
if (lockAcquired && lock.isHeldByCurrentThread()) {
logger.info("flag3. Releasing lock for key: {}", lock.getName());
lock.unlock();
}
}
}
처음에는 위 코드처럼 Redisson을 사용해 분산 락을 적용하려 했을 때 락을 획득하지 못하고 중복된 회원가입 요청이 처리되는 문제가 발생했다.
위 코드에서는 회원가입 시 이메일을 기반으로 락 키를 생성하여 해당 이메일로는 동시에 한 번의 요청만 처리되도록 하였다.
lock.tryLock() 메서드를 통해 40초 동안 락을 시도하고 락을 성공적으로 획득하면 20초 동안 유지하도록 설정했다.
만약 락을 획득하지 못하면 중복 요청이 발생했다고 판단하여 예외를 발생시켜 중복 처리를 방지하는 구조였다.
그러나 이 로직에는 한 가지 문제가 있었다.
락을 획득하기 위한 대기 시간이 3초로 설정되어 있었고 만약 3초 이내에 첫 번째 요청이 처리되어 락이 해제된다면, 두 번째 요청이 다시 락을 획득하고 회원가입이 진행될 수 있었다.
즉, 락 대기 시간이 설정된 동안 첫 번째 요청이 완료되면 이후의 중복 요청을 막지 못하게 되는 상황이 발생한 것이다.
문제 1. 해결방안
락 획득 대기 시간을 0으로 설정하여 동시성 요청에서 최초의 요청만 처리하고 나머지 요청은 모두 중복 요청으로 간주하여 예외를 발생시키는 방식으로 로직을 수정했다.
boolean lockAcquired = lock.tryLock(0, 3, TimeUnit.SECONDS);
Local 회원가입 분산락 적용 성공
문제 2. OAuth Pointcut이 authenticate()일때
문제 2-1. 모든 OAuth Login Redis 사용 문제
OAuth 회원가입 로직에서 처음에는 authenticate() 메서드에 분산락을 적용하였다.
그러나 이렇게 했을 때 모든 OAuth 로그인 요청이 Redis를 거치게 되는 문제가 발생했다. 즉 모든 OAuth 로그인을 시도하는 이메일에 대해 불필요하게 Redis 락을 확인하는 과정이 추가되었고 이는 비효율적이다.
이 문제를 해결하기 위해 OAuth 회원가입 로직에서 실제로 회원을 등록하는 시점에만 락을 적용하기로 하였다.
그래서 registerMember() 메서드에 포인트컷을 적용해봤다. 이 메서드는 OAuth 로그인 중 이미 회원이 아닌 경우에만 호출되기 때문에 실제 회원가입이 일어나는 상황에서만 락을 걸도록 하여 Redis 사용을 최소화해야한다.
문제 2-2. OAuth 토큰이 만료되었을 때 발생하는 문제
처음에 authenticate() 메서드에 분산락을 적용했을 때 OAuth 토큰이 만료된 경우에도 문제가 발생했다.
authenticate() 메서드를 가로채면서 실제로 토큰이 유효한지를 판단하기 전에 Redis에 락 키를 저장하는 문제가 발생한 것이다.
즉, accessToken을 사용하여 Redis 락 키를 생성하고 분산락을 적용하는데 이 과정이 토큰의 유효성 검증보다 먼저 일어나게 된다. 따라서 실제로 토큰이 만료되어야 하는 상황에서 잘못된 예외가 발생하게 된다. 원래는 토큰 만료 예외가 발생해야 하지만 오히려 중복된 회원가입이라는 잘못된 예외가 반환되는 것이다.
문제 2. 해결방안 pointcut 변경
Redis 락을 registerMember() 메서드에만 적용하기로 변경한다.
이 메서드는 OAuth 인증이 성공한 후 실제로 회원가입이 필요한 경우에만 호출된다.
이렇게 하면 accessToken의 유효성을 먼저 검증한 후 회원가입이 필요할 때만 분산락이 적용되므로 잘못된 예외가 발생하지 않는다.
따라서 authenticate() 메서드에서는 토큰 유효성 검증 및 회원 여부 확인 로직만 수행하고 회원가입이 필요한 경우에만 registerMember()에서 분산락을 적용하는 방식으로 로직을 수정했다.
아래와 같이 포인트컷 및 uniqueKey 추출 수정했다.
public class SignupDistributedLockAspect {
// .. Local Signup Pointcut 기존과 동일
// 포인트컷 변경
@Pointcut("execution(* com.api.ttoklip.global.security.oauth2.service.OAuthService.registerMember(..))")
private void oauthSignupMethodPointcut() {
}
// 기존과 동일
@Around("localSignupMethodPointcut() || oauthSignupMethodPointcut()")
public Object lockSignupMethod(ProceedingJoinPoint joinPoint) throws Throwable {}
// Token이 아닌 Email로 변경
private String generateLockKey(Object[] args) {
return Arrays.stream(args)
.map(arg -> {
if (arg instanceof AuthRequest) {
String email = ((AuthRequest) arg).getEmail();
return getLockKey(email, LOCAL_SIGNUP_KEY_PREFIX);
}
if (arg instanceof OAuth2UserInfo) {
String email = ((OAuth2UserInfo) arg).getEmail();
return getLockKey(email, OAUTH_SIGNUP_KEY_PREFIX);
}
return null;
})
.filter(Objects::nonNull)
.findFirst()
.orElseThrow(() -> new ApiException(ErrorType.INVALID_METHOD));
}
private String getLockKey(String email, String prefix) {
if (email == null) {
throw new ApiException(ErrorType.INVALID_EMAIL_KEY_TYPE);
}
return prefix + email;
}
// 기존과 동일
private void releaseLockIfHeld(RLock lock, boolean lockAcquired) {}
}
registerMember로 포인트컷을 이동 후 키를 생성하는 generateLockKey() 메서드 또한 더 깔끔해졌다. 이것으로 회원가입이 아닌 모든 요청에 대해서 redis를 거치는 것은 수정했다.
문제 3. OAuth 회원가입에 여전히 분산락 적용되지 않음
registerMember() 메서드로 포인트컷을 이동하고, 락 키를 이메일을 기반으로 생성하는 방식으로 로직을 수정하였다.
이를 통해 회원가입 시점에만 Redis 락을 적용하여 모든 OAuth 로그인 요청에서 Redis를 거치지 않도록 하는 비효율을 해결할 수 있었다.
그러나 이 방식으로도 분산락이 정상적으로 동작하지 않는 문제가 여전히 존재했다.
실제 테스트를 진행한 결과 중복된 회원가입 요청이 다시 발생하였다.
이는 self-invocation 문제로 인해 발생한 것이었다.
Spring AOP는 프록시 기반으로 동작하는데 같은 클래스 내에서 메서드를 호출할 경우 프록시를 거치지 않기 때문에 AOP가 적용되지 않게 된다.
즉 authenticate() 메서드에서 같은 클래스 내의 registerMember() 메서드를 호출하는 방식으로 인해 AOP를 통한 분산락이 동작하지 않았던 것이다.
결과적으로 self-invocation 문제가 발생하여 OAuth 회원가입에서 분산락을 제대로 적용하지 못하게 되었다.
문제 3. 해결방안 self-invocation 문제 해결
문제의 원인은 self-invocation에 있었다. Spring AOP는 프록시 기반으로 동작하는데 같은 클래스 내에서 메서드를 호출하면 프록시를 거치지 않아 AOP가 적용되지 않는다.
구체적으로 authenticate() 메서드에서 registerMember() 메서드를 같은 클래스 내에서 호출하면서 AOP가 적용되지 않아 분산락이 정상적으로 동작하지 않았다. 이로 인해 중복 회원가입 요청이 처리되는 문제가 발생했다.
해결 방안
registerMember() 메서드를 별도의 클래스로 분리하여 self-invocation을 피했다. 분리된 클래스에서 분산락을 적용해 AOP 프록시가 정상적으로 동작하도록 변경했다.
public class SignupDistributedLockAspect {
@Pointcut("execution(* com.api.ttoklip.global.security.oauth2.service.OAuthRegisterService.registerNewMember(..))")
private void oauthSignupMethodPointcut() {
}
}
분리한 후 토큰이 잘못되었을 때 아래와 같이 잘못된 Token이라는 올바른 예외 반환
동시에 여러 요청이 들어왔을 경우 분산락을 통한 중복 회원가입 해결
OAuthService 클래스는 OAuthRegisterService를 호출해 회원가입 처리를 하도록 수정했다.
멀티쓰레드 환경에서의 요청을 분산락으로 거르고, 그 과정에서 self-invocation 문제를 해결하여 분산락이 정상적으로 적용되며 중복 회원가입 요청을 막을 수 있었다.
'프로젝트 > 똑립' 카테고리의 다른 글
멀티모듈에서 AOP 사용시 주의점 (0) | 2024.11.15 |
---|---|
똑립 2. REQUIRES_NEW / TransactionalEventListener + Async 알림 문제 복기 (1) | 2024.09.04 |
똑립 1. 알림기능, 문제의 시작 (0) | 2024.04.20 |