[Project]/[Momo]

[이슈 #2] 분산락을 활용한 중복 데이터 삽입 이슈 해결1

DevLoki 2022. 8. 2. 18:57

문제인식

개발 중 클라이언트로부터 동일한 데이터에 대한 삽입 요청이 동시에 들어오는 경우, 중복 데이터 검사 로직을 거치더라도 데이터가 중복 삽입되는 문제를 발견했습니다.

 

회원이 모임을 가입할 때 발생하는 문제를 예시로 들어보겠습니다.

모임 가입 로직

위와 같은 로직을 거쳐 회원이 모임에 가입하는 경우입니다.

중복 요청시 중복 데이터 삽입 발생

위처럼 클라이언트의 오류로 동일한 두 요청이 동시에 들어온 경우 회원이 한 모임에 중복으로 가입하게 되는 문제가 발생하게 됩니다.

 

이 문제는 간단하게 Unique 제약조건을 통해 해결할 수 있습니다.

Unique 제약 조건을 통한 문제 해결

그러나 만약 '모임에 역할을 부여하여 관리자 회원은 관리자 역할과 일반 역할로 중복 가입할 수 있다.'라는 요구사항이 추가되면 다음과 같이 DB의 제약조건을 변경해 주어야 합니다.

Unique 제약 조건의 변경 불가피

또한, 제약조건을 변경하는 불편함을 감수한다고 하더라도 Application에서 데이터의 검증 과정 없이 DB에 삽입하는 것은 DB를 불안정한 환경에 노출시키게 됩니다.

 

별도의 구현 없이 격리 수준을 Read Committed로 낮추는 방법 또한, 운영 중 다양한 문제(Non-Repeatable Read)를 일으킬 수 있기 때문에 고려하지 않았습니다.

분산락을 통한 문제 해결

분산락을 구현하는 방법은 크게 두가지가 있습니다.

  1. ZooKeeper, Redis 사용
  2. RDBMS 제공 락 사용

1번의 방식을 사용하는 경우 인프라 구축에 대한 비용이 발생하며, 인프라 구축 후 유지보수에 대한 비용이 발생하지만, 2번의 경우 기존에 사용하는 MySQL을 그대로 사용하여 별도의 구축이 필요하지 않습니다.

따라서 MySQL의 USER_LEVEL_LOCK(NAMED_LOCK)을 통해 문제를 해결했습니다.

 

분산락의 구현은 기존에 구현한 서비스 비즈니스 로직의 부가적인 관점이기 때문이 AOP를 적용하여 개발하였습니다.

분산락 핵심 로직

초기 구현한 모습은 다음과 같습니다.

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface DistributedLock {
    DistributedLockPrefix prefix();
}

 

public enum DistributedLockPrefix {
    CLUB_ID("club_id"),
    CLUB_NAME("club_name"),
    MEMBER_ID("member_id"),
    MEMBER_LOGIN_ID("member_login_id");

    private String value;

    DistributedLockPrefix(String value) {
        this.value = value;
    }
}

 

@Slf4j
@Aspect
@Component
@Order(1)
@RequiredArgsConstructor
public class DistributedLockAspect {
    private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(3);
    private final DistributedLockManager distributedLockManager;
    private static final String GET_LOCK = "SELECT GET_LOCK(?,?)";
    private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(?)";
    private static final int LOCK_NAME_IDX = 1;
    private static final int TIMEOUT_IDX = 2;
    private static final int RESULT_IDX = 1;
    private static final int SUCCESS = 1;
    private final DataSource dataSource;

    @Around("@annotation(distributedLock)")
    public Object handleDistributedLock(ProceedingJoinPoint joinPoint) {
        String lockName = joinPoint.getArgs()[0].toString();
        Connection con = null;
        try {
            con = dataSource.getConnection();
            PreparedStatement statement = con.prepareStatement(GET_LOCK);
            statement.setString(LOCK_NAME_IDX, lockName);
            statement.setInt(TIMEOUT_IDX, (int)DEFAULT_TIMEOUT.getSeconds());
            checkResultSetSuccess(statement.executeQuery());
        } catch (SQLException e) {
            throw new DistributedLockException(e);
        }
        distributedLockManager.getLock(lockName, DEFAULT_TIMEOUT);
        try {
            return joinPoint.proceed();
        } catch (Throwable throwable) {
            throw (BusinessException) throwable;
        } finally {
            try {
                PreparedStatement statement = con.prepareStatement(RELEASE_LOCK);
                statement.setString(LOCK_NAME_IDX, lockName);
                ResultSet rs = statement.executeQuery();
                checkResultSetSuccess(rs);
            } catch (SQLException | DistributedLockException e) {
                log.error("error!!", e);
            }
        }
    }

    private void checkResultSetSuccess(ResultSet rs) throws SQLException, DistributedLockException {
        if (!rs.next() || rs.getInt(RESULT_IDX) != SUCCESS) {
            throw new DistributedLockException(ErrorCode.LOCK_FAILURE);
        }
    }

위와 같이 구현한 분산락은 정상적으로 실행되었지만, 여전히 중복 데이터가 삽입하는 문제가 발생했습니다.

MySQL에서 지원하는 기본 격리수준인 REPEATABLE READ가 원인이었습니다.

위 그림처럼 락이 정상적으로 적용되어 트랜잭션A가 데이터를 삽입했지만, 트랜잭션B는 자신이 시작한 시점의 데이터를 조회해서 다른 트랜잭션의 동작이 반영되지 않았기 때문입니다.

 

이를 해결하기 위해 트랜잭션이 시작하기 전에 분산락을 먼저 획득하게 변경하고자 했습니다.

우선 레이어의 추가를 고려하여 다음과 같이 설계하는 것을 고려해보았습니다.

레이어를 추가하여 분산락 우선 적용

하지만 이는 불필요한 클래스 관리 범위가 늘어나고 앞서 적용한 AOP의 필요성이 떨어지는 문제가 있기 때문에 AOP의 적용 순서를 지정하고자 했습니다.

Spring document를 읽던 중 aop의 적용 순서를 지정할 수 있다는 것을 알게 되었고 @Order 어노테이션을 통해 분산락을 적용할 수 있었습니다.

수정 후

@Slf4j
@Aspect
@Component
@Order(1)  //추가!!
@RequiredArgsConstructor
public class DistributedLockAspect2 {
    private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(3);
    private final DistributedLockManager distributedLockManager;
    private static final String GET_LOCK = "SELECT GET_LOCK(?,?)";
    private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(?)";
    private static final int LOCK_NAME_IDX = 1;
    private static final int TIMEOUT_IDX = 2;
    private static final int RESULT_IDX = 1;
    private static final int SUCCESS = 1;
    private final DataSource dataSource;

    @Around("@annotation(distributedLock)")
    public Object handleDistributedLock(ProceedingJoinPoint joinPoint) {
        //생략
    }

    private void checkResultSetSuccess(ResultSet rs) throws SQLException, DistributedLockException {
        //생략
    }
}