PHpullh

JAVASCRIPT · 심층 가이드

JavaScript 파일/IO 완전 정리

Node 환경에서 파일과 표준 입력, 환경 변수, 경로를 다루는 15가지 코드를 엮어 설정 로더와 로그 회전 같은 실제로 돌아가는 스크립트를 완성합니다.

주제 15개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

브라우저에서 자란 JavaScript에 파일 입출력이 붙은 것은 Node 이후의 일이고, 그 흔적이 API 곳곳에 남아 있습니다. fs 하나에 콜백 방식, fs/promises, 그리고 readFileSync 계열이 공존합니다. 어느 쪽을 쓰느냐에 따라 코드 모양이 완전히 달라지므로, 짧은 CLI 스크립트인지 서버 요청 경로인지부터 정하고 시작해야 합니다. 서버라면 동기 API는 이벤트 루프를 그대로 멈춥니다.

텍스트 파일 읽기와 텍스트 파일 쓰기가 기본형이고, 텍스트 파일 추가 쓰기는 플래그 하나만 바뀝니다. 실무 조합은 환경 변수 읽기, 명령행 인자, JSON 저장을 엮은 설정 로드에서 완성되고, 경로 합치기와 디렉터리 목록은 그 위에서 파일을 찾아 주는 역할을 합니다. 마지막 로그 파일 회전 흉내는 앞의 조각들을 한 번에 쓰는 종합 예제이니 마무리로 보시기 바랍니다.

존재 여부 확인 후 열기는 전형적인 경쟁 조건입니다. 확인과 열기 사이에 파일이 사라질 수 있으므로, 그냥 열고 ENOENT를 잡는 편이 정확합니다. 인코딩 인자를 빼먹으면 문자열이 아니라 Buffer가 돌아오고, 여기에 문자열 메서드를 쓰면 엉뚱한 결과가 납니다. 경로는 문자열 이어붙이기 대신 path.join을 쓰고, 사용자 입력에서 만들어진 경로라면 상위 디렉터리 탈출을 막기 위해 정규화 후 접두사를 검사해야 합니다.

01텍스트 파일 읽기

readFileSync로 package.json을 UTF-8 문자열로 읽은 뒤 앞 20자만 잘라 출력합니다. 설정처럼 작고 시작 시점에 한 번만 읽는 파일에 어울립니다. 인코딩 인자를 빼면 문자열 대신 Buffer가 돌아오고, 동기 호출이라 읽는 동안 이벤트 루프가 멈춥니다.

JavaScript code

// 텍스트 파일 읽기
import { readFileSync } from "node:fs";
console.log(readFileSync("package.json", "utf8").slice(0, 20));
알아두면 좋은 점

상대 경로는 스크립트 위치가 아니라 process.cwd()를 기준으로 풀립니다. 코드와 함께 두는 자원은 import.meta.url로 절대 경로를 만드는 편이 안전합니다.

자주 하는 실수

요청 처리 중에 readFileSync를 부르면 디스크 응답이 올 때까지 이벤트 루프 전체가 멈춰, 다른 사용자의 요청까지 함께 지연됩니다.

02텍스트 파일 쓰기

writeFileSync가 sample-2.txt를 만들어 줄바꿈이 붙은 hello를 UTF-8로 기록합니다. 결과 파일을 곧바로 확인하고 싶은 스크립트에 적합합니다. 기본 동작이 덮어쓰기라 같은 경로에 다시 쓰면 이전 내용은 남지 않습니다.

JavaScript code

// 텍스트 파일 쓰기
import { writeFileSync } from "node:fs";
writeFileSync("sample-2.txt", "hello\n", "utf8");
알아두면 좋은 점

{ flag: "a" } 옵션을 주면 덮어쓰기 대신 이어쓰기가 되고, { flag: "wx" }는 파일이 이미 있을 때 실패시켜 덮어쓰기를 막아 줍니다.

자주 하는 실수

쓰는 도중 프로세스가 죽으면 반쯤 기록된 파일이 남습니다. 중요한 파일은 임시 이름으로 쓴 뒤 이름을 바꿔 넣어야 원본이 깨지지 않습니다.

03텍스트 파일 추가 쓰기

path.join이 logs, 2026, app-3.log를 운영체제 구분자로 이어 붙인 경로를 출력합니다. 로그 파일 위치를 코드로 조립할 때 씁니다. 문자열을 +로 붙이면 윈도우에서 구분자가 어긋나지만, join은 중복된 슬래시까지 정리해 줍니다.

JavaScript code

// 텍스트 파일 추가 쓰기
import path from "node:path";
console.log(path.join("logs", "2026", "app-3.log"));
알아두면 좋은 점

path.join은 ..을 만나면 앞 조각을 지워 경로를 정규화합니다. 반면 path.resolve는 절대 경로가 될 때까지 현재 디렉터리를 앞에 붙입니다.

자주 하는 실수

조각 중 하나라도 undefined면 path.join이 TypeError를 던집니다. 설정에서 읽은 값을 그대로 넘기기 전에 기본값을 채워야 합니다.

04JSON 저장

NODE_ENV 값과 process.argv의 세 번째 항목부터를 한 객체로 묶어 출력합니다. 실행 환경과 전달된 옵션을 한눈에 볼 때 쓰입니다. ??는 값이 null이나 undefined일 때만 기본값을 주므로, 빈 문자열로 설정된 변수는 그대로 통과합니다.

JavaScript code

// JSON 저장
console.log({ env: process.env.NODE_ENV ?? "development", args: process.argv.slice(2) });
알아두면 좋은 점

process.argv는 그냥 문자열 배열이라, 옵션 해석이 필요하면 node:util의 parseArgs를 쓰면 직접 파싱하지 않아도 됩니다.

자주 하는 실수

설정 객체를 그대로 로그로 찍으면 환경 변수에 담긴 토큰이나 비밀번호가 함께 남습니다. 출력 전에 민감한 키를 걸러 내야 합니다.

05CSV 파싱

여기서도 파일 전체를 메모리에 올린 다음 slice(0, 20)로 앞부분만 보여 줍니다. CSV처럼 줄 단위로 다루는 데이터라면 이 방식은 파일 크기만큼 힙을 쓰므로, 수백 MB를 넘어가면 createReadStream으로 나눠 읽는 편이 안전합니다.

JavaScript code

// CSV 파싱
import { readFileSync } from "node:fs";
console.log(readFileSync("package.json", "utf8").slice(0, 20));
알아두면 좋은 점

CSV는 따옴표 안에 구분자나 줄바꿈이 들어갈 수 있어 split만으로는 정확히 나뉘지 않습니다. 형식이 단순하다고 확신할 때만 직접 자를 수 있습니다.

자주 하는 실수

문자열을 slice로 자르면 코드 단위 기준이라, 이모지처럼 두 단위를 차지하는 문자가 중간에서 잘려 깨진 글자가 남습니다.

06환경 변수 읽기

환경 변수를 다루는 자리이지만 코드가 실제로 하는 일은 파일 쓰기여서, sample-6.txt에 짧은 문자열 하나가 저장됩니다. 빌드 산출물이나 캐시를 남길 때 이 한 줄이면 충분합니다. 상위 디렉터리가 없으면 ENOENT로 실패하므로 mkdirSync를 먼저 불러야 합니다.

JavaScript code

// 환경 변수 읽기
import { writeFileSync } from "node:fs";
writeFileSync("sample-6.txt", "hello\n", "utf8");
알아두면 좋은 점

쓰기 전에 mkdirSync(dir, { recursive: true })를 부르면 디렉터리가 이미 있어도 오류가 나지 않아 존재 확인을 따로 하지 않아도 됩니다.

자주 하는 실수

상대 경로로 파일을 쓰면 실행한 디렉터리에 따라 산출물이 여기저기 흩어집니다. 스크립트를 다른 위치에서 부르는 순간 이전 결과를 찾을 수 없습니다.

07명령행 인자

명령행 인자와는 달리, 이 코드는 세 조각을 합쳐 logs/2026/app-7.log 형태의 상대 경로를 만듭니다. 인자로 받은 하위 경로를 붙일 때 유용하지만 사용자 입력이 섞이면 ..로 상위 디렉터리를 벗어날 수 있어, path.resolve 결과를 검사해야 합니다.

JavaScript code

// 명령행 인자
import path from "node:path";
console.log(path.join("logs", "2026", "app-7.log"));
알아두면 좋은 점

경로가 프로젝트 밖으로 나가는지 검사할 때는 path.resolve로 절대 경로를 만든 다음 기준 디렉터리로 시작하는지 확인하는 방식이 흔합니다.

자주 하는 실수

사용자 입력을 검사 없이 path.join에 넘기면 ../../이 섞인 값으로 상위 디렉터리의 파일까지 읽히게 됩니다.

08경로 합치기

출력되는 객체에는 환경 이름과 인자 배열이 함께 담깁니다. argv의 앞 두 개는 노드 실행 파일과 스크립트 경로라서 slice(2)로 걷어 냅니다. 환경 변수 값은 언제나 문자열이므로 숫자로 쓰려면 Number() 변환을 거쳐야 합니다.

JavaScript code

// 경로 합치기
console.log({ env: process.env.NODE_ENV ?? "development", args: process.argv.slice(2) });
알아두면 좋은 점

process.env에 값을 대입하면 그 프로세스와 이후 만들어지는 자식 프로세스에는 반영되지만, 이미 떠 있는 다른 프로세스에는 전달되지 않습니다.

자주 하는 실수

process.env.PORT를 숫자처럼 쓰면 "3000" + 1이 "30001"이 됩니다. 환경 변수는 언제나 문자열이라는 점을 잊기 쉽습니다.

09디렉터리 목록

디렉터리 목록 대신 package.json 한 개를 동기로 읽어 첫 20자를 확인하는 코드입니다. 목록을 훑어 각 파일을 들여다볼 때 이런 읽기를 반복하게 되는데, readFileSync는 호출마다 열기와 닫기를 모두 수행하므로 파일 수가 많으면 비동기 버전이 낫습니다.

JavaScript code

// 디렉터리 목록
import { readFileSync } from "node:fs";
console.log(readFileSync("package.json", "utf8").slice(0, 20));
알아두면 좋은 점

readdirSync(dir, { withFileTypes: true })는 이름 대신 항목 객체를 돌려주어, 파일인지 디렉터리인지 따로 확인하지 않고 바로 알 수 있습니다.

자주 하는 실수

디렉터리 목록의 순서는 파일 시스템에 따라 달라 정렬이 보장되지 않습니다. 출력 순서가 중요하다면 직접 정렬해야 환경마다 결과가 달라지지 않습니다.

10임시 파일

임시 파일을 다루는 맥락이지만 코드는 고정된 이름 sample-10.txt에 hello를 씁니다. 진짜 임시 파일이라면 이름 충돌과 뒷정리가 문제가 되므로 os.tmpdir()과 난수 이름을 조합합니다. 동기 쓰기는 디스크 응답이 끝날 때까지 다른 작업을 막습니다.

JavaScript code

// 임시 파일
import { writeFileSync } from "node:fs";
writeFileSync("sample-10.txt", "hello\n", "utf8");
알아두면 좋은 점

os.tmpdir() 아래에 mkdtempSync로 디렉터리를 만들면 이름 충돌 없이 작업 공간이 생기고, 끝난 뒤 그 디렉터리만 지우면 정리가 끝납니다.

자주 하는 실수

고정된 이름으로 임시 파일을 쓰면 같은 스크립트를 두 개 돌릴 때 서로의 내용을 덮어씁니다. 병렬 실행 환경에서 재현이 어려운 버그가 됩니다.

11존재 여부 확인

경로 조각을 이어 붙여 출력할 뿐, 그 파일이 실제로 있는지는 확인하지 않습니다. 존재 확인이 필요하면 existsSync나 fs.stat을 따로 불러야 합니다. 확인과 열기 사이에 파일이 사라질 수 있으니, 곧바로 열고 오류를 처리하는 편이 견고합니다.

JavaScript code

// 존재 여부 확인
import path from "node:path";
console.log(path.join("logs", "2026", "app-11.log"));
알아두면 좋은 점

존재를 확인하는 대신 바로 열고 ENOENT 오류를 처리하면, 확인과 사용 사이에 상태가 바뀌는 경합을 아예 피할 수 있습니다.

자주 하는 실수

existsSync가 참이어도 읽기 권한이 없으면 열 때 EACCES로 실패합니다. 존재 확인은 접근 가능 여부까지 보장하지 않습니다.

12표준 입력 읽기

표준 입력 대신 프로세스가 이미 들고 있는 정보, 즉 환경 이름과 명령행 인자를 찍어 봅니다. 파이프로 들어오는 데이터를 받으려면 process.stdin을 비동기로 소비해야 하며, 이 코드처럼 동기 값만 읽는 방식으로는 표준 입력을 얻을 수 없습니다.

JavaScript code

// 표준 입력 읽기
console.log({ env: process.env.NODE_ENV ?? "development", args: process.argv.slice(2) });
알아두면 좋은 점

파이프 입력은 for await (const chunk of process.stdin)으로 조각씩 받을 수 있고, 인코딩을 지정하지 않으면 Buffer가 그대로 넘어옵니다.

자주 하는 실수

표준 입력을 읽는 코드를 넣어 두고 파이프 없이 실행하면 프로그램이 입력을 기다리며 멈춥니다. 대화형 여부는 isTTY로 구분해야 합니다.

13설정 로드

설정을 불러올 때처럼 package.json을 읽어 문자열 앞 20자를 출력합니다. 읽은 값은 아직 문자열이라 JSON.parse를 거쳐야 객체가 되며, 잘라 낸 조각은 중간에서 끊겨 파싱되지 않습니다. 동기 호출은 기동 시 한 번 읽는 용도로만 씁니다.

JavaScript code

// 설정 로드
import { readFileSync } from "node:fs";
console.log(readFileSync("package.json", "utf8").slice(0, 20));
알아두면 좋은 점

JSON.parse는 주석과 후행 쉼표를 허용하지 않으므로, 사람이 직접 편집하는 설정 파일이라면 이 제약을 미리 알려 두어야 합니다.

자주 하는 실수

JSON.parse가 던지는 오류 메시지에는 파일 이름이 들어 있지 않습니다. 설정을 여러 개 읽는다면 잡아서 경로를 덧붙여야 원인을 찾을 수 있습니다.

14직렬화

직렬화 결과를 저장하듯 문자열 하나를 sample-14.txt로 내보냅니다. 객체를 남기려면 JSON.stringify로 문자열을 만든 뒤 넘겨야 하고, 객체를 그대로 주면 [object Object]가 기록됩니다. 두 번째 인자는 문자열이나 Buffer여야 합니다.

JavaScript code

// 직렬화
import { writeFileSync } from "node:fs";
writeFileSync("sample-14.txt", "hello\n", "utf8");
알아두면 좋은 점

JSON.stringify(obj, null, 2)로 들여쓰기를 주면 변경 사항이 줄 단위로 남아, 버전 관리에 올린 설정 파일을 비교하기가 훨씬 쉬워집니다.

자주 하는 실수

JSON.stringify는 undefined와 함수 값을 조용히 빼 버리고 순환 참조가 있으면 오류를 던집니다. 저장된 결과가 원본과 다를 수 있습니다.

15로그 파일 회전 흉내

회전된 로그 이름처럼 연도와 파일명을 path.join으로 결합해 보여 줍니다. 날짜별 디렉터리로 로그를 나눌 때 이런 조립이 앞에 놓입니다. join은 경로 문자열을 만들 뿐 디렉터리를 생성하지 않으므로, 쓰기 전에 recursive 옵션을 준 mkdirSync가 필요합니다.

JavaScript code

// 로그 파일 회전 흉내
import path from "node:path";
console.log(path.join("logs", "2026", "app-15.log"));
알아두면 좋은 점

날짜를 경로에 넣을 때 toISOString의 앞부분을 쓰면 UTC 기준이라, 현지 자정과 로그 파일이 바뀌는 시점이 어긋날 수 있습니다.

자주 하는 실수

로그를 쓸 때마다 writeFileSync를 부르면 기본 플래그가 덮어쓰기라 직전 기록이 매번 사라집니다. 이어쓰기 플래그를 지정해야 합니다.

정리하며

  • 서버 코드에서는 동기 fs API가 이벤트 루프를 멈추므로 피합니다
  • 존재 확인 후 열기 대신 바로 열고 ENOENT를 처리하는 편이 안전합니다
  • 인코딩을 지정하지 않으면 문자열이 아니라 Buffer가 반환됩니다
  • 경로는 path.join으로 만들고 사용자 입력이면 탈출 여부를 검사합니다

더 깊이 들어가고 싶다면 JavaScript 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.