1️⃣ 들어가며
멀티스레드 환경에서 데이터를 안전하게 공유하기 위해 어떤 Map을 사용해야 할까?
이전에 작성한 글 🔗ConcurrentHashMap 정리을 먼저 읽어보면,ConcurrentHashMap
이 어떤 자료구조이고 왜 필요한지를 이론적으로 이해하는 데 도움이 된다. 이번 글에서는 그 내용을 실습을 통해 직접 확인해보려 한다.
같은 코드, 같은 로직에서 Map만 HashMap으로 바꿨을 때 무슨 일이 벌어지는지 테스트를 통해 명확하게 보여줄 것이다.
🧩 실습 목표
ConcurrentHashMap
을 사용한 경우와HashMap
을 사용한 경우를 비교하여- 멀티스레드 환경에서 어떤 문제가 발생하는지를 실질적으로 검증한다
단순히 "thread-safe하지 않다"는 말로는 와닿지 않는 부분을, 100개의 쓰레드가 동시에 회원 가입 요청을 보낼 때 어떤 결과가 나오는지를 직접 확인하면서 체감할 수 있도록 구성했다.
2️⃣ 기본 구조와 코드 소개
이번 실습에서는 간단한 회원 가입 기능을 구현한 뒤,
멀티스레드 환경에서 데이터를 저장할 때 ConcurrentHashMap
과 HashMap
의 차이를 비교한다.
📌 1. MemberController
HTTP 요청을 받아 회원 가입 로직을 호출하는 역할을 한다.
@RestController
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@PostMapping
public Member create(@RequestBody MemberRequest request) {
return memberService.createMember(request);
}
}
📌 2. MemberRequest
클라이언트로부터 전달받은 데이터를 Member 엔티티로 변환하기 위한 DTO이다.
public record MemberRequest(String username, String password) {
public Member toEntity(Long id) {
return Member.builder()
.id(id)
.username(username)
.password(password)
.build();
}
}
📌 3. Member
회원 정보를 담는 간단한 도메인 객체로, Builder 패턴을 이용해 생성한다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Member {
private Long id;
private String username;
private String password;
}
📌 4. MemberRepository
실제 데이터를 저장하는 역할을 하며, 이번 실습의 핵심 대상이다. 기본 구현에서는 ConcurrentHashMap
을 사용하여 thread-safe한 저장소를 구성하고 있다.
@Repository
public class MemberRepository {
private final Map<Long, Member> store = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong(System.currentTimeMillis());
public Member save(Member member) {
store.put(member.getId(), member);
return member;
}
public Long nextId(){
return sequence.getAndIncrement();
}
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
}
store
: 회원 데이터를 저장하는 Map. 실험에서는 이 타입을HashMap
으로 변경하여 결과를 비교한다.sequence
: thread-safe한 ID 생성을 위해AtomicLong
을 사용한다.
📌 5. MemberService
비즈니스 로직을 담당하며, 클라이언트 요청을 받아 실제 회원 객체를 생성하고 저장하는 역할을 한다.
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
public Member createMember(MemberRequest memberRequest) {
Long id = memberRepository.nextId();
Member member = memberRequest.toEntity(id);
return memberRepository.save(member);
}
}
🔒 ConcurrentHashMap이 사용된 이유
이번 실습에서는 다수의 스레드가 동시에 회원 가입 요청을 보내는 상황을 가정한다. 이때 하나의 Map 객체에 여러 스레드가 동시에 접근해 데이터를 삽입하게 되므로, 동기화(synchronization)가 보장되지 않으면 예상치 못한 충돌과 데이터 손상이 발생할 수 있다.
Java의 HashMap
은 기본적으로 thread-safe하지 않기 때문에, 멀티스레드 환경에서 사용하는 것은 매우 위험하다. 실제로 동시 쓰기 작업이 발생하면 내부 배열 구조가 깨져 ArrayIndexOutOfBoundsException
과 같은 예외가 발생할 수 있다. 이를 방지하기 위해 ConcurrentHashMap
을 사용한다.
- 읽기(read)는 락 없이 처리 (성능 손실 없음)
- 쓰기(write)는 해당 버킷에만 국한된 범위에서 락을 적용
- 내부적으로
CAS
와synchronized
를 조합하여 높은 동시성과 안정성을 제공
이러한 특성 덕분에 ConcurrentHashMap
은 동시성 문제를 최소화하면서도 성능을 유지할 수 있는 구조를 제공한다.
🔢 AtomicLong을 이용한 ID 생성 방식
회원 가입 시 고유한 ID를 부여해야 하며, 이 ID는 중복되지 않아야 한다. 이를 위해 AtomicLong
을 사용하면, ID 생성 자체는 thread-safe하게 처리된다.
private final AtomicLong sequence = new AtomicLong(System.currentTimeMillis());
AtomicLong.getAndIncrement()
는 락 없이도 원자적으로 값을 증가시킨다.- 내부적으로 CAS(Compare-And-Swap) 연산을 사용해 ID 중복은 방지할 수 있다.
- 시작값을
System.currentTimeMillis()
로 설정함으로써, 테스트 간 충돌 가능성도 낮춘다.
하지만 주의할 점은, ID가 안전하게 생성된다고 해서 전체 로직이 thread-safe해지는 것은 아니라는 점이다.
- ID는 안전하게 생성되더라도
- 그 ID로
HashMap.put(id, member)
처럼 여러 스레드가 동시에 Map에 접근하면 Map 내부 구조가 깨질 수 있다.
즉, AtomicLong
은 ID 생성의 안정성만 보장할 뿐이며, Map 자체의 동시성은 전혀 보호하지 못한다.
따라서 실제 데이터를 저장하는 store
에 대해서는ConcurrentHashMap
과 같은 thread-safe한 자료구조를 사용해야 한다.
3️⃣ 테스트 코드 작성 및 검증
ConcurrentHashMap
이 실제로 얼마나 효과적으로 동시성을 보장하는지 검증하기 위해, 테스트 코드에서는 100개의 쓰레드가 동시에 회원 가입을 시도하는 상황을 구성했다.
🔍 테스트 핵심 검증 포인트
- 정확히 100개의 회원이 저장되었는가?
- 각 회원의 ID는 중복되지 않고 고유한가?
- 동시성 문제로 인해 저장 실패나 예외가 발생하지 않았는가?
🧪 테스트 코드
@Test
void 동시에_회원가입_요청_동작_확인() throws InterruptedException {
int threadCount = 100;
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch startSignal = new CountDownLatch(1);
CountDownLatch doneSignal = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
int index = i;
executorService.submit(() -> {
try {
startSignal.await();
String username = "user" + index;
String password = "pass" + index;
MemberRequest request = new MemberRequest(username, password);
memberService.createMember(request);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
doneSignal.countDown();
}
});
}
startSignal.countDown();
doneSignal.await();
List<Member> members = memberRepository.findAll();
System.out.println("저장된 회원 수 : " + members.size());
assertThat(members).hasSize(threadCount);
assertThat(members.stream()
.map(Member::getId)
.distinct()
.count())
.isEqualTo(threadCount);
}
⚙️ 사용된 주요 기술
ExecutorService
: 쓰레드 풀을 통해 100개의 작업을 병렬로 처리한다.CountDownLatch
: 모든 쓰레드가 동시에 시작되도록(startSignal) 제어하고, 모든 작업이 끝날 때까지(doneSignal) 대기하도록 한다.assertThat()
: 저장된 회원 수가 정확히 100개인지, 각 회원의 ID가 중복 없이 모두 생성되었는지를 검증한다.
✅ ConcurrentHashMap 사용 시 테스트 결과
앞서 작성한 멀티스레드 테스트 코드를 실행한 결과,ConcurrentHashMap
을 사용하는 경우 100명의 회원이 모두 정상적으로 저장되었고, ID 중복이나 저장 누락 없이 테스트가 성공적으로 통과되었다.

- 저장된 회원 수: 100명
- 중복 ID 없음
assertThat()
검증 통과
✅ 이 결과가 의미하는 것
ConcurrentHashMap
은 내부적으로 부분 락과 CAS 연산을 활용해, 여러 스레드가 동시에 데이터를 put() 하더라도 안전하게 처리할 수 있다.AtomicLong
을 통해 ID 충돌 없이 안전하게 ID가 생성되었고, 각 회원은 고유한 키로 저장되었다.
멀티스레드 환경에서도 데이터가 유실되지 않고 일관되게 처리된다는 점에서,ConcurrentHashMap
이 얼마나 thread-safe 구조인지를 테스트코드를 통해 확인할 수 있었다.
4️⃣ HashMap으로 변경 후 다시 테스트
이번에는 ConcurrentHashMap
을 사용하던 MemberRepository
의 내부 Map을 아래와 같이 HashMap
으로 변경한 뒤, 동일한 멀티스레드 테스트를 실행해보았다.
// 변경 전
private final Map<Long, Member> store = new ConcurrentHashMap<>();
// 변경 후
private final Map<Long, Member> store = new HashMap<>();
❌ 테스트 실행 결과

테스트 실행 중 아래와 같은 런타임 예외가 발생했다.
java.lang.ArrayIndexOutOfBoundsException: Index 98 out of bounds for length 98
at java.base/java.util.HashMap.valuesToArray(HashMap.java:973)
at java.base/java.util.HashMap$Values.toArray(HashMap.java:1050)
at java.base/java.util.ArrayList.<init>(ArrayList.java:181)
at com.example.demo.repository.MemberRepository.findAll(MemberRepository.java:30)
at com.example.demo.service.MemberServiceTest.동시에_회원가입_요청_동작_확인(MemberServiceTest.java:55)
해당 에러는 MemberRepository.findAll() 내부의 아래 코드에서 발생했다.
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
🧨 왜 이런 에러가 발생했을까?
HashMap
은 동기화가 전혀 적용되지 않은 자료구조이기 때문에, 여러 스레드가 동시에 put()
연산을 수행하게 되면 내부 구조가 불안정한 상태로 변형될 수 있다.
🔎 구체적으로 무슨 일이 벌어졌는가?
HashMap
내부는 다음과 같은 구조로 되어 있다:
- 데이터를 저장하는 배열(table)
- 충돌 시 데이터를 이어붙이기 위한 LinkedList 또는 TreeNode 구조
put()
연산이 수행되면 다음과 같은 작업이 내부적으로 일어난다:
- key의 hash값으로 table의 index를 계산
- 해당 위치에 노드가 없다면 추가
- 노드가 있다면 체이닝 구조로 이어붙임
size++
증가 및 필요한 경우 resize (배열 재할당)
이 과정에서 멀티스레드가 동시에 put()을 수행하면 다음 문제가 발생할 수 있다:
- resize 중 중간 상태의 배열에 접근하는 쓰레드가 존재
- 두 쓰레드가 같은 시점에 size를 변경하거나 table의 포인터를 변경
- 구조가 깨진 채로 값이 삽입되거나, 배열 크기와 실제 요소 수가 불일치한 상태 발생
이런 상태에서 아래 코드가 된다:
new ArrayList<>(store.values());
store.values()
는HashMap.valuesToArray()
를 호출하며 내부 배열을 순회한다.- 이 시점에 Map의 modCount(수정 횟수)와 size 또는 배열 상태가 일치하지 않으면,
복사 도중 배열 경계를 벗어난 인덱스에 접근하게 된다. - 결국 아래와 같은 예외가 발생한다:
java.lang.ArrayIndexOutOfBoundsException: Index 98 out of bounds for length 98
이는 "98개의 값이 있어야 한다고 판단했는데, 복사 중 98번째 인덱스를 읽으려 했더니, 그 시점의 배열 길이가 98이 아니거나 누락된 상태였기 때문에 발생한 것이다."
❗ 이건 단순한 오류가 아니다
이 에러는 단순히 데이터가 누락되었다는 수준의 문제가 아니다. HashMap 내부 배열(table)의 구조 자체가 손상되었음을 의미한다. 이는 다음과 같은 결과를 유발할 수 있다:
- ❌ 예외 발생 (
ArrayIndexOutOfBoundsException
) - 🔁 무한 루프 (JDK 6 이하에서 해시 충돌 시 재귀 링크가 꼬이는 현상)
- 🧹 데이터 손실
- 💥 JVM 자체의 불안정성
📌 요약
HashMap
은 멀티스레드 환경에서 내부적으로 thread-safe하지 않다.- 동시에 수정이 발생하면 내부 배열 상태가 깨져, 순회나 복사 중 예외가 발생할 수 있다.
- 이런 이유로, 멀티스레드 환경에서
HashMap
을 공유해서 사용하는 것은 금지다.
5️⃣ 마무리 및 정리
이번 실습에서는 동일한 코드 구조에서 ConcurrentHashMap
과 HashMap
을 각각 사용해보고, 멀티스레드 환경에서 어떤 문제가 발생할 수 있는지 직접 확인해보았다.
✅ 실험을 통해 얻은 교훈
ConcurrentHashMap
은 내부적으로 부분 락과 CAS 연산을 활용해
수많은 쓰레드가 동시에 데이터를 삽입해도 안정적으로 동작한다.- 반면
HashMap
은 멀티스레드 환경에서 사용하면
단순한 데이터 누락을 넘어서 내부 구조가 깨지고 런타임 예외가 발생할 수 있다.
💡 실험을 통해서 기억해야 할 팁
- 공유되는
Map
을 멀티스레드 환경에서 사용할 경우에는 반드시ConcurrentHashMap
과 같은 thread-safe한 자료구조를 선택해야 한다. AtomicLong
은 ID 생성에 있어 thread-safe를 보장하지만, Map 자체의 thread-safety까지 보장해주지는 않는다.- 동시성을 테스트할 때는
ExecutorService
와CountDownLatch
를 활용해 실제 서비스 환경과 유사한 조건을 만들어 테스트하는 것이 효과적이다.
📌 결론
단순히 "ConcurrentHashMap이 더 안전하다"는 말을 넘어서, "HashMap은 멀티스레드 환경에서 공유해서 사용해서는 안 된다"는 교훈을 실전 코드로 확인할 수 있었다. thread-safe한 자료구조의 선택은 성능보다 더 중요한 시스템의 안정성과 신뢰성에 직결되는 문제다.
'Java & Spring' 카테고리의 다른 글
[Java & Spring] ReentrantLock, 쿠폰 발급으로 실습하고 테스트 코드로 증명해보기 (0) | 2025.07.02 |
---|---|
[Java] ReentrantLock 정리 (feat. synchronized) (0) | 2025.06.27 |
[Java] Map의 computeIfAbsent()과 computeIfPresent() 메서드 (0) | 2025.06.22 |
[Java] Java의 ConcurrentHashMap 정리 (1) | 2025.06.12 |