영호

AOP와 Multi-Datasource를 활용해 동시성 문제 해결해보기 2탄(feat. NamedLock) 본문

개발지식

AOP와 Multi-Datasource를 활용해 동시성 문제 해결해보기 2탄(feat. NamedLock)

0h0 2023. 11. 15. 00:25

들어가면서

이전 포스팅에서 구현한 namedLock 에 이어서 multi-datasource 와 AOP 를 적용하는 과정에 대해 적어보겠습니다.

전체 코드 github입니다.

Multi-datasource 가 필요한 이유

namedLock 을 얻는 datasource 와 다른 비즈니스 로직을 수행하기 위한 datasource가 동일한 경우 커넥션이 부족할 수 있습니다.

 

만약, namedLock 을 얻으려는 요청이 갑자기 몰린다고 생각해봅시다. 그러면 해당 요청에 할당된 커넥션은namedLock 을 얻기 위해 기다릴 것입니다. 물론 timeout 을 설정해서 대기 시간을 조절할 수 있지만, 해당 시간 동안 비즈니스 로직 수행에 필요한 커넥션을 사용한다는 사실은 변하지 않습니다.

이러한 이유로 namedLock 을 얻고 해제하는 datasource 를 별도로 설정하면 커넥션 부족할 수 있다는 문제를 예방할 수 있습니다.

Multi-datasource 분리하기

application.yml

spring:
  datasource:
    main: // main datasource
      jdbc-url: jdbc:mysql://localhost/experiment
      username: root
      password: root  
	  pool-name: Spring-HikariPool-lock
    lock: // lock datasource
      jdbc-url: jdbc:mysql://localhost/experiment
      username: root
      password: root
			max-lifetime: 60000
      connection-timeout: 5000
      pool-name: Spring-HikariPool-lock-aop

 

이런 식으로 분리할 수 있습니다. 그리고 이를 통해 namedLock 을 얻기 위한 datasource 에는 별도의 connection-timeout 값과 커넥션 풀 사이즈 등의 설정을 별도로 지정할 수 있습니다.

 

이제 namedLock 을 얻는 LockRepository의 datasource에만 application.yml 에 주석으로 작성한 lock datasource 를 주입해줘야 합니다.

 

DatasourceConfig.java

@Configuration
public class DatasourceConfig {

    @Primary
    @ConfigurationProperties("spring.datasource.main")
    @Bean
    public DataSource mainDatasource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @ConfigurationProperties("spring.datasource.lock")
    @Bean
    public DataSource lockDatasource() {
        return DataSourceBuilder.create().type(HikariDataSource.class).build();
    }

    @Bean
    public LockRepository lockRepository() {
        return new LockRepository(lockDatasource());
    }
}

@ConfigurationProperties 을 통해 application.yml 에 작성한 값을 인식해 별도의 DataSource 빈을 등록합니다. 이후 namedLock 을 관리하는 LockRepository빈 등록 시 lockDatasource 를 주입함으로써 별도의 Datasource 를 사용할 수 있습니다.

실제 로그를 통해 확인해보겠습니다.

 

 

LockRepository.java

가독성을 위해 로그를 제외한 코드는 지웠습니다.

@Slf4j
@RequiredArgsConstructor
public class LockRepository {

    private final DataSource dataSource;

    public void executeWithLock(String userLockName,
                                int timeoutSeconds,
                                Runnable runnable) {

        try (Connection connection = dataSource.getConnection()) {
            log.info("lock datasource: {}", dataSource);
            // namedLock 획득
			// 비즈니스 로직 수행
			// namedLock 반환
        } catch (SQLException | RuntimeException e) {
            throw new RuntimeException(e.getMessage(), e);
        }
    }
}

 

LectureService.java

@RequiredArgsConstructor
@Slf4j
@Service
public class LectureService {

    private final LectureRepository lectureRepository;
    private final LectureStudentRepository lectureStudentRepository;
    private final DataSource dataSource;

    @Transactional
    public void enrolmentWithNamedLock(Long lectureId, Long studentId) {
        log.info("main datasource: {}", dataSource);
        Lecture lecture = lectureRepository.findById(lectureId)
                .orElseThrow();

        if (lecture.isPossibleEnrolment()) {
            lectureStudentRepository.save(new LectureStudent(lectureId, studentId));
            lecture.decrease();
            return;
        }

        log.info("{} 수강신청 실패", Thread.currentThread().getName());
    }
}

 

namedLock을 관리하는 LockRepository 와 비즈니스 로직을 수행하는 LectureService 가 별도의 히카리풀 을 사용하는 것을 pool-name 이 다른 것을 통해 확인할 수 있습니다.

AOP 를 통해 손쉽게 namedLock 적용하기

namedLock 을 통한 동시성 제어의 흐름을 생각해보면 namedLock 획득 → 비즈니스 로직 수행 및 커밋 → namedLock 반환 입니다.

즉, 동시성 제어가 필요한 메서드의 실행 전후로 namedLock 획득, 해제가 이루어지면 됩니다. 그렇다면 이를 AOP와 별도의 어노테이션 생성을 통해 동시성 제어가 필요한 메서드에 손쉽게 적용할 수 있을 것 같다는 생각이 들었습니다.

 

NamedLock.annotation

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

    String lockKey();

    int timeout() default 3000;
}

namedLock 키이름은 무조건 입력받도록 하고, timeout은 기본 3000으로 설정 했습니다.

 

NamedLockAop.java

@Slf4j
@RequiredArgsConstructor
@Aspect
@Order(Ordered.LOWEST_PRECEDENCE - 1)
public class NamedLockAop {

    private static final String GET_LOCK = "SELECT GET_LOCK(?, ?)";
    private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(?)";

    private final DataSource dataSource;

    @Around("@annotation(com.example.experiment.mysqlnamedlock.NamedLock)")
    public void lock(ProceedingJoinPoint joinPoint) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        NamedLock namedLock = method.getAnnotation(NamedLock.class);

        String lockKey = namedLock.lockKey();
        int timeout = namedLock.timeout();
        log.info("lockKey: {}, timeout: {}", lockKey, timeout);
        try (Connection connection = dataSource.getConnection()) {
            log.info("lock with aop datasource: {}, thread: {}", dataSource, Thread.currentThread().getName());
            try {
                getLock(connection, lockKey, timeout);
                log.info("getLock= {}, timeoutSeconds = {}, connection = {}, thread: {}", lockKey, timeout, connection, Thread.currentThread().getName());
                joinPoint.proceed(); // 비즈니스 로직 수행
            } catch (Throwable e) {
                throw new RuntimeException(e);
            } finally {
                releaseLock(connection, lockKey);
                log.info("releaseLock = {}, connection = {}, thread = {}", lockKey, connection, Thread.currentThread().getName());
            }
        } catch (SQLException | RuntimeException e) {
            throw new RuntimeException(e.getMessage(), e);
        }
    }

    private void getLock(Connection connection,
                         String userLockName,
                         int timeoutseconds) throws SQLException {

        try (PreparedStatement preparedStatement = connection.prepareStatement(GET_LOCK)) {
            preparedStatement.setString(1, userLockName);
            preparedStatement.setInt(2, timeoutseconds);
            preparedStatement.executeQuery();
        }
    }

    private void releaseLock(Connection connection,
                             String userLockName) throws SQLException {
        try (PreparedStatement preparedStatement = connection.prepareStatement(RELEASE_LOCK)) {
            preparedStatement.setString(1, userLockName);
            preparedStatement.executeQuery();
        }
    }
}

namedLock 획득, 해제하는 과정은 이전 포스팅과 동일합니다. 다만 AOP 적용을 통해 @NamedLock 이 붙어있는 메서드를 탐지해 해당 메서드 실행 시 실행 전후로 namedLock 획득, 해제 하도록 구현했습니다.

 

LectureService.java

@RequiredArgsConstructor
@Slf4j
@Service
public class LectureService {

    private final LectureRepository lectureRepository;
    private final LectureStudentRepository lectureStudentRepository;
    private final DataSource dataSource;

    @NamedLock(lockKey = "lecture_enrolment")
    @Transactional
    public void enrolmentWithNamedLockAndAop(Long lectureId, Long studentId) {
        Lecture lecture = lectureRepository.findById(lectureId)
                .orElseThrow();

        if (lecture.isPossibleEnrolment()) {
            lectureStudentRepository.save(new LectureStudent(lectureId, studentId));
            lecture.decrease();
            return;
        }

        log.info("{} 수강신청 실패", Thread.currentThread().getName());
    }
}

이처럼 동시성 제어가 필요한 메서드에 @NamedLock 을 통해 손쉽게 namedLock 적용이 가능해졌습니다.

테스트 해보기

@Test
void 다중_요청_수강_신청() throws InterruptedException {
    int maxLectureStudentNum = 10;
    Long lectureId = 강의등록(maxLectureStudentNum);

    int studentsNum = 50;
    ExecutorService executorService = Executors.newFixedThreadPool(15);
    CountDownLatch countDownLatch = new CountDownLatch(studentsNum);

    for (int i = 0; i < studentsNum; i++) {
        executorService.submit(() -> {
            try {
                락과_AOP를_이용한_수강신청(lectureId);
            } finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();

    int lectureStudentNum = 강의_수강생_조회(lectureId);
    assertThat(lectureStudentNum).isEqualTo(maxLectureStudentNum);
}

private void 락과_AOP를_이용한_수강신청(Long lectureId) {
    RestAssured.given()
            .contentType(JSON)
            .body(new LectureRequest(3L))
            .when()
            .post("/lecture/namedLock/aop/{lectureId}", lectureId)
            .then()
            .extract();
}

private int 강의_수강생_조회(Long lectureId) {
    ExtractableResponse<Response> extract = RestAssured.given()
            .contentType(JSON)
            .when()
            .get("/lecture/{lectureId}", lectureId)
            .then()
            .extract();

    return extract.response().body().as(Integer.class);
}

 

주의점

@NamedLock, @Transactional 모두 AOP 를 이용하고 있기 때문에 어느 AOP가 먼저 수행되느냐는 매우 중요합니다. 현재 저희는 반드시 @NamedLock 어노테이션이 먼저 수행되어야 합니다.만약, @Transactional 이 먼저 수행되면 다음과 같은 문제점이 있습니다.

트랜잭션 시작 → namedLock 획득 → 비즈니스 로직 수행(커밋 X) → namedLock 해제 → 비즈니스 로직 커밋

 

이런 흐름으로 동작하면 전혀 동시성 제어가 안됩니다. 왜냐하면 namedLock이 해제되고 커밋 전에 컨텍스트 스위칭이 발생할 수 있습니다. 이로 인해, 새로운 커넥션을 통해 값을 읽으면 해당 값은 커밋 되기 전 값이기 때문입니다.

 

그래서 NamedLockAop.java 를 보면 @Order 를 통해 @Transactional 보다 먼저 수행되도록 했습니다.

공식문서를 보면 @Transactional 의 order값은 Ordered.LOWEST_PRECEDENCE 로 되어 있다고 나와있습니다. 그래서 NamedLockAop의 @Order 는 Ordered.LOWEST_PRECEDENCE - 1 로 설정했습니다.

마무리

Multi-datasource 를 통해 커넥션이 부족할 수 있는 상황을 예방하고 AOP 를 통해 손쉽게 namedLock 을 적용해봤습니다.

 

namedLock 을 구현하면서 느낀 점은 커넥션 풀의 개수, timeout 시간 등 설정 값을 팀원과 협의하에 적절한 값으로 설정하는 것이 중요해보입니다. timeout 을 너무 길게 설정하면 커넥션 고갈 문제가 발생할 수 있기 때문입니다.

 

그리고 특정 자원에 대한 동시성이 요구된다면 비관적 락이나 낙관적 락도 충분히 고려할만 한 것 같습니다. 다만, namedLock 은 존재하지 않는 자원 즉 insert 되는 작업들에 대한 동시성 처리가 가능하기 때문에 이를 고려하여 동시성 제어 방법을 선택하면 좋아보입니다.

Comments