Trie: 같은 앞부분은 같은 길로 읽습니다
작은 단어 목록으로 Trie의 공유 경로와 단어 끝을 배웁니다. 접두사 개수와 삭제 때 보존할 연결을 상태 변화로 따라갑니다.
먼저 작은 예제로 원리를 익히고, 마지막에 전체 구현을 펼쳐 보세요. 앞쪽 예제는 각각 독립적으로 브라우저 개발자 도구 Console에서 실행합니다. 같은 이름을 다시 선언했다는 오류가 나오면 새로고침 후 해당 예제를 실행하세요. 출력 뒤에 콘솔이 별도로 보여주는 undefined는 마지막 명령의 반환값일 수 있습니다.

1. “가방”으로 시작하는 이름을 어떻게 모을까요?
전시관에 “가”, “가방”, “가방끈”이라는 별칭이 있다고 해 보겠습니다. “가방”이라는 이름이 있는지 묻는 것과 “가방으로 시작하는 이름이 몇 개인지” 묻는 것은 다릅니다. 전자는 “가방” 하나의 등록 여부이고, 후자는 “가방”과 “가방끈”을 함께 세는 질문입니다.
이름들을 글자별 갈림길에 놓으면 공통 앞부분을 같이 사용할 수 있습니다. 시작점에서 가 → 방으로 두 번 이동하면 “가방으로 시작하는 이름”의 위치에 도착합니다. 이처럼 앞부분을 공유하는 길을 만드는 구조가 Trie(트라이)입니다. 처음부터 모든 메서드를 외우지 말고 길을 따라가는 모습부터 보겠습니다.
| 등록할 이름 | 시작점에서 따라갈 길 | 정확히 끝나는 위치 |
|---|---|---|
| 가 | 시작 → 가 | 가 뒤 |
| 가방 | 시작 → 가 → 방 | 방 뒤 |
| 가방끈 | 시작 → 가 → 방 → 끈 | 끈 뒤 |
“가”로 가는 길은 세 벌 만들지 않습니다. 시작점의 같은 갈림길을 공유합니다. “가방”의 방 역시 같은 위치를 쓰며, 더 긴 이름만 끈이라는 다음 길을 추가합니다.
짧은 예제는 각각 독립적으로 실행할 수 있습니다. 브라우저 개발자 도구의 Console에 한 블록 전체를 붙여 넣거나, 파일로 저장해 Node.js로 실행하세요. 다른 예제를 먼저 실행할 필요는 없습니다. 콘솔에서 같은 변수 이름을 다시 선언했다는 오류가 나오면 새로고침한 뒤 해당 예제를 실행하세요. 바로 아래 상자는 console.log로 출력되는 값입니다.
2. Map 두 개로 갈림길만 만들어 봅니다
Map은 이름표로 값을 찾는 도구였습니다. 여기서는 글자를 이름표로 사용하고 그 글자를 지난 다음 갈림길을 값에 저장합니다. 첫 예제는 길이 어떻게 연결되는지만 보여 주며 아직 “단어의 끝”이나 개수는 저장하지 않습니다.
const start = new Map();
const afterGa = new Map();
start.set("가", afterGa);
afterGa.set("방", new Map());
console.log(start.has("가"));
console.log(start.get("가").has("방"));
true
true
첫 두 줄에서 빈 갈림길 두 개를 만듭니다. start.set(“가”, afterGa)는 시작점에서 가를 따라가면 afterGa로 가라는 연결입니다. 다음 줄은 그 위치에서 방을 따라갈 길을 추가합니다. Map 안에 문자열 다음 글자를 적는 것이 아니라 다음 Map 객체를 연결한다는 점이 중요합니다.
start.get(“가”).has(“방”)은 한 번에 읽지 말고 둘로 나누세요. 먼저 get(“가”)로 가를 지난 위치에 도착하고, 그곳에서 has(“방”)으로 방이라는 다음 길이 있는지 묻습니다. 두 출력은 길의 존재만 말합니다. “가”라는 이름 자체가 등록되어 있는지는 아직 알 수 없습니다.
3. 길의 존재와 단어의 끝은 다릅니다
“가방”만 등록했어도 중간의 가 길은 존재합니다. 그렇다고 “가”도 등록된 이름이라고 답하면 틀립니다. 그래서 각 위치에 end라는 끝 표시를 붙입니다. end=true는 “여기까지가 등록된 이름 하나”라는 뜻입니다. false여도 더 긴 이름으로 이어지는 길은 남아 있을 수 있습니다.
이제 “가”와 “가방” 두 이름을 등록한 상태를 보겠습니다. pass는 이 위치를 거쳐 등록된 이름의 수입니다. 해당 위치에서 끝나는 이름도 포함합니다. root는 아무 글자도 읽기 전인 시작점이며 모든 이름이 여기서 출발하므로 root.pass는 전체 이름 수입니다.
| 위치 | end | pass | 무엇을 세었나 |
|---|---|---|---|
| root: 시작점 | false | 2 | 가, 가방 |
| 가를 지난 위치 | true | 2 | 가, 가방 |
| 방까지 지난 위치 | true | 1 | 가방 |
const bang = { end: true, pass: 1, children: new Map() };
const ga = { end: true, pass: 2, children: new Map([["방", bang]]) };
const root = { end: false, pass: 2, children: new Map([["가", ga]]) };
console.log(root.pass);
console.log(ga.end);
console.log(ga.pass);
console.log(bang.pass);
2
true
2
1
이 코드는 표에 적힌 완성 상태를 작은 객체 세 개로 직접 만든 관찰용 예제입니다. 아직 insert 함수가 알아서 개수를 센 것은 아닙니다. children에는 글자별 다음 객체, end에는 정확한 이름의 끝, pass에는 위 표의 개수를 넣었습니다. new Map([[“방”, bang]])은 방이라는 이름표에 bang 객체를 미리 넣어 Map을 만드는 문법입니다.
출력 2, true, 2, 1은 각각 전체 이름 두 개, “가” 자체도 등록됨, 가로 시작하는 이름 두 개, 가방으로 시작하는 이름 한 개를 뜻합니다. 같은 pass 숫자를 보더라도 어느 위치에서 읽었는지에 따라 질문이 달라집니다.
새 이름 “가방끈”을 넣는다면 root → 가 → 방 → 끈까지 지난 모든 위치의 pass를 하나씩 올려야 합니다. 결과는 3, 3, 2, 1입니다. 반면 “가”를 다시 등록하면 이미 ga.end가 true이므로 아무 개수도 늘리지 않습니다. 이 글은 등장 횟수가 아니라 서로 다른 이름의 수를 저장합니다.
4. 짧은 이름을 지워도 긴 이름의 길은 남깁니다
이번에는 “가”, “가방” 상태에서 “가”만 지웁니다. 가 위치의 end를 false로 바꾸고, 지운 이름이 지나간 root와 가의 pass를 하나씩 줄입니다. 가방은 방 위치까지 가는 별개 이름이므로 방의 end와 pass는 그대로 둡니다.
| 상태 | root.pass | 가의 end / pass | 방의 end / pass |
|---|---|---|---|
| 삭제 전 | 2 | true / 2 | true / 1 |
| 가 하나 삭제 후 | 1 | false / 1 | true / 1 |
| 이어서 가방도 삭제 후 | 0 | false / 0 → 길 제거 | false / 0 → 길 제거 |
const bang = { end: true, pass: 1, children: new Map() };
const ga = { end: true, pass: 2, children: new Map([["방", bang]]) };
const root = { end: false, pass: 2, children: new Map([["가", ga]]) };
ga.end = false;
for (const node of [root, ga]) {
node.pass -= 1;
}
if (ga.pass === 0) root.children.delete("가");
console.log(root.pass);
console.log(ga.end);
console.log(root.children.get("가").children.has("방"));
1
false
true
ga.end = false는 이름의 끝 표시만 없앱니다. for문은 [root, ga]의 두 객체를 하나씩 꺼내 pass를 줄입니다. 방은 이 배열에 넣지 않았으므로 바뀌지 않습니다. 마지막 if는 가 경로를 쓰는 이름이 하나도 없을 때에만 시작점에서 그 길을 지웁니다.
여기서는 ga.pass가 1이어서 길을 지우지 않습니다. 그래서 출력은 전체 이름 수 1, “가” 등록 여부 false, 방으로 이어지는 길 true입니다. root.children.delete(“가”)를 조건 없이 실행하면 아직 필요한 “가방”까지 찾지 못하게 됩니다.
나중에 “가방”도 지우면 방과 가의 pass가 모두 0이 됩니다. 그때는 끝쪽 방부터 거꾸로 올라오며 안 쓰는 길만 정리합니다. 중간에 pass가 남은 위치를 만나면 더 올라가 지울 필요가 없습니다. 다른 이름이 그 공통 길을 쓰고 있기 때문입니다.
5. 빈 이름과 중복도 같은 원리로 생각합니다
“가”, “가방”이 있는 상태에서 “가방”을 한 번 더 넣고, 그다음 “가”를 지웠습니다. 전체 개수와 가로 시작하는 개수는 얼마일까요?
답과 이유 확인하기
둘 다 1입니다. 중복 등록은 아무것도 바꾸지 않습니다. “가” 삭제 뒤에는 “가방” 하나만 남으므로 root.pass와 가 위치의 pass가 모두 1입니다. 가 위치의 end만 false가 됩니다.
빈 문자열 “”은 글자를 하나도 따라가지 않는 이름입니다. 따라서 그 끝은 root 자신입니다. 빈 이름을 새로 넣으면 root.end를 true로 바꾸고 root.pass만 하나 올립니다. 빈 접두사로 조회하면 시작점의 pass, 즉 등록된 모든 이름의 수가 답입니다. 등록된 이름이 전혀 없을 때는 그 수도 0입니다.
6. 이제 전체 구현에서 어느 부분을 읽을까요?
아래 전체 구현은 손으로 만든 객체를 TrieNode로 만들고, 임의 길이 문자열에도 같은 작업을 반복합니다. walk는 글자를 따라 이동하기, insert는 새 길과 끝 표시 만들기, countPrefix는 도착 위치의 pass 읽기, delete는 사용하지 않는 길 정리하기입니다. 먼저 walk와 has부터 읽고, 그다음 insert와 delete를 읽어 보세요.
전체 코드의 path에는 root부터 도착 위치까지의 객체를 담습니다. 위에서 [root, ga]라고 직접 적은 것을 반복문이 만들어 주는 것입니다. 중복 등록이나 없는 이름 삭제는 pass를 바꾸기 전에 확인합니다. 바로 이 순서가 다른 이름의 개수를 지키는 장치입니다.
앞의 첫 실험은 Map만으로 길을 보였고 나머지는 두 이름의 상태를 직접 적었습니다. 모두 범용 Trie의 대체 구현은 아닙니다. 전체 버전은 문자열을 코드 포인트 단위로 나누고 자동 정규화하지 않습니다. 눈에 비슷하게 보여도 완성형 é와 e 뒤에 결합 악센트를 붙인 표현은 별개 키가 될 수 있습니다. 자동완성 목록 나열이나 인기순 정렬은 구현하지 않습니다. 우선 길·끝·개수를 구별한 뒤 아래의 자세한 계약을 읽으면 됩니다.
관련 코딩테스트 문제로 이어서 연습합니다
이 자료구조의 블로그 연습 문제와 해설에서 배운 동작을 적용해 보세요. 먼저 작은 예시를 직접 처리한 뒤 입력 전체를 다루는 코드로 확장하면 됩니다. 아래 전체 구현은 연결된 문제의 기존 메서드와 반환 형식을 유지합니다.
전체 구현 · 상세 설명 · 예외와 성능 분석 펼치기
여기부터는 필요한 기능을 골라 읽는 참고 영역입니다. 새로운 파일로 전체 코드를 실행할 때는 아래 Node.js 실행 안내를 따르세요. 앞쪽의 작은 브라우저 실습과 전체 파일을 한 콘솔에 이어 붙이지 마세요. 기능별 입력 조건과 반환 형식은 아래 설명을 기준으로 합니다.
문제 상황과 API 계약
별칭 “가”, “가방”, “가방끈”을 Set에 저장하면 정확한 이름 조회는 간단합니다. 하지만 “가방으로 시작하는 별칭이 몇 개인가?”를 매번 묻는다면 모든 문자열을 훑어야 합니다. Trie는 공통 접두사를 한 경로로 묶어 접두사 끝 노드에서 답을 얻습니다. 빈번한 접두사 질의가 이 구조를 선택하는 이유입니다.
이 구현은 중복을 저장하지 않는 문자열 집합입니다. insert는 새 단어일 때 true, delete는 실제 삭제했을 때 true입니다. has는 단어 전체가 등록되었는지, countPrefix는 해당 접두사로 시작하는 서로 다른 단어 수를 반환합니다. startsWith는 그 수가 0보다 큰지 확인합니다. 비어 있는 Trie에서 startsWith("")는 false라는 계약을 정했습니다.
문자열은 […word]로 순회합니다. 이는 UTF-16 코드 유닛 인덱싱 대신 JavaScript 문자열 반복자의 코드포인트 단위를 사용합니다. 😀는 한 간선이지만 가족 이모지나 결합 문자는 여러 간선일 수 있습니다. 사용자에게 보이는 한 글자와 동일하지 않으며 NFC 정규화도 하지 않습니다. 완성형 é와 e 뒤에 결합 악센트를 붙인 표현은 별개 키이고 대소문자도 구분합니다. 고립된 surrogate가 들어온 비정상 문자열을 별도로 거부하는 기능은 없습니다.
정확성을 지키는 불변식
각 노드의 children은 다음 코드포인트에서 자식 노드로 가는 Map입니다. end는 “여기에서 정확히 끝나는 단어가 있는가”를 나타냅니다. pass는 이 노드 아래에서 끝나는 서로 다른 단어 수이며, 해당 노드의 end가 true인 경우 자신도 포함합니다. root.pass는 전체 단어 수입니다.
insert는 먼저 경로를 만들고 마지막 노드의 end를 확인합니다. 이미 true이면 어떤 pass도 증가시키지 않습니다. 새 단어이면 root부터 끝 노드까지 pass를 1 올립니다. 빈 문자열은 간선을 만들지 않고 root.end만 바꾸므로 별도의 특수 노드를 만들 필요가 없습니다.
delete는 끝까지 경로가 있고 end도 true인 경우에만 변경합니다. end를 끄고 경로의 pass를 1 줄인 뒤 아래에서 위로 pass===0인 자식만 제거합니다. 짧은 단어를 지웠다고 children 전체를 지우면 “가” 삭제가 “가방”까지 날리는 오류가 생깁니다. 반대로 빈 경로를 계속 남기면 등록·삭제가 반복될수록 쓸모없는 노드가 쌓입니다.
전체 구현과 실행
Node.js 24의 CommonJS 환경을 기준으로 합니다. 아래 전체 코드를 trie.cjs로 저장하고 파일이 있는 폴더에서 node trie.cjs를 실행하세요. 외부 패키지는 필요하지 않습니다. module.exports는 다른 파일에서 가져올 때 사용하며 require.main 조건 안의 호출은 직접 실행할 때만 작동합니다.
class TrieNode {
constructor() {
this.children = new Map();
this.end = false;
this.pass = 0;
}
}
class Trie {
constructor() { this.root = new TrieNode(); }
chars(word) {
if (typeof word !== 'string') throw new TypeError('문자열이 필요합니다.');
return [...word];
}
walk(word) {
let node = this.root;
for (const ch of this.chars(word)) {
node = node.children.get(ch);
if (!node) return null;
}
return node;
}
insert(word) {
let node = this.root;
const path = [node];
for (const ch of this.chars(word)) {
if (!node.children.has(ch)) node.children.set(ch, new TrieNode());
node = node.children.get(ch);
path.push(node);
}
if (node.end) return false;
node.end = true;
for (const item of path) item.pass++;
return true;
}
has(word) { return this.walk(word)?.end ?? false; }
countPrefix(prefix) { return this.walk(prefix)?.pass ?? 0; }
startsWith(prefix) { return this.countPrefix(prefix) > 0; }
delete(word) {
const chars = this.chars(word);
let node = this.root;
const path = [node];
for (const ch of chars) {
node = node.children.get(ch);
if (!node) return false;
path.push(node);
}
if (!node.end) return false;
node.end = false;
for (const item of path) item.pass--;
for (let i = chars.length - 1; i >= 0; i--) {
if (path[i + 1].pass !== 0) break;
path[i].children.delete(chars[i]);
}
return true;
}
}
module.exports = { Trie };
if (require.main === module) {
const t = new Trie();
['가', '가방', '가', '😀빛'].forEach(word => t.insert(word));
t.delete('가');
console.log(JSON.stringify({ exact: t.has('가'), prefix: t.countPrefix('가'), emoji: t.has('😀빛') }));
}
직접 실행 출력:
{"exact":false,"prefix":1,"emoji":true}
walk는 정확 검색과 접두사 집계가 공유하는 경로 탐색입니다. 중간 자식이 없으면 null을 반환합니다. 경로가 있다고 단어가 등록된 것은 아니므로 has는 반드시 end를 읽고, countPrefix는 pass를 읽습니다.
삽입과 삭제에서 path 배열을 유지하면 재귀 없이 조상의 집계값을 한 번씩 갱신할 수 있습니다. 삭제 때 chars[i]는 path[i]가 가진 간선 이름이고 path[i+1]이 그 간선의 자식입니다. 인덱스를 뒤집어 부모의 pass로 제거 여부를 판단하면 다른 단어의 경로를 지울 수 있습니다.
예제에서 “가”를 지워도 “가방”은 남으므로 exact는 false, prefix는 1입니다. 이모지로 시작하는 문자열도 같은 순회 규칙으로 들어갔으므로 emoji는 true입니다. 접두사 결과 나열이나 자동완성 순위 정렬은 구현 범위에 포함하지 않았습니다.
상태 변화 따라가기
| 작업 | 가 노드 end / pass | 방 노드 end / pass | 의미 |
|---|---|---|---|
| insert("가") | true / 1 | 없음 | 가 자체가 단어 |
| insert("가방") | true / 2 | true / 1 | 공유 경로에 두 단어 |
| insert("가") 재요청 | true / 2 | true / 1 | 중복은 증가하지 않음 |
| delete("가") | false / 1 | true / 1 | 가방 경로 보존 |
| delete("가방") | 노드 제거 | 노드 제거 | pass 0인 경로만 정리 |
| insert("") | root.end=true | 해당 없음 | 빈 단어도 집합 원소 |
복잡도와 적용하지 말아야 할 경우
| 작업 | 통상적 시간 | 공간 / 엄밀한 전제 |
|---|---|---|
| insert / has / countPrefix / delete | O(L) | L은 코드포인트 수, Map 접근 기대 O(1) 가정 |
| chars와 path 작업 공간 | O(L) | 문자 배열을 먼저 만드는 이 구현의 비용 |
| 전체 저장 | O(S+1) | S는 저장 단어들의 코드포인트 길이 합의 상한 |
| Map 최악 시간 | ECMAScript는 최악 O(1) 보장 안 함 | 노드별 Map 구현 비용이 경로 비용에 곱해짐 |
Map 접근이 노드당 M 시간이라면 경로 연산은 O(L·M)입니다. 해시 테이블을 기대하고 쓰는 O(L)을 JavaScript 명세의 최악 시간 보장으로 바꾸면 안 됩니다. 알파벳이 고정되어 작다면 고정 배열 자식으로 더 명확한 시간 상한을 얻을 수 있지만 메모리 사용은 달라집니다.
정확한 문자열 포함 여부만 필요하면 Set이 더 간단합니다. 접두사 목록이 정적이라면 정렬 배열에서 접두사 범위를 이진 탐색하는 대안도 있습니다. 문자열이 길고 공통 접두사가 드물면 Trie의 노드 객체와 Map 오버헤드가 커집니다.
텍스트 정규화, 검색 언어의 대소문자 규칙, 사용자가 보는 글자 단위 분할이 요구되면 입력 정책을 먼저 정해야 합니다. 문자열을 일부에서만 normalize하면 삽입한 키를 삭제하지 못합니다. 이 코드의 pass는 빈 문자열도 포함한 집합 개수이며 출현 빈도 카운터가 아닙니다.
연습으로 확인하기
등록과 삭제가 섞인 별칭 목록에서 접두사별 서로 다른 항목 수를 반환해 보세요. 전체 목록을 나열하지 않고 pass만 읽는 이유를 확인할 수 있습니다.
LeetCode 208 – Implement Trie (Prefix Tree) — 기본 삽입·정확 검색·접두사 검색을 연습하는 공식 문제입니다. 여기의 빈 문자열·Unicode·삭제 계약은 그 문제와 별도로 정한 확장입니다.
공식 자료
공식 자료 확인일: 2026년 9월 10일. 구현은 이 글의 JavaScript 계약에 맞춰 독립적으로 작성했습니다. 다른 언어 라이브러리의 API와 숫자 범위가 그대로 적용되는 것은 아닙니다.
직접 실습: 연산 비용으로 구조를 설명합니다
실습 주제: JavaScript Trie: Unicode 접두사 검색과 안전한 삭제 구현
- 본문 구현에서 저장되는 값과 연결 관계를 그림으로 적습니다.
- 조회·삽입·삭제 중 이 구조가 가장 자주 수행할 연산을 고릅니다.
- 연산 전후에도 유지되어야 하는 규칙을 한 문장으로 적습니다.
- 배열이나 Map 같은 다른 구조로 바꿨을 때 시간·공간 비용을 비교합니다.
풀이 기준과 확인 결과
메서드 이름만 외우지 말고 한 번의 연산에서 어떤 값과 연결이 바뀌는지 추적하세요. 빈 구조, 원소 한 개, 중복값, 연속 삽입·삭제를 실행했을 때 본문이 설명한 불변식이 유지되면 성공입니다.
테스트 체크리스트
- 빈 구조에 대한 조회·삭제 처리
- 첫 원소와 마지막 원소 변경
- 중복값 또는 동일 우선순위 처리
- 입력 크기가 커졌을 때 예상 복잡도 유지
이 글이 도움이 되었나요?
자료구조 학습 순서
필수 18개 · 전체 18개
읽음 기록 관리
전체 과정 목차 (18개)
- 필수 학습 · 자료구조 선택 가이드: 연산 비용으로 배열·스택·큐·Set 고르기
- 필수 학습 · JavaScript 배열: 인덱스 조회와 삽입·삭제 비용
- 필수 학습 · JavaScript Map·Set: 값 조회와 중복 제거 실습
- 필수 학습 · 자료구조 스택 쉽게 이해하기: push pop으로 문제 풀이 감 잡기
- 필수 학습 · 큐와 FIFO: head 인덱스로 JavaScript 대기열 만들기
- 필수 학습 · 단방향 연결 리스트: head·tail 삽입과 삭제
- 필수 학습 · JavaScript 원형 덱 구현: 양끝 삽입·삭제와 고정 용량 버퍼
- 필수 학습 · JavaScript 문자열 해시 테이블 구현: 충돌 처리와 리사이즈, NFC 정규화
- 필수 학습 · 트리 자료구조 차이: 이진 트리 BST MST 구분하기
- 필수 학습 · JavaScript 이진 탐색 트리 구현: 중복 키와 세 가지 삭제 처리
- 필수 학습 · JavaScript 최소 힙 구현: 우선순위 큐의 push·pop과 비교 함수
- 필수 학습 · JavaScript 그래프 구현: 인접 리스트·인접 행렬 비교와 BFS
- 필수 학습 · JavaScript Union-Find: 경로 압축과 크기 합치기로 연결 상태 관리하기
- 필수 학습 · JavaScript Trie: Unicode 접두사 검색과 안전한 삭제 구현 현재 글
- 필수 학습 · JavaScript Fenwick Tree: lowbit로 구간 합과 단일 증가 갱신 구현
- 필수 학습 · JavaScript 반복형 세그먼트 트리: 구간 합·단일 대입·결합 순서
- 필수 학습 · JavaScript LRU 캐시: Map과 이중 연결 리스트의 불변식
- 필수 학습 · JavaScript AVL 트리: 높이 불변식과 LL·RR·LR·RL 삽입 회전
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.