배열은 여러 값을 담는 상자가 아니다

vx_developer·2026년 8월 27일

개발하다가

목록 보기
6/30
post-thumbnail

일반적으로 배열을 어떻게 설명하는가

배열은 여러 개의 값을 하나의 변수에 저장할 수 있는 자료구조다.
쿠폰 이름을 각각의 변수에 저장한다면 다음과 같이 작성할 수 있다.

const firstCoupon = "커피 쿠폰";
const secondCoupon = "저녁 식사 쿠폰";
const thirdCoupon = "마사지 쿠폰";

배열을 사용하면 관련된 여러 값을 하나의 목록으로 묶을 수 있다.

const coupons = [
  "커피 쿠폰",
  "저녁 식사 쿠폰",
  "마사지 쿠폰",
];

배열의 각 값에는 위치를 나타내는 인덱스가 있다.
JavaScript의 배열 인덱스는 0부터 시작한다.

console.log(coupons[0]); // 커피 쿠폰
console.log(coupons[1]); // 저녁 식사 쿠폰
console.log(coupons[2]); // 마사지 쿠폰

배열의 길이는 length로 확인한다.

console.log(coupons.length); // 3

새로운 값을 마지막에 추가할 때는 push를 사용할 수 있다.

coupons.push("영화 선택권");

console.log(coupons);

마지막 값을 제거할 때는 pop을 사용한다.

const removedCoupon = coupons.pop();

console.log(removedCoupon);

이러한 설명을 통해 배열의 기본 문법을 배운다.

  • 여러 값을 하나의 변수에 저장한다.
  • 각 값은 인덱스로 접근한다.
  • 배열에는 순서가 있다.
  • 값을 추가하거나 제거할 수 있다.
  • 배열의 길이를 확인할 수 있다.

문법적으로는 모두 맞는 설명이다.

하지만 실제 서비스에서는 “여러 값을 어디에 담을 것인가”보다 이게 더 중요하다.

이 목록은 어떤 대상을 나타내며, 어떤 규칙에 따라 정렬되고 검색되고 변환되어야 하는가?


그렇다면 실제 서비스에서는 어떨까

실제 서비스에서 배열은 단순한 값의 모음으로 존재하지 않는다.

배열은 대부분 사용자에게 보여줄 목록이거나, 서버에서 조회된 데이터 집합이거나, 특정 작업의 처리 대상이다.

예를 들어 쿠폰 서비스에는 다음과 같은 목록이 있을 수 있다.

  • 사용자가 받은 쿠폰 목록
  • 사용자가 보낸 쿠폰 목록
  • 사용할 수 있는 쿠폰 목록
  • 만료된 쿠폰 목록
  • 쿠폰 템플릿 목록
  • 알림 목록
  • 쿠폰 사용 기록
  • 선택된 이미지와 스티커 목록

겉으로는 모두 배열이지만 각각의 의미와 규칙은 다르다.

const receivedCoupons: Coupon[] = [];
const sentCoupons: Coupon[] = [];
const availableCoupons: Coupon[] = [];
const templates: CouponTemplate[] = [];
const notifications: Notification[] = [];

receivedCouponsavailableCoupons는 모두 Coupon[] 타입일 수 있다.
하지만 두 배열이 나타내는 서비스의 의미는 같지 않다.
receivedCoupons에는 만료되거나 이미 사용된 쿠폰도 포함될 수 있다.
반면 availableCoupons에는 현재 사용 가능한 쿠폰만 있어야 한다.

const receivedCoupons = await couponRepository.findByReceiverId(userId);

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

두 변수 모두 배열이지만 하나는 조회된 원본 데이터이고, 다른 하나는 비즈니스 규칙을 적용한 결과다.
배열을 단순히 “여러 값을 담는 상자”라고만 이해하면 다음과 같은 문제를 놓치게 된다.

  • 목록에 어떤 데이터가 포함되어야 하는가?
  • 목록의 순서는 무엇을 의미하는가?
  • 같은 데이터가 중복으로 들어가도 되는가?
  • 특정 항목을 무엇으로 식별할 것인가?
  • 목록을 직접 변경해도 되는가?
  • 검색 결과가 없을 때 무엇을 반환해야 하는가?
  • 서버 데이터와 화면 데이터의 형태가 같은가?
  • 데이터가 수만 개여도 배열로 모두 가져와야 하는가?
  • 정렬과 필터링은 서버와 클라이언트 중 어디에서 해야 하는가?
  • 배열 안의 객체가 변경되면 다른 화면에도 영향을 주는가?

실제 서비스에서 배열은 저장 문법이 아니라 목록의 의미와 처리 흐름을 설계하는 도구다.


배열의 의미 알아보기

다음과 같은 쿠폰 데이터가 있다고 가정해 보자.

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

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

사용자가 받은 쿠폰 목록을 배열로 표현할 수 있다.

const coupons: Coupon[] = [
  {
    id: "coupon-101",
    senderId: "user-1",
    receiverId: "user-2",
    title: "커피 한 잔 사주기",
    status: "active",
    createdAt: new Date("2026-08-20T10:00:00Z"),
    expiresAt: new Date("2026-09-20T10:00:00Z"),
    redeemedAt: null,
  },
  {
    id: "coupon-102",
    senderId: "user-3",
    receiverId: "user-2",
    title: "저녁 메뉴 선택권",
    status: "redeemed",
    createdAt: new Date("2026-08-15T09:00:00Z"),
    expiresAt: null,
    redeemedAt: new Date("2026-08-24T11:30:00Z"),
  },
];

이 배열은 단순히 쿠폰 객체 두 개를 저장하고 있는 것이 아니다.
서비스 관점에서는 다음과 같은 의미를 가진다.

특정 사용자가 받은 쿠폰을 현재 조회 조건과 정렬 규칙에 따라 모아놓은 결과다.

따라서 배열을 다룰 때는 배열 자체뿐 아니라 그 배열을 만든 조건도 알아야 한다.

const coupons = await couponRepository.findMany({
  receiverId: currentUser.id,
  status: ["active", "redeemed"],
  orderBy: {
    createdAt: "desc",
  },
  limit: 20,
});

이 목록에는 이미 여러 규칙이 적용되어 있다.

  • 현재 사용자가 받은 쿠폰만 포함한다.
  • active 또는 redeemed 상태만 포함한다.
  • 최근 생성된 쿠폰부터 정렬한다.
  • 한 번에 최대 20개만 가져온다.

배열을 이해한다는 것은 대괄호 안의 값을 이해하는 데서 끝나지 않는다.
그 배열이 다음 질문에 어떤 답을 가지고 있는지 알아야 한다.

  • 누구의 데이터인가?
  • 어떤 조건으로 선택된 데이터인가?
  • 어떤 순서로 배치된 데이터인가?
  • 전체 데이터인가, 일부 데이터인가?
  • 원본인가, 가공된 결과인가?
  • 현재 시점에 유효한 데이터인가?

배열의 순서는 비즈니스 의미가 될 수 있다

배열에는 순서가 있다.
하지만 실제 서비스에서 중요한 것은 배열에 순서가 있다는 사실이 아니라 그 순서가 무엇을 의미하는가이다.
서비스의 쿠폰 목록을 최근에 받은 순서로 보여준다고 가정해 보자.

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

이 배열의 첫 번째 항목은 단순히 인덱스가 0인 쿠폰이 아니다.
“사용자가 가장 최근에 받은 쿠폰”이라는 의미를 가진다.

const mostRecentCoupon = sortedCoupons[0];

그러나 정렬 규칙이 달라지면 첫 번째 항목의 의미도 달라진다.

const couponsExpiringSoon = [...coupons].sort((first, second) => {
  if (first.expiresAt === null) return 1;
  if (second.expiresAt === null) return -1;

  return first.expiresAt.getTime() - second.expiresAt.getTime();
});

const mostUrgentCoupon = couponsExpiringSoon[0];

이번에는 첫 번째 쿠폰이 “가장 최근에 받은 쿠폰”이 아니라 “가장 빨리 만료될 쿠폰”이다.
따라서 다음과 같은 코드는 위험할 수 있다.

const importantCoupon = coupons[0];

coupons[0]만으로는 그 쿠폰이 왜 중요한지 알 수 없다.

변수와 함수 이름에 순서의 의미가 드러나야 한다.

const couponsOrderedByExpiry =
  sortCouponsByNearestExpiry(coupons);

const nextExpiringCoupon =
  couponsOrderedByExpiry[0];

더 나아가 특정 항목 하나만 필요하다면 전체 배열을 정렬할 필요가 없는지도 생각해야 한다.

function findNextExpiringCoupon(
  coupons: Coupon[]
): Coupon | undefined {
  return coupons.reduce<Coupon | undefined>(
    (nearest, coupon) => {
      if (coupon.expiresAt === null) {
        return nearest;
      }

      if (
        nearest === undefined ||
        nearest.expiresAt === null ||
        coupon.expiresAt < nearest.expiresAt
      ) {
        return coupon;
      }

      return nearest;
    },
    undefined
  );
}

데이터가 데이터베이스에 있다면 가장 빨리 만료되는 한 건만 요청하는 것이 더 적절할 수도 있다.

const nextExpiringCoupon =
  await couponRepository.findFirst({
    receiverId: userId,
    status: "active",
    expiresAt: {
      not: null,
      after: now,
    },
    orderBy: {
      expiresAt: "asc",
    },
  });

배열의 순서를 설계한다는 것은 단지 sort를 사용하는 일이 아니다.
사용자에게 어떤 항목을 먼저 보여줄 것인지 결정하는 일이다.


배열의 인덱스는 데이터의 정체성이 아니다

배열의 값에는 인덱스가 있다.

const firstCoupon = coupons[0];

하지만 인덱스는 배열 안에서의 현재 위치일 뿐 데이터 자체의 정체성이 아니다.
쿠폰의 실제 정체성은 id로 구분해야 한다.

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

배열이 정렬되거나 항목이 추가·삭제되면 인덱스는 달라질 수 있다.

const coupons = [
  { id: "coupon-a", title: "커피 쿠폰" },
  { id: "coupon-b", title: "영화 쿠폰" },
];

const selectedIndex = 1;

현재 selectedIndex1이면 영화 쿠폰을 가리킨다.
하지만 앞에 새로운 쿠폰이 추가되면 상황이 달라진다.

coupons.unshift({
  id: "coupon-c",
  title: "아침 식사 쿠폰",
});

console.log(coupons[1]);
// 이제 coupon-a를 가리킨다.

사용자가 영화 쿠폰을 선택했다는 사실을 인덱스로 저장했다면 선택 대상이 바뀌어버린다.

let selectedCouponIndex = 1;

대신 안정적인 ID를 저장해야 한다.

let selectedCouponId = "coupon-b";

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

React에서 목록을 렌더링할 때도 같은 원칙이 적용된다.

{coupons.map((coupon) => (
  <CouponCard
    key={coupon.id}
    coupon={coupon}
  />
))}

배열의 인덱스를 key로 사용하면 항목의 순서가 바뀌거나 삭제되었을 때 컴포넌트 상태가 다른 항목과 잘못 연결될 수 있다.

{coupons.map((coupon, index) => (
  <CouponCard
    key={index}
    coupon={coupon}
  />
))}

인덱스는 위치이고, ID가 정체성이다.
둘은 같은 것이 아니다.


목록을 검색하는 방법은 데이터의 크기와 사용 방식에 따라 달라진다

특정 쿠폰 하나를 찾을 때는 find를 사용할 수 있다.

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

find는 앞에서부터 값을 확인하다가 조건을 만족하는 첫 번째 값을 반환한다.
찾지 못하면 undefined를 반환한다.

if (selectedCoupon === undefined) {
  throw new Error("쿠폰을 찾을 수 없습니다.");
}

한두 번 검색하는 작은 목록에서는 충분히 적절하다.
하지만 같은 배열에서 ID로 쿠폰을 수백 번 검색한다면 매번 배열 전체를 순회할 수 있다.

for (const selectedId of selectedCouponIds) {
  const coupon = coupons.find(
    (coupon) => coupon.id === selectedId
  );

  if (coupon) {
    processCoupon(coupon);
  }
}

이럴 때는 ID를 키로 사용하는 Map을 만들 수 있다.

const couponById = new Map(
  coupons.map((coupon) => [coupon.id, coupon])
);

for (const selectedId of selectedCouponIds) {
  const coupon = couponById.get(selectedId);

  if (coupon) {
    processCoupon(coupon);
  }
}

배열과 Map은 역할이 다르다.

  • 배열은 순서가 있는 목록을 표현하기 좋다.
  • Map은 특정 키로 값을 반복해서 조회하기 좋다.

화면에 쿠폰을 순서대로 표시해야 하면서 ID 검색도 자주 해야 한다면 두 구조를 함께 사용할 수 있다.

interface CouponCollection {
  orderedCoupons: Coupon[];
  couponById: Map<string, Coupon>;
}

function createCouponCollection(
  coupons: Coupon[]
): CouponCollection {
  return {
    orderedCoupons: coupons,
    couponById: new Map(
      coupons.map((coupon) => [coupon.id, coupon])
    ),
  };
}

하지만 무조건 모든 배열을 Map으로 바꿀 필요는 없다.
데이터가 작고 검색 횟수가 적다면 find가 더 단순하고 이해하기 쉽다.
자료구조는 빠르게 보이기 위해 선택하는 것이 아니라 실제 접근 방식에 맞게 선택해야 한다.


배열의 핵심은 데이터를 처리 단계에 따라 변화시키는 데 있다

실제 서비스에서 배열은 여러 처리 단계를 통과한다.
예를 들어 쿠폰 목록 화면은 다음 과정으로 만들어질 수 있다.

  1. 서버에서 쿠폰을 조회한다.
  2. 사용할 수 있는 쿠폰만 선택한다.
  3. 만료가 가까운 순서로 정렬한다.
  4. 화면에 필요한 형태로 변환한다.
  5. 사용자에게 표시한다.

이 흐름을 코드로 표현하면 다음과 같다.

const now = new Date();

const couponCards = coupons
  .filter((coupon) => isCouponAvailable(coupon, now))
  .sort(compareByNearestExpiry)
  .map(toCouponCardViewModel);

각 배열 메서드는 서로 다른 목적을 가진다.

filter: 조건에 맞는 항목을 선택한다

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

입력 배열보다 결과 배열의 길이가 같거나 짧아질 수 있다.
filter는 원본 배열을 변경하지 않고 새로운 배열을 반환한다.

map: 각 항목을 새로운 형태로 변환한다

interface CouponCardViewModel {
  id: string;
  title: string;
  senderName: string;
  expiryLabel: string;
  isExpiringSoon: boolean;
}

const couponCards: CouponCardViewModel[] =
  availableCoupons.map((coupon) => ({
    id: coupon.id,
    title: coupon.title,
    senderName: coupon.sender.name,
    expiryLabel: formatExpiryLabel(coupon.expiresAt, now),
    isExpiringSoon: isExpiringWithinDays(
      coupon.expiresAt,
      now,
      3
    ),
  }));

일반적으로 입력 항목 하나가 결과 항목 하나로 대응된다.
Coupon을 화면에서 사용할 CouponCardViewModel로 변환함으로써 데이터베이스 모델과 UI의 책임을 분리할 수 있다.

find: 조건에 맞는 항목 하나를 찾는다

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

조건을 만족하는 첫 번째 항목만 필요할 때 사용한다.

some: 하나라도 조건을 만족하는지 확인한다

const hasExpiringSoonCoupon = coupons.some(
  (coupon) =>
    coupon.status === "active" &&
    isExpiringWithinDays(coupon.expiresAt, now, 3)
);

사용자에게 “곧 만료되는 쿠폰이 있습니다”라는 알림을 보여줄 때 사용할 수 있다.

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
  );

결과는 하나의 객체가 된다.

{
  draft: 2,
  active: 7,
  redeemed: 4,
  expired: 3,
  cancelled: 1
}

다만 모든 배열 처리를 reduce 하나로 해결하려고 하면 코드의 의미가 흐려질 수 있다.

const result = coupons.reduce(
  (accumulator, coupon) => {
    // 필터링
    // 변환
    // 정렬용 데이터 생성
    // 통계 계산
    // 그룹화
    return accumulator;
  },
  {}
);

한 번만 순회한다는 이유로 여러 책임을 하나의 reduce에 넣으면 유지보수가 어려워질 수 있다.
배열 처리 코드는 실행 횟수뿐 아니라 읽는 사람이 데이터의 변화를 이해할 수 있는지도 중요하다.


구체적인 서비스 사례: 받은 쿠폰 목록 만들기

쿠폰 서비스에서 사용자가 받은 쿠폰을 다음 기준으로 보여준다고 가정해 보자.

  • 현재 사용 가능한 쿠폰만 보여준다.
  • 만료가 가까운 쿠폰을 먼저 보여준다.
  • 유효기간이 없는 쿠폰은 뒤에 보여준다.
  • 같은 만료일이라면 최근에 받은 쿠폰을 먼저 보여준다.
  • 데이터베이스 객체를 그대로 노출하지 않고 화면 전용 형태로 변환한다.

먼저 쿠폰의 사용 가능 여부를 판단하는 함수를 만든다.

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

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

  return true;
}

다음으로 정렬 규칙을 정의한다.

function compareByNearestExpiry(
  first: Coupon,
  second: Coupon
): number {
  if (
    first.expiresAt === null &&
    second.expiresAt === null
  ) {
    return (
      second.createdAt.getTime() -
      first.createdAt.getTime()
    );
  }

  if (first.expiresAt === null) {
    return 1;
  }

  if (second.expiresAt === null) {
    return -1;
  }

  const expiryDifference =
    first.expiresAt.getTime() -
    second.expiresAt.getTime();

  if (expiryDifference !== 0) {
    return expiryDifference;
  }

  return (
    second.createdAt.getTime() -
    first.createdAt.getTime()
  );
}

화면 전용 타입을 정의한다.

interface CouponCardViewModel {
  id: string;
  title: string;
  statusLabel: string;
  expiryLabel: string;
  isExpiringSoon: boolean;
}

쿠폰을 화면 데이터로 변환하는 함수도 만든다.

function toCouponCardViewModel(
  coupon: Coupon,
  now: Date
): CouponCardViewModel {
  return {
    id: coupon.id,
    title: coupon.title,
    statusLabel: "사용 가능",
    expiryLabel:
      coupon.expiresAt === null
        ? "유효기간 없음"
        : coupon.expiresAt.toLocaleDateString("ko-KR"),
    isExpiringSoon: isExpiringWithinDays(
      coupon.expiresAt,
      now,
      3
    ),
  };
}

이제 전체 처리 과정을 조합할 수 있다.

function createAvailableCouponCards(
  coupons: Coupon[],
  now: Date
): CouponCardViewModel[] {
  return coupons
    .filter((coupon) =>
      isCouponAvailable(coupon, now)
    )
    .sort(compareByNearestExpiry)
    .map((coupon) =>
      toCouponCardViewModel(coupon, now)
    );
}

하지만 여기에는 미묘한 문제가 하나 있다.
filter는 새로운 배열을 반환하므로 뒤의 sort가 원본 coupons를 변경하지 않는다.
현재 코드에서는 안전하다.
그러나 필터링하지 않고 바로 정렬한다면 원본 배열이 변경된다.

function sortCoupons(
  coupons: Coupon[]
): Coupon[] {
  return coupons.sort(compareByNearestExpiry);
}

JavaScript의 sort는 기본적으로 원본 배열을 직접 변경한다.
원본을 유지해야 한다면 먼저 복사해야 한다.

function sortCoupons(
  coupons: Coupon[]
): Coupon[] {
  return [...coupons].sort(compareByNearestExpiry);
}

최신 JavaScript 환경에서는 원본을 변경하지 않는 toSorted를 사용할 수도 있다.

const sortedCoupons =
  coupons.toSorted(compareByNearestExpiry);

이 차이는 단순한 문법 차이가 아니다.
같은 배열을 여러 화면이나 상태가 참조하고 있다면 원본 정렬로 인해 다른 영역의 표시 순서까지 갑자기 바뀔 수 있다.


sort의 기본 동작을 그대로 믿으면 안 된다

숫자 배열을 정렬할 때 다음처럼 작성할 수 있을 것 같다.

const numbers = [2, 10, 100, 21];

numbers.sort();

console.log(numbers);

결과는 숫자 크기순이 아닐 수 있다.

[10, 100, 2, 21]

기본 sort는 값을 문자열처럼 비교하기 때문이다.
숫자를 정렬하려면 비교 함수를 제공해야 한다.

const ascending = [...numbers].sort(
  (first, second) => first - second
);

날짜 역시 명확한 비교 기준을 사용해야 한다.

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

문자열 정렬에서는 언어와 대소문자도 고려해야 한다.

const couponsByTitle = [...coupons].sort(
  (first, second) =>
    first.title.localeCompare(
      second.title,
      "ko-KR"
    )
);

정렬 기준이 하나만으로 충분하지 않을 수도 있다.
예를 들어 상태별 우선순위를 다음처럼 정할 수 있다.

const statusPriority: Record<CouponStatus, number> = {
  active: 1,
  draft: 2,
  redeemed: 3,
  expired: 4,
  cancelled: 5,
};

먼저 상태를 비교하고, 상태가 같으면 생성일을 비교한다.

function compareCoupons(
  first: Coupon,
  second: Coupon
): number {
  const priorityDifference =
    statusPriority[first.status] -
    statusPriority[second.status];

  if (priorityDifference !== 0) {
    return priorityDifference;
  }

  return (
    second.createdAt.getTime() -
    first.createdAt.getTime()
  );
}

실제 서비스의 정렬에는 최소한 다음이 정의되어야 한다.

  • 첫 번째 정렬 기준은 무엇인가?
  • 오름차순인가, 내림차순인가?
  • 값이 같을 때 두 번째 기준은 무엇인가?
  • null 값은 앞에 둘 것인가, 뒤에 둘 것인가?
  • 언어별 문자열 정렬이 필요한가?
  • 정렬 결과가 항상 동일하게 유지되어야 하는가?
  • 서버와 클라이언트가 같은 정렬 규칙을 사용하는가?

“날짜순으로 정렬한다”는 요구사항만으로는 충분하지 않다.


배열의 중복은 문법 문제가 아니라 서비스 규칙 문제다

배열은 같은 값을 여러 번 가질 수 있다.

const couponIds = [
  "coupon-1",
  "coupon-2",
  "coupon-1",
];

문법적으로는 아무 문제가 없다.
하지만 일괄 발행 대상 목록이라면 같은 쿠폰을 두 번 처리할 위험이 있다.
문자열처럼 단순한 값은 Set을 사용해 중복을 제거할 수 있다.

const uniqueCouponIds =
  [...new Set(couponIds)];

객체 배열에서는 객체 전체가 아니라 어떤 필드를 기준으로 중복을 판단할지 결정해야 한다.

const coupons = [
  { id: "coupon-1", title: "커피 쿠폰" },
  { id: "coupon-1", title: "커피 쿠폰 수정본" },
];

두 객체는 서로 다른 객체이므로 단순히 Set에 넣어도 하나로 합쳐지지 않는다.

const uniqueCoupons = [...new Set(coupons)];

console.log(uniqueCoupons.length); // 2

ID를 기준으로 하나만 남기려면 명시적인 규칙이 필요하다.

const couponById = new Map<string, Coupon>();

for (const coupon of coupons) {
  couponById.set(coupon.id, coupon);
}

const uniqueCoupons =
  [...couponById.values()];

이 코드는 같은 ID가 여러 번 나오면 마지막 값을 남긴다.
하지만 마지막 값을 남기는 것이 올바른지는 별도의 문제다.

  • 첫 번째 데이터를 신뢰할 것인가?
  • 마지막 데이터를 신뢰할 것인가?
  • updatedAt이 가장 최근인 데이터를 선택할 것인가?
  • 중복 자체를 오류로 처리할 것인가?
  • 서로 다른 내용을 가진 중복 ID를 운영자에게 보고할 것인가?

중복 제거는 단순히 Set을 사용하는 작업이 아니다.
서비스에서 무엇이 같은 데이터인지 정의하는 일이다.


원본 배열 VS 새로운 배열

JavaScript의 배열 메서드 중에는 원본을 변경하는 것과 새로운 배열을 반환하는 것이 섞여 있다.
원본을 변경하는 대표적인 메서드는 다음과 같다.

coupons.push(newCoupon);
coupons.pop();
coupons.shift();
coupons.unshift(newCoupon);
coupons.splice(0, 1);
coupons.sort(compareCoupons);
coupons.reverse();

새로운 배열을 반환하는 대표적인 메서드는 다음과 같다.

const filtered = coupons.filter(predicate);
const mapped = coupons.map(transform);
const sliced = coupons.slice(0, 10);
const combined = coupons.concat(otherCoupons);

원본 변경이 항상 잘못된 것은 아니다.
함수 내부에서 새로 만든 배열을 조립하는 경우에는 push가 명확하고 효율적일 수 있다.

function collectExpiredCouponIds(
  coupons: Coupon[],
  now: Date
): string[] {
  const expiredCouponIds: string[] = [];

  for (const coupon of coupons) {
    if (
      coupon.expiresAt !== null &&
      coupon.expiresAt <= now
    ) {
      expiredCouponIds.push(coupon.id);
    }
  }

  return expiredCouponIds;
}

이 배열은 함수 내부에서 만들어졌고 다른 코드와 공유되지 않으므로 직접 변경해도 영향 범위가 제한적이다.
반면 React 상태를 직접 변경하는 것은 문제가 될 수 있다.

coupons.push(newCoupon);
setCoupons(coupons);

배열의 참조가 그대로이기 때문에 React가 변경을 제대로 감지하지 못하거나 상태 추적이 어려워질 수 있다.
새로운 배열을 만들어 전달하는 편이 안전하다.

setCoupons((previousCoupons) => [
  ...previousCoupons,
  newCoupon,
]);

쿠폰을 삭제할 때도 원본을 직접 변경하기보다 새 배열을 만들 수 있다.

setCoupons((previousCoupons) =>
  previousCoupons.filter(
    (coupon) => coupon.id !== couponId
  )
);

특정 쿠폰을 수정할 때는 해당 객체도 새로 만든다.

setCoupons((previousCoupons) =>
  previousCoupons.map((coupon) =>
    coupon.id === updatedCoupon.id
      ? updatedCoupon
      : coupon
  )
);

여기서 중요한 질문은 단순히 “불변성을 지켜야 하는가?”가 아니다.

이 배열을 누가 함께 참조하고 있으며, 변경 사실을 어떤 방식으로 감지해야 하는가?

변경의 영향 범위를 알 수 없다면 새로운 배열을 만드는 방식이 예측하기 쉽다.


얕은 복사는 배열 안의 객체까지 복사하지 않는다

스프레드 문법으로 배열을 복사할 수 있다.

const copiedCoupons = [...coupons];

두 배열은 서로 다른 배열이다.

console.log(copiedCoupons === coupons);
// false

하지만 배열 안의 쿠폰 객체는 여전히 같은 객체를 참조한다.

console.log(
  copiedCoupons[0] === coupons[0]
);
// true

복사된 배열의 객체를 수정하면 원본 배열에서 보는 객체도 변경된다.

copiedCoupons[0].title = "변경된 제목";

console.log(coupons[0].title);
// 변경된 제목

객체까지 새로 만들고 싶다면 각 항목도 복사해야 한다.

const copiedCoupons = coupons.map(
  (coupon) => ({
    ...coupon,
  })
);

하지만 쿠폰 안에 중첩 객체가 있다면 이것도 완전한 깊은 복사는 아니다.

interface CouponWithDesign {
  id: string;
  title: string;
  design: {
    backgroundColor: string;
    textColor: string;
  };
}

다음 복사에서는 design 객체가 공유된다.

const copiedCoupons = coupons.map(
  (coupon) => ({
    ...coupon,
  })
);

copiedCoupons[0].design.backgroundColor =
  "#000000";

중첩 객체도 새로 만들려면 명시적으로 복사해야 한다.

const copiedCoupons = coupons.map(
  (coupon) => ({
    ...coupon,
    design: {
      ...coupon.design,
    },
  })
);

데이터 구조가 복잡하다면 무작정 깊은 복사를 하는 것보다 어떤 부분을 실제로 변경할 것인지 확인하고 필요한 경로만 새로 만드는 것이 좋다.


서버 데이터와 화면 데이터는 같은 배열일 필요가 없다

서버에서 받은 쿠폰 객체를 그대로 화면에 사용하면 처음에는 편리해 보인다.

function CouponCard({
  coupon,
}: {
  coupon: Coupon;
}) {
  return (
    <article>
      <h2>{coupon.title}</h2>
      <p>{coupon.expiresAt?.toString()}</p>
    </article>
  );
}

그러나 화면에는 데이터베이스 필드보다 사용자에게 필요한 정보가 중요하다.

  • 유효기간을 어떤 형식으로 보여줄 것인가?
  • 만료가 가까운 쿠폰인지 표시할 것인가?
  • 상태를 어떤 한국어 문구로 보여줄 것인가?
  • 사용 버튼을 활성화할 것인가?
  • 발행자의 내부 ID가 아니라 이름과 프로필 이미지를 보여줄 것인가?

따라서 서버 배열을 화면 전용 배열로 변환할 수 있다.

interface CouponCardViewModel {
  id: string;
  title: string;
  senderName: string;
  senderImageUrl: string | null;
  statusLabel: string;
  expiryLabel: string;
  canRedeem: boolean;
  isExpiringSoon: boolean;
}
const couponCards: CouponCardViewModel[] =
  coupons.map((coupon) => ({
    id: coupon.id,
    title: coupon.title,
    senderName: coupon.sender.displayName,
    senderImageUrl:
      coupon.sender.profileImageUrl,
    statusLabel:
      getCouponStatusLabel(coupon.status),
    expiryLabel:
      formatExpiryLabel(coupon.expiresAt, now),
    canRedeem:
      isCouponAvailable(coupon, now),
    isExpiringSoon:
      isExpiringWithinDays(
        coupon.expiresAt,
        now,
        3
      ),
  }));

이렇게 하면 UI가 날짜 계산이나 비즈니스 상태 판단을 직접 반복하지 않아도 된다.
화면은 전달받은 값을 표시하는 데 집중할 수 있다.

function CouponCard({
  coupon,
}: {
  coupon: CouponCardViewModel;
}) {
  return (
    <article>
      <h2>{coupon.title}</h2>
      <p>{coupon.senderName}</p>
      <p>{coupon.expiryLabel}</p>

      {coupon.isExpiringSoon && (
        <span>곧 만료돼요</span>
      )}

      <button disabled={!coupon.canRedeem}>
        사용하기
      </button>
    </article>
  );
}

배열의 변환은 단순히 필드 이름을 바꾸는 작업이 아니다.
한 계층의 데이터를 다른 계층이 사용할 수 있는 의미로 번역하는 과정이다.


필터링과 정렬은 어디에서 해야 하는가

쿠폰이 10개라면 클라이언트에서 배열을 필터링하고 정렬해도 큰 문제가 없을 수 있다.

const visibleCoupons = coupons
  .filter((coupon) =>
    coupon.title
      .toLowerCase()
      .includes(searchKeyword.toLowerCase())
  )
  .sort(compareByNearestExpiry);

하지만 사용자가 쿠폰을 10만 개 가지고 있다면 모든 데이터를 브라우저로 가져온 뒤 필터링하는 것은 비효율적이다.

const allCoupons =
  await couponRepository.findAllByUserId(userId);

다음과 같은 문제가 발생할 수 있다.

  • 데이터베이스 조회 시간이 길어진다.
  • 서버 응답 크기가 커진다.
  • 네트워크 전송 시간이 증가한다.
  • 브라우저 메모리 사용량이 증가한다.
  • 정렬과 필터링으로 UI가 느려진다.
  • 사용자가 보지 않을 데이터까지 전송된다.

이 경우 검색 조건과 정렬 기준을 서버에 전달해야 한다.

interface CouponSearchQuery {
  receiverId: string;
  status?: CouponStatus;
  keyword?: string;
  orderBy: "createdAt" | "expiresAt";
  direction: "asc" | "desc";
  cursor?: string;
  limit: number;
}
const result =
  await couponRepository.search({
    receiverId: userId,
    status: "active",
    keyword: "커피",
    orderBy: "expiresAt",
    direction: "asc",
    limit: 20,
  });

다만 서버에서 받아온 현재 페이지 안에서 화면 표시용 변환을 하는 것은 여전히 클라이언트나 API 계층의 역할일 수 있다.

const couponCards =
  result.items.map((coupon) =>
    toCouponCardViewModel(coupon, now)
  );

“배열은 어디에서 처리해야 하는가?”에 대한 하나의 정답은 없다.

다음 기준으로 판단해야 한다.

  • 전체 데이터의 크기
  • 네트워크 비용
  • 검색 조건의 복잡도
  • 데이터베이스 인덱스 사용 가능성
  • 클라이언트에서 즉시 반응해야 하는지
  • 서버가 반드시 보장해야 하는 규칙인지
  • 현재 가져온 데이터만 대상으로 하는지
  • 전체 데이터 집합을 대상으로 하는지

중요한 것은 클라이언트에 있는 배열이 전체 데이터라고 착각하지 않는 것이다.


페이지네이션된 배열은 전체 목록이 아니다

서버에서 한 번에 20개의 쿠폰을 받았다고 가정해 보자.

interface CouponPage {
  items: Coupon[];
  nextCursor: string | null;
  hasNextPage: boolean;
}
const firstPage =
  await couponApi.getReceivedCoupons({
    limit: 20,
  });

firstPage.items는 사용자가 가진 전체 쿠폰이 아니다.
전체 쿠폰 중 첫 번째 페이지일 뿐이다.
따라서 다음 코드는 전체 쿠폰에 사용 가능한 쿠폰이 없는지를 판단하지 못한다.

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

이 결과가 false라는 것은 첫 페이지에 사용 가능한 쿠폰이 없다는 뜻이다. 다음 페이지에는 있을 수 있다.
클라이언트가 가진 배열의 범위를 명확하게 이름으로 표현하면 오해를 줄일 수 있다.

const currentPageCoupons =
  firstPage.items;

또는 서버가 전체 데이터에 대한 별도 정보를 반환할 수 있다.

interface CouponPageResponse {
  items: Coupon[];
  nextCursor: string | null;
  summary: {
    totalCount: number;
    availableCount: number;
  };
}

무한 스크롤에서 다음 페이지를 합칠 때는 중복도 고려해야 한다.

const mergedCoupons = [
  ...previousCoupons,
  ...nextPage.items,
];

데이터가 갱신되거나 페이지 요청이 중복되면 같은 쿠폰이 두 번 들어갈 수 있다.
ID를 기준으로 병합할 수 있다.

function mergeCouponsById(
  currentCoupons: Coupon[],
  incomingCoupons: Coupon[]
): Coupon[] {
  const couponById = new Map(
    currentCoupons.map((coupon) => [
      coupon.id,
      coupon,
    ])
  );

  for (const coupon of incomingCoupons) {
    couponById.set(coupon.id, coupon);
  }

  return [...couponById.values()];
}

하지만 Map으로 변환하면 원하는 표시 순서가 유지되는지도 확인해야 한다.
페이지 병합에서는 다음과 같은 규칙이 필요하다.

  • 중복 쿠폰이 들어오면 기존 값과 새 값 중 무엇을 사용할 것인가?
  • 페이지를 합친 후 다시 정렬해야 하는가?
  • 중간에 새 쿠폰이 생성되면 커서가 안정적으로 동작하는가?
  • 삭제된 쿠폰을 기존 배열에서 제거해야 하는가?
  • 요청 응답 순서가 뒤바뀌면 어떻게 처리할 것인가?

페이지에서 받은 배열은 완성된 전체 목록이 아니라 계속 변화하는 일부 결과다.


코드가 어떻게 달라지는가

배열에 대한 이해가 깊어지면 코드는 단순히 짧아지는 것이 아니라 데이터의 의미와 처리 단계가 명확해진다.

첫 번째 단계: 각각의 변수로 관리한다

const firstCouponTitle =
  "커피 한 잔 사주기";

const secondCouponTitle =
  "저녁 메뉴 선택권";

const thirdCouponTitle =
  "주말 설거지 대신하기";

쿠폰이 늘어날 때마다 새로운 변수를 만들어야 한다.
특정 쿠폰을 찾거나 정렬하는 것도 어렵다.

두 번째 단계: 배열에 값을 담는다

const couponTitles = [
  "커피 한 잔 사주기",
  "저녁 메뉴 선택권",
  "주말 설거지 대신하기",
];

관련 값이 하나의 목록이 되었다.
하지만 문자열만으로는 각 쿠폰의 정체성과 상태를 표현하기 어렵다.

세 번째 단계: 객체 배열로 모델링한다

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

const coupons: Coupon[] = [
  {
    id: "coupon-1",
    title: "커피 한 잔 사주기",
    status: "active",
    createdAt: new Date(
      "2026-08-20T10:00:00Z"
    ),
    expiresAt: new Date(
      "2026-09-20T10:00:00Z"
    ),
  },
  {
    id: "coupon-2",
    title: "저녁 메뉴 선택권",
    status: "redeemed",
    createdAt: new Date(
      "2026-08-18T10:00:00Z"
    ),
    expiresAt: null,
  },
];

이제 각 쿠폰은 고유한 ID와 상태, 날짜를 가진다.

네 번째 단계: 배열 처리 목적을 분리한다

const now = new Date();

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

const orderedCoupons =
  availableCoupons.toSorted(
    compareByNearestExpiry
  );

const couponCards =
  orderedCoupons.map((coupon) =>
    toCouponCardViewModel(coupon, now)
  );

각 변수에는 처리 단계가 드러난다.

  • coupons: 조회된 쿠폰
  • availableCoupons: 사용 가능한 쿠폰
  • orderedCoupons: 표시 순서가 결정된 쿠폰
  • couponCards: 화면용으로 변환된 쿠폰

다섯 번째 단계: 하나의 사용 사례로 묶는다

interface GetAvailableCouponCardsInput {
  receiverId: string;
  now: Date;
  limit: number;
}

async function getAvailableCouponCards(
  input: GetAvailableCouponCardsInput
): Promise<CouponCardViewModel[]> {
  const coupons =
    await couponRepository.findAvailableByReceiver({
      receiverId: input.receiverId,
      now: input.now,
      orderBy: "expiresAt",
      limit: input.limit,
    });

  return coupons.map((coupon) =>
    toCouponCardViewModel(
      coupon,
      input.now
    )
  );
}

필터링과 정렬을 데이터베이스 조회로 옮겼기 때문에 애플리케이션은 조회된 결과를 화면용으로 변환하는 데 집중한다.
배열 처리를 잘 설계한다는 것은 모든 메서드를 연결하는 것이 아니다.
각 작업을 어느 계층에서 수행할지 결정하고, 배열이 어떤 단계를 거친 결과인지 명확하게 만드는 것이다.


배열을 설계하기 전에 물어볼 질문

목록의 의미

  • 이 배열은 어떤 대상의 목록인가?
  • 누구에게 속한 데이터인가?
  • 전체 목록인가, 조회 결과의 일부인가?
  • 원본 데이터인가, 필터링되거나 변환된 결과인가?
  • 변수 이름만 보고 포함 조건을 이해할 수 있는가?

항목의 정체성

  • 각 항목을 무엇으로 구분하는가?
  • 배열의 인덱스를 ID처럼 사용하고 있지는 않은가?
  • 같은 ID의 항목이 두 번 들어올 수 있는가?
  • 중복이 허용되는 목록인가?
  • 중복이 발생했을 때 어떤 값을 남겨야 하는가?

순서

  • 배열의 현재 순서에 의미가 있는가?
  • 어떤 필드를 기준으로 정렬하는가?
  • 오름차순인가, 내림차순인가?
  • 값이 같을 때 두 번째 정렬 기준은 무엇인가?
  • null 값은 어디에 배치하는가?
  • 실행할 때마다 같은 순서가 보장되어야 하는가?

검색과 접근

  • 항목을 순서대로 읽는가?
  • ID로 특정 항목을 자주 찾는가?
  • find로 충분한 크기와 검색 횟수인가?
  • 반복 검색을 위해 Map이 필요한가?
  • 하나의 항목이 필요한데 전체 배열을 가져오고 있지는 않은가?

변경

  • 원본 배열을 직접 변경해야 하는가?
  • 다른 코드가 같은 배열을 참조하고 있는가?
  • 배열만 복사하면 되는가, 내부 객체도 복사해야 하는가?
  • 변경 사실을 UI 프레임워크가 감지해야 하는가?
  • 새 배열을 만드는 편이 변경 흐름을 이해하기 쉬운가?

처리 위치

  • 필터링과 정렬을 서버에서 해야 하는가?
  • 데이터베이스가 더 효율적으로 처리할 수 있는가?
  • 클라이언트에 전체 데이터를 보내도 되는가?
  • 검색 조건이 보안이나 권한과 관련되어 있는가?
  • 클라이언트 필터링 결과를 비즈니스 검증으로 신뢰하고 있지는 않은가?

데이터 규모

  • 현재는 몇 개이고 최대 몇 개까지 늘어날 수 있는가?
  • 한 번에 전부 메모리에 올려도 되는가?
  • 페이지네이션이나 무한 스크롤이 필요한가?
  • 배열을 변환하면서 불필요한 중간 배열을 너무 많이 만들지는 않는가?
  • 사용자 입력 때마다 큰 배열을 다시 정렬하고 있지는 않은가?

실패와 빈 결과

  • 검색 결과가 없으면 빈 배열인가, undefined인가?
  • 배열 자체가 아직 조회되지 않은 상태와 빈 결과를 구분해야 하는가?
  • 일부 항목의 변환에 실패하면 전체를 실패시킬 것인가?
  • 잘못된 항목을 제외할 것인가, 오류로 보고할 것인가?
  • 페이지 중 하나의 요청이 실패하면 기존 목록은 유지할 것인가?

이 질문에 답하면 배열을 단순한 저장 공간이 아니라 서비스 목록의 규칙으로 설계할 수 있다.


흔히 하는 실수

인덱스를 데이터 ID처럼 사용한다

const selectedCouponIndex = 2;

정렬이나 삭제가 발생하면 인덱스가 다른 쿠폰을 가리킬 수 있다.

const selectedCouponId = "coupon-123";

항목의 정체성은 안정적인 ID로 관리해야 한다.


존재하지 않는 인덱스에 바로 접근한다

const firstCoupon = coupons[0];

console.log(firstCoupon.title);

빈 배열이면 firstCouponundefined다.

const firstCoupon = coupons[0];

if (firstCoupon === undefined) {
  return {
    message: "표시할 쿠폰이 없습니다.",
  };
}

또는 요구사항에 따라 별도의 빈 상태를 렌더링해야 한다.


find의 결과가 항상 있다고 가정한다

const coupon = coupons.find(
  (coupon) => coupon.id === couponId
);

console.log(coupon.title);

find는 찾지 못하면 undefined를 반환한다.

if (coupon === undefined) {
  throw new CouponNotFoundError(couponId);
}

검색 실패가 정상적인 빈 결과인지 예외적인 상태인지도 구분해야 한다.


filterfind를 혼동한다

const coupon = coupons.filter(
  (coupon) => coupon.id === couponId
);

filter는 항상 배열을 반환한다.

console.log(coupon.title);
// 배열에는 title이 없다.

하나의 항목이 필요하다면 find가 목적에 맞다.

const coupon = coupons.find(
  (coupon) => coupon.id === couponId
);

map의 결과를 반환하지 않는다

const titles = coupons.map((coupon) => {
  coupon.title;
});

중괄호를 사용한 화살표 함수에서는 명시적으로 값을 반환해야 한다.

const titles = coupons.map((coupon) => {
  return coupon.title;
});

간단한 표현식은 다음과 같이 작성할 수 있다.

const titles = coupons.map(
  (coupon) => coupon.title
);

map을 단순 실행 용도로 사용한다

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

map은 각 항목을 변환해 새로운 배열을 만들기 위한 메서드다.
반환된 배열을 사용하지 않는다면 의도가 맞지 않는다.

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

또는 동기적인 단순 실행이라면 forEach를 사용할 수 있다.

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

비동기 작업에서는 forEach가 Promise를 기다리지 않는다는 점을 별도로 주의해야 한다.


sort가 원본 배열을 변경한다는 사실을 놓친다

const sortedCoupons =
  coupons.sort(compareByNearestExpiry);

sortedCoupons만 바뀌는 것처럼 보이지만 coupons의 순서도 변경된다.
원본을 유지하려면 복사하거나 toSorted를 사용한다.

const sortedCoupons =
  [...coupons].sort(compareByNearestExpiry);
const sortedCoupons =
  coupons.toSorted(compareByNearestExpiry);

숫자를 기본 sort로 정렬한다

const days = [1, 2, 10, 20];

days.sort();

문자열 기준으로 비교될 수 있으므로 숫자 비교 함수를 제공해야 한다.

days.sort(
  (first, second) => first - second
);

배열을 복사하면 내부 객체도 복사된다고 생각한다

const copiedCoupons = [...coupons];

copiedCoupons[0].title = "새로운 제목";

배열은 복사되었지만 내부 객체는 공유된다.
변경할 객체도 새로 만들어야 한다.

const updatedCoupons =
  coupons.map((coupon) =>
    coupon.id === targetCouponId
      ? {
          ...coupon,
          title: "새로운 제목",
        }
      : coupon
  );

빈 배열을 false처럼 취급한다

const coupons: Coupon[] = [];

if (coupons) {
  console.log("쿠폰이 있습니다.");
}

JavaScript에서 빈 배열도 참으로 평가된다.
길이를 확인해야 한다.

if (coupons.length > 0) {
  console.log("쿠폰이 있습니다.");
}

빈 상태를 확인하려면 다음처럼 작성한다.

if (coupons.length === 0) {
  console.log("쿠폰이 없습니다.");
}

클라이언트 배열을 전체 데이터라고 생각한다

const hasRedeemedCoupon =
  currentPageCoupons.some(
    (coupon) =>
      coupon.status === "redeemed"
  );

현재 페이지에서 찾지 못했다고 해서 전체 데이터에 없다는 뜻은 아니다.
전체 데이터에 대한 판단이 필요하다면 서버에 별도 조회를 요청해야 한다.

const hasRedeemedCoupon =
  await couponRepository.exists({
    receiverId: userId,
    status: "redeemed",
  });

모든 데이터를 가져온 뒤 브라우저에서 처리한다

const allCoupons =
  await api.getAllCoupons();

const result = allCoupons
  .filter(matchesKeyword)
  .sort(compareByNearestExpiry)
  .slice(0, 20);

데이터가 커지면 네트워크와 메모리, 렌더링 비용이 증가한다.
필요한 조건과 범위를 서버에 전달해야 한다.

const result = await api.searchCoupons({
  keyword,
  status: "active",
  orderBy: "expiresAt",
  limit: 20,
});

화면 필터링을 권한 검사로 사용한다

const visibleCoupons =
  coupons.filter(
    (coupon) =>
      coupon.receiverId === currentUser.id
  );

화면에서 다른 사용자의 쿠폰을 제외했다고 해서 데이터가 보호되는 것은 아니다.
서버가 요청한 사용자의 권한을 확인하고 허용된 데이터만 반환해야 한다.

const coupons =
  await couponRepository.findMany({
    receiverId: authenticatedUser.id,
  });

배열 필터링은 화면 표시를 위한 도구일 수 있지만 보안 경계가 될 수는 없다.


한 배열에 서로 다른 의미의 데이터를 섞는다

const selectedItems = [
  coupon,
  couponTemplate,
  uploadedImage,
];

서로 다른 항목을 하나의 배열에 넣으면 각 항목의 타입을 계속 확인해야 한다.

for (const item of selectedItems) {
  if ("expiresAt" in item) {
    // 쿠폰 처리
  } else if ("templateType" in item) {
    // 템플릿 처리
  }
}

실제로 하나의 목록이어야 하는지, 서로 다른 목록으로 분리해야 하는지 검토해야 한다.

const selectedCoupons: Coupon[] = [];
const selectedTemplates: CouponTemplate[] = [];
const uploadedImages: UploadedImage[] = [];

서로 다른 타입이 하나의 목록을 구성해야 한다면 구분 가능한 유니언 타입을 명확히 정의할 수 있다.

type EditorElement =
  | {
      type: "text";
      id: string;
      content: string;
    }
  | {
      type: "image";
      id: string;
      imageUrl: string;
    }
  | {
      type: "emoji";
      id: string;
      emoji: string;
    };
const elements: EditorElement[] = [];

이 경우 서로 다른 데이터가 섞인 것이 실수가 아니라 쿠폰 편집 화면의 요소 목록이라는 명확한 의미를 가진다.


핵심 정리

배열은 여러 값을 하나의 변수에 담는 자료구조다.
하지만 실제 서비스에서 이 정의만으로는 충분하지 않다.
서비스에서 배열은 사용자, 상품, 주문, 쿠폰과 같은 여러 데이터를 특정한 규칙에 따라 모아놓은 목록이다.
배열에는 포함 조건, 정렬 순서, 검색 방식, 중복 정책, 변경 방식, 데이터 범위가 함께 존재한다.

같은 Coupon[] 타입이라도 다음 배열들은 서로 다른 의미를 가진다.

const receivedCoupons: Coupon[] = [];
const availableCoupons: Coupon[] = [];
const expiringSoonCoupons: Coupon[] = [];
const currentPageCoupons: Coupon[] = [];

따라서 배열의 타입뿐 아니라 그 배열이 어떤 처리 단계를 거친 결과인지 이름과 구조로 표현해야 한다.
배열의 인덱스는 데이터의 정체성이 아니다.
항목이 추가되거나 삭제되고 정렬되면 인덱스는 바뀐다.
사용자 선택, React의 key, 데이터 수정 대상은 안정적인 ID로 관리해야 한다.

filter, map, find, some, every, reduce, sort는 단순히 외워야 할 배열 메서드가 아니다.

  • filter는 필요한 데이터를 선택한다.
  • map은 데이터를 다른 형태로 변환한다.
  • find는 조건에 맞는 하나의 데이터를 찾는다.
  • some은 하나라도 조건을 만족하는지 확인한다.
  • every는 모든 데이터가 조건을 만족하는지 확인한다.
  • reduce는 여러 데이터를 하나의 결과로 합친다.
  • sort는 목록에서 무엇을 먼저 보여줄지 결정한다.

어떤 메서드를 선택할지는 코드 스타일의 문제가 아니라 데이터 처리의 목적을 표현하는 문제다.

원본 배열을 직접 변경할 것인지 새로운 배열을 만들 것인지도 의도적으로 결정해야 한다.
특히 sort, splice, push처럼 원본을 변경하는 작업은 같은 배열을 참조하는 다른 코드에 영향을 줄 수 있다.
배열을 복사하더라도 내부 객체까지 자동으로 복사되는 것은 아니라는 점도 알아야 한다.

데이터가 많아지면 모든 데이터를 배열로 가져온 뒤 클라이언트에서 처리하는 방식은 한계가 생긴다.
필터링, 정렬, 검색, 페이지네이션 중 어떤 작업을 데이터베이스와 서버에 맡길 것인지 결정해야 한다.
현재 페이지의 배열은 전체 데이터가 아니므로 전체 서비스 상태를 판단하는 근거로 사용할 수도 없다.

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

어떤 데이터를 하나의 목록으로 묶고, 어떤 순서로 보여주며, 어떻게 찾고 변환하고 변경할 것인가?

배열은 여러 값을 담는 상자가 아니다.
서비스 안에서 목록 데이터가 어떤 의미를 가지며 어떤 규칙에 따라 사용자에게 전달되는지를 표현하는 방법이다.

profile
Vision eXperience Developer

0개의 댓글