← 목록으로

스레드 관점으로 보는 동기/비동기와 블로킹/논블로킹

devjavacsharp
muho
2025. 10. 13.므호

안녕하세요. 므호입니다.

많은 개발자들이 동기/비동기를 단순히 '코드 실행 순서'의 문제로만 생각하곤 합니다. 저 역시 그랬어요.

하지만 이 개념들의 진짜 힘은 스레드를 얼마나 효율적으로 사용하는지에 달려있습니다. 잘못 사용하면 서버의 성능을 크게 저하 시킬 수 있죠.

이 글에서는 이 네 가지 개념이 스레드 관점에서 어떻게 서로 맞물려 동작하는지 파헤쳐 보겠습니다.

정의 알아보기

동기와 비동기란?

동기와 비동기는 작업의 완료를 기다리는지에 대한 관점입니다.

  • 동기(Synchronous): 호출한 작업에 대해 완료를 기다리는 방식입니다. 즉 호출자는 작업이 끝날 때까지 대기하고, 결과를 받은 후에야 다음 작업을 진행합니다.
  • 비동기(Asynchronous): 호출한 작업에 대해 완료를 기다리지 않는 방식입니다. 즉 호출자는 작업을 요청한 후 즉시 다른 작업을 진행할 수 있으며, 나중에 콜백이나 별도의 방법을 통해 결과를 받을 수 있습니다.

블로킹과 논블로킹이란?

블로킹과 논블로킹은 호출된 함수가 제어권을 언제 반환하는지에 대한 관점입니다.

  • 블로킹(Blocking): 호출된 함수가 자신의 작업을 모두 마칠 때까지 제어권을 반환하지 않는 것입니다. 이 시간 동안 호출한 스레드는 말 그대로 멈춘 상태(Blocked)에 빠집니다.
  • 논블로킹(Non-Blocking): 호출된 함수가 작업을 완료하지 않았더라도 제어권을 즉시 반환하는 것입니다. 덕분에 호출한 스레드는 멈추지 않고 다른 작업을 계속할 수 있습니다.

동기/비동기와 블로킹/논블로킹 조합

각 케이스별로 전체적인 흐름을 보면 조금 더 이해하기 쉬울거예요.

일반적으로 많이 쓰이는 동기 + 블로킹, 비동기 + 논블로킹에 대해 알아보겠습니다.

1. 동기 + 블로킹

이는 가장 고전적이고 직관적인 모델입니다. (Java, Spring MVC의 기본 동작 방식)

동작 방식

  1. 사용자의 요청이 들어오면 웹 서버(Tomcat 등)의 스레드 풀에서 스레드 A를 꺼내 할당합니다.
  2. 스레드 A는 요청 처리의 모든 과정을 처음부터 끝까지 책임집니다.
  3. 파라미터, 리퀘스트 바디 파싱 등을 처리하여 컨트롤러 메서드에 진입합니다.
  4. 비즈니스 서비스 메서드 작업을 순차적으로 진행합니다.
  5. DB 조회 작업을 시작합니다. 이때 DB 조회는 대표적인 I/O 작업으로, 스레드 A는 DB로부터 응답이 올 때까지 꼼짝없이 Blocked 상태에 빠져 대기합니다. (이것이 블로킹입니다. 스레드 A는 다른 일을 전혀 하지 못하고 자원만 차지한 채 기다립니다. 즉, 스레드 풀에 반환되지 않고 계속 대기 중입니다.)
  6. DB 조회 작업이 완료되어 결과가 반환되면 블로킹이 풀리고 스레드 A는 그 이후 작업을 처리합니다.
  7. 모든 작업이 완료되면 응답을 보내고, 스레드 A는 비로소 스레드 풀에 반납됩니다.

장단점

  • 장점: 코드가 위에서 아래로 순서대로 실행되므로 흐름이 직관적이고 디버깅이 편리합니다.
  • 단점: I/O 작업 동안 대기하는 스레드가 많아져 스레드 풀이 금방 고갈될 수 있습니다. 결국 사용 가능한 스레드가 없어지면 서버는 더 이상 새로운 요청을 처리하지 못하는 '먹통' 상태가 될 수 있습니다.

예시 코드

Java + Spring MVC 예시 코드입니다.

UserController.java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
 
    private final UserService userService;
 
    @GetMapping
    public ResponseEntity<UserResponse> getUsers() {
        // 스레드 A가 할당받아 작업을 처리합니다.
        UserResonse response = userService.getUsers();
        // 10번 줄의 작업이 완료된 이후에 12번 줄의 작업이 진행됩니다. 이것이 "동기"입니다.
        return ResponseEntity.ok(response);
    }
}
UserService.java
@Service
@RequiredArgsConstructor
public class UserService {
 
    private final UserRepository userRepository;
 
    public UserResponse getUsers() {
        // 10번 줄에서 DB I/O 작업이 시작되고 I/O 작업이 완료될 때까지
        // 스레드 A는 Blocked 상태로 변경되어 대기합니다. 이것이 "블로킹"입니다.
        List<User> users = userRepository.findAll();
        return UserResponse.from(users);
    }
}

2. 비동기 + 논블로킹

현대적인 애플리케이션이 지향하는 고성능 모델입니다. (C#, ASP.NET Core 또는 Java, WebFlux)

동작 방식

여기서는 ASP.NET Core의 async/await 키워드를 중심으로 설명하겠습니다.

  1. 사용자의 요청이 들어오면 스레드 풀에서 스레드 A를 꺼내 할당합니다.
  2. 비동기(async) 메서드 내에서 I/O 작업을 await 키워드로 호출합니다.
  3. 이 순간, 실제 I/O 작업은 백그라운드(운영체제)에 위임되고, 스레드 A는 결과를 기다리며 멈추는 대신 즉시 스레드 풀로 반납됩니다. (이것이 논블로킹입니다.) 스레드 A는 이제 완전히 다른 요청을 처리하러 갈 수 있습니다.
  4. 얼마 후 I/O 작업이 완료되면 스레드 풀에서 쉬고 있는 아무 스레드(이전에 요청을 처리하던 스레드 A일 수도 있고, 스레드 B일 수도 있음)를 꺼내 await 이후의 나머지 작업을 실행하도록 할당합니다.

장단점

  • 장점: 스레드가 I/O 작업 때문에 노는 일이 없습니다. 적은 수의 스레드로 수많은 동시 요청을 효율적으로 처리할 수 있어 자원 효율성과 서버 전체의 처리량이 극대화됩니다.
  • 단점: 동키 코드에 비해 코드의 실행 흐름을 추적하기가 조금 더 복잡할 수 있습니다. (하지만 async/await 덕분에 동기 코드와 매우 유사하게 작성할 수 있습니다.)

예시 코드

C# + ASP.NET Core 예시 코드입니다. await 키워드를 언제 사용하냐에 따라 2가지의 케이스가 존재합니다.

UsersController.cs
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IUserService _userService;
 
    public UsersController(IUserService userService)
    {
        _userService = userService;
    }
 
    [HttpGet]
    public async Task<ActionResult<UserResponse>> GetUsersAsync()
    {
        // 스레드 A가 할당받아 작업을 처리합니다.
        UserResponse response = await _userService.GetUsersAsync();
        /*
        _userService.GetUsersAsync는 비동기입니다.
        작업의 완료를 기다리지 않고 즉시 Task를 반환하기 때문입니다.
        하지만 await 키워드가 동시에 사용되었기 때문에
        16번 줄의 작업이 완료된 이후에 22번 줄의 작업이 진행됩니다.
        하지만 이것은 완전히 "동기"라고 표현하기보다는 동기적인 코드 흐름이라고 표현할 수 있습니다.
        여전히 내부적으로는 완전히 비동기/논블로킹이기 때문입니다.
        */
        return Ok(response);
    }
}
UserService.cs - await를 바로 사용하는 경우
public class UserService : IUserService
{
    private readonly IUserRepository _userRepository;
 
    public UserService(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }
 
    public async Task<UserResponse> GetUsersAsync()
    {
        // DB 조회 요청을 시작함과 동시에 await 키워드를 만나
        // 스레드 A는 대기하지 않고 즉시 스레드 풀에 반납됩니다. 이것이 "논블로킹"입니다.
        List<User> users = await _userRepository.GetAllUsersAsync();
        // DB 조회 작업이 완료되면 스레드 풀에서 새로운 스레드를 할당받아 이후 작업을 진행합니다.
        return UserResponse.From(users);
    }
}
UserService.cs - await를 따로 사용하는 경우
public class UserService : IUserService
{
    private readonly IUserRepository _userRepository;
 
    public UserService(IUserRepository userRepository)
    {
        _userRepository = userRepository;
    }
 
    public async Task<UserResponse> GetUsersAsync()
    {
        /*
        DB 조회 요청을 보낸 뒤 I/O 작업이 완료되면 결과를 반환받을 것을 약속(Task) 받습니다.
        이때 작업이 완료되는 것을 기다리지 않고 바로 다음 PerformSomeCpuIntensiveWork()을 진행합니다.
        이것이 "비동기"입니다.
        */
        Task<List<User>> usersTask = _userRepository.GetAllUsersAsync();
 
        PerformSomeCpuIntensiveWork(); // CPU 집약 작업
 
        /*
        await 키워드를 만나는 순간 아직 DB I/O 작업이 완료되지 않았다면,
        스레드 A는 Blocked 상태로 변경되지 않고 즉시 스레드 풀에 반납됩니다.
        이것이 "논블로킹"입니다.
        하지만 만약 DB I/O 작업이 완료된 상태라면,
        스레드 A는 스레드 풀에 반환되지 않고 이어서 다음 작업을 진행합니다.
        이 경우도 마찬가지로 "논블로킹"입니다.
        */
        List<User> users = await usersTask;
        return UserResponse.From(users);
    }
}

마무리

지금까지 동기/비동기와 블로킹/논블로킹의 개념과 동작 방식을 스레드 관점에서 살펴보았습니다.

한 문장으로 요약하자면 다음과 같습니다.

동기/블로킹"내 스레드가 직접 멈춰서 기다리는" 방식이고, 비동기/논블로킹"작업은 다른 곳에 맡기고 내 스레드는 계속 일하다가 나중에 결과를 통보받는" 방식입니다.

'이 중 무엇이 절대적으로 좋으냐?'는 질문은 무의미합니다. 애플리케이션의 특성에 따라 올바른 모델을 선택하는 것이 중요합니다.

  • I/O Bound 작업이 대부분인 웹 애플리케이션: 대부분의 웹 서버는 DB 조회, 외부 API 호출 등 I/O 작업에 대부분의 시간을 사용합니다. 이런 환경에서는 비동기/논블로킹 모델을 사용하여 스레드가 대기하는 시간을 최소화하고 서버의 처리량을 늘리는 것이 거의 표준처럼 여겨집니다. 이는 곧 더 적은 서버 자원으로 더 많은 사용자를 응대할 수 있음을 의미합니다.
  • CPU Bound 작업이나 간단한 스크립트: 복잡한 계산이나 암호화 작업처럼 CPU를 계속 사용하는 작업 혹은 순차 실행이 매우 중요한 짧은 스크립트의 경우, 동기/블로킹 모델이 코드를 더 단순하고 예측 가능하게 만들어 유지보수에 유리할 수 있습니다.

결국 이 개념들을 이해하는 것은 단순히 기술 면접을 통과하기 위함이 아닙니다. 한정된 서버 자원을 최대한 효율적으로 활용하여 더 빠르고 안정적인 서비스를 만들기 위한 개발자의 필수 역량입니다.

여러분의 코드에서 스레드는 지금 '기다리고' 있나요, 아니면 '일하고' 있나요?