Post

Springboot Java - 효율성을 높이는 동시성, 그리고 피할 수 없는 동시성 이슈

보다 효율적인 처리를 위한 동시성, 하지만 항상 따라오는 동시성 이슈! 두개를 모두 알아보자.

Springboot Java - 효율성을 높이는 동시성, 그리고 피할 수 없는 동시성 이슈

동시성 이란?

두가지 의미?

동시성의 정확한 정의와 의미를 파악하기 위해 여러 글을 읽어보는데, 2가지로 나뉘어서 어느 것이 정확한 의미인지 헷갈렸다.

첫번째 동시성
시스템이 동시 실행 또는 시간 공유(컨텍스트 전환)를 통해 여러 작업을 실행하고 리소스를 공유하며 상호 작용을 관리하는 기능
병렬성, 멀티스레딩 및 멀티프로세싱, 동기화, 동시성 제어, 프로세스 간 통신와 같은 여러 아이디어를 포함하는 광범위한 개념
두번째 동시성
하나의 코어에서도 여러 작업을 번갈아 실행하면서 동시에 실행되는 것처럼 보이게 하는 방식
동시에 실행되는 것처럼 보이지만, 사실은 CPU가 작업을 빠르게 전환하여 그렇게 보이도록 하는 것

이렇게 두가지로 나뉘었는데, 첫번째 의미는 아마 동시 컴퓨팅(Concurrent Computing)을 의미하고, 두번째는 논리적 동시성(Time-sharing)을 의미하는 것 같았다.

동시성이라는 용어가 이중적으로 사용되어 더 헷갈리게 느껴진 것 같다.

그럼 이 글에서 동시성은 첫번째 동시성인 동시 컴퓨팅에 대해 알아보려 한다.

동시성과 관련된 용어 정리

병렬성
여러 처리 장치에서 동시 실행하는 것
멀티코어 CPU에서 각 작업이 병렬로 실행하는 방식으로 동시성을 구현하여 물리적으로 동시에 실행된다.
멀티스레딩
하나의 프로세스 내에서 여러 개의 스레드를 실행하는 방식으로 동시성을 구현하여 여러 작업을 실행될 수 있도록 한다.
멀티프로세싱
여러 개의 프로세스를 동시에 실행하는 방식으로 동시성을 구현하여 여러 프로그램이 실행될 수 있도록 한다.
동기화
동시성을 가진 여러 프로세스, 프로그램에서 각 스레드와 프로세스가 같은 리소스에 접근해 발생할 수 있는 문제를 안전하게 하는 방법이다.
동시성 제어
동시성을 효율적으로 처리하고 충돌을 방지하는 기법이다.
프로세스 간 통신
멀티프로세싱 환경에서, 각 작업끼리 데이터를 주고 받는 방법이다.
조정
동시성을 구현한 방법에서 각 작업의 순서가 적절한 순서로 실행되도록 하는 기법이다.

동시성을 구현하는 방법

위에서 말한 것처럼 동시성은 동시 실행이나 시간 공유를 통해 여러 작업을 실행 및 리소스를 공유하는 기능이라고 했다. 이런 동시성은 여러가지 방법으로 구현되는데, 어떻게 구현되었는지 알아보자.

멀티스레딩과 멀티프로세싱

먼저 멀티스레딩은 하나의 프로세스 내에서 여러 개의 스레드를 실행해 동시성을 구현했다.

싱글스레드와 멀티스레드 싱글스레드와 멀티스레드

이렇게 여러개의 스레드를 이용해, 동시에 여러개의 작업이 실행 되는 것처럼 보이게한다. 만약 코어가 여러개라면 실제로 병렬적으로 수행도 가능하지만, 그림처럼 1개여도 컨텍스트 스위칭을 하며 동시에 실행되도록 한다.

멀티프로세싱은 멀티스레드와 비슷한 방식인데, 스레드에서 프로세스로 변경되었다는 점이 다르다. 그래서 방식은 같지만 스레드와 프로세스의 차이에서 발생하게된다. 스레드는 데이터 공유가 가능하고, 프로세스는 각자 자신만의 메모리 공간을 가지게 되어 공유가 어렵다는 점이다.

비동기 프로그래밍

동기와 비동기 동기와 비동기

비동기 프로그래밍은, 위 그림에서와 같이 작업을 요청한 후 요청이 완료될때까지 기다리는 것이 아니라 완료가 된다면 callback을 호출하고, callback 호출에 의해 작업을 다시 진행하는 것이다. 물론 그림에서는 블로킹/논블로킹을 제외하기 위해 블로킹 작업으로 예시를 들었다.

예를 든다면, API 호출이나 DB 조회와 같은 작업이 작업2,작업3이라고 생각하면 좋을 것 같다.

논블로킹 I/O

블로킹과 논 블로킹 블로킹과 논 블로킹

논블로킹 I/O는 비동기와 비슷하지만, 작업2와 작업3의 완료 여부와 관계 없이 자신의 작업을 계속한다는 차이가 있다. 이렇게 되면 CPU가 유휴상태가 되지 않고, 지속적으로 다른 작업을 수행할 수 있어 CPU 리소스를 더 효율적으로 사용할 수 있다.

예시로는 파일 시스템에서 값을 읽거나 , DB와 연동하는 등의 작업이 있다.

비동기와 논블로킹이 유사하게 느껴진다면? 맞다. 두 개념의 목적이 동시에 다른 작업을 수행한다는 것으로 같기 때문에 그렇게 느껴진다. 이는 관점에 대한 차이인데, 이 두개가 너무 헷갈린다면 비동기와 멀티스레딩을 한번 읽어보자.

동시성과 병렬성

지금까지 동시성에 대해 알아봤다. 동시성과 비슷한 개념이 하나 더 있는데, 그건 병렬성이다. 동시성이 포함하는 개념 중 하나인데, 어떤 차이가 있는지 보자.

동시성과 병렬성 동시성과 병렬성

이 그림이 낯익을 수도 있다. 위에서 싱글스레드와 멀티스레드를 설명할 때 그림과 유사하다. 특히 코어가 1개인경우와 3개인 경우에 대한 차이라고 볼 수 있는데, 그 이유는 병렬성은 각 코어가 따로 실행하는 방식이기 때문이다.

Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once. 동시성은 한번에 많은 일을 다루는 것이고, 병렬성은 한 번에 많은 일을 하는 것이다. 출처 : Concurrency vs. Parallelism — A brief view

번역은 구글 번역을 이용했는데, 생각보다 잘 된 것 같다.

동시성과 병렬성의 차이를 잘 말해주는 문장인 것 같아 그대로 인용했다. 말 그대로 동시성은 진행중인 과정에서 한번에 많은 일(=작업1, 작업2 등)을 다루는 것이고, 병렬성은 아예 물리적으로 동시에 실행되는 것을 의미한다.

그래서 병렬성이 되기 위해서는 멀티 코어가 필수적이다. 멀티코어가 없는 상황에서는 병렬성이 아닌 마치 동시에 여러개가 되는 것처럼, 동시에 여러일을 하는 것처럼 보이는 것인 동시성이다.

병렬성
물리적으로 동시에, 한번에 많은 일을 하는 것
멀티 코어가 필수적
동시성
한번에 많은 일을 다루는 것
여러가지 일에 대해 컨텍스트 스위칭을 하며, 동시에 여러일을 하는 것처럼 보이게 함

동시성의 문제

동시성은 프로세스나 프로그램이 보다 효율적으로 동작할 수 있게 해주지만, 관련된 문제도 많다. 동시성 환경에서는 어떤 문제가 발생될 수 있을까?

동시성 환경에서 발생되는 문제들

데이터 일관성 문제
여러 스레드가 공유 데이터를 처리할 때 발생하는 문제
경쟁 조건(Race Condition), 데이터 경합(Data Race), 메모리 일관성 문제(Memory Consistency Issue)
자원 접근 문제
특정 스레드가 자원(락)을 차지한 상태에서, 다른 스레드가 실행되지 못하는 문제
데드락(Deadlock), 기아 상태(Starvation), 우선순위 반전(Priority Inversion)
성능 문제
스레드 관리 또는 과도한 동기화로 인한 성능 저하
컨텍스트 스위칭 오버헤드(Context Switching Overhead), 라이브락(Livelock)

데이터 일관성 문제

먼저 데이터 일관성 문제에 대해 알아보고, 동기화로 해결하는 방법까지 보자.

데이터 일관성 문제
여러 스레드가 공유 데이터에 읽고 쓰면서 발생하는 문제다.

중요 포인트는 공유 데이터에 했다는 것이다.

경쟁 조건(Race Condition)
두 개 이상의 스레드가 공유 자원(변수, 메모리, 파일 등)에 동시에 접근해 비정상적인 결과가 발생하는 문제
논리적인 실행 순서(조건문, 흐름 등)가 꼬여서 문제가 발생
데이터 경합(Data Race)
둘 이상의 스레드가 하나의 변수를 동시에 읽고 쓰면서, 올바르지 않은 값이 발생하는 문제
여러 스레드가 하나의 변수를 동시에 읽고 쓰면서 발생하는 문제
메모리 일관성 문제(Memory Consistency Issue)
멀티스레드 환경에서 한 스레드의 변경 사항이 다른 스레드에서 즉시 반영되지 않는 문제
변수 값이 변경되었음에도 불구하고, 다른 스레드에 변경된 내용이 반영되지 않아 여전히 이전 값을 보게 되어 생기는 문제
CPU 캐시나 메모리 가시성 문제로 인해 최신 데이터가 보이지 않을 수 있음

이 3가지 문제에서 경쟁 조건과 데이터 경합은 유사하게 느껴지는데, 그 이유는 경쟁 조건이라는 문제 안에 데이터 경합이 포함되기 때문이다. 그래도 이해하기 위해 예제를 보자면,

경쟁 조건 예시

1
2
3
4
5
6
7
8
9
class BankAccount {
    private int balance = 100;

    public void withdraw(int amount) {
        if (balance >= amount) {
            balance -= amount;
        }
    }
}

코드 작성시 예상 시나리오

  1. balance >= amount라는 조건을 thread A가 통과해 balance값을 변경
  2. thread B는 조건을 통과하지 못해 값을 변경할 수 없음

실제 동작시 문제가 되는 시나리오

  1. balance >= amount라는 조건을 thread A가 통과
  2. balance >= amount라는 조건을 thread B도 통과
  3. 먼저 통과한 thread A가 balance값을 변경
  4. thread B도 이미 통과했으니, balance값을 변경
문제점
라인 5의 if문 통과와 라인 6의 값 변경이 한번에 동작할 줄 알았지만, 이 과정이 나뉘어져 동작해 문제가 발생

여러 스레드가 동일한 자원을 수정할 때, 이렇게 코드의 실행 순서에 따라 결과가 달라지는 문제를 경쟁 조건이라고 한다.

데이터 경합 예시

1
2
3
4
5
6
7
class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }
}

코드 작성시 예상 시나리오

  1. thread A가 increment()호출
  2. count 값 증가

실제 동작시 문제가 되는 시나리오

  1. thread A: increment()호출
  2. thread A: count 값 읽음
  3. thread B: increment()호출
  4. thread A: count 값 증가
  5. thread B: count 값 읽음
  6. thread B: count 값 증가
  7. thread A: count 값 저장 //count = 1
  8. thread B: count 값 저장 //count = 1
  9. 원래는 값이 2가 되어야하는데, 1이 됨
문제점
증가 연산이 한번에 동작하는 것으로 생각했는데, 사실 3단계(읽기 -> 증가 -> 값 쓰기)로 이루어져있어 이 과정이 나뉘어져 동작해 문제가 발생

여러 스레드가 동시에 같은 변수를 읽고, 쓰면서 값이 꼬이는 문제를 데이터 경합 이라고 한다.

해결 방법: 동기화(Synchronization)

동기화
동시성 환경에서 여러 스레드/프로세스가 같은 자원에 접근할 때 발생하는 문제를 해결하는 기법이다.
멀티스레딩, 멀티프로세싱 환경에서 데이터를 안전하게 보호하는 역할이다.
주요 기법으로는 락(Lock)기반 기법과 락-프리(Lock-Free) 기법이 있다.
락(Lock)기반 기법과 락-프리(Lock-Free)기법
락(Lock)기반 기법
여러 스레드가 공유 자원에 접근할 때, 한 번에 하나의 스레드만 접근하도록 제한하여 동기화를 보장하는 방식
공유 자원을 하나의 스레드만 사용할 수 있도록 보호하지만, 락 획득 실패 시 대기(블로킹) 상태가 된다.
데이터 정합성을 보장하지만, 성능 저하가 있을 수 있다.

종류

  • synchronized: 한 번에 하나의 스레드만 특정 코드 블록을 실행하도록 보장
  • ReentrantLock: 타임아웃이나 공정한 락 등을 지원해 더 유연하고, 락이 사용 중이면 바로 다른 작업을 수행 가능하도록 해 non-blocking 으로 동작
  • ReadWriteLock: 읽기(Read)는 여러 스레드가 동시 실행 가능하지만, 쓰기(Write)는 단 하나의 스레드만 가능
  • Semaphore: 특정 개수의 스레드만 동시에 접근 가능하도록 제한
락-프리(Lock-Free)기법
락을 사용하지 않고 동기화를 보장하는 방식
락을 사용하지 않기 때문에 대기(Blocking) 상태가 발생하지 않음
복잡한 구현이 필요하며, 모든 상황에서 적용할 수 있는 것은 아님

종류

  • volatile: 변수 값을 캐시에 저장하지 않고, 항상 메모리에서 읽도록 보장
  • Atomic 변수 (AtomicInteger, AtomicReference 등): 락 없이 원자적 연산을 보장하는 클래스
  • CAS (Compare-And-Swap): 값이 예상한 값과 같을 때만 변경하는 방식
  • ThreadLocal: 각 스레드마다 별도의 변수 공간을 제공하여 공유 문제 해결
  • CopyOnWriteArrayList / CopyOnWriteSet: 읽기 작업이 많고 쓰기 작업이 적은 경우에 최적화된 컬렉션

자원 접근 문제

동기화 중 락을 이용한 동기화 중에서 발생되는 문제들이다. 그 중에서도 락을 가지는 문제와 관련되어 있다.

자원 접근 문제
데이터 일관성 문제의 해결을 위해 락을 이용해 동기화를 시킬 때, 잘못 사용해 발생하는 문제다.

공통점은 락을 사용했을 때 라는 것이다.

데드락(Deadlock)
여러 스레드가 각각 락을 점유하고 있는 상태에서, 서로 상대방의 락을 기다리고 있어 어떤 락도 풀리지 않아 영원히 대기해 발생하는 문제
thread A는 1번락 소유, 2번 락을 얻기를 기다림 / thread B는 2번락 소유, 1번 락을 얻기를 기다림 -> 아무도 락을 풀수 없는 상태로 대기하게 된다.
기아상태(Starvation)
락을 할당시 우선순위를 지정해 받을 수 있도록 지정했는데, 높은 우선순위의 스레드가 계속 있어서 낮은 우선순위 스레드가 자원을 계속 할당받지 못하는 문제
우선 순위 1,2,3 의 스레드가 있을때 1,2가 작업이 끝나지 않거나, 3이 자원을 할당 받기전에 다시 작업이 생성되어서 3은 우선순위가 계속 밀리게되어 3만 처리가 안된다.
우선순위 반전(Priority Inversion)
낮은 우선순위 스레드가 락을 잡고 있어서 높은 우선순위 스레드가 실행되지 못하는 문제
(우선순위는 실행의 우선순위고) 낮은 우선순위의 스레드가 락을 획득한 상황에서 높은 우선순위가 들어오면, 높은 우선순위의 스레드를 실행시키려고 하지만 락이 없어서 대기하게 되고 낮은 우선순위의 스레드는 락은 있지만 우선순위가 밀려서 대기하게 되어 멈추게 된다.

해결 방법

락 획득 순서를 조절

  • 락 획득 순서를 고정적으로 획득하도록 해, 데드락을 방지한다.
  • tryLock(): 일정시간 내에 락을 획득하지 못하면, 다른 작업을 한다.
  • 공정한 락: 락을 요청한 순서대로 부여해 기아를 방지한다.

우선 순위를 조절

  • 우선순위 조절: 특정 스레드의 우선순위를 동적으로 변경해 기아나, 우선순위 반전을 해결한다.
  • 우선순위 상속: 높은 우선순위의 스레드가 자원을 점유하고 있을 때, 낮은 우선순위의 스레드를 일시적으로 우선순위를 높여준다.
  • 우선순위 천장: 락에 접근하는 모든 스레드는 최고 우선순위로 설정해, 우선순위 반전을 방지한다.

성능 문제

성능 문제는 로직 상에 문제가 발생하지 않아도, 생길 수 있는 문제들이다.

컨텍스트 스위칭 오버헤드(Context Switching Overhead)
CPU가 여러 스레드를 번갈아 가며 실행하는데, 이 스위치 비용이 과도하게 많이 들 때를 말한다.
과도한 동기화(Excessive Synchronization)
불필요한 락을 사용함으로써 성능이 저하되는 문제를 말한다.
라이브락(Livelock)
스레드들이 서로 양보하면서 실제로 작업을 수행하지 못하는 문제를 말한다.

해결 방법

스레드의 수를 조절

  • 컨텍스트 스위칭 오버헤드를 개선하기 위해 적절한 개수의 스레드로 조절한다.
  • CPU 바운더리 작업이라면 코어와 같은 개수를 I/O 바운더리 작업이라면 코어보다 2배 + @ 개수를 설정한다.

락은 필요한 곳에만 사용

  • 가능하면 락-프리를 이용한다.
  • 만약 락이 꼭 필요한 곳에서만 사용하거나, 읽기와 쓰기를 분리해 쓰기에서만 사용하는 것과 같이 사용 범위를 줄인다.

랜덤 대기 시간 설정

  • 여러 스레드가 동시에 같은 자원을 요청할 때, 랜덤한 대기시간을 설정해 충돌이 발생하지 않도록 한다.

동시성 제어

동시성 제어는 동시성 환경에서 발생하는 문제들을 해결하는 것으로, 앞 글에서 얘기한 동기화를 포함하고 있다. 그럼 그 외에도 어떤 해결 방법이 있는지 알아보자.

비관적 동시성 제어(Pessimistic Concurrency Control, PCC)
미리 락(Lock)을 사용하여 다른 트랜잭션이 데이터를 변경하지 못하도록 막는 것을 말한다.
충돌 가능성이 높은 경우에는 적합하지만, 아니라면 성능 저하를 유발한다.
낙관적 동시성 제어(Optimistic Concurrency Control, OCC)
충돌 가능성을 무시하고, 트랜잭션 종료 시 충돌 여부를 확인한다.
만약 충돌이 발생했다면, 롤백 후 다시 시도해야한다.
MVCC(Multi-Version Concurrency Control)
데이터 변경 시 새로운 버전을 생성하여 충돌 없이 동시성 보장한다.
데이터를 읽는 작업은 이전 버전의 데이터를 보고 있고, 데이터를 수정하거나 쓰는 작업은 새로운 버전을 생성해 작업하도록 한다.
This post is licensed under CC BY 4.0 by the author.