문제 발견: 중복 회원가입 문제
회원가입 로직에는 중복 검증 로직이 존재하지만, 회원가입이 완료되기 전에 사용자가 버튼을 다시 누르면 중복으로 회원가입이 처리되는 문제가 발생한다. 이는 여러 요청이 동시에 처리되면서 데이터베이스의 동시성 제어가 제대로 작동하지 않기 때문이다. 이러한 문제는 데이터의 무결성을 훼손하고 리소스 낭비를 초래하며, 사용자 경험에도 악영향을 끼친다.
실제 서비스의 문제를 확인한 뒤 테스트 코드를 통해서 회원가입이 동시에 발생하는 환경을 만들어 주었을 때도 같은 오류가 나는 것을 확인할 수 있었다.
테스트는 동일한 회원가입 요청이 동시에 처리되는 상황을 시뮬레이션하고, 중복 회원가입이 방지되었는지 확인한다.
- threadCount = 2으로 2개의 동시 요청을 준비하고, 이를 병렬로 처리하기 위해 ExecutorService와 CountDownLatch를 사용한다.
- for 루프에서 각 스레드가 authService.signUp()을 호출하며 회원가입 요청을 보낸다. 요청이 중복되면 ConflictException이 발생하며 차단된다.
- 모든 요청이 완료된 후, userRepository.count()를 확인해 데이터베이스에 단 한 명의 사용자만 저장되었는지 검증한다.
문제 해결이 되기 전에는 userRepository.count()에서 2개의 사용자가 저장되어 테스트 코드를 통과하지 못하였다.
동시성 제어란 무엇인가?
동시성 제어는 여러 작업이 동시에 수행되는 환경에서 데이터의 무결성과 일관성을 유지하기 위해 필요한 개념이다. 애플리케이션에서 여러 사용자가 동시에 요청을 보낼 때, 각 요청이 서로 간섭하지 않도록 제어해야 한다. 이를 구현하지 않으면 데이터 무결성 및 애플리케이션 안정성에 큰 문제가 발생할 수 있다. 위와 같은 회원가입에서도 동시성 제어가 필요하지만 대표적으로 쿠폰 발급이나, 재고 소진과 같은 상황에서 동시성 제어가 필요하다.
이 문제를 어떻게 해결할 수 있는가?
동시성 제어를 통해 이러한 문제를 해결할 수 있다. 방법 중에는 synchronized와 락(lock)을 사용하는 방식이 있다.
synchronized는 메서드나 블록에 명시하여 하나의 스레드만 접근 가능하도록 만들어 주는 간단한 해결 방법이다. 이를 통해 한 번에 하나의 요청만 처리되도록 보장할 수 있다. 하지만 분산 환경에서는 synchronized가 의미가 없다. 이는 synchronized가 단일 JVM 내에서만 작동하기 때문에, 여러 컨테이너로 구성된 환경에서는 요청 간 동시성을 제어할 수 없기 때문이다. 현재 내가 진행 중인 서비스는 컨테이너가 여러 대로 구성된 다중 컨테이너 환경이므로, synchronized를 사용해도 해당 문제가 해결되지 않는다.
이러한 경우에는 락(lock)을 사용하는 것이 적합하다. 락을 사용하면 동일한 데이터에 대한 동시 접근을 효과적으로 제어할 수 있으며, 분산 환경에서도 적용 가능하다. 다음으로 락의 개념과 종류에 대해 자세히 알아보자.
락(Lock)이란?
락은 동시에 여러 트랜잭션이나 요청이 동일한 리소스에 접근하는 것을 방지하기 위해 사용된다. 락을 걸면 다른 트랜잭션이 해당 리소스를 사용하려는 시도를 차단하거나 대기시킨다. 이를 통해 데이터의 무결성을 보장할 수 있다. 락에는 크게 DB에서 제공하는 비관적 락과 애플리케이션에서 버전을 통한 낙관적 락이 있다.
비관적 락 (Pessimistic Lock)
비관적 락은 데이터에 대한 충돌이 자주 발생한다고 가정하고, 데이터에 접근하기 전에 락을 걸어 다른 트랜잭션이 동시에 접근하지 못하도록 차단하는 방식이다. 즉, 데이터의 일관성을 강하게 보장하지만, 성능에 영향을 미칠 수 있다.
비관적 락은 주로 SELECT ... FOR UPDATE와 같은 SQL 구문을 사용하여 구현할 수 있다. JPA에서는 @Lock 어노테이션과 LockModeType.PESSIMISTIC_WRITE를 사용하여 비관적 락을 적용할 수 있다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.id = :id")
Optional<User> findByIdWithPessimisticLock(@Param("id") Long id);
이 예제에서 PESSIMISTIC_WRITE 락 모드는 해당 데이터에 쓰기 작업을 수행하는 동안 다른 트랜잭션이 접근하지 못하도록 한다. (이 외에도 LockModeType이 더 있지만 여기서는 다루지 않겠다.) 이를 통해 동시성 문제를 효과적으로 방지할 수 있지만, 데드락(Deadlock)이나 성능 저하와 같은 부작용이 발생할 수 있다.
낙관적 락 (Optimistic Lock)
낙관적 락은 데이터 충돌 가능성이 낮다고 가정하고, 데이터 접근 시 락을 걸지 않는다. 대신 트랜잭션이 완료되기 전에 데이터의 변경 여부를 확인하여 충돌이 발생했는지 검사한다. 충돌이 발생하면 예외를 던지고 트랜잭션을 재시도한다.
낙관적 락은 주로 데이터에 버전 정보를 추가하여 구현한다. JPA에서는 @Version 어노테이션을 사용하여 낙관적 락을 쉽게 구현할 수 있다.
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Version
private Integer version;
// 기타 필드 및 메서드 생략
}
이 예제에서 @Version 어노테이션은 엔티티의 버전 정보를 관리한다. 어노테이션이 붙은 필드는 엔티티를 수정할 때마다 버전이 하나씩 자동으로 증가한다. JPA는 트랜잭션이 종료될 때 자동으로 버전을 비교하여 변경 사항이 있는 경우 OptimisticLockException을 발생시킨다. 이를 통해 충돌을 감지하고 적절히 처리할 수 있다. (엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외가 발생한다.)
낙관적 락은 읽기 작업이 많고 데이터 충돌이 드문 환경에서 매우 효과적이다. 그러나 충돌이 잦은 환경에서는 성능이 저하될 수 있다.
JPA에서 비관적 락과 낙관적 락 비교
비관적 락은 데이터 충돌 가능성이 높은 환경에서 안전한 선택이다. 락을 통해 데이터를 확실히 보호하기 때문에 데이터 무결성을 보장한다. 하지만 성능 저하와 데드락 위험이 있으며, 동시에 처리해야 할 트랜잭션이 많을수록 효과가 떨어질 수 있다.
낙관적 락은 데이터 충돌 가능성이 낮고, 읽기 작업이 많은 환경에서 적합하다. 락을 사용하지 않기 때문에 성능이 더 뛰어나며 데드락 문제가 발생하지 않는다. 그러나 충돌이 발생하면 트랜잭션을 재시도해야 하므로, 재시도가 잦을 경우 성능이 저하될 수 있다.
분산 락이란?
위와 같은 한계를 극복하기 위해 분산 락(Distributed Lock)을 사용했다.
분산 락(Distributed Lock)은 여러 서버나 노드가 공유 자원에 동시에 접근하는 것을 제어하기 위한 메커니즘이다. 분산 시스템 환경에서는 단일 서버나 데이터베이스에 의존하지 않고, 중앙 관리 시스템(예: Redis, Zookeeper, Etcd)을 통해 락을 관리한다. 이를 통해 여러 컨테이너나 서버에서도 데이터 무결성과 일관성을 유지할 수 있다.
동작 원리
분산 락은 다음과 같은 절차로 동작한다.
- 락 생성: 자원에 접근하려는 클라이언트는 락 서버(Redis, Zookeeper 등)에 특정 키를 생성하거나 등록하여 락을 요청한다. 이때 락은 유효 시간(TTL)과 함께 생성된다.
- 락 획득: 락 서버는 해당 자원이 이미 다른 클라이언트에 의해 점유 중인지 확인한다. 자원이 점유되지 않았다면 락을 획득하고, 점유 중이라면 대기하거나 실패를 반환한다.
- 자원 접근: 락을 획득한 클라이언트만 자원에 접근할 수 있다. 이를 통해 동시 접근으로 인한 충돌을 방지한다.
- 락 해제: 작업이 완료되면 락을 해제한다. 만약 클라이언트가 비정상적으로 종료되더라도 TTL을 통해 락이 자동으로 해제된다.
분산 락은 데이터 무결성과 일관성을 보장하며, 여러 서버나 컨테이너에서도 안전한 동시성 제어가 가능하다. TTL 설정으로 데드락을 방지하고, 수평적 확장이 필요한 환경에서도 유연하게 동작한다. (물론, 락 관리로 인한 복잡성과 성능 저하가 있을 수 있으며, 중앙 관리 시스템 의존성이 장애 시 문제가 될 수 있다. 설정 미흡 시 데드락이나 자원 낭비가 발생할 가능성도 있다.)
Redis 분산 락
Redis는 인메모리 기반으로 동작하여 속도가 빠르고, SET NX 명령어를 통해 락 생성과 TTL 설정을 간단하게 처리할 수 있다는 장점이 있다. 그리고 이미 나는 이 프로젝트에서 Redis를 사용하고 있기 때문에, 추가적인 도구를 도입하지 않고 Redis를 활용해 분산 락을 구현하게 되었다.
Redis로 구현하는 클라이언트 라이브러리의 종류에는 Jedis, Redisson, Lettuce가 있다. 이들은 각기 다른 특징과 장단점이 있다. Jedis는 간단하고 직관적인 사용법으로 적합하지만 동기 방식에 한정되어 성능이 좀 낮다 그래서 요즘엔 잘 안 쓰는 편인 것 같다. (이 블로그에서 성능 테스트에 관해서 아주 잘 다룬 것 같으니 참고해도 좋을 것 같다. 참고 블로그)
Lettuce는 고성능과 유연성을 장점이지만, 직접 구현해야 하는 부분이 많다는 점에서 추가적인 노력이 필요하다.
Redisson은 Lock Interface를 통해 락을 구현할 수 있어 사용이 편리하며, 완성도 높은 기능을 제공한다. Lettuce로 스핀락을 구현할 때 발생하는 CPU 리소스 낭비와 Redis 부하 문제에 비해, Redisson은 Pub/Sub 방식을 이용해 효율적으로 동작한다. Pub/Sub 방식을 활용하면 락 해제 이벤트를 기다릴 때 스레드를 차단(Blocking) 상태로 두며, Busy-Wait 상태 없이 락 획득을 처리하며, 이를 통해 CPU 리소스 낭비를 방지하고 Redis에 과도한 요청을 보내지 않아 성능이 더 뛰어나다.
사실, 위와 같은 이유로 처음에는 Redisson을 사용하려고 했었다.. But.. 우리 서비스는 이미 Redis Lettuce를 사용하고 있었고, 해당 코드는 내가 아닌 다른 팀원이 구현한 부분이었다. 또한, 현재 서비스 규모가 매우 크지 않아 Redis Lettuce로도 충분히 요구사항을 충족할 수 있다고 판단했다. 따라서 Redisson을 새로 도입하기보다는 기존 Lettuce를 활용해 스핀락 방식으로 동시성 제어를 구현하는 것이 리소스를 더 적게 소모하는 현실적인 선택이라고 생각했다. (다음 프로젝트를 새롭게 하게 된다면 Redisson을 써보고 블로그를 적어야겠다.. 우선 잘 설명되어 있는 블로그 링크를 걸어두겠다,, Redission 관련 블로그)
이처럼 각 프로젝트의 요구사항에 따라 적합한 클라이언트를 선택하는 것이 중요하니 각자의 상황에 맞게 고르면 된다.
Redis Lettuce 코드 예제
그렇다면 코드를 보면서 진행해보겠다.
Redis Lettuce를 사용하려면 우선 의존성을 추가하고 Config를 작성해야 한다. 하지만 이미 기존에 사용 중이었기 때문에 예시 코드로 대체하겠다. Redis를 단일 서버 환경에서 사용하려면 아래와 같이 간단하게 작성할 수 있다. 하지만 클러스터 환경에서는 비밀번호 설정이나 커넥션 풀 설정과 같은 추가 구성이 필요하다.
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String host;
@Value("${spring.data.redis.port}")
private int port;
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port));
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
Redis 락을 관리하는 RedisLockManager 클래스이다. StringRedisTemplate을 사용해 락을 획득(acquireLock)하고 해제(releaseLock)하는 기능을 제공한다.
acquireLock은 주어진 키와 타입으로 락을 설정하며, TTL(만료 시간)을 설정해 락이 자동으로 해제되도록 한다. releaseLock은 해당 키를 삭제해 락을 해제한다. 이를 통해 Redis를 활용한 기본적인 락 관리 로직을 간단하게 구현할 수 있다.
@Component
public class RedisLockManager {
private final StringRedisTemplate redisTemplate;
public RedisLockManager(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public Boolean acquireLock(String key, String lockType, long timeout) {
return redisTemplate.opsForValue().setIfAbsent(key, lockType, Duration.ofSeconds(timeout));
}
public void releaseLock(String key) {
redisTemplate.delete(key);
}
}
여기까지 하면 락 관리 기능이 구현된 상태이다. 이제 이 기능을 어디서 어떻게 사용할지 구체적으로 적용하면 된다. 보통 비즈니스 로직에 락을 적용해 데이터 충돌을 방지하고 동시성 문제를 해결하기 위해 서비스 레이어에서 사용한다. 그래서 다음과 같은 코드를 서비스 레이어에 작성했다.
이 코드는 lettuceSignUp 메서드를 통해 사용자 회원가입을 처리하면서 Redis 락을 사용해 동시성을 제어하는 로직이다. token은 각 사용자를 고유하게 식별할 수 있는 값이기 때문에 이를 기준으로 Redis 락을 시도한다. 락이 이미 점유 중일 경우 acquireLock 메서드가 false를 반환하므로, 락을 획득할 때까지 반복적으로 재시도한다. 락을 성공적으로 획득한 경우 회원가입 로직인 signUp 메서드를 호출해 작업을 수행하며, 작업이 완료된 후에는 releaseLock 메서드를 호출해 락을 해제한다. token을 기준으로 락을 설정함으로써 동일 사용자가 동시에 여러 요청을 보내더라도 중복 회원가입이 발생하지 않도록 동시성을 관리한다.
public UserJwtInfoRes lettuceSignUp(final String token, final UserSignUpReq userSignUpReq, @Nullable final MultipartFile image, final List<DateTagType> tag) {
while (!redisLockManager.acquireLock(token, Constants.SIGN_UP_LOCK_TYPE, 10L)) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new DateRoadException(FailureCode.REDIS_LOCK_ERROR);
}
}
try {
return signUp(token, userSignUpReq, image, tag);
} finally {
redisLockManager.releaseLock(token);
}
}
TTL 10L의 이유
Redis 락의 TTL을 10초로 설정한 이유는 실제 배포된 서비스에서 회원가입 처리 시간을 테스트한 결과에 근거한다. 테스트 시 사진이 없는 경우 회원가입 처리는 평균 7.46초가 소요되었으며, 핸드폰 평균 사진 크기(약 2MB)의 이미지를 첨부했을 경우 평균 9.79초가 소요되었다. 이를 기반으로, TTL을 10초로 설정해 대부분의 회원가입 요청이 완료되는 시간을 충분히 커버하도록 하였다. 이를 통해 TTL 만료로 락이 자동 해제되는 상황을 방지하며, 안정적으로 회원가입 동시성을 관리할 수 있다.
코드 개선
하고 나니 다음과 같은 두 개의 경고가 있었다.
- Call transactional methods via an injected dependency instead of directly via 'this': 이 경고는 트랜잭션 메서드를 동일 클래스 내에서 this를 통해 호출할 경우 발생한다. 이는 트랜잭션 프록시가 제대로 적용되지 않아 트랜잭션 관리가 기대한 대로 동작하지 않을 수 있음을 경고하는 것이다. 따라서 Controller와 Service 사이에 Facade 계층을 추가하여 이 문제를 해결했다.
- Use a primitive boolean expression here.: 원시 타입을 사용하면 불필요한 박싱과 언박싱을 줄일 수 있으며, NullPointerException 가능성도 제거할 수 있다. boolean lockAcquired = redisLockManager.acquireLock(token, Constants.SIGN_UP_LOCK_TYPE, 10L); 를 추가해 이를 해결했다.
마지막으로 스핀락이 신경 쓰였다. 사실 일반적으로 동시성 제어가 사용되는 선착순 쿠폰 발급이나 재고 관리, 대기열 같은 로직에서는 while 문을 통해 계속적으로 락을 시도하는 방식이 적합하다고 생각한다. 하지만 회원가입의 경우, 동시에 여러 사용자가 버튼을 눌러도 결국 회원가입이 성공하는 사람은 1명뿐이면 충분하다. 그럼에도 불구하고, while 문을 사용해 락을 획득하려는 시도를 회원가입 이후의 로직까지 계속 반복하는 것은 비효율적이라는 의문이 들었다.
따라서 while 문 대신 if 문으로 변경하여 락을 단 한 번만 시도하도록 수정했다. 이를 통해 불필요한 반복을 제거하고, 회원가입 로직을 더 효율적으로 처리할 수 있었다.
public UserJwtInfoRes lettuceSignUp(final String token, final UserSignUpReq userSignUpReq, @Nullable final MultipartFile image, final List<DateTagType> tag) {
boolean lockAcquired = redisLockManager.acquireLock(token, Constants.SIGN_UP_LOCK_TYPE, 10L);
if (!lockAcquired) {
throw new DateRoadException(FailureCode.REDIS_LOCK_ERROR);
} try {
return authService.signUp(token, userSignUpReq, image, tag);
} finally {
redisLockManager.releaseLock(token);
}
}
TestCode
그리고 실제 다음과 같은 테스트 코드를 작성해 동시성 제어가 잘 되는지 확인해 봤다.
@DisplayName("동일한 회원가입 요청이 동시에 들어올 경우 중복 회원가입이 방지된다.")
@Test
void preventDuplicateSignUp() throws InterruptedException {
// given
int threadCount = 10; // 동시 요청 개수
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
// when
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
authFacade.lettuceSignUp(testToken1, USER1_SIGN_UP_REQ, null, USER1_TAGS);
} catch (ConflictException e) {
log.error("회원가입 동시성 테스트 중 예외 발생: ", e);
} finally {
latch.countDown();
}
});
}
latch.await(); // 모든 요청이 완료될 때까지 대기
executorService.shutdown();
// then
assertThat(userRepository.count()).isEqualTo(1); // 중복 회원가입이 발생하지 않아야 함
}
while 문(스핀락)과 if 문을 각각 테스트했으며, 동시 요청 개수 100개를 기준으로 비교한 결과, 스핀락은 평균 7.228초가 소요된 반면, if 문은 평균 2.55초가 소요되었다. 두 방식 간 약 4.678초의 차이가 발생했다.
물론 동시 요청 개수가 10개일 때는 약 1.4초 정도의 차이로 크지 않았지만, 동시 요청의 개수가 커질수록 그 차이는 점점 더 커졌다. 이를 통해 if 문 방식이 요청 처리 시간 면에서 더 효율적임을 확인할 수 있었고, 동시에 동시성 제어 로직이 정상적으로 작동하는 것도 확인할 수 있었다.
추가로, 서로 다른 토큰으로 동시에 회원가입을 시도할 경우에도 시스템이 정상적으로 작동하는지 확인하기 위해 테스트 코드를 작성했다.
@DisplayName("서로 다른 토큰으로(사용자가) 동시에 회원가입 요청 시 모두 회원가입이 성공한다.")
@Test
void signUpWithDifferentTokensLatch() throws InterruptedException {
// given
int threadCount = 2; // 두 명의 동시 요청
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch startLatch = new CountDownLatch(1); // 요청 시작 신호
CountDownLatch latch = new CountDownLatch(threadCount); // 요청 완료 대기
// when
executorService.submit(() -> {
try {
startLatch.await(); // 모든 스레드가 시작 신호를 기다림
authFacade.lettuceSignUp(testToken1, USER1_SIGN_UP_REQ, null, USER1_TAGS);
} catch (Exception e) {
log.error("회원가입 동시성 테스트 중 예외 발생 (user1): ", e);
} finally {
latch.countDown();
}
});
executorService.submit(() -> {
try {
startLatch.await(); // 모든 스레드가 시작 신호를 기다림
authFacade.lettuceSignUp(testToken2, USER2_SIGN_UP_REQ, null, USER2_TAGS);
} catch (Exception e) {
log.error("회원가입 동시성 테스트 중 예외 발생 (user2): ", e);
} finally {
latch.countDown();
}
});
// 모든 요청이 동시에 시작되도록 신호를 줌
startLatch.countDown();
latch.await(); // 모든 요청이 완료될 때까지 대기
executorService.shutdown();
// then
assertThat(userRepository.count()).isEqualTo(2); // 두 명 모두 가입되어야 함
}
실제로 테스트 코드를 실행한 결과, Discord에 연동된 시스템에서 두 사용자가 정상적으로 생성되는 것을 확인할 수 있었다.
다만, 하나의 문제는 현재 가입자 수가 동일하게 계산된다는 점이다. 이 문제도 분산 락을 통해 해결할 수 있지만, 가입자 수는 단순히 참고용으로 사용되는 데이터이기 때문에 굳이 추가적인 리소스를 투입할 필요는 없다고 판단하여 그대로 두었다.
이 외에도, User가 정상적으로 생성되는지와 동시성 제어가 제대로 동작하는지를 확인하기 위해 테스트 코드를 작성하여 SignUp 서비스에 대한 테스트를 진행했다. 그 결과, JaCoCo를 통해 RedisLockManager 클래스는 100%의 코드 커버리지를 달성했으며, AuthService의 SignUp 메서드 또한 100% 커버리지를 기록했다ㅎㅎ😊
'Spring' 카테고리의 다른 글
[Spring/DB] 실제 운영 서버에서 ddl-auto update의 문제점과 Flyway 도입 (ddl-auto, Flyway) (0) | 2025.02.28 |
---|---|
[Spring] MDC + Logback을 활용한 Discord Webhook 연동하기(2) - Troubleshooting (1) | 2024.12.02 |
[Spring] MDC + Logback을 활용한 Discord Webhook 연동하기(1) (0) | 2024.11.19 |
[Spring] JPA 지연 로딩으로 인한 equals 비교 오류 해결 - Troubleshooting (0) | 2024.10.04 |