← Posts

Concurrency · Thread · Java

Concurrency

서버는 요청을 동시에 처리해야 한다. 그런데 여러 스레드가 같은 것을 건드리면 조용히 어긋난다 - 경쟁 상태와 그걸 막는 법.

목차
  1. 요청을 하나씩 처리하면 서버가 막힌다
  2. 동시성과 병렬성
  3. 서버는 스레드로 동시에 처리한다
  4. 문제는 공유 상태다
  5. Race condition: 같이 건드리면 어긋난다
  6. count++는 한 동작이 아니다
  7. 막는 법: 하나씩만 들어가게
  8. 락은 공짜가 아니다
  9. 실무: 공유하지 않는 게 최선
  10. 정리

요청을 하나씩 처리하면 서버가 막힌다

서버가 요청을 한 번에 하나씩만 처리한다고 하자. 요청 하나가 DB 조회로 200ms를 기다리는 동안, 뒤에 온 요청 100개는 전부 줄을 서서 기다린다.

앞 요청이 노는 시간(DB를 기다리는 시간) 동안 뒤 요청은 아무것도 못 한다. 사용자는 자기 요청과 상관없는 남의 대기까지 고스란히 떠안는다.

그래서 서버는 요청을 동시에 처리한다. 한 요청이 DB를 기다리는 동안 다른 요청을 진행시켜, 노는 시간을 겹친다.

동시성과 병렬성

“동시에”라는 말부터 정리하고 가자. 비슷해 보이는 두 단어가 다르다.

  • 병렬성(parallelism) - 진짜로 같은 순간에 여러 개가 실행된다. CPU 코어가 여럿이라 물리적으로 동시에 돈다.
  • 동시성(concurrency) - 여러 작업이 진행 중인 상태. 코어가 하나여도, 조금씩 번갈아 실행하면 여러 작업이 동시에 진행되는 것처럼 된다.
diagramdiagram

요점은 **동시성은 ‘동시에 실행’이 아니라 ‘동시에 진행’**이라는 것이다. 코어가 하나여도 동시성은 있다. 한 요청이 DB를 기다리는 동안 그 스레드를 놀리지 않고 다른 요청을 진행시키면, 둘이 겹쳐 진행된다. 이 글이 다루는 건 이 동시성 쪽이다.

서버는 스레드로 동시에 처리한다

동시에 진행되는 각 작업의 실행 단위가 스레드다.

Spring Boot 같은 서버는 요청이 오면 스레드 하나를 붙여 처리한다. 이걸 thread-per-request라고 한다. 요청 100개가 몰리면 스레드 여러 개가 각자 하나씩 맡아, 한 요청이 기다리는 동안 다른 스레드에서 다른 요청이 진행된다.

여기까진 좋다. 스레드를 여럿 두니 요청이 서로 막지 않는다. 그런데 바로 이 “스레드가 여럿”이라는 사실에서 이 글의 진짜 주제가 나온다.

문제는 공유 상태다

스레드가 각자 자기 것만 만지면 아무 문제 없다. 메서드 안의 지역 변수는 스레드마다 따로라, 서로 간섭할 일이 없다.

문제는 여러 스레드가 같은 것을 건드릴 때 생긴다. 같은 객체의 필드, 같은 정적 변수, 같은 캐시 - 이렇게 여러 스레드가 공유하는 상태를 동시에 읽고 쓰면 어긋나기 시작한다.

Race condition: 같이 건드리면 어긋난다

페이지 방문 수를 세는 공유 카운터를 보자.

java
public class VisitCounter {
    private int count = 0;

    public void hit() {
        count++;     // 방문마다 1 증가
    }
}

VisitCounter 하나를 여러 스레드가 같이 쓴다. 요청 100개가 동시에 hit()을 부르면 count가 100이 될 것 같다. 그런데 100보다 작게 나올 수 있다. 증가가 몇 번 사라진다.

두 스레드가 거의 동시에 count++를 하면 이런 일이 벌어진다.

diagramdiagram

43이어야 하는데 42다. 이렇게 타이밍에 따라 결과가 달라지는 것을 race condition(경쟁 상태)이라고 한다. 무서운 건 대부분의 경우엔 잘 되고, 아주 가끔만 어긋난다는 점이다. 그래서 테스트에선 안 잡히고 운영에서 터진다.

count++는 한 동작이 아니다

왜 이런 일이 생기나. count++가 한 줄이라 한 번에 처리되는 것 같지만, 속을 보면 세 단계다.

diagramdiagram

이 세 단계 사이에 다른 스레드가 끼어들 수 있다. A가 읽고(①) 아직 쓰기(③) 전인데 B가 읽으면, 둘 다 같은 옛 값을 본다. 그래서 각자 더한 결과가 서로를 덮어써 하나가 사라진다.

한 줄처럼 보이는 연산이 원자적(더 쪼갤 수 없는 하나)이 아니라서 생기는 일이다. 쪼개질 수 있으면, 그 틈으로 끼어들 수 있다.

막는 법: 하나씩만 들어가게

해법의 핵심은 이 세 단계가 쪼개지지 않게 만드는 것이다. 그 구간에 한 번에 한 스레드만 들어가게 한다.

가장 기본은 synchronized다.

java
public synchronized void hit() {
    count++;     // 한 번에 한 스레드만 이 메서드에 들어온다
}

A가 안에 있는 동안 B는 문 앞에서 기다린다. A가 나오면 B가 들어간다. 읽기-쓰기가 겹치지 않으니 증가가 사라지지 않는다.

카운터 하나처럼 단순한 경우엔 더 가벼운 길이 있다. AtomicInteger다.

java
private final AtomicInteger count = new AtomicInteger();

public void hit() {
    count.incrementAndGet();   // 증가 자체가 원자적
}

incrementAndGet()이 읽기-더하기-쓰기를 하나의 원자적 연산으로 처리한다. 문을 잠그지 않고도 안전하다.

참고

이건 앱(프로세스) 안에서 스레드끼리 벌어지는 이야기다. 여러 서버나 DB에 걸친 동시성(트랜잭션·격리·DB 락)은 층이 다르고, 그건 DB가 따로 다룬다. 여기서 말하는 락은 한 서버 안 스레드들 사이의 것이다.

락은 공짜가 아니다

그럼 불안하니 다 synchronized로 감싸면 되나. 안 된다. 락은 대가가 있다.

  • 직렬화. 잠근 구간은 한 번에 한 스레드만 지난다. 너무 넓게 잠그면 스레드들이 죄다 문 앞에 줄을 선다. 동시에 처리하려고 스레드를 늘려놓고, 락으로 도로 한 줄로 만드는 셈이다. 동시성이 사라진다.
  • 데드락. 두 스레드가 서로가 쥔 락을 기다리면 둘 다 영영 멈춘다. A가 락1을 쥔 채 락2를 기다리고, B가 락2를 쥔 채 락1을 기다리는 식이다. 락을 잡는 순서를 정해두면 이건 안 생긴다.

그래서 락은 필요한 만큼만, 좁게 건다. 감싸는 구간이 넓을수록 안전해 보이지만, 그만큼 동시성을 반납하는 것이다.

실무: 공유하지 않는 게 최선

동시성 버그를 가장 확실히 없애는 방법은 락을 잘 거는 게 아니라 애초에 공유를 안 하는 것이다. 건드릴 공유 상태가 없으면 경쟁도 없다.

  • 상태를 안 두는(stateless) 설계. Spring의 @Service 같은 컴포넌트는 요청 사이에 바뀌는 값을 필드에 담지 않는다. 그러면 여러 스레드가 같은 빈을 써도 서로 건드릴 게 없다.
  • 불변 객체. 한 번 만들면 안 바뀌는 객체는 여러 스레드가 동시에 읽어도 안전하다.
  • 지역 변수. 메서드 안 지역 변수는 스레드마다 따로다. 공유되지 않는다.
  • 공유가 불가피하면 AtomicInteger·ConcurrentHashMap처럼 이미 안전하게 만들어진 도구를 쓰고, 진짜 공유 자원(계좌 잔액 같은 것)은 DB의 트랜잭션·락에 맡긴다.

그래서 실무의 순서는 이렇다. 공유를 없앤다 → 안 되면 안전한 도구 → 그래도 안 되면 락. 락이 첫 수단이 아니라 마지막 수단이다.

한 가지 더. 지금까지 “서버가 스레드로 동시에 처리한다”고 했는데, 그 스레드를 요청이 올 때마다 새로 만들면 그것대로 문제가 된다. 스레드를 어떻게 만들고 재사용하는지가 다음 이야기, Thread Pool이다.

정리

왜 동시성요청을 하나씩 처리하면 서로 막는다. 노는 시간을 겹치려고 동시에 처리
동시성 vs 병렬성동시성=동시에 진행, 병렬성=동시에 실행. 코어 하나여도 동시성은 있다
실행 단위스레드. 서버는 요청마다 스레드를 붙인다(thread-per-request)
문제여러 스레드가 공유 상태를 동시에 읽고 쓰면 어긋난다 (race condition)
원인count++도 읽기-더하기-쓰기 3단계라 원자적이지 않다. 그 틈에 끼어든다
막는 법한 번에 하나만 들어가게 - synchronized·Atomic
락의 대가너무 넓게 잠그면 직렬화로 동시성이 사라진다. 데드락도 조심
최선공유를 안 하는 것 - stateless·불변·지역변수. 락은 마지막 수단

동시에 처리하는 건 공짜로 얻는 성능이 아니다. 여럿이 같은 것을 건드리는 순간 조용히 어긋나기 때문에, 무엇을 공유하고 어떻게 지킬지를 정하는 일이 따라온다. 그게 동시성을 다룬다는 것이다.

관련 글