1. 비동기 프로그래밍의 이해
1.1 동기 vs 비동기 프로그래밍
프로그램 실행 흐름을 관리하는 방식에는 크게 동기 방식과 비동기 방식이 있습니다. 이 둘의 차이를 명확히 이해하는 것은 비동기 프로그래밍을 이해하는 첫걸음입니다.
동기(Synchronous) 프로그래밍: 코드가 작성된 순서대로, 위에서 아래로 순차적으로 실행됩니다. 하나의 작업이 완료되어야 다음 작업이 시작됩니다. 마치 줄을 서서 기다리는 것과 같습니다.
- 예시: 파일을 읽고 그 내용을 출력하는 경우, 파일 읽기가 완료될 때까지 기다린 후 출력 작업을 수행합니다.
비동기(Asynchronous) 프로그래밍: 특정 작업이 진행되는 동안 다른 작업을 동시에 실행할 수 있습니다. 하나의 작업이 완료될 때까지 기다리지 않고 다음 작업을 시작할 수 있어, 전체적인 처리 시간을 단축할 수 있습니다. 마치 여러 개의 작업을 동시에 처리하는 것과 같습니다.
- 예시: 데이터베이스 쿼리를 보내고 응답을 기다리는 동안, 다른 API 요청이나 계산 작업을 계속 진행할 수 있습니다.
1.2 비동기 프로그래밍이 필요한 이유
비동기 프로그래밍은 왜 중요할까요? 그 이유는 다음과 같습니다.
- 성능 향상: 서버 자원을 효율적으로 활용하여 높은 트래픽 상황에서도 빠르게 응답할 수 있습니다. 특히, I/O 작업에 소요되는 시간을 줄여 전체적인 애플리케이션 성능을 개선합니다.
- 사용자 경험 개선: UI 스레드와 백그라운드 처리를 분리하여 사용자 인터페이스가 멈추거나 응답이 없는 상황을 방지합니다. 사용자에게 더욱 매끄러운 경험을 제공합니다.
1.3 Node.js에서의 비동기 프로그래밍
Node.js는 비동기 I/O 모델을 기반으로 설계되었기 때문에, 비동기 프로그래밍이 매우 중요합니다. Node.js에서 주로 사용되는 비동기 프로그래밍 구현 방법은 다음과 같습니다.
- 콜백 함수 (Callback functions)
- 프로미스 (Promise)
- async/await
이제 각 구현 방법을 자세히 알아보겠습니다.
2. 콜백 함수 (Callback Functions)
2.1 콜백 함수란 무엇인가?
콜백 함수는 다른 함수에 인자로 전달되어, 특정 작업이 완료된 후에 실행되는 함수입니다. 주로 시간이 오래 걸리는 비동기 작업(파일 읽기, 데이터베이스 쿼리 등)의 완료 시점을 처리하는 데 사용됩니다.
2.2 콜백 함수의 기본 구조
function fetchData(callback) {
setTimeout(() => { // 2초 후에 데이터 반환
const data = { name: "John", age: 30 };
callback(data);
}, 2000);
}
fetchData((result) => {
console.log("데이터:", result);
});
위 코드는 fetchData
함수가 2초 후에 데이터를 반환하고, 전달된 콜백 함수를 호출하여 결과를 처리하는 간단한 예시입니다.
2.3 콜백 함수의 추가 예제
예제 1: 파일 읽기 콜백
const fs = require('fs');
function readFileCallback(filePath, callback) {
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
callback(err, null); // 에러 발생 시 첫 번째 인자로 에러 전달
return;
}
callback(null, data); // 성공 시 두 번째 인자로 데이터 전달
});
}
readFileCallback('example.txt', (err, data) => {
if (err) {
console.error('파일 읽기 오류:', err);
} else {
console.log('파일 내용:', data);
}
});
예제 2: 비동기 API 호출 콜백
function fetchUserData(userId, callback) {
setTimeout(() => {
const users = {
1: { name: 'Alice', email: 'alice@example.com' },
2: { name: 'Bob', email: 'bob@example.com' }
};
const user = users[userId];
if (user) {
callback(null, user);
} else {
callback('User not found', null);
}
}, 1500); // 1.5초 후에 데이터 반환
}
fetchUserData(1, (err, userData) => {
if (err) {
console.error('사용자 정보 가져오기 실패:', err);
} else {
console.log('사용자 정보:', userData);
}
});
fetchUserData(3, (err, userData) => {
if (err) {
console.error('사용자 정보 가져오기 실패:', err);
} else {
console.log('사용자 정보:', userData);
}
});
2.4 콜백 지옥 (Callback Hell)
콜백 함수를 여러 개 중첩하여 사용할 경우, 코드의 가독성이 급격히 떨어지고 유지보수가 어려워지는 "콜백 지옥" 문제가 발생할 수 있습니다.
login(userCredentials, (error, user) => {
if (error) {
console.error(error);
} else {
fetchUserProfile(user.id, (error, profile) => {
if (error) {
console.error(error);
} else {
fetchUserPosts(profile.id, (error, posts) => {
if (error) {
console.error(error);
} else {
console.log(posts);
}
});
}
});
}
});
위 코드에서처럼 콜백이 중첩될수록 코드의 복잡성이 증가합니다. 이를 해결하기 위해 프로미스(Promise)와 async/await가 등장하게 되었습니다.
3. 프로미스 (Promise)
3.1 프로미스란 무엇인가?
프로미스는 JavaScript에서 비동기 작업의 완료 또는 실패와 그 결과 값을 나타내는 객체입니다. 콜백 지옥 문제를 해결하고 비동기 코드를 더 깔끔하게 작성할 수 있도록 도와줍니다.
3.2 프로미스의 상태
프로미스는 다음 세 가지 상태를 가집니다.
- 대기(Pending): 비동기 작업이 아직 완료되지 않은 초기 상태입니다.
- 이행(Fulfilled): 비동기 작업이 성공적으로 완료되었고, 결과 값을 가지고 있습니다.
- 거부(Rejected): 비동기 작업이 실패했고, 실패 이유를 가지고 있습니다.
3.3 프로미스의 생성 및 사용
프로미스는 new Promise()
생성자를 사용하여 생성합니다.
const fs = require('fs');
function readFilePromise(filePath) {
return new Promise((resolve, reject) => {
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
reject(err); // 에러 발생 시 거부
} else {
resolve(data); // 성공 시 이행
}
});
});
}
// 사용 예제
readFilePromise('example.txt')
.then(data => {
console.log('파일 내용:', data);
})
.catch(error => {
console.error('파일 읽기에 실패했습니다:', error);
});
위 코드는 readFilePromise
함수가 파일을 읽고 그 결과를 프로미스로 반환하는 예시입니다. .then()
메서드는 프로미스가 이행되었을 때 호출되고, .catch()
메서드는 프로미스가 거부되었을 때 호출됩니다.
3.4 프로미스의 추가 예제
예제 1: 타이머 프로미스
function delay(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
delay(1000)
.then(() => console.log("1초 후 실행"));
delay(2000)
.then(() => console.log("2초 후 실행"));
예제 2: 비동기 데이터 가져오기 프로미스
function fetchDataPromise(url) {
return new Promise((resolve, reject) => {
// 실제 HTTP 요청을 보내는 대신 setTimeout으로 흉내냅니다.
setTimeout(() => {
const fakeData = { message: "Data fetched successfully from " + url };
if (url.includes('error')) {
reject(new Error('Failed to fetch data'));
} else {
resolve(fakeData);
}
}, 1000);
});
}
fetchDataPromise('https://api.example.com/data')
.then(data => {
console.log('데이터:', data);
})
.catch(error => {
console.error('데이터 가져오기 실패:', error);
});
fetchDataPromise('https://api.example.com/error')
.then(data => {
console.log('데이터:', data);
})
.catch(error => {
console.error('데이터 가져오기 실패:', error);
});
3.5 프로미스 체이닝 (Promise Chaining)
프로미스는 체이닝을 통해 여러 개의 비동기 작업을 순차적으로 연결할 수 있습니다. 이를 통해 코드를 더욱 간결하고 가독성 좋게 만들 수 있습니다.
readFilePromise('example.txt')
.then(data => {
console.log('첫 번째 처리:', data);
return data.toUpperCase(); // 대문자로 변환하여 다음 then으로 전달
})
.then(upperData => {
console.log('두 번째 처리:', upperData);
})
.catch(error => {
console.error('오류 발생:', error);
});
4. async/await
4.1 async/await란 무엇인가?
async/await는 프로미스를 기반으로 비동기 코드를 동기 코드처럼 작성할 수 있게 해주는 문법입니다. async
키워드를 함수 앞에 붙여서 해당 함수가 비동기 함수임을 표시하고, 함수 내부에서는 await
키워드를 사용하여 프로미스가 완료될 때까지 기다릴 수 있습니다.
4.2 async/await 사용 예제
const fs = require('fs').promises;
async function readAndLogFile() {
try {
const data = await fs.readFile('example.txt', 'utf8');
console.log(data);
} catch (err) {
console.error(err);
}
}
readAndLogFile();
위 코드에서 readAndLogFile
함수는 async
로 선언되었고, 함수 내부에서 await
키워드를 사용하여 fs.readFile
프로미스가 완료될 때까지 기다립니다. 이는 코드를 더욱 직관적이고 간결하게 만들어줍니다.
4.3 async/await의 추가 예제
예제 1: 여러 개의 비동기 작업 순차 실행
async function processMultipleFiles() {
try {
const file1Data = await fs.readFile('example.txt', 'utf8');
console.log('첫 번째 파일:', file1Data);
const file2Data = await fs.readFile('another.txt', 'utf8');
console.log('두 번째 파일:', file2Data);
const combinedData = file1Data + " " + file2Data
console.log('두 파일 결합:', combinedData);
} catch (err) {
console.error('파일 처리 중 오류:', err);
}
}
processMultipleFiles();
예제 2: 여러 API 호출 병렬 처리
async function fetchMultipleData() {
try {
const [data1, data2] = await Promise.all([
fetchDataPromise('https://api.example.com/data1'),
fetchDataPromise('https://api.example.com/data2'),
]);
console.log('데이터 1:', data1);
console.log('데이터 2:', data2);
} catch (err) {
console.error('데이터 가져오기 중 오류:', err);
}
}
fetchMultipleData();
4.4 async/await의 장점
- 가독성 향상: 코드가 동기 코드처럼 보여 이해하기 쉽습니다.
- 에러 핸들링 용이:
try...catch
구문을 사용하여 비동기 작업에서 발생하는 에러를 쉽게 처리할 수 있습니다. - 간결한 코드: 프로미스의
.then()
과.catch()
를 사용하는 것보다 코드가 더 간결해집니다.
5. 실전 예제: 파일 읽고 대문자로 변환하여 출력하기
지금까지 배운 내용을 바탕으로, 파일을 읽고 그 내용을 대문자로 변환하여 출력하는 예제를 통해 세 가지 방법을 비교해 보겠습니다.
5.1 콜백 함수
const fs = require('fs');
fs.readFile('example.txt', 'utf8', (err, data) => {
if (err) {
console.error(err);
return;
}
const upperData = data.toUpperCase();
console.log(upperData);
});
5.2 프로미스
const fs = require('fs').promises;
fs.readFile('example.txt', 'utf8')
.then(data => {
const upperData = data.toUpperCase();
console.log(upperData);
})
.catch(err => {
console.error(err);
});
5.3 async/await
const fs = require('fs').promises;
async function processFile() {
try {
const data = await fs.readFile('example.txt', 'utf8');
const upperData = data.toUpperCase();
console.log(upperData);
} catch (err) {
console.error(err);
}
}
processFile();
세 가지 예제 모두 동일한 작업을 수행하지만, async/await
를 사용한 코드가 가장 간결하고 직관적임을 알 수 있습니다.
6. 결론
비동기 프로그래밍은 Node.js에서 필수적인 개념이며, 효율적인 애플리케이션 개발을 위해 반드시 이해하고 활용해야 합니다. 본 가이드에서는 콜백 함수, 프로미스, async/await에 대한 심도 있는 설명을 제공하고, 실제 코드 예제를 통해 각 방법의 차이점을 명확히 했습니다. 특히, 각 섹션별로 추가된 예제들을 통해 비동기 프로그래밍 개념을 더욱 확실하게 이해할 수 있도록 했습니다.
Node.js 개발을 시작하시는 분들이나 비동기 프로그래밍에 대한 이해를 높이고 싶은 분들에게 이 글이 도움이 되기를 바랍니다. 비동기 프로그래밍을 자유자재로 활용하여 더욱 강력하고 안정적인 Node.js 애플리케이션을 만들어보세요!
'프로그래밍 > Node.js' 카테고리의 다른 글
Node.js로 시작하는 HTTP 서버 구축 완벽 가이드: 요청, 응답 (0) | 2025.02.18 |
---|---|
Node.js 파일 시스템 완벽 가이드: 파일 및 디렉토리 관리 심층 분석 (0) | 2025.02.18 |
Node.js 모듈 시스템 완벽 가이드: 핵심 개념부터 활용까지 (0) | 2025.02.18 |
Node.js 완벽 가이드: 설치부터 npm 활용까지 (1) | 2025.02.18 |
Node.js 정복 가이드: 서버 개발의 새로운 패러다임을 만나다 (0) | 2025.02.18 |