Java로 서버 개발을 하다 보면 동시성 이슈를 한번쯤은 만나게 된다. 개발 후 테스트나 운영 환경에서 여러 사용자가 동시에 접근하는 상황에서 데이터가 꼬이거나, 예상치 못한 결과가 나오는 경험을 한 번쯤은 해봤을 것이다.
그렇다면 왜 자바에서 이런 동시성 이슈가 발생할까? 그 이유는 크게 세 가지로 정리할 수 있다.
- 멀티스레드 환경
- 서버는 여러 요청을 동시에 처리하기 위해 스레드를 병렬로 실행
- 여러 스레드가 같은 객체나 자원에 접근할 경우, 읽기·쓰기 순서가 뒤섞이면서 데이터 충돌 발생 가능
- JMM
- CPU 캐시와 메인 메모리 간 불일치 때문에, 한 스레드에서 변경한 값이 다른 스레드에 즉시 반영되지 않을 수 있으며, 이로 인해 문제가 발생하고, 예상치 못한 결과를 만들 수 있음
- 원자성 및 실행 순서 문제
- i++ 같은 단순 연산도 실제로는 읽기-계산-쓰기 3단계로 이루어져 있어, 중간에 다른 스레드가 끼어들 수 있음, JIT 컴파일러와 CPU 최적화로 인해 코드 실행 순서가 바뀌는 경우도 있어, 동시성 문제가 더 복잡해짐
처음에는 synchronized 하나면 모든 문제가 해결될 것 같지만, 막상 실무에 적용하다 보면 성능 문제나 데드락 같은 새로운 문제들이 생긴다. 그래서 동시성 프로그래밍은 단순히 문법을 아는 것을 넘어서, 근본적인 원리를 이해하는 것이 중요하다.
오늘은 Java 동시성의 핵심 개념부터 실무에서 자주 사용하는 고급 기법까지, 체계적으로 정리해보겠다. 참고로, 이 내용들은 아직도 면접에서 단골로 나오는 주제이기도 하다. 아래와 같이 질문에 제대로 답이 떠오르지 않는다면 이번 기회에 제대로 이해해 보자.
자바 동시성 관련 면접 질문 예시
동시성 문제의 원인
- “왜 Java 멀티스레드 환경에서 공유 자원이 꼬일 수 있나요?”
- “Java Memory Model(JMM)이 동시성 문제에 어떤 영향을 미치나요?”
동기화(synchronization) 기법
- “synchronized와 ReentrantLock의 차이점은 무엇인가요?”
- “volatile 키워드는 언제 사용하고, 어떤 한계가 있나요?”
- “Atomic 클래스는 어떤 상황에서 유용하게 쓰이나요?”
실무 경험 및 문제 해결 능력
- “멀티스레드 환경에서 발생한 Race Condition 문제를 어떻게 발견하고 해결했나요?”
- “Java에서 데드락이나 성능 병목을 예방하기 위해 어떤 설계를 고려하시나요?”
- “동시성 컬렉션(ConcurrentHashMap, BlockingQueue 등)을 언제 선택하시나요?”
고급 동시성 기법
- “ExecutorService, ThreadPoolExecutor, ForkJoinPool 등 스레드 풀을 어떻게 활용하나요?”
동시성의 기본: Thread와 생명주기
우리는 보통 “스레드(Thread)”라는 단어를 들어봤지만, 스프링 부트를 위주로 개발하다 보면 스레드의 생명주기(Lifecycle)에 대해 깊게 생각해본 적은 많지 않을 수 있다.
스프링 부트에서는 @Async나 스레드 풀(Executor)을 사용하면 스레드 생성, 실행, 종료까지 대부분 프레임워크가 관리해주기 때문에, 직접 Thread를 관리할 일이 거의 없다.
하지만 스레드가 어떻게 동작하고 상태가 변하는지를 이해하면, 동시성 문제를 디버깅하거나 성능 병목을 분석할 때 큰 도움이 된다.
Thread vs Runnable, 무엇을 써야 할까?
Java에서는 스레드를 만들 때 두 가지 방법이 있다.
// Thread 클래스 상속
class DataProcessor extends Thread {
@Override
public void run() {
// 데이터 처리 로직
}
}
// Runnable 인터페이스 구현 (권장)
class DataTask implements Runnable {
@Override
public void run() {
// 데이터 처리 로직
}
}
- Thread 상속: 간단하지만, Java는 단일 상속만 지원하기 때문에 다른 클래스를 상속받을 수 없어 확장성이 떨어진다.
- Runnable 구현: 권장되는 방식. 다른 클래스를 상속하면서 스레드 작업을 정의할 수 있고, 스레드 풀과 함께 사용하기도 쉽다.
실무에서는 대부분 Runnable을 사용하며, 스프링 부트에서는 Thread를 직접 만들지 않고도 스레드를 사용할 수 있다.
Thread 생명주기, 이렇게 이해하면 쉽다
스레드는 생성부터 종료까지 여러 상태를 거친다. 각 상태를 감 잡으면 디버깅이나 문제 분석에 큰 도움이 된다.
상태 | 설명 | |
NEW | 스레드 객체는 생성됐지만 아직 start()가 호출되지 않은 상태 | 준비 중인 스레드 |
RUNNABLE | 실행 중이거나 실행 가능한 상태. CPU를 기다리는 중일 수도 있음 | 언제든 실행될 준비 완료 |
BLOCKED | synchronized 블록에 진입하려고 대기 중인 상태 | 다른 스레드가 먼저 락을 잡고 있음 |
WAITING | 다른 스레드의 특정 작업이 완료되기를 무한정 기다리는 상태 | 이벤트나 조건을 기다림 |
TIMED_WAITING | 정해진 시간 동안만 대기하는 상태 | 타임아웃이 있는 기다림 |
TERMINATED | 실행이 완료되어 종료된 상태 | 작업 끝, 스레드 종료 |
스프링 부트에서는 이런 상태를 직접 관리하지 않아도 된다. 대부분 프레임워크가 Thread Pool과 생명주기를 관리해주기 때문에, 개발자는 @Async 같은 어노테이션만 붙여서 멀티스레드 작업을 실행하면 된다.
하지만 동시성 문제를 디버깅하거나 스레드 풀을 설계할 때는 각 상태가 어떤 의미인지 알고 있는 것이 큰 도움이 된다.
Thread 상태 흐름 차트
+-------+
| NEW |
| 생성됨 |
+-------+
|
v
+---------+
| RUNNABLE|
| 실행 가능 |
+---------+
/ \\
/ \\
v v
+---------+ +---------------+
| BLOCKED | | TIMED_WAITING |
| 락 대기 | | 지정 시간 대기 |
+---------+ +---------------+
| |
| v
| +---------+
| | RUNNABLE|
| | 다시 실행 |
| +---------+
|
v
+-----------+
| WAITING |
| 무한 대기 |
+-----------+
|
v
+-----------+
| TERMINATED|
| 종료됨 |
+-----------+
- NEW → RUNNABLE : start() 호출 시
- RUNNABLE → BLOCKED : synchronized 락 대기
- RUNNABLE → TIMED_WAITING : sleep(), join(timeout), wait(timeout)
- RUNNABLE → WAITING : join(), wait() (무한 대기)
- 모든 상태 → TERMINATED : 스레드 작업 종료
Thread 상태 확인 예제
public class ThreadStateExample {
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(() -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
System.out.println("생성 직후: " + thread.getState()); // NEW
thread.start();
System.out.println("start() 호출 후: " + thread.getState()); // RUNNABLE
Thread.sleep(100);
System.out.println("sleep 중: " + thread.getState()); // TIMED_WAITING
}
}
- 생성 직후 (NEW)
- 스레드 객체를 만들었지만 아직 start()를 호출하지 않은 상태
- 실행 준비는 되었지만 아직 CPU에서 실행되지 않음
- start() 호출 후 (RUNNABLE)
- 스레드가 실행될 수 있는 상태가 됨
- 실제로 CPU에서 실행될 수도, 아직 대기 중일 수도 있음
- sleep 중 (TIMED_WAITING)
- 스레드가 Thread.sleep(1000) 때문에 지정된 시간만큼 기다리는 상태
- 대기 후 다시 RUNNABLE 상태로 돌아가 실행 가능
이렇게 간단한 코드로 스레드 상태를 눈으로 확인하면, 생명주기 이해가 훨씬 쉽다.
스프링 부트에서는 대부분 Thread Pool과 @Async가 관리하지만, 내부적으로 이런 상태 변화를 이해하면 동시성 문제 디버깅에 큰 도움이 된다.
동시성 문제의 근본 원인
Race Condition이 발생하는 이유
Race Condition은 경쟁 조건으로 여러 스레드가 공유 자원에 동시에 접근할 때, 실행 순서에 따라 결과가 달라지는 현상을 말한다.
여러 스레드가 공유 자원에 동시에 접근할 때 실행 순서에 따라 결과가 달라지는 현상이다.
public class RaceConditionExample {
private static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[1000];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
counter++; // 이 부분이 위험하다!
}
});
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("예상값: 1000000, 실제값: " + counter);
// 실제로는 1000000보다 작은 값이 나온다
}
}
왜 발생하는가?
- CPU 코어가 여러 개일 경우, 여러 스레드가 동시에 실행될 수 있음
- counter++ 같은 연산은 사실 3단계 연산임→ 두 스레드가 동시에 읽고 쓰면, 증가분이 날아감
1) 메모리에서 counter 값 읽기
2) 1 증가
3) 메모리에 다시 쓰기 - 스레드 간 실행 순서가 불확실하기 때문에 결과가 달라짐
Critical Section (임계 영역)
공유 자원에 접근하는 코드 영역을 Critical Section이라고 한다. 위 예제에서 counter++ 부분이 바로 Critical Section이다. 원하는 예상값 ‘1000000’이 나오기 위해서는 이 영역은 한 번에 하나의 스레드만 접근할 수 있도록 보호해야 한다.
동기화 메커니즘 방법 (java lock 기준)
동기화 메커니즘 선택 가이드
메커니즘 | 장점 | 주의할 점 |
synchronized | 문법이 간단하고 JVM이 관리 | 블록 범위가 크면 성능 저하 가능 |
volatile | 변수 가시성 보장, 단순 상태 플래그에 적합 | 복합 연산에는 사용할 수 없음 |
ReentrantLock | 세밀한 락 제어, 조건 대기 가능 | 반드시 unlock을 finally에서 호출해야 함 |
Atomic 클래스 | 락 없이 원자적 연산, 성능 우수 | 단일 변수 연산에만 적합, 복합 연산에는 부적합 |
synchronized: 가장 기본적인 동기화
synchronized는 두 가지 방식으로 사용할 수 있다. 블록 레벨 동기화를 사용하면 락의 범위를 더 세밀하게 제어할 수 있어 성능상 유리하다.
public class SynchronizedExample {
private int count = 0;
// 메서드 레벨 동기화
public synchronized void incrementMethod() {
count++;
}
// 블록 레벨 동기화
public void incrementBlock() {
synchronized(this) {
count++;
}
}
// 정적 메서드 동기화
public static synchronized void staticMethod() {
// 클래스 레벨 락 사용
}
}
volatile: 가시성 문제 해결
volatile 키워드는 변수의 값이 메인 메모리에서 직접 읽고 쓰여지도록 보장한다.
volatile은 단일 변수의 읽기/쓰기에만 원자성을 보장한다. count++ 같은 복합 연산에는 사용할 수 없다.
public class VolatileExample {
private volatile boolean running = true;
public void stop() {
running = false; // 모든 스레드에게 즉시 보임
}
public void doWork() {
while (running) {
// 작업 수행
}
}
}
ReentrantLock: synchronized보다 강력한 락
ReentrantLock은 synchronized보다 유연한 락 메커니즘을 제공한다:
public class ReentrantLockExample {
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock(); // 반드시 finally에서 해제
}
}
public boolean tryIncrement() {
if (lock.tryLock()) { // 논블로킹 락 시도
try {
count++;
return true;
} finally {
lock.unlock();
}
}
return false;
}
}
Atomic 클래스: 락 없는 동시성
Atomic 클래스들은 CAS(Compare-And-Swap) 연산을 사용해 락 없이도 스레드 안전성을 보장한다.
public class AtomicExample {
private final AtomicInteger counter = new AtomicInteger(0);
private final AtomicReference<String> reference = new AtomicReference<>("initial");
public void increment() {
counter.incrementAndGet(); // 원자적 증가
}
public void compareAndSet() {
reference.compareAndSet("old", "new"); // CAS 연산
}
}
고급 동시성 도구
자바 락과 달리 스레드 실행 흐름을 제어하는 도구들이다. Race Condition 방지가 목적이 아니라 스레드 조율, 대기, 제한, 재사용 목적으로 사용한다.
도구 | 용도 | 특징/사용 사례 |
CountDownLatch | 여러 스레드 작업 완료 대기 | 한 번만 사용, 테스트/초기화용 |
CyclicBarrier | 모든 스레드 특정 지점 도달 대기 | 재사용 가능, 병렬 알고리즘/시뮬레이션 |
Semaphore | 동시 접근 스레드 수 제한 | DB 커넥션 풀, 파일/API 접근 제어 |
ThreadPoolExecutor | 스레드 재사용, 효율적 관리 | 거의 모든 병렬 작업, Spring @Async 등과 사용 |
- 동기화 메커니즘 → 공유 자원 안전
- 고급 동시성 도구 → 스레드 흐름/작업 조율
CountDownLatch: N개 작업 완료 대기
여러 스레드가 특정 작업을 완료할 때까지 메인 스레드를 대기시킬 때 사용한다.
public class CountDownLatchExample {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
new Thread(() -> {
try {
// 작업 수행
Thread.sleep(1000);
System.out.println("작업 완료");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown(); // 카운트 감소
}
}).start();
}
latch.await(); // 모든 작업 완료까지 대기
System.out.println("모든 작업 완료!");
}
}
CyclicBarrier: 모든 스레드가 특정 지점 도달 대기
CountDownLatch와 비슷하지만, 재사용 가능하고 모든 스레드가 동시에 진행한다.
public class CyclicBarrierExample {
public static void main(String[] args) {
CyclicBarrier barrier = new CyclicBarrier(3, () -> {
System.out.println("모든 스레드가 준비됨!");
});
for (int i = 0; i < 3; i++) {
new Thread(() -> {
try {
System.out.println("준비 중...");
Thread.sleep(1000);
barrier.await(); // 다른 스레드들 대기
System.out.println("동시 시작!");
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
Semaphore: 리소스 접근 제한
동시에 특정 리소스에 접근할 수 있는 스레드 수를 제한한다.
public class SemaphoreExample {
private final Semaphore semaphore = new Semaphore(2); // 최대 2개 스레드만 허용
public void accessResource() {
try {
semaphore.acquire(); // 허가 획득
System.out.println("리소스 사용 중...");
Thread.sleep(2000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
semaphore.release(); // 허가 반납
}
}
}
ThreadPoolExecutor: 효율적인 스레드 관리
스레드를 매번 생성하고 소멸시키는 것은 비효율적이다. 스레드 풀을 사용해 재사용한다.
public class ThreadPoolExample {
public static void main(String[] args) {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, // 코어 스레드 수
4, // 최대 스레드 수
60, // 유휴 시간
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10) // 작업 큐
);
for (int i = 0; i < 20; i++) {
final int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId + " 실행");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
}
}
동시성 컬렉션 활용
동기화 메커니즘과 고급 동시성 도구를 사용하면 스레드 안전성을 확보할 수 있지만, 컬렉션 같은 자료구조를 직접 보호하려면 락을 직접 걸어야 하는 불편함이 있다.
동시성 컬렉션은 내부적으로 락이나 원자적 연산을 처리하여, 개발자가 직접 동기화 코드를 쓰지 않고도 안전하게 여러 스레드가 접근할 수 있게 해준다.
ConcurrentHashMap vs HashMap vs Hashtable
// 일반 HashMap - 스레드 안전하지 않음
Map<String, Integer> hashMap = new HashMap<>();
// Hashtable - 스레드 안전하지만 성능이 떨어짐
Map<String, Integer> hashtable = new Hashtable<>();
// ConcurrentHashMap - 스레드 안전하면서 성능도 좋음
Map<String, Integer> concurrentMap = new ConcurrentHashMap<>();
- 언제 사용?
- 여러 스레드가 동시에 Map을 읽고 쓰는 경우
- Hashtable보다 성능이 뛰어나면서 안전하게 쓰고 싶을 때
- 왜 쓰는가?
- 내부적으로 세그먼트 락 또는 원자적 연산을 사용해, 전체 Map을 락하지 않고도 안전하게 동작
BlockingQueue: 생산자-소비자 패턴
public class ProducerConsumerExample {
private final BlockingQueue<String> queue = new ArrayBlockingQueue<>(10);
// 생산자
public void producer() {
try {
for (int i = 0; i < 5; i++) {
queue.put("item-" + i); // 큐가 가득 찰 때까지 블로킹
System.out.println("생산: item-" + i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// 소비자
public void consumer() {
try {
for (int i = 0; i < 5; i++) {
String item = queue.take(); // 큐가 빌 때까지 블로킹
System.out.println("소비: " + item);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
- 언제 사용?
- 생산자-소비자 패턴 구현 시
- 큐가 가득 차거나 비었을 때 자동으로 블록 처리
- 왜 쓰는가?
- 스레드 안전성을 보장하면서, 대기/알림 로직을 직접 구현할 필요 없음
CopyOnWriteArrayList: 읽기 위주의 리스트
쓰기가 드물고 읽기가 빈번한 경우에 사용한다:
List<String> list = new CopyOnWriteArrayList<>();
list.add("item1"); // 복사본을 만들어서 수정
list.add("item2");
// 읽기는 락 없이 빠르게 수행됨
for (String item : list) {
System.out.println(item);
}
- 언제 사용?
- 쓰기는 드물고, 읽기가 빈번한 경우
- 왜 쓰는가?
- 읽기는 락 없이 빠르게 수행 가능
- 쓰기가 발생하면 내부적으로 복사본을 만들어 안전하게 반영
핵심 포인트
- 동시성 컬렉션은 실제 공유 자원 접근을 안전하게 처리하는 편리한 도구직접 락을 걸 필요 없이, 목적에 맞는 컬렉션 선택만으로 안전하게 스레드 간 접근 가능
- 실무에서는 ConcurrentHashMap, BlockingQueue, CopyOnWriteArrayList가 가장 자주 쓰임
비동기 프로그래밍과 CompletableFuture
전통적인 멀티스레딩은 스레드를 직접 관리해야 하는 부담이 있다. CompletableFuture는 비동기 작업을 더 선언적이고 함수형으로 처리할 수 있게 해준다.
- CompletableFuture: Java 8에서 도입된 강력=한 비동기 프로그래밍 도구
기본 비동기 작업
public class CompletableFutureBasics {
public static void main(String[] args) {
// 비동기로 작업 실행
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(1000);
return "안녕하세요!";
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
// 작업 완료 후 추가 처리
CompletableFuture<String> processed = future.thenApply(result ->
result.toUpperCase());
// 결과 출력
processed.thenAccept(result ->
System.out.println("결과: " + result));
// 메인 스레드는 다른 작업 수행 가능
System.out.println("메인 스레드는 계속 실행됩니다.");
// 모든 작업 완료 대기
processed.join();
}
}
여러 비동기 작업 조합
public class CompletableFutureComposition {
public static void main(String[] args) {
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(1000);
return "Hello";
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(1000);
return "World";
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
// 두 작업의 결과를 조합
CompletableFuture<String> combined = future1.thenCombine(future2,
(s1, s2) -> s1 + " " + s2);
System.out.println(combined.join()); // "Hello World"
// 모든 작업 중 가장 빠른 결과 사용
CompletableFuture<String> fastest = CompletableFuture.anyOf(future1, future2)
.thenApply(result -> result.toString());
}
}
예외 처리와 타임 아웃
public class CompletableFutureErrorHandling {
public static void main(String[] args) {
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (Math.random() > 0.5) {
throw new RuntimeException("작업 실패!");
}
return "성공!";
}).exceptionally(throwable -> {
System.out.println("예외 발생: " + throwable.getMessage());
return "기본값";
}).orTimeout(2, TimeUnit.SECONDS); // 2초 타임아웃
System.out.println(future.join());
}
}
- 언제 사용?
- 여러 비동기 작업을 조합해야 할 때
- 콜백 지옥을 피하고 싶을 때
- 논블로킹 방식으로 결과를 처리하고 싶을 때
- 왜 쓰는가?
- 함수형 프로그래밍 스타일로 비동기 로직 작성
- 체이닝을 통한 깔끔한 코드
- 예외 처리와 타임아웃 내장
CompletableFuture를 활용하면 멀티스레드 환경에서 비동기 작업을 손쉽게 처리할 수 있지만, 여러 스레드가 공유 자원을 동시에 접근하는 상황에서는 여전히 데드락이나 경쟁 상태가 발생할 수 있다. 따라서 다음으로 데드락의 원인과 예방 방법을 살펴보자.
데드락: 원인과 해결 방법
데드락 발생 조건
- 상호 배제: 리소스를 한 번에 하나의 스레드만 사용
- 점유와 대기: 리소스를 점유한 상태에서 다른 리소스 대기
- 비선점: 다른 스레드가 리소스를 강제로 빼앗을 수 없음
- 순환 대기: 스레드들이 원형으로 서로를 대기
데드락 예방하기
public class DeadlockPreventionExample {
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public void goodMethod1() {
synchronized(lock1) {
synchronized(lock2) { /* 작업 수행 */ }
}
}
public void goodMethod2() {
synchronized(lock1) { synchronized(lock2) { /* 작업 수행 */ } }
}
}
타임아웃 활용 예제
public class DeadlockAvoidanceExample {
private final ReentrantLock lock1 = new ReentrantLock();
private final ReentrantLock lock2 = new ReentrantLock();
public boolean transferMoney(Account from, Account to, int amount) {
try {
boolean acquired1 = lock1.tryLock(1, TimeUnit.SECONDS);
if (!acquired1) return false;
try {
boolean acquired2 = lock2.tryLock(1, TimeUnit.SECONDS);
if (!acquired2) return false;
from.withdraw(amount);
to.deposit(amount);
return true;
} finally { lock2.unlock(); }
} finally { lock1.unlock(); }
}
}
데드락을 예방하려면 락 획득 순서 일관성 + tryLock/타임아웃 활용이 중요하다.
다음으로는 락 사용 시 성능 저하를 최소화하는 최적화 방법을 알아보자.
실전 성능 최적화 팁
락 범위 최소화
public class LockOptimization {
private final Object lock = new Object();
private volatile boolean flag = false;
// 나쁜 예: 락 범위가 너무 큼
public void badMethod() {
synchronized(lock) {
// 긴 계산 작업
complexCalculation();
flag = true;
// 또 다른 긴 작업
anotherLongOperation();
}
}
// 좋은 예: 꼭 필요한 부분만 동기화
public void goodMethod() {
// 락 없이 실행
Object result = complexCalculation();
synchronized(lock) {
// 최소한의 동기화
updateSharedState(result);
flag = true;
}
// 락 없이 실행
anotherLongOperation();
}
}
읽기-쓰기 락 활용
- 읽기가 많고 쓰기가 적은 경우 ReadWriteLock 사용
- 읽기는 락 없이 빠르게, 쓰기 시만 락 확보
public class ReadWriteLockExample {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
private final Map<String, String> cache = new HashMap<>();
public String getData(String key) {
readLock.lock();
try {
return cache.get(key);
} finally {
readLock.unlock();
}
}
public void putData(String key, String value) {
writeLock.lock();
try {
cache.put(key, value);
} finally {
writeLock.unlock();
}
}
}
안전성과 성능을 동시에 고려하려면 락 범위를 최소화하고, 읽기/쓰기 패턴에 맞는 락 전략을 적용해야 한다.
자바 동시성 면접 질문 답변 가이드
이제는 처음에 보았던 질문에 대한 대답이 가능할 것이다.
1. 동시성 문제의 원인
- Q) 왜 Java 멀티스레드 환경에서 공유 자원이 꼬일 수 있나요?
A) 멀티스레드 환경에서 여러 스레드가 동시에 같은 객체나 자원을 읽고 쓰면, 실행 순서가 엇갈리면서 데이터 충돌(Race Condition)이 발생한다. 예를 들어 counter++ 연산은 읽기-계산-쓰기 3단계로 이루어져 중간에 다른 스레드가 끼어들 수 있다. - Q) Java Memory Model(JMM)이 동시성 문제에 어떤 영향을 미치나요?
A) CPU 캐시와 메인 메모리 간 불일치로 한 스레드의 변경이 다른 스레드에 즉시 반영되지 않을 수 있다. volatile 없이 상태 공유 시, 예상치 못한 결과가 발생할 수 있다.
2. 동기화(synchronization) 기법
- Q) synchronized와 ReentrantLock의 차이점은 무엇인가요?
A) synchronized는 문법이 간단하고 JVM이 관리하지만, 락 범위가 크면 성능 저하 가능
ReentrantLock은 락 해제/획득을 세밀하게 제어 가능, tryLock과 Condition 활용 가능하지만 반드시 finally에서 unlock 필요 - Q) volatile 키워드는 언제 사용하고, 어떤 한계가 있나요?
A) 변수 가시성을 보장하고 단순 상태 플래그에 적합하다. 하지만 복합 연산(예: count++)에는 원자성을 보장하지 못한다. - Q) Atomic 클래스는 어떤 상황에서 유용하게 쓰이나요?
A) CAS(Compare-And-Swap)를 사용해 락 없이 원자적 연산을 지원한다. 단일 변수 연산에 적합하며, 성능이 중요한 경우 유용하다.
3. 실무 경험 및 문제 해결
- Q) 멀티스레드 환경에서 발생한 Race Condition 문제를 어떻게 발견하고 해결했나요?
A) 로그와 멀티스레드 테스트로 문제 재현 후, synchronized, ReentrantLock 또는 AtomicInteger를 사용해 Critical Section 보호 - Q) Java에서 데드락이나 성능 병목을 예방하기 위해 어떤 설계를 고려하시나요?
A) 락 획득 순서 일관성 유지, tryLock과 타임아웃 활용, 락 범위 최소화. 읽기-쓰기 패턴에 맞춰 ReadWriteLock 활용 - Q) 동시성 컬렉션(ConcurrentHashMap, BlockingQueue 등)을 언제 선택하시나요?
A)
ConcurrentHashMap → 다중 스레드 안전, 성능 우수
BlockingQueue → 생산자-소비자 패턴, 자동 블로킹
CopyOnWriteArrayList → 읽기 위주, 쓰기 드문 경우 최적
4. 고급 동시성 기법
- Q) ExecutorService, ThreadPoolExecutor, ForkJoinPool 등 스레드 풀을 어떻게 활용하나요?
A) 스레드 재사용으로 생성/소멸 비용 절감, 작업 큐 관리. 스프링 @Async와 연계 가능. ForkJoinPool은 작업 분할-정복(Parallel Stream 등)에 최적
'Java | spring > Java Basic' 카테고리의 다른 글
Java 파일 업로드, 다운로드 처리(+파일 사이즈 제한 세팅 방법) (2) | 2023.12.17 |
---|---|
알아도 어려운 트랜잭션 개념정리 (0) | 2021.06.04 |
Optional 제대로 알기, 면접 대비! (+간단 실무 코드 예시) (0) | 2021.06.03 |
Mac java 여러 버전 설치 (0) | 2021.05.26 |
Java 네트워크 프로그래밍 : 용어, TCP/UDP (0) | 2019.05.08 |
댓글