안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 관계형 데이터베이스에서 발생하는 동시성 문제를 해결하는 과정을 작성하려고 합니다.

 

다른 서비스들은 모두 동시성 문제를 해결하여 완성시켰지만, member-service 프로젝트는 동시성 문제를 해결하지 않았습니다.

따라서 리펙토링을 수행하며 동시성 문제가 해결되는 과정을 정리하고자 합니다.

 

 

1. 동시성 문제

 

동시성 문제는 가장 까다롭고 어려운 문제입니다. 성능과 안정성을 모두 고려해야 하는 난제로, 관계형 데이터베이스 혹은 인메모리 데이터베이스 등 다양한 분야에서 발생하는 문제입니다. 동시성 문제는 다수의 스레드가 동시에 공유 자원에 접근할 때 발생합니다. Read 모드로 접근하는 경우, 락의 상황에 따라 다르지만 일반적으로 동시성 문제가 발생하지 않는다고 판단합니다.

하지만 Write 모드로 데이터베이스에 접근할 때, 다수의 쓰레드가 동시에 Write 요청을 수행하면 원하지 않는 결과가 발생할 수 있습니다.

 

단일 Thread에서 요청을 수행할 경우, 만약 수량을 10개씩 3번 빼라는 요청이 오면 10개씩 정상적으로 수량이 감소합니다. 

하지만 멀티 쓰레드에서는 수량을 10개씩 3번 빼라는 요청이 오면 어떻게 될까요? 맨 오른쪽 그림과 같이 70이 나온다는 보장을 하기 어렵습니다. 동시에 스레드가 접근할 경우 제일 먼저 나온 100개라는 공유 자원에 동시에 접근할 수 있기 때문입니다. 따라서 A 스레드와 B 스레드는 동시에 100개 자원에 접근하게 되고 수량을 제거하고 90이라는 값을 리턴하게 됩니다. C 스레드는 A, B의 결과 이후에 수행된다고 가정하면 90이라는 값을 읽고 80을 리턴하게 됩니다.

 

 

만약, 선착순이나 한정된 개수만 판매해야하는 비즈니스 로직이 있다면, 혹은 단 하나의 유니크한 값만 가질  수 있는 제약조건이 있는 데이터베이스라면, 동시성 문제가 발생할 때 큰 장애가 일어날 수 있습니다. 따라서 이러한 문제는 Lock을 활용하거나, RedissonClient 등 다양한 방법을 통해 동시성 문제를 제어할 수 있습니다.

 

 

2. 동시성 발생 확인하기

 

먼저, 제가 작성했던 이전 코드는 Member 객체를 저장하는 일반적인 코드입니다.

 

@Getter
@RequiredArgsConstructor
@Entity
public class Member extends BaseEntity implements Persistable {

    @Id
    @Column(name = "member_id")
    private String id;
    
    --- 중략 ---
    
    @Builder
    public Member(String userId, String password, String registrationId, String registerId,
                  String email, String picture, String username) {
        this.id = UUID.randomUUID().toString();
        this.userId = userId;
    
    --- 중략 ---
    
     /**
     * Returns if the {@code Persistable} is new or was persisted already.
     *
     * @return if {@literal true} the object is new.
     */
    @Override
    public boolean isNew() {
        return super.getCreatedAt() == null;
    }
@Repository
public interface MemberRepository extends JpaRepository<Member, String> {

    Member findByEmail(String email);

    Optional<Member> findByRegisterId(String registerId);
    Optional<Member> findByUserId(String userId);
}

 

먼저 싱글 스레드 환경의 테스트에서는 동일한 email로 여러 번 계정 생성 요청이 오면 성공합니다.

 

@Test
@DisplayName("단일 스레드 save")
public void save_single() throws Exception {
    //given
    int result = 0;
    int count = 5;

    Member[] members = new Member[count];
    Member kose = Member.builder().email("kose@naver.com").password("1234").username("kose").build();

    //when
    for (int i = 0; i < count; i++) {
        try{
            Member member = memberRepository.findByEmail("kose@naver.com");

            if (member == null) members[i] = memberRepository.save(kose);
            else members[i] = null;

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    for (int i = 0; i < count; i++) if (members[i] != null) result++;

    //then
    assertThat(result).isEqualTo(1);

}

 

 

하지만, 아쉽게도 이 코드는 쓰레드에 세이프하지 않은 코드입니다. 멀티 스레드 테스트로 확인해 보겠습니다. 멀티 스레드를 구축하는 데는 다양한 방법이 있습니다. new Thread(){}를 선언하여 내부에 익명 클래스 형태 혹은 람다 형태로 스레드 환경을 만들 수 있습니다.

 

new Thread() {
    
}.start();

new Thread(() -> {
    
}).start();

 

저는 countDownLatch()를 활용하여 테스트 환경을 구축하였습니다.

 

int threadCount = 5;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);

//when
for (int i = 0; i < threadCount; i++) {
    int finalIdx = i;
    executorService.submit(() -> {
        try{

        } catch (Exception e) {

        } finally {
            countDownLatch.countDown();
        }
    });
}

countDownLatch.await();

 

coundDownLatch.countDown()은 스레드 카운트 개수를 줄이는 역할을 수행하고 coundDownLatch.await()는 모든 카운트 개수가 줄어들 때까지 대기하는 역할을 수행합니다.
따라서, await() 이후에 테스트 검증을 위한 처리작업이나 //then 에 해당하는 검증 로직을 추가할 수 있습니다.

 

@Test
@DisplayName("멀티 스레드 save")
public void save_mt() throws Exception {
    //given
    int threadCount = 5;
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    Member[] members = new Member[threadCount];
    Member kose = Member.builder().email("kose@naver.com").password("1234")
            .username("kose").build();

    //when
    for (int i = 0; i < threadCount; i++) {
        int finalIdx = i;
        executorService.submit(() -> {
            try{
                Member member = memberRepository.findByEmail("kose@naver.com");

                if (member == null) members[finalIdx] = memberRepository.save(kose);
                else members[finalIdx] = null;

            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                countDownLatch.countDown();
            }
        });
    }

    countDownLatch.await();

    int result = 0;
    for (int i = 0; i < threadCount; i++) if (members[i] != null) result++;

    //then
    assertThat(result).isEqualTo(1);

}

 

익명 클래스나 람다식에서는 암묵적 fianl 타입의 값이 입력되어야 합니다. 즉, 배열에 선언되는 인덱스는 final 타입으로 선언하여야 의도하지 않은 값 변경을 막고 생명 주기를 명확하게 할 수 있습니다.

 

결과는 다음과 같이 실패합니다. 실패 발생 로그는 id는 primary 키이므로 유니크 제약조건이 적용되는데 같은 3개의 유니크한 값이 리턴되었기 때문입니다. 실제 서비스 환경에서는 큰 장애로 이어질 수 있습니다.

 

 

 

3. 동시성 문제 해결하기 - Lock 비교

 

동시성 문제를 해결하기 위해서는 락 기능을 활용할 수 있습니다. 락은 기본적으로 특정 자원에 대한 변경 요청을 할 때 락을 획득하고 변경을 실행한 후 락을 해제하는 개념입니다. 따라서, 락이 걸려있는 자원에 대한 변경 요청이 오면 락이 해제될 때까지 대기해야 합니다.

 

동시성 문제를 해결하기 위해서는 Pessimistic Lock 혹은 RedissonClient의 분산락 등을 활용할 수 있습니다. 하지만, 이는 간단한 문제가 아니며 작성한 코드와 환경에 따라 달라질 수 있습니다.
(OptimissticLock 도 사용할 수 있는데 이는 버전 관리에 적절하기 때문에 두 가지 방법으로 적용하였습니다)

 

Pessimistic Lock은 비관적인 방법으로 락을 적용하는 방식입니다. 트랜젝션 도중에 다른 트랜젝션이 데이터를 변경할 가능성이 있으면 해당 값에 락을 걸어 변경을 막도록 하는 방법입니다. 따라서, 여러 트랜잭션이 동시에 데이터에 접근하더라도, 락을 걸게 됨으로써 데이터의 동시적 변경 요청을 방지하는데 도움을 줄 수 있습니다.

하지만, 데이터베이스 자체에 락을 걸어버리는 개념이기 때문에 성능 문제나 데드락의 가능성이 높기 때문에 Pessimistic Lock을 사용할 때 충분한 점검이 반드시 필요합니다.

 

두 번째 방법으로, RedissonClient의 분산락을 활용할 수 있습니다.  분산락은 기본적으로 락을 획득하여 락이 유지되는 시간 동안 비즈니스 로직을 처리한 후 락을 해제하는 개념입니다. 실제 분산환경에서 사용하기에 적합하다는 평가를 받고 있습니다. 

하지만, 실제 비즈니스 로직이 수행되는 기간 동안 Redis 클러스터 지연이 발생한다면 락이 해제되는 시점에 다른 요청이 락을 획득하여 공유 자원 변경을 요청할 수도 있습니다. 이 경우 동시성 문제가 발생할 수 도 있습니다. 따라서, Redis로 요청이 수행되는 기간동안 지연이 발생할 가능성을 배제할 수 없기에 적절한 추가 대응 조치가 필요합니다. 

또한, 분산락의 경우는 외부의 Redis를 사용해야 한다는 단점이 있습니다. AWS에 배포하는 경우에 ElasticCache 같은 서버를 하나 더 추가하는 것은 비용적 부담이 될 수밖에 없습니다. 정리하면, Redis 클러스터의 지연 문제가 발생하지 않는지, 또한 외부 Redis 서버를 운영할 수 있는 비용적인 문제가 해결되었는지를 판단하여 적용하여야 합니다.

 

하지만, 아쉽게도 Pessimistic 락 혹은 RedissonClient를 사용하더라도, 동시성 문제가 발생할 수 있습니다.

 

 

실제로 JPA를 이용하여 데이터베이스와 커넥션 풀을 연결하면 데이터베이스 커밋이 완료되면 커넥션이 끊어집니다. 하지만 이는 데이터베이스에 그 값이 바로 저장되었다는 것을 의미하지 않습니다. 실제 DB는 내부적으로 복잡한 과정을 거치고 I/O 연산에서 시간이 소요될 수 있습니다. 만약 DB에 저장되는 과정에 바로 다음 요청이 동시적으로 발생한다면 DB에는 값이 온전히 저장되지 않았기 때문에, findById와 같은 요청으로 값을 찾을 때, 값이 없다고 나올 수 있는 것입니다.

 

따라서, 이 경우에는 두 가지 방법을 모두 사용하여 문제를 해결할 수 있습니다. 하지만 이 두 가지 방법을 모두 사용하는 것은 데드락을 발생시킬 수 있으므로 트랜젝션 관리를 적절하게 처리해주어야 합니다.

 

 

4. 문제 파악 및 문제 해결하기

 

 

@Transactional
    public boolean registerForm(FormRegisterUserDto dto) {

        Member findMember = memberRepository.findByEmail(dto.getEmail());

        if (findMember == null)  {
            Member user = Member.builder()
                    .userId(UUID.randomUUID().toString())
                    .username(dto.getUsername())
                    .password(passwordEncoder.encode(dto.getPassword()))
                    .email(dto.getEmail())
                    .build();

            memberRepository.save(user);
            authorityRepository.save(Authority.builder().member(user).authorities(ROLE_USER).build());

            return true;
        }
        throw new UserRegisterConflictException();
    }

 

이 소스는 email 값을 가져와서 member 객체를 찾은 후, 값이 null이라면, member와 authority를 저장하는 로직입니다.

싱글 스레드에서는 다음과 같이 정상적으로 성공합니다.

 

 

@Test
@DisplayName("싱글 스레드에서 같은 email 저장 요청이 오면  맨처음 이외에 요청은 실패한다.")
public void registerForm_single() throws Exception {
    //given
    int count = 5;
    FormRegisterUserDto dto = new FormRegisterUserDto("kose", "kose@naver.com", "12345678910");

    //when
    memberService.registerForm(dto);

    //then
    for (int i = 0; i < count; i++) {
        Assertions.assertThatThrownBy(() -> {
            memberService.registerForm(dto);
        }).isInstanceOf(UserRegisterConflictException.class);
    }
}

 

 

하지만, 동시성 테스트에서는 실패합니다.

 

@Test
@DisplayName("멀티 스레드 save")
@Transactional
public void save_mt() throws Exception {
    //given
    int result = 0;
    int threadCount = 5;
    boolean[] registers = new boolean[threadCount];

    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    FormRegisterUserDto dto = new FormRegisterUserDto("kose", "kose@naver.com", "12345678910");

    //when
    for (int i = 0; i < threadCount; i++) {
        int finalIdx = i;
        executorService.submit(() -> {
            try{
                registers[finalIdx] = memberService.registerForm(dto);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                countDownLatch.countDown();
            }
        });
    }

    countDownLatch.await();


    for (int i = 0; i < threadCount; i++) {
        if (registers[i]) result++;
    }

    //then
    assertThat(result).isEqualTo(1);
}

 

이는 해당 코드가 스레드 세이프 하지 않기 때문입니다.

 

 

 

4. 코드 수정하기 

 

먼저, 분산락을 적용하기 위해 RedissonClient를 추가하여야 합니다.

 

//redissonClient
implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'
@Configuration
@EnableAspectJAutoProxy
public class RedisAopConfig {

    @Bean
    public RedisLockAspect redisLockAspect(RedissonClient redissonClient) {
        return new RedisLockAspect(redissonClient);
    }

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        return Redisson.create(config);
    }
}

 

 

이후 문제가 되는 코드에 RedissonClient를 적용하여 락을 걸어줍니다.

RegisterUserDto에 email은 고유한 값을 가져야 하므로, 해당 값으로 Key를 생성한 후 try - catch -finally로 
내부 로직을 작성합니다.

 

락을 획득하면 내부 비즈니스 로직이 실행이 되며, 락을 획득하지 못하면 예외가 발생합니다.
finally에서는 락을 획득한 경우에는 락을 해제하는 과정을 추가하여 데드락이 발생하는 문제를 해결하고자 하였습니다.

 

@Transactional
public boolean registerForm(FormRegisterUserDto dto) {
    String lockKey = "registerForm: " + dto.getEmail();
    RLock lock = redissonClient.getLock(lockKey);
    boolean isLocked = false;
    try {
        isLocked = lock.tryLock(20, 3, TimeUnit.SECONDS);
        if (!isLocked) {
            throw new RedisLockException();
        }
        log.info("lockKey = {} 락 획득", lockKey);
        Member findMember = memberRepository.findWithMemberForSave(dto.getEmail());
        if (findMember == null) {
            Member user = Member.builder()
                    .userId(UUID.randomUUID().toString())
                    .username(dto.getUsername())
                    .password(passwordEncoder.encode(dto.getPassword()))
                    .email(dto.getEmail())
                    .build();
            memberRepository.save(user);
            authorityRepository.save(Authority.builder().member(user).authorities(ROLE_USER).build());
            return true;
        }
        throw new UserRegisterConflictException();
    } catch (InterruptedException e) {
        throw new RedisLockException();
    } finally {
        if (isLocked) {
            lock.unlock();
            log.info("lockKey = {} 락 반환", lockKey);
        }
    }
}

 

이후 Pessimistic Lock을 적용하기 위해 Repository를 수정합니다.

 

@Repository
public interface MemberRepository extends JpaRepository<Member, String> {

	--- 중략 ---
    
    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @Query("select m from Member m where m.email = :email")
    Member findWithMemberForSave(@Param("email") String email);
}

 

@Lock(value = LockModeType.PESSIMISTIC_WRITE)를 사용하여 락을 획득할 수 있고 내부에는 JPQL로 쿼리를 작성합니다. 이경우 findWithMEmberForSave()가 실행되는 트랜젝션에서는 쓰기 락을 획득하게 되어 다른 요청이 수행될 때 락이 해제될 때까지 기다리게 됩니다.

 

이후 테스트를 진행하면 성공하였습니다 하지만, 로그를 보면 데드락이 발생한 것을 확인할 수 있습니다.

 

 

"이유는 바로, save() 메서드에 있습니다."

 

제가 작성한 로직은

RedissonClient Lock 획득 -> Pessimistic Lock 획득 -> Pessimistic Lock 해제 -> RedissonClientLock 해제입니다.

save()는 해당 엔티티를 영속화시키기는 과정이 추가되지만 실제 데이터베이스에는 전송되지 않습니다. 이는 곧,
member 객체에 대한 데이터를 데이터베이스에 써서 락을 해제하는 과정이 실행되어야 하는데 실제로 쓰기 작업이 수행되지 않았기 때문에 영속성은 있으나 락이 해제되지 않은 것입니다. 따라서, 데드락으로 이어진 것입니다.

 

이것을 해결하기 위해서는 save() -> saveAndFlush()로 데이터베이스에 쓰기 작업을 즉각 수행하도록 처리합니다.

코드를 수정하면 다음과 같습니다.

 

@Transactional
public boolean registerForm(FormRegisterUserDto dto) {
    String lockKey = "registerForm: " + dto.getEmail();
    RLock lock = redissonClient.getLock(lockKey);
    boolean isLocked = false;
    try {
        isLocked = lock.tryLock(20, 3, TimeUnit.SECONDS);
        if (!isLocked) {
            throw new RedisLockException();
        }
        log.info("lockKey = {} 락 획득", lockKey);
        Member findMember = memberRepository.findWithMemberForSave(dto.getEmail());
        if (findMember == null) {
            Member user = Member.builder()
                    .userId(UUID.randomUUID().toString())
                    .username(dto.getUsername())
                    .password(passwordEncoder.encode(dto.getPassword()))
                    .email(dto.getEmail())
                    .build();
            memberRepository.saveAndFlush(user);
            authorityRepository.saveAndFlush(Authority.builder().member(user).authorities(ROLE_USER).build());
            return true;
        }
        throw new UserRegisterConflictException();
    } catch (InterruptedException e) {
        throw new RedisLockException();
    } finally {
        if (isLocked) {
            lock.unlock();
            log.info("lockKey = {} 락 반환", lockKey);
        }
    }
}

 

테스트 결과 데드락이 발생하지 않고 테스트에 성공한 것을 확인할 수 있습니다.

 

 

혹시 그렇다면, saveAndFlush()가 문제였을 수 있다는 생각과 함께 pessimisticLock을 해제해보았습니다.

 

@Query("select m from Member m where m.email = :email")
Member findWithMemberForSave(@Param("email") String email);

 

결과는 테스트 성공하였습니다 ㅜㅜ!!

saveAndFlush는 변경 사항을 즉시 데이터베이스에 반영하는 기능을 수행하기 때문에 락이 해제되어 변경 사항이 즉시 반영되지 않는 경우에 해결할 수 있습니다.!!!

 

 

 

마지막으로 해당 로직을 AOP로 분리하여 횡단 관심사와 핵심 로직을 분리하는 과정을 처리하도록 하겠습니다.

 

@Transactional
public boolean registerForm(FormRegisterUserDto dto) {
    Member findMember = memberRepository.findWithMemberForSave(dto.getEmail());
    if (findMember == null) {
        Member user = Member.builder()
                .userId(UUID.randomUUID().toString())
                .username(dto.getUsername())
                .password(passwordEncoder.encode(dto.getPassword()))
                .email(dto.getEmail())
                .build();
        memberRepository.saveAndFlush(user);
        authorityRepository.saveAndFlush(Authority.builder().member(user).authorities(ROLE_USER).build());
        return true;
    }
    throw new UserRegisterConflictException();
}

 

@Around("execution(* liar.memberservice.auth.service.MemberService.registerForm(..)) && args(dto)")
public boolean registerForm(ProceedingJoinPoint joinPoint, FormRegisterUserDto dto) throws Throwable {

    String lockKey = "registerForm: " + dto.getEmail();
    return (boolean) executeWithRedisLock(joinPoint, lockKey);
}

public Object executeWithRedisLock(ProceedingJoinPoint joinPoint, String lockKey) throws Throwable {
    RLock lock = redissonClient.getLock(lockKey);

    try {
        boolean isLocked = lock.tryLock(20, 3, TimeUnit.SECONDS);
        if (!isLocked) {
            throw new RedisLockException();
        }
        log.info("lockKey = {} 락 획득", lockKey);

        return joinPoint.proceed();

    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
            log.info("lockKey = {} 락 반환", lockKey);
        }
    }
}

 

 

테스트가 정상으로 성공하였습니다. ㅠㅠ !!

 

 

5. 느낀점 

 

가장 좋은 방법은 좋은 데이터베이스 환경을 써서 RedissonClient를 적용하여 분산락을 적용하는 것이라고 합니다.

하지만, 제 로컬 환경이나 AWS에 배포된 데이터베이스 환경은 프리티어로 성능이 좋지 않습니다. 이 경우에는 어쩔 수 없이 다른 대안을 적용하여 동시성 문제를 해결하는 과정이 필요하였습니다. 

 

하지만, 락을 여러 개 거는 것은 데드락을 발생시킬 수 있기 때문에, saveAndFlush() 기능을 활용하여 
즉시 업데이트 시키는 과정을 수행할 수 있었습니다. 그 결과, pessimistic 락과 분산락을 함께 적용하지 않고 문제를 해결할 수 있었습니다.

 

물론 AWS에 배포할 때 제 로컬환경에서 적용한 테스트가 다시 실패할 수도 있습니다. ㅜㅜ

이 경우 다시 리팩터링 하며, 다시 추가 글을 작성하도록 하겠습니다.

 

 

부족하지만, 오늘도 읽어주셔서 감사드립니다.!

 

+ Recent posts