javascript내에서 배열(자료구조) - 고차함수/콜백/프로미스/비동기/대기
1. 자바스크립트에서 배열과 자바에서의 배열의 차이점
자바스크립트의 배열과 자바의 배열은 이름은 같지만 내부 동작과 메모리 관리 방식에서 큰 차이가 있습니다.
-
자바(Java)의 배열 (밀집 배열):
-
선언할 때 크기가 고정되며, 한 가지 타입의 데이터만 저장할 수 있습니다.
-
메모리 상에서 연속된 공간을 차지하므로, 인덱스를 통한 접근 속도가 매우 빠릅니다.
-
자바스크립트(JavaScript)의 배열 (희소 배열):
-
크기가 동적으로 변하며, 하나의 배열에 숫자, 문자열, 객체, 함수 등 서로 다른 타입의 데이터를 함께 담을 수 있습니다.
-
실제로는 연속된 메모리 공간을 갖는 정통적인 배열이 아니라, 배열의 동작을 흉내 낸 특수한 객체입니다. 인덱스가 객체의 Key 역할을 합니다.
보충 설명
모던 자바스크립트 엔진(V8 등)은 성능 최적화를 위해 배열 내부의 타입이 모두 같을 때는 자바처럼 연속된 메모리 공간(밀집 배열)으로 처리하고, 타입이 섞이거나 빈 공간이 생기면 객체 형태(희소 배열)로 전환하여 동작합니다.
1) 단일 타입(밀집)에서 믹스 타입(희소)으로 변환되는 예시
자바스크립트 엔진은 처음 배열을 만들 때 담긴 데이터의 상태를 보고 메모리 구조를 결정합니다.
// 1. 처음에는 순수한 정수만 들어있음
// 내부 상태: PACKED_SMI_ELEMENTS (가장 빠름, 자바의 int[] 배열처럼 연속된 공간에 배치)
const arr = [1, 2, 3];
// 2. 소수(Double)를 추가함
// 내부 상태: PACKED_DOUBLE_ELEMENTS로 전환 (메모리 공간을 조금 더 넓히지만 여전히 밀집 구조)
arr.push(4.5);
// 3. 갑자기 문자열(객체)을 추가함
// 내부 상태: PACKED_ELEMENTS로 전환 (타입이 섞여 더 이상 일반 배열처럼 다룰 수 없음)
arr.push("hello");엔진의 동작 원리 정수만 있을 때는 굳이 메모리에 이 데이터가 무엇인지 표시하는 태그를 붙이지 않고 숫자만 촘촘히 채웁니다. 하지만 문자열이 들어오는 순간, 엔진은 배열의 모든 칸을 "어떤 타입이든 들어올 수 있는 포인터 방"으로 재구조화합니다. 이 과정에서 메모리 재할당 오버헤드가 발생합니다.
2) 빈 공간(Hole)이 생겨 희소 배열로 변환되는 예시
배열의 연속성이 깨지고 중간에 빈 공간(Hole)이 생기면 엔진은 성능이 떨어지는 스키마로 강제 전환합니다.
// 1. 촘촘하게 채워진 정수 배열
// 내부 상태: PACKED_SMI_ELEMENTS
const denseArr = [1, 2, 3];
// 2. 인덱스를 건너뛰고 저 멀리 값을 할당함
// 내부 상태: HOLEY_SMI_ELEMENTS (구멍이 뚫린 배열로 다운그레이드)
denseArr[100] = 999;엔진의 동작 원리
PACKED상태일 때는denseArr[5]를 찾을 때 그냥 "배열 시작 주소 + 5" 연산으로 끝냅니다. 하지만HOLEY(구멍 난) 상태가 되면, 해당 인덱스가 비어있을 때 프로토타입 체인(Array.prototype)까지 거슬러 올라가서 값이 정의되어 있는지 확인해야 하므로 접근 속도가 크게 떨어집니다.
3) 극단적인 희소 배열: 딕셔너리 모드 (Dictionary Mode)
구멍이 너무 많거나 배열에 배열과 상관없는 커스텀 프로퍼티를 붙이면, 엔진은 배열로서의 최적화를 완전히 포기하고 일반 해시 테이블 객체(Dictionary)로 구조를 바꿔버립니다.
const dictArr = [];
// 인덱스를 엄청난 간격으로 띄워서 할당
dictArr[0] = "시작";
dictArr[999999] = "끝";
// 배열에 일반 객체처럼 텍스트 키로 속성을 추가
dictArr.customKey = "정체성 상실";
// 내부 상태: DICTIONARY_ELEMENTS
// 이제 이 배열은 내부적으로 명칭만 배열일 뿐, {}와 같은 일반 자바스크립트 객체로 동작합니다.성능 최적화를 위한 실무 팁
모던 자바스크립트 엔진의 이러한 특성을 이해했다면, 코드를 작성할 때 다음과 같은 규칙을 지키는 것이 좋습니다.
- 배열의 타입을 섞지 마세요 하나의 배열에는 하나의 타입(숫자면 숫자, 객체면 객체)만 담는 것이 좋습니다.
- 인덱스를 건너뛰지 마세요
push()나pop()을 사용해 순차적으로 배열 크기를 늘리거나 줄여PACKED상태를 유지하세요. - 배열 크기를 미리 크게 잡고 빈 칸으로 두지 마세요
크기가 예측 가능하다면 빈 배열에 인덱스로 접근하기보다
Array.from()등을 활용해 초기값을 채워두는 것이 유리합니다.
2. 자바스크립트의 자료구조 및 예시
자바스크립트에서 기본으로 제공하거나 구현하여 사용하는 대표적인 자료구조들입니다.
1) 배열 (Array)
순서가 있는 데이터의 집합입니다.
const items = ['사과', '바나나', '포도'];2) 객체 (Object)
키(Key)와 값(Value)의 쌍으로 이루어진 데이터 구조로, 자바의 Map이나 해시 테이블과 유사하게 활용됩니다.
const user = {
name: '홍길동',
age: 25
};3) 맵 (Map)
객체와 유사하지만, 키(Key) 자리에 문자열뿐만 아니라 객체나 함수 등 모든 타입을 사용할 수 있으며 메서드를 통한 데이터 관리가 명확합니다.
const userMap = new Map();
userMap.set('id', 1);
userMap.set(user, 'admin'); // 객체도 키로 사용 가능4) 셋 (Set)
중복되지 않는 유일한 값들의 집합입니다.
const uniqueNumbers = new Set([1, 2, 2, 3, 3, 3]);
// 결과: Set { 1, 2, 3 }5) 스택 / 큐 (Stack / Queue)
배열의 내장 메서드를 활용하여 구현할 수 있습니다.
- 스택 (LIFO): push()와 pop() 사용
- 큐 (FIFO): push()와 shift() 사용
3. 구조분해 (Destructuring) 자료구조 활용 및 예시
구조분해 할당은 배열이나 객체의 속성을 해체하여 그 값을 개별 변수에 맞춤형으로 담을 수 있게 하는 자바스크립트의 표현식입니다.
1) 배열 구조분해 할당
배열의 인덱스 순서대로 변수에 값이 할당됩니다.
const colors = ['red', 'green', 'blue'];
const [firstColor, secondColor] = colors;
console.log(firstColor); // 'red'
console.log(secondColor); // 'green'2) 객체 구조분해 할당
객체의 Key 이름을 기준으로 변수에 값이 할당되며, 순서는 상관없습니다.
const setting = { theme: 'dark', language: 'ko' };
const { theme, language } = setting;
console.log(theme); // 'dark'
console.log(language); // 'ko'4. 스프레드(Spread)와 구조분해(Destructuring) 차이점
두 문법 모두 세 개의 점(...)을 사용하여 형태가 비슷해 보이지만, 동작하는 목적과 방향성이 완전히 반대입니다.
- 스프레드 연산자 (Spread): 포장된 데이터를 풀어서 확장(전개)하는 역할을 합니다. 배열이나 객체를 복사하거나 병합할 때 사용합니다.
- 구조분해 할당 (Destructuring): 전체 데이터 묶음에서 필요한 알맹이만 추출해서 변수에 할당하는 역할을 합니다.
// 1. 스프레드 (데이터를 펼쳐서 합치거나 복사)
const arr1 = [1, 2];
const arr2 = [...arr1, 3, 4]; // [1, 2, 3, 4]
// 2. 구조분해 (데이터를 쪼개서 추출)
const [one, two] = arr1; // one = 1, two = 2보충 설명 (Rest 파라미터와의 혼동 주의) 구조분해 할당 내부에서 세 개의 점(...)이 쓰일 때는 스프레드가 아니라 나머지 요소를 모아주는 Rest 파라미터 역할을 합니다.
const [first, ...rest] = [1, 2, 3, 4];
// first = 1
// rest = [2, 3, 4] -> 나머지 요소를 다시 배열로 묶음5. 고차 함수 (Higher-Order Function) 사용법 및 예시
고차 함수(상위 함수)는 함수를 인수로 전달받거나 함수를 반환하는 함수를 말합니다. 자바스크립트에서는 함수가 '일급 객체'이기 때문에 가능하며, 주로 배열 메서드(map, filter, reduce 등)에서 많이 사용됩니다.
const numbers = [1, 2, 3, 4, 5];
// map: 각 요소를 가공하여 새로운 배열 반환
const doubled = numbers.map(num => num * 2); // [2, 4, 6, 8, 10]
// filter: 조건에 맞는 요소만 걸러서 새로운 배열 반환
const evens = numbers.filter(num => num % 2 === 0); // [2, 4]고차 함수 남발에 따른 문제점
고차 함수와 메서드 체이닝(Method Chaining)은 코드를 간결하게 만들어 주지만, 과도하게 사용할 경우 다음과 같은 문제가 발생합니다.
- 성능 및 메모리 오버헤드
map().filter().map()과 같이 고차 함수를 연속으로 체이닝하면, 각 단계마다 새로운 배열 객체가 메모리에 계속 생성됩니다. 대용량 데이터를 처리할 때 메모리 낭비와 가비지 컬렉션 부담이 커집니다. - 가독성 저하 및 디버깅의 어려움 지나치게 복잡한 함수형 파이프라인은 코드를 한눈에 파악하기 어렵게 만듭니다. 체이닝 도중 중간 단계에서 어떤 값이 넘어가는지 확인하기 위한 중단점(Breakpoint) 설정이나 디버깅이 까다로워집니다.
- Call Stack 및 실행 속도 저하
단순
for문이나while문은 루프 내부에서 제어문만 실행되지만, 고차 함수는 매 반복마다 콜백 함수를 호출하므로 컨텍스트 스위칭 비용이 발생하여 아주 미세하게 속도가 더 느릴 수 있습니다. (극단적인 고성능이 필요한 작업이 아니라면 미미한 차이일 수 있습니다.)
추가보충 1) 콜백 함수 (Callback Function)
콜백 함수는 다른 함수에 인자(Argument)로 전달되어, 특정 작업이 완료되거나 특정 시점이 되었을 때 "뒤늦게 호출(Call back)"되는 함수를 말합니다.
자바스크립트에서는 함수가 '일급 객체'이기 때문에 변수처럼 다른 함수의 매개변수로 함수 자체를 넘겨줄 수 있으며, 이 특성을 활용한 핵심 패턴이 바로 콜백 함수입니다.
1. 동기적 콜백 (Synchronous Callback)
코드가 위에서 아래로 순차적으로 실행되는 흐름 속에서 즉시 실행되는 콜백 함수입니다. 앞서 살펴본 고차 함수(map, filter 등)에 인자로 넘겨주는 함수들이 대표적인 동기적 콜백입니다.
function greetUser(name, callback){
console.log("인사를 준비합니다.");
// 인자로 받은 콜백 함수를 내부에서 실행
callback(name);
}
// 콜백 함수로 사용할 익명 함수를 인자로 전달
greetUser("홍길동", function(name){
console.log("안녕하세요, " + name + "님!");
});
// 출력 순서:
// 1. 인사를 준비합니다.
// 2. 안녕하세요, 홍길동님!2. 비동기적 콜백 (Asynchronous Callback)
특정 작업(타이머, 네트워크 요청, 이벤트 발생 등)이 끝날 때까지 기다렸다가, 그 작업이 완료되는 시점에 실행되는 콜백 함수입니다. 자바스크립트에서 비동기 처리를 구현하는 가장 기본적인 방법입니다.
console.log("1. 주문을 받습니다.");
// 3초(3000ms) 뒤에 콜백 함수를 실행하도록 예약
setTimeout(function(){
console.log("3. 음식이 완성되었습니다."); // 비동기적 콜백
}, 3000);
console.log("2. 다른 손님의 주문을 받습니다.");
// 출력 순서:
// 1. 주문을 받습니다.
// 2. 다른 손님의 주문을 받습니다.
// (3초 후) 3. 음식이 완성되었습니다.3. 보충: 콜백 함수의 문제점 (콜백 지옥)
비동기 작업을 연속적으로 처리해야 할 때, 콜백 함수 내부에 또 콜백 함수를 넣고, 그 내부에 또 넣는 식으로 코드를 작성하다 보면 코드의 들여쓰기가 감당할 수 없을 정도로 깊어지는 현상이 발생합니다. 이를 콜백 지옥(Callback Hell) 또는 멸망의 피라미드라고 부릅니다.
JavaScript
// 콜백 지옥 예시
step1(function(value1){
step2(value1, function(value2){
step3(value2, function(value3){
step4(value3, function(value4){
console.log("최종 결과:", value4);
});
});
});
});문제점
- 가독성 저하: 코드가 가로로 너무 길어져서 전체적인 흐름을 한눈에 파악하기 어렵습니다.
- 에러 처리의 어려움: 각 단계마다 에러 핸들링을 따로 해야 하거나, 어디서 에러가 발생했는지 추적하기가 매우 까다롭습니다.
추가보충 2) 프로미스 (Promise) 활용법
프로미스는 비동기 작업의 최종 완료 또는 실패를 나타내는 객체입니다. 작업이 성공했을 때 실행할 콜백과 실패했을 때 실행할 콜백을 미리 연결해둘 수 있는 구조를 제공합니다.
프로미스의 3가지 상태
- Pending (대기): 비동기 처리가 아직 완료되지 않은 상태
- Fulfilled (이행/성공): 비동기 처리가 완료되어 프로미스 결과 값을 반환한 상태
- Rejected (실패): 비동기 처리가 실패하거나 오류가 발생한 상태
프로미스 생성 및 기본 소비 예시
// 1. 프로미스 객체 생성 (제작 코드)
const delayTask = (condition) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (condition) {
resolve("작업 성공"); // 성공 시 값을 전달
} else {
reject(new Error("작업 실패")); // 실패 시 에러 객체를 전달
}
}, 1000);
});
};
// 2. 프로미스 사용 (소비 코드)
delayTask(true)
.then((result) => {
console.log("성공 결과:", result); // "작업 성공" 출력
})
.catch((error) => {
console.error("에러 결과:", error.message);
})
.finally(() => {
console.log("성공/실패 여부와 상관없이 무조건 실행");
});프로미스 체이닝 (Promise Chaining)
여러 비동기 작업을 연속적으로 처리해야 할 때, .then() 메서드를 연결하여 콜백 지옥을 1차적으로 해결할 수 있습니다. 각 .then() 내부에서 값을 반환하거나 새로운 프로미스를 반환하면 다음 .then()으로 이어집니다.
function step1(){ return Promise.resolve("단계 1 완료"); }
function step2(prev){ return Promise.resolve(prev + " -> 단계 2 완료"); }
function step3(prev){ return Promise.resolve(prev + " -> 최종 완료"); }
step1()
.then(result1 => step2(result1))
.then(result2 => step3(result2))
.then(finalResult => {
console.log(finalResult); // "단계 1 완료 -> 단계 2 완료 -> 최종 완료"
})
.catch(error => console.error(error));추가보충 3) async / await 활용법
async/await는 프로미스를 기반으로 작동하지만, 비동기 코드를 마치 동기식 코드(위에서 아래로 한 줄씩 실행되는 일반적인 코드)처럼 작성할 수 있게 해주는 문법 설탕(Syntactic Sugar)입니다. 가독성을 극대화하는 모던 자바스크립트의 표준 방식입니다.
핵심 규칙
async키워드: 비동기 작업을 수행할 함수 앞에 선언합니다.async함수는 내부에 명시적인 return이 없더라도 항상 프로미스 객체를 반환합니다.- ``await
키워드: 반드시async` 함수 내부에서만 사용할 수 있습니다. 프로미스 비동기 작업이 끝날 때까지 코드 실행을 일시 정지하고, 결과가 나오면 다음 줄로 넘어갑니다.
기본 구조 및 에러 처리 예시
async/await 구조에서는 프로미스의 .catch() 대신, 동기식 코드에서 쓰이는 일반적인 try...catch 문을 사용하여 에러를 핸들링합니다.
// 비동기 데이터를 받아오는 모의 함수
function fetchUserData(){
return new Promise((resolve, reject) => {
setTimeout(() => resolve({ id: 101, name: "John" }), 1000);
});
}
function fetchUserPosts(userId){
return new Promise((resolve) => {
setTimeout(() => resolve(["게시글 1", "게시글 2"]), 1000);
});
}
// async/await 활용 함수
async function getUserDashboard(){
try {
console.log("데이터 조회 시작");
// await를 만나면 fetchUserData가 완료될 때까지 1초 동안 멈춘 후 결과를 변수에 할당합니다.
const user = await fetchUserData();
console.log("유저 정보 수신:", user);
// 이전 단계에서 얻은 user.id를 사용해 연속적인 비동기 호출을 진행합니다.
const posts = await fetchUserPosts(user.id);
console.log("글 목록 수신:", posts);
return { user, posts };
} catch (error) {
console.error("데이터를 가져오는 중 에러 발생:", error);
}
}
getUserDashboard();추가보충 4) 진화 과정 비교 (콜백 vs 프로미스 vs async/await)
동일한 흐름의 연쇄 비동기 작업을 세 가지 방식으로 작성했을 때 구조가 어떻게 변하는지 확인하면 활용 목적이 명확해집니다.
1) 콜백 방식 (콜백 지옥)
login(username, (user) => {
getProfile(user, (profile) => {
getHistory(profile, (history) => {
console.log(history);
});
});
});2) 프로미스 방식 (체이닝)
login(username)
.then(user => getProfile(user))
.then(profile => getHistory(profile))
.then(history => console.log(history))
.catch(error => console.error(error));3) async/await 방식 (직관적 흐름)
async function run(){
try {
const user = await login(username);
const profile = await getProfile(user);
const history = await getHistory(profile);
console.log(history);
} catch (error) {
console.error(error);
}
}
run();추가 팁
무조건적인 await 연속 사용은 직렬 처리가 되어 속도가 느려질 수 있습니다. 서로 연관이 없는 독립적인 비동기 작업 여러 개를 동시에 병렬 처리하고 싶을 때는 Promise.all()을 결합하여 await Promise.all([작업1, 작업2]) 형태로 최적화하는 것이 효율적입니다.
댓글
GitHub 계정으로 의견이나 질문을 남길 수 있습니다.