
서블릿 네트워크 커넥션 sync/async blocking/non-blocking
요즘 제가 꽂혀있는 주제들입니다.
저는 컴퓨터공학을 복수전공으로 수료했지만, 취업을 해야했기 때문에
그 당시 배웠던 전공 이론들을 코드로 와닿게 적용할 시간은 적었습니다.
이 부분에 아쉬움을 느꼈고 취업 후 꾸준히 공부하겠다고 마음을 먹었습니다.
최근 현업에서 일하면서 기능을 구현하다 보니
위와 관련된 이론적 배경을 알아야 할 필요가 생겼습니다.
그래서 이와 관련된 이론을 모두 정리하고 코드레벨에서 확인하여
블랙박스를 없애고자 글을 작성하게 되었습니다.
오늘 작성한 글은 앞으로 작성할 글들의 배경지식이 됩니다.
아주 기본적인 내용이지만 중요하기에 확실하게 정리하고자 글을 작성했습니다.
Sync/Async, Blocking/Non-Blocking는 얼핏 보기에 개념이 비슷해 보인다.
그래서 종종 엔지니어들이 이에 관해 확실하게 대답하지 못하는 경우도 봤다.
나도 마찬가지로 이 둘을 항상 헷갈린다. 안다고 생각했는데 뒤 돌아보면 갸우뚱한다.
이 참에 확실하게 이 둘을 구분하고 가보자.
이 둘을 구분할 수 있는 여러 방법이 있지만, 이 글에서는 관심사로 구분하도록 하겠다.
작업 완료여부를 누가 신경쓰는 지가 Synchronous와 Asynchronous의 관심사이다.
그림을 통해 더 쉽게 설명해보도록 하겠다. 먼저 싱글 스레드의 관점에서 보도록 하자.

싱글 스레드는 작업이 없을 때는 Idle 상태로 있는다.
그러다 특정 작업이 주어지면 작업을 수행하고 완료 후 다시 Idle 상태가 되어 다른 작업을 기다린다.
이 작업을 할 때는 당연히 다른 작업을 할 수 없는 것이다.
시간이 지나 기술이 발전하면서 하나의 스레드가 아닌 여러 스레드가 함께 일 할 수 있게 되었다.
기존에 하나의 스레드가 일을 했을 때는 아래와 같이 작업을 했을 것이다.

하지만 이제는 아래와 같이 일 할 수 있게 되었다.

하나의 스레드가 해야만 했던 일을 여러 스레드에게 작업을 위임함으로써 더 많은 작업을 할 수 있게 되었다.
컴퓨팅 자원을 좀 더 할당함으로써 생산성이 더 좋아진 것이다. 그런데 문제가 하나 생겼다.
기존에는 하나의 스레드가 모든 일을 맡아서 진행했기에 작업의 결과를 모두 알 수 있었다.
하지만 멀티스레드로 작업하게 되면서 하나의 쓰레드가 이 결과를 알 수 없게 되었다.
그래서 이 작업에 관한 결과를 확인해보던지, 다 끝났다고 알려줘야만 한다.
위의 다이어그램을 예시로 들어보자.
스레드1이 스레드2와 3에게 작업이 끝났는지 확인하기

스레드2와 3이 스레드1에게 작업이 끝났다고 말을 해주기

두 다이어그램의 차이점이 보이는가?
Synchronous(동기식) - 첫 번째 다이어그램
스레드1(메인 스레드)가 작업의 결과를 계속 신경쓰고 있는 것이 보일 것이다.
작업이 안 끝났음에도 확인할 때도 있고 작업이 끝나고 나서 한 참 후에야 확인하는 것도 보인다.
스레드3의 경우처럼 운 좋게 작업이 끝난 후 물어본다면 결과를 바로 받을 수 있지만
스레드2의 경우처럼 작업이 아직 안끝난 경우라면 결과를 받을 때까지 대기해야만 한다.
즉, 작업의 결과를 위임한 스레드가 신경쓰고 필요하다면 결과가 나올 때까지 기다려야 한다.
Asynchronous(비동기식) - 두 번째 다이어그램
스레드1(메인 스레드)가 작업의 결과를 신경쓰지 않는다.
왜냐하면 스레드2와 스레드3이 작업을 끝낸 후 그 결과를 알려주기 때문이다.
이 경우 Synchronous 때처럼 운에 따라 효율성이 달라지지 않는다.
즉, Sync/Async는 작업의 결과를 누가 신경쓰고 있느냐로 정리하면 된다.
Async 방식에 관해 '작업 완료 후의 처리방식`에 관해 보충 설명을 해보겠습니다.
Async의 경우는 작업 완료 시점에 콜백(callback)이나
이벤트 리스너(listener)를 통해 작업을 처리합니다.
즉, 작업을 위임한 스레드는 다른 일을 처리하다가
설정해 놓은 콜백이나 이벤트 리스너를 통해 알림을 받아서 후속 작업을 처리합니다.
작업의 제어권을 누가 가지고 있느냐가 관심사이다.
NIC(Network Interface Controller)와 스레드 사이의 관계를 예시로 들어보자.
Blocking 방식 - 첫 번째 다이어그램

Non-Blocking 방식 - 두 번째 다이어그램

Blocking 방식인 첫 번째 다이어그램을 보면, 스레드가 NIC에게 읽어올 데이터를 요청한다.
물론 운 좋게 요청하자마자 데이터를 받아오면 좋겠지만, 그런 경우는 흔치 않을 것이다.
스레드는 필요한 데이터를 받을 때까지 다른 작업을 진행하지 못한다.
즉, NIC가 작업의 제어권을 쥐고 있기 때문에 막혀있는 상태가 되는 것이다.
이제 두 번째 다이어그램으로 Non-Blocking에 관해서 보도록 하자.
NIC에게 읽기 데이터를 요청하지만, 읽기 데이터가 모두 준비될 때까지 기다리지 않는다.
즉, 작업의 제어권이 계속 스레드가 쥐고 있는 것이다.
그렇기 때문에 막혀있는 상태가 아닌 것이고,
스레드가 작업을 하기 위해 데이터가 필요하다면 NIC에게 달라고 요청하거나
NIC로부터 데이터를 받게 될 것이다.
이 글을 확실히 이해했다면 데이터를 받는 것과 관련된 것은 Sync/Async라는 것을 눈치챘을 것이다.

Blocking-Synchronous는 간단하다.
멀티스레드를 사용하지 않고 메인 스레드로만 작업을 진행하면 확인할 수 있다.

Thread.sleep에 의해 10초간 작업이 멈추게 되고
그 이후 순차적으로 결과 값과 텍스트를 콘솔 아웃하는 것을 확인할 수 있다.

멀티스레드를 이용하여 작업을 진행한다.
작업이 끝나게 되면 Future로 결과를 받아볼 수 있다.
여기서 주목할 점은 futureResult.get이다.
그 이전까는 Blocking이 되지 않았지만,
멀티스레드에서 수행하는 작업의 결과가 아직 안나온 상태에서
결과를 얻기 위해 Blocking된 상태로 대기하게 된다.

따라서 위와 같이 "Blocking 되기 전..."이란 메시지를 띄워 Blocking 전까지의 작업을 진행하지만,
그 이후에는 future가 결과를 알려주기 전까지 계속 대기하게 된다.

멀티 스레드에 작업을 맡겨서 메인스레드는 Non-Blocking으로 작업을 진행한다.
하지만 메인스레드가 모든 작업을 끝낸 후에도 멀티 스레드가 작업을 끝내어 결과를 반환할 때까지 기다린다.

위 처럼 메인 스레드가 동작을 끝내고 프로그램을 종료하는 것이 아니라
동작을 끝낸 후 멀티스레드의 작업 결과에 관심을 가져 대기하게 된다.

CompletableFuture에 의해 멀티 스레드로 작업을 진행한다.
작업이 끝난 후에는 콜백 클래스로 만든 Consumer 람다 클래스가
반환된 작업의 결과 값을 가지고 작업을 진행한다.
이 코드에서 주목할 점은 future.join이다.
이는 멀티스레드가 종료될 때까지 메인스레드를 대기시키고
멀티스레드에 할당된 자원을 회수하는 명령 메소드이다.
메인스레드는 non-blocking된 상태라 본인의 작업을 계속 진행한다.
만약 join이 없다면 메인스레드가 먼저 종료되어 멀티 스레드가 결과를 알려주지 못할 수 있기 때문이다.

join 메소드가 있는 경우 메인 스레드는 동작을 모두 끝냈지만, 멀티스레드가 작업을 마칠 때까지 대기한다.
멀티 스레드가 작업을 마치고 메인 스레드에 결과를 알려주면 메인스레드는
콜백 클레스로 받은 결과를 가지고 추가 작업을 한다.

만일 join 메소드가 없는 경우 위와 같이 멀티스레드의 작업 결과를 알 수 없다. 메인스레드가 먼저 종료되어 프로세스가 종료되기 때문이다.
아마 눈치 빠른 분들은 new Thread.start와 CompletableFuture.supplyAsync의 동작에서
이상함을 느꼈을 것이다. 분명 둘 다 멀티 스레드로 동작하고 있다.
하지만
Thread.start는 Synchronous라 결과를 받을 때까지 메인스레드가 대기를 한다.
반면
CompletableFuture.supplyAsync는 join이 없다면 메인스레드가 종료된다.
둘의 차이점이 뭐길래 같은 멀티스레드를 사용했음에도 동작이 다를까?
이는 daemon thread이냐 non-daemon thread이냐의 차이에서 나온다.
(이 둘의 차이를 모른다면 https://colevelup.tistory.com/31 를 참고)
Thread 클래스는 기본적으로 non-daemon thread를 사용하므로,
메인 스레드가 종료되더라도 스레드가 종료될 때까지 JVM이 기다린다.
CompletableFuture.supplyAsync()는 daemon thread를 사용하는 ForkJoinPool을 사용한다.
따라서 메인 스레드가 종료되면 스레드가 즉시 종료된다.
그렇기 때문에future.join()을 호출하지 않으면 작업이 완료되기 전에 메인 스레드와 함께 종료된다.
+++ 추가적으로 아래와 같이 작성하면 CompletableFuture도 non-daemon 스레드를 사용하게 된다.

여기서 executorService.shutdown을 호출해야한다.
만약 호출하지 않으면 멀티스레드가 작업을 끝 마쳐도
작업이 끝난줄 모르기 때문에 프로세스가 종료되지 않고 계속 기다리게 된다.
위와 같은 개념을 확실하게 이해했다면...!
흔히 블로그에 올려져 있는 썸네일의 표가 더 이상 헷갈리지 않을 것이다.
이제 나도 안헷갈린다 ^_^
Reference
1. https://hadev.tistory.com/27
2. https://chatgpt.com/share/66fe609b-7538-800c-875a-2e8509ccad3a
3. https://engineerinsight.tistory.com/295#%F0%9F%92%8B%C2%A0Asynchronous%20blocking%20I%2FO-1
재밌게 잘 읽었습니다~