반복문은 같은 코드를 여러 번 실행하는 문법이 아니다

vx_developer·2026년 8월 26일

개발하다가

목록 보기
5/30
post-thumbnail

프로그래밍을 처음 배울 때 반복문은 보통 같은 코드를 여러 번 실행하기 위한 문법이라고 배운다.

for (let i = 0; i < 5; i++) {
  console.log("Hello");
}

이 코드는 "Hello"를 다섯 번 출력한다.

조금 더 나아가면 배열의 데이터를 하나씩 꺼내 처리하는 방법을 배운다.

const names = ["JungWoo", "Minji", "Alex"];

for (const name of names) {
  console.log(name);
}

이 설명은 반복문의 기본 문법을 이해하는 데는 충분하다. 그러나 실제 서비스를 개발할 때 반복문의 역할을 “같은 코드를 여러 번 실행하는 것”으로만 이해하면 중요한 설계 문제를 놓치게 된다.

실제 서비스에서 반복문은 단순히 실행 횟수를 줄이는 문법이 아니다.

반복문은 사용자 목록, 상품 목록, 주문 목록, 쿠폰 목록처럼 여러 개의 데이터를 같은 기준으로 검사하고, 변환하고, 분류하고, 저장하기 위한 방법이다.

즉, 반복문을 작성한다는 것은 다음 질문에 답하는 일이다.

여러 데이터를 어떤 규칙으로, 어떤 순서로, 어디까지, 실패했을 때 어떻게 처리할 것인가?

이 질문에 대한 답이 명확하지 않으면 반복문 자체는 정상적으로 실행되더라도 서비스 데이터는 서로 다른 규칙으로 처리되거나 일부만 변경될 수 있다.


교과서에서는 반복문을 어떻게 설명하는가

JavaScript에서 자주 사용하는 반복문에는 for, while, for...of 등이 있다.

for (let index = 0; index < 3; index++) {
  console.log(index);
}

조건이 참인 동안 반복하는 while문도 있다.

let count = 0;

while (count < 3) {
  console.log(count);
  count += 1;
}

배열의 값을 순서대로 꺼낼 때는 for...of를 사용할 수 있다.

const coupons = ["Coffee Coupon", "Dinner Coupon", "Massage Coupon"];

for (const coupon of coupons) {
  console.log(coupon);
}

이러한 예제를 통해 반복문의 구성 요소를 배운다.

  • 반복을 시작할 위치
  • 반복을 계속할 조건
  • 반복할 때마다 변경할 값
  • 반복해서 실행할 코드

문법적으로 보면 반복문은 어떤 작업을 여러 번 실행하는 구조가 맞다.

하지만 실제 서비스에서는 “몇 번 반복할 것인가”보다 “각 데이터를 동일한 의미로 처리하고 있는가”가 더 중요하다.


그 설명만으로는 부족한 이유

실제 서비스에서 반복 횟수는 대부분 개발자가 직접 정하지 않는다.

사용자가 쿠폰을 몇 개 가지고 있는지, 데이터베이스에서 주문이 몇 개 조회되는지, 외부 API에서 몇 건의 데이터가 돌아오는지에 따라 반복 횟수가 결정된다.

const coupons = await couponRepository.findByReceiverId(userId);

for (const coupon of coupons) {
  // 조회된 쿠폰 수만큼 실행된다.
}

오늘은 쿠폰이 3개일 수 있지만 내일은 30개일 수 있고, 서비스가 성장하면 30만 개가 될 수도 있다.

따라서 실제 서비스의 반복문에는 단순한 반복 이상의 문제가 포함된다.

  • 모든 데이터에 같은 규칙이 적용되는가?
  • 처리 순서가 결과에 영향을 주는가?
  • 하나가 실패하면 나머지도 중단해야 하는가?
  • 이미 처리한 데이터는 되돌려야 하는가?
  • 처리 도중 데이터가 변경될 수 있는가?
  • 외부 API를 동시에 몇 번까지 호출해도 되는가?
  • 메모리에 모든 데이터를 한 번에 불러와도 되는가?
  • 데이터베이스에서 처리할 일을 애플리케이션 반복문으로 가져오고 있지는 않은가?

반복문을 단순한 문법으로만 이해하면 이런 문제는 코드가 운영 환경에 들어간 이후에 드러난다.


실제 서비스에서 반복문이 의미하는 것

실제 서비스에서 반복문의 핵심은 여러 데이터에 하나의 규칙을 일관되게 적용하는 것이다.

쿠폰 서비스에 다음과 같은 쿠폰들이 있다고 가정해 보자.

type CouponStatus =
  | "draft"
  | "active"
  | "redeemed"
  | "expired"
  | "cancelled";

interface Coupon {
  id: string;
  title: string;
  receiverId: string;
  status: CouponStatus;
  expiresAt: Date | null;
}

서비스는 사용자가 가진 쿠폰을 조회한 뒤 현재 사용할 수 있는 쿠폰만 화면에 보여줘야 한다.

쿠폰 하나만 처리한다면 다음처럼 작성할 수 있다.

const isAvailable =
  coupon.status === "active" &&
  (coupon.expiresAt === null || coupon.expiresAt > new Date());

하지만 사용자가 여러 개의 쿠폰을 가지고 있다면 모든 쿠폰에 같은 규칙을 적용해야 한다.

const now = new Date();

const availableCoupons = coupons.filter((coupon) => {
  return (
    coupon.status === "active" &&
    (coupon.expiresAt === null || coupon.expiresAt > now)
  );
});

여기서 중요한 것은 filter가 내부적으로 데이터를 반복한다는 사실이 아니다.

중요한 것은 모든 쿠폰이 동일한 사용 가능 조건으로 평가된다는 점이다.

만약 쿠폰 목록 화면, 쿠폰 상세 화면, QR 사용 화면에서 각각 다른 조건을 작성하면 같은 쿠폰을 두고 서로 다른 판단을 내릴 수 있다.

// 목록 화면
const canShow = coupon.status === "active";

// 상세 화면
const canOpen =
  coupon.status === "active" &&
  coupon.expiresAt !== null &&
  coupon.expiresAt > new Date();

// 사용 처리 API
const canRedeem =
  coupon.status !== "redeemed" &&
  coupon.status !== "cancelled";

목록에서는 사용할 수 있다고 보이지만 실제 사용 단계에서는 거절되거나, 이미 만료된 쿠폰이 사용되는 문제가 생길 수 있다.

따라서 반복문보다 먼저 하나의 쿠폰을 판단하는 규칙을 명확하게 정의해야 한다.

function isCouponAvailable(coupon: Coupon, now: Date): boolean {
  if (coupon.status !== "active") {
    return false;
  }

  if (coupon.expiresAt !== null && coupon.expiresAt <= now) {
    return false;
  }

  return true;
}

그다음 그 규칙을 여러 쿠폰에 적용한다.

const now = new Date();

const availableCoupons = coupons.filter((coupon) =>
  isCouponAvailable(coupon, now)
);

좋은 반복 처리는 복잡한 규칙을 반복문 안에 숨기지 않는다.

하나의 데이터에 적용할 규칙을 먼저 정의하고, 반복문은 그 규칙을 데이터 집합 전체에 적용하는 역할을 맡는다.


반복문을 사용하기 전에 처리 목적부터 구분해야 한다

배열을 처리할 때는 for문뿐 아니라 map, filter, find, some, every, reduce 같은 메서드를 사용할 수 있다.

중요한 것은 어떤 문법이 더 세련되어 보이는지가 아니다. 무엇을 하려는지가 코드에서 드러나야 한다.

모든 데이터를 하나씩 실행해야 하는가

쿠폰마다 알림 메시지를 기록하는 것처럼 각 항목에 대해 행동을 실행한다면 for...of를 사용할 수 있다.

for (const coupon of coupons) {
  await notificationService.sendCouponReminder(coupon);
}

모든 데이터를 새로운 형태로 바꾸려는가

API 응답에 필요한 형태로 변환하려면 map이 목적을 잘 표현한다.

interface CouponResponse {
  id: string;
  title: string;
  expiresAt: string | null;
}

const response: CouponResponse[] = coupons.map((coupon) => ({
  id: coupon.id,
  title: coupon.title,
  expiresAt: coupon.expiresAt?.toISOString() ?? null,
}));

map은 원본 목록의 각 항목을 새로운 값으로 대응시킨다. 일반적으로 입력 항목 하나당 결과 항목 하나가 만들어진다.

조건을 만족하는 데이터만 남기려는가

사용 가능한 쿠폰만 선택하려면 filter가 적절하다.

const availableCoupons = coupons.filter((coupon) =>
  isCouponAvailable(coupon, now)
);

특정 데이터를 하나 찾으려는가

ID가 일치하는 쿠폰 하나를 찾으려면 find를 사용할 수 있다.

const selectedCoupon = coupons.find(
  (coupon) => coupon.id === selectedCouponId
);

find는 조건을 만족하는 첫 번째 값을 반환하며, 찾지 못하면 undefined를 반환한다.

하나라도 조건을 만족하는지 확인하려는가

사용 가능한 쿠폰이 하나라도 있는지 확인하려면 some이 의도를 더 명확하게 표현한다.

const hasAvailableCoupon = coupons.some((coupon) =>
  isCouponAvailable(coupon, now)
);

다음과 같이 전체 목록을 만든 뒤 길이를 확인할 수도 있다.

const hasAvailableCoupon =
  coupons.filter((coupon) => isCouponAvailable(coupon, now)).length > 0;

하지만 이 코드는 모든 데이터를 검사하고 새로운 배열까지 만든다. some은 조건을 만족하는 데이터를 발견하는 순간 검사를 끝낼 수 있다.

모든 데이터가 조건을 만족하는지 확인하려는가

선택한 쿠폰을 모두 발행할 수 있는지 확인한다면 every를 사용할 수 있다.

const canPublishAll = selectedCoupons.every(
  (coupon) => coupon.status === "draft"
);

여러 데이터를 하나의 결과로 합치려는가

상태별 쿠폰 수를 계산한다면 reduce를 사용할 수 있다.

type CouponCountByStatus = Record<CouponStatus, number>;

const initialCount: CouponCountByStatus = {
  draft: 0,
  active: 0,
  redeemed: 0,
  expired: 0,
  cancelled: 0,
};

const countByStatus = coupons.reduce<CouponCountByStatus>(
  (counts, coupon) => {
    counts[coupon.status] += 1;
    return counts;
  },
  initialCount
);

다만 reduce가 항상 가장 좋은 선택은 아니다. 복잡한 reduce는 읽기 어려울 수 있다.

const result = coupons.reduce(
  (acc, coupon) => {
    // 필터링, 변환, 그룹화, 합계 계산을 모두 수행
    return acc;
  },
  {}
);

한 번의 반복으로 모든 작업을 끝내는 것보다 작업의 목적을 분리하는 편이 더 이해하기 쉬울 수 있다.

반복 횟수를 줄이는 것과 코드를 이해하기 쉽게 만드는 것 사이에서 균형을 잡아야 한다.


구체적인 서비스 사례: 만료된 쿠폰 처리하기

쿠폰 서비스에서 매일 만료된 쿠폰을 찾아 상태를 expired로 변경한다고 가정해 보자.

가장 단순하게는 모든 쿠폰을 조회하고 하나씩 확인할 수 있다.

const coupons = await couponRepository.findAll();

for (const coupon of coupons) {
  if (
    coupon.status === "active" &&
    coupon.expiresAt !== null &&
    coupon.expiresAt <= new Date()
  ) {
    await couponRepository.updateStatus(coupon.id, "expired");
  }
}

이 코드는 소규모 테스트에서는 정상적으로 동작할 수 있다. 하지만 실제 서비스에서는 몇 가지 문제가 있다.

첫째, 반복할 때마다 new Date()를 생성한다. 처리 시간이 길어지면 앞쪽 쿠폰과 뒤쪽 쿠폰이 서로 다른 시간을 기준으로 검사될 수 있다.

둘째, 쿠폰이 10만 개라면 애플리케이션이 모든 쿠폰을 메모리로 불러와야 한다.

셋째, 만료되지 않은 쿠폰과 이미 사용된 쿠폰도 모두 조회한다.

넷째, 쿠폰마다 데이터베이스 업데이트 요청을 한 번씩 보낸다. 만료된 쿠폰이 1만 개라면 1만 번의 요청이 발생할 수 있다.

다섯째, 처리 도중 오류가 발생하면 앞의 쿠폰은 변경되고 뒤의 쿠폰은 변경되지 않은 상태가 될 수 있다.

반복문 문법에는 문제가 없지만 서비스 운영 방식에는 문제가 있는 코드다.

같은 기준 시간을 사용하기

먼저 한 작업 안에서는 기준 시간을 한 번만 생성하는 것이 좋다.

const now = new Date();

for (const coupon of coupons) {
  const shouldExpire =
    coupon.status === "active" &&
    coupon.expiresAt !== null &&
    coupon.expiresAt <= now;

  if (shouldExpire) {
    await couponRepository.updateStatus(coupon.id, "expired");
  }
}

이제 모든 쿠폰은 동일한 시각을 기준으로 판단된다.

필요한 데이터만 조회하기

데이터베이스에 조건을 전달해 만료 대상만 조회할 수 있다.

const now = new Date();

const expiredCandidates =
  await couponRepository.findActiveCouponsExpiredBefore(now);

데이터베이스가 한 번에 처리하도록 만들기

각 쿠폰을 개별적으로 업데이트할 필요가 없다면 일괄 업데이트를 사용할 수 있다.

const result = await couponRepository.expireCouponsBefore(now);

console.log(`${result.count}개의 쿠폰을 만료 처리했습니다.`);

예를 들어 ORM에서는 다음과 같은 형태가 될 수 있다.

const result = await prisma.coupon.updateMany({
  where: {
    status: "active",
    expiresAt: {
      lte: now,
    },
  },
  data: {
    status: "expired",
  },
});

이 경우 애플리케이션 코드에 명시적인 반복문이 보이지 않더라도 데이터 집합 전체에 규칙을 적용하고 있다.

실제 서비스에서 중요한 것은 반복문을 직접 작성했는지가 아니다.

이 작업을 애플리케이션이 하나씩 처리해야 하는가, 데이터베이스가 집합 단위로 처리해야 하는가?

이 판단이 성능과 데이터 일관성에 더 큰 영향을 준다.


코드가 어떻게 달라지는가

반복문에 대한 이해가 깊어지면 코드는 단순히 짧아지는 것이 아니라 책임이 분리되고 의도가 명확해진다.

첫 번째 단계: 작업을 복사해서 작성한다

if (coupon1.status === "active") {
  console.log(coupon1.title);
}

if (coupon2.status === "active") {
  console.log(coupon2.title);
}

if (coupon3.status === "active") {
  console.log(coupon3.title);
}

데이터가 늘어나면 코드를 계속 복사해야 한다. 규칙이 바뀌었을 때 일부 코드만 수정할 위험도 있다.

두 번째 단계: 반복문으로 중복을 제거한다

for (const coupon of coupons) {
  if (coupon.status === "active") {
    console.log(coupon.title);
  }
}

코드 중복은 줄었지만 어떤 기준으로 “사용 가능”을 판단하는지는 여전히 반복문 내부에 들어 있다.

세 번째 단계: 비즈니스 규칙을 함수로 분리한다

function isCouponAvailable(coupon: Coupon, now: Date): boolean {
  return (
    coupon.status === "active" &&
    (coupon.expiresAt === null || coupon.expiresAt > now)
  );
}

const now = new Date();

for (const coupon of coupons) {
  if (isCouponAvailable(coupon, now)) {
    console.log(coupon.title);
  }
}

이제 반복과 판단의 책임이 분리되었다.

네 번째 단계: 처리 목적이 드러나는 도구를 선택한다

사용 가능한 쿠폰 목록이 필요하다면 다음처럼 표현할 수 있다.

const now = new Date();

const availableCoupons = coupons.filter((coupon) =>
  isCouponAvailable(coupon, now)
);

화면에 표시할 형태까지 변환한다면 다음과 같이 작성할 수 있다.

interface CouponCardViewModel {
  id: string;
  title: string;
  expiryLabel: string;
}

const couponCards: CouponCardViewModel[] = coupons
  .filter((coupon) => isCouponAvailable(coupon, now))
  .map((coupon) => ({
    id: coupon.id,
    title: coupon.title,
    expiryLabel:
      coupon.expiresAt === null
        ? "유효기간 없음"
        : coupon.expiresAt.toLocaleDateString("ko-KR"),
  }));

이 코드는 “쿠폰을 반복한다”보다 다음 의도를 보여준다.

  1. 사용할 수 있는 쿠폰을 선택한다.
  2. 선택된 쿠폰을 화면 표시용 데이터로 변환한다.

반복문의 핵심은 실행 횟수가 아니라 데이터가 어떤 처리 단계를 통과하는지를 표현하는 데 있다.


원본 데이터를 직접 변경할 것인가

반복문 안에서 객체를 직접 변경하면 원본 데이터도 함께 바뀐다.

for (const coupon of coupons) {
  coupon.title = coupon.title.trim();
}

이 방식이 항상 잘못된 것은 아니다. 그러나 다른 코드도 같은 객체를 참조하고 있다면 예상하지 못한 변화가 발생할 수 있다.

const selectedCoupon = coupons[0];

for (const coupon of coupons) {
  coupon.title = coupon.title.trim();
}

console.log(selectedCoupon.title);
// 반복문에서 변경된 제목이 출력된다.

새로운 데이터가 필요하다면 map을 사용해 새 객체를 만드는 방법을 고려할 수 있다.

const normalizedCoupons = coupons.map((coupon) => ({
  ...coupon,
  title: coupon.title.trim(),
}));

이제 기존 coupons는 유지되고, 정규화된 결과가 별도로 만들어진다.

console.log(coupons[0] === normalizedCoupons[0]);
// false

반복 처리 전에 다음을 결정해야 한다.

  • 기존 데이터를 변경하는 작업인가?
  • 기존 데이터는 유지하고 새로운 결과를 만드는 작업인가?
  • 다른 코드가 같은 객체를 참조하고 있는가?
  • 변경된 사실을 추적할 필요가 있는가?

특히 React 같은 UI 환경에서는 원본 상태를 직접 변경하면 화면이 정상적으로 갱신되지 않거나 이전 상태를 추적하기 어려워질 수 있다.


반복 중에 배열을 변경하면 어떤 문제가 생기는가

반복 중인 배열에서 항목을 삭제하거나 추가하면 일부 데이터가 건너뛰어질 수 있다.

const numbers = [1, 2, 3, 4];

for (let index = 0; index < numbers.length; index++) {
  if (numbers[index] % 2 === 0) {
    numbers.splice(index, 1);
  }
}

console.log(numbers);

splice로 항목을 제거하면 뒤의 항목이 앞으로 이동한다. 하지만 반복문의 index는 계속 증가하기 때문에 이동한 항목을 검사하지 못할 수 있다.

조건에 맞는 항목을 제외하고 싶다면 filter로 새로운 배열을 만드는 편이 의도를 더 정확하게 표현한다.

const oddNumbers = numbers.filter((number) => number % 2 !== 0);

쿠폰 목록에서도 마찬가지다.

const visibleCoupons = coupons.filter(
  (coupon) => coupon.status !== "cancelled"
);

반복 중인 컬렉션 자체를 변경해야 한다면 인덱스 변화와 다른 참조에 미치는 영향을 명확하게 이해해야 한다.


비동기 작업의 반복은 실행 순서를 설계하는 일이다

실제 서비스의 반복문 안에서는 데이터베이스 조회, 파일 업로드, 이메일 발송, 외부 API 호출 같은 비동기 작업이 자주 발생한다.

이때 어떤 반복 방식을 선택하느냐에 따라 실행 순서와 속도, 실패 방식이 달라진다.

forEach는 비동기 작업을 기다려주지 않는다

다음 코드는 모든 알림 발송이 끝난 뒤 완료 메시지를 출력할 것처럼 보인다.

coupons.forEach(async (coupon) => {
  await notificationService.sendCouponReminder(coupon);
});

console.log("모든 알림 발송 완료");

하지만 forEach는 콜백이 반환한 Promise를 기다리지 않는다. 따라서 알림 발송이 끝나기 전에 완료 메시지가 출력될 수 있다.

순서대로 처리해야 한다면 for...of

쿠폰을 한 개씩 순차적으로 처리해야 한다면 for...ofawait를 사용할 수 있다.

for (const coupon of coupons) {
  await notificationService.sendCouponReminder(coupon);
}

console.log("모든 알림 발송 완료");

이 방식은 앞 작업이 끝난 후 다음 작업을 시작한다.

호출 순서를 보장해야 하거나 외부 서비스의 요청 제한을 조심해야 할 때 유용하다. 하지만 각 작업이 독립적이라면 전체 처리 시간이 길어질 수 있다.

독립적인 작업을 동시에 처리한다면 Promise.all

모든 작업이 서로 독립적이고 동시에 실행해도 안전하다면 Promise.all을 사용할 수 있다.

await Promise.all(
  coupons.map((coupon) =>
    notificationService.sendCouponReminder(coupon)
  )
);

console.log("모든 알림 발송 완료");

다만 쿠폰이 10만 개라면 외부 알림 서비스에 10만 건을 한꺼번에 요청하게 될 수 있다.

동시 실행이 가능하다는 것과 무제한으로 동시에 실행해도 된다는 것은 다르다.

실제 서비스에서는 다음과 같은 제한이 필요할 수 있다.

  • 한 번에 처리할 작업 수
  • 외부 API의 요청 제한
  • 데이터베이스 연결 수
  • 서버 메모리 사용량
  • 실패한 작업의 재시도 횟수
  • 중복 발송 방지 방법

대규모 작업이라면 일정한 크기의 묶음으로 나누거나 작업 큐를 사용하는 편이 적절할 수 있다.

async function processInBatches<T>(
  items: T[],
  batchSize: number,
  processItem: (item: T) => Promise<void>
): Promise<void> {
  for (let start = 0; start < items.length; start += batchSize) {
    const batch = items.slice(start, start + batchSize);

    await Promise.all(batch.map(processItem));
  }
}

await processInBatches(coupons, 20, async (coupon) => {
  await notificationService.sendCouponReminder(coupon);
});

이 코드는 한 번에 최대 20개씩 처리한다. 실제 운영 환경에서는 검증된 동시성 제한 라이브러리나 작업 큐를 사용하는 것이 더 적절할 수 있다.


하나가 실패하면 나머지는 어떻게 해야 하는가

여러 데이터를 처리할 때는 실패 정책을 먼저 결정해야 한다.

for (const coupon of coupons) {
  await publishCoupon(coupon);
}

세 번째 쿠폰 발행에서 오류가 발생하면 앞의 두 쿠폰은 이미 발행되었지만 나머지는 처리되지 않을 수 있다.

이 결과가 올바른지는 비즈니스 요구사항에 따라 달라진다.

하나라도 실패하면 전체가 실패해야 하는 경우

선택한 쿠폰들이 하나의 패키지이며 반드시 함께 발행되어야 한다면 전체 성공이나 전체 실패가 필요하다. 데이터베이스 트랜잭션이 필요할 수 있다.

await database.transaction(async (transaction) => {
  for (const coupon of coupons) {
    await couponRepository.publish(coupon.id, transaction);
  }
});

일부 성공을 허용하는 경우

여러 사용자에게 독립적인 알림을 발송한다면 한 건의 실패 때문에 전체 작업을 중단할 필요가 없을 수 있다.

interface ProcessingResult {
  couponId: string;
  success: boolean;
  errorMessage?: string;
}

const results: ProcessingResult[] = [];

for (const coupon of coupons) {
  try {
    await notificationService.sendCouponReminder(coupon);

    results.push({
      couponId: coupon.id,
      success: true,
    });
  } catch (error) {
    results.push({
      couponId: coupon.id,
      success: false,
      errorMessage:
        error instanceof Error ? error.message : "알 수 없는 오류",
    });
  }
}

이제 어떤 쿠폰의 알림이 성공하고 실패했는지 확인할 수 있다.

const failedResults = results.filter((result) => !result.success);

반복문 안에 try-catch를 넣는 것만으로 충분한 것은 아니다. 다음 정책도 필요하다.

  • 실패한 작업을 자동으로 재시도할 것인가?
  • 최대 몇 번 재시도할 것인가?
  • 같은 알림이 중복 발송되지 않게 하려면 어떻게 할 것인가?
  • 실패 기록을 어디에 저장할 것인가?
  • 운영자에게 알릴 것인가?
  • 사용자에게 일부 실패 사실을 보여줄 것인가?

반복 처리에서 오류는 단순한 예외가 아니라 부분 성공 상태를 어떻게 관리할 것인지에 관한 문제다.


모든 데이터를 메모리에 불러올 필요는 없다

다음 코드는 데이터베이스에 저장된 모든 쿠폰을 한꺼번에 불러온다.

const coupons = await couponRepository.findAll();

for (const coupon of coupons) {
  await processCoupon(coupon);
}

쿠폰이 수백 개라면 괜찮을 수 있다. 하지만 수백만 개라면 메모리 사용량과 조회 시간이 크게 증가한다.

이럴 때는 페이지나 묶음 단위로 처리할 수 있다.

const pageSize = 500;
let cursor: string | undefined;

while (true) {
  const page = await couponRepository.findNextPage({
    cursor,
    limit: pageSize,
  });

  if (page.items.length === 0) {
    break;
  }

  for (const coupon of page.items) {
    await processCoupon(coupon);
  }

  cursor = page.nextCursor;

  if (cursor === undefined) {
    break;
  }
}

여기서 반복문은 단순히 쿠폰을 여러 번 처리하는 문법이 아니다. 제한된 메모리 안에서 큰 데이터 집합을 안전하게 순회하는 전략이다.

대규모 데이터를 처리할 때는 다음을 함께 고려해야 한다.

  • 페이지 크기
  • 중간 실패 후 재개할 위치
  • 처리 도중 새로 추가된 데이터
  • 처리 도중 수정되거나 삭제된 데이터
  • 같은 데이터를 두 번 처리해도 안전한지
  • 전체 작업의 진행 상황을 어떻게 기록할지

반복 순서가 결과에 영향을 주는가

모든 반복이 순서와 무관한 것은 아니다.

예를 들어 발행일 순으로 가장 오래된 쿠폰부터 사용해야 한다면 정렬 순서가 비즈니스 규칙의 일부가 된다.

const sortedCoupons = [...coupons].sort(
  (first, second) =>
    first.createdAt.getTime() - second.createdAt.getTime()
);

for (const coupon of sortedCoupons) {
  if (isCouponAvailable(coupon, now)) {
    return coupon;
  }
}

반대로 각 쿠폰에 독립적인 알림을 보내는 작업이라면 순서가 중요하지 않을 수 있다.

순서가 중요하다면 다음을 명확히 해야 한다.

  • 어떤 필드를 기준으로 정렬하는가?
  • 값이 같으면 두 번째 기준은 무엇인가?
  • 데이터베이스 정렬과 애플리케이션 정렬 중 어디에서 처리하는가?
  • 병렬 실행으로 순서가 달라져도 되는가?
  • 결과만 순서대로 정렬하면 되는가, 실행 자체가 순서대로 이루어져야 하는가?

“목록을 반복한다”는 표현만으로는 이 요구사항을 알 수 없다.


반복문 안의 데이터베이스 요청을 조심해야 한다

쿠폰 목록을 가져온 뒤 각 쿠폰의 발행자 정보를 다시 조회한다고 가정해 보자.

const coupons = await couponRepository.findByReceiverId(receiverId);

const results = [];

for (const coupon of coupons) {
  const sender = await userRepository.findById(coupon.senderId);

  results.push({
    coupon,
    sender,
  });
}

쿠폰을 가져오는 요청이 1번, 발행자를 가져오는 요청이 쿠폰 수만큼 발생한다.

쿠폰이 100개라면 총 101번의 데이터베이스 요청이 발생할 수 있다. 이를 흔히 N+1 문제라고 부른다.

필요한 관계를 한 번에 조회할 수 있다면 데이터베이스에서 함께 가져오는 편이 효율적일 수 있다.

const coupons = await prisma.coupon.findMany({
  where: {
    receiverId,
  },
  include: {
    sender: true,
  },
});

또는 필요한 사용자 ID를 모아 한 번에 조회할 수 있다.

const senderIds = [...new Set(coupons.map((coupon) => coupon.senderId))];

const senders = await userRepository.findManyByIds(senderIds);

const senderById = new Map(
  senders.map((sender) => [sender.id, sender])
);

const results = coupons.map((coupon) => ({
  coupon,
  sender: senderById.get(coupon.senderId),
}));

반복문 안에서 비동기 요청이 보인다면 다음을 확인해야 한다.

이 요청은 각 데이터마다 반드시 개별적으로 실행해야 하는가?

여러 요청을 하나의 조회나 일괄 작업으로 바꿀 수 있다면 성능과 안정성을 크게 개선할 수 있다.


반복하기 전에 설계자가 물어봐야 할 질문

여러 데이터를 처리하는 코드를 작성하기 전에는 다음 질문을 먼저 확인해야 한다.

데이터와 범위

  • 처리할 데이터는 어디에서 오는가?
  • 최대 몇 개까지 늘어날 수 있는가?
  • 모든 데이터를 한 번에 메모리에 불러와도 되는가?
  • 전체 데이터가 필요한가, 조건에 맞는 일부만 필요한가?
  • 중복 데이터가 들어올 수 있는가?

처리 목적

  • 각 항목에 행동을 실행하려는가?
  • 새로운 목록으로 변환하려는가?
  • 조건에 맞는 항목만 선택하려는가?
  • 하나를 찾으려는가?
  • 하나라도 또는 모두가 조건을 만족하는지 확인하려는가?
  • 여러 값을 하나의 결과로 합치려는가?

규칙과 일관성

  • 모든 항목에 동일한 비즈니스 규칙이 적용되는가?
  • 그 규칙이 반복문 내부에 중복되어 있지는 않은가?
  • 같은 기준 시간과 설정값을 사용해야 하는가?
  • 원본 데이터를 변경해야 하는가?
  • 처리 순서가 결과에 영향을 주는가?

비동기 처리

  • 작업을 순서대로 실행해야 하는가?
  • 동시에 실행해도 안전한가?
  • 동시에 실행할 수 있는 최대 개수는 얼마인가?
  • 외부 API나 데이터베이스의 요청 제한은 무엇인가?
  • 반복문 안의 개별 요청을 일괄 요청으로 바꿀 수 있는가?

실패 처리

  • 하나가 실패하면 전체를 중단해야 하는가?
  • 이미 처리된 데이터는 되돌려야 하는가?
  • 일부 성공을 허용하는가?
  • 실패한 항목만 재시도할 수 있는가?
  • 같은 작업을 다시 실행해도 중복 결과가 생기지 않는가?
  • 실패 결과와 진행 상황을 어디에 기록할 것인가?

이 질문에 답하면 반복문을 단순히 작성하는 것을 넘어 반복 작업의 운영 방식까지 설계할 수 있다.


흔히 하는 실수

반복문의 종료 조건을 잘못 작성한다

for (let index = 0; index <= coupons.length; index++) {
  console.log(coupons[index]);
}

배열의 마지막 인덱스는 length - 1이다. <=를 사용하면 마지막 반복에서 undefined에 접근하게 된다.

for (let index = 0; index < coupons.length; index++) {
  console.log(coupons[index]);
}

인덱스가 필요하지 않다면 for...of를 사용하는 것이 더 단순하다.

for (const coupon of coupons) {
  console.log(coupon);
}

반복문마다 현재 시간을 새로 만든다

for (const coupon of coupons) {
  if (coupon.expiresAt && coupon.expiresAt <= new Date()) {
    // 처리
  }
}

작업 전체가 같은 기준 시각을 사용해야 한다면 시간을 한 번만 만든다.

const now = new Date();

for (const coupon of coupons) {
  if (coupon.expiresAt && coupon.expiresAt <= now) {
    // 처리
  }
}

map을 단순 반복용으로 사용한다

coupons.map((coupon) => {
  console.log(coupon.title);
});

map은 새로운 배열을 만들기 위한 메서드다. 반환값을 사용하지 않는 단순 행동이라면 for...offorEach가 의도를 더 잘 표현할 수 있다.

for (const coupon of coupons) {
  console.log(coupon.title);
}

forEach에서 비동기 작업이 끝나기를 기대한다

await coupons.forEach(async (coupon) => {
  await publishCoupon(coupon);
});

forEach 자체는 Promise를 반환하지 않으므로 바깥의 await가 내부 작업을 기다려주지 않는다.

순차 처리는 for...of, 병렬 처리는 Promise.all 등을 목적에 맞게 사용해야 한다.

반복 중인 배열을 직접 변경한다

for (const coupon of coupons) {
  if (coupon.status === "cancelled") {
    coupons.splice(coupons.indexOf(coupon), 1);
  }
}

항목 이동으로 인해 일부 데이터가 건너뛰어질 수 있다. 제외된 새 목록이 필요하다면 filter가 더 적절하다.

const activeList = coupons.filter(
  (coupon) => coupon.status !== "cancelled"
);

반복문 안에서 같은 데이터를 계속 조회한다

for (const coupon of coupons) {
  const sender = await userRepository.findById(coupon.senderId);
}

반복 횟수만큼 데이터베이스 요청이 발생할 수 있다. 관계 조회나 일괄 조회가 가능한지 먼저 확인해야 한다.

모든 작업을 무조건 동시에 실행한다

await Promise.all(
  coupons.map((coupon) => sendNotification(coupon))
);

데이터가 많으면 외부 서비스와 서버에 과도한 부하를 줄 수 있다. 동시 실행 수를 제한하거나 작업 큐를 사용해야 할 수 있다.

실패 정책 없이 반복 작업을 실행한다

for (const coupon of coupons) {
  await publishCoupon(coupon);
}

중간에 실패했을 때 일부만 처리된 상태가 허용되는지 정의되어 있지 않다. 전체 실패, 부분 성공, 재시도, 롤백 정책을 먼저 결정해야 한다.

너무 많은 로직을 반복문 안에 넣는다

for (const coupon of coupons) {
  // 권한 확인
  // 상태 확인
  // 날짜 계산
  // 가격 계산
  // 데이터 저장
  // 알림 발송
  // 로그 기록
}

반복문 안에 여러 책임이 섞이면 규칙을 테스트하고 변경하기 어려워진다.

각 행동을 의미 있는 함수로 분리할 수 있다.

for (const coupon of coupons) {
  const validation = validateCouponForPublishing(coupon, now);

  if (!validation.success) {
    await recordPublishingFailure(coupon.id, validation.reason);
    continue;
  }

  await publishCoupon(coupon);
  await notifyCouponReceiver(coupon);
}

반복문은 처리 흐름을 보여주고, 세부 규칙은 이름이 있는 함수가 설명하도록 만드는 편이 이해하기 쉽다.


핵심 정리

반복문은 같은 코드를 여러 번 실행하는 문법이다. 하지만 실제 서비스에서 그 설명만으로는 충분하지 않다.

서비스에서 반복문은 여러 사용자, 상품, 주문, 쿠폰에 같은 규칙을 일관되게 적용하는 방법이다. 동시에 처리 목적과 순서, 속도, 실패 방식, 데이터 크기까지 결정하는 설계 지점이기도 하다.

for, for...of, map, filter, find, some, every, reduce 중 무엇을 선택할지는 단순한 취향의 문제가 아니다.

  • 행동을 하나씩 실행하는가
  • 새로운 형태로 변환하는가
  • 일부 데이터를 선택하는가
  • 특정 데이터를 찾는가
  • 조건 만족 여부를 판단하는가
  • 여러 데이터를 하나의 결과로 합치는가

이 목적에 따라 적절한 도구가 달라진다.

비동기 작업에서는 더 많은 판단이 필요하다. 순차 실행과 병렬 실행은 결과와 성능이 다르며, 무제한 병렬 처리는 외부 API나 데이터베이스를 과부하시킬 수 있다. 한 작업의 실패가 전체 실패인지 부분 실패인지에 따라서도 코드 구조가 달라진다.

데이터가 많다면 모든 항목을 메모리에 불러와 반복하기보다 데이터베이스의 일괄 처리, 페이지네이션, 배치 처리, 작업 큐를 고려해야 한다. 반복문 안에서 데이터베이스나 외부 API를 호출한다면 N+1 요청과 요청 제한도 확인해야 한다.

결국 반복문을 설계한다는 것은 다음 질문에 답하는 일이다.

여러 데이터를 같은 규칙으로 처리하면서도 순서, 성능, 실패와 데이터 일관성을 어떻게 지킬 것인가?

반복문을 제대로 이해하면 단순히 중복 코드를 줄이는 데서 멈추지 않는다. 데이터 집합이 서비스의 규칙을 따라 안전하게 이동하고 변화하도록 처리 과정을 설계할 수 있게 된다.

반복문은 같은 코드를 여러 번 실행하는 문법이 아니다.

여러 데이터를 하나의 서비스 규칙 아래에서 일관되고 안전하게 처리하는 방법이다.

profile
Vision eXperience Developer

0개의 댓글