스레드 안전(Thread Safety)이란 무엇인가요?

김상욱·2024년 11월 14일

스레드 안전(Thread Safety)이란 무엇인가요?

Thread Safety란 다중 스레드 환경에서 프로그램이 실행될 때, 공유 데이터에 대한 동시 접근으로 인해 발생할 수 있는 문제를 방지하기 위해 올바르게 동작하는 코드의 특성을 의미합니다.

발생가능한 문제들
1. 데이터 불일치(Data Inconsistency)

  • 여러 스레드가 공유 자원에 동시에 접근하여 수정할 때 발생.
    ex) 은행 계좌에서 100원을 두 번 출금하려는 두 스레드가 있을 때, 각각 100원씩 출금했음에도 각 접근 시점이 같아서 총 100원만 줄어들었을 때
    해결 방법 : 동기화를 사용하여 한 번에 하나의 스레드만 공유 자원에 접근하도록 제한
  1. 경합 상태(Race Condition)
  • 두 개 이상의 스레드가 동시에 같은 자원에 접근하려 할 대, 그 순서와 타이밍에 따라 실행 결과가 달라지는 문제.
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을 사용해야 합니다.

  1. Deadlock(데드락)
    데드락은 두 개 이상의 스레드가 서로 자원을 점유한 상태에서 상대방이 가진 자원을 기다리며 무한 대기에 빠지는 상황을 말합니다. 이로 인해 프로그램이 멈추거나 작동하지 않을 수 있습니다.
    ex) 스레드 A는 자원 1을 점유하고 자원 2를 기다립니다. + 스레드 B는 자원 2를 점유하고 자원 1을 기다립니다.
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 활용

원자적 연산과 관련된 주제는 백엔드 신입 개발자로서 실습을 통해 다중 스레드 환경에서 발생할 수 있는 문제를 체감하고, 이를 해결하는 방법을 익히는 데 매우 유익합니다. 실습을 통해 스레드 안전성과 성능의 균형을 이해할 수 있습니다. 다음은 추천하는 실습 주제와 세부 내용입니다.


실습 주제

1. Race Condition 재현 및 해결

목표

  • 다중 스레드 환경에서 Race Condition의 발생을 직접 경험하고 이를 원자적 연산으로 해결하는 실습.

실습 내용

  1. 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());
        }
    }
    • 기대값은 2000이지만, 실제로는 그보다 낮은 값이 출력됨.
  2. 해결하기

    • AtomicInteger, synchronized, 또는 ReentrantLock을 사용해 문제를 해결.
    • 각각의 방법에 따른 코드 작성과 결과 비교.

2. AtomicInteger 활용

목표

  • 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());
    }
}

3. 성능 비교: synchronized vs Atomic

목표

  • 동기화 방식(synchronized)과 원자적 연산(AtomicInteger)의 성능 차이를 비교.

실습 내용

  1. 테스트 환경 구축
    • 같은 연산을 동기화(synchronized)와 AtomicInteger로 각각 구현.
    • 각 방식으로 다중 스레드가 값을 증가시키는 작업을 수행하고 실행 시간을 측정.
  2. 결과 분석
    • 두 방식의 실행 시간과 CPU 사용률 비교.
    • 적절한 상황에 따른 선택 기준 이해.

4. 멀티스레드 환경에서 작업 큐 구현

목표

  • Atomic 클래스를 활용하여 스레드 안전한 작업 큐를 설계하고 구현.

실습 내용

  1. 작업 큐 설계
    • 큐에 작업을 추가하고 처리하는 메서드를 작성.
    • AtomicInteger를 사용해 큐의 크기를 스레드 안전하게 관리.
  2. 동시 작업 처리
    • 여러 스레드가 작업 큐에 동시에 작업을 추가하거나 처리할 때, 데이터 손상이 발생하지 않도록 구현.
    • 예: 작업 큐에 1000개의 작업을 추가하고 이를 4개의 스레드로 처리.

5. 복잡한 작업 흐름의 원자성 보장

목표

  • 원자적 연산만으로 해결할 수 없는 복잡한 작업 흐름을 구현하고, 이를 동기화로 처리.

실습 내용

  1. 복합 작업 설계
    • 은행 계좌 송금 기능 구현:
      • 한 계좌에서 금액을 출금하고 다른 계좌로 입금.
      • 두 작업이 모두 성공해야만 상태가 업데이트되도록 보장.
  2. 문제 발생 재현
    • 동기화 없이 송금을 구현하고 문제점을 재현.
  3. 해결하기
    • synchronized 또는 ReentrantLock을 사용하여 원자성을 보장.

실습 목표와 기대 효과

  1. 스레드 안전성 체감: 원자적 연산이 왜 필요한지와 동기화의 중요성을 실무적으로 경험.
  2. 성능과 안전성 균형 학습: synchronizedAtomicInteger의 트레이드오프 이해.
  3. 멀티스레드 환경 설계 역량 강화: 다중 스레드 환경에서 공유 자원을 다루는 백엔드 로직 설계 능력 향상.

추가 실습 자료

  • Java 공식 문서: java.util.concurrent.atomic
  • 멀티스레드 실습 강의: YouTube와 Udemy 같은 플랫폼에서 "Java Multithreading" 검색.
  • 백엔드 실무 적용: 스레드 안전성을 고려한 API 설계(예: 트랜잭션 처리).

이 실습을 통해 멀티스레드 환경에서 발생하는 문제를 이해하고, 원자적 연산 및 동기화를 활용하여 안전한 백엔드 로직을 설계할 수 있는 능력을 키울 수 있습니다.

0개의 댓글