목차
요청을 하나씩 처리하면 서버가 막힌다
서버가 요청을 한 번에 하나씩만 처리한다고 하자. 요청 하나가 DB 조회로 200ms를 기다리는 동안, 뒤에 온 요청 100개는 전부 줄을 서서 기다린다.
앞 요청이 노는 시간(DB를 기다리는 시간) 동안 뒤 요청은 아무것도 못 한다. 사용자는 자기 요청과 상관없는 남의 대기까지 고스란히 떠안는다.
그래서 서버는 요청을 동시에 처리한다. 한 요청이 DB를 기다리는 동안 다른 요청을 진행시켜, 노는 시간을 겹친다.
동시성과 병렬성
“동시에”라는 말부터 정리하고 가자. 비슷해 보이는 두 단어가 다르다.
- 병렬성(parallelism) - 진짜로 같은 순간에 여러 개가 실행된다. CPU 코어가 여럿이라 물리적으로 동시에 돈다.
- 동시성(concurrency) - 여러 작업이 진행 중인 상태. 코어가 하나여도, 조금씩 번갈아 실행하면 여러 작업이 동시에 진행되는 것처럼 된다.
요점은 **동시성은 ‘동시에 실행’이 아니라 ‘동시에 진행’**이라는 것이다. 코어가 하나여도 동시성은 있다. 한 요청이 DB를 기다리는 동안 그 스레드를 놀리지 않고 다른 요청을 진행시키면, 둘이 겹쳐 진행된다. 이 글이 다루는 건 이 동시성 쪽이다.
서버는 스레드로 동시에 처리한다
동시에 진행되는 각 작업의 실행 단위가 스레드다.
Spring Boot 같은 서버는 요청이 오면 스레드 하나를 붙여 처리한다. 이걸 thread-per-request라고 한다. 요청 100개가 몰리면 스레드 여러 개가 각자 하나씩 맡아, 한 요청이 기다리는 동안 다른 스레드에서 다른 요청이 진행된다.
여기까진 좋다. 스레드를 여럿 두니 요청이 서로 막지 않는다. 그런데 바로 이 “스레드가 여럿”이라는 사실에서 이 글의 진짜 주제가 나온다.
문제는 공유 상태다
스레드가 각자 자기 것만 만지면 아무 문제 없다. 메서드 안의 지역 변수는 스레드마다 따로라, 서로 간섭할 일이 없다.
문제는 여러 스레드가 같은 것을 건드릴 때 생긴다. 같은 객체의 필드, 같은 정적 변수, 같은 캐시 - 이렇게 여러 스레드가 공유하는 상태를 동시에 읽고 쓰면 어긋나기 시작한다.
Race condition: 같이 건드리면 어긋난다
페이지 방문 수를 세는 공유 카운터를 보자.
public class VisitCounter {
private int count = 0;
public void hit() {
count++; // 방문마다 1 증가
}
}이 VisitCounter 하나를 여러 스레드가 같이 쓴다. 요청 100개가 동시에 hit()을 부르면 count가 100이 될 것 같다. 그런데 100보다 작게 나올 수 있다. 증가가 몇 번 사라진다.
두 스레드가 거의 동시에 count++를 하면 이런 일이 벌어진다.
43이어야 하는데 42다. 이렇게 타이밍에 따라 결과가 달라지는 것을 race condition(경쟁 상태)이라고 한다. 무서운 건 대부분의 경우엔 잘 되고, 아주 가끔만 어긋난다는 점이다. 그래서 테스트에선 안 잡히고 운영에서 터진다.
count++는 한 동작이 아니다
왜 이런 일이 생기나. count++가 한 줄이라 한 번에 처리되는 것 같지만, 속을 보면 세 단계다.
이 세 단계 사이에 다른 스레드가 끼어들 수 있다. A가 읽고(①) 아직 쓰기(③) 전인데 B가 읽으면, 둘 다 같은 옛 값을 본다. 그래서 각자 더한 결과가 서로를 덮어써 하나가 사라진다.
한 줄처럼 보이는 연산이 원자적(더 쪼갤 수 없는 하나)이 아니라서 생기는 일이다. 쪼개질 수 있으면, 그 틈으로 끼어들 수 있다.
막는 법: 하나씩만 들어가게
해법의 핵심은 이 세 단계가 쪼개지지 않게 만드는 것이다. 그 구간에 한 번에 한 스레드만 들어가게 한다.
가장 기본은 synchronized다.
public synchronized void hit() {
count++; // 한 번에 한 스레드만 이 메서드에 들어온다
}A가 안에 있는 동안 B는 문 앞에서 기다린다. A가 나오면 B가 들어간다. 읽기-쓰기가 겹치지 않으니 증가가 사라지지 않는다.
카운터 하나처럼 단순한 경우엔 더 가벼운 길이 있다. AtomicInteger다.
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·불변·지역변수. 락은 마지막 수단 |
동시에 처리하는 건 공짜로 얻는 성능이 아니다. 여럿이 같은 것을 건드리는 순간 조용히 어긋나기 때문에, 무엇을 공유하고 어떻게 지킬지를 정하는 일이 따라온다. 그게 동시성을 다룬다는 것이다.