멀티스레드 환경에서 index++ 는 왜 위험한가?

2025. 10. 26. 14:41·Trouble Shooting/JAVA

 

Spring으로 커뮤니티 백엔드 코드를 작성하던 중, DB를 사용하지 못하는 환경이었기 때문에 LinkedHashMap을 통해 key : index, value : post(게시글)엔티티 형태로 인메모리 데이터 저장소를 구성하였다.
이때, 게시글의 식별자를 직접 관리하기 위해 index++연산을 사용하여 새로운 게시글이 생성될 때마다 ID를 1씩 증가시키는 방식으로 구현하였다.

그러나 이 방식은 단일 스레드에서는 정상적으로 동작하지만, 여러 사용자가 동시에 게시글을 등록하는 멀티스레드 환경에서는 동일한 index값이 중복으로 부여되어 ID 충돌(중복)이 발생할 수 있다.

public class InMemoryPostRepository implements PostRepository {
    private long index; 
    //Map<Key : index, Value : PostEntity>
    private Map<Long, PostEntity> Posts = new LinkedHashMap<>();
    
    ...

}

 


동시성(Concurrency)과 원자성(Atomicity)

💡 멀티스레드 환경이란?

하나의 프로세스 내에서 두개 이상의 스레드(실행 흐름)가 동시에 실행되는 환경



💡 동시성(Concurrency)이란?
여러 작업이 동시에 수행되는 것처럼 보이지만,
실제로는 CPU가 작업을 번갈아가며 수행한다.

+ 병렬성 vs 동시성
여러 코어에서 동시에 작업을 수행되는 상태는 병렬성(Parallelism)이라고 하며
한정된 자원을 여러 스레드가 번갈아가며 처리하는 방식을 동시성(Concurrency)라고 한다.

 

그렇다면, 여러 스레드가 번갈아가며 수행(동시성)하는 과정에서, 동시에 같은 자원에 접근하게 된다면 어떤 일이 발생할까?

즉, 서로 다른 스레드가 동일한 공유 자원(Shared Resource)에 접근하여 데이터를 읽거나 쓰는데 충돌이 발생할 수 있는 문제를 동시성 문제(Concurrency Issue) 라고 한다. 

 


하나의 연산 과정

먼저, 프로세스가 수행하는 하나의 연산 과정은 예시를 들어 아래와 같이 진행된다.

long count = 0;
count++;

 

count++의 내부 동작

1. count 값을 읽는다. [0] -> 읽기(Read)

2. count + 1 연산을 수행한다. [1] -> 연산(Compute)

3. count 값을 저장한다. [1] -> 쓰기(Write)

 

총 3단계를 이룰 때, 이 전체 과정은 원자성(Atomic)을 가지고 있어야 한다.

💡 원자성이란?
하나의 작업은 더이상 쪼갤 수 없으며, 하나의 연산은 인터럽트 없이 진행되어야한다.

: 원자성과 트랜잭션 간단한 차이점

원자성 : 연산 단위
트랜잭션 : 업무 단위

 

여러 스레드의 환경을 가진 멀티 스레드 환경에서 발생할 수 있는 동시성 문제 시나리오를 상상해본다. 

 

  • A 스레드가 ① 읽기(0) 후, ② 연산(1) 을 수행하기 직전
  • B 스레드가 거의 동시에 ① 읽기(0) → ② 연산(1) → ③ 쓰기(1)까지 완료

 

이후 A 스레드가 연산을 진행하고, 쓰기 과정까지 거치게 될 경우, B스레드가 저장했던 1의 값을 덮기 때문에 결과적으로는 count는 1의 값을 갖는다. 

 

 

해당 문제에서 동시성 문제(Concurrency Issue)를 띄게 된다.

즉 , count++는 원자적으로 보장되지 않는다. 

 


멀티스레드 환경에서 index++는 왜 안전하지 않은가?

위의 개념을 바탕으로 long타입과 LinkedHashMap에서 발생할 수 있는 동시성 문제를 알아보도록 한다.

아래는 게시글 생성 메서드이다. 

public Post postCreate(User user, PostCreateFormDto postCreateFormDto) {
    //1. ID 생성 : 동시성 문제 발생 위험 
    long postId = index++;
    
    //2. 게시글 객체 생성
    Post post = new Post(
        postId,
        postCreateFormDto.getTitle(),
        postCreateFormDto.getContent(),
        user.getUserId(),
        LocalDateTime.now()
    );

	//3. Posts Map에 게시글 저장
    Posts.put(postId, post);

    return post;
}

 

여러 스레드에서 postCreate를 호출할 경우, 서로 다른 스레드가 동시에 index가 0인 상태에서 각각 증가 연산을 수행하기 때문에, 동일한 ID(0, 1)가 중복 생성될 수 있다. 이후, 서로 다른 객체가 동일한 index를 발급받을 것이다.

하지만, Map에서 index는 사실상 데이터베이스의 PK 역할을 수행하기 때문에, 중복은 곧 데이터 무결성 위반으로 이어진다.

 

해당 문제를 해결하기 위해

long 타입의 index를 atomicLong 타입으로 바꾸어서 index++ 연산 자체에 원자성을 갖도록 한다면 어떨까?

 


AtomicLong과 CAS를 이용한 개선 

💡 atomicLong이란? 
하나의 연산 작업을 원자적이게 진행해주는 클래스(타입)
 volatile + CAS로 결합되어 연산작업이 원자성을 보장할 수 있도록 하여주는 타입이다.

💡 CAS 란?
Compare And Set(Compare And Swap) 
예상 값과 비교 후 같으면 set, 다르면 갱신한다.

 

AtomicLong의 내부 메소드 구조

1. getAndIncrement (반환 후 증가)

 public final long getAndIncrement() {
        return U.getAndAddLong(this, VALUE, 1L);
 }

 

index++ 을 대신하여 사용할 메서드인 getAndIncrement의 핵심 로직인 getAndAddLong의 내부는 아래와 같다. 

public final long getAndAddLong(Object o, long offset, long delta) {
    long v;
    do {
        v = getLongVolatile(o, offset);  // ① 현재 값 읽기 (volatile로 최신 값 보장)
    } while (!weakCompareAndSetLong(o, offset, v, v + delta)); 
			    // ② CAS 시도, 실패하면 반복
    return v;  // ③ 원래 값 반환
}

 

volatile을 통해 현재 값을 읽었을때, 값이 전과 달라지지 않았다면(예상과 같다면) 원래 값을 반환하고, 다르다면 실패하여 반복문이 실행된다.

 

 

volatile을 사용하는데 차라리 Syncronized를 사용해서 전체 연산에 lock을 걸면 더 안전했을 텐데
굳이 반복문 로직을 선택한 이유가 있을까?

 

아래와 같은 방식으로 synchronized 블럭을 통해 구현해볼수있다. 

synchronized(this) {
    index += delta;
}

 

하지만, lock이 걸려있다는 것은 다른 스레드가 대기 상태에 놓인다는 뜻이다.

여러 스레드가 동시에 공유자원에 접근하려 할때, 하나의 스레드만 lock 을 점유하고 나머지는 블로킹(Blocking) 상태에 빠진다.

이 과정에서 스레드 스위칭(Context Switching) 비용이 발생하고, 경쟁이 심할 수록 전체 성능이 저하된다.

 

CAS는 실패하더라도 다른 스레드의 작업을 기다리지 않는다.

단순히 "값이 다르네? 다시 시도하자"는 식으로 루프를 반복하기 때문에, lock-free(비차단) 구조라고 부른다

 

💡 컨텍스트 스위치이란?
CPU 코어는 한번에 하나의 스레드만 실행될 수 있다.
이때, 멀티스레드에서 여러 스레드를 실행시키기 위해서는 스레드를 교체가 일어난다.
해당 스레드를 교체하는 과정을 Context Switching 문맥 교환라고 한다.

💡 컨텍스트 스위치 비용이란?
CPU가 실제로 연산를 수행하지 않고, 스레드를 교체하기 위해 상태를 저장하고 복원하는 데 소비되는 시간을 말한다.

즉, 스레드를 바꾸는 동안 CPU는 “일”을 하지 않기 때문에이 시간 자체가 성능 손실(비용)로 작용한다.

 


✨ 개선사항이 반영된 코드 (AtomicLong 적용)

   private AtomicLong postId = new AtomicLong(0);
   private Map<Long, PostEntity> Posts = new LinkedHashMap<>();
   
   public Post postCreate(User user, PostCreateFormDto postCreateFormDto) {
   	
    //값 반환 후 id값 증가 : i++
    long id = postId.getAndIncrement();
    
    //게시글 객체 생성
    Post post = new Post(
        id,
        postCreateFormDto.getTitle(),
        postCreateFormDto.getContent(),
        user.getUserId(),
        LocalDateTime.now()
    );

	//3. Posts Map에 게시글 저장
    Posts.put(post.getPostId(), post);

    return post;
}

 

'Trouble Shooting > JAVA' 카테고리의 다른 글

LinkedHashMap은 왜 멀티스레드 환경에서 위험한가?  (0) 2025.10.26
'Trouble Shooting/JAVA' 카테고리의 다른 글
  • LinkedHashMap은 왜 멀티스레드 환경에서 위험한가?
happy7yong
happy7yong
뭇별에 닿기를
  • happy7yong
    yongyong
    happy7yong
  • 전체
    오늘
    어제
    • 분류 전체보기 (8)
      • JavaScript (4)
      • Coding Test (0)
        • JavaScript (0)
      • Trouble Shooting (2)
        • JAVA (2)
      • 회고 (2)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
happy7yong
멀티스레드 환경에서 index++ 는 왜 위험한가?
상단으로

티스토리툴바