1. 프로세스와 스레드 (Process & Thread)
- 프로세스(Process): 간단히 말해 실행 중인 프로그램이다. 자원(메모리, 데이터 등)을 가지고 있다.
- 스레드(Thread): 프로세스의 자원을 이용해서 실제로 작업을 수행하는 일꾼이다.
모든 프로세스에는 최소한 하나 이상의 스레드가 존재한다. 그리고 둘 이상의 스레드를 가진 프로세스를 멀티스레드 프로세스(Multi-threaded Process)라고 한다.

2. 멀티스레딩의 장단점
멀티스레딩은 강력하지만, 양날의 검과 같다.
장점 4가지
- CPU 사용률 향상: 노는 자원 없이 CPU를 알뜰하게 쓸 수 있다.
- 자원의 효율적 사용: 프로세스 내 자원을 공유하므로 효율적이다.
- 응답성 향상: 사용자 입장에서 프로그램이 멈추지 않고 반응한다.
- 코드 간결화: 작업이 논리적으로 분리되어 코드가 깔끔해질 수 있다.
단점 및 주의사항
멀티스레딩에 장점만 있는 것은 아니다. 여러 스레드가 같은 프로세스 내에서 자원을 공유하며 작업하기 때문에 동기화(Synchronization), 교착상태(Deadlock)와 같은 골치 아픈 문제들이 발생할 수 있다. 따라서 매우 신중하게 프로그래밍해야 한다.
3. Java에서의 스레드 구현
자바에서 스레드를 구현하는 방법은 교과서적으로 두 가지가 있다.
- Thread 클래스 상속:
extends Thread - Runnable 인터페이스 구현:
implements Runnable
Thread 클래스를 상속받으면 자바는 다중 상속을 지원하지 않기 때문에 다른 클래스를 상속받을 수 없다는 단점이 있다. 반면 Runnable을 구현하면 작업과 실행을 분리할 수 있어 유연하다.
하지만 위 두 방법을 직접 쓰기보다, 스레드 풀(Thread Pool)을 주로 사용한다. ExecutorService를 통해 스레드를 관리하는 것이 표준이다.
참고:
Thread클래스의start()와run()은 다르다.run()은 단순히 메서드를 호출하는 것이고,start()는 새로운 Call Stack(호출 스택)을 생성한 뒤 작업을 실행한다.

4. 스레드의 실행 제어와 상태
스레드 스케줄링과 불확실성
싱글 코어에서 멀티스레드를 쓰면 오히려 싱글 스레드보다 느릴 수 있다. 바로 Context Switching(문맥 교환) 비용 때문이다.
또한 멀티 코어 환경에서 스레드의 실행 순서는 OS의 프로세스 스케줄러가 결정한다. 실행할 때마다 결과가 달라질 수 있다는 불확실성을 가진다. 자바가 '플랫폼 독립적'이라고 하지만, 스레드 스케줄링만큼은 OS에 의존적이므로 완전한 독립은 아니다.
스레드 우선순위 (Priority)
스레드는 1~10 사이의 우선순위를 가진다 (기본값 5). main 메서드의 우선순위가 5이므로, 여기서 생성된 스레드들도 기본적으로 5를 상속받는다.
⚠️ ThreadGroup은 사용 금지
ThreadGroup은 자바 1.0 시절의 유물이다. 현재는 "망한 API" 취급을 받는다. 자바의 아버지 제임스 고슬링이나 조슈아 블로크 같은 거장들도 "스레드 보안에 취약하고 기능도 불완전하니 절대 쓰지 말라"고 비판했다. 그냥 잊어버리자.
데몬 스레드 (Daemon Thread)
일반 스레드의 작업을 돕는 보조적인 역할을 한다. 일반 스레드가 모두 종료되면 데몬 스레드는 강제적으로 자동 종료된다. 대표적인 예가 가비지 컬렉터(Garbage Collector)다.

5. 동기화의 진화: synchronized를 넘어 Lock으로
자바의 초기 동기화 도구인 synchronized는 간편하지만 명확한 한계가 있다. 블록에 들어가려는 스레드가 많아지면 누가 먼저 락을 얻을지 보장할 수 없고(불공정성), 락을 얻지 못한 스레드는 무한정 기다려야 할 수도 있다.
이를 해결하기 위해 JDK 1.5부터 java.util.concurrent.locks 패키지가 도입되었고, 더 세밀한 제어가 가능한 다양한 Lock 클래스들이 등장했다.
ReentrantLock: 가장 일반적인 락
가장 기본이 되는 락이다. synchronized와 유사하게 배타적 락(Exclusive Lock) 기능을 제공한다.
- 재진입 가능(Re-entrant): 이미 락을 쥐고 있는 스레드가 다시 같은 락을 요청해도 데드락에 걸리지 않고 진입할 수 있다는 뜻이다.
synchronized와 달리 수동으로 락을 잠그고(lock()) 풀어야(unlock()) 한다.- 특정 시간만큼만 기다리거나(
tryLock()), 인터럽트에 반응하도록 락 대기를 깰 수 있는 등 유연한 제어가 가능하다.
ReentrantReadWriteLock: 읽기와 쓰기의 분리
모든 작업에 배타적 락을 걸 필요는 없다. 데이터 변경 없이 단순히 읽기만 하는 작업끼리는 서로 막을 필요가 없기 때문이다.
- 읽기 락(Read Lock): 공유 락(Shared Lock)이다. 쓰기 락이 걸려있지 않다면, 여러 스레드가 동시에 읽기 락을 걸고 접근할 수 있다.
- 쓰기 락(Write Lock): 배타적 락(Exclusive Lock)이다. 쓰기 락이 걸려있으면 다른 스레드는 읽기도, 쓰기도 불가능하다.
- 활용: 읽기 작업이 압도적으로 많고 데이터 변경은 드문 경우 성능 효율이 매우 좋다.

StampedLock: 낙관적 읽기의 등장 (JDK 1.8+)
ReentrantReadWriteLock의 성능을 더 극한으로 끌어올린 락이다. 락을 걸거나 해제할 때 스탬프(long 타입의 정수값)를 사용한다. 가장 큰 특징은 낙관적 읽기(Optimistic Reading) 기능이다.
- 낙관적 읽기란?
- 일단 락을 걸지 않고 데이터를 읽는다. (무작정 읽기)
- 읽은 후에 "내가 읽는 동안 누가 데이터를 수정했나?"를 스탬프를 통해 확인(Validate)한다.
- 수동된 적이 없다면? -> 락 없이 읽기 성공 (성능 최고)
- 수정된 적이 있다면? -> 그때서야 읽기 락을 걸고 다시 데이터를 읽어온다.
⚠️ 치명적인 단점: 데드락 위험과 재진입 불가StampedLock을 쓸 때 가장 조심해야 할 점은 재진입(Re-entrancy)이 불가능하다는 것이다.ReentrantLock은 이미 락을 가진 스레드가 다시 락을 요청해도 통과시켜 주지만, StampedLock은 그렇지 않다. 만약 쓰기 락을 쥔 스레드가 로직 내부에서 실수로 다시 락을 얻으려 하면(예: 재귀 호출), 자기가 건 락에 자기가 가로막혀 영원히 멈춰버리는 데드락(Deadlock)에 빠진다.
또한 wait()/notify() 같은 기능을 지원하지 않고, 인터럽트(interrupt) 시에도 예기치 않게 동작할 수 있어 다루기가 매우 까다롭다. 성능은 좋지만 읽기가 압도적인 상황이 아니라면 굳이 위험을 감수하고 쓸 필요는 없다.
Condition: 스레드를 구분해서 기다리게 하기
synchronized에서 wait()와 notify()를 쓸 때 가장 큰 문제는 "누구를 깨울지 모른다"는 것이다. notifyAll()을 하면 대기 중인 모든 스레드가 벌떼처럼 일어나 락을 얻으려 경쟁(Race Condition)하게 된다.
Condition은 이 문제를 해결한다.
wait()대신await()를,notify()대신signal()을 사용한다.- 핵심 기능: 스레드의 종류에 따라 대기실(Waiting Pool)을 나눌 수 있다.
- 예를 들어, 빵집 예제에서 '빵을 만드는 스레드'와 '빵을 사는 스레드'가 있다고 치자.
Condition을 쓰면 "빵이 없으니 손님 스레드만 기다려(buyer.await())"라고 하거나 "빵 다 만들었으니 손님 스레드만 일어나(buyer.signal())"라고 콕 집어서 제어할 수 있다. 불필요한 경쟁을 획기적으로 줄여준다.
6. Volatile과 메모리 가시성
멀티 코어 프로세서는 각 코어마다 캐시(Cache)를 가지고 있다. 메모리에서 값을 읽어 캐시에 저장해두고 쓰는데, 메모리 값은 변했는데 캐시가 갱신되지 않으면 문제가 생긴다.
이때 변수 앞에 volatile을 붙이면 캐시가 아니라 항상 메인 메모리에서 값을 읽고 쓴다. (가시성 문제 해결).long과 double 같은 8byte 타입은 4byte 단위로 처리되는 JVM 특성상 원자성이 깨질 수 있는데, volatile을 쓰면 원자적인 읽기/쓰기가 가능해진다. 물론 final 상수는 어차피 불변이므로 Thread-safe 하다.

7. Fork & Join 프레임워크
하나의 큰 작업을 작은 단위로 쪼개서(Fork) 여러 스레드가 처리한 뒤 합치는(Join) 방식이다. start() 대신 invoke()로 시작한다.
- Work Stealing: 할 일이 없는 스레드가 바쁜 스레드의 큐에서 작업을 훔쳐와서 처리한다. 효율적이다.
- ForkJoinPool: 스레드 풀을 제공한다.
- 활용: 직접 구현할 일은 드물고, 주로 Java Stream의
parallelStream()내부 엔진으로 사용된다. 자바 21의 가상 스레드(Virtual Thread)도 이 위에서 돌아간다.
정리하며
멀티스레딩은 성능을 위해 필수적이지만, 동기화 이슈와 데드락 같은 복잡성을 동반한다. synchronized 뿐만 아니라 ReentrantLock, volatile, Atomic 변수 등 상황에 맞는 도구를 적절히 사용하는 것이 중요하다.
'공부 > JAVA' 카테고리의 다른 글
| [JAVA] 배열(Array) vs ArrayList vs Vector (0) | 2026.02.06 |
|---|---|
| [JAVA] 어노테이션과 리플렉션 원리 (1) | 2025.12.11 |
| [Java] Record는 무엇인가? (0) | 2025.12.05 |
| [Spring]서블릿(Servlet)과 Spring MVC (0) | 2025.11.17 |