forEach를 쓰지 말아야 하는 이유 - 더 나은 배열 메소드 선택하기

JavaScript 코드 리뷰를 받으면서 이런 피드백을 받아본 적 있나요?

// 리뷰어: "forEach 대신 map을 사용하세요"
const userNames = [];
users.forEach(user => {
  userNames.push(user.name);
});

// "이게 뭐가 다르지? 둘 다 잘 동작하는데..."

처음에는 이해가 안 됐습니다. forEachmap이나 결과는 같은데, 왜 굳이 map을 써야 할까요? 하지만 함수형 프로그래밍을 공부하고, 실무에서 복잡한 데이터 처리 로직을 다루면서 깨달았습니다. forEach는 생각보다 많은 문제를 일으킨다는 것을.

이 문서에서는 왜 forEach를 지양해야 하는지, 그리고 각 상황에서 어떤 배열 메소드를 사용해야 하는지를 실전 경험을 바탕으로 자세히 설명하겠습니다.

목차

왜 forEach를 지양해야 할까요?

1. 의도가 불분명합니다

forEach는 “무엇을 하는지” 코드를 읽어야만 알 수 있습니다.

// ❌ forEach - 코드를 읽어봐야 알 수 있음
const results = [];
items.forEach(item => {
  if (item.price > 100) {
    results.push(item.name);
  }
});

// ✅ filter + map - 한눈에 의도 파악 가능
const results = items
  .filter(item => item.price > 100)
  .map(item => item.name);

코드를 읽는 사람(미래의 당신 포함)은 forEach 블록 전체를 읽어야 “아, 필터링하고 변환하는구나”를 알 수 있습니다. 반면 filtermap은 이름만 봐도 무엇을 하는지 명확합니다.

2. 반환값이 없어서 체이닝이 불가능합니다

// ❌ forEach는 undefined를 반환
const result = [1, 2, 3].forEach(x => x * 2);
console.log(result); // undefined

// ✅ map은 새 배열을 반환하여 체이닝 가능
const result = [1, 2, 3]
  .map(x => x * 2)
  .filter(x => x > 2)
  .reduce((sum, x) => sum + x, 0);

함수형 프로그래밍의 핵심은 데이터 변환의 흐름을 명확히 표현하는 것입니다. forEach는 이 흐름을 끊어버립니다.

3. 부작용(Side Effect)을 유발하기 쉽습니다

forEach는 반환값이 없기 때문에, 외부 상태를 변경하는 것 외에는 할 수 있는 일이 없습니다.

// ❌ forEach - 외부 변수를 수정해야 함
let total = 0;
numbers.forEach(num => {
  total += num; // 외부 변수 수정 (부작용)
});

// ✅ reduce - 순수 함수
const total = numbers.reduce((sum, num) => sum + num, 0);

외부 상태 변경은 디버깅을 어렵게 만들고, 예측 불가능한 버그를 일으킵니다.

4. 코드가 장황해집니다

// ❌ forEach - 7줄
const activeUsers = [];
users.forEach(user => {
  if (user.isActive) {
    activeUsers.push(user);
  }
});
console.log(activeUsers);

// ✅ filter - 1줄
const activeUsers = users.filter(user => user.isActive);

간결함은 단순히 코드 줄 수를 줄이는 것이 아닙니다. 핵심 로직에 집중할 수 있게 해줍니다.

먼저, 문제 상황을 보면서 시작해볼까요?

시나리오: 전자상거래 장바구니 처리

온라인 쇼핑몰에서 장바구니의 총 금액을 계산하는 코드를 작성한다고 해봅시다.

접근 1: forEach 사용 (문제 많음)

// 장바구니 데이터
const cartItems = [
  { id: 1, name: '노트북', price: 1200000, quantity: 1, inStock: true },
  { id: 2, name: '마우스', price: 30000, quantity: 2, inStock: true },
  { id: 3, name: '키보드', price: 80000, quantity: 1, inStock: false },
];

// ❌ forEach로 구현 - 문제점이 많음
function calculateCartTotal(items) {
  let total = 0;
  let count = 0;
  const availableItems = [];

  items.forEach(item => {
    if (item.inStock) {
      const itemTotal = item.price * item.quantity;
      total += itemTotal;
      count += item.quantity;
      availableItems.push(item.name);
    }
  });

  return { total, count, items: availableItems };
}

const result = calculateCartTotal(cartItems);

문제점:

  • 여러 개의 외부 변수를 수정 (total, count, availableItems)
  • 하나의 루프에서 여러 가지 일을 동시에 처리
  • 디버깅이 어려움 (어느 시점에 어떤 값이 변경되었는지 추적 곤란)
  • 코드의 의도가 불명확

접근 2: 적절한 배열 메소드 조합 (권장)

// ✅ 명확한 배열 메소드 조합
function calculateCartTotal(items) {
  const inStockItems = items.filter(item => item.inStock);

  const total = inStockItems
    .map(item => item.price * item.quantity)
    .reduce((sum, price) => sum + price, 0);

  const count = inStockItems
    .map(item => item.quantity)
    .reduce((sum, qty) => sum + qty, 0);

  const itemNames = inStockItems.map(item => item.name);

  return { total, count, items: itemNames };
}

const result = calculateCartTotal(cartItems);

개선된 점:

  • ✅ 각 단계의 의도가 명확 (필터링 → 변환 → 누적)
  • ✅ 외부 상태 변경 없음 (순수 함수)
  • ✅ 중간 결과를 쉽게 확인 가능 (디버깅 용이)
  • ✅ 각 연산을 독립적으로 테스트 가능

접근 3: 더 최적화된 버전

// 🎯 성능과 가독성을 모두 고려
function calculateCartTotal(items) {
  return items
    .filter(item => item.inStock)
    .reduce((acc, item) => ({
      total: acc.total + (item.price * item.quantity),
      count: acc.count + item.quantity,
      items: [...acc.items, item.name]
    }), { total: 0, count: 0, items: [] });
}

const result = calculateCartTotal(cartItems);

장점:

  • 한 번의 순회로 모든 계산 완료 (성능 최적화)
  • 여전히 순수 함수 유지
  • 체이닝으로 데이터 흐름이 명확

forEach의 근본적인 문제점

문제 1: 부작용(Side Effect)을 강제합니다

forEach는 반환값이 undefined이기 때문에, 유용한 일을 하려면 반드시 부작용을 일으켜야 합니다.

// forEach는 부작용 없이는 아무것도 할 수 없음
[1, 2, 3].forEach(x => x * 2); // 아무 일도 일어나지 않음

// 부작용을 일으켜야 함
const results = [];
[1, 2, 3].forEach(x => {
  results.push(x * 2); // 외부 배열 수정 (부작용)
});

부작용이 왜 문제일까요?

실제 프로젝트에서 이런 버그를 경험한 적이 있습니다:

// 버그가 있는 코드 - forEach 사용
function processUserData(users) {
  const processedUsers = [];

  users.forEach(user => {
    // 여러 조건 처리...
    if (user.age >= 18) {
      processedUsers.push(user);
    }

    // ... 100줄의 코드 ...

    // 버그: 여기서 실수로 다시 push
    if (user.isPremium) {
      processedUsers.push(user); // 중복 추가!
    }
  });

  return processedUsers;
}

100줄짜리 forEach 블록에서 같은 배열을 여러 곳에서 수정하다 보니, 중복 데이터가 들어간 버그를 발견하는 데 한참이 걸렸습니다.

// ✅ filter를 사용하면 이런 버그가 불가능
function processUserData(users) {
  return users.filter(user =>
    (user.age >= 18) || user.isPremium
  );
}

문제 2: 함수형 프로그래밍 원칙 위배

함수형 프로그래밍의 핵심 원칙은 불변성(Immutability)순수 함수(Pure Function)입니다.

불변성 위반

// ❌ forEach - 원본 데이터 수정
const users = [
  { name: 'John', active: false },
  { name: 'Jane', active: false }
];

users.forEach(user => {
  user.active = true; // 원본 객체 수정!
});

console.log(users); // 원본이 변경됨

// ✅ map - 새로운 데이터 생성
const activeUsers = users.map(user => ({
  ...user,
  active: true
}));

console.log(users);       // 원본 유지
console.log(activeUsers); // 새 배열

왜 불변성이 중요할까요?

React 같은 프레임워크를 사용하면서 이런 경험을 해보셨을 겁니다:

// React에서 흔한 실수
function TodoList() {
  const [todos, setTodos] = useState([...]);

  const toggleTodo = (id) => {
    // ❌ 원본 수정 - React가 변경을 감지 못함!
    const todo = todos.find(t => t.id === id);
    todo.completed = !todo.completed;
    setTodos(todos); // 리렌더링 안 됨!

    // ✅ 새 배열 생성 - React가 변경 감지
    setTodos(todos.map(todo =>
      todo.id === id
        ? { ...todo, completed: !todo.completed }
        : todo
    ));
  };
}

순수 함수 위반

// ❌ 부작용이 있는 함수 (forEach 사용)
let globalCounter = 0;

function processItems(items) {
  const results = [];
  items.forEach(item => {
    globalCounter++; // 전역 상태 수정 (부작용)
    results.push(item * 2);
  });
  return results;
}

// 같은 입력, 다른 결과
console.log(processItems([1, 2])); // [2, 4], globalCounter: 2
console.log(processItems([1, 2])); // [2, 4], globalCounter: 4 (다름!)

// ✅ 순수 함수 (map 사용)
function processItems(items) {
  return items.map(item => item * 2);
}

// 같은 입력, 항상 같은 결과
console.log(processItems([1, 2])); // [2, 4]
console.log(processItems([1, 2])); // [2, 4] (항상 같음)

문제 3: 코드의 의도를 숨깁니다

// ❌ forEach - 무엇을 하는지 모름
const result = [];
data.forEach(item => {
  // 50줄의 복잡한 로직...
});
// result에 뭐가 들어있을까?

// ✅ map/filter - 이름이 의도를 말해줌
const validItems = data.filter(isValid);
const transformedItems = validItems.map(transform);
const total = transformedItems.reduce(sum, 0);

코드는 사람이 읽기 위한 것입니다. forEach는 “순회한다”는 것만 알려줄 뿐, 그 안에서 무슨 일이 일어나는지는 블록을 열어봐야 합니다.

문제 4: 조기 종료가 불가능합니다

// ❌ forEach - break, return이 동작하지 않음
const numbers = [1, 2, 3, 4, 5];

numbers.forEach(num => {
  if (num === 3) {
    return; // forEach만 종료되는 것이 아니라, 현재 반복만 건너뜀
  }
  console.log(num); // 1, 2, 4, 5 모두 출력됨
});

// ✅ for...of - 정상적으로 break 가능
for (const num of numbers) {
  if (num === 3) {
    break; // 전체 루프 종료
  }
  console.log(num); // 1, 2만 출력
}

// ✅ some - 조건을 만족하면 종료
const found = numbers.some(num => {
  console.log(num);
  return num === 3; // 1, 2, 3만 출력되고 종료
});

상황별 올바른 배열 메소드 선택하기

각 배열 메소드는 특정 목적을 위해 설계되었습니다. 올바른 도구를 선택하면 코드가 명확하고, 안전하며, 효율적입니다.

1. 데이터 변환 - map 사용

배열의 각 요소를 변환하여 새로운 배열을 만들 때 사용합니다.

// 사용자 이름만 추출
const users = [
  { id: 1, name: 'John', age: 25 },
  { id: 2, name: 'Jane', age: 30 }
];

// ❌ forEach
const names = [];
users.forEach(user => {
  names.push(user.name);
});

// ✅ map
const names = users.map(user => user.name);

실전 예제: API 응답 데이터 변환

// API에서 받은 데이터
const apiResponse = {
  users: [
    { user_id: 1, user_name: 'John', user_email: 'john@example.com' },
    { user_id: 2, user_name: 'Jane', user_email: 'jane@example.com' }
  ]
};

// ❌ forEach - 장황함
const normalizedUsers = [];
apiResponse.users.forEach(user => {
  normalizedUsers.push({
    id: user.user_id,
    name: user.user_name,
    email: user.user_email
  });
});

// ✅ map - 명확하고 간결
const normalizedUsers = apiResponse.users.map(user => ({
  id: user.user_id,
  name: user.user_name,
  email: user.user_email
}));

// 🎯 더 나아가기: 재사용 가능한 변환 함수
const normalizeUser = user => ({
  id: user.user_id,
  name: user.user_name,
  email: user.user_email
});

const normalizedUsers = apiResponse.users.map(normalizeUser);

2. 데이터 필터링 - filter 사용

조건을 만족하는 요소만 선택할 때 사용합니다.

const products = [
  { name: '노트북', price: 1200000, inStock: true },
  { name: '마우스', price: 30000, inStock: true },
  { name: '키보드', price: 80000, inStock: false }
];

// ❌ forEach
const availableProducts = [];
products.forEach(product => {
  if (product.inStock) {
    availableProducts.push(product);
  }
});

// ✅ filter
const availableProducts = products.filter(product => product.inStock);

실전 예제: 복잡한 필터링 로직

// 여러 조건으로 필터링
const users = [
  { name: 'John', age: 25, isPremium: true, lastLogin: '2025-12-01' },
  { name: 'Jane', age: 17, isPremium: false, lastLogin: '2025-11-28' },
  { name: 'Bob', age: 30, isPremium: true, lastLogin: '2025-10-15' }
];

// ❌ forEach - 복잡하고 읽기 어려움
const activeUsers = [];
users.forEach(user => {
  const isAdult = user.age >= 18;
  const recentlyActive = new Date(user.lastLogin) > new Date('2025-11-01');

  if (isAdult && (user.isPremium || recentlyActive)) {
    activeUsers.push(user);
  }
});

// ✅ filter - 조건이 명확
const activeUsers = users.filter(user => {
  const isAdult = user.age >= 18;
  const recentlyActive = new Date(user.lastLogin) > new Date('2025-11-01');
  return isAdult && (user.isPremium || recentlyActive);
});

// 🎯 더 나아가기: 조건을 함수로 분리
const isAdult = user => user.age >= 18;
const isRecentlyActive = user =>
  new Date(user.lastLogin) > new Date('2025-11-01');
const isEligible = user =>
  isAdult(user) && (user.isPremium || isRecentlyActive(user));

const activeUsers = users.filter(isEligible);

3. 누적 계산 - reduce 사용

배열을 순회하며 하나의 값으로 축약할 때 사용합니다.

const orders = [
  { id: 1, amount: 10000 },
  { id: 2, amount: 25000 },
  { id: 3, amount: 15000 }
];

// ❌ forEach
let total = 0;
orders.forEach(order => {
  total += order.amount;
});

// ✅ reduce
const total = orders.reduce((sum, order) => sum + order.amount, 0);

실전 예제: 복잡한 데이터 구조 생성

const users = [
  { id: 1, name: 'John', role: 'admin' },
  { id: 2, name: 'Jane', role: 'user' },
  { id: 3, name: 'Bob', role: 'admin' }
];

// ❌ forEach - ID를 키로 하는 맵 생성
const userMap = {};
users.forEach(user => {
  userMap[user.id] = user;
});

// ✅ reduce - 의도가 명확
const userMap = users.reduce((map, user) => {
  map[user.id] = user;
  return map;
}, {});

// 🎯 역할별 그룹화
const usersByRole = users.reduce((groups, user) => {
  const role = user.role;
  if (!groups[role]) {
    groups[role] = [];
  }
  groups[role].push(user);
  return groups;
}, {});

// 결과: { admin: [John, Bob], user: [Jane] }

4. 조건 확인 - some/every 사용

하나라도 조건을 만족하는지 또는 모두 만족하는지 확인할 때 사용합니다.

const products = [
  { name: '노트북', price: 1200000 },
  { name: '마우스', price: 30000 },
  { name: '키보드', price: 80000 }
];

// ❌ forEach - 불필요하게 전체 순회
let hasExpensive = false;
products.forEach(product => {
  if (product.price > 1000000) {
    hasExpensive = true;
  }
});

// ✅ some - 조건 만족하면 즉시 종료
const hasExpensive = products.some(product => product.price > 1000000);

// ✅ every - 모든 제품이 재고 있는지
const allInStock = products.every(product => product.stock > 0);

실전 예제: 폼 유효성 검사

const formFields = [
  { name: 'email', value: 'test@example.com', valid: true },
  { name: 'password', value: '', valid: false },
  { name: 'age', value: '25', valid: true }
];

// ❌ forEach
let hasError = false;
formFields.forEach(field => {
  if (!field.valid) {
    hasError = true;
  }
});

// ✅ some - 하나라도 유효하지 않은 필드가 있는지
const hasError = formFields.some(field => !field.valid);

// ✅ every - 모든 필드가 유효한지
const isFormValid = formFields.every(field => field.valid);

5. 검색 - find/findIndex 사용

특정 조건을 만족하는 첫 번째 요소를 찾을 때 사용합니다.

const users = [
  { id: 1, name: 'John' },
  { id: 2, name: 'Jane' },
  { id: 3, name: 'Bob' }
];

// ❌ forEach - 찾아도 계속 순회
let foundUser = null;
users.forEach(user => {
  if (user.id === 2) {
    foundUser = user;
  }
});

// ✅ find - 찾으면 즉시 종료
const foundUser = users.find(user => user.id === 2);

// ✅ findIndex - 인덱스 찾기
const index = users.findIndex(user => user.id === 2);

실전에서 forEach 제거하기

실제 프로젝트 코드에서 forEach를 제거하는 패턴들을 살펴봅시다.

패턴 1: 배열 변환 + 누적

// ❌ forEach - 변환과 누적을 동시에
let totalPrice = 0;
const processedItems = [];

cartItems.forEach(item => {
  const discountedPrice = item.price * 0.9;
  processedItems.push({
    ...item,
    finalPrice: discountedPrice
  });
  totalPrice += discountedPrice * item.quantity;
});

// ✅ map + reduce - 각 단계가 명확
const processedItems = cartItems.map(item => ({
  ...item,
  finalPrice: item.price * 0.9
}));

const totalPrice = processedItems.reduce(
  (sum, item) => sum + (item.finalPrice * item.quantity),
  0
);

// 🎯 더 간결하게: 한 번의 reduce로
const { items: processedItems, total: totalPrice } = cartItems.reduce(
  (acc, item) => {
    const finalPrice = item.price * 0.9;
    return {
      items: [...acc.items, { ...item, finalPrice }],
      total: acc.total + (finalPrice * item.quantity)
    };
  },
  { items: [], total: 0 }
);

패턴 2: 조건부 필터링 + 변환

// ❌ forEach - 조건과 변환이 뒤섞임
const results = [];
users.forEach(user => {
  if (user.age >= 18 && user.isActive) {
    results.push({
      id: user.id,
      displayName: `${user.firstName} ${user.lastName}`,
      email: user.email.toLowerCase()
    });
  }
});

// ✅ filter + map - 각 단계가 명확
const results = users
  .filter(user => user.age >= 18 && user.isActive)
  .map(user => ({
    id: user.id,
    displayName: `${user.firstName} ${user.lastName}`,
    email: user.email.toLowerCase()
  }));

패턴 3: 그룹화

// ❌ forEach - 복잡한 그룹화 로직
const groupedByCategory = {};
products.forEach(product => {
  const category = product.category;
  if (!groupedByCategory[category]) {
    groupedByCategory[category] = [];
  }
  groupedByCategory[category].push(product);
});

// ✅ reduce - 의도가 명확
const groupedByCategory = products.reduce((groups, product) => {
  const category = product.category;
  return {
    ...groups,
    [category]: [...(groups[category] || []), product]
  };
}, {});

// 🎯 재사용 가능한 유틸리티 함수로
const groupBy = (array, key) => {
  return array.reduce((groups, item) => {
    const value = item[key];
    return {
      ...groups,
      [value]: [...(groups[value] || []), item]
    };
  }, {});
};

const groupedByCategory = groupBy(products, 'category');

패턴 4: 데이터 정규화

// API 응답을 정규화하는 실제 시나리오
const apiResponse = [
  {
    post_id: 1,
    post_title: 'Hello',
    author: { author_id: 10, author_name: 'John' },
    tags: ['javascript', 'web']
  },
  {
    post_id: 2,
    post_title: 'World',
    author: { author_id: 20, author_name: 'Jane' },
    tags: ['react', 'web']
  }
];

// ❌ forEach - 복잡하고 읽기 어려움
const posts = {};
const authors = {};
const allTags = new Set();

apiResponse.forEach(post => {
  posts[post.post_id] = {
    id: post.post_id,
    title: post.post_title,
    authorId: post.author.author_id
  };

  authors[post.author.author_id] = {
    id: post.author.author_id,
    name: post.author.author_name
  };

  post.tags.forEach(tag => allTags.add(tag));
});

// ✅ reduce - 구조화되고 명확
const normalized = apiResponse.reduce(
  (acc, post) => ({
    posts: {
      ...acc.posts,
      [post.post_id]: {
        id: post.post_id,
        title: post.post_title,
        authorId: post.author.author_id
      }
    },
    authors: {
      ...acc.authors,
      [post.author.author_id]: {
        id: post.author.author_id,
        name: post.author.author_name
      }
    },
    tags: new Set([...acc.tags, ...post.tags])
  }),
  { posts: {}, authors: {}, tags: new Set() }
);

forEach를 써도 되는 예외 상황

모든 규칙에는 예외가 있습니다. 다음 경우에는 forEach를 사용해도 괜찮습니다 (하지만 for...of가 더 나을 수 있습니다).

1. 순수한 부작용이 목적인 경우

외부 시스템과의 상호작용이나 로깅 등, 부작용 자체가 목적일 때입니다.

// DOM 조작 (하지만 for...of가 더 명확)
// ⚠️ forEach 사용 가능
buttons.forEach(button => {
  button.addEventListener('click', handleClick);
});

// 🎯 더 나은 방법: for...of
for (const button of buttons) {
  button.addEventListener('click', handleClick);
}

// 로깅
users.forEach(user => {
  console.log(`Processing user: ${user.name}`);
  logToAnalytics('user_processed', { userId: user.id });
});

// 🎯 하지만 이것도 for...of가 더 명확
for (const user of users) {
  console.log(`Processing user: ${user.name}`);
  logToAnalytics('user_processed', { userId: user.id });
}

2. 비동기 작업 (하지만 주의 필요)

// ⚠️ forEach는 Promise를 기다리지 않음!
users.forEach(async user => {
  await sendEmail(user.email, message);
});
// 모든 이메일이 전송되기 전에 다음 코드가 실행됨

// ✅ Promise.all + map 사용
await Promise.all(
  users.map(user => sendEmail(user.email, message))
);

// ✅ 순차 실행이 필요하면 for...of
for (const user of users) {
  await sendEmail(user.email, message);
}

실제로 경험한 버그:

// 버그가 있는 코드
async function processOrders(orders) {
  orders.forEach(async order => {
    await updateInventory(order);
    await sendConfirmation(order);
  });

  console.log('All done!'); // 너무 빨리 실행됨!
  return 'success';
}

// ✅ 올바른 코드
async function processOrders(orders) {
  for (const order of orders) {
    await updateInventory(order);
    await sendConfirmation(order);
  }

  console.log('All done!'); // 모든 작업 완료 후 실행
  return 'success';
}

3. 성능이 극도로 중요한 경우

매우 큰 배열(수백만 개)을 처리할 때는 일반 for 루프가 가장 빠릅니다.

const hugeArray = new Array(10000000).fill(0).map((_, i) => i);

// 가장 빠름: for 루프
console.time('for');
let sum1 = 0;
for (let i = 0; i < hugeArray.length; i++) {
  sum1 += hugeArray[i];
}
console.timeEnd('for'); // ~40ms

// 중간: for...of
console.time('for...of');
let sum2 = 0;
for (const num of hugeArray) {
  sum2 += num;
}
console.timeEnd('for...of'); // ~45ms

// 느림: forEach
console.time('forEach');
let sum3 = 0;
hugeArray.forEach(num => sum3 += num);
console.timeEnd('forEach'); // ~50ms

// 가장 느림: reduce
console.time('reduce');
const sum4 = hugeArray.reduce((sum, num) => sum + num, 0);
console.timeEnd('reduce'); // ~60ms

하지만 대부분의 경우 이 정도 차이는 무시할 수 있습니다. 가독성과 유지보수성이 더 중요합니다.

함수형 프로그래밍 관점에서 본 배열 메소드

함수형 프로그래밍의 핵심 개념들을 이해하면, 왜 forEach를 지양해야 하는지 더 명확해집니다.

순수 함수 (Pure Function)

같은 입력에 항상 같은 출력을 반환하고, 부작용이 없는 함수입니다.

// ❌ 순수하지 않은 함수 (forEach 사용)
let total = 0;
const addToTotal = (numbers) => {
  numbers.forEach(num => {
    total += num; // 외부 상태 수정 (부작용)
  });
};

addToTotal([1, 2, 3]); // total = 6
addToTotal([1, 2, 3]); // total = 12 (같은 입력, 다른 결과!)

// ✅ 순수 함수 (reduce 사용)
const sum = (numbers) => {
  return numbers.reduce((acc, num) => acc + num, 0);
};

sum([1, 2, 3]); // 6
sum([1, 2, 3]); // 6 (항상 같은 결과)

순수 함수의 장점:

  • 테스트하기 쉽움
  • 예측 가능함
  • 병렬 처리 가능
  • 메모이제이션 가능

불변성 (Immutability)

데이터를 변경하지 않고, 새로운 데이터를 생성합니다.

// ❌ 가변 (forEach로 원본 수정)
const updatePrices = (products, increaseBy) => {
  products.forEach(product => {
    product.price += increaseBy; // 원본 수정!
  });
  return products;
};

const original = [{ name: 'A', price: 100 }];
const updated = updatePrices(original, 10);

console.log(original === updated); // true (같은 객체!)
console.log(original[0].price); // 110 (원본이 변경됨!)

// ✅ 불변 (map으로 새 배열 생성)
const updatePrices = (products, increaseBy) => {
  return products.map(product => ({
    ...product,
    price: product.price + increaseBy
  }));
};

const original = [{ name: 'A', price: 100 }];
const updated = updatePrices(original, 10);

console.log(original === updated); // false (다른 객체)
console.log(original[0].price); // 100 (원본 유지)
console.log(updated[0].price); // 110 (새 객체)

함수 합성 (Function Composition)

작은 함수들을 조합하여 복잡한 기능을 만듭니다.

// 작은 순수 함수들
const isActive = user => user.isActive;
const isAdult = user => user.age >= 18;
const toUpperCase = str => str.toUpperCase();
const getUserName = user => user.name;

// ❌ forEach - 합성이 어려움
const getActiveAdultNames = (users) => {
  const names = [];
  users.forEach(user => {
    if (isActive(user) && isAdult(user)) {
      names.push(toUpperCase(getUserName(user)));
    }
  });
  return names;
};

// ✅ filter + map - 함수 합성이 자연스러움
const getActiveAdultNames = (users) => {
  return users
    .filter(isActive)
    .filter(isAdult)
    .map(getUserName)
    .map(toUpperCase);
};

// 🎯 더 나아가기: 범용 유틸리티
const pipe = (...fns) => x => fns.reduce((v, f) => f(v), x);
const compose = (...fns) => x => fns.reduceRight((v, f) => f(v), x);

const processUser = pipe(
  users => users.filter(isActive),
  users => users.filter(isAdult),
  users => users.map(getUserName),
  names => names.map(toUpperCase)
);

const result = processUser(users);

성능 비교와 고려사항

벤치마크 테스트

다양한 크기의 배열에서 성능을 측정해봤습니다.

// 테스트 데이터
const smallArray = Array.from({ length: 100 }, (_, i) => i);
const mediumArray = Array.from({ length: 10000 }, (_, i) => i);
const largeArray = Array.from({ length: 1000000 }, (_, i) => i);

// 테스트 함수
function benchmarkSum(arr, method) {
  console.time(method);

  let result;
  switch(method) {
    case 'for':
      let sum = 0;
      for (let i = 0; i < arr.length; i++) {
        sum += arr[i];
      }
      result = sum;
      break;

    case 'for...of':
      let sum2 = 0;
      for (const num of arr) {
        sum2 += num;
      }
      result = sum2;
      break;

    case 'forEach':
      let sum3 = 0;
      arr.forEach(num => sum3 += num);
      result = sum3;
      break;

    case 'reduce':
      result = arr.reduce((sum, num) => sum + num, 0);
      break;
  }

  console.timeEnd(method);
  return result;
}

// 큰 배열에서 테스트
console.log('Large Array (1,000,000 items):');
benchmarkSum(largeArray, 'for');        // ~40ms
benchmarkSum(largeArray, 'for...of');   // ~45ms
benchmarkSum(largeArray, 'forEach');    // ~50ms
benchmarkSum(largeArray, 'reduce');     // ~60ms

성능 순위:

  1. for - 가장 빠름 (하지만 가독성이 떨어짐)
  2. for...of - 빠르면서도 읽기 쉬움
  3. forEach - 중간 (부작용 필요)
  4. reduce - 느림 (하지만 함수형)

실무에서의 성능 고려사항

대부분의 경우 성능 차이는 무시할 수 있습니다:

// 일반적인 웹 애플리케이션의 배열 크기
const typicalData = Array.from({ length: 100 }, (_, i) => i);

// 100개 요소에서의 차이
benchmarkSum(typicalData, 'reduce');  // ~0.1ms
benchmarkSum(typicalData, 'forEach'); // ~0.08ms

// 차이: 0.02ms = 무시 가능!

성능보다 중요한 것:

  • 코드 가독성
  • 유지보수성
  • 버그 가능성 감소
  • 팀원들의 이해도

성능이 정말 중요한 경우에만 for 사용:

  • 실시간 데이터 처리
  • 게임 엔진의 렌더링 루프
  • 수백만 개 이상의 데이터
  • 초당 수천 번 호출되는 함수

메모리 사용량

const data = Array.from({ length: 1000000 }, (_, i) => ({ id: i, value: i * 2 }));

// ❌ forEach - 중간 배열 필요
const results1 = [];
data.forEach(item => {
  if (item.value > 100) {
    results1.push(item.id);
  }
});

// ✅ filter + map - 두 개의 중간 배열 생성
const results2 = data
  .filter(item => item.value > 100)
  .map(item => item.id);

// 🎯 최적화: reduce로 한 번만 순회
const results3 = data.reduce((acc, item) => {
  if (item.value > 100) {
    acc.push(item.id);
  }
  return acc;
}, []);

// 🚀 최고 성능: for 루프
const results4 = [];
for (let i = 0; i < data.length; i++) {
  if (data[i].value > 100) {
    results4.push(data[i].id);
  }
}

권장사항:

  • 일반적인 경우: filter + map (가독성 우선)
  • 매우 큰 배열: reduce 또는 for (성능 우선)
  • 실시간 처리: for (최고 성능)

결론: 배열 메소드 선택 가이드

빠른 결정 트리

데이터를 변환하나요?
├─ 예 → map 사용
└─ 아니오 → 다음으로

조건에 맞는 것만 선택하나요?
├─ 예 → filter 사용
└─ 아니오 → 다음으로

하나의 값으로 누적하나요?
├─ 예 → reduce 사용
└─ 아니오 → 다음으로

조건을 확인만 하나요?
├─ 하나라도 만족? → some 사용
├─ 모두 만족? → every 사용
└─ 특정 요소 찾기? → find 사용

부작용이 목적인가요?
├─ 예 → for...of 사용 (forEach보다 명확)
└─ 성능이 극도로 중요? → for 루프 사용

핵심 원칙

  1. forEach는 최후의 수단
    • 다른 메소드로 표현 가능하면 그것을 사용
    • 부작용이 목적이라면 for...of가 더 명확
  2. 의도를 명확하게
    • map: “변환합니다”
    • filter: “선택합니다”
    • reduce: “누적합니다”
    • forEach: “…뭔가 합니다?” (불명확!)
  3. 함수형 우선, 성능은 나중에
    • 먼저 읽기 쉬운 코드 작성
    • 성능 문제가 실제로 발생하면 최적화
    • 대부분의 경우 차이를 느낄 수 없음
  4. 순수 함수와 불변성
    • 외부 상태 수정 지양
    • 새로운 데이터 생성
    • 테스트와 디버깅이 쉬워짐

실전 체크리스트

코드 리뷰 시 확인할 항목:

  • forEach 대신 map/filter/reduce를 쓸 수 있나?
  • 외부 변수를 수정하고 있나?
  • 반환값이 필요한가?
  • 체이닝이 가능하면 더 명확한가?
  • 조기 종료가 필요한가? (some/every/find)
  • 부작용이 목적이라면 for...of가 더 명확한가?

마지막 조언

forEach를 완전히 금지할 필요는 없습니다. 하지만 forEach를 사용할 때마다 “더 나은 방법이 있지 않을까?”를 자문해보세요.

처음에는 map, filter, reduce가 낯설 수 있습니다. 하지만 익숙해지면, 코드가 더 읽기 쉽고, 더 안전하며, 더 유지보수하기 쉬워진다는 것을 느낄 수 있을 겁니다.

함수형 프로그래밍은 단순히 “멋있어 보이는” 코드를 작성하는 것이 아닙니다. 더 적은 버그, 더 쉬운 테스트, 더 명확한 의도를 가진 코드를 작성하는 실용적인 방법입니다. 🎯

참고 자료

MDN 공식 문서

함수형 프로그래밍 학습

도구와 라이브러리

관련 문서

댓글