기록
[GPT가 알려주는] 동시성 문제와 경쟁 상태 (race condition) 본문
좋은 질문이에요 👍
지금 코드 위에 적힌 주석은 “실무에서는 이대로 쓰면 안 되고 동시성을 고려해야 한다”는 말이에요.
하나씩 풀어서 설명해 드릴게요.
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으로 세분화하는 방법도 이어서 보여줄게!
'잡동사니 > GPT가 알려주는' 카테고리의 다른 글
| [GPT가 알려주는] UDS 통신 예제: DTC 읽기 (0) | 2025.09.16 |
|---|---|
| [GPT가 알려주는] CAN 통신 (0) | 2025.09.15 |
| [GPT가 알려주는] 오디오 코덱이란? (0) | 2025.09.12 |
| [GPT가 알려주는 Springboot] gradlew.bat 실행해서 실행파일 만드는 법 (0) | 2025.09.12 |
| [GPT가 알려주는] Watchdog (1) | 2025.09.12 |