동기(Synchronous), 비동기(Asynchronous), 블로킹(Blocking), 논블로킹(Non-blocking)은 I/O와 동시성 프로그래밍에서 자주 혼용되는 개념이다. 이 네 가지는 서로 독립된 두 축이며, 조합에 따라 4가지 모드가 만들어진다.


핵심 개념 정의

동기(Synchronous) vs 비동기(Asynchronous)

동기비동기결과를 어떻게 받는가에 관한 개념이다.

  동기 비동기
결과 수신 호출자가 직접 기다려 결과를 받음 결과를 나중에 콜백/이벤트/Future로 받음
제어 흐름 결과가 올 때까지 다음 코드 실행 안 함 결과와 무관하게 다음 코드 바로 실행
주체 호출자가 완료를 직접 확인 시스템/런타임이 완료를 알려줌

블로킹(Blocking) vs 논블로킹(Non-blocking)

블로킹논블로킹I/O 대기 중 스레드가 어떻게 동작하는가에 관한 개념이다.

  블로킹 논블로킹
I/O 대기 중 스레드가 멈춰서 대기 스레드가 즉시 반환되어 다른 작업 가능
커널 관점 데이터 준비 전까지 시스템 콜 반환 안 함 데이터 미준비 시 즉시 EAGAIN/EWOULDBLOCK 반환
스레드 활용 대기 중 CPU 낭비 대기 중 다른 작업 가능

4가지 조합

1. 동기 + 블로킹 (Synchronous Blocking)

가장 일반적인 방식. 결과를 기다리는 동안 스레드가 멈춘다.

호출자 스레드:  ──────[요청]──[대기 중...]──[결과 수신]──[다음 작업]──→
                               ↑ 스레드 블로킹
// Java - 전통적인 블로킹 I/O
try (Socket socket = new Socket("example.com", 80)) {
    InputStream in = socket.getInputStream();
    byte[] buffer = new byte[1024];
    int bytesRead = in.read(buffer); // 데이터가 올 때까지 블로킹
    System.out.println("받은 바이트: " + bytesRead);
}

// JDBC 쿼리
Connection conn = DriverManager.getConnection(url, user, password);
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
ps.setLong(1, 1L);
ResultSet rs = ps.executeQuery(); // 쿼리 완료까지 블로킹

특징

  • 코드가 단순하고 직관적
  • 스레드당 하나의 요청만 처리 가능
  • 대용량 동시 처리 시 스레드 폭발 문제

사용 사례: 전통적인 Spring MVC, JDBC, 일반 파일 I/O


2. 동기 + 논블로킹 (Synchronous Non-blocking)

I/O 시스템 콜은 즉시 반환하지만, 호출자가 직접 반복 폴링(polling)해서 결과를 확인한다.

호출자 스레드:  ──[요청]──[폴링]──[폴링]──[폴링]──[결과!]──[다음 작업]──→
                            ↑EAGAIN  ↑EAGAIN  ↑완료
// Java NIO - 논블로킹 소켓
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false); // 논블로킹 모드 설정
channel.connect(new InetSocketAddress("example.com", 80));

// 연결 완료까지 직접 폴링
while (!channel.finishConnect()) {
    // 연결 대기 중 다른 작업 가능하지만, 여기선 spin-wait
    Thread.onSpinWait();
}

ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead;
while ((bytesRead = channel.read(buffer)) == 0) {
    // 데이터 없으면 0 반환 → 폴링 반복
    Thread.onSpinWait();
}

특징

  • I/O 대기 중 스레드가 반환되지만, 폴링으로 CPU를 계속 사용
  • 단독으로 쓰이기보다 Selector와 결합해서 사용
  • CPU 낭비(busy-waiting) 문제 있음

사용 사례: NIO Selector 내부 동작, 게임 루프에서의 I/O 처리


3. 비동기 + 블로킹 (Asynchronous Blocking)

드문 조합. 비동기로 요청하지만 결과를 받을 때 블로킹한다.

호출자 스레드:  ──[요청 발행]──[Future.get() 블로킹]──[결과]──[다음 작업]──→
                                      ↑ 블로킹
// Java Future - 비동기 요청 후 블로킹 대기
ExecutorService executor = Executors.newCachedThreadPool();

Future<String> future = executor.submit(() -> {
    // 별도 스레드에서 실행 (비동기)
    Thread.sleep(1000);
    return "결과";
});

// 결과를 기다리며 블로킹 → 비동기의 이점이 반감됨
String result = future.get(); // 블로킹!
System.out.println(result);

특징

  • 비동기로 시작했지만 결과 수집 시 블로킹
  • Future.get() 패턴이 대표적
  • 실질적인 이점이 없어 안티패턴으로 간주됨

사용 사례: 여러 Future를 병렬 실행 후 한꺼번에 join하는 경우에는 의미 있음

// 의미 있는 경우: 여러 작업 병렬 실행
Future<String> f1 = executor.submit(() -> callApi1());
Future<String> f2 = executor.submit(() -> callApi2());
Future<String> f3 = executor.submit(() -> callApi3());

// 각각 독립 실행 후 결과 수집
String r1 = f1.get();
String r2 = f2.get();
String r3 = f3.get();

4. 비동기 + 논블로킹 (Asynchronous Non-blocking)

가장 효율적인 조합. 요청 후 즉시 반환되고, 완료 시 콜백/이벤트로 통보된다.

호출자 스레드:  ──[요청 발행]──[즉시 반환]──[다른 작업]──[다른 작업]──→
                                                              ↑
                                                  [완료 이벤트 수신 → 콜백 실행]
// Java NIO2 (AsynchronousSocketChannel)
AsynchronousSocketChannel channel = AsynchronousSocketChannel.open();

channel.connect(new InetSocketAddress("example.com", 80), null,
    new CompletionHandler<Void, Void>() {
        @Override
        public void completed(Void result, Void attachment) {
            // 연결 완료 콜백 (다른 스레드에서 실행)
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            channel.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
                @Override
                public void completed(Integer bytesRead, ByteBuffer buf) {
                    // 읽기 완료 콜백
                    System.out.println("받은 바이트: " + bytesRead);
                }
                @Override
                public void failed(Throwable exc, ByteBuffer buf) {
                    exc.printStackTrace();
                }
            });
        }
        @Override
        public void failed(Throwable exc, Void attachment) {
            exc.printStackTrace();
        }
    }
);

// 연결 대기 없이 즉시 다음 코드 실행
System.out.println("연결 요청 발행 완료, 다른 작업 진행");

CompletableFuture (콜백 지옥 개선)

CompletableFuture.supplyAsync(() -> callExternalApi())
    .thenApply(response -> parseResponse(response))
    .thenCompose(data -> saveToDatabase(data))
    .thenAccept(saved -> log.info("저장 완료: {}", saved))
    .exceptionally(ex -> {
        log.error("처리 실패", ex);
        return null;
    });

Project Reactor (WebFlux)

webClient.get()
    .uri("/api/data")
    .retrieve()
    .bodyToMono(Data.class)
    .map(data -> transform(data))
    .flatMap(data -> saveReactive(data))
    .subscribe(
        result -> log.info("완료: {}", result),
        error -> log.error("실패", error)
    );

특징

  • 스레드를 블로킹하지 않아 리소스 효율 최대
  • 코드 복잡도가 높음(콜백, 리액티브 체인)
  • 대용량 동시 I/O에 최적

사용 사례: Spring WebFlux, Node.js, Netty, Redis Lettuce


Unix I/O 모델 5가지

Unix/Linux 시스템에서 I/O는 5가지 모델로 분류된다. (W. Richard Stevens, “Unix Network Programming” 기준)

1. Blocking I/O

Application                    Kernel
    │──── recvfrom() 호출 ────→│
    │                          │ 데이터 없음
    │◄──────── 대기 ──────────►│ ...
    │                          │ 데이터 도착
    │                          │ 커널 버퍼 → 유저 버퍼 복사
    │◄──── OK, 데이터 반환 ────│
    │ (애플리케이션 재개)

2. Non-blocking I/O

Application                    Kernel
    │──── recvfrom() ─────────→│ 데이터 없음
    │◄──── EAGAIN 즉시 반환 ───│
    │ (폴링 반복)
    │──── recvfrom() ─────────→│ 데이터 없음
    │◄──── EAGAIN 즉시 반환 ───│
    │──── recvfrom() ─────────→│ 데이터 도착! 복사 중
    │◄──── OK, 데이터 반환 ────│

3. I/O Multiplexing (select/poll/epoll)

하나의 스레드로 여러 소켓을 감시할 수 있다. Java NIO Selector가 이 방식이다.

Application                    Kernel
    │──── select() ───────────→│ 여러 소켓 감시 시작
    │◄──── 블로킹 ────────────►│ 감시 중...
    │                          │ 소켓 중 하나 준비됨
    │◄──── 준비된 소켓 반환 ───│
    │──── recvfrom() ─────────→│
    │◄──── 데이터 반환 ────────│

epoll (Linux 고성능 방식)

select/poll: O(n) — 감시 중인 모든 fd를 순회
epoll:       O(1) — 준비된 fd만 반환

Java NIO Selector는 내부적으로 epoll(Linux), kqueue(macOS)를 사용한다.

Selector selector = Selector.open();
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
    int readyCount = selector.select(); // 준비된 채널이 생길 때까지 블로킹
    if (readyCount == 0) continue;

    Set<SelectionKey> selectedKeys = selector.selectedKeys();
    Iterator<SelectionKey> iter = selectedKeys.iterator();

    while (iter.hasNext()) {
        SelectionKey key = iter.next();
        iter.remove();

        if (key.isAcceptable()) {
            SocketChannel client = serverChannel.accept();
            client.configureBlocking(false);
            client.register(selector, SelectionKey.OP_READ);
        } else if (key.isReadable()) {
            SocketChannel client = (SocketChannel) key.channel();
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            int bytesRead = client.read(buffer);
            if (bytesRead == -1) {
                client.close();
            } else {
                // 데이터 처리
                buffer.flip();
                System.out.println(StandardCharsets.UTF_8.decode(buffer));
            }
        }
    }
}

4. Signal-driven I/O (SIGIO)

소켓이 준비되면 커널이 SIGIO 시그널을 보낸다. 잘 사용하지 않는다.

Application                    Kernel
    │── sigaction() 등록 ──────→│
    │ (즉시 반환, 다른 작업)
    │                          │ 데이터 도착
    │←─── SIGIO 시그널 ─────────│
    │── recvfrom() ────────────→│
    │←─── 데이터 반환 ──────────│

5. Asynchronous I/O (aio_read)

데이터 복사(커널 버퍼 → 유저 버퍼)까지 완료된 후 통보된다. Java의 AsynchronousSocketChannel이 이 방식이다.

Application                    Kernel
    │── aio_read() ───────────→│
    │ (즉시 반환, 다른 작업)
    │                          │ 데이터 도착
    │                          │ 커널 버퍼 → 유저 버퍼 복사 완료
    │←─── 완료 시그널/콜백 ─────│
    │ (데이터 이미 유저 버퍼에 있음)

5가지 모델 비교

모델 I/O 대기 데이터 복사 대기 동기/비동기 블로킹/논블로킹
Blocking I/O 블로킹 블로킹 동기 블로킹
Non-blocking I/O 즉시 반환 블로킹 동기 논블로킹
I/O Multiplexing 블로킹(select) 블로킹 동기 블로킹(select 단계)
Signal-driven 즉시 반환 블로킹 동기 논블로킹
Asynchronous I/O 즉시 반환 즉시 반환 비동기 논블로킹

Java 구현

Java IO vs NIO vs NIO.2

  java.io (BIO) java.nio (NIO) java.nio (NIO.2 / AIO)
패키지 java.io java.nio java.nio.channels (Async)
방식 동기 블로킹 동기 논블로킹 비동기 논블로킹
스트림 Stream 기반 Buffer 기반 콜백/Future 기반
대표 클래스 InputStream/OutputStream SocketChannel/Selector AsynchronousSocketChannel
등장 Java 1.0 Java 1.4 Java 7

BIO vs NIO 파일 읽기

// BIO - InputStream
try (FileInputStream fis = new FileInputStream("data.txt");
     BufferedReader reader = new BufferedReader(new InputStreamReader(fis))) {
    String line;
    while ((line = reader.readLine()) != null) { // 블로킹
        System.out.println(line);
    }
}

// NIO - FileChannel + Buffer
try (FileChannel channel = FileChannel.open(Paths.get("data.txt"), StandardOpenOption.READ)) {
    ByteBuffer buffer = ByteBuffer.allocateDirect(4096); // Direct Buffer (OS 버퍼 직접 접근)
    while (channel.read(buffer) != -1) {
        buffer.flip();
        while (buffer.hasRemaining()) {
            System.out.print((char) buffer.get());
        }
        buffer.clear();
    }
}

CompletableFuture - 비동기 조합

// 여러 비동기 작업 조합
CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> fetchUser(userId));
CompletableFuture<String> orderFuture = CompletableFuture.supplyAsync(() -> fetchOrder(orderId));

// 두 작업이 모두 완료되면 조합
CompletableFuture<String> combined = userFuture.thenCombine(orderFuture,
    (user, order) -> "User: " + user + ", Order: " + order
);

// 결과 처리 (블로킹 없이)
combined.thenAccept(result -> log.info(result))
        .exceptionally(ex -> {
            log.error("실패", ex);
            return null;
        });

Spring WebFlux - 리액티브

@Service
@RequiredArgsConstructor
public class OrderService {

    private final WebClient webClient;
    private final R2dbcOrderRepository orderRepository;

    // 비동기 논블로킹 파이프라인
    public Mono<OrderResponse> createOrder(OrderRequest request) {
        return validateInventory(request.productId())  // 재고 확인 (외부 API)
            .flatMap(available -> {
                if (!available) {
                    return Mono.error(new OutOfStockException());
                }
                return orderRepository.save(Order.from(request)); // R2DBC 논블로킹
            })
            .flatMap(order -> notifyUser(order.userId()).thenReturn(order)) // 알림 발송
            .map(OrderResponse::from)
            .timeout(Duration.ofSeconds(5))
            .onErrorMap(TimeoutException.class, e -> new ServiceTimeoutException());
    }

    private Mono<Boolean> validateInventory(Long productId) {
        return webClient.get()
            .uri("/inventory/{id}", productId)
            .retrieve()
            .bodyToMono(InventoryResponse.class)
            .map(InventoryResponse::isAvailable);
    }
}

정리

동기 vs 비동기  → 결과를 누가 어떻게 받는가
블로킹 vs 논블로킹 → I/O 대기 중 스레드가 어떻게 동작하는가

실무 조합:
  동기 + 블로킹    → 전통 Spring MVC, JDBC (심플, 낮은 처리량)
  동기 + 논블로킹  → NIO Selector (Netty 내부 구조)
  비동기 + 블로킹  → Future.get() 안티패턴 (병렬 수집 시에만 유효)
  비동기 + 논블로킹 → WebFlux, CompletableFuture (복잡, 높은 처리량)

Java 21 Virtual Thread:
  → 동기 블로킹 코드로 작성하지만 내부적으로 비동기처럼 동작
  → 복잡한 리액티브 없이 높은 처리량 달성 가능