알고리즘을 처음 배울 때 배열은 여러 값을 순서대로 담는 자료구조라고 배운다.
const tracks = [
"Amazing Grace",
"Oceans",
"Goodness of God",
];
이 설명은 배열의 문법을 이해하는 데 충분하다. 하지만 실제 음악 서비스에서 플레이리스트를 만들기 시작하면 새로운 질문이 생긴다. 사용자가 곡의 순서를 바꾸면 무엇을 저장해야 할까? 세 번째 곡을 삭제해 달라는 요청에서 3은 곡을 식별하는 값일까, 지금 화면에서의 위치일까? 제목순으로 정렬한 화면은 사용자가 만든 재생 순서까지 바꿔도 될까?
배열을 서비스에서 사용한다는 것은 값을 한곳에 모으는 일에 그치지 않는다. 어떤 순서가 원본인지 정하고, 그 순서 안에서 각 항목을 어떻게 찾고 변경할지 결정하는 일이다.
플레이리스트에 같은 곡들이 들어 있어도 순서가 다르면 사용자가 경험하는 결과도 달라진다.
const morningPlaylist = [
"Quiet Morning",
"Fresh Start",
"Move Forward",
];
const workoutPlaylist = [
"Move Forward",
"Fresh Start",
"Quiet Morning",
];
두 배열은 같은 값을 가지고 있지만 같은 플레이리스트는 아니다. 첫 곡부터 차례로 재생한다면 배열의 위치가 곧 재생 흐름을 결정하기 때문이다. 이때 순서는 화면을 보기 좋게 정리한 결과가 아니라 사용자가 만든 데이터다.
그래서 플레이리스트의 순서를 바꾸는 기능은 단순한 UI 효과로 끝나지 않는다. 사용자가 세 번째 곡을 첫 번째로 옮겼다면 변경된 순서를 서버에도 저장해야 다음에 앱을 열었을 때 같은 흐름이 유지된다.
const orderedEntryIds = [
"entry-c",
"entry-a",
"entry-b",
];
await updatePlaylistOrder({
playlistId,
orderedEntryIds,
});
여기서 서버로 보내는 값은 곡 제목이나 화면의 HTML 요소가 아니라 플레이리스트 항목의 ID다. 순서가 서비스의 상태라면 그 순서를 구성하는 항목도 안정적으로 식별할 수 있어야 한다.
배열의 인덱스는 항목의 현재 위치를 알려준다. 따라서 다음 곡을 재생하는 기능처럼 위치를 기준으로 움직이는 작업에는 잘 맞는다.
function getNextEntry(entries, currentIndex) {
return entries[currentIndex + 1] ?? null;
}
문제는 인덱스를 항목의 정체성으로 사용할 때 생긴다. 사용자가 두 번째 위치에 새로운 곡을 추가하면 기존의 세 번째 곡은 네 번째 곡이 된다. 정렬하거나 삭제해도 인덱스는 달라진다. 어제의 playlist[2]와 오늘의 playlist[2]가 같은 곡이라는 보장은 없다.
다음과 같이 위치만 보내 삭제하는 API를 생각해보자.
// Bad
await deletePlaylistEntry({
playlistId,
index: 2,
});
사용자가 보고 있는 동안 다른 곡이 앞에 추가되면 2가 가리키는 항목이 달라질 수 있다. 삭제할 대상을 전달할 때는 현재 위치가 아니라 안정적인 항목 ID를 사용하는 편이 안전하다.
// Better
await deletePlaylistEntry({
playlistId,
entryId: "entry-c",
});
곡 ID와 플레이리스트 항목 ID도 구분할 필요가 있다. 사용자는 같은 곡을 한 플레이리스트에 두 번 추가할 수 있기 때문이다.
const playlistEntries = [
{
entryId: "entry-1",
trackId: "track-a",
},
{
entryId: "entry-2",
trackId: "track-b",
},
{
entryId: "entry-3",
trackId: "track-a",
},
];
첫 번째와 세 번째 항목은 같은 음악을 가리키지만 플레이리스트에서는 서로 다른 항목이다. 하나는 사용자가 직접 추가했고 다른 하나는 공동 편집자가 추가했을 수도 있다. 항목별 메모나 추가 시각도 다를 수 있다. trackId는 어떤 음악인지 알려주고, entryId는 플레이리스트에 포함된 어느 항목인지 알려주며, 인덱스는 그 항목이 지금 몇 번째에 있는지를 알려준다.
이 세 값을 분리하면 삭제나 이동 요청에서도 데이터의 의미가 흔들리지 않는다.
사용자는 자신이 만든 재생 순서를 유지하면서 화면만 제목순으로 보고 싶을 수 있다. 그런데 JavaScript의 sort()는 원본 배열을 직접 변경한다.
playlistEntries.sort((first, second) =>
first.title.localeCompare(second.title)
);
이 배열이 플레이리스트의 원본 상태라면 화면을 한 번 정렬한 것만으로 실제 재생 순서까지 달라질 수 있다. 화면에서 계산한 결과가 필요한 경우에는 원본을 복사한 뒤 정렬해야 한다.
const entriesByTitle = [
...playlistEntries,
].sort((first, second) =>
first.title.localeCompare(second.title)
);
playlistEntries에는 사용자가 저장한 재생 순서가 남고, entriesByTitle은 현재 화면을 위한 결과로만 사용된다. 셔플도 같은 관점에서 볼 수 있다. 셔플된 배열은 원본 플레이리스트를 대신하는 값이 아니라 한 번의 재생 세션에서 사용할 임시 순서다.
const playbackSession = {
playlistId,
orderedEntryIds: shuffle(
playlistEntries.map((entry) => entry.entryId)
),
currentIndex: 0,
};
저장된 순서, 화면에서 정렬한 순서, 셔플한 순서는 모두 배열로 표현할 수 있다. 형태가 같다고 역할까지 같은 것은 아니다. 어느 배열이 원본이고 어느 배열이 계산된 결과인지 이름과 저장 위치에서 드러나야 한다.
플레이리스트 화면에서는 항목을 처음부터 끝까지 방문하는 작업이 많다. 화면에 필요한 형태로 바꾸고, 재생할 수 없는 곡을 제외하고, 전체 재생 시간을 계산한다.
const playableEntries =
playlistEntries.filter(isPlayable);
const responseItems =
playableEntries.map(toPlaylistResponse);
const totalDurationSeconds =
playableEntries.reduce(
(total, entry) =>
total + entry.durationSeconds,
0
);
이 코드는 배열의 순서를 따라 각 항목을 한 번씩 처리한다. 플레이리스트처럼 순차 재생과 전체 변환이 주요 작업인 데이터에는 배열이 자연스럽다. 현재 인덱스를 알고 있을 때 이전 곡이나 다음 곡을 바로 찾을 수 있다는 장점도 있다.
반면 항목 ID로 특정 곡을 계속 찾아야 한다면 매번 find()를 실행하는 방식이 불편해질 수 있다. 작은 플레이리스트에서 몇 번 검색하는 정도라면 그대로 두는 편이 읽기 쉽다. 하지만 수천 개의 항목을 같은 ID로 반복해서 조회한다면 Map을 함께 만들어 조회 책임을 나누는 선택을 검토할 수 있다.
const entryById = new Map(
playlistEntries.map((entry) => [
entry.entryId,
entry,
])
);
const currentEntry =
entryById.get(currentEntryId);
이때 배열을 버리는 것은 아니다. 재생 순서는 배열이 맡고, ID 검색은 Map이 맡는다. 자료구조 하나로 모든 작업을 해결하려 하기보다 서비스에서 자주 실행되는 작업에 맞춰 책임을 나눈 것이다.
데이터가 아주 크다면 전체 플레이리스트를 한 번에 배열로 가져오는 방식도 다시 생각해야 한다. 화면에 처음 50곡만 보인다면 서버 역시 필요한 구간만 반환하면 된다. 애플리케이션에서 배열을 쓴다는 이유로 데이터베이스의 모든 행을 한 번에 메모리에 올릴 필요는 없다.
플레이리스트에 배열이 잘 맞는 이유는 곡이 여러 개이기 때문만은 아니다. 사용자가 만든 순서가 중요하고, 대부분의 작업이 그 순서를 따라 진행되기 때문이다. 배열을 설계할 때는 다음 정도를 먼저 확인하면 된다.
배열을 잘 사용한다는 것은 map, filter, reduce를 많이 아는 것과 다르다. 어떤 순서를 보존해야 하는지, 위치가 바뀌어도 같은 항목을 어떻게 찾을지, 계산된 결과가 원본을 변경해도 되는지를 판단할 수 있어야 한다.
실제 서비스에서 배열은 여러 값의 묶음이 아니라 순서가 있는 상태를 다루는 방법이다.
다음 글에서는 연결 리스트가 중간 삽입에 유리하다는 설명만으로 실제 서비스의 저장 구조를 결정하기 어려운 이유를 살펴본다.