안녕하세요! NewCodes입니다!
최근에 Spring&Java로 과제테스트를 봤었다. 이때 동시성 이슈와 관련한 요구사항이 있었다.
하지만 평소에 동시성 이슈 해결에 관해서 Atomic한 접근 방법만 써봤었다.
그래서인지 다양한 동시성 문제 해결 방법 중 해당 과제에서 적합한 방법을 잘 녹여내지 못했다.
이번을 계기로 Java에서 동시성 이슈를 해결하는 방법들이 어떤 게 있는지 알아보려한다!
우선 첫 번째로 synchronized 키워드에 대해서 자세히 알아보자!
📍 용어 정리
관련 용어부터 확실히 하고 들어가자!
- 동시성 (concurrency)
- 하나의 시스템에서 여러 작업이 동시에 실행되는 것처럼 보이게 하는 개념
- 주로 멀티스레드를 통해 동시성 높은 처리를 함
- 동시성 이슈
- 이러한 동시성으로 인해 생기는 문제
- 대표적으로 deadlock, starvation, race condition이 있음
- deadlock
- 서로 다른 두 스레드가 서로의 작업이 끝나기를 기다리는 상태
- 락을 사용할 때 생길 수 있는 문제
- 스레드1이 자원1의 락 보유한 상태에서 자원2의 락 요청
- 스레드2가 자원2의 락 보유한 상태에서 자원1의 락 요청
- -> 서로의 락을 기다리며 무한대기
- starvation
- 우선순위가 낮거나 실행 순서가 오지 않아 계속해서 실행되지 않는 상태
- 이것도 락을 사용할 때 락 배치 우선순위 잘못 설정하면 생길 수 있는 문제
- race condition(경쟁 조건)
- 여러 스레드가 공유 자원에 접근할 때, 의도치 않은 결과 발생하는 문제
- critical section(임계 구역)
- 공유 자원 접근 순서에 따라 결과가 달라질 수 있는 코드 영역
- 즉, race condition이 일어날 수 있는 영역
- race condition을 피하기 위해서는 한 번에 하나의 스레드만 들어가야 하는 영역
Java의 synchronized는 동시성 이슈 중에서도 가장 흔히 일어나는 race condition(경쟁 조건)을 해결하는 방법이다.
이를 기억하고 넘어가자.
📣 race condition의 실제 예시
race condition은 여러 상황에서 일어날 수 있다! 여러 애플리케이션을 예로 들어보자.
티켓팅 서비스를 떠올려보자.

내가 한 좌석을 구매했다면, 다른 사람은 해당 좌석을 구매하지 못하는 게 상식적으로 맞다.
하지만, 두 사용자가 동시에 좌석을 선택하면 한 좌석이 중복해서 구매가 될 수 있다. 이게 바로 race condition이다. 우리의 상식과 예측과는 다른 결과가 나오게 된 것이다. 티켓팅 서비스에서는 이러한 race condtion 문제를 해결하는 게 중요할 것이다.
다른 문제로는 내가 직접 겪었던 문제가 있다. 실시간 퀴즈 게임 플랫폼 프로젝트를 하면서 생긴 문제였다.

퀴즈의 제한시간이 끝나면 각 플레이어는 답안을 서버에 제출한다. 모든 플레이어가 제출했다면 서버는 채점을 하고 다음 퀴즈로 넘어간다.
이때 모든 사용자가 제출했는지 확인하기 위해 플레이어가 답안을 제출할 때마다 count를 1씩 증가시켰다. 그리고 `count == players.size()` 이런 식으로 모든 플레이어가 답안을 제출했는지 확인했다.
하지만, 몇몇 경우에 다음 퀴즈로 넘어가지지 않는 문제가 발생했다. 여러 스레드가 동시에 count에 접근했기에 발생한 문제였다. 한 게임방에 플레이어가 5명이라면 count는 최종적으로 5가 되는 게 맞는데 3이거나 4인 경우가 생긴 것이었다.
이러한 유형의 문제에 대해서는 아래 예제에서 또 살펴보자.
🤸♂️ Synchronized로 race condition 해결하기
문제 상황
아래 코드는 race condition이 일어날 수 있는 코드이다.
총 10개의 스레드가 존재한다. 각 스레드는 Counter 클래스의 count를 1씩 증가시키는 연산을 1000번 수행한다. 그러면 우리의 예상대로는 count의 최종 값은 10000이 되어야한다.
이를 한번 실행해보자!
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ConcurrencyTest {
private static class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
// 멀티스레드로 돌리기 위한 준비
int threadCount = 10;
int incrementsPerThread = 1000;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
// 시작 시간 기록
long startTime = System.currentTimeMillis();
// count 증가시키는 로직
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
for (int j = 0; j < incrementsPerThread; j++) {
counter.increment();
}
} finally {
latch.countDown();
}
});
}
// 모든 스레드가 완료될 때까지 대기
latch.await();
executor.shutdown();
long endTime = System.currentTimeMillis();
System.out.println("예상 결과: " + (threadCount * incrementsPerThread));
System.out.println("실제 결과: " + counter.getCount());
System.out.println("실행 시간: " + (endTime - startTime) + "ms");
System.out.println("동시성 이슈 발생: " +
(counter.getCount() != threadCount * incrementsPerThread));
}
}
위 코드를 실행하면 아래와 같이 예상대로 잘 나오게 된다.

하지만, 여러 번 실행하다보면 아래와 같이 예상 값과 실제 값이 다른 경우가 생긴다.

이게 바로 동시성 이슈 중 하나인 race condtion이다. 각 스레드의 실행 타이밍에 따라 동시성 이슈가 발생할 수도 있고 안 할 수도 있다.
왜 이러한 문제가 나타나는지 아래 그림을 통해 살펴보자.

thread1에서 count를 증가시키는 동안 thread2도 count를 증가시켰다. 문제는 thread1이 작업의 결과를 count에 반영하기도 전에 thread2가 접근했다는 점이다. 그래서 우리의 예상과는 달리 2가 아니라 1이라는 결과가 나오게 된다.
이러한 race condition 발생하다보면 원래 예상했던 숫자보다는 작은 숫자가 결과로 나올 수밖에 없다. 그래서 아까 예상 값은 10000이었지만 9911이 실제 값으로 나온 것이었다.
이러한 race conditon 문제를 synchronized를 통해 해결해보자.
사용법
synchronized를 사용하는 방법은 어렵지 않다.
단순히 method 앞에 synchronized 키워드를 붙이는 것이다.
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
}
이렇게 하면 한 스레드가 `increment()`를 하는 동안 Counter는 잠기게 되어 다른 스레드는 `increment()`할 수 없게 된다.
즉, synchronized를 사용하면 아래 그림처럼 `increment()`가 독립적으로 수행된다.

이렇게 하면 예상대로 count가 2라는 결과가 나오게 된다.
단순히 synchronized만을 붙였는데도 문제가 해결됐다!

그런데 도대체 어떤 원리로 문제가 해결된 걸까? 이 원리를 이해하는 건 앞으로 다양한 동시성 문제를 해결하는 데 있어서 기반이 된다. 모니터를 중심으로 그 원리를 이해해보자.
🚀 그래서 synchronized가 뭐야?
synchronized는 특정 코드 블록이나 메서드에 대해 한 번에 한 스레드만 접근할 수 있도록 잠금하는 역할을 한다. 마치 코드 실행의 흐름을 동기화하는 것과도 같다.
동기란 다른 작업의 완료 여부를 기다리며 코드들이 순차적으로 실행되게 하는 개념이다. 아래 그림처럼 두 작업이 있을 때 한 작업이 끝날 때까지 기다리고 끝나면 다음 작업을 순서대로 실행하는 것이다.

동기 비동기 개념이 잘 떠오르지 않는다면 이 글을 추천한다.
동기 비동기 블로킹 논블로킹, 이 글 하나로 끝내자!
안녕하세요! NewCodes입니다! 프로그래밍을 배우다보면 꼭 마주치는 개념이 있죠! 바로 동기 / 비동기 / 블로킹 / 논블로킹입니다. 처음 접하면 너무 헷갈리고 어렵지만,이 글 하나로 명확하게 정리
newcodes.tistory.com
참고로 synchronized는 1995년 자바의 첫 버전인 Java 1.0부터 등장했다. 오래전부터 사용됐던 synchronized의 원리에 대해서 알아보자.
monitor의 원리
synchronized는 monitor를 이용한다. monitor란 Java와 같은 애플리케이션 레벨에서 쓰이는 고수준의 동기화 구조이다.
Java에서 모든 객체는 하나의 내장된 monitor lock을 가지고 있다. 이는 모든 객체마다 고유하게 락이 있다고 해서 고유 락(intrinsic lock)이라고도 한다. 참고로 고유 락은 객체의 헤더 중 메타데이터를 담는 Mark Word에 저장되어 관리된다.
monitor lock은 공유자원의 객체에도 당연히 존재한다. 스레드는 이를 소유하고 있어야 공유자원에 접근할 수 있다. 반면, monitor를 가지고 있지 않다면 공유자원에 절대 접근할 수 없다.
monitor는 두 가지 기능을 제공한다.
- mutual exclusion: 한 번에 오직 하나의 스레드만 실행되게 한다.
- cooperation: 특정한 조건이 충족될 때까지 스레드들을 기다리게 한다.
두 가지 기능을 어떻게 제공하는지 아래 그림을 통해 알아보자.

위 그림에서 보이듯이 Entry Set과 Wait Set에는 Waiting Thread가 있다. monitor를 받길 원하는 스레드들이 기다리고 있는 집합이라고 보면 된다. 이를 통해 monitor는 하나의 스레드만 가질 수 있도록 한다. 즉, 이 두 set을 통해 mutual exclusion이 성립된다.
Entry Set은 block된 스레드들이 monitor를 기다리고 있는 집합이다. 스레드가 모니터를 획득하려 했을 때 모니터를 가질 수 없다면 Entry Set에 들어가 대기하게 된다.
Wait Set은 특정 조건이 충족될 때까지 대기하는 스레드들의 집합이다. 특정 조건이 충족되지 않으면 Wait Set에 들어간다. 그러다가 조건이 충족되면 entry set으로 이동한 뒤 대기한 다음 monitor를 얻어 작업을 수행한다.
특정 조건의 충족은 두 가지 함수로 개발자가 직접 컨트롤할 수 있다. `wait()`과 `notify()` 두 함수는 Java의 최상위 객체인 Object의 기본 메서드로 구현되어 있다.
- `wait()`: 다른 스레드가 notify()를 호출할 때까지 현재 스레드를 Wait Set에 대기시킨다.
- `notify()`: 객체의 monitor를 기다리는 스레드 중 임의로 하나를 깨워서 Entry Set에 이동시킨다.
- `notifyAll()`: 객체의 monitor를 기다리는 Wait Set에 있는 모든 스레드를 깨워서 Entry Set에 이동시킨다.
이를 통해 스레드는 다른 스레드를 위해 대기하기도 하고, 다른 스레드를 깨워주기도 한다. 그래서 cooperation을 한다고 볼 수 있겠다.
아직 Wait Set의 쓰임새에 대해 잘 안 와닿을 수 있다. Producer-Consumer 문제를 해결한 예시를 통해 살펴보자.
Producer-Consumer 문제는 생산자와 소비자가 공유 버퍼를 통해 데이터를 주고받을 때 발생하는 동기화 문제이다. 그중에서도 특히 버퍼가 비어있거나 꽉 차 있을 때의 문제를 해결한 코드를 살펴보자.
import java.util.LinkedList;
import java.util.Queue;
public class ProducerConsumerExample {
private final Queue<Integer> buffer = new LinkedList<>();
private final int CAPACITY = 5;
// Producer가 데이터를 생성하는 메서드
public synchronized void produce(int item) throws InterruptedException {
// 버퍼가 가득 찬 경우 대기
while (buffer.size() == CAPACITY) {
System.out.println("Buffer is full. Producer waiting...");
wait(); // 현재 스레드를 대기 상태로 전환
}
buffer.add(item);
System.out.println("Produced: " + item + " | Buffer size: " + buffer.size());
// Consumer에게 데이터가 있음을 알림
notifyAll();
}
// Consumer가 데이터를 소비하는 메서드
public synchronized int consume() throws InterruptedException {
// 버퍼가 비어있는 경우 대기
while (buffer.isEmpty()) {
System.out.println("Buffer is empty. Consumer waiting...");
wait(); // 현재 스레드를 대기 상태로 전환
}
int item = buffer.poll();
System.out.println("Consumed: " + item + " | Buffer size: " + buffer.size());
// Producer에게 공간이 있음을 알림
notifyAll();
return item;
}
}
코드를 잘 살펴보면 `wait()`과 `notifyAll()`의 동작이 이해가 될 것이다. 이로써 특정 조건이 충족될 때까지 스레드를 대기시키는 Wait Set도 납득이 될 것이다.
`notify()`가 아닌 `notifyAll()`을 사용한 이유는 Consumer와 Producer 모두를 깨울 필요가 있기 때문이다. 버퍼가 꽉 차거나 꽉 비지 않았다면 Consumer와 Producer 모두 작업을 진행할 수 있기 때문이다.
💡 synchronized의 또 다른 사용법
synchronized의 새로운 사용법을 알아보자.
하나의 synchronized 코드 블록을 만드는 방법이다.
public class Counter {
private int count = 0;
public void increment() {
synchronized (this) {
count++;
}
}
}
이렇게 하면 synchronized 코드 블록 안에 있는 코드들을 동기화할 수 있다. 함수에 synchronized 키워드를 붙이는 것보다 조금 더 원하는 영역을 세밀하게 설정할 수 있다는 장점이 있다.
그러면 this는 뭘까? this는 현재 Counter 객체에 대한 monitor를 획득해야만 해당 코드를 실행할 수 있다는 것을 나타낸다.
아래 코드도 마찬가지이다.
public class Counter {
private final Object dataLock = new Object(); // 특정 데이터 보호를 위한 전용 락 객체
private int count = 0;
public void increment() {
synchronized (dataLock) { // 'dataLock' 객체의 락을 사용하여 동기화
count++;
}
}
}
monitor를 얻고자 하는 객체를 다른 객체로 명시할 수 있다.
아까 처음에 봤던 사용법 또한 마찬가지이다.
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
}
객체는 명시가 되어있진 않지만, 현재 Counter 객체인 this의 monitor를 기준으로 하겠다는 의미이다.
🔧 Synchronized의 특징, 장단점
특징
같은 객체의 monitor를 사용하는 여러 synchronized 메서드들이 있다면 성능상 불리할 수 있다. 여러 synchronized 코드가 하나의 monitor를 공유하는 것이기 때문이다. 이러면 여러 개의 스레드가 하나의 monitor를 기다리는 시간이 더 늘어날 것이다.
또, synchronized는 재진입이 가능하다는 특징이 있다. 예를 들어, 한 객채에 두 synchronized 메서드가 있다고 해보자. 한 synchronized 메서드 안에서 다른 synchronized 메서드를 호출할 때 락을 추가로 획득하지 않아도 호출이 가능하다. 이것이 재진입이다. 이를 통해 deadlock을 피할 수 있다. 자신이 락을 가진 상태에서 또 락을 요구한다면 이건 deadlock이 발생하기 때문이다.
장단점
장점은 우선 사용하기 쉽다는 점이다. 동기화하고 싶은 임계 영역을 코드 블록으로 감싸거나, 함수에 키워드만 붙이면 된다. 또, 스레드가 monitor를 기다리는 동안 busy waiting을 하지 않고 단순히 block되는 것이기에 CPU 자원이 낭비되지 않는다는 장점이 있다.
단점으로는 한 번에 하나의 스레드만 접근이 가능하기에 나머지 스레드는 블로킹되기에 전반적인 성능 저하를 우려할 수 있다. 이는 임계 구역의 크기가 넓을수록 스레드가 블로킹되는 시간은 더욱 많아질 것이다.
이러한 단점 때문에 높은 성능을 요구하는 곳에서는 사용하지 않거나, 임계구역을 최대한 타이트하게 잡는 게 좋다. 그렇기에 임계 구역을 세밀하게 잡을 수 있고 monitor를 얻고자 하는 객체를 직접 명시할 수 있는 'synchronized 블록으로 감싸는 방법'을 사용하는 것이 좋다.
📌 마무리
요약
- race condtion은 여러 스레드가 공유자원에 접근했을 때 예상치 못한 결과가 나오는 문제이다.
- synchronized는 monitor를 사용해 한 번에 하나의 스레드만 공유자원에 접근할 수 있도록 해서 race condtion을 해결한다.
- synchronized를 사용할 때는 코드 블록을 감싸서 사용하는 방법이 효과적이다.
소감
평소에 원리를 잘 모르고 있었던 synchronized에 대해 이번을 계기로 자세히 이해할 수 있어서 속 시원했다. 개발은 참 이런 재미가 있는 것 같다. 이전에는 새까만 블랙박스처럼 보였던 것들이 점점 투명해지는 이 느낌이 좋다.
synchronized는 Java 1.0부터 나왔고 여전히도 종종 사용되는 동시성 문제 해결법이다. 요새는 concurrent 패키지를 많이 쓰긴 하지만, 그래도 여전히 그 원리를 알아둘 필요가 있다. lock을 사용하여 동시성을 해결하는 건 다른 곳에서도 응용이 많이 되기 때문이다.
다음 글에서는 Java의 volatile 키워드, concurrent 패키지에 대해서 알아보자!
레퍼런스
- https://en.wikipedia.org/wiki/Monitor_(synchronization)
- https://en.wikipedia.org/wiki/Critical_section
- https://en.wikipedia.org/wiki/Mutual_exclusion
- https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html
- https://www.artima.com/insidejvm/ed2/threadsynchP.html
- https://docs.oracle.com/javase/tutorial/essential/concurrency/locksync.html