본문 바로가기

책 리뷰/주니어 백엔드 개발자가 반드시 알아야 할 실무 지식

6장. 동시성, 데이터가 꼬이기 전에 잡아야 한다

1. 서버와 동시실행

트래픽이 많지 않은 서비스라도 실제 운영 환경에서는 초당 수십~수백 개의 요청이 동시에 유입될 수 있다.

특히 로그인, 결제, 예약, 포인트 차감과 같은 기능은 특정 시점에 요청이 집중되는 경우가 많다.

서버는 이러한 요청을 하나씩 순차적으로 처리하지 않는다.

만약 요청을 동시에 처리하지 못한다면 대기 시간이 증가하고, 전체적인 서비스 성능은 급격히 저하된다.

서버가 여러 요청을 처리하는 대표적인 방식은 다음과 같다.

  • 클라이언트 요청마다 스레드를 할당해 처리하는 방식
  • 비동기 I/O 기반으로 이벤트를 처리하는 방식

어떤 방식을 사용하든, 현대 서버 환경에서 동시 실행은 기본 전제다.

즉 개발자는 “요청은 항상 동시에 들어온다”는 가정 하에 코드를 작성해야 한다.

동시 실행을 고려하지 않고 작성한 코드는 특정 조건에서만 재현되는 버그를 만들어낸다.

이러한 버그는 개발 환경이나 테스트 환경에서는 잘 드러나지 않지만, 운영 환경에서는 치명적인 장애로 이어질 수 있다.

특히 다음과 같은 공유 자원을 변경하는 로직에서 문제가 자주 발생한다.

  • 재고 차감
  • 잔액 차감
  • 예약 인원 증가

이전 요청이 완료되기 전에 다음 요청이 동일한 데이터를 변경하면 개발자가 의도하지 않은 결과가 발생할 수 있다.

이처럼 여러 스레드가 동시에 공유 자원에 접근하고 접근 순서에 따라 결과가 달라지는 상황을 경쟁 상태(Race Condition) 라고 한다.

동시성 문제를 해결하려면 이러한 경쟁 상태를 항상 염두에 두고 설계해야 한다.

 

2. 잘못된 데이터 공유로 인한 문제 예시

동시성 문제가 가장 자주 발생하는 지점 중 하나는 싱글톤 객체의 잘못된 사용이다.

싱글톤은 애플리케이션 전체에서 하나의 인스턴스만 생성하여 사용하는 구조로 공통 설정이나 공용 서비스 객체에서 자주 사용된다.

문제는 싱글톤 객체가 상태를 가지는 경우다.

다중 스레드 환경에서는 다음과 같은 상황이 발생할 수 있다.

  • 스레드 1이 싱글톤 객체의 필드를 변경
  • 스레드 2가 같은 필드를 다시 변경
  • 스레드 1은 자신이 설정한 값이 유지될 것이라 기대하지만, 실제로는 스레드 2의 값이 사용됨

즉, 먼저 시작한 스레드가 반드시 먼저 종료된다는 보장이 없기 때문에 전혀 다른 데이터가 로직에 사용되는 문제가 발생한다.

싱글톤 자체가 문제라기보다는 싱글톤 객체가 상태를 가지고 있고 그 상태가 여러 스레드에서 동시에 수정될 수 있을 때잘못된 데이터 공유로 인한 동시성 문제가 발생한다.

따라서 싱글톤을 사용할 때는 반드시 다음을 고려해야 한다.

  • 불변 객체로 설계할 수 있는가
  • 상태를 메서드 지역 변수로 한정할 수 있는가
  • 상태 변경이 필요하다면 동기화가 필요한가

 

3. 프로세스 수준에서의 동시 접근 제어

프로세스 내부에서 여러 스레드가 동일한 데이터를 동시에 수정하지 못하도록 막는 가장 기본적인 방법은 잠금(Lock) 이다.

 

잠금을 사용하면 한 번에 하나의 스레드만 공유 자원에 접근할 수 있다.

이렇게 동시 접근이 제한된 영역을 임계 영역(Critical Section) 이라고 한다.

synchronized

  • 자바에서 가장 기본적인 동기화 방식
  • 메서드 또는 블록 단위로 동시 접근 차단
  • 명시적으로 unlock을 호출할 필요가 없음
  • 문법은 간단하지만 세밀한 제어가 어렵다

ReentrantLock

  • 명시적으로 lock() / unlock() 호출 필요
  • 잠금 획득 대기 시간 지정 가능
  • tryLock을 통해 교착 상태 회피 가능
  • synchronized보다 유연하지만 unlock 보장이 필수

세마포어(Semaphore)

  • 동시에 접근 가능한 스레드 수를 제한
  • N개의 스레드까지만 임계 영역 진입 허용
  • 커넥션 풀, 리소스 풀 관리에 적합

읽기 쓰기 잠금(Read-Write Lock)

  • 조회와 수정의 특성이 다른 점을 활용한 잠금 방식
  • 쓰기 잠금은 단일 스레드만 가능
  • 읽기 잠금은 여러 스레드 동시 가능

규칙은 다음과 같다.

  • 쓰기 잠금이 획득되면 읽기 불가
  • 읽기 잠금이 하나라도 있으면 쓰기 불가

조회 비중이 높은 시스템에서 성능 개선 효과가 크다.

원자적 타입(Atomic Type)

잠금은 컨텍스트 스위칭 비용으로 인해 CPU 효율을 떨어뜨릴 수 있다.

이를 보완하기 위해 원자적 타입이 제공된다.

대표적으로 AtomicInteger가 있으며, 내부적으로 CAS(Compare And Swap) 연산을 사용한다.

CAS는 다음 순서로 동작한다.

  1. 현재 값과 기대 값을 비교
  2. 값이 같으면 새로운 값으로 교체
  3. 다르면 실패하고 재시도

명시적인 락 없이도 동시성을 확보할 수 있다는 장점이 있다.

동시성 지원 컬렉션

기본 컬렉션은 스레드 안전하지 않다.

이를 보완하기 위해 동기화된 컬렉션을 사용할 수 있다.

 

예를 들어 다음과 같은 방식이 있다.

  • Collections.synchronizedMap(new HashMap<>())
  • ConcurrentHashMap과 같은 고성능 동시성 컬렉션

DB 트랜잭션과 동시성

DB 트랜잭션은 여러 연산을 하나의 논리적 작업으로 묶지만,

트랜잭션만으로 모든 동시성 문제가 해결되지는 않는다.

 

DB 수준의 동시성 제어 방식에는 다음이 있다.

비관적 잠금

  • 선점 잠금 방식
  • 먼저 접근한 트랜잭션이 레코드를 잠금
  • 다른 트랜잭션은 대기

충돌 가능성이 높다고 가정하는 방식이다.

낙관적 잠금

  • 비선점 잠금 방식
  • 조회 시점과 수정 시점의 값 비교
  • 충돌 시 수정 실패

충돌 가능성이 낮다고 가정한다.

트랜잭션 범위 내에 외부 연동이 포함되는 경우에는 데이터 정합성을 위해 비관적 잠금이 더 안전한 선택이 될 수 있다.

 

4. 잠금 사용 시 주의사항

잠금은 강력한 도구이지만, 잘못 사용하면 더 큰 장애를 유발한다.

잠금 해제하기

잠금을 획득한 뒤에는 반드시 해제해야 한다.

특히 예외 발생 시에도 unlock이 보장되도록 설계해야 한다.

대기 시간 지정하기

잠금 획득을 무한정 대기하는 것은 사용자 경험을 악화시킨다.

타임아웃을 지정하고 실패 시 대체 로직을 준비해야 한다.

교착 상태 피하기

서로의 잠금을 기다리며 무한 대기하는 상태다.

 

회피 방법은 다음과 같다.

  • 잠금 대기 시간 제한
  • 항상 동일한 순서로 잠금 획득

라이브락

서로 양보만 하며 상태만 바뀌고 작업이 진행되지 않는 상태다.

중재자 도입이나 우선순위 설정으로 해결할 수 있다.

기아 상태

특정 스레드가 자원을 계속 얻지 못하는 상태다.

자원 독점 제한이나 스케줄링 정책 조정이 필요하다.

 

5. 단일 스레드로 처리하기

동시성 문제를 근본적으로 제거하는 방법 중 하나는 데이터 처리를 단일 스레드로 제한하는 것이다.

구조는 다음과 같다.

  • 요청은 여러 스레드에서 수신
  • 데이터 변경 또는 조회 작업은 작업 큐에 적재
  • 하나의 상태 관리 스레드가 큐를 소비하며 처리

이 방식의 장점은 다음과 같다.

  • 동시성 문제에서 자유로움
  • 락이 필요 없음
  • 상태 관리가 명확함

반면 구조가 복잡해지고 처리량에 한계가 있다는 단점이 있다.

비동기 I/O 기반 시스템에서는 동기 연산을 최소화해야 하므로 이러한 단일 스레드 처리 방식이 오히려 적합한 경우도 많다.