영호
AOP와 Multi-Datasource를 활용해 동시성 문제 해결해보기 2탄(feat. NamedLock) 본문
들어가면서
이전 포스팅에서 구현한 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 되는 작업들에 대한 동시성 처리가 가능하기 때문에 이를 고려하여 동시성 제어 방법을 선택하면 좋아보입니다.
'개발지식' 카테고리의 다른 글
AOP와 Multi-Datasource를 활용해 동시성 문제 해결해보기 1탄(feat. NamedLock) (3) | 2023.11.14 |
---|---|
[CLI] Linux CLI |(pipe), ||(or) (0) | 2022.05.22 |
[개발지식] Library vs Framework (0) | 2022.05.17 |