2025.8.4: 동기와 비동기

jiyongg·2025년 8월 4일

TIL: Today I Learned

목록 보기
14/30

저번 주에, 이 글에서 간단한 웹 애플리케이션을 만들어 보고 있다고 적었다. 주말동안, 그 웹 앱 프로젝트를 진행하며 코드를 좀 짰다.

fetch를 통해 백엔드에서 가져온 데이터를 가지고 컴포넌트를 만드는 코드를 작성하고 있었는데, 자꾸 Promise라는 것이 나를 괴롭혔다. 내가 원하는 흐름대로 코드를 작성하기 힘들었다.

Promise에 대한 공식 문서의 첫 줄은 아래와 같이 적혀 있었다.

Promise 객체는 비동기 작업이 맞이할 미래의 완료 또는 실패와 그 결과 값을 나타냅니다.

비동기 작업? 대충 수박 겉핥기로 들어본 적은 있는 개념이었다. 그런데, 비동기 함수에서 값을 꺼낼 수는 없는 것인가? 왜 Promise에 대한 작업은 항상 Promise를 반환하는 것이지? 이러한 질문들이 동기와 비동기에 대한 공부의 필요성으로 이어졌다.

1. 🖼️ 배경 지식

일단 동기와 비동기를 쓰는 이유는 운영체제가 어떻게 작업을 스케쥴링하는가와 관련이 있다. 그리고 이것을 이해하려면 먼저 프로세스, 스레드라는 개념을 이해할 필요가 있다.

내가 아직 운영 체제 과목을 공부한 게 아니라, 인터넷 이곳저곳을 뒤지면서 최대한 이해한 내용을 정리한 것이라 틀릴 수도 있으니 너무 맹신하진 말기를...

1) 하드웨어 프로세서의 코어와 스레드

컴퓨터 견적을 볼 때 8코어 16스레드.. 16코어.. 뭐 이런 말을 본 적이 있을 것이다

코어는 프로세서에서 프로그램의 명령을 읽고 수행하는 독립적인 처리 장치를 의미한다. 스레드는 그 코어에 있는 논리적 처리 단위를 의미한다.

전통적인 프로세서는, 코어:스레드=1:1의 개념이었지만, 기술이 발전함에 따라 동시 멀티스레딩(Simultaneous Multi-Threading, SMT) 기술이 개발되었다.

프로세서의 코어에서 자원이 활용되지 않는 시간이 생기는데, 이 시간에 다른 스레드의 명령을 실행해서 최대한 코어의 자원을 활용하자는 개념이다.

(그림 출처: Simultaneous Multithreading: Driving Performance and Efficiency on AMD EPYC CPUs)

위 그림에서 위쪽은 전통적인 코어의 작업 흐름이고, 아래는 SMT가 적용된 코어의 작업 흐름이다. 위에서는 군데군데 빈 곳이 있어 자원 활용률이 떨어지는데, 이에 비해 아래에서는 코어의 자원 활용률이 더 높은 것을 볼 수 있다.

OS는 SMT 기술이 적용된 프로세서의 스레드들을 논리적 코어로 인식한다. 그래서 SMT 기술이 적용된 프로세서를 OS에서 확인해보면 실제 코어와 코어 개수가 다른 것을 볼 수 있다. 그리고, 스레드들을 논리적 코어로 인식하므로 각 코어에는 1개의 작업이 아니라 코어의 스레드 개수만큼의 작업이 배정되게 된다.

2) 소프트웨어의 프로세스와 스레드

소프트웨어에서 프로세스란 실행중인 프로그램의 인스턴스를 의미한다. 스레드는 그 프로세스 내의 작업 흐름의 단위를 의미한다.

3) CPU 스케쥴링

CPU 스케쥴링은 운영체제가 어떤 작업이나 프로세스에 CPU를 사용할 수 있도록 하는 작업을 의미한다. 싱글코어에서는 어떤 프로세스로 교체할지만을 고려하면 되었지만, 프로세서들이 대체로 멀티코어인 요즘은 어느 코어에 배정할 것인가도 고려해야 한다.

4) 리눅스에서의 스케쥴링

리눅스의 커널 스케쥴러는 작업(task)을 스케쥴링한다. 이때 작업의 기준은 아래와 같다.

  • 싱글 스레드 프로세스
  • 멀티 프로세스 내의 어떤 하나의 스레드
  • 커널 작업

이 작업의 기준에서 알 수 있는 것은, 하나의 스레드를 작업의 기준으로 보고 있다는 것이다. 또한, 스레드를 스케쥴링하므로 같은 프로세스 내의 스레드가 서로 다른 코어에서 동작할 수 있다는 것이다. 만약 프로세스 내의 공유 자원이 필요하다면 프로세스에 할당된 메모리 주소가 공유되기 때문에 이 주소를 이용할 수 있다.

2. 📝 요약

  • 동기와 비동기는 Blocking의 여부로 구분되는 개념이다. 동기의 경우 실행중인 작업이 다른 작업을 막지만, 비동기는 실행중인 작업이 다른 작업을 막지 않는다.
  • 콜백 함수 자체가 필연적으로 비동기인 것은 아니지만, 콜백 함수는 비동기 프로그래밍에 도움을 준다.

3. 🖥️ 동기와 비동기

핵심은 Blocking이 발생하는가 그렇지 않은가이다.

(그림 출처: Synchronous vs Asynchronous Programming)

그림에서 동기의 경우 한 작업이 시작되려면 먼저 진행중인 작업이 끝날 때까지 기다려야 한다. 하지만, 비동기의 경우 그렇지 않다. 먼저 진행중인 작업이 끝나지 않아도 다른 작업을 실행할 수 있다.

동기의 장점과 단점

  • 장점
    • 동기로 실행되는 작업은 순서대로 실행되기 때문에, 흐름을 파악하기 쉽다.
    • 적은 연산을 요구하는 짧은 작업의 경우 동기를 사용하는 것이 유리하다.
  • 단점
    • 먼저 실행한 작업이 끝날 때까지 다른 작업의 실행이 막힌다. 이것을 Blocking이라고 한다.
    • 만약 먼저 실행한 작업이 매우 긴 작업이고 그 뒤의 작업이 독립적인 짧은 작업이라면, 짧은 작업을 별도로 처리하면 유리할 것이다. 하지만, 동기는 그렇게 처리할 수 없으니 이런 상황에서 비효율적이다.

비동기의 장점과 단점

  • 장점
    • 다른 작업의 실행을 막지 않는다.
    • 많은 연산을 요구하는 복잡한 작업의 경우 비동기가 유리하다.
  • 단점
    • 동기와 다르게 작업이 순서대로 실행되지 않으므로 작업의 흐름을 이해하기 어려울 수 있다.
    • 만약 여러 작업이 어떤 데이터나 리소스에 동시에 접근하려고 하면 경쟁 상태(Race Condition)가 발생하여 예상과 다른 결과가 나타날 수 있다.

콜백 함수

콜백 함수는 어떤 함수에 인자로 전달되는 함수를 뜻한다. 인자로 전달된 콜백 함수는 그 인자를 받은 함수에서 필요에 의해 호출되게 된다.

const callbackFn((arg) => {
    console.log(arg);
}

const myFunction(arg1, arg2, callback) => {
    let sumResult = arg1 + arg2;
    callback(sumResult);
}

myFunction(1, 2);

대충 이런 자바스크립트 코드가 있다고 생각해보자. myFunction12를 더한 후 이를 sumResult에 할당한 후 sumResult를 인자로 하여 callbackFn을 호출하게 된다. 그러면 callbackFnconsole.log로 콘솔에 sumResult를 출력하게 된다. 이처럼 콜백 함수는 인자로 넘어가 그 인자를 받은 함수에서 호출된다.

콜백 함수를 구글링을 해보면 대체로 비동기 프로그래밍과 함께 묶여서 등장하는 것을 볼 수 있다. 그래서 콜백 함수에 대해 이런 궁금증이 들 수 있다. 그렇다면 콜백 함수는 필연적으로 비동기인 것인가..?

콜백함수를 호출하는 방법에는 "synchronous" 및 "asynchronous" 두 가지가 있습니다. 동기식 콜백(synchronous callbacks)은 중간에 비동기 작업 없이 외부 함수 호출 직후에 호출되는 반면에, 비동기식 콜백(asynchronous callbacks)은 asynchronous 작업이 완료된 후 나중에 호출됩니다.

MDN의 콜백 함수 문서를 인용한 것이다. 위 설명은 콜백 함수 자체는 필연적으로 비동기인 것은 아니라는 사실을 알려준다. 하지만 콜백 함수가 비동기 프로그래밍에 도움을 준다는 것은 사실이다.

비동기는 아니지만 비동기 프로그래밍에 도움을 준다?

콜백 함수 자체는 필연적으로 비동기이지는 않은데, 콜백 함수가 비동기 프로그래밍에 도움을 준다는 게 무슨 뜻일까?

콜백 함수는 비동기 내에서 순서를 보장해준다. 이 이야기에는 setTimeout 함수가 빠지지 않고 등장한다. setTimeout 함수는 타이머를 설정하고, 그 타이머가 끝나면 functionRef를 실행한다. setTimeout 함수는 비동기 함수의 대표적인 예시이다. 즉, 타이머가 끝날 때까지 기다리지 않고 다른 작업을 진행할 수 있다는 것이다. 그렇다면, 타이머가 끝난 후에 어떤 함수를 실행시키려면 어떻게 해야 할까? 바로 이럴 때 콜백 함수를 활용하는 것이다.

비동기를 동기처럼 처리하기 위해서 await를 쓸 수 있지 않을까라고 생각할 수도 있다. 그런데 setTimeoutPromise를 반환하지 않는다.

MDN의 await 설명을 보자.

await 연산자는 Promise를 기다리기 위해 사용됩니다. 연산자는 async function 내부에서만 사용할 수 있습니다.

그러니까 Promise를 반환하지 않는 setTimeout에서는 콜백이 필요할 수밖에 없는 것이다.

const myFunc = async () => {
    await setTimeout(() => {}, 5000);
    console.log("5초 지났음");
}

실제로 이 코드를 실행시켜보면 setTimeoutPromise를 반환하지 않으므로 await가 예상대로 작동하지 않아 5초가 지나지 않았는데 5초가 지났다고 거짓말하는 콘솔을 볼 수 있다. 또한 setTimeout 말고도 콜백 함수는 Promise.then 메소드와 같은 곳에서도 유용하게 사용되는데, 여기서도 콜백 함수는 Promise가 이행되거나 실패한 후에 실행되므로 비동기 내에서 순서를 보장해준다.

4. 🔚 결론

이때까지 공부한 내용 중에 제일 정리가 힘들었다. 자정 전에 글을 못 쓸 것이라는 예감이 강하게 왔다. 워낙 헷갈리는 개념이 많았다. 자료 찾으면서 탭을 100개 정도는 띄운 것 같다. 위의 내용을 쓰는 동안에도 조금이나마 더 정확한 정보를 적기 위해서 계속 구글링하면서 새로운 정보를 찾았다. 다루진 않았지만 비동기, Conurrency, Parallel의 차이라든지, 윈도우의 경우 어떻게 CPU 스케쥴링을 하는지.. (검색 결과에 따르면 스레드 단위로 한다고 한다)

사실 원래는 파이썬의 동기, 비동기를 다루고 싶었다. 그 전에 일반적인 동기와 비동기를 배경지식 삼아서 알아봐야겠다고 생각했는데, 파면 팔수록 단순한 배경지식 그 이상의 내용이어서 이렇게 일반적인 동기와 비동기 개념을 따로 정리해보게 되었다. Promise로 시작된 나비효과가 여기까지 와버렸다..

찾다가 알게 된 것인데, Promise의 콜백 함수는 마이크로태스크 큐에 들어간다는데, 이것에 대해서도 나중에 알아봐야겠다.

5. 📚 참고 자료

profile
그냥 쓰고 싶은 것 쓰는 개발(?) 블로그

0개의 댓글