개요: 자바에서의 스레드 안전성
현대 애플리케이션에서 멀티스레딩은 성능 향상을 위한 필수적인 요소입니다.
특히 자바는 태생적으로 멀티스레드 환경을 지원하도록 설계되었습니다.
하지만 여러 스레드가 동시에 실행될 때 발생하는 동기화(Synchronization) 이슈는 개발자들에게 항상 골치 아픈 문제입니다.
이 글에서는 자바 개발자 면접에서 자주 등장하는 스레드 안전성 문제와 해결 방법을 실제 코드 예제를 통해 심층적으로 살펴보겠습니다.
스레드 안전성(Thread Safety)이란 여러 스레드가 동시에 같은 자원에 접근하더라도
프로그램의 실행 결과가 예측 가능하게 유지되는 특성을 말합니다.
이는 자바 개발자로서 반드시 이해하고 있어야 할 개념이며, 특히 기업 면접에서 자주 등장하는 주제이기도 합니다.
면접에서 자주 등장하는 동기화 이슈
기술 면접에서 면접관들이 가장 관심을 갖는 동기화 이슈 관련 주제는 다음과 같습니다:
- 가시성(Visibility): 한 스레드에서 변경한 데이터가 다른 스레드에 보이지 않는 문제
- 원자성(Atomicity): 복합 연산이 중간에 끊기지 않고 완전히 수행되어야 하는 특성
- 데드락(Deadlock): 둘 이상의 스레드가 서로 상대방의 작업이 끝나기를 기다리며 무한정 대기하는 상태
- 레이스 컨디션(Race Condition): 여러 스레드가 공유 자원에 동시에 접근하여 예기치 않은 결과가 발생하는 상황
- 스레드 안전한 컬렉션:
java.util.concurrent
패키지의 컬렉션 클래스 사용법
이제 각각의 이슈를 실제 자바 코드로 살펴보고, 효과적인 해결 방법을 알아보겠습니다.
스레드 안전성이 깨지는 주요 상황
스레드 안전성 문제는 주로 다음과 같은 상황에서 발생합니다:
- 여러 스레드가 공유 변수에 동시에 접근할 때
- 한 스레드가 변경한 값을 다른 스레드가 즉시 볼 수 없을 때
- 복합 연산이 원자적으로 수행되지 않을 때
- 스레드 간 자원 획득 순서가 일정하지 않을 때
아래 코드는 스레드 안전하지 않은 카운터 예제입니다:
public class UnsafeCounter {
private int count = 0;
public void increment() {
count++; // 이 연산은 원자적이지 않습니다!
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
UnsafeCounter counter = new UnsafeCounter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final count: " + counter.getCount()); // 20000이 아닐 가능성이 높습니다
}
}
이 코드를 실행하면 최종 카운트가 20000이 아닌 경우가 많습니다. 그 이유는 count++
연산이 원자적이지 않기 때문입니다. 이 연산은 실제로 다음 세 단계로 이루어집니다:
- 현재
count
값을 읽는다. - 읽은 값에 1을 더한다.
- 계산된 값을
count
에 저장한다.
여러 스레드가 동시에 이 과정을 수행하면 일부 증가 연산이 누락될 수 있습니다.
자바의 기본 동기화 메커니즘
자바는 다양한 동기화 메커니즘을 제공합니다:
1. synchronized 키워드
가장 기본적인 동기화 방법은 synchronized
키워드를 사용하는 것입니다:
public class SafeCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
synchronized
는 메서드나 블록에 사용할 수 있으며, 해당 영역을 한 번에 하나의 스레드만 실행할 수 있도록 보장합니다.
다음은 블록 수준의 동기화 예제입니다:
public class SafeCounter {
private int count = 0;
private final Object lock = new Object(); // 락으로 사용할 객체
public void increment() {
synchronized(lock) {
count++;
}
}
public int getCount() {
synchronized(lock) {
return count;
}
}
}
synchronized
블록은 더 세밀한 제어가 가능하므로 성능상 이점이 있을 수 있습니다.
2. volatile 키워드
volatile
키워드는 변수의 가시성 문제를 해결합니다:
public class VolatileExample {
private volatile boolean flag = false;
public void setFlag() {
flag = true; // 이 변경사항은 즉시 다른 스레드에 보입니다
}
public boolean isFlag() {
return flag;
}
}
volatile
은 변수를 메인 메모리에 직접 읽고 쓰도록 보장하여, 변경 사항이 모든 스레드에 즉시 보이게 합니다.
하지만 원자성은 보장하지 않습니다.
가시성(Visibility) 문제와 해결책
가시성 문제는 한 스레드에서 변경한 값이 다른 스레드에 즉시 보이지 않는 현상입니다.
이는 CPU 캐시와 최적화 때문에 발생합니다.
가시성 문제 예제
public class VisibilityProblem {
private boolean running = true;
public void stop() {
running = false; // 다른 스레드에서 이 변경을 즉시 볼 수 없을 수 있습니다
}
public void runForever() {
while (running) {
// 무한 루프 - 다른 스레드에서 running을 false로 변경해도
// 이 스레드는 변경을 보지 못할 수 있습니다
}
System.out.println("Finally stopped!");
}
public static void main(String[] args) throws InterruptedException {
VisibilityProblem example = new VisibilityProblem();
Thread t = new Thread(example::runForever);
t.start();
Thread.sleep(1000);
example.stop(); // 이 호출이 효과가 없을 수 있습니다
t.join(2000); // 2초 대기
System.out.println("Thread state: " + t.getState()); // 여전히 RUNNABLE일 수 있습니다
}
}
해결책: volatile과 synchronized
public class VisibilitySolution {
private volatile boolean running = true; // volatile로 가시성 보장
public void stop() {
running = false; // 이제 이 변경은 즉시 다른 스레드에 보입니다
}
public void runForever() {
while (running) {
// 무한 루프가 아닌 제한된 실행
}
System.out.println("Successfully stopped!");
}
}
volatile
은 변수의 읽기/쓰기 작업이 메인 메모리에서 직접 이루어지도록 보장합니다.
따라서 한 스레드에서 변경한 값은 즉시 다른 모든 스레드에 보이게 됩니다.
원자성(Atomicity) 문제와 해결책
원자성 문제는 복합 연산이 중간에 끊기지 않고 완전히 수행되어야 하는데, 멀티스레드 환경에서는 이 보장이 깨질 수 있는 상황을 말합니다.
원자성 문제 예제
public class AtomicityProblem {
private int x = 0;
private int y = 0;
public void update() {
x++; // 비원자적 연산
y++; // 비원자적 연산
}
public void check() {
if (y > x) {
// 이 조건이 성립할 수 있습니다!
System.out.println("y > x: This should never happen in single-threaded execution");
}
}
public static void main(String[] args) throws InterruptedException {
AtomicityProblem example = new AtomicityProblem();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
example.update();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) {
example.check();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
해결책: synchronized와 AtomicInteger
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicitySolution {
// 방법 1: AtomicInteger 사용
private AtomicInteger x = new AtomicInteger(0);
private AtomicInteger y = new AtomicInteger(0);
public void updateWithAtomic() {
x.incrementAndGet(); // 원자적 연산
y.incrementAndGet(); // 원자적 연산
}
// 방법 2: synchronized 사용
private int a = 0;
private int b = 0;
public synchronized void updateWithSync() {
a++; // synchronized 블록 내에서는 원자적으로 실행됨
b++; // synchronized 블록 내에서는 원자적으로 실행됨
}
public synchronized void checkWithSync() {
if (b > a) {
// synchronized로 보호되어 있어 이 조건은 성립할 수 없습니다
System.out.println("This will never happen");
}
}
}
AtomicInteger
와 같은 java.util.concurrent.atomic
패키지의 클래스들은 CAS(Compare-And-Swap) 알고리즘을 사용하여 락 없이도 원자적 연산을 제공합니다. 이는 synchronized
보다 성능이 좋을 수 있습니다.
데드락(Deadlock) 문제와 해결책
데드락은 두 개 이상의 스레드가 서로가 가진 자원을 기다리며 영원히 대기하는 상태입니다.
데드락 예제
public class DeadlockExample {
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public void method1() {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock1...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 1: Waiting for lock2...");
synchronized (lock2) {
System.out.println("Thread 1: Holding lock1 & lock2...");
}
}
}
public void method2() {
synchronized (lock2) {
System.out.println("Thread 2: Holding lock2...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 2: Waiting for lock1...");
synchronized (lock1) {
System.out.println("Thread 2: Holding lock1 & lock2...");
}
}
}
public static void main(String[] args) {
DeadlockExample deadlock = new DeadlockExample();
new Thread(deadlock::method1).start();
new Thread(deadlock::method2).start();
}
}
해결책: 락 획득 순서 일관성 유지
public class DeadlockSolution {
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public void method1() {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock1...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 1: Waiting for lock2...");
synchronized (lock2) {
System.out.println("Thread 1: Holding lock1 & lock2...");
}
}
}
public void method2() {
// 락 획득 순서를 method1과 동일하게 유지
synchronized (lock1) {
System.out.println("Thread 2: Holding lock1...");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("Thread 2: Waiting for lock2...");
synchronized (lock2) {
System.out.println("Thread 2: Holding lock1 & lock2...");
}
}
}
}
데드락 방지의 핵심은 모든 스레드가 동일한 순서로 락을 획득하도록 보장하는 것입니다.
이 외에도 락 타임아웃, tryLock() 메서드 사용 등의 방법이 있습니다.
레이스 컨디션(Race Condition) 문제와 해결책
레이스 컨디션은 둘 이상의 스레드가 공유 자원에 접근할 때 실행 순서에 따라 결과가 달라지는 현상입니다.
레이스 컨디션 예제
public class RaceConditionExample {
private int lastId = 0;
public int generateId() {
// 이 메서드는 스레드 안전하지 않습니다
lastId++;
return lastId;
}
public static void main(String[] args) throws InterruptedException {
RaceConditionExample example = new RaceConditionExample();
Set<Integer> ids = Collections.synchronizedSet(new HashSet<>());
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
int id = example.generateId();
if (!ids.add(id)) {
System.out.println("중복 ID 발생: " + id);
}
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("생성된 총 ID 수: " + ids.size());
System.out.println("예상 ID 수: " + (example.lastId));
}
}
해결책: synchronized 또는 AtomicInteger
import java.util.concurrent.atomic.AtomicInteger;
public class RaceConditionSolution {
// 방법 1: AtomicInteger 사용
private AtomicInteger lastIdAtomic = new AtomicInteger(0);
public int generateIdAtomic() {
return lastIdAtomic.incrementAndGet();
}
// 방법 2: synchronized 사용
private int lastIdSync = 0;
public synchronized int generateIdSync() {
lastIdSync++;
return lastIdSync;
}
}
레이스 컨디션을 해결하기 위해서는 공유 자원에 대한 접근을 동기화하거나, 원자적 연산을 제공하는 클래스를 사용해야 합니다.
Thread-safe 컬렉션 활용하기
자바는 스레드 안전한 컬렉션 클래스를 java.util.concurrent
패키지에서 제공합니다.
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.List;
public class ThreadSafeCollections {
// Thread-safe 맵
private Map<String, Integer> safeMap = new ConcurrentHashMap<>();
// Thread-safe 리스트
private List<String> safeList = new CopyOnWriteArrayList<>();
public void updateCollections() {
safeMap.put("key", safeMap.getOrDefault("key", 0) + 1);
safeList.add("item");
}
// ConcurrentHashMap 특수 기능 사용 예
public void concurrentMapFeatures() {
// 키가 없을 때만 값 추가 (원자적 연산)
safeMap.putIfAbsent("key", 100);
// 조건부 업데이트 (원자적 연산)
safeMap.computeIfPresent("key", (k, v) -> v + 1);
// 병렬 처리
safeMap.forEach(2, (k, v) -> System.out.println(k + "=" + v));
}
}
주요 스레드 안전 컬렉션으로는 다음이 있습니다:
ConcurrentHashMap
: 스레드 안전한 해시맵CopyOnWriteArrayList
: 쓰기 작업 시 전체 배열을 복사하는 리스트ConcurrentLinkedQueue
: 락 없이 구현된 큐BlockingQueue
: 생산자-소비자 패턴을 위한 큐
스레드 풀과 ExecutorService
자바에서는 스레드를 직접 관리하는 대신 ExecutorService
를 사용하여 스레드 풀을 관리하는 것이 권장됩니다.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.Callable;
public class ExecutorServiceExample {
public static void main(String[] args) throws Exception {
// 고정 크기 스레드 풀 생성
ExecutorService executor = Executors.newFixedThreadPool(5);
// Runnable 실행
executor.execute(() -> {
System.out.println("Runnable task executed by " + Thread.currentThread().getName());
});
// Callable 실행 (결과 반환 가능)
Future<Integer> future = executor.submit(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
System.out.println("Callable task executed by " + Thread.currentThread().getName());
return 42;
}
});
// 결과 가져오기
Integer result = future.get(1, TimeUnit.SECONDS);
System.out.println("Result: " + result);
// 스레드 풀 종료
executor.shutdown();
if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
}
}
ExecutorService
는 스레드 생명주기 관리, 작업 큐잉, 결과 처리 등을 자동화하여 동시성 프로그래밍을 단순화합니다.
실제 면접 질문과 모범 답안
다음은 실제 면접에서 자주 물어보는 동기화 관련 질문과 모범 답안입니다:
Q1: synchronized 메서드와 synchronized 블록의 차이점은 무엇인가요?
A1: synchronized
메서드는 메서드 전체를 동기화하며, 락 객체로 인스턴스 메서드의 경우 현재 객체(this
), 정적 메서드의 경우 클래스 객체를 사용합니다. 반면 synchronized
블록은 코드의 특정 부분만 동기화하고, 락 객체를 명시적으로 지정할 수 있습니다. 블록 동기화는 더 세밀한 제어가 가능하고 필요한 부분만 동기화하여 성능을 향상시킬 수 있다는 장점이 있습니다.
// 메서드 동기화
public synchronized void methodSync() {
// 전체 메서드가 동기화됨
}
// 블록 동기화
public void blockSync() {
// 동기화되지 않은 코드
synchronized(this) {
// 동기화된 블록
}
// 동기화되지 않은 코드
}
Q2: volatile과 synchronized의 차이점은 무엇인가요?
A2: volatile
은 변수의 가시성만 보장하며, 모든 스레드가 변수의 최신 값을 볼 수 있게 합니다. 그러나 원자성은 보장하지 않습니다. 반면 synchronized
는 가시성과 원자성 모두를 보장하며, 한 번에 하나의 스레드만 동기화된 코드 블록에 접근할 수 있게 합니다. 따라서 단순히 가시성만 필요한 경우 volatile
이 더 가볍고 효율적이며, 복합 연산의 원자성이 필요한 경우 synchronized
가 적합합니다.
Q3: 데드락을 방지하는 방법은 무엇인가요?
A3: 데드락 방지를 위한 주요 방법은 다음과 같습니다:
- 락 획득 순서 일관성 유지: 모든 스레드가 같은 순서로 락을 획득하도록 합니다.
- 락 타임아웃 사용:
tryLock()
메서드와 타임아웃을 사용하여 무한정 대기하지 않도록 합니다. - 락 점유 시간 최소화: 락을 보유하는 시간을 최소화하여 교착 가능성을 줄입니다.
- 데드락 감지 및 회복: 데드락이 발생했는지 주기적으로 확인하고, 발생 시 하나 이상의 스레드를 강제 종료합니다.
- 중첩된 락 최소화: 가능한 한 한 번에 하나의 락만 보유하도록 코드를 구성합니다.
Q4: ConcurrentHashMap과 Hashtable의 차이점은 무엇인가요?
A4: 둘 다 스레드 안전한 맵이지만, 동작 방식과 성능에 큰 차이가 있습니다:
- 동기화 범위:
Hashtable
은 메서드 수준에서 동기화되어 한 번에 하나의 스레드만 맵에 접근할 수 있습니다. 반면ConcurrentHashMap
은 세그먼트 단위로 동기화되어 여러 스레드가 다른 세그먼트에 동시에 접근할 수 있습니다. - 성능:
ConcurrentHashMap
은 동시성이 높은 환경에서Hashtable
보다 훨씬 뛰어난 성능을 제공합니다. - NULL 허용:
Hashtable
은 키와 값 모두 NULL을 허용하지 않습니다.ConcurrentHashMap
도 NULL을 허용하지 않습니다. - Iterator:
ConcurrentHashMap
의 이터레이터는 fail-safe로, 순회 중에 맵이 수정되어도ConcurrentModificationException
을 발생시키지 않습니다. 반면Hashtable
의 이터레이터는 fail-fast로, 순회 중 맵이 수정되면 예외가 발생합니다. - 현대성:
ConcurrentHashMap
은 Java 5부터 도입된 최신 API로, 최신 동시성 기법을 활용합니다.Hashtable
은 레거시 클래스로 간주됩니다.
결론: 스레드 안전한 코드 작성을 위한 체크리스트
자바에서 스레드 안전한 코드를 작성하기 위한 체크리스트는 다음과 같습니다:
- 가변 상태 식별하기: 여러 스레드가 접근할 수 있는 가변 상태(변수)를 식별합니다.
- 불변 객체 활용하기: 가능한 한 불변(immutable) 객체를 사용하여 동기화 필요성을 줄입니다.
- 적절한 동기화 메커니즘 선택하기:
- 단순 가시성 →
volatile
- 원자적 연산 필요 →
synchronized
또는AtomicXXX
클래스 - 복잡한 조건부 동기화 →
wait()/notify()
또는Lock
/Condition
- 단순 가시성 →
- 스레드 안전한 컬렉션 사용하기: 표준 컬렉션 대신
java.util.concurrent
패키지의 클래스를 사용합니다. - 스레드 풀과 ExecutorService 활용하기: 직접 스레드 생성 대신 스레드 풀을 사용합니다.
- 락 계층 구조 정의하기: 데드락 방지를 위해 일관된 락 획득 순서를 정의합니다.
- 코드 리뷰와 동시성 테스트: 동시성 이슈는 발견하기 어려우므로 적절한 테스트를 수행합니다.
멀티스레드 프로그래밍은 자바 개발자에게 필수적인 역량입니다. 특히 면접에서는 동기화와 스레드 안전성에 관한 질문이 자주 등장하므로, 이 블로그에서 다룬 개념과 예제를 충분히 이해하고 준비하는 것이 중요합니다. 실무에서도 이러한 지식은 성능과 안정성이 중요한 애플리케이션 개발에 큰 도움이 될 것입니다.
아래는 더 깊이 공부하기 위한 참고 자료입니다:
- "Java Concurrency in Practice" - Brian Goetz
- "Effective Java, 3rd Edition" - Joshua Bloch
- Java 공식 문서의 Concurrency 튜토리얼
- 스레드 덤프 분석 및 데드락 감지 도구 사용법
동기화 이슈는 실전에서 경험하며 학습하는 것이 가장 효과적입니다.
다양한 시나리오를 직접 코드로 구현하고, 발생하는 문제를 분석하며 실력을 쌓아가시길 바랍니다.
'컴퓨터 과학(CS)' 카테고리의 다른 글
메모리 계층 구조 이해: 레지스터, 캐시, RAM, 디스크 차이 (0) | 2025.05.18 |
---|---|
해시(Hash) 함수와 충돌 해결 방법 – CS 면접 대비 실전 예제 (2) | 2025.05.18 |
TCP vs UDP - 실무 예제 기반 차이 완벽 설명 (면접 답변 예시 포함) (1) | 2025.05.12 |
REST vs GraphQL vs gRPC: API 통신 방식의 모든 것 - 장단점, 사용 예시 총정리 (1) | 2025.05.08 |
쓰레드와 프로세스의 차이: 실무 예제 기반으로 완벽 이해 (0) | 2025.05.07 |