JavaScript 원형 덱 구현: 양끝 삽입·삭제와 고정 용량 버퍼

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

덱: 양쪽 끝을 쓰고, 빈 칸을 돌아가며 재사용합니다

세 칸의 원형 배열로 덱을 이해합니다. 앞 위치와 개수가 바뀌는 과정을 따라간 뒤 양끝 삽입·삭제 구현으로 연결합니다.

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

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

먼저 엘리베이터 대기 줄을 떠올려 봅니다

보통 줄은 뒤로 들어와 앞에서 나갑니다. 그런데 관리자가 긴급 점검 요청을 맨 앞에 넣거나, 잘못 들어온 마지막 요청을 뒤에서 취소해야 한다면 양쪽 끝을 모두 써야 합니다. 이런 줄을 덱이라고 합니다.

여기서는 값 세 개만 들어가는 작은 칸을 사용합니다. 칸 번호는 0, 1, 2이고, 끝 다음은 다시 0입니다. 종이에 원을 그리지 않아도 아래 표처럼 따라갈 수 있습니다.

동작 0번 칸 1번 칸 2번 칸 읽는 순서
A를 뒤에 넣기 A A
B를 뒤에 넣기 A B A → B
A를 앞에서 빼기 (사용 안 함) B B
C를 뒤에 넣기 B C B → C
D를 뒤에 넣기 D B C B → C → D

마지막 단계에서 D는 2번 다음인 0번 칸에 들어갑니다. 배열에 보이는 물리 순서는 D, B, C이지만 사람이 읽을 논리 순서는 B, C, D입니다. 원형 덱의 핵심은 값을 매번 옮기지 않고 “어느 칸이 앞인가”를 기억하는 것입니다.

세 칸을 도는 번호부터 확인합니다

const size = 3;
for (let index = 0; index < 5; index += 1) {
  console.log(index % size);
}
0
1
2
0
1

%는 나눈 뒤의 나머지를 구합니다. 크기가 3이면 결과는 0, 1, 2 안에서만 움직입니다. 2 다음 계산 결과가 0이 되므로 배열 끝에서 처음으로 돌아갈 수 있습니다. 이 규칙 없이 인덱스를 계속 3, 4로 늘리면 준비한 세 칸 밖에 값을 쓰게 됩니다.

앞 위치를 옮겨도 값은 복사하지 않습니다

const slots = ['A', 'B', 'C'];
let front = 0;

front = (front + 1) % slots.length;
console.log(slots[front]);
console.log(slots.join(', '));
B
A, B, C

front는 현재 맨 앞 값의 칸 번호입니다. A를 앞에서 꺼냈다고 생각하면 front만 0에서 1로 옮깁니다. 배열 자체는 여전히 A, B, C로 보이지만 다음 앞 값은 B입니다. 실제 구현은 사용이 끝난 A 칸을 비워 오래된 객체를 계속 붙잡지 않도록 합니다.

순서를 반대로 해 칸을 먼저 비운 뒤 값을 읽으면 반환할 A를 잃습니다. 따라서 “값 저장 → 칸 비우기 → 앞 위치 이동”의 순서를 지켜야 합니다.

뒤에 넣을 칸도 앞에서부터 셉니다

const capacity = 3;
const frontIndex = 1;
const length = 2;
const backIndex = (frontIndex + length) % capacity;

console.log(backIndex);
0

앞이 1번이고 값이 두 개라면 사용 중인 칸은 1번과 2번입니다. 그 다음 칸은 3처럼 보이지만 세 칸만 있으므로 0번으로 돌아갑니다. 여기서 length는 마지막 인덱스가 아니라 현재 들어 있는 값의 개수입니다.

확인 문제: 앞이 2번이고 값이 하나라면 뒤에 넣을 칸은?

계산은 (2 + 1) % 3이고 정답은 0번입니다. 현재 값은 2번에 있고 새 값은 그 다음인 0번에 들어갑니다.

양쪽 동작의 차이를 한 표로 정리합니다

동작 먼저 해야 할 일 바뀌는 정보
뒤에 넣기 앞 위치와 현재 개수로 빈 칸 계산 값 개수 증가
앞에서 빼기 현재 앞 값을 보관 앞 위치 이동, 값 개수 감소
앞에 넣기 앞 위치를 한 칸 뒤로 감기 앞 위치, 값 개수
뒤에서 빼기 마지막 값의 칸 계산 값 개수 감소

비어 있는데 빼려고 하거나 세 칸이 가득 찼는데 더 넣으려는 경우도 먼저 처리해야 합니다. 단순 예제에서는 이 검사를 생략했지만, 이어지는 기존 전체 구현은 빈 상태와 가득 찬 상태, 앞뒤 네 동작, 사용한 칸 정리까지 포함합니다.

이제 기존 전체 구현을 읽는 순서

전체 코드를 한 번에 외우지 마세요. 먼저 생성자에서 칸 배열, 앞 위치, 값 개수 세 가지를 찾습니다. 다음으로 뒤에 넣기와 앞에서 빼기만 읽어 위 표와 맞춰 봅니다. 그 뒤 앞에 넣기와 뒤에서 빼기를 대칭으로 비교하고, 마지막에 빈 덱과 가득 찬 덱의 처리를 확인하면 됩니다.

아래 정밀 구현에서는 고정 용량 정책과 모든 경계 상황을 다룹니다. 위 코드는 원형 이동 하나씩을 배우기 위한 축소 예제이며, 그대로 완성 덱으로 사용하기 위한 코드는 아닙니다.

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

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

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

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

왜 덱이 필요한가

일반 큐는 뒤로 넣고 앞으로 꺼냅니다. 편집 기록의 최근 항목을 되돌리려면 뒤에서도 꺼낼 수 있어야 합니다. 덱(deque, double-ended queue)은 이 두 방향을 함께 지원하는 추상 자료형입니다. 여기서 원형 배열은 그 계약을 구현하는 한 방법이지, 덱 자체의 정의는 아닙니다.

JavaScript 배열의 앞에 unshift로 삽입하거나 shift로 삭제하면 인덱스를 앞으로 또는 뒤로 당기는 비용을 고려해야 합니다. 원형 덱은 값의 자리를 옮기지 않고 “논리적 첫 항목이 어디인가”만 바꿉니다. 반대로 중간 위치 삽입이나 정렬이 주작업이라면 이 구조가 특별히 유리하지 않습니다.

저장 계약: front와 length가 의미하는 것

capacity는 1 이상의 정수이며 실행 가능한 메모리 크기 안에서 정합니다. data는 고정 개수의 슬롯, front는 첫 항목의 물리 위치, length는 현재 원소 수입니다. 항상 0 ≤ length ≤ capacity, 0 ≤ front < capacity를 유지하며 논리 위치 i의 실제 위치는 (front + i) % capacity입니다.

삽입은 성공하면 true, 가득 차면 false를 반환하고 기존 값을 바꾸지 않습니다. 오래된 기록을 자동으로 버릴지는 덱을 사용하는 프로그램이 결정합니다. 이런 분리는 실수로 작업을 잃으면 안 되는 큐에도 같은 구현을 재사용하게 해 줍니다.

삭제 결과는 빈 덱이면 {done:true}, 값이 있으면 {done:false,value}입니다. undefined 자체도 저장할 수 있으므로 undefined만 반환하면 “빈 덱”과 “undefined 저장”을 구별하지 못합니다. 이 예제는 별도의 done 표시로 구별합니다. 객체를 저장하면 복사가 아니라 참조가 보관됩니다.

전체 코드와 실행

아래 전체를 deque.cjs로 저장하고 터미널에서 node ./deque.cjs를 실행합니다. Node.js CommonJS 파일 하나이며 npm 패키지나 브라우저가 필요하지 않습니다. module.exports는 테스트용이고 맨 아래 직접 실행 블록이 실제 호출과 출력을 담당합니다.

'use strict';

class CircularDeque {
  constructor(capacity) {
    if (!Number.isInteger(capacity) || capacity < 1) {
      throw new RangeError('capacity must be a positive integer');
    }
    this.data = new Array(capacity);
    this.capacity = capacity;
    this.front = 0;
    this.length = 0;
  }

  pushFront(value) {
    if (this.length === this.capacity) return false;
    this.front = (this.front - 1 + this.capacity) % this.capacity;
    this.data[this.front] = value;
    this.length += 1;
    return true;
  }

  pushBack(value) {
    if (this.length === this.capacity) return false;
    const index = (this.front + this.length) % this.capacity;
    this.data[index] = value;
    this.length += 1;
    return true;
  }

  popFront() {
    if (this.length === 0) return { done: true };
    const value = this.data[this.front];
    this.data[this.front] = undefined;
    this.front = (this.front + 1) % this.capacity;
    this.length -= 1;
    return { done: false, value };
  }

  popBack() {
    if (this.length === 0) return { done: true };
    const index = (this.front + this.length - 1) % this.capacity;
    const value = this.data[index];
    this.data[index] = undefined;
    this.length -= 1;
    return { done: false, value };
  }

  toArray() {
    const result = [];
    for (let offset = 0; offset < this.length; offset += 1) {
      result.push(this.data[(this.front + offset) % this.capacity]);
    }
    return result;
  }
}

module.exports = { CircularDeque };

if (require.main === module) {
  const deque = new CircularDeque(3);
  deque.pushBack('A');
  deque.pushBack('B');
  const removed = deque.popFront();
  deque.pushBack('C');
  deque.pushFront('D');
  const full = deque.pushBack('E');
  console.log(JSON.stringify({ removed, full, values: deque.toArray(), front: deque.front }));
}

실행 출력

{"removed":{"done":false,"value":"A"},"full":false,"values":["D","B","C"],"front":0}

각 메서드가 불변식을 지키는 방법

pushBack은 length개 뒤의 빈 슬롯을 선택합니다. length를 먼저 늘리면 한 칸 뒤에 써 버리므로 값을 기록한 뒤 증가시킵니다. full 검사를 가장 먼저 하는 이유는 덮어쓰기를 막기 위해서입니다. 읽을 때도 물리 배열 순서가 아니라 front에서 시작하는 순서를 사용합니다.

pushFront는 첫 위치를 한 칸 뒤로 이동시킨 다음 값을 기록합니다. JavaScript의 %는 수학적 양의 모듈러와 달라 -1 % 3이 -1입니다. 따라서 front – 1에 capacity를 더해 음수를 피합니다. front가 0인 경우에도 새 위치는 capacity – 1이 됩니다.

popFront는 현재 front의 값을 먼저 저장하고 슬롯을 비운 뒤 front를 한 칸 전진시킵니다. popBack은 마지막 논리 위치 length – 1을 계산하고 front는 유지합니다. 사용한 슬롯에 undefined를 대입하면 큰 객체에 대한 불필요한 참조를 덱이 계속 붙들지 않습니다.

마지막 항목을 지운 뒤 front가 0이 아니어도 오류가 아닙니다. length가 0이면 어떤 슬롯도 활성 원소가 아니며 다음 삽입은 동일한 인덱스 공식을 따릅니다. 빈 덱을 항상 초기 위치로 되돌려야 한다는 조건은 이 계약에 없습니다.

용량 3에서의 동작 추적

연산 front length 논리 순서 결과
시작 0 0 []
pushBack(A), pushBack(B) 0 2 [A,B] 각 true
popFront() 1 1 [B] A
pushBack(C) 1 2 [B,C] true
pushFront(D) 0 3 [D,B,C] true
pushBack(E) 0 3 [D,B,C] false
popBack() 0 2 [D,B] C
popFront() 두 번 2 0 [] D, B
popBack() 2 0 [] done:true

배열을 실제로 순환시키는 경우도 확인해 보세요. 위 표의 빈 상태에서 X,Y를 뒤에 넣으면 슬롯 2와 0에 저장되지만 toArray()는 [X,Y]를 반환합니다. 물리 배열을 그대로 출력하는 것과 논리 덱을 출력하는 것은 다릅니다.

복잡도와 적용 한계

작업 시간 추가 공간
생성 O(K) O(K) 슬롯
양끝 삽입·삭제 최악 O(1) O(1)
toArray O(n) 반환 배열 O(n)

K는 용량, n은 현재 항목 수입니다. 위 비용은 배열 인덱스 접근을 상수 시간으로 보는 일반적인 모델이며 값 자체를 깊은 복사하지 않습니다. 고정 용량이므로 이 구현에는 자동 확장에 따른 가끔 O(n)이 되는 삽입이 없습니다. 대신 K개를 넘으면 반드시 실패합니다.

크기 상한을 모르는 프로그램은 동적 원형 배열이나 연결 덱을 고려할 수 있습니다. 메모리가 제한된 기록 버퍼라면 고정 용량과 명시적 퇴출 정책이 오히려 계약을 선명하게 합니다. 전체 기록을 매번 배열로 반환하면 덱 연산이 빨라도 출력 복사 비용이 더 커질 수 있습니다.

경계 확인과 연습

용량 1에서 삽입·실패·삭제를 반복하고, 빈 상태의 양쪽 삭제, null과 undefined 저장, 물리 끝을 여러 번 넘는 삽입을 확인하세요. 성공한 삽입만 length를 늘리고 성공한 삭제만 줄인다는 조건으로 대부분의 인덱스 오류를 찾을 수 있습니다.

JavaScript 원형 덱 연습: 최근 기록 창과 되돌리기에서 직접 구현을 적용해 보세요.

함께 연습하고 확인할 자료

Design Circular Deque: 양끝 삽입·삭제 API를 직접 설계하는 외부 연습입니다. 반환 계약은 이 글과 다르므로 공식 문제의 요구를 별도로 읽으세요.

관련 개념 참고: 위 공식 문제의 API 계약을 함께 살펴보세요. 공식 자료와 문제 페이지는 2026년 9월 10일 확인했습니다. 외부 문제의 지문·예시를 복제하지 않고 이 글의 예제와 창작 문제를 별도로 구성했습니다.

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

실습 주제: JavaScript 원형 덱 구현: 양끝 삽입·삭제와 고정 용량 버퍼

  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 피드 구독하기

댓글 남기기