안녕하세요! NewCodes입니다!
Node.js에서
`readFileSync`를 써보신 적이 있으신가요?
`readFileSync`는 특별한 경우를 제외하고
써서는 안 되는 함수입니다.
`readFileSync`를 쓰면
주방에 있는 셰프가 멈추는 것과도 같습니다.
그러면 주방이 돌아가질 않고, 음식이 늦게 나오겠죠?
Node.js에서도 마찬가지입니다.
여러분이 웹 애플리케이션 서버를 만들고 있는데
`readFileSync`를 썼다면
성능이 매우 떨어질 것입니다.
특히, 사용자가 동시적으로 몰린다면요!
이 글의 주요 목적은 단순히
'readFileSync를 쓰면 안 된다.'
에서 끝나진 않습니다.
소중한 main thread가 block되지 않게 하자!
를 전달하는 게 주요 목적입니다!!
우선 간단한 예제를 통해 포문을 열어보겠습니다.
💿 readFile과 readFileSync에 대해 알아보자!
readFile의 실행흐름
const fs = require('fs');
console.log('1. 파일 읽기 시작');
fs.readFile('example.txt', 'utf8', (err, data) => {
console.log('2. 파일 내용:', data);
});
console.log('3. 프로그램 종료');
위 코드를 실행하면 아래와 같이 출력됩니다.
1. 파일 읽기 시작
3. 프로그램 종료
2. 파일 내용: Hello, backend world!
코드의 순서대로라면 '2. 파일 내용'이 3번보다 먼저 실행되어야 할 것 같은데 막상 출력된 내용을 보면 3번이 먼저 출력되었습니다.
그 이유는 `readFile` 함수는 비동기이자 논블로킹 함수이기 때문입니다. 쉽게 말하면 파일 읽기는 다른 친구에게 맡겨두고 곧바로 3번 코드를 읽게 됩니다.
그런데 3번 코드가 제일 마지막에 출력되게 하고 싶다면 어떻게 해야할까요?
간단하면서 위험한 방법 중 하나는 `readFileSync`를 사용하는 것입니다.
readFileSync의 실행흐름
const fs = require('fs');
console.log('1. 파일 읽기 시작');
const data = fs.readFileSync('example.txt', 'utf8');
console.log('2. 파일 내용:', data);
console.log('3. 프로그램 종료');
위 코드를 실행하면 아래와 같이 출력됩니다.
1. 파일 읽기 시작
2. 파일 내용: Hello, backend world!
3. 프로그램 종료
우리가 원하는 대로 1번, 2번, 3번 순서대로 출력됐습니다!
이게 가능한 이유는 `readFileSync`는 동기이자 블로킹 함수이기 때문입니다. 쉽게 말하면 파일을 읽을 때까지 기다리며 다음 코드를 실행시키지 않습니다. 파일 읽기가 완료되면 그때서야 다음 코드를 읽기 시작합니다.
그런데도 왜 readFileSync를 쓰면 안 될까?
글의 제목을 보시다시피 `readFileSync`를 쓰면 안 된다는 걸 알려드리려 합니다. 우리가 원했듯이 순서대로 실행되게 도와주는 이 편한 함수를 왜 사용하면 안 되는 걸까요?
사용하면 안 되는 이유는 간단합니다. 서버의 성능이 느려지기 때문입니다. 특히 사용자의 요청이 동시적으로 들어왔을 때 그러합니다.
그러면 `readFileSync`를 쓰면 서버의 성능이 왜 느려질까요? 이를 알기 위해서는 Node.js의 아키텍처와 동작원리에 대해 이해해야 합니다. 이를 이해하지 못하고 코드를 작성하다 보면, Node.js 앱의 성능이 급격하게 떨어지는 경험을 하게 되실 겁니다.
이 이유를 이해하는 건 정말 정말 중요합니다! Node.js를 사용하고 있다면 꼭 알고 있어야 합니다!
💡 Node.js의 핵심인 libuv에 대해 알아봅시다!
libuv를 처음 본다면 이해하기 어려울 수 있습니다!
하지만 Node.js를 사용하는 개발자로서 이를 이해하는 건 정말 정말 중요합니다. 집중해서 살펴봅시다!
libuv는 어떤 역할을 하는가?
libuv는 Node.js 안에 있는 핵심 구성 요소입니다.
libuv만 제대로 알아도 Node.js의 절반 이상을 이해한 것과 다름없습니다.

libuv는 비동기 I/O를 처리하는 크로스 플랫폼 라이브러리입니다.
'크로스 플랫폼'이란 무엇일까요?

'크로스 플랫폼'이란 건 다양한 OS에서 동작할 수 있도록 지원한다는 의미입니다. JavaScript는 본래는 브라우저 위에서 동작하도록 만들어졌습니다. 하지만, 사람들은 다양한 OS 위에서도 JS를 실행시키고 싶었습니다. 이걸 가능하게 해 준 라이브러리가 바로 libuv입니다. 이를 통해 mac, Window, Linux 등에서도 JS를 실행시킬 수 있게 되었습니다.
'비동기 I/O'를 왜 처리해줄까요?

JavaScipt는 싱글 스레드이기에 I/O 작업이나 무거운 작업들을 처리하기에는 무리가 있기 때문입니다.
하나의 스레드가 무거운 작업을 열심히 처리하는 동안 다른 작업을 처리할 수 없게 됩니다. 무거운 작업이 있다면 이를 다른 스레드들에게 도와달라며 위임하는 게 좋은 방법이겠죠. 이걸 해주는 게 바로 libuv입니다! I/O 작업이나 CPU Intensive한 작업들을 비동기적으로 처리할 수 있게 지원합니다.
비동기 I/O를 처리하는 방식은 2가지가 있습니다.

첫 번째는 OS Kernel을 이용하는 Native async mechanism입니다. OS 커널에서 제공하는 api를 이용해 처리하는 것입니다. libuv는 최대한 OS 커널을 활용하려 합니다. 하지만, OS 커널에서 지원하는 비동기 api가 없다면 어떻게 해야 할까요?
이때, 두 번째 방식인 thread pool을 사용하여 처리할 수 있습니다. OS 커널에서 지원하는 게 없거나 CPU Intensive한 작업이라면, thread pool 사용해서 직접 실행합니다. 참고로 스레드 풀의 스레드 기본 개수는 4개로 설정되어 있습니다.
이러한 libuv 덕분에 웹에서만 작동했던 JS를 여러 플랫폼에서 쓸 수 있게 되었고, 오늘날 서버로써 많이 사용되고 있습니다.
+) JavaScript는 왜 싱글 스레드로 설계되었을까요?
간단한 언어를 만드는 게 목표였기 때문입니다. 자바스크립트가 만들어질 때 당시 목표는 비개발자도 동적인 웹을 쉽게 만들 수 있도록 하는 대중들을 위한 언어를 만드는 것이었습니다. 또한, 초창기 웹 조작이라 함은 정적 컨텐츠 정도를 다루는 간단한 작업이었습니다. 복잡한 작업들이 필요로 되지 않았기에 싱글 스레드로도 충분했을 것입니다.
libuv의 핵심 요소인 event loop에 대해 알아보자!
앞에서 libuv에 대해 개괄적으로 말씀드렸는데요. 조금 더 자세히 살펴보시죠!
libuv에서의 핵심 요소는 event loop입니다. event loop의 주 역할은 2가지입니다.
첫 번째 역할은 I/O를 polling하는 것입니다. I/O 작업이 완료되었는지 주기적으로 확인합니다. 참고로 이때 linux의 epoll과 같은 시스템콜을 활용합니다.
두 번째 역할은 완료된 I/O의 결과를 어떻게 처리할지 스케줄링하는 것입니다. 완료된 비동기 작업들 중 어떤 유형의 작업부터 콜백을 실행시킬지 결정합니다. 즉, 비동기 작업이 완료됐다는 이벤트를 받으면 적절한 처리를 해주는 게 event loop입니다.
그리고 이 event loop를 실행시키는 건 단 하나의 스레드입니다.

이 스레드는 JavaScript 코드를 실행하면서도 event loop를 실행합니다. 이때 event loop를 항상 돌리는 게 아니라, 콜스택이 비어있을 때만 event loop를 돌립니다.
이러한 main thread(single thread)의 역할을 정리해봅시다.
- JavaScript 코드를 실행하며, 비동기 작업은 libuv에게 맡기기
- libuv의 event loop를 돌리며 비동기 작업의 콜백 실행
이러한 main thread가 잠시라도 멈추면 어떻게 될까요?
프로그램 성능에 상당히 치명적일 것입니다!!!
그런데 이를 멈추게 하는 메서드가 있습니다. 바로 `readFileSync`입니다!! 이제 사용하면 안 되는 이유가 점점 감이 오시나요?
🎯 그래서 readFileSync를 쓰면 안 되는 이유
Node.js의 동작원리에 대해서 이해했다면, 이제 드디어 `readFileSync`를 쓰면 안 되는 이유에 대해 정리해봅시다.
readFile을 실행하면 Node.js에서 일어나는 일
- JS 코드 중 `readFile` 실행
- Node.js Bindings를 통해 libuv로 요청
- libuv 측에서 OS 커널을 통해 해당 작업 실행
- `readFile`이 비동기적으로 이루어지고 있는 동안, 나머지 JS 코드 실행
- (완료된 비동기 작업에 대해 큐에 삽입)
- 모든 JS 코드 실행이 끝나 콜스택이 비어있으면, event loop를 통해 큐에 있는 콜백 실행
readFileSync을 실행하면 Node.js에서 일어나는 일
- JS 코드 중 `readFileSync` 실행
- libuv에게 넘기지 않고 main thread가 직접 동기적으로 파일 읽음
- 파일을 읽는 동안 다음 JS 코드를 실행하지 못하고, 파일 읽는 작업이 완료될 때까지 기다리기
- 파일 읽는 작업 완료되면 다음 코드 실행
readFileSync를 쓰면 안 되는 이유 정리
`readFileSync`를 쓰면 main thread가 직접 파일 읽기 작업을 동기적으로 처리합니다. 그렇기에 main thread는 곧바로 다음 코드를 실행할 수 없습니다. 즉, main thread를 block하는 거나 마찬가지입니다. 파일을 읽는 동안 사용자로부터 새로운 요청 또한 바로 처리할 수 없습니다. 이는 서버에 심각한 성능 저하를 불러일으킵니다.
반면에 `readFile`을 쓰면 libuv가 대신 비동기적으로 작업을 처리합니다. 이 동안 서버는 다음 코드를 실행하거나 사용자의 새로운 요청을 받아줄 수 있습니다.
물론 `readFileSync`를 절대 쓰면 안 되는 건 아닙니다. 써도 되는 경우가 딱 하나 있긴 합니다. 단발적으로 실행시켜야 하는 경우입니다. 예를 들어, 서버를 처음 초기화할 때 읽어야 하는 파일이 있다면 이때는 써도 됩니다. 딱 한 번만 실행되고 이후에는 실행이 안 되기에 서버 성능에 영향을 주는 요소가 아니기 때문입니다.
Q. readFileSync을 안 쓰면 동작 순서는 제어하나요?
동작 순서를 보장하고 싶다면 콜백 혹은 Promise&await을 활용하면 됩니다.
먼저 콜백을 사용하는 경우입니다. 간단히, 콜백 함수 안에 3번 출력 코드를 포함시키면 1 -> 2 -> 3 출력 순서가 보장될 것입니다.
const fs = require('fs');
console.log('1. 파일 읽기 시작');
fs.readFile('example.txt', 'utf8', (err, data) => {
console.log('2. 파일 내용:', data);
console.log('3. 프로그램 종료'); // <- 콜백 안에 둠으로써 1->2->3 보장
});
두 번째는 Promise와 await을 사용하는 것입니다. Promise를 반환하는 함수를 호출할 때 await를 걸어주면 마치 동기적으로 실행되는 것처럼 보이게 됩니다.
const fs = require('fs/promises'); // Promise를 반환하는 fs 모듈로 불러오기
async function readFileExample() { // await은 async 함수 안에서만 쓸 수 있음
console.log('1. 파일 읽기 시작');
const data = await fs.readFile('example.txt', 'utf8'); // 파일 읽어서 결과 반환할 때까지 대기
console.log('2. 파일 내용:', data);
console.log('3. 프로그램 종료');
}
readFileExample();
`await readFile()`과 `readFileSync()`의 차이점은 무엇일까요?
출력 순서는 같아 보이더라도 내부 동작은 완전히 다릅니다!
`readFile`은 비동기 I/O이기에 libuv가 대신 처리해줍니다. 그리고 await가 걸어져 있기 때문에 다음 코드를 실행시키지 않고 기다립니다. 이때 기다리는 동안 main thread를 쓸 수 있기 때문에 다른 사용자의 요청이 들어오면 받아서 실행할 수가 있습니다.
반면, `readFileSync`는 main thread가 직접 파일을 읽고 있기에 다른 사용자 요청이 들어와도 처리해 줄 수가 없죠.
겉으로 봤을 때는 동일하게 동작하는 코드이지만, 이렇게 내부적으로는 큰 차이가 있습니다!!
Node.js Architecture 정리

다시 아키텍처를 보며 정리해보겠습니다. 위에서 설명하지 못했던 부분도 간략히 언급하도록 하겠습니다.
- application: Node.js로 작성한 우리의 코드를 의미합니다.
- V8: JS 엔진으로 app을 실행시키는 역할을 합니다.
- Node.js Bindings: JS를 C++로 변환시켜주는 역할을 합니다. Libuv는 C++로 작성되어 있기에 변환이 필요합니다.
- Libuv: 비동기 I/O를 지원하는 크로스 플랫폼 라이브러리입니다. Node.js에서 무거운 작업을 비동기적으로 실행시켜주고, 다 실행이 되면 이를 알려줍니다.
- Event Loop: I/O를 polling 하고, 완료된 비동기 작업의 콜백을 실행시킵니다.
- Thread Pool: OS kernel 수준에서 지원이 안 되는 작업 혹은 CPU Intensive한 작업을 실행합니다. 스레드 기본 개수는 4개입니다.
📌 마무리
요약
- Node.js에서 비동기 I/O 혹은 무거운 작업은 libuv가 대신해준다.
- Node.js의 main thread가 비동기 I/O 혹은 무거운 작업을 직접 하게 해서는 안 된다.
- `readFileSync`를 쓰면 main thread가 I/O 작업을 처리하면 block되기에 다른 요청을 받아줄 수 없다.
- 따라서, `readFileSync`와 같은 동기/블로킹 함수는 특수한 경우가 아니라면 사용하지 말자.
더 알아보기 - Reactor 패턴
libuv에서는 비동기 작업이 완료되어 이벤트가 발생하면 큐에 삽입되고 콜백을 호출한다고 했습니다. 이러한 패턴을 Reactor Pattern이라고 합니다. 그리고 이 Reactor Pattern의 핵심 구성 요소가 event loop인 것입니다.
이러한 패턴은 Node.js에만 있는 게 아닙니다. 대표적으로 Redis와 NginX가 이러한 Reactor Pattern을 사용하고 있습니다. 이 둘 모두 성능이 좋기로 유명한 기술이며 Reactor Pattern 덕분입니다.
아래 그림은 Redis의 내부 동작 과정을 간단히 나타낸 그림입니다.

보시다시피 single thread를 통한 event loop 방식으로 동작하는 걸 볼 수 있습니다. NginX도 마찬가지이니 궁금하신 분들은 더 찾아보시는 걸 권합니다!
교훈 - main thread가 block되지 않게!!
- JS의 main thread는 작고 소중하다.
- 이를 힘들게 하지 말고 아껴주자.
- 비동기 작업을 최대한 활용하자.
`readFileSync` 말고도 또 다른 원인으로 main thread가 block되는 경우가 있습니다. 우선 `readFileSync`와 같은 동기 I/O함수들이 기본적으로 block을 유도할 수 있습니다. 또한 암호화, 해시 계산, 압축과 같은 CPU Intensive한 작업이 있을 수 있습니다. 이러한 작업들을 해야 할 때는 main thread를 아껴주고 비동기 작업을 최대한 활용해야 합니다!
레퍼런스
- https://docs.libuv.org/en/v1.x/design.html
- https://docs.libuv.org/en/v1.x/loop.html
- https://youtu.be/L18RHG2DwwA?si=QdC1DU1kmV4OXWn3
- https://en.wikipedia.org/wiki/JavaScript
- https://groovetechnology.com/blog/technologies/why-javascript-is-single-threaded/
- https://en.wikipedia.org/wiki/Reactor_pattern