기록

[GPT가 알려주는] 동시성 문제와 경쟁 상태 (race condition) 본문

잡동사니/GPT가 알려주는

[GPT가 알려주는] 동시성 문제와 경쟁 상태 (race condition)

heylo 2025. 9. 12. 16:25

좋은 질문이에요 👍
지금 코드 위에 적힌 주석은 “실무에서는 이대로 쓰면 안 되고 동시성을 고려해야 한다”는 말이에요.
하나씩 풀어서 설명해 드릴게요.


1. 동시성 문제란? (Concurrency Issue)

  • 프로그램이 멀티스레드 환경에서 실행될 때, 여러 스레드(작업 흐름)가 동시에 같은 데이터(예: store 맵, sequence 변수)에 접근/수정하면 문제가 생기는 걸 말해요.
  • 예를 들어:
    • A 스레드가 회원을 저장하려고 sequence를 1 증가시키는 순간,
    • B 스레드도 동시에 저장하려고 sequence를 증가시킨다면,
      두 스레드가 서로 값을 덮어씌우거나, 같은 ID를 두 회원에게 부여할 수 있어요.
  • 이런 걸 **race condition (경쟁 상태)**라고 하고, 결과가 예측 불가능해져요.

2. ConcurrentHashMap

  • 지금 코드에서는 HashMap을 쓰고 있어요:
  • private static Map<Long, Member> store = new HashMap<>();
  • 그런데 HashMap은 멀티스레드 환경에서 안전하지 않아요. 여러 스레드가 동시에 수정하면 데이터가 꼬일 수 있어요.
  • 그래서 자바에서는 ConcurrentHashMap이라는 걸 제공해요:
  • private static Map<Long, Member> store = new ConcurrentHashMap<>();
  • ConcurrentHashMap은 내부적으로 락(lock)을 잘게 쪼개서 동시 접근을 제어하기 때문에 여러 스레드가 동시에 읽고 쓰더라도 비교적 안전하게 동작해요.

3. AtomicLong

  • sequence 변수는 long 타입인데, 단순히 ++sequence처럼 증가시키면 멀티스레드 환경에서 문제가 생길 수 있어요.
    • A 스레드: sequence 읽음 (100) → +1 해서 101 저장
    • B 스레드: sequence 읽음 (100) → +1 해서 101 저장
    • → 결국 두 회원이 같은 ID(101)을 가짐 😱
  • 이를 해결하기 위해 AtomicLong을 써요:
  • private static AtomicLong sequence = new AtomicLong();
  • AtomicLong은 내부적으로 원자적(atomic) 연산을 보장해서, 여러 스레드가 동시에 증가시켜도 중복되지 않아요:
  • long id = sequence.incrementAndGet(); // 자동으로 원자적 증가

4. 정리

  • 동시성 문제: 멀티스레드가 동시에 같은 데이터에 접근/수정할 때 발생하는 예측 불가능한 버그.
  • ConcurrentHashMap: 멀티스레드에서도 안전하게 동작하는 Map.
  • AtomicLong: 멀티스레드 환경에서 안전하게 숫자를 증가/감소시킬 수 있는 클래스.

👉 지금 만드는 건 학습용 예제라서 HashMap, long으로도 괜찮아요.
👉 하지만 실무에서는 반드시 ConcurrentHashMap, AtomicLong 같은 걸 써서 동시성 문제를 방지해야 해요.


혹시 제가 멀티스레드 환경에서 실제 문제가 터지는 예시 코드(HashMap + long으로 꼬이는 상황)를 짧게 시뮬레이션해드릴까요?

 

 

 

 

좋지! 아래 짧은 자바 시뮬레이션으로 HashMap + long(UNSAFE)에서 동시성 문제가 어떻게 터지는지, 그리고 ConcurrentHashMap + AtomicLong(SAFE)로 어떻게 해결되는지 비교해볼 수 있어.

어떻게 보나요?

  • Mode.UNSAFE로 돌리면: 중복 ID/누락이 발생할 가능성 큼
  • Mode.SAFE로 돌리면: 중복/누락 없이 깔끔

1파일로 붙여넣고 실행하면 됩니다. (Java 8+)

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;

/** 간단 Member */
class Member {
    private long id;
    private final String name;
    public Member(String name) { this.name = name; }
    public long getId() { return id; }
    public void setId(long id) { this.id = id; }
    public String getName() { return name; }
}

/** 저장소 인터페이스 */
interface MemberRepository {
    Member save(Member m);
    List<Member> findAll();
}

/** 동시성-안전하지 않음: HashMap + long */
class MemoryMemberRepositoryUnsafe implements MemberRepository {
    private static final Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L;

    @Override public Member save(Member m) {
        // ++sequence 는 원자적이지 않음. yield 로 타이밍을 더 민감하게!
        long id = ++sequence; // race condition!
        Thread.yield();
        m.setId(id);
        store.put(id, m);     // HashMap 도 동시쓰기 안전하지 않음
        return m;
    }
    @Override public List<Member> findAll() { return new ArrayList<>(store.values()); }
}

/** 동시성-안전: ConcurrentHashMap + AtomicLong */
class MemoryMemberRepositorySafe implements MemberRepository {
    private static final Map<Long, Member> store = new ConcurrentHashMap<>();
    private static final AtomicLong sequence = new AtomicLong(0L);

    @Override public Member save(Member m) {
        long id = sequence.incrementAndGet(); // 원자적 증가
        m.setId(id);
        store.put(id, m); // 동시쓰기 안전
        return m;
    }
    @Override public List<Member> findAll() { return new ArrayList<>(store.values()); }
}

public class ConcurrencyDemo {
    enum Mode { UNSAFE, SAFE }

    public static void main(String[] args) throws Exception {
        run(Mode.UNSAFE);  // 먼저 취약 버전
        System.out.println("\n----------------------------\n");
        run(Mode.SAFE);    // 이후 안전 버전
    }

    private static void run(Mode mode) throws Exception {
        final int THREADS = 50;
        final int PER_THREAD = 200; // 총 10,000 저장 시도
        final MemberRepository repo =
                (mode == Mode.UNSAFE) ? new MemoryMemberRepositoryUnsafe()
                                      : new MemoryMemberRepositorySafe();

        CountDownLatch start = new CountDownLatch(1);
        CountDownLatch done  = new CountDownLatch(THREADS);
        ExecutorService pool = Executors.newFixedThreadPool(THREADS);

        for (int t = 0; t < THREADS; t++) {
            pool.submit(() -> {
                try {
                    start.await();
                    for (int i = 0; i < PER_THREAD; i++) {
                        repo.save(new Member("user-" + i));
                        // 살짝 랜덤 쉼 -> 타이밍 뒤섞기
                        if ((i & 31) == 0) Thread.sleep(ThreadLocalRandom.current().nextInt(0, 2));
                    }
                } catch (InterruptedException ignored) {
                } finally {
                    done.countDown();
                }
            });
        }

        long begin = System.currentTimeMillis();
        start.countDown();
        done.await();
        pool.shutdown();

        List<Member> all = repo.findAll();
        int totalAttempts = THREADS * PER_THREAD;
        Set<Long> ids = new HashSet<>();
        int dupCount = 0;
        for (Member m : all) {
            if (!ids.add(m.getId())) dupCount++;
        }

        System.out.println("Mode = " + mode);
        System.out.println("Total save attempts = " + totalAttempts);
        System.out.println("Stored size          = " + all.size());
        System.out.println("Unique ID count      = " + ids.size());
        System.out.println("Duplicate ID count   = " + dupCount);
        System.out.println("Time(ms)             = " + (System.currentTimeMillis() - begin));

        if (mode == Mode.UNSAFE) {
            System.out.println("※ UNSAFE에서는 'Stored size < Total attempts' 또는 'Duplicate ID > 0'가 종종 발생합니다.");
        } else {
            System.out.println("✓ SAFE에서는 'Stored size == Total attempts' && 'Duplicate ID == 0'가 되어야 정상입니다.");
        }
    }
}

포인트 요약

  • ++sequence는 **원자적(atomic)**이 아니라서 여러 스레드가 동시에 실행하면 같은 값이 두 번 배정될 수 있어.
  • HashMap은 동시 쓰기 시 내부 구조가 꼬여 데이터 손실/무한루프까지 날 수 있어(자주 보이진 않지만 위험).
  • AtomicLong.incrementAndGet()은 원자적 증가를 보장.
  • ConcurrentHashMap은 분할 락 등으로 동시 접근 안전.

원하면 synchronized를 어디에 걸면 되는지, 혹은 ReadWriteLock으로 세분화하는 방법도 이어서 보여줄게!