배열을 순회하는 방법은 여러 가지가 있음. 전통적인 for, Array.prototype.forEach, ES6 for…of, 그리고 객체 속성에 적합한 for…in. 여기에 forEach의 비동기 제어 한계와 값/참조 전달 의미 차이를 함께 정리함
개념/배경
자바스크립트에서 순회는 목적과 제약에 맞춰 선택하는 문제임
- for 인덱스 제어, 경계 조건, 성능 미세 조정, 조기 종료 필요 시 유용
- forEach 선언적 스타일, 반환값 없음, 조기 종료 불가, 비동기 완료 제어 불가
- for…of 이터러블 값 순회에 적합, 인덱스 필요 없을 때 간결, await와 결합 가능
- for…in 객체의 열거 가능 속성 순회용. 배열에는 권장하지 않음. 키 순서와 상속 프로퍼티 이슈 존재
이터러블과 이터레이터 프로토콜을 따르는 객체에 for…of를 적용 가능. 배열, 문자열, Map, Set 등이 대표적임
forEach의 비동기 한계
forEach는 콜백을 동기적으로 순회함. 콜백 내부에서 비동기 작업을 호출해도 forEach 자체는 해당 작업의 완료를 기다리지 않음. 반환값은 항상 undefined이며 프로미스 체이닝에 직접 쓸 수 없음. break, continue, return으로 흐름 제어 불가
아래 코드는 바깥 스코프에서 모든 비동기 처리가 끝났는지 보장하지 않음
items.forEach(async item => {
await doSomethingAsync(item)
})
// 이 시점에 모든 작업이 끝났다고 보장 못함
핵심 포인트
- 순회는 동기적으로 완료되고 비동기 작업은 백그라운드로 진행됨
- await를 콜백 내부에 써도 forEach 전체의 완료 시점은 추적되지 않음
- 콜백 결과를 수집해 단일 프로미스로 합치기 어려움
비동기 제어 대안
순차 처리 필요 시 for…of + await 선택
for (const item of items) await doSomethingAsync(item)
// 여기 도달 시 순차 처리 완료
병렬 처리 필요 시 map + Promise.all 사용
await Promise.all(items.map(item => doSomethingAsync(item)))
// 병렬 실행 후 전부 완료된 시점
순차 누적 계산이 필요하면 reduce + async/await 패턴 고려
const result = await items.reduce(async (accP, item) => {
const acc = await accP
const next = await stepAsync(acc, item)
return next
}, Promise.resolve(init))트레이드오프 요약
- for…of + await 순차적 안정성, 외부 리소스 제한 정책과 궁합 좋음, 총 소요시간 증가 가능
- Promise.all 병렬 성능, 실패 시 전체 거부, 동시성 제한 없으므로 과부하 주의
- reduce 기반 순차 누적 로직 표현력 좋음, 가독성 비용 존재
반복문 선택 가이드
- 인덱스 필요, 조기 종료 필요, 성능 미세 튜닝 필요 for 사용
- 값만 필요, 동기적 작업, 간결한 선언형 스타일 선호 forEach 사용
- 이터러블을 값 중심으로 순회, await로 흐름 제어 필요 for…of 사용
- 객체 속성 키 순회 for…in 사용. 배열에는 비권장
참고 이슈
- forEach는 break/continue 불가. 일부 조건에서 return으로 콜백만 종료 가능하나 전체 순회는 계속됨
- for…of는 break/continue/yield/await 등 제어 흐름 조합 용이
- 배열을 객체처럼 취급해 for…in을 쓰면 예상치 못한 키 순서, 상속 프로퍼티 순회 등으로 버그 유발 가능
TypeScript/JavaScript의 값과 참조 전달 이해
TypeScript는 JavaScript와 동일한 호출 의미론을 가짐. 자바스크립트는 엄밀히 값에 의한 전달 모델이며 객체의 참조도 값으로 복사됨. 흔히 참조에 의한 전달로 오해하지만 실제로는 참조 값이 복사되어 공유되는 pass-by-sharing 특성에 가깝다고 이해하는 편이 안전함
- 원시 타입 number, string, boolean, null, undefined, symbol, bigint는 값이 복사되어 전달됨. 함수 내부 변경이 외부에 영향 없음
- 객체, 배열, 함수는 참조 값이 복사되어 전달됨. 함수 내부에서 프로퍼티를 변경하면 같은 객체를 가리키므로 외부에서도 변경이 보임. 단, 매개변수 변수에 새 객체를 재할당해도 외부 바인딩은 그대로 유지됨
원시 타입 예시
let n = 50
function changeValue(x: number) { x = 100 }
changeValue(n) // n은 50 유지
객체 프로퍼티 변경 예시
const user = { name: 'Original' }
function changeObject(o: { name: string }) { o.name = 'Changed' }
changeObject(user) // user.name은 'Changed'
객체 자체 재할당 예시
const user = { name: 'Original' }
function replaceObject(o: { name: string }) { o = { name: 'New' } }
replaceObject(user) // 외부 user는 그대로 유지
배열도 동일한 참조 값 복사 특성 적용
const arr = [1, 2, 3]
function pushItem(a: number[]) { a.push(4) }
pushItem(arr) // arr는 [1,2,3,4]
프로퍼티 읽기 시 값 복사 예시. 문자열은 원시 타입이므로 별도 복사본을 가짐
const user = { name: 'John' }
let userName = user.name // 'John' 값 복사
userName = 'Jane' // user.name에는 영향 없음
실무 체크리스트
- 원시와 참조 타입의 전달 차이를 명확히 구분할 것
- 함수에서 외부 객체를 변경하려는 의도가 없다면 얕은 복사나 깊은 복사로 불변성 유지 고려
- 비동기 루프에서 완료 시점이 중요하면 for…of + await 또는 Promise.all을 명시적으로 사용할 것
- 병렬 처리 시 동시성 제한이 필요하면 p-limit 류의 제한 기법 적용 검토
마무리
forEach는 설계상 비동기 완료를 기다리지 않는 동기 순회 도구임. 비동기 흐름 제어가 필요하면 for…of + await 또는 Promise.all 같은 명시적 조합 선택이 안전함. 또한 JS/TS는 값에 의한 전달이며 참조 값 복사라는 특성 때문에 객체 내용 변경은 공유되지만 재할당은 외부에 파급되지 않음을 전제하고 코드를 작성할 것
참고자료
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...in
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures
- https://developer.mozilla.org/en-US/docs/Glossary/Primitive