영호

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

개발지식

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

0h0 2023. 11. 14. 23:29

들어가면서

분산서버에서 발생하는 동시성 문제를 어떻게 해결할 수 있을까 고민하다가 namedLock 이란 개념에 대해 알게되어 이를 간단하게 구현해본 과정을 정리하려고 합니다.

 

혹시 잘못된 부분 있으면 언제든지 댓글 달아주시면 감사하겠습니다~

전체코드 주소입니다. 다음 포스팅 (AOP, multi-datasource 적용)

예제 상황

수강신청 상황을 예제로 사용할 예정입니다. 정원이 있는 강의에 여러 명의 사용자가 수강신청 요청을 보낼 때, 강의 정원 만큼만 수강신청이 가능한 상황입니다.

물론 수강신청은 순서도 중요하지만 이번 포스팅에선 동시성 제어에만 초점을 맞춰주시면 감사하겠습니다.

Lecture 는 강의 엔티티이고 강의 정원 (restCount) 필드를 가지고 있습니다. LectureStudent 엔티티는 강의에 수강신청한 학생들의 목록을 관리하고 있습니다.

 

수강신청의 흐름은 아래와 같습니다.

  1. 인자로 받은 lectureId 를 통해 lecture 를 조회한다.
  2. lecture 의 정원이 남았다면 LectureStudent 에 수강신청 목록 데이터를 추가한다.
  3. lecture 의 남은 정원에 -1 을 한다.

namedLock 사용 이유

RealMySQL 책을 보다가 namedLock을 활용하면 분산서버에서 발생하는 동시성 문제를 쉽게 해결할 수 있다는 내용을 보고 직접 해보고 싶어져서 namedLock을 사용했습니다.

비관적락이나 낙관적락을 사용해도 동시성 문제를 제어할 수 있습니다.

예제 코드

Lecture.java

@Entity
public class Lecture {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    private Integer restCount; // 남은 정원

    public Lecture() {
    }

    public Lecture(Integer restCount) {
        this.restCount = restCount;
    }

    public Lecture(Long id, Integer restCount) {
        this.id = id;
        this.restCount = restCount;
    }

    public boolean isPossibleEnrolment() {
        return restCount > 0;
    }

    public void decrease() {
        restCount--;
    }

    public Long getId() {
        return id;
    }
}

LectureStudent.java

@Entity
public class LectureStudent {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    private Long lectureId;
    private Long studentId;

    public LectureStudent() {
    }

    public LectureStudent(Long lectureId, Long studentId) {
        this.lectureId = lectureId;
        this.studentId = studentId;
    }
}

LectureRepository.java

@Repository
public interface LectureRepository extends JpaRepository<Lecture, Long> {
}

LectureStudentRepository.java

@Repository
public interface LectureStudentRepository extends JpaRepository<LectureStudent, Long> {

    int countByLectureId(Long lectureId);
}

LectureService.java

@RequiredArgsConstructor
@Slf4j
@Service
public class LectureService {

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

    @Transactional
    public Long create(int number) { // 강의 생성
        Lecture lecture = new Lecture(number);
        return lectureRepository.save(lecture).getId();
    }

    @Transactional
    public void enrolment(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());
    }

    @Transactional(readOnly = true)
    public int countLectureStudents(Long lectureId) { // 강의 수강 신청 인원 조회
        lectureRepository.findById(lectureId).orElseThrow();
        return lectureStudentRepository.countByLectureId(lectureId);
    }
}

동시성 테스트

RestAssured와 CountDownLatch 를 활용해 테스트 해보겠습니다. api 코드는 github에 있습니다.

수강신청이 완료되면 강의 정원인 10명만 수강신청 목록에 있기를 기대하고 있습니다.

@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 {
                수강신청(lectureId);
            } finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();

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

private void 수강신청(Long lectureId) {
    RestAssured.given()
            .contentType(JSON)
            .body(new LectureRequest(3L))
            .when()
            .post("/lecture/{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);
}

 

39명이나 수강신청에 성공해 테스트가 실패합니다.

namedLock 구현

이제 namedLock을 이용해 여러 요청이 동시에 와도 정합성을 유지해보겠습니다.

 

기본적으로 MySQL의 namedLock은 SELECT GET_LOCK(keyName, timeout)SELECT RELEASE_LOCK(keyname) 를 통해 namedLock을 획득하고 해제할 수 있습니다. 명시적으로 락을 획득하는 만큼 반드시 명시적으로 락을 해제해줘야 합니다.

timeout 은 namedLock 을 얻기 위한 최대 대기 시간입니다. 이를 통해 무한 대기를 방지할 수 있습니다.

namedLock 과 비즈니스 로직 수행 흐름

namedLock을 구현할 때 중요한 점은 반드시 namedLock 획득 → 비즈니스 로직 commit → namedLock 해제 순으로 이루어져야 합니다. 아래 코드의 경우를 살펴보겠습니다.

@Transactional
public void create(){
	// getLock
	// 비즈니스 로직
	// releaseLock
}

비즈니스 로직이 커밋되기 전에 namedLock 이 해제됩니다. 이로 인해, namedLock이 해제되고 비즈니스 로직이 commit 되기 전에 다른 커넥션이 namedLock 을 점유해 자신의 작업을 수행할 수 있습니다.

 

그러면 namedLock 을 사용하기 전과 마찬가지로 동시성 문제가 발생하게 됩니다. 그래서 반드시 namedLock 획득 → 비즈니스 로직 commit → namedLock 해제 순으로 동작해야 합니다.

이제 구현 코드를 살펴보겠습니다.

 

LockRepository.java

@Slf4j
@RequiredArgsConstructor
@Repository
public class LockRepository {

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

  private final DataSource dataSource;

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

      try (Connection connection = dataSource.getConnection()) {
          log.info("lock datasource: {}", dataSource.toString());
          try {
              getLock(connection, userLockName, timeoutSeconds);
              log.info("getLock= {}, timeoutSeconds = {}, connection = {}, thread: {}", userLockName, timeoutSeconds, connection, Thread.currentThread().getName());
              runnable.run();

          } finally {
              releaseLock(connection, userLockName);
              log.info("releaseLock = {}, connection = {}, thread = {}", userLockName, 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);
      }
  }

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

락을 얻고 해제하는 코드는 단순히 datasource 를 활용해 쿼리만 실행하는 거라 링크 코드를 참고했습니다.

 

getLock() 메서드는 SELECT GET_LOCK(?, ?) 쿼리를 통해 namedLock 을 획득합니다. releaseLock() 메서드는 SELECT RELEASE_LOCK(?) 쿼리를 통해 비즈니스 로직 수행 후 namedLock 을 해제합니다.

비즈니스 로직 수행은 Runnable 을 활용했습니다.(이 부분은 링크의 예제 코드와 다릅니다 ㅎ)

getLock() → 비즈니스 로직 수행 및 커밋 → releaseLock() 순 입니다.

 

JpaRepository 를 사용하지 않은 이유는 namedLock 을 얻을 때는 어떤 엔티티 객체도 필요하지 않기 때문입니다.

JdbcTemplate 의 경우 namedLock 을 얻기위해 쿼리를 날리면 해당 커넥션이 반환됩니다. 그러나, namedLock 은 커넥션 단위에 걸리는 것이기 때문에 반환 전에 반드시 namedLock 을 해제 해줘야 하기 때문에 Datasource 를 활용했습니다.

 

FacadeLectureService.java

@RequiredArgsConstructor
@Component
public class FacadeLectureService {

    private final LectureService lectureService;
    private final LockRepository lockRepository;

    public void enrolment(Long lectureId, Long studentId) {
        lockRepository.executeWithLock("lecture_enrolment", 3000,
                () -> lectureService.enrolmentWithNamedLock(lectureId, studentId));
    }
}

비즈니스 로직과 namedLock을 얻는 작업을 분리하기 위해 Facade 패턴을 사용했습니다.

다시 테스트 해보기

@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 {
                락을_이용한_수강신청(lectureId);
            } finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();

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

private void 락을_이용한_수강신청(Long lectureId) {
    RestAssured.given()
            .contentType(JSON)
            .body(new LectureRequest(3L))
            .when()
            .post("/lecture/namedLock/{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);
}

생각해볼점

커넥션 부족

현재 구조는 lock 을 얻기 위한 Datasource와 비즈니스 로직을 수행하는 Datasource가 동일합니다. 이로 인해, lock을 얻기 위한 커넥션들로 인해 비즈니스 로직을 수행할 커넥션이 부족해질 수 있습니다.

이를 해결하기 위해 다음 포스팅에서는 multi-datasource 를 통해 각각의 Datasource를 분리해보겠습니다.

 

손쉽게 namedLock 적용해보기

위 코드를 구현하다 보니 메서드에 어노테이션을 붙이는 것으로 namedLock을 적용할 수 있을 것 같습니다. 비즈니스 로직 수행 전후로 namedLock 획득, 해제만 해주면 되기 때문입니다.

다음 포스팅에서 multi-datasource 와 AOP 적용을 다뤄보겠습니다.

 

namedLock 을 획득한 커넥션이 있는 프로세스의 ec2 가 다운됐을 때는 어떻게 namedLock 을 반환할까?

비즈니스 로직을 수행하다 발생하는 예외는 try…catch…finally 를 통해 namedLock을 해제할 수 있습니다. 하지만 namedLock을 점유하고 ec2 가 namedLock을 해제하기 전에 죽는 경우는 try…catch…finally로 가능하지 않습니다.

 

이 경우 커넥션 타임아웃으로 인해 커넥션이 종료될 경우 자동적으로 namedLock 을 해제해주고 있습니다. 실제로 mysql 에서 namedLock 을 점유하고 있는 스레드를 kill 명령어를 이용해 죽이면 해당 스레드의 커넥션이 점유하고 있는 namedLock 이 해제됩니다.

마무리

직접 구현하면서 느낀 점은 namedLock 은 특정 자원에 대한 잠금이 아니라 어떠한 행위를 하기 위해 팀 내부적으로 약속한 잠금이라는 느낌을 받았습니다.

 

만약 A테이블과 B테이블에 각각 잠금을 거는 트랜잭션 TX1, TX2 가 있다고 가정해봅시다. TX1 은 B테이블에 잠금을 걸고 컨텍스트 스위칭이 발생해 TX2 가 A테이블에 잠금을 겁니다. 이후 B테이블에 잠금을 걸기 위해 TX1 이 잠금을 해제하길 기다립니다.

 

이런 작업들은 ‘namedLock’ 이라는 문자열에 대한 잠금을 얻고 자신의 작업을 수행하도록 설정하면 데드락을 피할 수 있습니다. TX1은 자신의 작업을 하기 위해 ‘namedLock’ 문자열에 대해 잠금을 얻고 B테이블에 잠금을 겁니다.

이후 컨텍스트 스위칭이 일어나 TX2 가 자신의 작업을 위해 ‘namedLock’ 문자열에 대한 잠금을 얻으려고 하지만 실패하고 다시 TX1으로 컨텍스트 스위칭이 발생합니다. TX1이 자신의 작업을 마무리 하고 namedLock 을 반환하면 TX2 가 namedLock 을 얻고 자신의 작업을 마무리하는 방식으로 데드락 회피 및 동시성 제어가 가능합니다.

참고

https://techblog.woowahan.com/2631/

Comments