자바 동시성(Concurrency)과 멀티스레드 환경의 동기화 문제

자바 백엔드 개발자라면 실무에서, 혹은 대기업 기술 면접에서 반드시 마주치게 되는 핵심 주제가 있습니다. 바로 ‘동시성(Concurrency)’과 ‘멀티스레드(Multi-Thread)’입니다.

스프링 프레임워크(Spring Framework) 기반의 백엔드 서버는 기본적으로 수많은 사용자의 요청을 멀티스레드 방식으로 처리합니다. 즉, 우리가 작성한 코드가 여러 스레드에 의해 동시에 실행된다는 뜻입니다. 이때 동시성 문제를 제대로 제어하지 못하면 데이터가 유실되거나 결제 오류가 발생하는 등 치명적인 금융·비즈니스 장애로 이어질 수 있습니다.

이번 글에서는 멀티스레드 환경에서 왜 동기화 문제가 발생하는지 원인을 파악하고, 자바가 제공하는 다양한 해결 책들을 깊이 있게 살펴보겠습니다.

1. 동시성(Concurrency)과 병렬성(Parallelism)의 차이

가장 먼저 많은 개발자가 혼동하는 두 가지 개념을 명확히 구분해야 합니다.

  • 동시성 (Concurrency): 싱글 코어에서 여러 스레드가 번갈아 가며 실행되는 것을 말합니다. CPU가 워낙 빠르게 스레드를 전환(Context Switching)하기 때문에 사용자 눈에는 동시에 실행되는 것처럼 보입니다. (논리적인 개념)
  • 병렬성 (Parallelism): 멀티 코어에서 각 코어가 실제로 여러 작업을 동시에 물리적으로 실행하는 것을 말합니다. (물리적인 개념)

자바 백엔드 환경에서는 주로 멀티 코어를 활용한 병렬성과, 한정된 자원 속에서 여러 요청을 번갈아 처리하는 동시성 제어가 모두 중요하게 다뤄집니다.

2. 멀티스레드 환경에서 동기화 문제가 발생하는 원인

왜 여러 스레드가 동시에 같은 코드를 실행하면 문제가 생길까요? 핵심은 ‘공유 자원(Shared Resource)’에 있습니다. 여러 스레드가 힙(Heap) 영역이나 메서드 영역의 동일한 메모리 공간을 동시에 읽고 쓸 때 데이터의 일관성이 깨지는 현상이 발생하는데, 이를 경쟁 상태(Race Condition)라고 합니다.

간단한 자바 코드로 예시를 들어보겠습니다.

public class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

위의 count++ 연산은 겉보기에는 한 줄짜리 단순한 연산 같지만, CPU 내부적으로는 다음 3단계로 나누어 동작합니다.

  1. 메모리에서 count 값을 읽어온다. (Read)
  2. 읽어온 값에 1을 더한다. (Modify)
  3. 변경된 값을 다시 메모리에 저장한다. (Write)

만약 스레드 A가 1단계(Read)를 수행해 count 값인 0을 읽어간 상태에서, 스레드 B로 제어권이 넘어갔다고 가정해 봅시다. 스레드 B도 0을 읽어서 1을 더해 저장(Write)합니다. 그 후 다시 제어권을 받은 스레드 A 역시 아까 읽었던 0에 1을 더해 저장합니다.

두 번의 증가 연산이 일어났으므로 결과는 2가 되어야 하지만, 실제 메모리에는 1만 남게 됩니다. 스레드가 안전하지 않은(Thread-Unsafe) 전형적인 동시성 오류입니다.

3. 자바의 동시성 문제 해결 방법 3가지

자바는 이러한 경쟁 상태를 방지하고 스레드 안전성을 확보하기 위해 다양한 레벨에서의 동기화 메커니즘을 제공합니다.

3-1. synchronized 키워드 (암묵적 락)

가장 전통적이고 직관적인 방법은 synchronized 키워드를 사용하는 것입니다. 이 키워드가 붙은 메서드나 블록은 한 번에 하나의 스레드만 진입할 수 있도록 제어합니다.

public synchronized void increment() {
    count++;
}
  • 원리: 스레드가 해당 메서드에 진입할 때 객체의 모니터 락(Monitor Lock)을 획득하고, 메서드가 종료될 때 락을 반납합니다. 락이 없는 다른 스레드는 앞선 스레드가 락을 반납할 때까지 차단(Blocked) 상태로 대기합니다.
  • 단점: 성능 저하가 발생할 수 있습니다. 락이 걸려있는 동안 다른 스레드들이 아무 작업도 못 하고 대기해야 하므로 병목 현상의 원인이 됩니다.

3-2. volatile 키워드와 가시성(Visibility) 문제

동시성 문제의 또 다른 원인은 CPU 캐시 메모리입니다. 현대 CPU는 성능 향상을 위해 메인 메모리에서 값을 매번 가져오지 않고 CPU 내부의 캐시 메모리를 이용합니다. 이 때문에 스레드가 변경한 값이 메인 메모리에 즉시 반영되지 않아 다른 스레드가 옛날 데이터를 읽는 가시성(Visibility) 문제가 생깁니다.

public class Flag {
    private volatile boolean stopRequested = false;
}
  • 원리: 변수에 volatile 키워드를 붙이면 CPU 캐시가 아닌 메인 메모리에 직접 읽고 쓰도록 강제합니다. 이를 통해 한 스레드가 변경한 값을 다른 스레드가 즉시 볼 수 있게 됩니다.
  • 주의점: volatile은 가시성만 보장할 뿐, 앞서 본 count++와 같은 원자성(Atomicity) 문제는 해결하지 못합니다. 연산 자체가 여러 단계로 나뉘는 작업에는 사용할 수 없습니다.

3-3. Atomic 클래스와 CAS(Compare-And-Swap) 알고리즘

락(Lock)을 걸면 성능이 떨어지고, volatile은 원자성을 보장하지 못하는 한계를 극복하기 위해 자바는 java.util.concurrent.atomic 패키지를 제공합니다.

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet(); // 하드웨어 수준에서 동기화 보장
    }
}
  • 원리: 이 방식은 스레드를 차단하는 락을 사용하지 않는 Non-blocking(논블로킹) 방식입니다. 내부적으로 CAS(Compare-And-Swap) 알고리즘을 사용합니다.
  • CAS 동작 방식: 내가 값을 변경하기 전에 ‘내가 알고 있던 기존 값’과 ‘실제 메모리의 값’이 일치하는지 비교(Compare)합니다. 일치할 때만 새로운 값으로 교체(Swap)하고, 만약 일치하지 않으면 실패로 간주하고 일치할 때까지 루프를 돌며 재시도합니다. 하드웨어 수준에서 지원하므로 synchronized보다 성능이 훨씬 뛰어납니다.

4. 스프링(Spring) 백엔드 환경에서의 동시성 주의점

실무에서 스프링 부트(Spring Boot)로 웹 애플리케이션을 개발할 때 가장 실수하기 쉬운 부분이 바로 싱글톤 빈(Singleton Bean)에서의 상태 값 관리입니다.

스프링 컨테이너는 기본적으로 자바 객체(Bean)를 단 하나만 생성하여 공유하는 싱글톤 패턴으로 관리합니다.

@Service
public class UserService {
    private String currentLoginUser; // ❌ 위험한 코드

    public void login(String username) {
        this.currentLoginUser = username;
        // 비즈니스 로직 수행...
    }
}

만약 위와 같이 서비스 클래스에 멤버 변수(필드)를 두고 변경 가능한 상태 값을 저장하면 어떻게 될까요? 사용자 A가 로그인한 직후 사용자 B가 요청을 보내면 currentLoginUser가 B로 덮어써집니다. 결과적으로 사용자 A의 화면에 사용자 B의 정보가 노출되는 대형 보안 사고가 발생할 수 있습니다.

스프링 빈은 반드시 상태를 가지지 않는 무상태(Stateless)로 설계해야 합니다. 지역 변수, 파라미터, 리턴 값 등을 활용하거나 반드시 공유해야 하는 상태가 있다면 ThreadLocal을 사용해 각 스레드만의 고유한 보관소를 활용해야 합니다.

5. 핵심 요약: 동기화 기법 비교

동기화 방식원자성 보장가시성 보장블로킹(Lock) 여부주요 용도
synchronizedOOO (Blocking)복잡한 로직 및 여러 연산의 임계 영역 제어
volatileXOX (Non-blocking)한 스레드가 쓰고 다른 스레드가 읽는 플래그 값
Atomic 클래스OOX (Non-blocking)단순 값의 증가/감소 연산 성능 최적화

6. 결론

자바 멀티스레드 환경에서의 동시성 제어는 시스템의 안정성과 직결되는 백엔드 개발자의 핵심 역량입니다. 무조건 synchronized로 락을 거는 것은 성능을 갉아먹는 주범이 되므로, 상황에 맞춰 Atomic 클래스나 무상태 설계를 적절히 선택할 수 있어야 합니다.

다음 글에서는 실제 실무에서 대량의 스레드를 효율적으로 관리하기 위해 사용하는 ‘스레드 풀(Thread Pool)의 원리와 스프링의 Async 비동기 처리’에 대해 자세히 알아보겠습니다.