본문 바로가기

프로젝트/오디

[오디] '따닥' 중복 삽입 동시성 이슈 대응을 위한 8가지 대안

개요

오디 프로젝트 운영 중 나타난 동시성 이슈에 대해 8가지 대안을 살펴보고, 결론을 도출해낸 과정을 소개합니다.

8가지 대안이란 다음과 같습니다.

 

1) synchronized + @Transactional

2) synchronized + 비관적 락

3) synchronized + 수동 트랜잭션

4) DB 락(낙관적 락 / 비관적 락)

5) Unique Index

6) Redis 분산락

7) Mysql 네임드 락 + @Transactional

8) Mysql 네임드 락 + 수동 트랜잭션

 

상황

회원 탈퇴 에러를 디버깅하던 중 동일 회원이 동일 약속에 중복참여한 케이스가 있다는 것을 알게되었습니다.

 

의아했습니다.

 

분명 약속 참여자인 Mate 엔티티에 Unique Index로 (meeting_id, member_id, deleted_at)를 걸어주고 있었고,

약속 참여 시에 동일 회원이 동일 약속에 참여했는지 검증하는 로직이 있었기 때문입니다.

    public void validateAlreadyAttended(Member member, Meeting meeting) {
        if (mateRepository.existsByMeetingIdAndMemberId(meeting.getId(), member.getId())) {
            throw new OdyBadRequestException("약속에 이미 참여했습니다.");
        }
    }

 

원인은 2가지 였습니다.

 

원인1) MySQL Unique Index의 경우 null값의 중복을 허용합니다. 

MySQL 공식문서를 보면 Unique Index의 경우 null 값의 중복을 허용하고 있습니다.

 

즉, 1번 약속에 1번 회원이 참여할 시 Unique Index(meeting_id, mate_id, deleted_at)에는 (1, 1, null)이 삽입됩니다.

저희가 기대한 것은 1번 약속에 1번 회원이 중복참여를 할 경우 Unique 에러를 통해 약속 중복 참여를 잡아주는 것이었지만 안타깝게도 유니크 인덱스는 null 중복을 허용하여 1번 회원이 1번 약속에 다시한번 참여를 허용하게 합니다. 즉, 또다른 (1, 1, null) index의 삽입이 가능합니다.

 

원인2) 동시성 따닥 이슈

validate를 통해 exists > save 를 하는 경우, 동시성 이슈가 발생할 수 있습니다.

 

즉, 1번 약속에 1번 회원이 동시에 참여를 요청해 두 트랜잭션이 발생한 경우

A 트랜잭션  => (1번 약속에 1번 회원이 있니? -> 없어요) => 약속에 참여시킬게~

B 트랜잭션  => (1번 약속에 1번 회원이 있니? -> 없어요) => 약속에 참여시킬게~

 

처럼 동일 회원이 동일 약속에 참여하는 시나리오가 발생 가능합니다.

 

이에 8가지 관점에서 동시성 이슈를 바라보고, 현재 오디 프로젝트에 가장 어울리는 대안을 도입하고자 하였습니다.

옆에 있는 표시는 시도한 방식이 동시성 이슈 처리에 성공했는지를 기반으로 표기했습니다.

 

O - 동시성 이슈 처리 성공

△ - 처리에 성공했으나 단점이 명확함

X - 동시성 이슈 처리에 실패함


1) Synchronized  + @Transactional (X)

 

가장 처음 생각해볼 수 있는 것은 Java 에서 Thread-Safe한 블록을 보장하는 Syncrhonized 키워드입니다.

    @Transactional
    public synchronized MateSaveResponseV2 saveMateAndSendNotifications(MateSaveRequestV2 mateSaveRequest, Member member) {
		 Meeting meeting = findByInviteCode(mateSaveRequest.inviteCode());
         if (meeting.isEnd()) {
                throw new OdyBadRequestException("과거 약속에 참여할 수 없습니다.");
         }
         return mateService.saveAndSendNotifications(mateSaveRequest, member, meeting);
       
    }

 

 

그러나, 해당 케이스에 Synchronized를 적용했음에도 불구하고 따닥 테스트 결과, 동시성 이슈가 해결되지 않는 모습을 보입니다.

 

그 이유는 Syncrhonized가 트랜잭션의 Thread-Safe까지 보장하지 않기 때문입니다.

스프링 빈은 Transaction 애너테이션을 상속 프록시를 통해 구현합니다.

public class RealClass {

    @Transactional
    public synchronized void method() {
        //메소드 내용
    }
}

public class ProxyClass extends RealClass {
	private final RealClass realClass;
    
    public void method() {
    	EntityTransaction tx = em.getTransaction(); //트랜잭션 열림
        tx.begin();
        
        super.method(); //thread-safe 보장
        
        tx.commit(); //트랜잭션 닫힘
    }
}

 

예를 들어 RealClass의 syncrhonized 메서드가 있어도 함수의 시그니처가 아니기 때문에 이것을 상속한 ProxyClass의 method()에는 synchronized가 적용되지 않습니다. 따라서 RealClass의 method() 블록은 thread-safe가 보장되지만 실제 트랜잭션은 RealClass method()에 진입하기 이전에 ProxyClass에서 열립니다. 

 

다시 돌아와서 MateService의 saveMateAndSendNotification을 바라보겠습니다. syncrhonzied 키워드가 있으면 MateService 빈 호출 시 다음과 같은 순서를 따릅니다.

 

 트랜잭션 1

: 빈 프록시의 메서드 호출 => 참여하지 않은 상태에서 트랜잭션 열림

=> syncrhonized [(1번 약속에 1번 회원이 있니? -> 없어요) => 약속에 참여~]

 

트랜잭션 2

: 빈 프록시의 메서드 호출 => 참여하지 않은 상태에서 트랜잭션 열림

=> (트랜잭션1 의 스레드가 syncrhonized 블록을 점유하지 않을 때까지 대기) => 트랜잭션 1 스레드 종료

=> syncrhonized [(1번 약속에 1번 회원이 있니? -> 없어요) => 약속에 참여~]

 

여기서 B트랜잭션이 1번 약속에 1번 회원이 없다고 판단한 이유는 MySQL의 격리수준이 REPETABLE_READ로 기본 설정되어 있기 때문입니다. 즉, MVCC 언두로그를 통해 처음 열린 트랜잭션 버전에서 동일한 결과를 반환하기에 B트랜잭션은 약속에 회원이 없는 것으로 판단하고 약속참여를 허용하게 됩니다.

 

 

2) Synchronized + 비관적 락 (△)

 

그럼 트랜잭션들이 각자의 언두로그가 아닌 실제 레코드를 바라보게 하면 어떨까요?

비관적 락과 함께 (1번 약속에 1번 회원이 존재하니?)를 검증하면, 비관적 락 획득을 위해서 언두로그가 아닌 실제 레코드를 조회하게 됩니다.

public class MateService {

	public void validateAlreadyAttended(Member member, Meeting meeting) {
        if (mateRepository.findByMeetingIdAndMemberId(meeting.getId(), member.getId()).isPresent()) {
            throw new OdyBadRequestException("약속에 이미 참여했습니다.");
        }
    }
}

public interface MateRepository extends JpaRepository<Mate, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Mate> findByMeetingIdAndMemberId(Long meetingId, Long memberId);
}

 

그러나, 이 경우에도 동시성 이슈를 해결하지는 못합니다.

 

그 이유는 앞선 ProxyClass의 syncrhonized가 상속되지 않으면서 commit 시점이 보장되지 않기 때문입니다.

즉, 트랜잭션1에서 삽입된 약속 참여자가 커밋되기 이전에 트랜잭션2가 (1번 약속에 1번 회원이 있니?)를 조회한다면 비관적 락을 잡아 실제 레코드를 보려고 해도 약속 참여자가 없는 것으로 조회됩니다.

 

그렇기에 Thread.sleep을 통해 첫번째 트랜잭션이 커밋될 만큼의 여유를 주고 비관적 락을 통해 실제 레코드를 조회하면 예상한 대로 동시성 이슈는 해결이 됩니다.

 

 

그러나, 해당 방식은 앞선 커밋이 완료되기 까지, Thread.sleep이 필요하다는 점과 이 sleep 구간이 DB 네트워크 환경에 따라 달라질 수 있다는 점 때문에 당연히 보류했습니다.

 

3) Synchronized + 수동 트랜잭션 (O)

 

그럼, 트랜잭션이 열리고 닫히는 범위 자체를 Synchronized 안에 넣으면 어떻게 될까요?

실제로 Spring에서는 TransactionTemplate을 통해 수동 트랜잭션을 지원하고 있습니다.

 

이에 따라 스프링 @Transaction을 활용하지 않고 Syncrhonized 메서드 내에서 트랜잭션을 수동으로 열고 닫도록 하였는데요.

    //@Transactional 제거
    public synchronized MateSaveResponseV2 saveMateAndSendNotifications(MateSaveRequestV2 mateSaveRequest, Member member) {

        return transactionTemplate.execute((status) -> {
            Meeting meeting = findByInviteCode(mateSaveRequest.inviteCode());
            if (meeting.isEnd()) {
                throw new OdyBadRequestException("과거 약속에 참여할 수 없습니다.");
            }
            return mateService.saveAndSendNotifications(mateSaveRequest, member, meeting);
        });
    }

 

그 결과, 트랜잭션 범위 자체도 Thread-safe 해지면서 동시성 이슈가 해결되는 모습을 볼 수 있었습니다.

 

 

 

그러나, Synchronized를 활용한 1, 2, 3의 대안은 하나의 프로세스 내에서만 동시성을 보장합니다. 즉, 분산환경으로 확장되었을 때, 각기 다른 인스턴스 서버에서 DB에 동시 요청을 하면 1번 회원이 1번 약속에 중복 참여하는 상황이 발생가능합니다.

 

또한, 수동 트랜잭션을 통해 트랜잭션을 직접 관리해주어야 한다는 점도 부담이었습니다. 특히 TransactionTemplate의 경우도 기본 전파 레벨이 REQUIRED로 맞추어져 있어, 만약 상위 서비스에서 트랜잭션이 열린다면 해당 트랜잭션이 전파되어 동시성 이슈가 재발할 위험도 있었습니다.

 

4) 락 (낙관적 락, 비관적 락) (X)

 

그래서 다시금 시선을 DB 단으로 옮겼습니다. 그러나, 중복 삽입 경쟁문제에서 락을 고려하기는 어려웠습니다.

 

낙관적 락은 삽입되어있는 레코드의 버전관리를 통해 동시 수정 시, 버전 충돌을 통해 동시성 이슈를 감지합니다. 그러나 현재 다루고 있는 문제는 경쟁 삽입문제로 아직 레코드가 존재하지 않았고 이에 따라 버전 자체가 초기에 존재하지 않기 때문에 낙관적 락을 통해 해결할 수가 없는 상황이었습니다.

 

비관적 락의 케이스도 마찬가지로 실제 행이 존재하지 않으므로 레코드 락 획득이 불가합니다. 갭락의 경우, 다른 트랜잭션이 같은 갭락을 획득가능하므로 중복 insert 쿼리를 방지하지 못합니다.

 

따라서 DB 락을 통한 중복 삽입 방지는 고려하지 않았습니다. 만약 가능하다 했더라도 따닥 문제가 꽤 자주 발생하는 문제가 아닌 것을 감안해 락을 잡는 건 꽤 큰 성능 손실이라 생각했고, 분산 DB 환경에서는 적용하지 못한다는 점이 치명적으로 와닿았습니다.

 

5) Unique Index (X)

 

다음으로 고려했던 Unique Index 입니다. DB 유니크 index를 활용해 동시삽입을 막는 전략입니다.

 

5-1) deleted_at > is_deleted

먼저 soft delete의 행을 삭제 시점(deleted_at)이 아닌 삭제 여부(is_deleted)로 바꾸는 안입니다.

이 경우 (meeting_id, member_id, is_deleted)를 unique index로 잡아 중복 삽입 시도 시 Unique 에러가 발생하도록 할 수 있습니다.

 

그러나, 이 경우 약속에 여러번 재참여하는 경우에 문제가 발생합니다.

먼저 1번 약속에 1번 회원이 참여합니다.

meeting_id member_id is_deleted
1 1 false

 

이후에 1번 회원이 나갑니다.

meeting_id member_id is_deleted
1 1 true

 

1번 회원이 1번 약속에 다시 들어왔습니다.

meeting_id member_id is_deleted
1 1 true
1 1 false

 

만약 다시 나가게 된다면 Unique Error가 발생하게 되므로 회원은 약속에 다시 나가지 못하는 상황이 됩니다.

meeting_id member_id is_deleted
1 1 true
1 1 true -> Unique Error 발생

 

5-2) created_at

그럼 생성 시점을 기반으로 (meeting_id, member_id, created_at)으로 Unique 조건을 걸면 어떨까요?

겉으로는 그럴듯 해보이지만 꽤 위험한 선택입니다. 따닥 문제가 동시적으로 발생하더라도 나노초의 차이가 있을 수 있는데, 생성 시점의 유니크 인덱스로 동시성 이슈를 해결하려면 정확히 생성 시점이 나노초 까지 동일해야 하기 때문입니다.

 

실제로 해당 유니크 인덱스로 동시성 이슈를 해결하려 하였으나, created_at의 나노초 불일치 문제로 실패하는 모습을 볼 수 있었습니다.

 

 

6) Redis 분산락 (O)

다음으로 분산환경을 포괄가능한 레디스 분산락입니다.

 

분산락 에너테이션을 설정하고

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {

    String key();

    TimeUnit timeUnit() default TimeUnit.SECONDS;

    long waitTime() default 5L;

    long leaseTime() default 10L;
}

 

AOP를 활용해 분산락을 획득하도록 하였습니다.

@Aspect
@Component
@Slf4j
@RequiredArgsConstructor
public class DistributedLockAspect {

    private final RedissonClient redissonClient;

    @Around("@annotation(distributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();

        String lockKey = createLockKey(
                distributedLock.key(),
                signature.getParameterNames(),
                joinPoint.getArgs()
        );

        RLock lock = redissonClient.getLock(lockKey);

        try {
            boolean acquired = lock.tryLock(
                    distributedLock.waitTime(),
                    distributedLock.leaseTime(),
                    distributedLock.timeUnit()
            );

            if (!acquired) {
                log.warn("Lock acquisition failed: {}", lockKey);
                throw new OdyServerErrorException("락 획득에 실패했습니다.");
            }

            log.info("Lock acquired: {}", lockKey);
            return joinPoint.proceed();

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new OdyServerErrorException("Lock interrupted");
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
                log.info("Lock released: {}", lockKey);
            }
        }
    }

    private String createLockKey(String key, String[] parameterNames, Object[] args) {
        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, String.class);
    }
}

 

 

구현체로는 분산락 구현체로 많이 쓰이는 Redisson을 채택했습니다.

- Lettuce는 스핀락을 통해 락을 얻어올 때까지 폴링처럼 Redis를 조회하여 부하 증가

- Redisson은 Pub/Sub을 활용해 락 획득 실패 시, 해당 메서드를 구독하고 락 릴리즈 이벤트를 받아 획득하여 부하가 상대적으로 적음

 

실제 적용된 모습을 보면 다음과 같습니다. 

    @Transactional
    @DistributedLock(
            key = "'mate:create:' + #meeting.id + ':' + #member.id",
            waitTime = 5,
            leaseTime = 10
    )
    public MateSaveResponseV2 saveAndSendNotifications(
            MateSaveRequestV2 mateSaveRequest,
            Member member,
            Meeting meeting
    ) {
        validateMeetingOverdue(meeting);
        validateAlreadyAttended(member, meeting);
        Mate mate = saveMateAndEta(mateSaveRequest, member, meeting);
        return MateSaveResponseV2.from(meeting);
    }

락 획득 - 릴리즈를 통해 동시성 이슈도 잘 처리해주는 것을 알 수 있습니다.

 

분산락의 경우, 인프라 확장에 조금 더 자유로운 동시성 처리 방식이라는 생각이 들었습니다. 분산 DB 혹은 분산 인스턴스 환경으로 확장된다하더라도 일관적으로 적용이 가능한 동시성 처리 방식이었습니다.

 

그러나, DB 트랜잭션과의 정합성이라던가, Redis 다운 시 실패전략 등등 꽤 복잡한 문제들이 남아있는 것으로 보입니다.

일단 동시성을 해결하는 것에 초점을 맞추었기에 유지보수 및 Redisson 구현체에 대한 세부 원리는 TODO로 남겨놓았습니다.

 

7) Named Lock + @Transactional(X)

 

다음으로 DB Named Lock을 활용한 동시성 처리를 시도해보았습니다.

Named Lock은 DB 세션 단위로 소유되는 DB 내부의 문자열 키 단위 뮤텍스입니다.

다만, 예외 및 에러가 발생한다고 락 해제가 되지 않으므로 락 해제에 신경을 써주어야 합니다.

만약 락 해제가 되지 않으면 락을 얻기 위한 타 요청들이 무한 대기를 하거나, 실패하는 상황이 발생가능합니다.

 

먼저 MateRepository에 락 획득, 해제를 위한 메서드를 만들어주었습니다.

    @Query(value = "SELECT GET_LOCK(:key, :timeout)", nativeQuery = true)
    Integer acquireLock(@Param("key") String key, @Param("timeout") long timeout);

    @Query(value = "SELECT RELEASE_LOCK(:key)", nativeQuery = true)
    Integer releaseLock(@Param("key") String key);

 

 

그리고 NamedLock을 활용해 임계영역을 만들어 동시성 테스트를 해보았습니다.

public MateSaveResponseV2 saveAndSendNotifications(
            MateSaveRequestV2 mateSaveRequest,
            Member member,
            Meeting meeting
    ) {
        String key = meeting.getId() + ":" + member.getId(); //락 획득
        try {
            if(mateRepository.acquireLock(key, Duration.ofSeconds(3L).toSeconds()) !=1) {
                throw new OdyServerErrorException("약속 참여자 생성 락 획득에 실패하였습니다.");
            }
            validateMeetingOverdue(meeting);
            validateAlreadyAttended(member, meeting);
            Mate mate = saveMateAndEta(mateSaveRequest, member, meeting);
            return MateSaveResponseV2.from(meeting);
        } finally {
            mateRepository.releaseLock(key); //락 해제
        }
    }

 

그러나, Named Lock은 동시성 이슈를 해결해주지 못했습니다.

 

원인은 앞선 Syncrhonized + @Transactional과 유사했습니다.  트랜잭션이 먼저 열리고 Named Lock을 활용한 임계영역에 진입하기 때문에 MVCC 언두로그를 통해 앞선 트랜잭션에서 약속참여자가 삽입된 것을 바라보지 못하는 문제가 있었습니다.

 

8) Named Lock + 수동 트랜잭션(O)

따라서 트랜잭션이 임계영역 안에서 열리도록 수동 트랜잭션을 열면 동시성 이슈가 해결되는 모습을 볼 수 있었습니다.

    public MateSaveResponseV2 saveAndSendNotifications(
            MateSaveRequestV2 mateSaveRequest,
            Member member,
            Meeting meeting
    ) {
        String key = meeting.getId() + ":" + member.getId(); //락 획득
        try {
            Integer acquired = mateRepository.acquireLock(key, Duration.ofSeconds(3L).toSeconds());
            return transactionTemplate.execute(status -> {
                if (acquired != 1) {
                    throw new OdyServerErrorException("약속 참여자 생성 락 획득에 실패하였습니다.");
                }
                validateMeetingOverdue(meeting);
                validateAlreadyAttended(member, meeting);
                Mate mate = saveMateAndEta(mateSaveRequest, member, meeting);
                return MateSaveResponseV2.from(meeting);
            });
        }finally{
            mateRepository.releaseLock(key); //락 해제
        }
    }

 

그러나, 해당 방법은 다음과 같은 부분에서 단점이 있다고 판단했습니다.

- DB 다중화 시 Replica 대상 Named Lock을 취득하여 동시성 이슈 재발 가능

- Native Query 사용으로 인한 Named Lock을 지원하는 DB 사용 강제

- Lock 관리 실패 시, 자동 해제 불가 -> 철저한 관리 필요

- Fail Fast 원칙을 지키지 못하고 DB단까지 내려와 동시성 에러가 발생하는 문제

 


나의 선택 : Redis 분산락

현재 오디 프로젝트는 단일 DB+ 단일 인스턴스를 기반으로 운용되고 있습니다. 그렇기에 MySQL NamedLock + 수동 트랜잭션이나 Synchronized 등을 통한 해결도 충분히 가능합니다.

 

그러나, Synchronized를 활용한 동시성 해결은 최대한 피하고 싶었습니다. 동시성 문제가 없는 대부분의 경우에도 임계영역을 만들어 성능이슈가 있는 것은 물론이고 분산환경으로 확장시 동시성 이슈 해결이 불가하기 때문입니다.

 

MySQL NamedLock 같은 경우에는 nativeQuery가 마음에 들지 않았습니다. 레포지토리란 단순히 엔티티를 조회하는 것을 넘어 ORM을 통해 DB 벤더를 캡슐화하는 역할도 있는데 nativeQuery가 그 가치를 깬 것 같아 아쉬웠습니다. 또한, 테스트 환경에서는 H2 DB를 활용하고 있었기 때문에 H2는 MySQL과 동일한 네임드락 쿼리를 지원하지 않아 테스트가 실패하는 문제도 발생했습니다.

 

결국, 가장 합리적이면서도 따닥 동시성 문제만을 타겟팅하여 임계영역을 생성하는 Redis 분산락을 채택하기로 하였습니다. 특히나 Redis의 경우, 현 프로젝트에서 이미 사용하고 있었기 때문에 인프라 컴포넌트의 추가적인 확장이나 세팅이 필요하지 않아 상대적으로 리소스가 적게 도입할 수 있었으며, 앞으로의 유지 보수나 인프라 확장 시에도 가장 안정적인 동시성 이슈 처리 방식이라 생각했습니다.

 

이것으로 8가지 방법을 통한 동시성 이슈 처리 시도 글을 마치려 합니다.

오류나 의견은 언제나 환영합니다.

 

감사합니다.