[프로그래밍] 비동기 프로그래밍
비동기 프로그래밍
요청자(호출자)(caller)가 작업을 요청한 후 해당 작업이 완료되었을 때 그 다음 작업을 수행할 수 있다면 요청한 작업은 동기적(synchronous)이라고 한다. 작업들이 정해진 순서에 따라 순차적으로 진행되어야 한다면 각 작업은 동기적으로 호출 및 수행되어야 할 것이다. 앞의 작업이 먼저 수행되어야 뒤의 작업을 수행할 수 있기 때문이다. 동기적인 작업이란 일반적으로 다음 특성 중 하나를 갖는다.
- 작업 간 의존성
- 선 작업의 결과가 후 작업의 입력으로 사용된다. 작업의 실행 순서, 작업 실행에 필요한 요소 두 측면에서 후 작업은 선 작업에 의존적이다.
- 선 작업의 결과가 후 작업의 입력으로 사용되지 않지만 후 작업은 선 작업이 완료된 후에 시작되어야 한다. 작업의 실행 순서 측면에서 후 작업은 선 작업에 의존적이다.
- 작업 요청자: 작업 요청자는 선 작업을 요청한 후 선 작업이 완료되어야 후 작업을 요청할 수 있다. 상황에 따라 작업 요청자는 선 작업이 완료될 때까지 아무런 작업도 하지 못하거나 다른 작업을 수행할 수 있다.
반면 요청자가 작업을 요청한 후 해당 작업의 완료 여부에 관계 없이(작업의 완료를 기다릴 필요 없이) 그 다음 작업을 수행할 수 있다면 요청한 작업은 비동기적(asynchronous)이라고 한다. 비동기적 작업이란 다음 특성을 하나를 갖는다.
- 작업 간 의존성: 선 작업의 결과가 후 작업의 입력으로 사용되지 않는다. 작업의 실행 순서, 작업 실행에 필요한 요소 두 측면에서 후 작업은 선 작업에 의존적이지 않다.
- 작업 요청자: 작업 요청자는 선 작업을 요청한 후 선 작업이 완료되지 않더라도 후 작업을 요청할 수 있다.
작업들의 실행 및 완료 순서가 중요하지 않다면 동기적 호출 방식 대신 비동기 호출 방식을 사용하고 요청 스레드가 블로킹(blocking)되지 않도록 함으로써 스레드 자원의 효율성과 애플리케이션의 확장성을 높일 수 있다.
작업의 흐름이 A와 B라고 할 때 다음 시나리오를 살펴보자. 토스트 요리라는 작업을 예로 들 것이다. 실제 작업은 CPU 연산일 수도 있고 네트워크나 파일 입출력 등과 관련된 I/O 연산일 수 있다.
동기와 비동기
작업 A의 수행 결과를 작업 B가 사용하는 경우 작업들에 대한 요청은 동기적이어야 한다. 작업 A에 대한 수행 결과가 응답으로 반환될 때까지 작업 요청자는 기다릴 수 밖에 없다. 요리사는 빵을 굽고 난 후 구워진 빵에 잼을 바를 것이다.
반면 작업 A의 수행 결과를 작업 B가 사용하지 않는 경우 작업 A에 대한 요청은 동기적이거나 비동기적일 수 있다. 빵을 다 굽고 나서 계란 후라이 요리를 시작할 필요는 없다.
동기적으로 수행되어야 하는 작업 A와 작업 C가 있고, 작업 A와 작업 C 사이에서 비동기적으로 수행될 수 있는 작업 B가 있다고 가정해보자.
작업 요청자가 작업 A가 완료될 때까지 아무런 작업도 하지 못하거나 작업B를 수행할 수 있다.
- 블로킹 작업: 작업 요청자는 작업 A가 완료될 때까지 작업 B를 수행할 수 없다. 이 때 작업 A를 블로킹 작업이라고 한다. 블로킹 작업은 요청 스레드(요청자의 작업 흐름)를 블로킹한다. 작업 A가 완료된 후 작업 C가 수행되어야 하는 사실은 변함 없다.
- 논블로킹 작업: 작업 요청자는 작업 A가 완료될 때까지 작업 B를 수행할 수 있다. 이 때 작업 A를 논블로킹 작업이라고 한다. 논블로킹 작업은 요청 스레드를 블로킹하지 않는다. 작업 A가 완료된 후 작업 C가 수행되어야 하는 사실은 변함 없다.
작업 A가 수행 중일 때 작업 B를 수행할 수 있지만 어떤 이유에서 작업 A가 완료되기만을 기다린다면 A에 대한 요청은 동기적이다. 개발자의 의도와 다르게 이러한 상황이라면 동기적 호출을 사용하고 있었을 것이며 비동기적 호출을 사용하도록 코드 변경을 할 수 있다. 단, 이 경우 작업 A가 스레드를 논블로킹한 특성을 갖는 경우에만 가능하다.
동기적 요청 및 스레드 논블로킹 상황이 의도된 것인지 여부와 상관 없이 이 경우 동기적 요청 및 스레드 블로킹 상황과 동일하게 리소스 효율면에도 좋지 못하다. 토스트기가 요리사 대신 빵을 굽는 동안 요리사는 계란 후라이 요리를 할 수 있는데 요리사에게 계란 후라이 요리는 빵이 다 구워지면 하라고 비효율적인 지시를 한 상황일 것이다.
작업 A가 완료되지 않고 수행 중이더라도 작업 B를 시작할 것이라면(가능하다는 조건하에) 작업 A에 대한 요청은 비동기적이어야 한다. 작업 A가 요청 스레드 블로킹하는 상황이라면 어떻게 될까. 작업 요청이 비동기적이더라도 작업 B를 수행할 수 없다. 요리사가 토스트기에서 빵이 잘 구워지는지 살펴보느라 계란 후라이를 만들 수 없는 상황이다. 빵과 계란 후라이만 보면 의존성이 없어보이지만 빵 굽기라는 작업이 요리사의 작업 흐름을 블로킹시켜 버렸다. 이처럼 비동기적으로 처리될 수 있는 작업은 스레드가 블로킹되는 상황 때문에 동기적으로 처리되는 상황이 발생할 수 있다. 비동기적 작업 수행은 스레드 논블로킹을 전제로 이루어져야 한다.
위와 같이 작업 A가 완료될 때까지 작업 요청자가 비동기적으로 다른 작업을 수행할 수 있는지 그렇지 않은지는 작업의 특성에 따라 달라진다. 요리사는 빵에 소스를 바르기 전에 빵이 토스트기에서 구워지는 동안 계란 후라이 요리를 할 수 있을까? 빵을 직접 굽는다면 어떨까? 빵을 굽는 과정이 어떻게 처리되는지(작업의 특성)에 따라 달라진다. 이때, 토스트기는 요리사 대신 빵 굽기라는 작업을 대신 수행할 수 있는 별도의 작업자이며, 토스트기는 빵 굽기 작업이 완료되면 요리사에게 알람을 울려 작업이 완료되었음을 알려준다.
- 블로킹 작업: 요리사는 토스트기를 사용하여 빵을 굽지만, 토스트기에서 빵이 잘 구워지는지 살펴보느라 계란 후라이를 만들지 못한다. 이 경우 빵 굽기 작업이 완료되기 전에 요리사는 다른 작업을 수행할 수 없으며 이 경우 빵 굽기 작업은 요리사를 블로킹한다.
- 논블로킹 작업: 요리사는 토스트기를 사용하여 빵을 굽는 동안 계란 후라이 요리를 한다. 토스트기에서 알람이 울리면 요리사는 빵 굽기 작업이 완료되었음을 확인한다. 이 경우 빵 굽기 작업이 완료되기 전에 요리사는 다른 작업을 수행할 수 있으며 이 경우 빵 굽기 작업은 요리사를 블로킹하지 않는다.
프로그래밍에서 함수(또는 메서드)는 여러 작업을 실행하는 코드 블록이다. 함수의 코드 실행 블록에 실행할 작업들을 정의하거나, 함수 내에서 다른 함수를 호출할 수 있다. 따라서 항상 하나의 함수가 하나의 작업을 실행하는 것을 의미하는 것은 아니며, 함수와 작업은 포함 관계로 봐야 한다. 동기적/비동기적 작업의 의미하는 것과, 동기적/비동기적 함수가 의미하는 것에는 차이가 있다.
- 동기적 작업: 동기적 작업은 실행이 완료될 때까지 다른 작업은 실행될 수 없다. 동기적 작업은 다른 작업들의 실행을 블로킹한다.
- 동기적 함수: 동기적 함수는 동기적 작업만 실행할 수 있다. 동기적 함수에서 비동기적 작업을 실행할 수 없다.
- 비동기적 작업: 비동기 작업은 실행이 완료되기 전에 다른 작업이 실행될 수 있다. 비동기적 작업은 다른 작업들의 실행을 블로킹하지 않는다.
- 비동기 함수: 비동기 함수는 하나 이상의 비동기 작업을 실행한다. 또한 동기 작업도 실행할 수 있다.
토스트 요리 작업을 코드 상으로 살펴보면 다음과 같다. 이 예제에서는 하나의 함수가 하나의 작업을 실행한다.
토스트기에서_빵굽기_함수1() {
// 요리사는 토스트기를 사용하여 빵을 굽는 동안 빵이 잘 구워지는지 살펴봐야 한다.
...
return 구운빵;
}
토스트기에서_빵굽기_함수2() {
// 요리사는 토스트기를 사용하여 빵을 굽는 동안 다른 작업을 수행할 수 있다.
...
return 구운빵;
}
계란후라이_요리하기_함수() {
...
return 계란후라이;
}
구운빵에_잼바르고_계란후라이_얹기_함수(구운빵, 계란후라이) {
...
return 잼바른구운빵;
}
두 토스트기에서_빵굽기_함수
와 구운빵에_잼바르고_계란후라이_얹기_함수
가 수행하는 작업은 서로 의존적이므로 비동기적으로 처리될 수 없으며 동기적으로 처리되어야 한다. 계란후라이_요리하기_함수
와 구운빵에_잼바르고_계란후라이_얹기_함수
도 마찬가지이다. 토스트기에서_빵굽기_함수1
과 계란후라이_요리하기_함수
가 수행하는 작업은 서로 의존적인 반면 토스트기에서_빵굽기_함수2
와 계란후라이_요리하기_함수
가 수행하는 작업은 서로 의존적이지 않으므로 비동기적으로 처리될 수 있다.
토스트기에서_빵굽기_함수1
의 실행 코드는 다음과 같다.
// 작업1: 요리사는 토스트기를 사용하여 빵을 굽는 동안 빵이 잘 구워지는지 살펴봐야 한다.
구운빵 = 토스트기에서_빵굽기_함수1();
// 작업2: 요리사는 빵굽기가 완료된 후 계란 후라이 요리를 할 수 있다.
계란후라이 = 계란후라이_요리하기_함수();
// 작업3: 요리사는 빵굽기와 계란 후라이 요리가 완료된 후 구운빵에 잼을 바른다.
구운빵에_잼바르고_계란후라이_얹기_함수(구운빵, 계란후라이);
작업 실행 흐름은 다음과 같다.
구운빵 = 토스트기에서_빵굽기_함수1();
계란후라이 = 계란후라이_요리하기_함수();
구운빵에_잼바르고_계란후라이_얹기_함수(구운빵, 계란후라이);
토스트기에서_빵굽기_함수2
의 실행 코드는 다음과 같다.
// 작업1: 요리사는 토스트기를 사용하여 빵을 굽는 동안 다른 작업을 수행할 수 있다.
구운빵 = 토스트기에서_빵굽기_함수2();
// 작업2: 요리사는 빵굽기가 완료되기 전에 계란 후라이 요리를 할 수 있다.
계란후라이 = 계란후라이_요리하기_함수();
// 작업3: 요리사는 빵굽기와 계란 후라이 요리가 모두 완료된 후 구운빵에 잼을 바르고 계란 후라이를 얹을 수 있다.
구운빵에_잼바르고_계란후라이_얹기_함수(빵, 계란후라이);
작업 실행 흐름은 다음과 같다.
구운빵 = 토스트기에서_빵굽기_함수2();
,계란후라이 = 계란후라이_요리하기_함수();
구운빵에_잼바르고_계란후라이_얹기_함수(구운빵, 계란후라이);
작업이 완료되기를 기다리는 동안 아무일도 하지 못한다는 것 자체가 리소스 낭비이므로 동기적 작업 실행과 스레드 블로킹 상황은 리소스 사용 관점에서 비효율적일 수 있다. 요청한 작업이 빠르고 정확하게 완료되는 것이 보장된다면 큰 문제가 되지 않겠지만 작업이 매우 오래 걸리고 도중 실패할 수도 있다면, 이에 더해 작업이 요청자가 다른 작업을 못하게 만든다면 요청자는 기다린 시간이 너무 아까울 것이다.
비동기 작업은 콜백(callback) 처리를 수반할 수 있다. 콜백이란 요청한 작업이 완료되었을 때 추가로 실행할 코드를 의미한다. 메인 스레드가 특정 작업 처리를 스레드풀의 작업 스레드에게 할당하는 경우 작업 스레드가 작업을 완료하게 될 때 실행할 코드를 전달할 수 있다. 콜백 메서드는 작업 스레드가 요청한 작업을 완료하면 자동으로 호출된다. 빵 굽기와 계란 후라이 요리를 또다른 요리사에게 비동기적으로 요청하면서 완료되면 접시에 옮기라는 작업을 지시할 수 있다. 이 작업이 바로 콜백이다. 콜백은 이벤트 루프 모델(event loop model)을 사용하는 시스템에서 비동기적으로 처리되는 여러 작업들 각각의 후속 작업을 동기적으로 처리하기 위한 중요한 개념이다.
비동기 프로그래밍 구현
앞서 토스트기에서_빵굽기_함수2
의 실행 코드와 작업 실행 흐름은 다음과 같았다.
// 작업1: 요리사는 토스트기를 사용하여 빵을 굽는 동안 다른 작업을 수행할 수 있다.
구운빵 = 토스트기에서_빵굽기_함수2();
// 작업2: 요리사는 빵굽기가 완료되기 전에 계란 후라이 요리를 할 수 있다.
계란후라이 = 계란후라이_요리하기_함수();
// 작업3: 요리사는 빵굽기와 계란 후라이 요리가 모두 완료된 후 구운빵에 잼을 바르고 계란 후라이를 얹을 수 있다.
구운빵에_잼바르고_계란후라이_얹기_함수(빵, 계란후라이);
구운빵 = 토스트기에서_빵굽기_함수2();
,계란후라이 = 계란후라이_요리하기_함수();
구운빵에_잼바르고_계란후라이_얹기_함수(구운빵, 계란후라이);
위 예제에서는 비동기적 프로그래밍 구현이 단순화되어 있다. 프로그래밍 언어 마다 비동기 프로그래밍 구현 방식은 다르지만 작업들의 비동기적 실행을 위한 별도의 장치가 마련되어 있는 것이 일반적이다. 함수를 단순히 정의하고 호출하는 경우, 함수의 호출 순서대로 동기적으로 함수들이 실행된다. 동기적인 함수 호출을 통한 동기적인 작업 실행이 목적이라면 비동기 프로그래밍은 필요하지 않다.
많은 프로그래밍 언어에서 사용하고 있는 async
, await
키워드 및 Future
타입을 사용한 비동기 처리, 코루틴(coroutine)을 사용한 비동기 및 논블로킹 처리, 기타 동시성(concurrency) 처리를 살펴보겠다.
async, await 및 Future 타입
실행이 완료되는 것을 기다리지 않아도 되는 함수(기다려도 된다)는 비동기적인 함수이다. 비동기적인 함수는 결과를 반환하지 않을 수도, 반환할 수도 있다. 비동기 함수를 정의할 때 async
키워드를 사용하며 반환형은 Future
타입으로 선언한다. Future
는 비동기 작업의 최종 결과를 나타내는 객체이다. Future
객체는 함수가 실행 중인지, 실행 완료되었는지, 실행 중에 실패하였는지와 같이 작업 실행에 관련된 상태를 갖는다.
// 결과를 반환하는 비동기 함수
Future<T> asyncFunc1() async { ... }
// 결과를 반환하지 않는 비동기 함수
Future<void> asyncFunc2() async { ... }
비동기적인 함수를 호출하는 호출자가 결과를 반환 받아야 하는지에 따라 비동기적인 함수는 비동기적으로 실행될 수도 있고, 동기적으로 실행될 수도 있다.
- 호출자가 비동기 함수의 호출 결과를 반환 받아야 하는 경우, 비동기적인 함수는 동기적으로 실행되어야 한다.
- 호출자가 비동기 함수의 호출 결과를 반환 받지 않아도 되는 경우, 비동기적인 함수는 비동기적으로 실행되어야 한다.
비동기 함수를 동기적으로 실행하고 작업 실행 결과를 반환 받기 위해 함수 호출 시 await
키워드를 사용한다.
var result = await asyncFunc1();
비동기 함수를 비동기적으로 실행하고 작업 실행 결과를 반환 받을 필요가 없다면 함수 호출 시 await
키워드를 생략한다.
asyncFunc2();
비동기 함수를 호출하는 또다른 함수를 정의하는 경우 await
를 사용하여 동기적으로 실행할 것인지, 아닌지에 따라 함수의 정의 및 동작이 달라진다.
await
를 사용하여 비동기 함수를 동기적으로 실행하는 경우- 함수는 반드시
async
키워드를 사용하여 비동기 함수로 정의해야 한다. 이때, 함수 내에서 비동기 함수 호출 전까지의 코드(await
이전의 코드)는 동기적으로 실행된다. - 비동기 함수가 호출되면 작업이 완료될 때까지(
await
를 사용하여 비동기 함수의 작업 실행 결과를 반환 받아야 하므로) 현재 함수의 실행은 일시 중지된다. 이때, 비동기 함수는 요청 스레드를 블로킹하지 않으므로 요청 스레드는 다른 작업을 수행할 수 있다. - 비동기 함수의 작업 실행이 완료되면 이후의 코드(
await
이후의 코드)가 실행된다.
- 함수는 반드시
await
를 사용하지 않고 비동기 함수를 비동기적으로 실행하는 경우async
키워드를 사용하여 비동기 함수로 정의하거나 일반 함수로 정의할 수 있다. 이때, 함수 내에서 비동기 함수 호출 전까지의 코드는 동기적으로 실행된다.- 비동기 함수가 호출되면 현재 함수의 실행은 일시 중지되지 않는다. 이때, 비동기 함수는 요청 스레드를 블로킹하지 않으므로 요청 스레드는 다른 작업을 수행할 수 있다.
- 비동기 함수의 작업 실행이 완료되지 않더라도 이후의 코드가 실행된다.
다트, C#, 코틀린, 파이썬, 자바스크립트 등의 언어가 async
, await
키워드를 사용하여 비동기 함수를 선언 및 호출한다. 자바의 경우 async
, await
키워드를 사용하지 않으며 비동기 작업 실행 결과는 Future
에 담긴다. 자바스크립트의 경우 Future
용어 대신 Promise
를 사용한다. 파이썬의 경우 await
를 사용하지 않고 비동기 함수를 비동기적으로 실행할 수 없으며 asyncio
라이브러리를 사용해야 한다. 다른 언어의 경우에도 개념 및 동작 방식은 조금씩 다를 수 있다.
코루틴
async
, await
라는 키워드는
기타 동시성 처리
스레드 블로킹과 애플리케이션 성능
이와 같이 작업 중에는 요청자의 코드 실행 흐름인 스레드를 블로킹시키는 작업들이 있다. 예를 들어 데이터베이스 쿼리를 실행하여 결과를 응답으로 반환하는 JDBC는 블로킹 API이다. 이러한 블로킹 작업들은 단일 스레드(또는 제한된 수의 스레드) 환경에서 시스템의 성능을 저하시키거나 아예 불능 상태로 만들어 버리기도 한다.
스레드가 어떤 작업이 끝날 때까지 블로킹되지 않고 다른 작업을 수행할 수 있도록 만든다면 시스템 자원이 허용하는 한도 내에서 스레드 사용 효율을 극대화시킬 수 있다. 이러한 요구사항으로 인해 비동기 및 논블로킹 프로그래밍과 리액티브 프로그래밍이 등장하게 되었다.
스레드는 제한된 자원이다. CPU의 코어 수 보다 많은 수의 스레드를 사용하면 컨텍스트 스위칭으로 인한 오버헤드가 증가하므로 코어 수에 맞게 스레드 풀을 설정하도록 하는 것이 성능 효율 면에서 좋다. 하지만 현실적으로 요청해야 할 작업의 수는 많은데 코어와 스레드 수는 제한되어 있기 때문에 스레드를 블로킹하지 않는 논블로킹 방식으로 동작하도록 애플리케이션을 설계할 필요성이 있는 것이다.
Comments