JavaScript 그래프 구현: 인접 리스트·인접 행렬 비교와 BFS

2026.09.10·수정 2026.09.13·약 21분·작성: 해비·블로그 소개

그래프: 이웃을 적고, 발견한 곳을 큐에 예약합니다

네 장소의 연결로 인접 리스트와 행렬을 비교합니다. 큐와 방문 표시가 바뀌는 표를 따라가며 BFS를 구현하는 이유를 배웁니다.

먼저 작은 예제로 원리를 익히고, 마지막에 전체 구현을 펼쳐 보세요. 앞쪽 예제는 각각 독립적으로 브라우저 개발자 도구 Console에서 실행합니다. 같은 이름을 다시 선언했다는 오류가 나오면 새로고침 후 해당 예제를 실행하세요. 출력 뒤에 콘솔이 별도로 보여주는 undefined는 마지막 명령의 반환값일 수 있습니다.

그래프 표현의 핵심 구조를 표현한 개념 표지
구조의 특징을 표현한 개념 이미지입니다. 정확한 동작은 아래 코드와 추적 표를 함께 확인하세요.

1. 세 장소 사이에 길을 표시해 봅니다

A, B, C 세 장소가 있고 A-B, A-C, B-C 사이를 양방향으로 오갈 수 있다고 합시다. 또 D는 다른 장소와 연결되지 않았습니다. 장소 하나를 정점, 장소 사이의 길을 간선이라고 부릅니다. 이런 연결 관계를 저장하는 것이 그래프입니다.

장소 한 번에 갈 수 있는 이웃
A B, C
B A, C
C A, B
D 없음

각 장소 옆에 이웃 목록을 적은 위 표를 인접 리스트라고 합니다. ‘인접’은 바로 연결되어 있다는 뜻입니다. A에서 D로는 갈 수 없지만 D도 그래프에 포함된 장소입니다. 길이 없는 장소를 목록에서 지워 버리면 전체 장소 수를 셀 때 빠뜨리게 됩니다.

2. 이웃 목록을 Map에 넣습니다

Map의 키는 장소 이름, 값은 이웃을 담은 배열입니다. 배열·Map·큐가 처음이라면 초급 글을 먼저 읽고 돌아오세요. 아래 예제는 각각 브라우저 Console에서 독립적으로 실행됩니다.

const neighbors = new Map();
neighbors.set('A', ['B', 'C']);
neighbors.set('B', ['A', 'C']);
neighbors.set('C', ['A', 'B']);
neighbors.set('D', []);
console.log(neighbors.get('A').join(', '));
console.log(neighbors.get('D').length);
B, C
0

get(‘A’)는 A의 이웃 배열을 가져옵니다. D에는 빈 배열을 넣었으므로 이웃 수가 0입니다. 양방향 길 A-B는 A 목록의 B와 B 목록의 A 두 곳에 나타납니다. 한쪽 목록에만 넣으면 반대쪽에서 길을 찾을 수 없습니다.

3. 같은 관계를 표의 칸으로도 저장할 수 있습니다

행은 출발 장소, 열은 도착 장소로 정하고, 바로 연결되면 1 아니면 0을 적어 보겠습니다. 이것이 인접 행렬입니다. 이름은 달라도 앞서 적은 길과 동일합니다.

출발 → 도착 A B C D
A 0 1 1 0
B 1 0 1 0
C 1 1 0 0
D 0 0 0 0
const matrix = [
  [0, 1, 1, 0],
  [1, 0, 1, 0],
  [1, 1, 0, 0],
  [0, 0, 0, 0]
];
console.log(matrix[0][1]);
console.log(matrix[0][3]);
1
0

A, B, C, D를 배열 번호 0, 1, 2, 3에 대응시켰습니다. matrix[0][1]은 A에서 B로 가는 칸이므로 1이고, matrix[0][3]은 A에서 D로 가는 칸이므로 0입니다. 이름과 번호의 대응을 바꾸면 같은 숫자 표도 뜻이 달라지므로 그 순서를 함께 기억해야 합니다.

행렬은 특정 두 장소가 이어졌는지 한 칸으로 확인하기 편합니다. 대신 장소가 4개면 16칸, 100개면 10,000칸이 필요합니다. 실제 길이 적어도 빈 칸을 많이 만듭니다. 이웃을 하나씩 읽는 작업은 앞의 인접 리스트가 편할 수 있습니다. 어느 표현이 항상 정답인 것은 아닙니다.

4. A에서 갈 수 있는 곳을 가까운 층부터 찾습니다

A에서 출발해 바로 갈 수 있는 B와 C를 먼저 확인하고, 그다음 그들의 이웃을 확인하려고 합니다. 새로 발견한 장소를 큐 뒤에 넣으면 먼저 발견한 장소부터 꺼낼 수 있습니다. 이것이 너비 우선 탐색, BFS입니다. 여기서 가까움은 비용이 아니라 지나가는 길의 개수 기준입니다.

A-B-C에는 돌고 돌아 A로 오는 길이 있습니다. 따라서 어디를 이미 발견했는지 따로 적어야 합니다. visited는 이미 끝낸 곳뿐 아니라 큐에 넣어서 방문을 예약한 곳도 포함합니다.

현재 꺼낸 장소 이번에 확인한 이웃 앞으로 처리할 큐 발견한 곳 visited
시작 전 A를 먼저 예약 A A
A B, C를 새로 발견 B, C A, B, C
B A, C는 이미 발견 C A, B, C
C A, B는 이미 발견 비어 있음 A, B, C

B를 읽을 때 C는 아직 큐에서 기다리고 있습니다. 그래도 발견 표에 C를 미리 적었기 때문에 C를 두 번 넣지 않습니다. 표시를 뒤로 미루면 같은 장소를 여러 번 예약할 수 있습니다.

5. 큐와 발견 표시를 함께 움직입니다

이제 앞서 만든 이웃 목록에 큐와 발견 표를 붙입니다. 첫 네 줄의 new Map([…])은 2절의 네 번의 set을 한 번에 적은 같은 데이터입니다. 중첩 배열 문법 자체가 BFS의 원리는 아닙니다. 먼저 그 부분을 ‘앞에서 만든 neighbors’라고 읽고, queue부터 한 줄씩 따라가세요. Set은 이름을 중복 없이 적는 발견 표이며, head는 큐에서 다음에 읽을 칸입니다.

const neighbors = new Map([
  ['A', ['B', 'C']], ['B', ['A', 'C']],
  ['C', ['A', 'B']], ['D', []]
]);
const queue = ['A'];
const visited = new Set(['A']);
let head = 0;
while (head < queue.length) {
  const current = queue[head];
  head += 1;
  for (const next of neighbors.get(current)) {
    if (visited.has(next)) continue;
    visited.add(next);
    queue.push(next);
  }
}
console.log(queue.join(', '));
console.log(visited.has('D'));
A, B, C
false

new Map 안의 각 두 칸 배열은 [장소, 이웃 배열] 한 쌍입니다. 처음에는 A를 queue와 visited 두 곳에 넣습니다. head는 다음에 처리할 배열 위치입니다. 값을 읽은 뒤 하나 늘리므로 이미 읽은 장소를 다시 처리하지 않습니다.

안쪽 for문은 현재 장소의 이웃을 하나씩 읽습니다. visited.has(next)가 참이면 continue로 이번 이웃만 건너뜁니다. 처음 본 곳이면 발견 표시를 하고 큐 뒤에 추가합니다. 이 두 줄은 서로 다른 일입니다. Set은 중복 예약을 막고, 큐는 처리할 순서를 정합니다.

이 구현은 처리한 장소도 queue 배열에 남깁니다. 실제 대기 부분은 head부터 끝까지지만 전체 배열은 발견 순서 기록이 됩니다. 그래서 마지막에 queue를 출력할 수 있습니다. 일반적인 ‘항목을 실제로 삭제하는 큐’와 표현 방식이 다를 뿐 순서는 같습니다.

확인 문제: A의 이웃 순서가 C, B로 바뀌면 결과는 어떻게 될까요? D도 방문할까요?

정답과 이유 보기

A, C, B가 됩니다. 같은 층에서는 이웃을 읽은 순서가 큐 순서에 영향을 줍니다. D는 여전히 방문하지 않습니다. 출발점 A와 연결된 길이 없기 때문입니다. 전체 연결 구역을 찾으려면 아직 발견하지 않은 D에서 다시 시작하는 바깥 반복이 필요합니다.

6. 이제 전체 구현에서 필요한 부분만 연결합니다

아래 참고용 Graph 클래스에서는 addVertex가 장소 목록, addEdge가 양쪽 이웃 추가, bfs가 방금 따라간 방문 순서를 담당합니다. toMatrix는 이미 저장된 리스트를 행렬로 옮기는 별도 기능입니다. 먼저 addEdge와 bfs를 읽고, 행렬 변환은 다음 단계에 살펴보세요.

위 실습은 모든 이웃이 등록된 작은 무방향 그래프를 사용했습니다. 방향 그래프·중복 간선·자기 자신으로의 연결·없는 출발점 처리는 아래 확장 구현에서 확인합니다. 이 BFS 출력은 방문 순서이지 거리 배열이나 실제 경로는 아닙니다.

관련 코딩테스트 문제로 이어서 연습합니다

이 자료구조의 블로그 연습 문제와 해설에서 배운 동작을 적용해 보세요. 먼저 작은 예시를 직접 처리한 뒤 입력 전체를 다루는 코드로 확장하면 됩니다. 아래 전체 구현은 연결된 문제의 기존 메서드와 반환 형식을 유지합니다.

전체 구현 · 상세 설명 · 예외와 성능 분석 펼치기

여기부터는 필요한 기능을 골라 읽는 참고 영역입니다. 새로운 파일로 전체 코드를 실행할 때는 아래 Node.js 실행 안내를 따르세요. 앞쪽의 작은 브라우저 실습과 전체 파일을 한 콘솔에 이어 붙이지 마세요. 기능별 입력 조건과 반환 형식은 아래 설명을 기준으로 합니다.

그래프의 표현과 탐색은 서로 다른 선택입니다

그래프는 정점과 정점 사이의 간선을 저장합니다. 도로망, 의존 관계, 사람 사이의 연결처럼 부모 하나로 표현할 수 없는 관계를 다룹니다. 인접 리스트와 인접 행렬은 이 관계를 보관하는 형식이며, BFS는 그 형식에서 이웃을 읽어 방문 순서를 정하는 알고리즘입니다.

인접 리스트는 각 정점에 이웃 배열을 연결합니다. 정점이 많고 간선이 적은 희소 그래프에서 없는 간선을 저장하지 않아 공간을 아낍니다. 인접 행렬은 정점 쌍마다 칸을 만들어 간선 존재를 바로 확인하지만 간선이 없어도 V×V칸을 확보합니다.

질문 인접 리스트 0/1 인접 행렬
전체 저장 공간 O(V+E) O(V²)
u→v 간선 존재 u의 이웃 탐색 O(deg(u)) 인덱스를 알면 O(1)
u의 이웃 전체 열거 O(deg(u)) 한 행 O(V)
중복 간선 보존 배열에 여러 번 보존 가능 이 글의 0/1 행렬은 존재 여부만 보존

정점·간선·방향 계약

정점 식별자는 문자열입니다. 생성자의 directed=false가 기본이므로 addEdge(A,B)는 A의 이웃에 B, B의 이웃에 A를 넣습니다. directed=true면 A→B 하나만 기록합니다. addVertex는 같은 정점을 다시 추가해도 기존 이웃을 지우지 않고, addEdge는 없는 끝점도 자동 생성합니다.

중복 간선은 제거하지 않고 그대로 저장합니다. 무방향 자기 루프 A-A는 같은 이웃 배열에 A를 두 번 넣습니다. 따라서 E를 입력 간선 수로 셀 때 무방향 인접 항목 수는 자기 루프를 포함해 2E입니다. 이 정책이 BFS의 방문 횟수를 늘리지 않도록 방문 집합을 별도로 사용합니다.

내부 Map의 키 순서는 정점 최초 추가 순서이며 각 이웃은 간선 입력 순서입니다. BFS 출력도 이 순서의 영향을 받습니다. 결과를 사전순이라고 가정하지 않습니다. adj는 학습을 위해 보여 주지만 외부에서 수정해 존재하지 않는 이웃을 넣으면 계약을 깨므로 메서드로만 갱신합니다.

전체 코드와 실행

graph.cjs로 아래 전체를 저장하고 Node.js에서 node ./graph.cjs를 실행합니다. 외부 패키지가 없는 CommonJS 예제입니다. 같은 A-B-C 삼각형과 고립 정점 D를 인접 리스트·행렬로 출력한 뒤 A에서 BFS를 실행합니다.

'use strict';

class Graph {
  constructor(directed = false) {
    this.directed = directed;
    this.adj = new Map();
  }

  addVertex(vertex) {
    if (typeof vertex !== 'string') throw new TypeError('vertex must be a string');
    if (!this.adj.has(vertex)) this.adj.set(vertex, []);
    return this;
  }

  addEdge(from, to) {
    this.addVertex(from);
    this.addVertex(to);
    this.adj.get(from).push(to);
    if (!this.directed) this.adj.get(to).push(from);
    return this;
  }

  hasEdge(from, to) {
    return this.adj.get(from)?.includes(to) ?? false;
  }

  toMatrix() {
    const vertices = [...this.adj.keys()];
    const indices = new Map(vertices.map((vertex, index) => [vertex, index]));
    const matrix = Array.from({ length: vertices.length },
      () => new Array(vertices.length).fill(0));
    for (const [from, neighbors] of this.adj) {
      for (const to of neighbors) matrix[indices.get(from)][indices.get(to)] = 1;
    }
    return { vertices, matrix };
  }

  bfs(start) {
    if (!this.adj.has(start)) return [];
    const visited = new Set([start]);
    const queue = [start];
    let head = 0;
    while (head < queue.length) {
      const vertex = queue[head];
      head += 1;
      for (const next of this.adj.get(vertex)) {
        if (visited.has(next)) continue;
        visited.add(next);
        queue.push(next);
      }
    }
    return queue;
  }
}

module.exports = { Graph };

if (require.main === module) {
  const graph = new Graph();
  graph.addEdge('A', 'B').addEdge('A', 'C').addEdge('B', 'C');
  graph.addVertex('D');
  console.log(JSON.stringify({
    list: [...graph.adj],
    ...graph.toMatrix(),
    bfs: graph.bfs('A'),
    hasAD: graph.hasEdge('A', 'D')
  }));
}

실행 출력

{"list":[["A",["B","C"]],["B",["A","C"]],["C",["A","B"]],["D",[]]],"vertices":["A","B","C","D"],"matrix":[[0,1,1,0],[1,0,1,0],[1,1,0,0],[0,0,0,0]],"bfs":["A","B","C"],"hasAD":false}

리스트를 행렬로 바꾸는 데 필요한 인덱스

정점 이름이 A나 서울처럼 문자열이면 행렬의 몇 번째 행인지 결정해야 합니다. toMatrix는 vertices 배열과 이름→인덱스 Map을 함께 만듭니다. 배열 순서가 행과 열의 표제 역할을 하므로 숫자 행렬만 단독으로 보관하면 어떤 정점 관계인지 잃을 수 있습니다.

각 행은 Array.from의 콜백으로 별도 배열을 생성합니다. 같은 내부 배열을 fill로 반복하면 한 칸 수정이 여러 행에 반영되는 참조 공유 오류가 생길 수 있습니다. 이후 모든 이웃을 읽어 해당 칸에 1을 넣습니다. 중복 간선이 여러 번 같은 칸을 1로 만들어도 개수 정보는 추가되지 않습니다.

toMatrix 결과는 그 시점의 복사본입니다. 나중에 graph.addEdge를 호출해도 이미 만든 행렬이 자동 갱신되지 않습니다. 두 표현을 항상 동기화하는 구현이 아니라 표현 변환을 보여 주는 예제라는 점을 구별해야 합니다. 가중치나 다중 간선 개수도 이 0/1 형식에는 담지 않습니다.

BFS는 넣는 순간 방문 처리합니다

bfs(start)는 정점이 없으면 빈 배열을 반환합니다. 정점이 있으면 시작점을 visited와 queue에 동시에 넣습니다. 하나를 꺼내 이웃을 읽을 때 이미 방문한 정점은 건너뛰고 처음 발견한 정점만 표시한 뒤 큐에 추가합니다. 이렇게 하면 한 정점이 여러 경로로 발견되어도 큐에 한 번만 들어갑니다.

방문 표시를 꺼낼 때로 미루면 A-B-C처럼 연결된 그래프에서 같은 정점이 아직 큐에 있는데 다시 들어갈 수 있습니다. 이웃 검사 시점의 visited가 “처리 완료”가 아니라 “이미 발견해서 예약함”이라는 의미라는 점이 중요합니다. 자기 루프와 중복 간선에도 같은 규칙을 적용하므로 무한히 순환하지 않습니다.

처리 처리 후 queue head 새로 표시된 정점
시작 [A] 0 A
A의 이웃 B,C [A,B,C] 1 B,C
B의 이웃 A,C [A,B,C] 2 없음
C의 이웃 A,B [A,B,C] 3 없음
종료 [A,B,C] 3 D는 연결되지 않아 미방문

queue의 head를 늘리는 방식은 shift로 앞을 삭제하지 않으므로 기존 항목을 당기는 비용을 피합니다. 대신 처리한 정점도 배열에 남고 이 배열 자체를 방문 순서로 반환합니다. 따라서 이 구현의 큐 저장 공간은 방문한 정점 수 O(Vr)이며, 순간 대기열 길이만으로 공간을 계산하면 안 됩니다.

BFS는 가중치가 없는 그래프에서 시작점으로부터 간선 수 기준으로 가까운 층부터 방문합니다. 하지만 이 코드의 반환값은 방문 순서뿐입니다. 거리나 경로를 얻으려면 발견 시 거리와 이전 정점을 별도로 기록해야 합니다. 가중치가 서로 다른 그래프의 최단 경로를 이 순서만으로 구할 수는 없습니다.

전체 그래프를 다루려면 고립 정점도 필요합니다

간선만 입력받아 addEdge를 호출하면 어떤 간선에도 나오지 않는 정점은 존재를 알 수 없습니다. 연결 요소 수처럼 전체 정점이 중요한 문제는 vertices 목록을 받아 addVertex부터 수행해야 합니다. 한 시작점 BFS는 그 시작점의 연결 영역만 방문하므로 나머지 정점에서 다시 시작하는 바깥 반복이 필요합니다.

복잡도와 적용 한계

연산 시간 추가 공간
addVertex / addEdge Map 접근과 배열 append의 기대·상환 모델 O(1) 새 항목 O(1)
bfs(start) 도달 정점 Vr와 인접 항목 Er 기준 O(Vr+Er) visited와 반환 queue O(Vr)
toMatrix O(V²+E) 행렬 O(V²), 인덱스 O(V)
행렬로 모든 이웃을 조사하는 전체 순회 O(V²) 방문 정보 O(V)

이 비용은 짧은 정점 식별자의 Map/Set 접근을 통상 상수 시간으로 보는 모델입니다. JavaScript 명세가 모든 Map 연산의 최악 O(1)을 약속한다는 의미는 아닙니다. 긴 문자열 식별자의 처리 비용과 입력 전체 JSON 메모리도 별도 고려해야 합니다. 큰 희소 그래프에서 출력 목적으로 V² 행렬을 만들면 리스트의 공간 이점을 잃습니다.

경계 확인과 연습

빈 그래프, 고립 정점, 중복 간선, 자기 루프, 방향 간선의 역방향 부재를 확인하세요. 작은 무작위 그래프에서는 행렬의 전이 폐쇄로 도달 가능성을 따로 구한 뒤 BFS 결과 집합과 비교하면 표현이나 방문 처리 오류를 독립적으로 검증할 수 있습니다.

JavaScript 그래프 연습: 연결 구역 크기를 작은 순서로 출력하기에서 직접 구현을 적용해 보세요.

함께 연습하고 확인할 자료

Number of Provinces: 행렬로 주어진 연결 관계에서 구역 수를 구하는 외부 연습입니다. 이 글은 정점·간선 목록에서 구역 크기를 출력합니다.

관련 개념 참고: Princeton Algorithms — Undirected Graphs 공식 자료와 문제 페이지는 2026년 9월 10일 확인했습니다. 외부 문제의 지문·예시를 복제하지 않고 이 글의 예제와 창작 문제를 별도로 구성했습니다.

직접 실습: 연산 비용으로 구조를 설명합니다

실습 주제: JavaScript 그래프 구현: 인접 리스트·인접 행렬 비교와 BFS

  1. 본문 구현에서 저장되는 값과 연결 관계를 그림으로 적습니다.
  2. 조회·삽입·삭제 중 이 구조가 가장 자주 수행할 연산을 고릅니다.
  3. 연산 전후에도 유지되어야 하는 규칙을 한 문장으로 적습니다.
  4. 배열이나 Map 같은 다른 구조로 바꿨을 때 시간·공간 비용을 비교합니다.
풀이 기준과 확인 결과

메서드 이름만 외우지 말고 한 번의 연산에서 어떤 값과 연결이 바뀌는지 추적하세요. 빈 구조, 원소 한 개, 중복값, 연속 삽입·삭제를 실행했을 때 본문이 설명한 불변식이 유지되면 성공입니다.

테스트 체크리스트

  • 빈 구조에 대한 조회·삭제 처리
  • 첫 원소와 마지막 원소 변경
  • 중복값 또는 동일 우선순위 처리
  • 입력 크기가 커졌을 때 예상 복잡도 유지

이 글이 도움이 되었나요?

조회 중

자료구조 학습 순서

필수 18개 · 전체 18개

읽음 기록 관리

전체 과정 목차 (18개)
  1. 필수 학습 · 자료구조 선택 가이드: 연산 비용으로 배열·스택·큐·Set 고르기
  2. 필수 학습 · JavaScript 배열: 인덱스 조회와 삽입·삭제 비용
  3. 필수 학습 · JavaScript Map·Set: 값 조회와 중복 제거 실습
  4. 필수 학습 · 자료구조 스택 쉽게 이해하기: push pop으로 문제 풀이 감 잡기
  5. 필수 학습 · 큐와 FIFO: head 인덱스로 JavaScript 대기열 만들기
  6. 필수 학습 · 단방향 연결 리스트: head·tail 삽입과 삭제
  7. 필수 학습 · JavaScript 원형 덱 구현: 양끝 삽입·삭제와 고정 용량 버퍼
  8. 필수 학습 · JavaScript 문자열 해시 테이블 구현: 충돌 처리와 리사이즈, NFC 정규화
  9. 필수 학습 · 트리 자료구조 차이: 이진 트리 BST MST 구분하기
  10. 필수 학습 · JavaScript 이진 탐색 트리 구현: 중복 키와 세 가지 삭제 처리
  11. 필수 학습 · JavaScript 최소 힙 구현: 우선순위 큐의 push·pop과 비교 함수
  12. 필수 학습 · JavaScript 그래프 구현: 인접 리스트·인접 행렬 비교와 BFS 현재 글
  13. 필수 학습 · JavaScript Union-Find: 경로 압축과 크기 합치기로 연결 상태 관리하기
  14. 필수 학습 · JavaScript Trie: Unicode 접두사 검색과 안전한 삭제 구현
  15. 필수 학습 · JavaScript Fenwick Tree: lowbit로 구간 합과 단일 증가 갱신 구현
  16. 필수 학습 · JavaScript 반복형 세그먼트 트리: 구간 합·단일 대입·결합 순서
  17. 필수 학습 · JavaScript LRU 캐시: Map과 이중 연결 리스트의 불변식
  18. 필수 학습 · JavaScript AVL 트리: 높이 불변식과 LL·RR·LR·RL 삽입 회전

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기