2.3 배열과 튜플
배열(Array)은 동일한 타입의 값들을 순서 있게 모은 컬렉션이다. 튜플(Tuple)은 길이가 고정되고 각 인덱스마다 타입이 정해진 특수한 배열이다. TypeScript는 두 구조에 강력한 타입 검사를 제공하며, readonly 수식어, 레이블 튜플, as const 단언, 나머지 요소 등의 기능으로 표현력을 극대화한다.
배열 타입 선언 — T[] vs Array<T>
배열의 타입을 표기하는 방법은 두 가지다. 결과는 완전히 동일하다.
T[] 문법
const numbers: number[] = [1, 2, 3, 4, 5];
const names: string[] = ["Alice", "Bob", "Charlie"];
const flags: boolean[] = [true, false, true];
Array<T> 제네릭 문법
const numbers: Array<number> = [1, 2, 3, 4, 5];
const names: Array<string> = ["Alice", "Bob", "Charlie"];
어느 쪽을 선택해야 하는가
두 문법은 컴파일 결과가 동일하지만 상황에 따라 선택 기준이 다르다.
| 상황 | 권장 문법 | 이유 |
|---|---|---|
단순 타입 (string[]) | T[] | 더 간결하고 읽기 쉽다 |
| 복잡한 제네릭 타입 | Array<T> | 중첩 꺾쇠가 혼란스럽지 않다 |
| 유니온 타입 요소 | Array<string | number> | (string | number)[]보다 명확하다 |
| readonly 배열 | readonly T[] 또는 ReadonlyArray<T> | 둘 다 동일 |
// 유니온 타입 요소 — Array<T>가 더 읽기 쉽다
const mixed: Array<string | number> = ["a", 1, "b", 2];
const mixed2: (string | number)[] = ["a", 1, "b", 2]; // 괄호 필요
// 복잡한 중첩 타입 — Array<T>가 명확하다
type Callback = (value: number) => void;
const callbacks: Array<Callback> = [];
const callbacks2: ((value: number) => void)[] = []; // 읽기 어렵다
배열 메서드와 타입 안전성
TypeScript는 배열 메서드의 반환 타입도 정확하게 추론한다.
const scores: number[] = [90, 85, 78, 92, 88];
const doubled = scores.map((s) => s * 2); // number[]
const passed = scores.filter((s) => s >= 80); // number[]
const total = scores.reduce((sum, s) => sum + s, 0); // number
const first = scores.find((s) => s > 90); // number | undefined
// 잘못된 콜백 — 타입 오류 발생
const wrong = scores.map((s) => s.toUpperCase());
// 오류: Property 'toUpperCase' does not exist on type 'number'
readonly 배열 — 불변성 보장
readonly 수식어를 붙이면 배열의 내용을 변경하는 메서드(push, pop, splice, sort, reverse 등)를 호출할 수 없다. 함수가 전달받은 배열을 변경하지 않음을 타입으로 명시할 때 유용하다.
const frozen: readonly number[] = [1, 2, 3];
frozen.push(4); // 오류: Property 'push' does not exist on type 'readonly number[]'
frozen.pop(); // 오류
frozen[0] = 99; // 오류: Index signature in type 'readonly number[]' only permits reading
// 읽기는 가능
console.log(frozen[0]); // 1
console.log(frozen.length); // 3
console.log(frozen.map((n) => n * 2)); // [2, 4, 6] — 새 배열 반환이라 OK
ReadonlyArray<T>
readonly T[]와 완전히 동일하다. 긴 타입 표현에서 가독성이 조금 더 나을 수 있다.
function processItems(items: ReadonlyArray<string>): void {
// items를 읽기만 한다는 의도를 명시
items.forEach((item) => console.log(item));
// items.push("new"); // 오류
}
일반 배열과 readonly 배열의 관계
readonly 배열은 일반 배열보다 제한적이므로, 일반 배열은 readonly 배열에 할당할 수 있지만 그 반대는 불가능하다.
const mutable: number[] = [1, 2, 3];
const immutable: readonly number[] = mutable; // OK — 더 제한적인 타입으로 확대
// immutable을 mutable에 다시 할당은 불가
const mutable2: number[] = immutable;
// 오류: Type 'readonly number[]' is not assignable to type 'number[]'
// 'readonly number[]' is 'readonly' and cannot be assigned to the mutable type 'number[]'
함수 매개변수에 readonly 적용
함수가 배열을 변경하지 않는다는 계약을 타입으로 표현한다.
// 입력 배열을 변경하지 않는 순수 함수
function sum(arr: readonly number[]): number {
return arr.reduce((total, n) => total + n, 0);
}
function sortedCopy(arr: readonly string[]): string[] {
return [...arr].sort(); // 스프레드로 새 배열 생성 후 정렬
}
const original = ["banana", "apple", "cherry"];
const sorted = sortedCopy(original);
console.log(original); // ["banana", "apple", "cherry"] — 변경되지 않음
console.log(sorted); // ["apple", "banana", "cherry"]
다차원 배열
중첩 배열 타입을 선언하는 방법이다.
// 2차원 배열 — 행렬
const matrix: number[][] = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
// 또는 Array<Array<number>>
const matrix2: Array<Array<number>> = [[1, 2], [3, 4]];
// 3차원 배열
const cube: number[][][] = [
[[1, 2], [3, 4]],
[[5, 6], [7, 8]],
];
다차원 배열 실전 활용
// 체스 보드 — 8x8
type Piece = "K" | "Q" | "R" | "B" | "N" | "P" | null;
type Board = Piece[][];
function createEmptyBoard(): Board {
return Array.from({ length: 8 }, () => Array(8).fill(null) as Piece[]);
}
function getCell(board: Board, row: number, col: number): Piece {
if (row < 0 || row >= 8 || col < 0 || col >= 8) {
throw new RangeError(`유효하지 않은 좌표: (${row}, ${col})`);
}
return board[row][col];
}
// 이미지 픽셀 데이터 — [R, G, B, A][]
type Pixel = [number, number, number, number]; // 튜플로 강타입화
type ImageData = Pixel[][];
튜플(Tuple)
튜플은 각 인덱스의 타입이 고정된 배열이다. 길이도 고정된다. 위치가 의미를 갖는 데이터를 다룰 때 유용하다.
// [id, name, isActive] 형태로 고정
let user: [number, string, boolean] = [1, "Alice", true];
// 인덱스로 타입 안전하게 접근
const id: number = user[0];
const name: string = user[1];
const isActive: boolean = user[2];
// 구조 분해 할당
const [userId, userName, userActive] = user;
console.log(`${userId}: ${userName} (${userActive ? "활성" : "비활성"})`);
// 잘못된 할당 — 오류
user = ["Alice", 1, true];
// 오류: Type 'string' is not assignable to type 'number'
레이블 튜플 (Labeled Tuple — TypeScript 4.0+)
각 요소에 이름을 붙여 가독성을 높인다. 레이블은 타입 검사에 영향을 주지 않지만, IDE의 툴팁과 에러 메시지에 표시된다.
// 레이블 없는 튜플
type Point2D = [number, number];
// 레이블 있는 튜플 — 의미가 명확해진다
type Point2DLabeled = [x: number, y: number];
type Point3DLabeled = [x: number, y: number, z: number];
// 함수 반환값 튜플에 레이블 적용
type MinMax = [min: number, max: number];
function getRange(arr: readonly number[]): MinMax {
return [Math.min(...arr), Math.max(...arr)];
}
const [min, max] = getRange([3, 1, 4, 1, 5, 9, 2, 6]);
console.log(`범위: ${min} ~ ${max}`); // 범위: 1 ~ 9
선택적 튜플 요소
?로 선택적 요소를 표시한다. 선택적 요소는 반드시 끝에 위치해야 한다.
type RGB = [r: number, g: number, b: number];
type RGBA = [r: number, g: number, b: number, a?: number];
const red: RGBA = [255, 0, 0]; // a 생략 — OK
const semiRed: RGBA = [255, 0, 0, 0.5]; // a 포함 — OK
// 선택적 요소 타입은 number | undefined
function toCSS(color: RGBA): string {
const [r, g, b, a] = color;
if (a !== undefined) {
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
return `rgb(${r}, ${g}, ${b})`;
}
나머지 요소 튜플 — 가변 길이
스프레드 연산자(...)를 사용해 고정 요소와 가변 요소를 혼합할 수 있다.
// 첫 번째는 string, 나머지는 number[]
type StringThenNumbers = [first: string, ...rest: number[]];
const a: StringThenNumbers = ["score"]; // rest 없음 — OK
const b: StringThenNumbers = ["score", 10]; // rest 하나 — OK
const c: StringThenNumbers = ["score", 10, 20, 30]; // rest 여럿 — OK
// 마지막은 boolean, 앞은 가변 number[]
type NumbersThenBoolean = [...nums: number[], flag: boolean];
const d: NumbersThenBoolean = [true]; // nums 없음
const e: NumbersThenBoolean = [1, 2, 3, true]; // nums 셋
// 양쪽 고정 + 가운데 가변
type Sandwich = [bread: string, ...fillings: string[], bread2: string];
const sandwich: Sandwich = ["sourdough", "ham", "cheese", "baguette"];
나머지 요소 튜플의 실전 활용
// 함수 시그니처 타입 표현
type FunctionArgs = [callback: (...args: unknown[]) => void, delay: number, ...params: unknown[]];
// 이벤트 로그 타입
type LogEntry = [timestamp: number, level: "info" | "warn" | "error", ...messages: string[]];
function createLog(level: "info" | "warn" | "error", ...messages: string[]): LogEntry {
return [Date.now(), level, ...messages];
}
const entry = createLog("warn", "디스크 사용량 90%", "즉시 확인 요망");
const [ts, lvl, ...msgs] = entry;
console.log(`[${lvl}] ${msgs.join(" ")}`); // [warn] 디스크 사용량 90% 즉시 확인 요망
as const와 튜플 — 리터럴 타입 보존
as const를 쓰면 배열이나 튜플의 타입이 리터럴 타입으로 고정된다. 이는 특히 설정 객체나 상수 배열에서 강력하다.
// as const 없이
const point = [10, 20];
// 추론 타입: number[] — 각 요소를 number로 넓혀서 추론
// as const 사용
const pointConst = [10, 20] as const;
// 추론 타입: readonly [10, 20] — 리터럴 타입 튜플로 고정
// as const는 깊게(deep) 적용된다
const config = {
endpoints: ["api/users", "api/posts"] as const,
timeout: 5000 as const,
};
// config.endpoints의 타입: readonly ["api/users", "api/posts"]
// config.timeout의 타입: 5000
// 변경 불가
pointConst[0] = 99; // 오류: Cannot assign to '0' because it is a read-only property
as const로 함수 반환값을 튜플로 강제하기
함수에서 배열을 반환하면 TypeScript는 기본적으로 T[]로 추론한다. 반환값을 튜플로 만들려면 as const 또는 반환 타입 명시가 필요하다.
// 문제: 배열로 추론됨
function useCounterBad() {
let count = 0;
const increment = () => { count++; };
return [count, increment];
// 타입: (number | (() => void))[] — 인덱스별 타입 구분 불가
}
// 해결책 1: 반환 타입 명시
function useCounterTyped(): [count: number, increment: () => void] {
let count = 0;
const increment = () => { count++; };
return [count, increment];
}
// 해결책 2: as const (단, readonly가 붙음)
function useCounterConst() {
let count = 0;
const increment = () => { count++; };
return [count, increment] as const;
// 타입: readonly [number, () => void]
}
const [count, increment] = useCounterTyped();
// count: number, increment: () => void — 명확하게 구분됨
실전 예제 1: React useState 반환값 타입
// React의 useState 타입 시그니처를 직접 구현해보면 튜플의 의미를 이해할 수 있다
type SetState<T> = (newValue: T | ((prev: T) => T)) => void;
type UseState<T> = [state: T, setState: SetState<T>];
function useState<T>(initialValue: T): UseState<T> {
let state = initialValue;
const setState: SetState<T> = (newValue) => {
if (typeof newValue === "function") {
state = (newValue as (prev: T) => T)(state);
} else {
state = newValue;
}
};
return [state, setState];
}
// 사용
const [count, setCount] = useState(0);
// count: number, setCount: SetState<number>
const [name, setName] = useState("Alice");
// name: string, setName: SetState<string>
setCount(1);
setCount((prev) => prev + 1);
실전 예제 2: CSV 파싱
// CSV의 각 행을 타입 안전하게 파싱
type CsvRow = [id: string, name: string, score: number, passed: boolean];
function parseCsvRow(line: string): CsvRow {
const parts = line.split(",").map((s) => s.trim());
if (parts.length !== 4) {
throw new Error(`잘못된 CSV 행: ${line}`);
}
const [id, name, scoreStr, passedStr] = parts;
const score = Number(scoreStr);
if (Number.isNaN(score)) {
throw new Error(`점수가 숫자가 아닙니다: ${scoreStr}`);
}
return [id, name, score, passedStr.toLowerCase() === "true"];
}
function parseCsv(csv: string): CsvRow[] {
return csv
.split("\n")
.slice(1) // 헤더 제거
.filter((line) => line.trim().length > 0)
.map(parseCsvRow);
}
const sampleCsv = `id,name,score,passed
1,Alice,95,true
2,Bob,72,false
3,Charlie,88,true`;
const rows = parseCsv(sampleCsv);
rows.forEach(([id, name, score, passed]) => {
console.log(`${id}. ${name}: ${score}점 (${passed ? "합격" : "불합격"})`);
});
실전 예제 3: 좌표계
// 2D, 3D, 4D(동차좌표계) 좌표
type Point2D = [x: number, y: number];
type Point3D = [x: number, y: number, z: number];
type Point4D = [x: number, y: number, z: number, w: number];
function distance2D([x1, y1]: Point2D, [x2, y2]: Point2D): number {
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}
function distance3D([x1, y1, z1]: Point3D, [x2, y2, z2]: Point3D): number {
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2 + (z2 - z1) ** 2);
}
function translate3D([x, y, z]: Point3D, [dx, dy, dz]: Point3D): Point3D {
return [x + dx, y + dy, z + dz];
}
function scale3D([x, y, z]: Point3D, factor: number): Point3D {
return [x * factor, y * factor, z * factor];
}
// 경로 계산
const waypoints: Point3D[] = [
[0, 0, 0],
[3, 4, 0],
[3, 4, 5],
];
function pathLength(points: readonly Point3D[]): number {
let total = 0;
for (let i = 1; i < points.length; i++) {
total += distance3D(points[i - 1], points[i]);
}
return total;
}
console.log(`경로 길이: ${pathLength(waypoints)}`); // 10
고수 팁
팁 1: 배열 불변성을 함수 경계에서 강제하라
// 내부적으로는 mutable, 외부에 노출할 때는 readonly
class DataStore {
private _items: string[] = [];
get items(): readonly string[] {
return this._items; // readonly 뷰 반환
}
add(item: string): void {
this._items.push(item); // 내부에서는 변경 가능
}
}
팁 2: 구조 분해 시 나머지 요소로 배열 분리
const [first, second, ...rest] = [1, 2, 3, 4, 5];
// first: 1, second: 2, rest: number[]
// 첫 번째와 마지막을 분리 (ES2023 스프레드 활용)
const arr = [1, 2, 3, 4, 5];
const [head, ...tail] = arr; // head: 1, tail: [2, 3, 4, 5]
팁 3: 튜플 타입을 유틸리티 타입으로 다루기
// 튜플의 첫 번째 요소 타입 추출
type Head<T extends unknown[]> = T extends [infer H, ...unknown[]] ? H : never;
type First = Head<[string, number, boolean]>; // string
// 튜플의 나머지 요소 타입 추출
type Tail<T extends unknown[]> = T extends [unknown, ...infer R] ? R : never;
type Rest = Tail<[string, number, boolean]>; // [number, boolean]
팁 4: as const 배열을 유니온 타입으로 변환
const STATUSES = ["pending", "active", "inactive", "banned"] as const;
type Status = typeof STATUSES[number];
// Status = "pending" | "active" | "inactive" | "banned"
// 런타임에도 배열을 활용할 수 있어 Enum 대신 자주 쓰이는 패턴
function isValidStatus(value: string): value is Status {
return (STATUSES as readonly string[]).includes(value);
}
팁 5: 튜플보다 객체가 나을 때
튜플은 요소 수가 적고(2~3개), 위치가 명확할 때 유용하다. 그 이상이라면 이름 있는 객체 타입이 낫다.
// 나쁜 예: 요소가 많아 인덱스 의미를 외워야 함
type UserTuple = [number, string, string, boolean, string, number];
// 좋은 예: 명명된 프로퍼티로 의미 전달
interface UserRecord {
id: number;
firstName: string;
lastName: string;
isActive: boolean;
email: string;
age: number;
}
정리 표
| 구분 | 문법 | 특징 | 주요 용도 |
|---|---|---|---|
| 기본 배열 | T[] | 가변 길이, 단일 타입 | 일반 컬렉션 |
| 제네릭 배열 | Array<T> | 동일, 복잡한 타입에 가독성 좋음 | 유니온·함수 타입 요소 |
| readonly 배열 | readonly T[] | 변경 메서드 사용 불가 | 불변 데이터, 순수 함수 매개변수 |
| 다차원 배열 | T[][] | 중첩 배열 | 행렬, 이미지 데이터 |
| 기본 튜플 | [T1, T2] | 고정 길이, 인덱스별 타입 | useState 반환값, 키-값 쌍 |
| 레이블 튜플 | [a: T1, b: T2] | 가독성 향상 | 의미 있는 위치 데이터 |
| 선택적 튜플 | [T1, T2?] | 마지막 요소 생략 가능 | 옵션 있는 고정 구조 |
| 나머지 튜플 | [T1, ...T2[]] | 가변 길이 + 앞/뒤 고정 | 로그 항목, 함수 인수 |
| const 튜플 | [...] as const | 리터럴 타입 고정, readonly | 상수 설정, 유니온 소스 |
다음 장에서는 열거형(Enum)을 다룬다. 숫자·문자열·const enum의 차이, 트리 쉐이킹 문제와 as const 객체 대안 패턴까지 실무에서 바로 쓸 수 있는 내용을 살펴본다.