
들어가며
프로그래밍 관점에서 I/O 방식은 크게 4가지로 나뉜다. 블로킹, 논-블로킹 그리고 동기, 비동기.
아마 개발자라면 한번 쯤은 접하는 용어이다. 어쩌면 자주..? ㅎ
나도 요즘 맡고있는 프로젝트의 성능과 관련해서 개편하다보니 자주 접하고있다.
대학생때부터 자주 접한 용어라 익숙하지만, 막상 설명하려고하면 쉽지 않은 개념이기도하다.
그리고 여러 개발자분들과 얘기를 나눠본 결과.. 각자 조금씩 이해하고 구분하는 방법이 조금씩 다르다.
그래서 이번 글은 여러 자료를 참고하여 나의 언어로 어떻게 이 4가지를 구분하는지 정리해보려고한다.
기본 Linux I/O 모델 Matrix
대부분의 이 주제를 다루는 글을보면 위 그림을 언급한다. 그만큼 4개의 개념을 하나의 그림으로 잘 설명하고있어서 그런 듯 하다.
하지만 위 그림은 2006년에 발표된 굉장히 오래된 그림이며, 그때는 비동기가 익숙하지않은 개념이었다고한다.
지금은.. 비동기 개념을 잘 모르면 개발을 못할 정도라고 생각든다..
그러므로 위 그림처럼 블로킹/논블로킹, 동기/비동기를 위와 같이 조합할 수 있다는 것만 알고넘어가면 좋을 듯 하다.
개별적 개념
위 그림을 이해하기에앞서 먼저 크게 두 그룹으로 나눠서 각각의 개념을 살펴본다.
- Blocking과 Non-Blocking
- Synchronous와 Asynchronous
Blocking vs Non-Blocking
💁♂️ 사전적 의미
Blocking
이란 단어를 사전에 검색해보면 아래와 같이 나온다.
the action or fact of blocking or obstructing someone or somthing
누군가 또는 무언가를 막거나 방해하는 행위 또는 사실
누군가의 행위로인해 무언가가 막혀버린 사실을 의미한다.
쉽게 말하면, 누군가의 행위로인해 다른 누군가가 제한되거나 대기하는 상태를 의미한다.
반대로 Non-Blocking
은 누군가의 행위로 인해 다른 누군가가 제한되지 않거나 대기하지 않는 상태를 의미한다.
💁♂️ 컴퓨터로 해석하면..
블로킹 - 하나의 함수(누군가)가 정해진 코드를 실행하는 과정에서, 다른 함수 (누군가)를 호출함으로써 제한되거나 대기하는 상태.
논블로킹 - 하나의 함수(누군가)가 정해진 코드를 실행하는 과정에서, 다른 함수 (누군가)를 호출하고도 제한되거나 대기하지 않는 상태.
그리고 이 개념은 제어권의 관점에서 이해하면 이해하기 쉽다.
💁♂️ 호출한 입장에서의 제어권
제어권 관점에서 Blocking/Non-Blocking은 호출되는 함수의 리턴여부가 관심사이다.

- Blocking
- 호출된 함수가 요청한 작업을 모두 완료할 때까지 호출한 함수에게 제어권을 넘겨주지않고 대기하게 만든다.
- 호출한 함수는 호출된 함수가 모든 작업을 끝마칠때까지 아무일도 못하고 대기하게된다.
- Non-Blocking
- 호출된 함수가 요청한 작업을 시작하기전에 바로 리턴해서 호출한 함수에게 제어권을 넘겨주고, 호출한 함수가 다른 일을 할 수 있게한다.
- 호출한 함수는 호출된 함수가 모든 작업을 끝마칠때까지 기다리지않고 다음 작업을 수행한다.
- Blocking: 제어권 하나
- Non-Blocking: 제어권 하나 이상
💁♂️ 이해하기 좋은 예시

- Blocking
- Blocking 방식은 보통 Pull 방식으로 수행된다.
- 위 이미지에서 Pull 방식으로 Network I/O 수행시 요청한 스레드를 OS의 I/O 결과가 나올 때까지 Blocking한다. 그리고 데이터를 Pull 방식으로 가져온다.
- Non-Blocking
- Non-Blocking 방식은 보통 Push 방식으로 수행된다.
- 위 이미지에서 Push 방식으로 Network I/O 수행시 요청한 스레드는 바로 리턴되고 자신의 일을 수행한다. 그리고 OS에서 요청한 I/O 작업이 완료되면 해당 스레드에 알림을 주고 해당 스레드는 그제야 Callback 형태로 일을 수행한다. 그리고 데이터는 Push 방식으로 받는다.
Synchronous vs Asynchronous
💁♂️ 사전적 의미
Synchronous
이란 단어를 사전에 검색해보면 아래와 같이 나온다.
happening, moving, or existing at the same time
동시에 발생, 이동 또는 존재
동시 즉, 동시간대에 같이 이루어지는 두 개 이상의 개체 혹은 이벤트를 의미한다.
💁♂️ 컴퓨터로 해석하면..
동기 - 하나의 함수(누군가)가 다른 함수 (누군가)를 호출하고 그 함수의 결과를 기다리거나 작업 완료 여부를 기다림.
비동기 - 하나의 함수(누군가)가 다른 함수 (누군가)를 호출하고 그 함수의 결과를 기다리지않으며, 작업 완료 여부도 기다리지않음.
그리고 이는 call의 완료를 기다리는 관점에서 이해하면 이해가 쉽다.
💁♂️ call의 완료를 기다리는 관점
- Synchronous
- 호출되는 함수의 작업 완료를 호출한 함수가 신경쓰면 Synchronous라고 볼 수 있다.
호출된 함수(B)
의 수행 결과 및 리턴을호출한 함수(A)
가 기다리거나 신경쓰는 것.
- 물론 Non-Blocking이여서
호출된 함수(B)
로부터 바로 제어권을 받더라고,호출한 함수(A)
가B
의 작업 완료 여부 및 리턴을 기다리거나 신경쓰면 동기다. - A라는 행위와 B라는 행위가 순차적으로 작동한다면 동기라고 볼 수 있다.
- A라는 행위가 별개의 것이 아니라, B라는 행위를 관찰하는 행위라면 이것이 동시에 일어나더라도 동기라고 본다.
- A라는 쓰레드와 B라는 쓰레드가 따로 돌아간다고 해도, 어떤 하나의 행위가 다른 행위에 밀착되어 있다면 두 행위가 다른 쓰레드에서 벌어지더라도 동기를 의미한다.
- 호출되는 함수의 작업 완료를 호출한 함수가 신경쓰면 동기이다. - 중요
- 호출되는 함수의 작업 완료를 호출한 함수가 신경쓰면 Synchronous라고 볼 수 있다.
- Asynchrnous
- 호출되는 함수의 작업 완료를 호출된 함수가 신경쓰면 Asynchronous라고 볼 수 있다.
호출된 함수(B)
의 수행 결과 및 리턴을호출한 함수(A)
가 전혀 안 기다리거나 신경쓰지 않는다면 비동기이다.- 대표적으로 callback 함수를 넘겨서 호출된 함수가 작업 완료후 callback을 실행시키는 것.
- 호출되는 함수의 작업 완료를 호출된 함수가 신경쓰면 비동기이다. - 중요
- 호출된 함수가 자신의 작업을 완료하고 넘겨받은 callback함수를 실행하는 것이 대표적이다.
- 이때, 호출된 함수가 자신의 작업을 완료하고 호출한 함수에게 알림을 줄 수 있는데, 이것 또한 비동기라고 볼 수 있다.
- 예를 들어, Asynchronous는 보통 별도의 스레드로 뺴서 실행하고, 완료되면 호출하는 측에 알려주는 것이다.
- 이때, 호출한 함수에선 결과를 계속 궁금해하진않지만, 호출된 함수가 그저 알려주는 것 뿐이다.
- 호출되는 함수의 작업 완료를 호출된 함수가 신경쓰면 Asynchronous라고 볼 수 있다.
- 동기: 하나 혹은 두 개의 제어권이 하나의 일 신경씀.
- 비동기: 하나 혹은 두 개의 제어권이 하나 이상의 서로 다른 일을 신경씀.
💁♂️ 이해하기 좋은 예시
A와 B라는 API가 존재한다, 웹 브라우저에서 2개의 API를 호출해야할 때를 가정해본다.
- 동기
- A 호출후 A의 결과를 B 호출시 포함해야하는경우. Sequential한 호출이므로, 동기이다.
- B API가 A API 결과에 의존적이므로, 브라우저에선 A를 호출후 B를 호출해야한다.
- 비동기
- A와 B API를 동시에 호출한다. 병렬적으로 호출하므로 비동기이다.
- B API가 A API 결과에 의존적이지 않으므로, 브라우저에선 동시에 두 API를 호출한다.
Blocking, Non-Blocking, Synchronous, Asynchronous의 조합
Blocking과 Non-Blocking, 그리고 Synchronous와 Asynchronous의 개별적인 개념을 알았으니 이제 서로 조합된 상황을 코드와 함께 살펴본다.
Synchronous Blocking
💁♂️ 설명
가장 이해하기 직관적인 조합이며, 하나의 제어권만 가지고 한 번에 한 가지의 일만 하는 것을 의미한다.
여러 가지의 작업을 순차적으로 하나의 제어권만을 가지고 처리하는 것이다.
💁♂️ I/O 관점

I/O 관점으로보면 하나의 Application에서 커널에 I/O 요청하고 Application은 제어권을 잃어 블록된 상태로 커널의 I/O 요청이 완료될 때까지 기다린다.
예를 들어, 하나의 Application이 사용자 입력을 받을때까지 대기된다면 이는 동기/블로킹이라고 볼 수 있다.
💁♂️ 자바로 살펴보는 예시
간단히 자바 코드로보면 아래와 같다.
public class SynchronousBlockingExample {
public static void main(String[] args) throws InterruptedException {
printAfterSeconds("Task A", 3000);
printAfterSeconds("Task B", 2000);
printAfterSeconds("Task C", 1000);
}
private static void printAfterSeconds(String text, long millis) throws InterruptedException {
Thread.sleep(millis);
System.out.println(text);
}
}
실행 결과는 아래와 같다.
[main] Task A
[main] Task B
[main] Task C
실행 순서는 아래와 같다.
- 3초 sleep이후 Task A 출력
- Task A가 출력되고나서 2초 sleep이후 Task B 출력
- Task B가 출력되고나서 1초 sleep이후 Task C 출력
실행결과와 순서를 보면 알 수 있듯이, 하나의 제어권안에서 세 가지의 Task를 순차적으로 진행한다.
이를 동기/블로킹
이라고보며, 굉장히 간단하며, 직관적이고 이해하기도 쉽다.
아마 가장 많이 작성하게되는 코드 동작 형태이기도하다.
이해를 위해 하나의 스레드에서 간단히만 예시를 작성해보았다.
Synchronous Non-Blocking
💁♂️ 설명
Synchronous (동기)는 호출하는 함수가 호출되는 함수의 작업 완료 여부를 신경쓴다는 의미이고, Non-Blocking은 호출하는 함수가 다른 함수를 호출해도 바로 리턴 받아 제어권을 그대로 가지고있는 것이다.
이를 조합하면 Non-Blocking 방식으로 함수를 호출하고 바로 반환 받아 다른 작업을 진행하지만, 함수 호출에 의해 수행되는 작업이 완료되었는지 호출하는 함수에서 계속해서 확인하는 것이다.
Select()
,Epoll()
등을 사용하지 않고, 특정 스레드가 계속해서 I/O 관련하여 체크하는 로직을 Synchrnous Non-Blocking이라고 볼 수 있다.
💁♂️ I/O 관점

I/O 관점으로보면 하나의 Application에서 커널에 I/O 요청하지만, Application은 제어권을 그대로 가지고있으며, 중간중간 Application이 커널에 I/O 요청이 완료되었는지 확인하면서 기다린다.
중요한 점은 Application은 I/O 요청을하고도 제어권을 가지고있으므로, 계속해서 자신의 일을 수행할 수 있다. 단, 자신의 일을 잠시 수행하고 커널에 요청한 I/O 작업이 완료되었는지 지속적으로 확인한다.
💁♂️ 자바로 살펴보는 예시
Future.isDone()
을 지속적으로 확인하는 예시를 통해 이해해볼 수 있을듯하다.
public class SynchronousNonBlockingExample {
public static void main(String[] args) throws InterruptedException, ExecutionException {
ExecutorService es = Executors.newCachedThreadPool();
// Task A 비동기 실행 - 10초동안 I/O 작업하는 Task (라고 생각하자)
System.out.println("Task A 실행 시작!");
Future<String> task_a = es.submit(() -> returnValueAfterSeconds("Task A (Kernel I/O)", 10_000));
// Task A가 종료되었는지 확인
while(!task_a.isDone()) {
System.out.println("Task A가 완료되었는지 계속 확인.");
// Task A가 완료되지않아도 여기에서 다른 작업을 계속 수행.
printAfterSeconds("Task B (Application 작업)", 2_000);
}
// Task A의 작업이 완료되면 작업 결과에 따른 다른 작업 처리.
System.out.println(task_a.get() + " 실행 종료!");
es.shutdown();
}
private static void printAfterSeconds(String text, long millis) throws InterruptedException {
Thread.sleep(millis);
System.out.println(text);
}
private static String returnValueAfterSeconds(String text, long millis) throws InterruptedException {
Thread.sleep(millis);
return text;
}
}
실행 결과는 아래와 같다.
Task A 실행 시작!
Task A가 완료되었는지 계속 확인.
Task B (Application 작업)
Task A가 완료되었는지 계속 확인.
Task B (Application 작업)
Task A가 완료되었는지 계속 확인.
Task B (Application 작업)
Task A가 완료되었는지 계속 확인.
Task B (Application 작업)
Task A가 완료되었는지 계속 확인.
Task B (Application 작업)
Task A (Kernel I/O) 실행 종료!
결과에서 알 수 있듯이, Application (Main 스레드이며 Task B
를 처리)는 Task A
를 실행하고, Task A
의 처리 결과를 계속 확인하면서 자신이 처리하는 Task B
도 처리하는 것을 볼 수 있다.
위 예시는 예시일 뿐, 실무에선 상황에 맞는 방법을 사용하는 것이 좋다.
💁♂️ 또다른 자바 NIO 예시
Java NIO를 잘못활용하면 소켓 변경사항을 Push 방식으로 처리하는 것이 아닌, Pull 방식으로 사용함으로써 Synchrnous Non-Blocking하게 구현할 수 있다.
이는 굉장히 잘못 사용하는 Bad Practice 이다.
public class NioNonBlockingServerApplication {
public static void main(String[] args) throws IOException {
// 서버 생성
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.bind(new InetSocketAddress(8080));
// Non-Blocking 모드로 전환한다.
serverSocket.configureBlocking(false);
// SocketChannel별로 하나의 ByteBuffer를 사용한다.
Map<SocketChannel, ByteBuffer> sockets = new ConcurrentHashMap<>();
while (true) {
// 여기서 accept()는 들어오는 연결 요청을 수락한다는 의미이다.
// Non-Blocking 모드이기에 accept() 메서드는 blocking 되지 않고, null을 리턴한다.
SocketChannel socket = serverSocket.accept();
// 새로운 소켓이 연결된 경우
if (socket != null) {
// 연결된 Socket을 Non-Blocking하게 처리하겠다는 의미.
socket.configureBlocking(false);
// 매 Socket마다 하나의 ByteBuffer를 할당한다.
sockets.put(socket, ByteBuffer.allocateDirect(80));
}
// 연결된 SocketChannel을 순회하면서, 연결이 끊긴 SocketChannel은 제거한다.
sockets.keySet().removeIf(it -> !it.isOpen());
// 연결된 SocketChannel을 순회하면서, 데이터를 읽고 작업을 수행한 다음 소켓에 다시 쓰기 작업을 수행한다. (Bad-Practice)
sockets.forEach((socketCh, byteBuffer) -> {
try {
// Non-Blocking 모드이기에 Blocking 모드와 다르게 read() 메서드 호출시 blocking 되지 않는다.
int data = socketCh.read(byteBuffer);
if (data == -1) { // 연결이 끊긴 경우
closeSocket(socketCh);
} else if (data != 0) { // 데이터가 들어온 경우
byteBuffer.flip(); // position=0으로해서 Read 모드로 전환한다.
// 작업을 수행한다. (대문자 변환)
toUpperCase(byteBuffer);
while (byteBuffer.hasRemaining()) {
socketCh.write(byteBuffer);
}
byteBuffer.compact();
}
} catch (IOException e) {
closeSocket(socketCh);
throw new UncheckedIOException(e);
}
});
}
}
private static void closeSocket(SocketChannel socket) {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
private static void toUpperCase(final ByteBuffer byteBuffer) {
// ByteBuffer내 모든 데이터를 읽어서 대문자로 변환한다.
for (int x = 0; x < byteBuffer.limit(); x++) {
byteBuffer.put(x, (byte) toUpperCase(byteBuffer.get(x)));
}
}
private static int toUpperCase(int data) {
return Character.isLetter(data) ? Character.toUpperCase(data) : data;
}
}
관련하여 더 자세한 내용은 사례를 통해 이해하는 네트워크 논블로킹 I/O와 Java NIO를 참고하길 추천한다.
Asynchronous Blocking
💁♂️ 설명
Asynchronous (비동기)는 호출하는 함수가 호출되는 함수의 작업 완료 여부를 신경쓰지 않는다는 의미이고, Blocking은 호출하는 함수가 다른 함수를 호출하고 제어권을 잃고 기다리는 것이다.
물론 호출된 함수가 자신의 작업을 완료하고 호출한 함수에게 알림을 줄 수 있는데, 이것 또한 비동기라고 볼 수 있다.
이를 조합하면, Blocking 방식으로 함수를 호출해 호출되는 함수에 제어권을 넘겨주며, 함수 호출에 의해 수행되는 작업이 완료되었는지 호출하는 함수에서 전혀 신경쓰지 않는다.
💁♂️ I/O 관점

I/O 관점으로보면 하나의 Application에서 커널에 I/O 요청하면서 제어권은 커널에 넘겨준다.
단, 이때 Asynchronous이므로 커널의 I/O 처리 결과를 알려주기전까진, Application에서 굳이 이를 알 필요가없으므로 제어권을 다시 Application에게 반환한다. Application은 제어권만 가질 뿐 Blocking된다.
커널은 요청받은 I/O 작업을 완료후 Application에게 알림을 준다. Application은 이때 Blocking이 풀리며 커널로부터 받은 알림을 바탕으로 I/O의 결과를 가져온다.
이미 select()
, epoll()
등을 아는 사람은 쉽게 이해했겠지만, 해당 함수들은 모두 등록된 소켓이나 file의 변경 사항이 OS가 감지할 때까지 해당 스레드가 Blocking된다.
그래서 Non-Blokcing I/O에서 많이 구성되는 Selector 스레드는 Asynchronous Blocking의 한 예시라고 볼 수 있다.
그림을 통해 알겠지만, Asynchronous Blocking은 Asynchronous Non-Blocking처럼 동작하는 과정에서 하나라도 Blocking으로 동작하는 경우라고 보면 이해하기가 쉽다.
즉, Task를 요청할 땐 Non-Blocking처럼 하지만, 해당 Task가 완료되고 호출된 함수에서 신호를 주기전까진 Blocking이된다.
만약 이해가 안된다면 사례를 통해 이해하는 네트워크 논블로킹 I/O와 Java NIO 읽기를 추천한다.
💁♂️ 자바로 살펴보는 예시
Non-Blocking 서버에서의 Multiplexing I/O를 위해 Selector가 Asynchronous Blocking하게 처리되는 것외에도 Asynchronous Blocking으로 처리하는 흔한 사례가 있다.
바로 서버 개발자라면 흔히 사용하는 CompletableFuture를 Callback 없이 get()
과 사용할 때이다.
Blocking으로 처리되는 서버 입장(ex. Spring MVC)에서 HTTP 응답은 Blocking인데, 비즈니스내 여러 Task를 비동기로 실행하고 결과들을 취합하여 내려주는 방식이 엄밀히 말하면 Asynchronous Blocking와 비슷하다고 볼 수 있다.
즉, 여러 Task를 비동기로 동시에 실행하고 all.of
등으로 모든 Task가 완료된 후에 취합하여 Client에게 응답을 내려주는 서버내 비즈니스 형태가 대표적이다.
아마 많은 서버 개발자들은 이러한 로직을 구성해보았을 것이다 :) 서버의 전반적인 처리 과정은 Synchronous-Blocking, 비즈니스내 몇몇 처리만 Asynchronous으로 할 경우를 말하는 것.
Asynchronous Non-Blocking
💁♂️ 설명
Asynchronous (비동기)는 호출하는 함수가 호출되는 함수의 작업 완료 여부를 신경쓰지 않는다는 의미이고, Non-Blocking은 호출하는 함수가 다른 함수를 호출해도 바로 리턴 받아 제어권을 그대로 가지고있는 것이다.
이를 조합하면, Non-Blocking 방식으로 함수를 호출하고 바로 반환 받아 다른 작업을 진행하지만, 함수 호출에 의해 수행되는 작업이 완료되었는지 신경쓰지 않는 것이다.
대표적인 예시는 Callback이다. Non-Blocking으로 함수를 호출할 때 Callback도 같이넘기는 방식이다.
Task를 처리하는데 가장 성능좋고 효율적인 방식이라고 볼 수 있다.
💁♂️ I/O 관점

I/O 관점으로보면 하나의 Application에서 커널에 I/O 요청하면서 제어권을 넘겨주지않으며, 커널의 I/O 작업 진행사항이나 완료 여부를 전혀 궁금해하지않는다.
I/O 작업을 요청한 Application은 자신의 일을 계속 제어권을 가지고 진행하며, 커널이 I/O 작업이 완료된 경우 callback으로 시그널을 줘서 Application에 알려주거나 Task 요청시 넘겨받은 Callback을 실행하기만한다.
💁♂️ 자바로 살펴보는 예시
Asynchronous Non-Blocking로 처리하는 가장 대표적인 사례는 Callback과 이벤트 드리븐이다.
이번 글에선 간단히 Non-Blocking으로 작업을 요청후 해당 작업을 전혀 신경쓰지않는 굉장히 간단한 예시를 남겨둔다.
public class AsynchronousNonBlockingExample {
public static void main(String[] args) throws InterruptedException {
ExecutorService es = Executors.newCachedThreadPool();
System.out.println("Task A 실행 시작!");
// Non-Blocking으로 작업 요청.
es.execute(() -> {
// 아래 작업의 완료 여부는 Main 스레드가 전혀 신경쓰지 않는다. (결과가 전혀 필요없으므로)
try {
System.out.println("Task B 실행");
Thread.sleep(1_000);
System.out.println("Task B 완료");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
System.out.println("Task A 작업은 계속 진행");
System.out.println("Task A 작업은 계속 진행");
System.out.println("Task A 작업은 계속 완료");
es.shutdown();
}
}
Callback과 함께 함수를 호출하면서 논블록으로 동작하는 대부분의 예시가 Asynchronous Non-Blocking 이긴하다.
Asynchrnous-Non-Blocking 관련된 이벤트 드리븐 예시는 사례를 통해 이해하는 네트워크 논블로킹 I/O와 Java NIO와 사례를 통해 이해하는 Event Loop를 참고하면 좋을 듯하다.
정리
- Blocking vs Non-Blocking
- 제어권 관점에서 Blocking/Non-Blocking은 호출되는 함수의 리턴여부가 관심사이다
- 함수 호출후 바로 리턴되지 않으면 Blocking
- 함수 호출후 바로 리턴되면 Non-Blocking
- Synchronous vs Asynchronous
- 호출되는 함수의 작업 완료 여부를 누가 신경쓰느냐가 관심사이다.
- 호출되는 함수의 작업 완료를 호출한 함수가 신경쓴다면 Synchronous
- 호출되는 함수의 작업 완료를 호출되는 함수가 신경쓴다면 Asynchronous
참고
- https://developer.ibm.com/articles/l-async/
- https://pediaa.com/what-is-the-difference-between-synchronous-and-asynchronous-calls-in-java/
- https://homoefficio.github.io/2017/02/19/Blocking-NonBlocking-Synchronous-Asynchronous/#
- https://musma.github.io/2019/04/17/blocking-and-synchronous.html
- https://interconnection.tistory.com/141