
배열은 여러 개의 값을 하나의 변수에 저장할 수 있는 자료구조다.
쿠폰 이름을 각각의 변수에 저장한다면 다음과 같이 작성할 수 있다.
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[] = [];
receivedCoupons와 availableCoupons는 모두 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 상태만 포함한다.배열을 이해한다는 것은 대괄호 안의 값을 이해하는 데서 끝나지 않는다.
그 배열이 다음 질문에 어떤 답을 가지고 있는지 알아야 한다.
배열에는 순서가 있다.
하지만 실제 서비스에서 중요한 것은 배열에 순서가 있다는 사실이 아니라 그 순서가 무엇을 의미하는가이다.
서비스의 쿠폰 목록을 최근에 받은 순서로 보여준다고 가정해 보자.
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;
현재 selectedIndex가 1이면 영화 쿠폰을 가리킨다.
하지만 앞에 새로운 쿠폰이 추가되면 상황이 달라진다.
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가 더 단순하고 이해하기 쉽다.
자료구조는 빠르게 보이기 위해 선택하는 것이 아니라 실제 접근 방식에 맞게 선택해야 한다.
실제 서비스에서 배열은 여러 처리 단계를 통과한다.
예를 들어 쿠폰 목록 화면은 다음 과정으로 만들어질 수 있다.
이 흐름을 코드로 표현하면 다음과 같다.
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이 가장 최근인 데이터를 선택할 것인가?중복 제거는 단순히 Set을 사용하는 작업이 아니다.
서비스에서 무엇이 같은 데이터인지 정의하는 일이다.
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>
);
}
그러나 화면에는 데이터베이스 필드보다 사용자에게 필요한 정보가 중요하다.
따라서 서버 배열을 화면 전용 배열로 변환할 수 있다.
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);
다음과 같은 문제가 발생할 수 있다.
이 경우 검색 조건과 정렬 기준을 서버에 전달해야 한다.
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
)
);
}
필터링과 정렬을 데이터베이스 조회로 옮겼기 때문에 애플리케이션은 조회된 결과를 화면용으로 변환하는 데 집중한다.
배열 처리를 잘 설계한다는 것은 모든 메서드를 연결하는 것이 아니다.
각 작업을 어느 계층에서 수행할지 결정하고, 배열이 어떤 단계를 거친 결과인지 명확하게 만드는 것이다.
null 값은 어디에 배치하는가?find로 충분한 크기와 검색 횟수인가?Map이 필요한가?undefined인가?이 질문에 답하면 배열을 단순한 저장 공간이 아니라 서비스 목록의 규칙으로 설계할 수 있다.
const selectedCouponIndex = 2;
정렬이나 삭제가 발생하면 인덱스가 다른 쿠폰을 가리킬 수 있다.
const selectedCouponId = "coupon-123";
항목의 정체성은 안정적인 ID로 관리해야 한다.
const firstCoupon = coupons[0];
console.log(firstCoupon.title);
빈 배열이면 firstCoupon은 undefined다.
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);
}
검색 실패가 정상적인 빈 결과인지 예외적인 상태인지도 구분해야 한다.
filter와 find를 혼동한다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처럼 원본을 변경하는 작업은 같은 배열을 참조하는 다른 코드에 영향을 줄 수 있다.
배열을 복사하더라도 내부 객체까지 자동으로 복사되는 것은 아니라는 점도 알아야 한다.
데이터가 많아지면 모든 데이터를 배열로 가져온 뒤 클라이언트에서 처리하는 방식은 한계가 생긴다.
필터링, 정렬, 검색, 페이지네이션 중 어떤 작업을 데이터베이스와 서버에 맡길 것인지 결정해야 한다.
현재 페이지의 배열은 전체 데이터가 아니므로 전체 서비스 상태를 판단하는 근거로 사용할 수도 없다.
결국 배열을 설계한다는 것은 다음 질문에 답하는 일이다.
어떤 데이터를 하나의 목록으로 묶고, 어떤 순서로 보여주며, 어떻게 찾고 변환하고 변경할 것인가?
배열은 여러 값을 담는 상자가 아니다.
서비스 안에서 목록 데이터가 어떤 의미를 가지며 어떤 규칙에 따라 사용자에게 전달되는지를 표현하는 방법이다.