- Published on
Race Condition 해결 이후, 진짜 끝일까?
- Authors
- Name
- 김민석
Introduction
- 문제 발생 과정
- 동시성 발생 시점 분석
- Race Condition 발생 시점 분석
- 문제 해결 방법
- 동시성 문제 해결 이후, 진짜 끝일까?
- "초당 2000건씩 요청이 들어오면 어떻게 될까?"
- 예상되는 문제
문제 발생 과정
새로운 클릭 데이터가 들어오면, ArrayDeque의 마지막 원소를 찾아서 값을 증가시키거나 새로운 원소를 추가해야 한다. 여러 개의 요청이 동시에 실행될 경우, 동일한 마지막 원소를 참조한 상태에서 연산을 수행하면서 데이터가 유실되는 문제가 발생한다.
- 각 요청은 먼저 현재 ArrayDeque의 마지막 원소를 확인한다.
- 여러 개의 요청이 거의 동시에 실행되면 각 요청이 동일한 마지막 원소를 참조하게 된다.
- 즉, 요청 A도 마지막 원소를 찾고, 요청 B도 같은 마지막 원소를 찾게 된다.
각 요청은 ‘현재 마지막 원소는 자신이 참조하고 있는 노드’라고 인식하며, 이후 연산을 수행하게 된다.
동시성 발생 시점 분석
"동시에 마지막 원소를 추가하려고 하는성데 왜 하나는 누락 될까?"
여러 개의 요청이 거의 동시에 들어오는 상황에서, 각각의 요청은 마지막 원소를 찾아 값을 증가시키거나 새로운 원소를 추가하는 과정을 거친다.
하지만, 이러한 과정이 동기화 없이 동시에 실행되면 Race Condition이 발생한 것이다.
Race Condition 발생 시점 분석
"어떤 시점에서 누락이 발생해서 어떻게 누락이 되는지?”
ArrayDeque
는 내부적으로 배열을 기반으로 구현된 큐이다. 배열이지만 노드를 연결하는 방식으로 동작하며, 새로운 노드를 추가할 때 기존 마지막 노드의 다음 주소를 변경하는 방식으로 확장된다.
하지만, 여러 개의 요청이 거의 동시에 마지막 원소를 수정하려고 하면 충돌이 발생한다.

- 여러 개의 요청이 동시에 발생하면, 각각의 요청이 동일한 마지막 원소를 참조할 수 있다.
- 즉, 두 개의 서로 다른 스레드가 동일한 마지막 원소를 보고 있는 상태가 된다.

- 두 개의 스레드가 각각 "새로운 원소를 추가해야겠다"라고 판단하고, 새로운 데이터를 생성한다.
- 여기서 각 스레드는 자신이 생성한 새로운 원소가 마지막에 추가될 것이라고 가정한다.

- 원래대로라면, 마지막 원소는 새로운 원소를 가리키도록 주소를 업데이트해야 한다.
- 하지만, 두 개의 스레드가 동일한 마지막 원소를 참조하고 있기 때문에, 각각 다른 원소를 가리키도록 수정하려고 한다.

- 마지막 원소의 다음 주소는 하나만 존재할 수 있다.
- 따라서, 두 개의 스레드가 서로 다른 값을 할당하면, 가장 마지막에 실행된 연산이 최종적으로 반영된다.
- 먼저 실행된 연산은 사라지고, 이전에 추가하려던 데이터는 유실된다.
결국
하나의 요청이 정상적으로 반영되지 않고, 데이터가 일부 사라지는 문제가 발생한다.
또한, 데이터 정렬 기준이 꼬일 수도 있기 때문에, 정확한 순서를 보장할 수 없게 된다.
문제 해결 방법
한 번에 하나의 스레드만 마지막 원소를 수정할 수 있도록 제한해야 한다.
즉, 마지막 원소를 가져오는 순간부터 새로운 데이터를 추가하는 순간까지, 하나의 요청만 처리되도록 만들어야 한다. synchronized 적용하여, 하나의 스레드만 접근할 수 있도록 제어했다
두 번째 스레드는 첫 번째 스레드의 연산이 끝날 때까지 기다렸다가 실행되므로, 데이터가 유실되지 않는다.
동시 접근을 방지하는 방법에는 여러 가지가 있었다.
방법 | 동작 방식 | 장점 | 단점 |
---|---|---|---|
동기화(synchronized) 적용 | 한 번에 하나의 스레드만 실행되도록 제어 | 구현이 간단하고 안정적 | 다수의 스레드가 동시에 접근하면 성능 저하 발생 |
Lock 사용 (ReentrantLock) | 스레드 간 lock을 걸어 순차 실행 | tryLock() 을 통해 lock 획득 여부를 확인할 수 있음 | 락을 해제하지 않으면 데드락 발생 가능 |
원자적 변수 (AtomicReference) | 원자적 연산을 통해 next 포인터 갱신 | lock-free 구조로 성능이 좋음 | 코드 복잡도가 증가할 수 있음 |
Concurrent 자료구조 활용 | ConcurrentLinkedDeque 같은 자료구조 사용 | 비동기 환경에서도 안전 | 기존 ArrayDeque 를 대체해야 하므로 구조 변경 필요 |
정합성 검증 테스트로 문제 해결 확인
동기화를 적용한 이후에는 실제로 클릭 수가 정확하게 누적되는지 확인할 필요가 있었다.
멀티스레드 환경에서 동시에 클릭 이벤트가 발생하는 테스트 코드를 작성하여 정합성을 검증했다. 아래 테스트는 총 6개의 스레드가 동시에 동일한 공고에 대해 클릭 요청을 보낸 상황을 가정한다.
@Test
void testIncreaseViewCountMultiThread() throws InterruptedException {
JobPostingInfoService service = new JobPostingInfoService(new CurrentViewStorage());
int threadCount = 6;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.execute(() -> {
service.increaseViewCount(1L);
latch.countDown();
});
}
latch.await();
executorService.shutdown();
assertEquals(threadCount, service.increaseViewCount(1L) - 1);
}
- 총 6개의 요청이 동시에 실행되며, 모두 동일한 공고 ID에 대해
increaseViewCount()
를 호출한다. - 테스트 마지막에 총 누적된 클릭 수가 정확히 6번인지 확인한다.
이 테스트를 통해, Race Condition이 발생하던 시점에서는 클릭 수가 누락되거나 더해지지 않았지만,
동기화 적용 이후에는 모든 클릭 요청이 정확히 반영됨을 확인할 수 있었다.
클릭 기준 정렬 정합성 테스트
이후 클릭 수 기준으로 정렬되는 기능에 대해서도 테스트를 추가했다.
@Test
void shouldSortJobPostingsByClickCount() {
service.increaseViewCount(3L);
service.increaseViewCount(3L);
service.increaseViewCount(2L);
service.increaseViewCount(2L);
service.increaseViewCount(2L);
service.increaseViewCount(1L);
List<JobPosting> sortedJobPostings = service.getAllJobPostings();
List<Long> actualSortedIds = sortedJobPostings.stream()
.map(JobPosting::getId)
.toList();
List<Long> expectedSortedIds = List.of(2L, 3L, 1L);
assertEquals(expectedSortedIds, actualSortedIds);
}
- 클릭 수가 각각 3회, 2회, 1회인 공고들이 있는 상황에서, 가장 클릭 수가 높은 공고부터 정확히 정렬되는지 확인하는 테스트이다.
이 테스트까지 통과함으로써, 클릭 수 누락 없이 정확한 순서 보장이 가능해졌으며,
데이터 정합성을 유지한 상태에서 사용자 경험을 훼손하지 않는 설계가 개선 되었다.
동시성 문제 해결 이후, 진짜 끝일까?
- 처음 Race Condition 문제가 발생했을 때는, ArrayDeque에 동시에 접근하면서 발생하는 정합성 이슈였다.
- synchronized를 통해 접근을 직렬화하고, 멀티스레드 환경에서 문제없이 동작하는지 테스트 코드를 작성해 확인했다.
ExecutorService executorService = Executors.newFixedThreadPool(6);
for (int i = 0; i < 6; i++) {
executorService.execute(() -> {
service.increaseViewCount(1L);
});
}
- 6개의 스레드가 동시에 동일한 공고에 대해 클릭 요청을 보내는 시나리오
synchronized
블록 내에서 정합성을 유지하면서 클릭 수가 정확히 6번 반영됨을 확인
테스트 결과는 정상적이었다.
하지만 여기서 의문이 들었다.
"초당 2000건씩 요청이 들어오면 어떻게 될까?"
애플리케이션이 성장하고 실제 사용자 수가 증가할수록, 동시에 처리해야 하는 요청 수는 기하급수적으로 늘어난다.
이때 단순히 테스트 코드를 통과했다고 해서 운영 환경에서도 안정적일 것이라 단정지을 수 없다.
예를 들어 초당 2000건 이상의 클릭 이벤트가 발생하는 상황을 가정해보자.
- 지금의 구조가 이 상황에서도 안정적으로 동작할까?
예상되는 문제
synchronized
는 소용이 없다
인스턴스가 늘어나면 
현재 구조에서는 ArrayDeque 라는 자료구조를 synchronized 로 감싸서 동시에 여러 요청이 들어오더라도 하나씩 순서대로 처리되도록 만들었다.
이 방식은 하나의 서버, 하나의 인스턴스 안에서는 충분히 안전하게 동작한다.
하지만 실제 서비스를 운영하다 보면, 트래픽이 많아질수록 서버 인스턴스를 여러 개로 늘려야 하는 상황이 생긴다.
- ex) 서버 2대, 3대로 확장해서 동시에 더 많은 요청을 처리하도록 하는 방식
그런데, synchronized 는 각 인스턴스 내부에서만 적용되는 동기화 방식이다.
즉, 서버가 여러 대가 되면 서버 간에는 서로 락을 공유하지 않기 때문에, 결국 같은 데이터에 대해서 서로 다른 값으로 처리될 위험이 생긴다.
한마디로, 서버를 확장하면 지금의 동기화 방식은 무용지물이 될 수 있다는 것이다.
모든 요청을 하나씩만 처리하면, 결국 느려진다.

synchronized 의 또 다른 한계는 모든 요청을 순차적으로 처리하게 만든다는 점이다.
예를 들어, 여러 명의 사용자가 동시에 어떤 공고를 클릭햇다면, 그 클릭 이벤트는 하나씩 차례대로만 처리된다.
- 서버가 처리할 수 있는 최대 속도가 정해져 버린다.
- 요청이 몰릴수록 처리 대기 줄이 점점 길어지게 된다.
- 사용자 입장에서는 화면 반응이 느려진다.
- 전체 서버 입장에서는 CPU는 놀고 있는데도 처리량은 적은 상황이 생긴다.
예를 들어, synchronized 안에서 하나의 요청을 처리하는 데 0.5ms가 걸린다고 한다면 초당 2000건의 요청이 들어 왔을때는 어떨까?
0.5ms × 2000건 = 1000ms (1초)
즉, 1초 내에 들어온 요청을 1초 안에 겨우 처리하는 수준이 된다.
요청 수가 더 늘어나면? → 처리 지연, 대기 시간 증가, 심하면 요청 누락까지 발생할 수 있는 것이다.
정리하면
synchronized 는 간단한 구조에서 정합성을 지키기엔 유용하지만, 많은 사용자가 동시에 접근하는 상황이나, 여러 대의 서버로 확장된 환경에서는 속도도, 안정성도 모두 보장할 수 없는 구조가 되는 것이다.