Synchronization이 없을 때 발생하는 오류들
서로 다른 스레드가 어떤 필드나 오브젝트를 공유하는 것은 흔한 일이다.
하지만 이러한 리소스 공유는 Thread Interference나 Memory Consistency Error와 같은 문제로 이어질 수 있다.
Thread Interference
Thread Interference는 동시에 두 개의 스레드가 한 개의 값에 접근할 때, 그 결과가 예상과 다르게 동작하는 현상이다.
대표적으로 아래와 같은 상황에서 발생한다.
int count = 0;
void inc(){
count = count+1;
}
여기서 두 스레드 A와 B가 동시에 inc를 호출했을 때, count가 2가 되는 것을 보장하지 않는다. 왜냐하면 A와 B가 동시에 count의 값을 읽었다면 inc를 호출했을 때 A와 B 모두 0을 1로 바꾸는 작업을 하게 되기 때문이다.
Memory Consistency Error
Memory Consistency Error는 서로 다른 스레드에서 가시성이 보장되지 않기에 발생하는 문제이다.
가시성(Visibility)는 스레드 A에서 값을 변경했을 때, 스레드 B에서 변경된 값을 확인할 수 있는지를 의미한다.
즉 다시 말해서 Memory Consistency Error는 서로 다른 스레드에서 동일한 자원에 접근할 때 가시성이 보장되지 않기에 발생하는 문제이다.
int count = 0;
void inc(){
count++;
}
단일 스레드에서는 inc를 호출한 후 count는 1이다.
하지만 멀티 스레드 환경에서는 inc로 인한 변경사항이 count에 즉시 적용되지 않아 Memory Consistency Error가 발생할 수 있다.
가시성을 보장하기 위해서는 happens-before 관계를 수립해야 한다. happens-before 관계는 어떤 쓰기 작업이 어떤 읽기 작업보다 먼저 일어나는 것을 보장하는 규칙으로 아래와 같은 방법으로 수립이 가능하다.
- Synchronization 사용
- Thread.start로 스레드 시작 시 Thread.start 호출 이전의 모든 코드들은 Thread.start로 실행하는 코드들과 happens-before 관계를 갖는다.
- 어떤 스레드 A가 종료되고, 스레드 B가 A를 join한다면, B의 join 이후 코드들은 스레드 A의 코드들과 happens-before 관계를 갖는다.
문제의 해결법과 제한
Synchronization은 이러한 문제를 효과적으로 해결할 수 있지만, 잘못된 사용은 Thread Contention으로 이어질 수 있다.
Thread Contention이란 동일한 자원에 대해 서로 다르 스레드가 동시에 접근하면서 발생하는 문제이고, 성능 저하나 오류의 원인이 되기도 한다.
Synchronization
Synchronized와 내부 락
Java에서 안전한 스레드 간 자원 공유를 가능하도록 해주는 Synchronized는 내부적으로 내부 락(intrinsic lock)을 사용한다. 내부 락은 객체에 대한 외부 접근과 happens-before 관계 수립에 사용될 수 있다.
내부 락의 동작 원리
모든 객체는 내부 락을 갖고 있고 해당 락을 통해서 어떤 객체에 대한 동시 접근을 제어한다.
스레드가 어떤 객체에 접근하기 위해서는 객체의 내부 락을 얻어야 하고, 작업 완료 시 반납한다. 동일한 객체에 대해 동시에 2개의 락을 얻는 것은 불가능하고 락을 습득하기 전까지 스레드는 block된다.
스레드 A가 내부 락을 반납한 후 해당 락을 다른 스레드 B가 습득하면 A와 B 사이에는 happens-before 관계가 형성된다.
Synchronized 메서드
Synchronized 메서드 생성 방법과 효과
Synchronized 메서드는 아래와 같이 Synchronized 키워드를 통해 선언 가능하다.
public synchronized void syncMethod(){
// do something
}
해당 키워드는 두 가지 효과를 갖는다.
- 동시접근 불가 : 동일한 객체의 synchronized 메서드에 대해서 동시 접근은 불가하다. 만약 동시 접근이 있다면 먼저 실행한 스레드가 종료되기 전까지 다른 스레드는 block된다.
- happens-before 보장 : 스레드 A가 synchronized 메서드 종료 후 스레드 B가 해당 메서드를 호출한다면, A로 인한 변경 사항과 B에 대해서 happens-before가 보장된다.
문법적 오류 : synchronized 생성자
생성자는 synchronized로 선언 불가능하다.
생성자 호출 시에는 객체가 완전히 호출된 상태가 아니고, synchronized가 내부적으로 락을 걸기 위해서는 완전한 객체가 필요하기에 대상이 되지도 않고, 컴파일 오류가 발생한다.
또한, 생성자 안에서 자기 자신을 다른 스레드에 공유한다면, 객체 초기화 전에 해당 객체에 다른 클래스가 접근하는 문제가 발생할 수 있다.
Synchronized Method의 락
스레드 A가 synchronized 메서드를 실행 시 아래 순서로 동작한다.
- A가 객체 인스턴스에 대한 내부 락을 습득
- 작업 완료 후 락 반환
락의 반환은 return 등의 함수 종료뿐만 아니라 uncaught exception( catch되지 않은 예외 )에 대해서도 발생한다.
synchronized 키워드가 적용된 정적 메서드는 일반 메서드에 대한 것과 다르게 동작한다.
synchronized static method는 객체 인스턴스의 락과 구분되는 클래스 객체 자체의 락을 사용한다.
Synchronized Statement
Synchronized Statement 사용 방법
Synchronized method와 달리 Synchronized Statement는 내부 락을 제공하는 객체를 명시할 수 있다.
또한 특정 블록에 대해서만 synchronized를 적용 가능하기에 세밀한 제어가 가능하다..
public class MyClass{
int counter = 0;
public void increment(){
synchronized(this){
counter++;
}
System.out.println(counter);
}
}
이 코드에서는 락을 제공하는 객체를 현재 인스턴스로 지정했다.
또한 counter에 대한 부분적 락을 설정했다.
뿐만 아니라 아래와 같이 자원별 락을 생성 가능하다.
public class MyClass{
Object lock1 = new Object();
Object lock2 = new Object();
int counter1 = 0;
int counter2 = 0;
public void increment1(){
synchronized(lock1){
counter1++;
}
}
public void increment2(){
synchronized(lock2){
counter2++;
}
}
}
Synchronized Statement 사용 시 주의할 점
Synchronized Statement는 프로그램의 병렬성을 증가시키기에 효율적이지만, 두 가지 문제가 발생할 수 있다.
- race condition : 같은 로직에 있는 필드들이 서로 다른 lock으로 관리되는 경우 발생
public class MyClass{
Object lock1 = new Object();
Object lock2 = new Object();
int counter1 = 0;
int counter2 = 0;
public void increment(){
synchronized(lock1){
counter1++;
}
synchronized(lock2){
counter2++;
}
}
public boolean isSame(){
return counter1 == counter2;
}
}
이 코드는 increment 호출 시 counter1과 counter2가 모두 증가되도록 설계되었다.
하지만, 아래 상황에서 race condition이 발생한다.
- counter1을 증가시킨 후 lock2를 얻기 위해 blocked
- isSame 호출 시 counter1과 counter2 값이 서로 다름
- Dead Lock : 락을 얻는 순서가 보장되지 않기에 발생
public class MyClass{
Object lock1 = new Object();
Object lock2 = new Object();
int counter1 = 0;
int counter2 = 0;
public void increment1(){
synchronized(lock1){
synchronized(lock2){
counter1++;
counter2++;
}
}
}
public void increment2(){
synchronized(lock2){
synchronized(lock1){
counter1++;
counter2++;
}
}
}
}
이 코드에서는 increment1를 호출하여 lock1을 얻고 lock2를 기다리는 상황에서 increment2를 호출했을 때 DeadLock이 발생할 수 있다.
따라서 필드 간 완전히 독립되었거나 병렬로 수행 시 시스템 간 일관성이 깨지지 않는 경우에만 Synchronized Statement를 사용하는 것이 좋다
Atomic Access
프로그래밍에서 atomic action은 하나의 완전한 단위로 실행되는 연산을 의미한다. 이는 곧 중간에 끊기지 않고 완전히 실행 및 실패하는 작업을 의미한다.
Java에서는 다음과 같은 작업이 atomic하다.
- 대부분의 원시 타입에 대한 읽기와 쓰기
- volatile로 선언한 모든 변수에 대한 읽기와 쓰기
원시 타입은 Thread Interference에서는 자유롭지만, Memory Consistency Error에서는 자유롭지 못하다.
하지만 Volitile은 happens-before를 보장하기에 이러한 문제에서 자유롭다
마무리
이번에는 스레드 간 자원 공유 시 발생할 수 있는 문제를 해결하는 방법을 알아보았다.
Synchronized는 이러한 문제를 해결하는 좋은 방법이지만, 내부적으로 무거운 작업인 Lock을 사용하기에 Thread Contention으로 인한 성능 저하가 존재한다는 문제가 있고 잘못 사용했을 때 Race Condition이나 Dead Lock이 발생할 수 있다.
따라서 이러한 부분을 적절하게 고려하여 개발하는 것이 중요할 것이다.
'CS 및 기본 개념' 카테고리의 다른 글
| [ Java 공식문서 ] Liveness와 Guarded Block (6) | 2025.08.11 |
|---|---|
| [cs] AWS lambda 기반의 Webhook (1) | 2025.08.07 |
| [ Java 공식문서 ] Java 동시성 프로그래밍 1 : Thread (3) | 2025.08.04 |
| [cs] HTTP 버전별 특징과 차이점 (4) | 2025.07.31 |
| [ NGINX ] NGINX를 리버스 프록시로 사용하는 WebSocket 시스템 구축 (6) | 2025.07.30 |