[Java & Spring] HashMap vs ConcurrentHashMap, 실습으로 알아보는 차이

2025. 6. 13. 22:29·Java & Spring
목차
  1. 1️⃣ 들어가며
  2. 🧩 실습 목표
  3. 2️⃣ 기본 구조와 코드 소개
  4. 📌 1. MemberController
  5. 📌 2. MemberRequest
  6. 📌 3. Member
  7. 📌 4. MemberRepository
  8. 📌 5. MemberService
  9. 🔒 ConcurrentHashMap이 사용된 이유
  10. 🔢 AtomicLong을 이용한 ID 생성 방식
  11. 3️⃣ 테스트 코드 작성 및 검증
  12. 🔍 테스트 핵심 검증 포인트
  13. 🧪 테스트 코드
  14. ⚙️ 사용된 주요 기술
  15. ✅ ConcurrentHashMap 사용 시 테스트 결과
  16. ✅ 이 결과가 의미하는 것
  17. 4️⃣ HashMap으로 변경 후 다시 테스트
  18. ❌ 테스트 실행 결과
  19. 🧨 왜 이런 에러가 발생했을까?
  20. ❗ 이건 단순한 오류가 아니다
  21. 📌 요약
  22. 5️⃣  마무리 및 정리
  23. ✅ 실험을 통해 얻은 교훈
  24. 💡 실험을 통해서 기억해야 할 팁
  25. 📌 결론
반응형

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 중복이나 저장 누락 없이 테스트가 성공적으로 통과되었다.

ConcurrentHashMap Test Result

  • 저장된 회원 수: 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<>();

❌ 테스트 실행 결과

HashMap Test Result

테스트 실행 중 아래와 같은 런타임 예외가 발생했다.

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() 연산이 수행되면 다음과 같은 작업이 내부적으로 일어난다:

  1. key의 hash값으로 table의 index를 계산
  2. 해당 위치에 노드가 없다면 추가
  3. 노드가 있다면 체이닝 구조로 이어붙임
  4. 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
  1. 1️⃣ 들어가며
  2. 🧩 실습 목표
  3. 2️⃣ 기본 구조와 코드 소개
  4. 📌 1. MemberController
  5. 📌 2. MemberRequest
  6. 📌 3. Member
  7. 📌 4. MemberRepository
  8. 📌 5. MemberService
  9. 🔒 ConcurrentHashMap이 사용된 이유
  10. 🔢 AtomicLong을 이용한 ID 생성 방식
  11. 3️⃣ 테스트 코드 작성 및 검증
  12. 🔍 테스트 핵심 검증 포인트
  13. 🧪 테스트 코드
  14. ⚙️ 사용된 주요 기술
  15. ✅ ConcurrentHashMap 사용 시 테스트 결과
  16. ✅ 이 결과가 의미하는 것
  17. 4️⃣ HashMap으로 변경 후 다시 테스트
  18. ❌ 테스트 실행 결과
  19. 🧨 왜 이런 에러가 발생했을까?
  20. ❗ 이건 단순한 오류가 아니다
  21. 📌 요약
  22. 5️⃣  마무리 및 정리
  23. ✅ 실험을 통해 얻은 교훈
  24. 💡 실험을 통해서 기억해야 할 팁
  25. 📌 결론
'Java & Spring' 카테고리의 다른 글
  • [Java & Spring] ReentrantLock, 쿠폰 발급으로 실습하고 테스트 코드로 증명해보기
  • [Java] ReentrantLock 정리 (feat. synchronized)
  • [Java] Map의 computeIfAbsent()과 computeIfPresent() 메서드
  • [Java] Java의 ConcurrentHashMap 정리
Penguin Dev
Penguin Dev
What does the Penguin say?
    글쓰기 관리
  • Penguin Dev
    Pengha!
    Penguin Dev
  • 전체
    오늘
    어제
    • 분류 전체보기 (152)
      • Java & Spring (5)
      • System Hacking (4)
      • Algorithm (8)
        • Sorting algorithm (3)
      • Python (6)
      • Web (2)
        • Web Hacking & Security (2)
      • Write-Up (108)
        • pwnable.kr (17)
        • HackCTF (16)
        • 해커스쿨 FTZ (21)
        • LOB(lord of bufferoverflow) (19)
        • LOS (lord of sql injection) (28)
        • XSS-game (6)
        • Webhacking.kr (1)
      • SUA (19)
        • 오픈소스 보안 (19)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    spring boot
    computeifabsent()
    동시성
    hashmap vs concurrenthashmap
    tabat
    thread-safe
    concurrenthashmap vs hashmap
    LOB
    computeifpresent()
    lord of bufferoverflow
    CountDownLatch
    쿠폰발급
    putval()
    Lock
    Java
    computeifabsent
    spring
    reentrantlock실습
    동시성처리
    DB정리
    nop sled
    AQS
    ConcurrentHashMap
    computeifpresent
    sqlinjection
    enumerate #list comprehension
    코드트리조별과제
    SpringBoot
    코드트리
    ReentrantLock
  • 최근 댓글

  • 반응형
  • hELLO· Designed By정상우.v4.10.3
Penguin Dev
[Java & Spring] HashMap vs ConcurrentHashMap, 실습으로 알아보는 차이
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.