다중화 어플리케이션 환경에서 동시성 처리하기 #2
Replies: 7 comments 31 replies
|
동시성 공부하다, 재밌는 예제를 발견해서요ㅎ public class TestApplication {
private static boolean stopRequested = false;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (stopRequested) {
i++;
}
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
} |
Pessimistic and Optimistic Locking MechanismLocking Mechanism 은 둘 이상의 서로 다른 프로세스/스레드(이하 스레드)가 동시에 공유 자원에 접근함을 통해 예측할 수 없고 프로그래머가 의도하지 않은 동시적인 이슈를 해결하기 위한 방법입니다. Locking Mechanism 은 크게 Pessimistic Locking 과 Optimistic Locking 으로 나뉩니다. Pessimistic Locking 은 다른 스레드가 특정 자원에 대한 접근을 끝낼 때까지 다른 스레드가 Lock 을 얻지 못하고 기다리는 것을 의미하구요. Optimistic Locking 은 여러 스레드가 특정 자원에 동시에 접근을 허용하고 변화를 영속화하는 시점에 자원에 대한 충돌을 검사하는 것입니다. Pessimistic Locking 의 장점은 우선 매우 간단하다는 것이고요. 하나의 스레드가 공유 자원에 접근하는 과정에서 다른 스레드의 접근을 아예 허용조차 하지 않기 때문에 데이터의 무결성을 지킬 수 있다는 장점이 있습니다. 반면, 단점은 다른 스레드의 block 을 야기시킬 수 있기 때문에 성능 저하나 응답 속도 감소 등이 발생할 여지가 있구요. 특정 스레드가 Lock 이 풀리기 기다리는 Deadlock 이 발생할 수 있습니다. Optimistic Locking 의 장점은 기본적으로 스레드를 Block 하지 않기 때문에 스레드 간의 경합이 적다면 성능이나 응답 속도가 Pessimistic Locking 에 비해 훨씬 빠르고요. 구현에 따라 다르지만 각 스레드가 Lock 을 최소한으로 얻거나 필요없는 경우도 있기 때문에 Deadlock 으로부터 비교적 자유롭습니다. 반면 단점은 충돌을 해소해야 하기 때문에 구현이 매우 복잡하고 사용이 불가능한 요구사항이 존재할 수 있다는 점인데요. 이 뿐만 아니라 구현에 따라서 일시적으로 일관되지 못한 데이터를 바라볼 수도 있다는 점을 감안해야 합니다. 각각의 동작방식을 간단히 살펴보면 다음과 같습니다. 이는 Pessimistic 과 Optimistic Locking Mechanism 의 여러 구현방식 중 가장 간단하면서 일반적인 구현 방식을 사용해서 이 두 방식을 설명한 예제입니다. 더 많고 디테일한 방법들이 많으니 요것이 이 두 방법의 전부라고 생각하지 않으셨으면 좋겠습니다. Pessimistic Locking Mechanism in A Thread.
Optimistic Locking Mechanism in A Thread.
Pessimistic 과 Optimistic Locking Mechanism 을 구현하기 위한 여러 방법들이 존재하는데 대표적인 방법은 다음과 같습니다. pessismistic locking implementations
optimistic locking implementations
|
|
사실 이번에 공부하고자 했던게 분산락이었는데, 오늘 synchronized랑 volatile 보다가 시간 다 가버렸네요.. |
[Sprout-Symposium] 다중화 어플리케이션 환경에서 동시성 처리하기들어가며동시성 문제가 일어나는 여러 가지 상황이 있겠지만 저는 2가지 상황을 떠올릴 수 있었습니다.
이번 글에서는 "순서가 중요한 동시성" 문제를 어플리케이션에서 어떻게 해결할 수 있을지 고민한 내용을 정리해보려 합니다. Redisson 분산락다중 어플리케이션 환경에서 동시성 문제 해결하기 위한 솔루션을 검색하며 Redisson을 활용한 분산락에 관한 내용을 접할 수 있었습니다. 주로 위의 코드와 같이
예상되는 문제하지만 위의 구현과 같이 일정 시간 동안 락을 획득하기 위해 시도하고 락을 획득하지 못하면 포기한다면 아래 이미지와 같은 문제가 일어날 수 있다고 생각합니다. 설명 : 스핀락요청의 순서와 상관없이 처리되는 데이터가 문제가 없는 것이 중요한 상황이라면 이는 문제가 되지 않을 수 있지만 순서가 중요한 상황이라면 Redisson을 활용한 분산락은 적절한 선택이 되지 못할 것이라 생각하였습니다. 이에 스핀락에 조금의 구현을 추가하여 순서가 보장되는 락을 구현해보았습니다.
이에 해당 인터페이스를 우선 로컬 환경에서 구현한다면 아래의
이를 다중화 어플리케이션 환경에 적용하기 위해서 Redis를 활용한 구현으로 변경한다면 아래와 같이 구현할 수 있습니다.
한계이렇게 스핀락에서도 순서를 보장하지 않는다면?스핀락에서도 순서를 보장하는 코드를 추가하지 않으면 아래 사진처럼 락 대신 큐의 ACK를 활용한 방법추가로 응답을 동기적으로 처리하는 것이 아닌 비동기적으로 처리하여도 괜찮은 경우 락을 대신해 큐의 ACK를 활용하여 보았습니다. 메시지 큐메시지 큐는 FIFO 형식의 자료구조를 가지고 있고 이를 통해 요청의 순서를 보장할 수 있다 생각하였습니다. 명시적 ACK
락을 획득하는 대신 큐에서 메시지를 받은 어플리케이션에서 요청에 대한 처리가 완료된 이후 명시적으로 ACK를 큐에 보낸다면 동시성 문제를 겪지 않을 수 있을 것이라 생각하였습니다. 이를 코드로 메시지를 전송하는 서비스 클래스와 메시지를 처리하는 핸들러 클래스를 통해 간단히 구현해보았습니다. 이를 다중화 어플리케이션 환경에 적용하기 위해서 RabbitMQ를 메시지 큐로 활용한 구현으로 변경한다면 아래와 같이 구현할 수 있습니다. 한계점과 개선 방안지금의 이에 메시지 큐 이전에 대기열 큐를 추가하여 이후 핸들러에 메시지를 하나씩 넘겨줌을 보장한다면 메시지를 처리하는 핸들러에서는 병렬적으로 메시지를 처리하여도 동시성 문제에서 벗어날 수 있을 것이라 생각하였습니다. queue-rabbitmq-waiting 구현 깃허브 바로가기 테스트상황 : 메시지 처리 과정에서 의도적으로 100ms의 지연을 추가하고 Jmeter를 통해 1011개의 요청을 처리하는 데 걸리는 시간 결과 :
3개의 핸들러가 메시지를 처리하는 만큼 3배 정도 빠른 결과를 확인할 수 있었고 대기열 큐로 인해 동시성 문제도 겪지 않음을 확인할 수 있었습니다. 참고 https://helloworld.kurly.com/blog/distributed-redisson-lock/ https://incheol-jung.gitbook.io/docs/q-and-a/spring/redisson-trylock |
|
추가적인 인프라를 도입하는 것은 언제나 가능한 일도 아니며 관리 포인트와 장애 유발 포인트를 하나 더 늘리는 것이라고 생각해서 최후의 보루라는 생각이 듭니다. 결국 서비스 관점에서 수많은 고객이 같은 자원에 접근하는 경우는 제한된 수량이 있는 경우일 것 같습니다. 제한된 수량이 있고 트래픽이 높은 환경이라면 낙관락 방식을 먼저 고려하거나 DB 제약조건(constraint 로 count 에 조건 걸기) 을 쉽게 적용하는 것으로 끝낼 것 같습니다. 여러 고객이 실패 알림으로 인해 불편함을 겪겠지만 성능과 편의를 둘다 챙길 수 있는 방법은 딱히 본 적이 없는 것 같네요. 그리고 admin 환경에서는 보통 트래픽이 높지 않으니 단순히 비관락을 적용할 것 같습니다. 이 주제에 대해서는 다양한 동시성 이슈에 대한 사례를 모아보는 것이 좋지 않을까라는 생각도 드네요. 그리고 동시성/병렬성 관련해서는 실용주의 프로그래머 6장(동시성 241p) 과 가상 면접 사례로 배우는 대규모 시스템 설계 2 7장(호텔 예약 시스템 231p) 를 한번씩 보시는 것도 도움이 많이 될 것 같아서 추천드려봅니다. 이번 주제에 대해 따로 정리는 못해서 죄송한 마음에 스레드들 읽고 의견만 남겨보고 갑니다. ps. 레디스 없던 시절엔 어떻게 했을까요? |
동시성 문제다수의 스레드가 동시에 공유 자원에 접근할 때 발생한다. 애플리케이션 개발에서의 동시성다중 스레드애플리케이션 개발에 주로 사용하는 스프링 부트의 경우 Tomcat을 내장하고 있다. Tomcat은 다중 요청을 처리하기 위해, 부팅할 때 Thread Pool을 생성한다. 애플리케이션에서는 미리 생성한 Thread Pool의 Thread를 통해 유저의 요청을 처리한다. 이때 Thread는 동시에 공유 자원에 접근 할 수 있고 그 결과 동시성 문제가 발생할 수 있다. 공유 자원애플리케이션 개발에서 공유자원은 DB에 저장된 정보가 대표적일 것 같다. DB 공유 자원DB에 저장된 정보에 접근하여 정보를 변경할 수 있는 고객이 여러 명이다.
위와 같은 정보를 저장하고 있는
동시성 예시에서 가장 많이 등장하는 DB 공유 자원 동시성 문제 상황공유 정보의 값을 기반으로 공유 정보를 변경하는 요청을 수행하는 상황DB 공유 자원을 변경하는 이에 공유 정보의 값을 기반으로 공유 정보를 변경하는 추가 예시 )
공유 정보의 값을 기준으로 이후 작업을 수행하는 상황
이에 공유 정보의 값을 기준으로 이후 작업을 수행하는 작업이 동시에 3개 요청되어 기대하지 않았던 추가 예시 )
DB 공유 자원 동시성 문제 공통 사항동시에 들어온 공용 자원의 변경이 포함된 여러 요청이 서로 다른 스레드에서 동시에 수행되며 요청에 따른 공용 자원의 변경 이전의 값을 사용하여 요청을 처리하고 있어 동시성 문제가 발생하는 것을 확인할 수 있다. DB 공유 자원 동시성 문제 해결 방법
|
1. Single Thread @Test
@DisplayName("딘일 스레드 환경에서 좋아요 요청 테스트")
void single_thread_like() {
// when
for (Member member : members) {
postLikeService.like(member.getId(), new CreatePostLikeRequest(post.getId()));
}
// then
int likeCount = postRepository.getById(post.getId()).getLikeCount();
Assertions.assertEquals(EXPECTED_LIKE_SIZE, likeCount);
}2. Multi Thread @Test
@DisplayName("멀티 스레드 환경에서 좋아요 요청 테스트")
void multi_thread_like() throws InterruptedException {
// given
ExecutorService executorService = Executors.newFixedThreadPool(30);
CountDownLatch latch = new CountDownLatch(EXPECTED_LIKE_SIZE);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// when
for (Member member : members) {
executorService.submit(
() -> {
try {
postLikeService.like(member.getId(), request);
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
} finally {
latch.countDown();
}
});
}
latch.await();
// then
int likeCount = postRepository.getById(post.getId()).getLikeCount();
Assertions.assertEquals(EXPECTED_LIKE_SIZE, likeCount);
}멀티스레드 환경에서 별도의 처리를 하지 않았을 때는 테스트 결과가 실패하였다. 심지어 갱신 손실 문제가 발생할 것이라는 것은 예상할 수 있는 문제였지만 deadlock이 발생하는 신기한 현상이 일어났다. 교착 상태가 왜 발생하더라?둘 이상의 프로세스들이 **자원을 점유(Lock을 획득)**한 상태에서 서로 다른 프로세스가 점유하고 있는 **자원(Lock)**을 요구하며 무한정 기다리는 상황 그럼 LOCK이 사용되었다는 건데 내가 별도로 LOCK을 사용하지 않았으니깐 내가 사용한 쿼리에서 언제 LOCK을 사용하고 있는지 파악해보자 mysql 명령을 통해 deadlock history를 확인하였다. S-LOCK, X-LOCK은 무엇인가S-Lock(Shared Lock)
X-Lock(Exclusive Lock)
그렇다면 언제 S-LOCK, X-LOCK이 나간걸까?S-Lock이 사용되는 상황💡 fk가 있는 테이블에서, fk를 포함한 데이터를 insert, update, delete 하는 쿼리는 제약조건을 확인하기 위해 shared lock(s-lock)을 설정한다X-Lock이 사용되는 상황💡 update 쿼리에 사용되는 모든 레코드에 exclusive lock(x-lock)이 설정된다.MySQL은 Row-Lovel Locking의 특성을 가지고 있다. 즉, 특정 행에 대해서 잠금을 걸지 않고 행 전체에 대해서 잠금을 건다. 코드에서 Lock이 걸리는 원인 확인 public void like(Long memberId, CreatePostLikeRequest command) {
Post post = postRepository.getById(command.postId());
Member member = memberRepository.getById(memberId);
PostLike postLike = toPostLike(post, member);
postLike.like(postLikeValidator); // post에 대해 x-lock
postLikeRepository.save(postLike); // post에 대해 s-lock
}post_like - post의 관계는 1:1 연관관계를 가지고 있다. 즉, post_like에서는 post의 id를 fk로 사용하고 있다. 따라서!!!
데드락 발생원인
즉 서로 S-Lock을 얻고 X-Lock을 얻기 위해 서로를 대기하는 상황이 발생한다. 해결책 1. 점유 대기를 하는 상황을 없애버리자(with 낙관적 락) @Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Post p WHERE p.id = :postId")
Post findByIdWithLock(@Param("postId") Long postId);현 트랜젝션이 완료될 때까지 다른 트랜젝션에서 해당 행을 수정하지 못 하도록 행을 잠그는 sql이 나간 것을 확인할 수 있다. 생각 : 낙관적 락으로도 해결할 수 있을까?우선 FK가 존재하기 때문에 s-lock은 발생할 수 밖에 없다. 따라서 낙관적 락을 사용한다고 해도 데드락을 피할 수는 없다. 실패 1. 낙관적 락만 적용똑같이 s-lock을 획득한 상태에서 x-lock을 획득하기 위해서 다른 transaction이 s-lock를 해제시키는 것을 무한정으로 기다리게 된다. 실패 2. synchronized만 적용각 스레드가 공유 자원을 동시에 접근해서 생긴 일이므로 공유 자원에 두 개 이상의 스레드가 접근할 수 없도록 synchronized를 적용하자 synchronized를 적용했지만 테스트가 실패했다. public synchronized void like(Long memberId, CreatePostLikeRequest command) {
Post post = postRepository.getById(command.postId());
Member member = memberRepository.getById(memberId);
PostLike postLike = toPostLike(post, member);
postLike.like(postLikeValidator);
postLikeRepository.save(postLike);
}그 이유는 transactional은 Spring AOP 방식으로 동작한다. 따라서 like 메서드를 호출하게 되면 해당 메서드에 대한 proxy 객체를 생성하고 proxy 객체에서 transaction을 start 및 commit한다. 따라서 like 메서드에만 한 트랜젝션이 접근할 수 있다라는 제약사항이 있으므로 proxy객체에서 commit을 하기 전에 다른 트랜젝션에서 transaction을 시작할 수 있다. 해결책 2. 낙관적 락 + synchronized를 적용하자 @Transactional
@Retryable(
retryFor = {ObjectOptimisticLockingFailureException.class},
maxAttempts = 1000,
backoff = @Backoff(100))
public synchronized void like(Long memberId, CreatePostLikeRequest command) {
Post post = postRepository.getById(command.postId());
Member member = memberRepository.getById(memberId);
PostLike postLike = toPostLike(post, member);
postLike.like(postLikeValidator);
postLikeRepository.save(postLike);
}@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@ToString
@SuperBuilder(toBuilder = true)
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String content;
private String color;
@OneToOne private Member member;
@Embedded private Coordinate coordinate;
private int likeCount = 0;
@Version private Long version;
public void clickLike() {
this.likeCount++;
}
public void cancelLike() {
if (likeCount == 0) {
throw new PostLikeCountNegativeException();
}
this.likeCount--;
}
}그러나 단일 서버인 경우에만 synchronized가 유효하다라는 문제점이 있다. 문제점단일 DB 환경 또는 단일 서버일 경우에만 해결이 된다. 또한 좋아요 같은 경우는 순차적으로 처리되지 않아도 되는데 비관적 락을 적용했을 때는 마치 선착순 처리처럼 순차적으로 처리가 된다. |













Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
일단 이 주제로 선정한 이유에 대해 말씀드리면, 공부할 포인트가 많다고 생각했어요
개인적으로 과제나 면접 경험이 많진 않지만, 그 적은 경험에서도 이 주제는 꼭 물어봤던 것 같습니다.
다중화 어플리케이션 환경에서 동시성 처리하는 방법만 생각해보는게 아닌 스레드 동기화, db lock, 저희가 보통 java 쓰니까 java에서 동기화 키워드 등등 여러가지 키워드들에 대해서 같이 공부해보면 좋을 것 같습니다!
최종적으로 정형화된 방법이 아닌 신선한 아이디어들을 기대하며 주제 던져봅니다!
All reactions