JavaScript 비동기 처리 완벽 이해하기: 콜백부터 async/await까지

JavaScript를 배우다 보면 누구나 한 번쯤은 비동기 처리라는 벽에 부딪히게 됩니다. 저도 처음 JavaScript를 공부할 때 “왜 코드가 순서대로 실행되지 않지?”라는 의문을 가졌던 기억이 납니다. 이 글에서는 JavaScript의 비동기 처리를 처음 접하는 분들도 쉽게 이해할 수 있도록, 기초 개념부터 실전 활용법까지 단계별로 상세하게 설명하겠습니다.

동기와 비동기, 무엇이 다른가요?

프로그래밍에서 동기(Synchronous)와 비동기(Asynchronous)의 차이를 이해하는 것이 모든 것의 시작입니다. 일상생활의 예를 들어 설명해보겠습니다.

동기 방식은 마치 은행 창구에서 업무를 보는 것과 같습니다. 앞사람의 업무가 완전히 끝나야 다음 사람이 업무를 볼 수 있죠. 코드로 표현하면 한 줄의 코드가 완전히 실행된 후에야 다음 줄로 넘어갑니다. 반면 비동기 방식은 음식점에서 주문하는 것과 비슷합니다. 주문을 하고 나면 음식이 나올 때까지 기다리지 않고 자리로 가서 다른 일을 할 수 있습니다. 음식이 준비되면 직원이 알려주는 방식이죠.

JavaScript에서 비동기 처리가 필요한 이유는 웹 브라우저의 특성 때문입니다. 웹 페이지는 단일 스레드에서 실행되기 때문에, 만약 서버에서 데이터를 가져오는 동안 모든 코드 실행이 멈춘다면 사용자는 화면이 얼어붙은 것처럼 느끼게 됩니다. 이런 문제를 해결하기 위해 JavaScript는 시간이 오래 걸리는 작업을 백그라운드에서 처리하고, 완료되면 알려주는 방식을 사용합니다.

콜백 함수: 비동기 처리의 첫 번째 방법

JavaScript에서 비동기 처리를 구현하는 가장 기본적인 방법은 콜백 함수를 사용하는 것입니다. 콜백 함수란 다른 함수에 인자로 전달되는 함수로, 특정 작업이 완료된 후에 실행됩니다.

간단한 예제로 시작해보겠습니다. setTimeout 함수는 지정된 시간이 지난 후에 함수를 실행하는 대표적인 비동기 함수입니다.

javascript

console.log('작업 시작');

// 2초 후에 실행될 콜백 함수를 등록합니다
setTimeout(function() {
    console.log('2초가 지났습니다');
}, 2000);

console.log('작업 계속 진행');

이 코드를 실행하면 “작업 시작”, “작업 계속 진행”, “2초가 지났습니다” 순서로 출력됩니다. setTimeout이 비동기로 동작하기 때문에 2초를 기다리지 않고 다음 코드가 바로 실행되는 것입니다.

실제 웹 개발에서는 서버에서 데이터를 가져오는 작업에 콜백을 많이 사용합니다. 예를 들어 사용자 정보를 가져오는 함수를 만들어보겠습니다.

javascript

function getUserData(userId, callback) {
    // 실제로는 서버 요청이 들어갑니다
    setTimeout(function() {
        const userData = {
            id: userId,
            name: '김개발',
            email: 'kim@example.com'
        };
        // 데이터를 가져온 후 콜백 함수를 실행합니다
        callback(userData);
    }, 1000);
}

// 함수를 호출하고 결과를 받아 처리합니다
getUserData(123, function(user) {
    console.log('사용자 이름:', user.name);
    console.log('이메일:', user.email);
});

이 패턴은 직관적이고 이해하기 쉽지만, 여러 비동기 작업을 연속으로 수행해야 할 때 문제가 발생합니다. 예를 들어 사용자 정보를 가져온 후, 그 사용자의 게시글을 가져오고, 각 게시글의 댓글을 가져와야 한다면 어떻게 될까요?

javascript

getUserData(123, function(user) {
    getUserPosts(user.id, function(posts) {
        getPostComments(posts[0].id, function(comments) {
            getCommentReplies(comments[0].id, function(replies) {
                // 코드가 계속 오른쪽으로 밀려납니다
                console.log(replies);
            });
        });
    });
});

이런 코드 구조를 “콜백 지옥(Callback Hell)”이라고 부릅니다. 코드가 오른쪽으로 계속 들여쓰기되면서 읽기 어려워지고, 에러 처리도 복잡해집니다. 이 문제를 해결하기 위해 등장한 것이 바로 Promise입니다.

Promise: 비동기 처리의 새로운 패러다임

Promise는 ES6(ES2015)에서 도입된 객체로, 비동기 작업의 최종 완료 또는 실패를 나타냅니다. Promise라는 이름 그대로 “약속”을 의미하는데, “지금은 없지만 나중에 결과를 줄게”라는 약속을 코드로 표현한 것입니다.

Promise는 세 가지 상태를 가집니다. 대기(Pending) 상태는 비동기 작업이 아직 완료되지 않은 초기 상태입니다. 이행(Fulfilled) 상태는 비동기 작업이 성공적으로 완료된 상태이고, 거부(Rejected) 상태는 비동기 작업이 실패한 상태입니다.

Promise를 생성하는 방법을 살펴보겠습니다.

javascript

// Promise 객체를 생성합니다
const myPromise = new Promise(function(resolve, reject) {
    // 비동기 작업을 수행합니다
    const success = true;
    
    if (success) {
        // 작업이 성공하면 resolve를 호출합니다
        resolve('작업 성공!');
    } else {
        // 작업이 실패하면 reject를 호출합니다
        reject('작업 실패!');
    }
});

// Promise의 결과를 처리합니다
myPromise
    .then(function(result) {
        // resolve가 호출되면 이 부분이 실행됩니다
        console.log(result);
    })
    .catch(function(error) {
        // reject가 호출되면 이 부분이 실행됩니다
        console.log(error);
    });

앞서 만든 사용자 정보 가져오기 함수를 Promise로 다시 작성해보겠습니다.

javascript

function getUserData(userId) {
    // Promise 객체를 반환합니다
    return new Promise(function(resolve, reject) {
        setTimeout(function() {
            if (userId > 0) {
                const userData = {
                    id: userId,
                    name: '김개발',
                    email: 'kim@example.com'
                };
                resolve(userData);
            } else {
                reject('유효하지 않은 사용자 ID입니다');
            }
        }, 1000);
    });
}

// Promise를 사용하여 결과를 처리합니다
getUserData(123)
    .then(function(user) {
        console.log('사용자 이름:', user.name);
        return user.id; // 다음 then으로 값을 전달할 수 있습니다
    })
    .then(function(userId) {
        console.log('사용자 ID:', userId);
    })
    .catch(function(error) {
        console.log('에러 발생:', error);
    });

Promise의 진정한 장점은 체이닝(Chaining)입니다. 여러 비동기 작업을 순차적으로 수행할 때 코드를 훨씬 깔끔하게 작성할 수 있습니다.

javascript

function getUserData(userId) {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve({ id: userId, name: '김개발' });
        }, 1000);
    });
}

function getUserPosts(userId) {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve([
                { id: 1, title: '첫 번째 게시글' },
                { id: 2, title: '두 번째 게시글' }
            ]);
        }, 1000);
    });
}

function getPostComments(postId) {
    return new Promise(function(resolve) {
        setTimeout(function() {
            resolve([
                { id: 1, text: '좋은 글이네요' },
                { id: 2, text: '감사합니다' }
            ]);
        }, 1000);
    });
}

// Promise 체이닝으로 순차적으로 데이터를 가져옵니다
getUserData(123)
    .then(function(user) {
        console.log('사용자:', user.name);
        return getUserPosts(user.id);
    })
    .then(function(posts) {
        console.log('게시글 수:', posts.length);
        return getPostComments(posts[0].id);
    })
    .then(function(comments) {
        console.log('댓글 수:', comments.length);
    })
    .catch(function(error) {
        console.log('에러:', error);
    });

콜백 지옥과 비교하면 코드가 훨씬 읽기 쉬워진 것을 알 수 있습니다. 각 단계가 명확하게 구분되고, 에러 처리도 catch 하나로 통합할 수 있습니다.

Promise의 유용한 메서드들

Promise는 여러 개의 비동기 작업을 다룰 때 유용한 메서드들을 제공합니다.

Promise.all 메서드는 여러 Promise를 동시에 실행하고, 모두 완료될 때까지 기다립니다. 예를 들어 여러 API를 동시에 호출하고 모든 결과를 한 번에 받아야 할 때 사용합니다.

javascript

const promise1 = getUserData(1);
const promise2 = getUserData(2);
const promise3 = getUserData(3);

// 세 개의 Promise가 모두 완료될 때까지 기다립니다
Promise.all([promise1, promise2, promise3])
    .then(function(results) {
        // results는 각 Promise의 결과를 담은 배열입니다
        console.log('사용자 1:', results[0].name);
        console.log('사용자 2:', results[1].name);
        console.log('사용자 3:', results[2].name);
    })
    .catch(function(error) {
        // 하나라도 실패하면 catch가 실행됩니다
        console.log('에러 발생:', error);
    });

Promise.race 메서드는 여러 Promise 중 가장 먼저 완료되는 것의 결과를 반환합니다. 타임아웃을 구현할 때 유용합니다.

javascript

const dataPromise = getUserData(123);
const timeoutPromise = new Promise(function(resolve, reject) {
    setTimeout(function() {
        reject('요청 시간 초과');
    }, 5000);
});

// 데이터를 가져오거나, 5초가 지나면 타임아웃 에러가 발생합니다
Promise.race([dataPromise, timeoutPromise])
    .then(function(result) {
        console.log('데이터:', result);
    })
    .catch(function(error) {
        console.log('에러:', error);
    });

Promise.allSettled 메서드는 모든 Promise가 완료될 때까지 기다리되, 실패한 것이 있어도 모든 결과를 반환합니다.

javascript

const promises = [
    getUserData(1),
    getUserData(-1), // 이것은 실패합니다
    getUserData(3)
];

Promise.allSettled(promises)
    .then(function(results) {
        results.forEach(function(result, index) {
            if (result.status === 'fulfilled') {
                console.log(`Promise ${index + 1} 성공:`, result.value);
            } else {
                console.log(`Promise ${index + 1} 실패:`, result.reason);
            }
        });
    });

async/await: 비동기 코드를 동기 코드처럼

ES2017에서 도입된 async와 await는 Promise를 더욱 간결하고 읽기 쉽게 만들어줍니다. 비동기 코드를 마치 동기 코드처럼 작성할 수 있게 해주는 문법적 설탕(Syntactic Sugar)입니다.

async 키워드를 함수 앞에 붙이면 그 함수는 자동으로 Promise를 반환합니다. await 키워드는 Promise가 처리될 때까지 기다리게 합니다.

javascript

// async 함수를 선언합니다
async function fetchUserData() {
    // await를 사용하여 Promise가 완료될 때까지 기다립니다
    const user = await getUserData(123);
    console.log('사용자:', user.name);
    
    // 다음 작업을 이어서 수행합니다
    const posts = await getUserPosts(user.id);
    console.log('게시글 수:', posts.length);
    
    const comments = await getPostComments(posts[0].id);
    console.log('댓글 수:', comments.length);
    
    return comments;
}

// async 함수는 Promise를 반환하므로 then을 사용할 수 있습니다
fetchUserData()
    .then(function(comments) {
        console.log('모든 작업 완료');
    })
    .catch(function(error) {
        console.log('에러:', error);
    });

Promise 체이닝과 비교하면 코드가 훨씬 직관적입니다. 마치 일반적인 동기 코드를 읽는 것처럼 위에서 아래로 흐름을 따라갈 수 있습니다.

에러 처리는 try-catch 문을 사용합니다.

javascript

async function fetchUserData() {
    try {
        const user = await getUserData(123);
        const posts = await getUserPosts(user.id);
        const comments = await getPostComments(posts[0].id);
        
        return {
            user: user,
            posts: posts,
            comments: comments
        };
    } catch (error) {
        // 어느 단계에서든 에러가 발생하면 여기서 처리됩니다
        console.log('데이터를 가져오는 중 에러 발생:', error);
        throw error; // 에러를 다시 던져서 호출한 쪽에서도 처리할 수 있게 합니다
    }
}

async/await를 사용할 때 주의할 점이 있습니다. await는 반드시 async 함수 안에서만 사용할 수 있습니다. 최상위 레벨에서는 사용할 수 없으므로 즉시 실행 함수를 사용하거나, 별도의 async 함수를 만들어야 합니다.

javascript

// 잘못된 사용법 - 최상위 레벨에서는 await를 사용할 수 없습니다
// const user = await getUserData(123); // 에러 발생!

// 올바른 사용법 1 - 즉시 실행 async 함수
(async function() {
    const user = await getUserData(123);
    console.log(user);
})();

// 올바른 사용법 2 - 별도의 async 함수 생성
async function main() {
    const user = await getUserData(123);
    console.log(user);
}
main();

병렬 처리와 순차 처리 이해하기

async/await를 사용할 때 성능을 위해 알아두어야 할 중요한 개념이 있습니다. 바로 병렬 처리와 순차 처리의 차이입니다.

다음 코드를 보겠습니다.

javascript

// 순차 처리 - 총 6초 소요
async function sequentialFetch() {
    const user1 = await getUserData(1); // 2초 대기
    const user2 = await getUserData(2); // 2초 대기
    const user3 = await getUserData(3); // 2초 대기
    
    return [user1, user2, user3];
}

이 코드는 각 사용자 데이터를 순차적으로 가져오므로 총 6초가 걸립니다. 하지만 세 개의 요청은 서로 독립적이므로 동시에 실행할 수 있습니다.

javascript

// 병렬 처리 - 총 2초 소요
async function parallelFetch() {
    // Promise들을 먼저 시작합니다
    const promise1 = getUserData(1);
    const promise2 = getUserData(2);
    const promise3 = getUserData(3);
    
    // 모든 Promise가 완료될 때까지 기다립니다
    const user1 = await promise1;
    const user2 = await promise2;
    const user3 = await promise3;
    
    return [user1, user2, user3];
}

// 또는 Promise.all을 사용할 수 있습니다
async function parallelFetchWithAll() {
    const users = await Promise.all([
        getUserData(1),
        getUserData(2),
        getUserData(3)
    ]);
    
    return users;
}

병렬 처리를 사용하면 실행 시간이 대폭 단축됩니다. 서로 독립적인 비동기 작업들은 가능한 한 병렬로 처리하는 것이 효율적입니다.

실전 예제: API 호출과 데이터 처리

실제 웹 개발에서 가장 많이 사용하는 fetch API를 활용한 예제를 만들어보겠습니다. 여러 API를 호출하고 데이터를 조합하는 실전 시나리오입니다.

javascript

// 사용자 대시보드에 필요한 모든 데이터를 가져오는 함수
async function loadUserDashboard(userId) {
    try {
        console.log('대시보드 로딩 시작...');
        
        // 1단계: 사용자 기본 정보 가져오기
        const userResponse = await fetch(`https://api.example.com/users/${userId}`);
        if (!userResponse.ok) {
            throw new Error('사용자 정보를 가져올 수 없습니다');
        }
        const user = await userResponse.json();
        console.log('사용자 정보 로드 완료');
        
        // 2단계: 독립적인 데이터들을 병렬로 가져오기
        const [posts, followers, settings] = await Promise.all([
            fetch(`https://api.example.com/users/${userId}/posts`).then(res => res.json()),
            fetch(`https://api.example.com/users/${userId}/followers`).then(res => res.json()),
            fetch(`https://api.example.com/users/${userId}/settings`).then(res => res.json())
        ]);
        console.log('추가 데이터 로드 완료');
        
        // 3단계: 첫 번째 게시글의 댓글 가져오기
        let comments = [];
        if (posts.length > 0) {
            const commentsResponse = await fetch(`https://api.example.com/posts/${posts[0].id}/comments`);
            comments = await commentsResponse.json();
            console.log('댓글 로드 완료');
        }
        
        // 모든 데이터를 조합하여 반환
        return {
            user: user,
            stats: {
                postsCount: posts.length,
                followersCount: followers.length,
                latestComments: comments.slice(0, 5)
            },
            settings: settings
        };
        
    } catch (error) {
        console.error('대시보드 로딩 실패:', error.message);
        // 사용자에게 보여줄 기본값을 반환할 수도 있습니다
        return {
            user: null,
            stats: { postsCount: 0, followersCount: 0, latestComments: [] },
            settings: {},
            error: error.message
        };
    }
}

// 함수 사용 예제
async function displayDashboard() {
    const dashboardData = await loadUserDashboard(123);
    
    if (dashboardData.error) {
        console.log('에러가 발생했습니다:', dashboardData.error);
        return;
    }
    
    console.log('대시보드 데이터:', dashboardData);
    // 여기서 UI를 업데이트하는 코드가 들어갑니다
}

displayDashboard();

이 예제는 실제 프로젝트에서 자주 마주치는 패턴을 보여줍니다. 사용자 정보를 먼저 가져온 후, 그와 관련된 여러 데이터를 병렬로 가져오고, 필요에 따라 추가 데이터를 순차적으로 가져오는 방식입니다.

에러 처리 베스트 프랙티스

비동기 코드에서 에러 처리는 매우 중요합니다. 제대로 처리하지 않으면 애플리케이션이 예기치 않게 중단될 수 있습니다.

javascript

// 좋은 에러 처리 패턴
async function robustDataFetch(userId) {
    try {
        // 타임아웃을 설정하여 무한 대기를 방지합니다
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), 5000);
        
        const response = await fetch(`https://api.example.com/users/${userId}`, {
            signal: controller.signal
        });
        
        clearTimeout(timeoutId);
        
        // HTTP 상태 코드를 확인합니다
        if (!response.ok) {
            throw new Error(`HTTP 에러: ${response.status}`);
        }
        
        const data = await response.json();
        
        // 데이터 유효성을 검증합니다
        if (!data || !data.id) {
            throw new Error('유효하지 않은 데이터 형식');
        }
        
        return { success: true, data: data };
        
    } catch (error) {
        // 에러 타입에 따라 다르게 처리합니다
        if (error.name === 'AbortError') {
            console.error('요청 시간 초과');
            return { success: false, error: 'timeout' };
        } else if (error.message.includes('HTTP 에러')) {
            console.error('서버 에러:', error.message);
            return { success: false, error: 'server_error' };
        } else {
            console.error('알 수 없는 에러:', error);
            return { success: false, error: 'unknown' };
        }
    }
}

// 재시도 로직 구현
async function fetchWithRetry(url, maxRetries = 3) {
    for (let i = 0; i < maxRetries; i++) {
        try {
            const response = await fetch(url);
            return await response.json();
        } catch (error) {
            console.log(`시도 ${i + 1}/${maxRetries} 실패`);
            
            // 마지막 시도가 아니면 잠시 대기 후 재시도
            if (i < maxRetries - 1) {
                await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
            } else {
                throw error; // 모든 시도가 실패하면 에러를 던집니다
            }
        }
    }
}

성능 최적화 팁

비동기 코드의 성능을 향상시키는 몇 가지 실용적인 팁을 소개합니다.

첫째, 불필요한 await를 피하세요. 함수의 마지막에서 Promise를 반환할 때는 await 없이 바로 반환하는 것이 더 효율적입니다.

javascript

// 비효율적인 방법
async function getUser(id) {
    const user = await fetchUser(id);
    return user; // 불필요한 await
}

// 효율적인 방법
async function getUser(id) {
    return fetchUser(id); // await 없이 Promise를 바로 반환
}

둘째, 캐싱을 활용하세요. 같은 데이터를 반복해서 가져올 필요가 없습니다.

javascript

const cache = new Map();

async function getCachedUserData(userId) {
    // 캐시에 데이터가 있으면 바로 반환
    if (cache.has(userId)) {
        console.log('캐시에서 데이터 반환');
        return cache.get(userId);
    }
    
    // 캐시에 없으면 서버에서 가져와서 캐시에 저장
    console.log('서버에서 데이터 가져오기');
    const data = await getUserData(userId);
    cache.set(userId, data);
    
    return data;
}

셋째, 데이터를 미리 가져오기(Prefetching)를 고려하세요.

javascript

async function smartDataLoader() {
    // 사용자가 필요로 할 가능성이 높은 데이터를 미리 가져옵니다
    const userPromise = getUserData(123);
    
    // 다른 작업을 수행합니다
    console.log('다른 작업 수행 중...');
    
    // 필요한 시점에 데이터를 사용합니다
    const user = await userPromise;
    console.log('사용자 데이터:', user);
}

마치며

JavaScript의 비동기 처리는 현대 웹 개발에서 피할 수 없는 핵심 개념입니다. 콜백 함수에서 시작해 Promise를 거쳐 async/await에 이르기까지, 각 방법은 이전 방법의 문제점을 개선하면서 발전해왔습니다.

이 글에서 다룬 내용을 정리하면, 콜백 함수는 가장 기본적인 비동기 처리 방법이지만 복잡한 로직에서는 콜백 지옥에 빠질 수 있습니다. Promise는 체이닝을 통해 코드를 더 읽기 쉽게 만들어주고, 에러 처리도 개선했습니다. async/await는 Promise를 기반으로 하면서도 동기 코드처럼 작성할 수 있게 해주어 가독성을 크게 향상시켰습니다.

실제 프로젝트에서는 이 세 가지 방법을 상황에 맞게 조합하여 사용합니다. 간단한 비동기 작업에는 Promise를 직접 사용하고, 복잡한 로직에는 async/await를 사용하는 것이 일반적입니다. 성능이 중요한 경우에는 Promise.all을 활용한 병렬 처리를 고려하고, 에러 처리와 재시도 로직을 꼼꼼하게 구현하는 것이 안정적인 애플리케이션을 만드는 비결입니다.

비동기 처리는 처음에는 어렵게 느껴질 수 있지만, 개념을 정확히 이해하고 실습을 반복하다 보면 자연스럽게 익숙해집니다. 이 글에서 제공한 예제들을 직접 실행해보고, 자신만의 프로젝트에 적용해보시기 바랍니다. 실제로 코드를 작성하면서 겪는 시행착오가 가장 좋은 학습 경험이 될 것입니다.


댓글 남기기