Thread Safety란 다중 스레드 환경에서 프로그램이 실행될 때, 공유 데이터에 대한 동시 접근으로 인해 발생할 수 있는 문제를 방지하기 위해 올바르게 동작하는 코드의 특성을 의미합니다.
발생가능한 문제들
1. 데이터 불일치(Data Inconsistency)
int counter = 0;
public void increment() {
counter++; // counter 읽기 -> 증가 -> 쓰기 (비원자적 연산)
}
각 스레드가 increment를 호출하여 1씩 증가시키지만 실제로 1로 남게 될 수 있다. 이는 원자적 연산이 아니기 때문에 읽기, 증가, 쓰기라는 세 개가 동시에 수행될 수 없기 때문입니다.
해결 방법 : synchronized 키워드 또는 ReentrantLock을 사용해 경합을 방지 / 원자적 연산(AtomicInteger)을 사용하여 안전하게 값을 갱신
: 원자적 연산이란 더 이상 나눌 수 없는 하나의 단위로 실행되는 연산으로 중간 상태가 외부에 노출되지 않고, 다른 스레드가 개입할 수 없는 상태로 실행, 즉 한번에 완료되며 끼어들기(Interruption)이 불가능 -> 연산이 시작하면 끝날 때까지 다른 작업이 개입할 수 없으며(Indivisible) 중간 상태를 노출할 수 없다(No Intermediate State) 또한 동기화가 필요가 없다.(자체적으로 Thread Safety를 보장)
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicExample {
private AtomicInteger counter = new AtomicInteger(0);
public void increment() {
counter.incrementAndGet(); // 원자적 연산으로 값 증가
}
public int getCounter() {
return counter.get(); // 현재 값을 안전하게 읽음
}
}
public class SynchronizedExample {
private int counter = 0;
public synchronized void increment() {
counter++; // 이 메서드는 원자적으로 실행됨
}
public synchronized int getCounter() {
return counter;
}
}
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
private int counter = 0;
private Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
counter++; // 락으로 보호된 코드 블록
} finally {
lock.unlock();
}
}
public int getCounter() {
return counter;
}
}
대부분의 CPU는 특정 연산을 원자적으로 수행하도록 설계되어 있으며 Java의 AtomicInteger 클래스는 내부적으로 compare-and-swap(CAS) 명령어를 사용하여 원자적 동작을 보장합니다.
한계점 : 원자적 연산은 개별 연산에 대해서는 안전하지만, 여러 연산을 조합한 복잡한 작업에서는 추가적인 동기화가 필요합니다. 예를 들어, 두 개 이상의 값을 동시에 업데이트하거나 특정 조건을 검사하며 값을 변경하는 경우에는 synchronized나 Lock을 사용해야 합니다.
class Resource {
void methodA(Resource r) {
synchronized (this) {
System.out.println("Method A locked this resource.");
synchronized (r) {
System.out.println("Method A locked r.");
}
}
}
void methodB(Resource r) {
synchronized (r) {
System.out.println("Method B locked r.");
synchronized (this) {
System.out.println("Method B locked this resource.");
}
}
}
}
해결 방법 :
1. 락 순서 동일 : 모든 스레드가 자원을 고정된 순서로 락을 획득하도록 강제합니다.
2. 타임아웃 설정 : 락을 획득하지 못하면 일정 시간 후 포기하게 합니다.
3. 데드락 감지 알고리즘 : 데드락 발생 여부를 탐지하여 해곃합니다.
4. 락의 최소화 : 가능한 한 락을 적게 사용하고, 락 범위를 줄이는 설계로 데드락 가능성을 줄입니다.
위의 나온 스레드 안전을 보장하는 방법
1. Synchronized(동기화) 사용
2. ReentrantLock
3. Thread-safe Collection 사용 : 스레드 안전한 자료구조 사용 ex - ConcurrentHashMap, CopyOnWriteArrayList
4. Immutable 객체 사용 : 상태를 변경하지 않는 불변 객체를 사용하여 데이터 손상 방지
5. Local 변수 사용 : 공유 자원 대신 스레드 내부에서만 사용하는 지역 변수 활용
6. Atomic Operation 활용
원자적 연산과 관련된 주제는 백엔드 신입 개발자로서 실습을 통해 다중 스레드 환경에서 발생할 수 있는 문제를 체감하고, 이를 해결하는 방법을 익히는 데 매우 유익합니다. 실습을 통해 스레드 안전성과 성능의 균형을 이해할 수 있습니다. 다음은 추천하는 실습 주제와 세부 내용입니다.
Race Condition의 발생을 직접 경험하고 이를 원자적 연산으로 해결하는 실습.Race Condition 구현
counter)을 증가시키는 코드를 작성.counter++를 사용하여 비원자적 연산의 문제를 재현.public class RaceConditionExample {
private int counter = 0;
public void increment() {
counter++; // Race Condition 발생 가능
}
public int getCounter() {
return counter;
}
public static void main(String[] args) throws InterruptedException {
RaceConditionExample example = new RaceConditionExample();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final counter value: " + example.getCounter());
}
}
해결하기
AtomicInteger, synchronized, 또는 ReentrantLock을 사용해 문제를 해결.AtomicInteger를 사용하여 Race Condition을 간단하게 해결하는 방법을 실습.AtomicInteger로 값을 증가시키는 방식 구현.import java.util.concurrent.atomic.AtomicInteger;
public class AtomicExample {
private AtomicInteger counter = new AtomicInteger(0);
public void increment() {
counter.incrementAndGet(); // 원자적 연산
}
public int getCounter() {
return counter.get();
}
public static void main(String[] args) throws InterruptedException {
AtomicExample example = new AtomicExample();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final counter value: " + example.getCounter());
}
}
synchronized)과 원자적 연산(AtomicInteger)의 성능 차이를 비교.synchronized)와 AtomicInteger로 각각 구현.Atomic 클래스를 활용하여 스레드 안전한 작업 큐를 설계하고 구현.AtomicInteger를 사용해 큐의 크기를 스레드 안전하게 관리.synchronized 또는 ReentrantLock을 사용하여 원자성을 보장.synchronized와 AtomicInteger의 트레이드오프 이해.이 실습을 통해 멀티스레드 환경에서 발생하는 문제를 이해하고, 원자적 연산 및 동기화를 활용하여 안전한 백엔드 로직을 설계할 수 있는 능력을 키울 수 있습니다.