새소식

Welcome to the tech blog of Junior Backend Developer Seulbin Kang!

Backend

(BE 개발자 3개월차) 현업에서 고민한 동시성 관리

  • -

👀 들어가기 전

2025년 12월 초, 진행하고 있던 프로젝트를 정리하고, 회사에서도 처음으로 진행하던 프로젝트의 MVP 모델을 마무리 짓고 기존 코드를 튜닝해 나가던 중 의문이 들기 시작했다.

 

Transaction을 처리하고 스레드 풀을 정리하던 중 “동시에 딱 요청이 들어왔다”면 정확히 누가 먼저 실행할까?라는 의문이 들기 시작했다. 물론, 동시성 처리를 공부를 안 했던 것도 아니다.

동시에 요청이 들어왔을 때, 나는 보통 이렇게 이해하고 있었다.

  • “동시에 요청이 들어오면, 먼저 스레드풀을 잡은 친구가 먼저 처리된다

그런데 여기서 의문이 생겼다.

  • 그 “먼저”는 누가 결정하지?
  • 진짜로 0ms 차이도 없이 동시에 들어오면 순서는 어떻게 정해지지?
  • 사람이 제어 가능한 영역일까?

이 글은 그 질문 하나에서 시작한다.
그리고 결론부터 말하면…

“진짜 동시에(0ms) 들어오는 요청”은 현실적으로 존재하기 어렵고,
설령 거의 동시에 들어와도 순서는 ‘한 명’이 결정하지 않는다.
OS, 런타임, 웹서버, DB가 각자의 규칙으로 순서가 결정된다.

 


👀 본론

1) 동시에 딱(0ms)은 정말 환상일까?

우선, 네트워크 단으로 들어가서 요청이 들어오고 나가는 것을 살펴보면 아래와 같다.

- 네트워크 패킷은 NIC로 들어오고

- 커널은 이를  처리해서

- 소켓 버퍼에 쌓고, accept/read 타이밍에 따라

- 애플리케이션에 전달된다.

즉, 물리적으로도 완벽한 동시 도착은 불가하고 보통은 수 ms 단위의 미세한 차이를 가지거나, 큐잉(버퍼/큐)에 의한 재정렬을 하거나 혹은 스케줄링에 의해 실행 순서가 결정된다.

 

위 같은 요소 때문에 진짜 동시에 딱(0ms)은 불가한 것이다.

 

2) 그럼 누가 먼저 실행할지는 누가 정할까?

요청의 순서를 결정하는 주체는 다양하다.

애초에 요청이 들어와서 Response를 주기까지 거쳐가는 애들(?)이 다양하기 때문에 각 구간마다 다른 것이다.

 

(1) OS 커널 : "누가 먼저 CPU를 사용할 것인가?"

  • 커널 스케줄러가 어떤 스레드를 먼저 실행할지를 결정한다.
  • 동시에 준비된 스레드들은 커널 정책(?)에 따라 섞인다.
  • 같은 우선순위라도 항상 FIFO보장인 것은 아니다. (이래서 컴구와 컴네가 중요한 거였구나...)

(2) 웹서버 (톰캣) : "어떤 요청을 워커로 넘길 것인가?"

  • 톰캣은 요청을 받아서 스레드풀에 태운다.
  • 이때, "큐에 먼저 들어간 요청"이 먼저 처리되는 것처럼 보인다. 
  • 그러나, 큐잉 타이밍 / 워커 스레드의 가용 상태 / OS스케줄링 때문에 실제 실행은 다를 수가 있다. (처음 알았다.,..)

(3) JVM : "스레드가 있어도, 지금 당장 실행 ㄱ?"

  • 자바 스레드도 결국 OS스레드와 연결된다. (대부분 1:1, 간혹 1:N 존재) 
  • JVM이 만든 스레드가 Runnable상태가 되어도 실제 CPU를 쥐고 있는 것은 결국엔 OS이다.

(4) DB : "락/격리 수준/MVCC 기준으로 누굴 먼저 커밋시킬래?"

  • 트랜잭션이 붙는 순간부터는 DB가 순서를 결정할 수 있다.
  • 누가 먼저 들어왔건, 락 대기, next-key / gap lock, 데드락 감지 후 롤백, MVCC 스냅숏 같은 것들로 얼마든 순서를 바꿀 수 있다.

3) 스레드 풀을 먼저 잡은 애가 그럼 항상 먼저 실행될 수 있을까?

“스레드풀을 먼저 할당받았다”는 건 “실행될 준비가 먼저 됐다”에 가깝고, “CPU에서 먼저 돌았다”와는 다르다.

 

아래 코드는 Spring MVC 기준으로 들어온 요청마다 스레드와 시간을 찍는다.

package com.exp.EXPController;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class RaceController {

    @GetMapping("/race")
    public String race(@RequestParam String id){
        Long now = System.nanoTime();
        String thread = Thread.currentThread().getName();

        busyWork(200000);

        return "id = "+id+", thread = "+thread+", nano="+now+"\n";
    }

    private void busyWork(int n){
        Long x=0L;

        for(int i=0;i<n;i++){
            x+=i;
        }

    }
}

 

 

위 코드를 실제 실행시켜 보면, 아래와 같은 결과가 나오는 것을 알 수 있다.

실행순서가 B,A가 되기도 A,B가 되기도 한다.

즉, 먼저 스레드를 잡았다 = 먼저 실행인 것이 아니라, 먼저 스레드를 잡았다 = runnable 상태가 되었을 가능성이 크다 인 것이다.

 

그리고, 이렇게 어떤 순서로 들어오든, 들어온 요청은 DB에 따라서 나가는 순서도 다르게 결정된다.

락 획득을 먼저 한 트랜잭션이 먼저 실행되게 되고, 나중에 온 트랜잭션은 당연히 대기하게 된다.

즉, 애플리케이션 레벨의 '스레드 순서'가 문제가 아니라, DB락이 결국 우선순위를 만든다.

 

 

4) 그게 그거 아님? 스레드 순서 = DB락 순서 아닌가?

락을 잡는 순서는 "스레드"가 아니라 "트랜잭션"이 잡는다.
그리고 그 트랜잭션은 어플리케이션 스레드가 JDBC 커넥션을 통해, DB 서버에 쿼리를 보냈을 때,
DB 서버 내부 스레드에서 실행이 된다.

 

아래와 같은 경우를 생각해 보면 이해가 쉬울 것 같다.

1. APP Thread A가 먼저 실행.

2. 근데, 커넥션 풀에서 커넥션을 못 얻고 잠시 대기

3. 그 사이에 Thread B가 커넥션을 얻어서 쿼리 전송

4. DB 서버에서는 B가 먼저 도착 

글로 하나하나 적어본,,트랜잭션,,,,

 


 

👀 결론

이런 현실 때문에, 실무에서는 아래와 같은 선택지들을 상황에 맞게 사용한다.

  • 원자적 UPDATE
  • 비관 락 (Pessimistic Lock)
  • 낙관 락 (Optimistic Lock)
  • 유니크 제약 + 예외 처리
  • Idempotency Key
  • Outbox / Saga
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.