Posts 애플리케이션에서의 동시성 처리
Post
Cancel

애플리케이션에서의 동시성 처리

동시성 문제 해결 위치

데이터베이스와 애플리케이션에서의 동시성 문제

지난번 포스팅에서 동시성 문제를 해결하기 위해 낙관적 락, 비관적 락을 비교해보았다. 두 방법은 모두 데이터베이스에서 트랜잭션이 작업을 처리하는 도중에 락을 이용하여 동일 자원에 동시 접근을 방지하거나 동시 접근을 감지하여 재시도하는 방법으로 동시성 문제를 해결했다.

하지만 데이터베이스 레벨에서의 동시성 처리는 아래와 같은 단점이 있다.

  • 데이터베이스가 분산된 환경이라면 락이 적용되지 않아 동시성 처리를 해결할 수 없다.
  • 데이터베이스에 요청이 발생한 뒤에 대기 또는 재시도가 발생하므로 성능이 저하될 수 있다.
  • 데이터베이스 뿐만 아니라 외부 API를 호출하는 경우에 대한 중복 요청 발생을 방지할 수 없다.

결국 비관적 락과 낙관적 락은 무조건 데이터베이스에 요청이 전달되므로 서비스에 발생하는 요청이 많아지면 데이터베이스에도 요청이 많아지고, 이로 인한 트랜잭션 대기나 재시도로 인해 커넥션 사용이 증가하며 성능이 저하될 수 있다. 하지만 이를 해결하고자 데이터베이스에 수평 확장을 진행한다면 동시성 문제는 다시 발생하게 되며, 외부 API의 중복 호출은 처음부터 예방할 수 없다.

이를 해결하기 위해 애플리케이션 레벨에서의 동시성 처리 방법도 존재한다. 애플리케이션 레벨에서의 동시성 처리가 가지는 장점은 아래와 같다.

  • 애플리케이션에서 락을 관리하므로 동시성 처리 과정에서 트랜잭션 대기나 재시도 로직으로 인한 인한 커넥션 점유가 적다.
  • 분산 락의 경우 서버나 데이터베이스가 분산된 환경에서도 적용 가능하다.
  • 락의 대상이 임계 영역이기 때문에 외부 API의 중복 호출까지 방지할 수 있다.

애플리케이션 레벨에서의 동시성 처리

애플리케이션 레벨에서 동시성 문제를 해결하는 방법으로는 대표적으로 재진입 락(ReentrantLock)과 분산 락(Distributed Lock)이 있다.

재진입 락(ReentrantLock)

재진입 락(ReentrantLock)은 스레드 간의 동기화를 위한 자바 표준 라이브러리의 기능이다.

코드 상에서 임계 구역을 잠금 영역으로 설정할 수 있으며, 락의 획득과 해제를 수동으로 관리할 수 있다.

락의 대상이 애플리케이션의 임계 구역이므로 낙관적 락이나 비관적 락과 달리 샤딩을 통해 데이터베이스를 분산하여 사용하는 경우에도 동시성 문제를 해결할 수 있다.

아래와 같이 락을 관리 및 획득, 해제하는 빈을 생성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Component
public class ReentrantLockManager {
	// fair를 true로 설정하여 대기 시간이 오래된 요청부터 처리
	private final ReentrantLock lock = new ReentrantLock(true);

	public <T> T executeWithReentrantLock(Supplier<T> task) {
		return executeInternalWithReentrantLock(task);
	}

	public void executeWithReentrantLock(Runnable task) {
		executeInternalWithReentrantLock(() -> {
			task.run();
			return null;
		});
	}

	private <T> T executeInternalWithReentrantLock(Supplier<T> task) {
		boolean isLocked = false;
		try {
			isLocked = lock.tryLock(5, TimeUnit.SECONDS);
			if (!isLocked) {
				throw new LockAcquisitionTimeoutException(ErrorCode.LOCK_ACQUISITION_TIMEOUT);
			}
			return task.get();

		} catch (InterruptedException e) {
			// 인터럽트 플래그를 복원
			Thread.currentThread().interrupt();
			throw new ServerInternalException(ErrorCode.THREAD_INTERRUPT);
		} finally {
			if (isLocked) {
				lock.unlock();
			}
		}
	}
}

개인이나 팀의 스타일에 따라 다르지만 재사용성을 높이기 위해 SupplierRunnable로 임계 구역에 해당하는 로직을 주입받도록 구현한 뒤 외부에서 호출한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
@RequiredArgsConstructor
public class LectureFacadeService {
	private final LectureService lectureService;
	private final ReentrantLockManager reentrantLockManager;
    ...

	public void tryRegisterStudentToLecture(final long lectureId, final long studentId) {
		reentrantLockManager.executeWithReentrantLock(
				() -> lectureService.registerStudentToLecture(lectureId, studentId)
		);
    ...
	}
}

주의해야할 점은 임계 구역에 해당하는 메서드가 트랜잭션 내에서 동작할 경우 재진입 락으로 로직을 실행하는 부분과 클래스를 분리해야 한다.

Spring의 @Transactional은 프록시 기반 AOP이므로 외부 클래스에서 해당 메서드를 호출한 경우에만 트랜잭션이 정상적으로 동작하기 때문이다.

@Transactional에 의한 트랜잭션은 해당 빈의 메서드를 호출했을 때 해당 빈을 프록시 객체로 감싼 뒤에 프록시가 메서드의 호출을 가로채서 동작하게 된다.

따라서 애플리케이션 내에서 락을 적용하거나 스레드간의 동기화를 처리하는 로직이 트랜잭션내에서 동작하는 로직과 같은 클래스에 존재할 경우 프록시를 거치지 않으므로 트랜잭션의 동작 전체가 동기화되지 않고, 트랜잭션의 생성과 커밋, 롤백을 제외한 내부 로직만 동기화가 적용된다.

위와 같이 재진입 락을 구현한 뒤에 30명의 학생이 수강 인원이 20명인 강의에 대해 동시에 수강 신청을 하는 경우를 테스트한 결과, lecture테이블의 잔여 좌석에 대한 카운트가 정상적으로 동작하였고, 수강 정보인 lecture_student에 등록된 학생의 수와 일치하는 것을 확인할 수 있었다.

1
2
3
4
5
+----------------+------------+------------+-----------------------+
| remaining_seat | total_seat | lecture_id | title                 |
+----------------+------------+------------+-----------------------+
|              0 |         20 |          1 | 컴퓨터구조              |
+----------------+------------+------------+-----------------------+

하지만 위의 방법에 대해 성능 지표를 측정한 결과 평균 응답 속도 67ms, 처리량 0.618/sec로 낙관적 락이나 비관적 락보다는 응답 속도가 조금 빠르지만 처리량이 매우 낮은 것을 확인할 수 있었다.

이는 락에 의한 대기가 데이터베이스가 아닌 애플리케이션에서 발생하기 때문임을 추측할 수 있다. 모든 요청을 병렬로 처리한 뒤에 충돌을 감지하는 낙관적 락이나, 공유 자원에 접근하는 쿼리에 대해서만 락을 적용하는 비관적 락에 비해, 재진입 락은 임계 구역으로 지정하는 전체 로직을 순차 처리하기 때문에 처리량이 훨씬 떨어지게 된다.

반면에 재진입 락은 JVM 내부에서 AbstractQueuedSynchronzier에 의해 대기되는 스레드가 FIFO 또는 priority queue로 관리되어 디스크 IO나 스케줄러의 동작 등이 없어 컨텍스트 스위칭 비용이 매우 낮지만, DB락은 트랜잭션 대기로 인한 커넥션 점유로 추가적인 대기가 발생하고 DBMS 내부적으로 스케줄러의 동작 등으로 인해 컨텍스트 스위칭 비용이 높아 상대적으로 응답 속도가 느려지게 된다.

따라서 이를 해결하기 위해 재진입 락이 적용될 단위를 설정하여 병렬성을 높여주는 것이 바람직하다.


참고자료 :

https://medium.com/sopt-makers

https://miiiinju.tistory.com

This post is licensed under CC BY 4.0 by the author.

Contents

NoSQL과 MongoDB

-