스택은 마지막 데이터를 먼저 꺼내는 구조가 아니다

vx_developer·약 11시간 전

코테보다가

목록 보기
25/25
post-thumbnail

스택을 처음 배우면 나중에 넣은 데이터를 먼저 꺼내는 자료구조라고 설명한다.

const stack: string[] = [];

stack.push("A");
stack.push("B");

const value = stack.pop(); // "B"

push()로 데이터를 쌓고 pop()으로 가장 최근 데이터를 꺼낸다. 접시를 위로 쌓았다가 위에서부터 꺼내는 모습으로 이해하면 LIFO(Last In, First Out)의 동작을 쉽게 기억할 수 있다.

입문 단계에서는 충분한 설명이지만, 이 정의만으로는 서비스에서 언제 스택을 선택해야 하는지 판단하기 어렵다. 배열의 마지막 요소를 꺼낼 수 있다고 해서 모든 데이터가 스택이 되는 것은 아니기 때문이다.

문서 편집기의 실행 취소 기능을 생각해보자. 크리스가 제목을 수정하고, 문단을 추가한 뒤, 이미지 하나를 삭제했다. 실행 취소 버튼을 누르면 어떤 작업부터 되돌려야 할까? 작업 이름만 저장하면 원래 상태를 복구할 수 있을까? 여러 번 되돌린 뒤 새로운 내용을 입력하면 기존의 다시 실행 기록은 어떻게 처리해야 할까?

실제 서비스에서 스택은 데이터를 거꾸로 꺼내기 위한 구조가 아니라, 가장 최근의 상태 변화가 먼저 해결되어야 하는 의존 관계를 표현하는 구조다.

왜 가장 최근 작업부터 되돌려야 할까?

크리스가 메모 앱에서 다음 순서로 문서를 편집했다고 해보자.

1. 제목을 "Meeting"에서 "Project Meeting"으로 변경
2. 첫 번째 문단 추가
3. 이미지 삭제

현재 문서 상태는 세 작업이 순서대로 반영된 결과다. 여기서 첫 번째 작업인 제목 변경만 먼저 취소하면, 그 이후 작업들이 어떤 상태를 기준으로 실행되었는지 설명하기 어려워진다. 반면 마지막에 적용한 이미지 삭제부터 취소하면 그 직전 상태로 자연스럽게 돌아갈 수 있다.

type EditAction =
  | {
      type: "change-title";
      previousTitle: string;
      nextTitle: string;
    }
  | {
      type: "insert-paragraph";
      paragraphId: string;
      position: number;
      content: string;
    }
  | {
      type: "delete-image";
      imageId: string;
      position: number;
      source: string;
    };

const undoStack: EditAction[] = [];

스택에는 화면에 표시할 문서 내용 자체보다 문서를 어떻게 변경했는지를 기록한다. 이미지 삭제를 되돌리려면 이미지 ID뿐 아니라 원래 위치와 이미지 주소도 필요하다. delete-image라는 작업 이름만 남기면 무엇을 어디에 복구해야 하는지 알 수 없다.

스택이 해결하는 핵심은 최근 항목 조회가 아니다. 여러 변경이 앞선 결과 위에 차례로 적용되었을 때, 그 의존 관계를 역순으로 해제하는 것이다.

작업 이름만 저장해서는 상태를 복구할 수 없다

다음 구현은 편집 작업의 종류만 스택에 넣는다.

// Bad: 되돌리는 데 필요한 이전 상태가 없다.
undoStack.push({
  type: "change-title"
});

이 기록을 꺼내도 이전 제목이 무엇이었는지 알 수 없다. 서버에서 변경 이력을 다시 조회하거나 현재 문서를 추측해서 복구해야 한다. 실행 취소 기록에는 작업을 식별하는 정보뿐 아니라 반대 작업을 수행하는 데 필요한 데이터가 포함되어야 한다.

// Better: 변경 전후의 값을 함께 기록한다.
undoStack.push({
  type: "change-title",
  previousTitle: document.title,
  nextTitle: newTitle
});

document.title = newTitle;

실행 취소할 때는 가장 최근 작업을 꺼내 변경 전 값을 복원한다.

function undo(document: DocumentState) {
  const action = undoStack.pop();

  if (!action) {
    return document;
  }

  if (action.type === "change-title") {
    document.title = action.previousTitle;
  }

  return document;
}

여기서 previousTitle은 사용자의 입력이 아니라 변경 직전에 애플리케이션이 확인한 상태여야 한다. 클라이언트가 이전 제목을 함께 보내더라도 그대로 믿으면 다른 문서의 값이나 오래된 값을 복원할 수 있다.

현실의 편집 작업은 다음과 같이 코드의 흐름으로 바뀐다.

입력
사용자가 요청한 새 제목과 문서 ID

상태
검증된 현재 제목과 실행 취소 작업 스택

출력
변경된 문서와 실행 취소 가능 여부

서버나 편집기는 문서 접근 권한과 현재 상태를 먼저 확인한다. 그다음 변경 직전의 값을 스택에 기록하고 새 값을 반영한다. 입력 검증, 상태 변경, 복구 정보 저장이 하나의 작업으로 이어져야 실행 취소 결과를 신뢰할 수 있다.

실행 취소와 다시 실행에는 두 개의 스택이 필요하다

사용자는 실행 취소만 누르지 않는다. 되돌린 작업을 다시 적용하기도 한다. 이를 위해 보통 실행 취소 스택과 다시 실행 스택을 따로 관리한다.

const undoStack: EditAction[] = [];
const redoStack: EditAction[] = [];

function undo(document: DocumentState) {
  const action = undoStack.pop();

  if (!action) {
    return document;
  }

  applyReverse(document, action);
  redoStack.push(action);

  return document;
}

function redo(document: DocumentState) {
  const action = redoStack.pop();

  if (!action) {
    return document;
  }

  apply(document, action);
  undoStack.push(action);

  return document;
}

크리스가 세 번 편집한 뒤 두 번 실행 취소하면, 취소된 두 작업은 다시 실행 스택에 쌓인다. 이때 크리스가 새로운 문단을 입력하면 어떻게 해야 할까?

function recordNewAction(action: EditAction) {
  undoStack.push(action);
  redoStack.length = 0;
}

새로운 작업은 기존 편집 흐름에서 다른 분기를 만든다. 이전에 취소했던 작업을 그대로 다시 실행하면 크리스가 방금 만든 상태와 충돌할 수 있으므로 다시 실행 스택을 비운다.

이 규칙은 push()와 pop()의 사용법만으로 나오지 않는다. 사용자가 기대하는 편집 흐름을 먼저 정의한 뒤, 그 흐름과 스택의 성질을 연결한 결과다.

모든 변경 이력을 스택으로 관리할 필요는 없다

실행 취소 기록과 감사 로그는 비슷해 보이지만 목적이 다르다. 실행 취소는 현재 상태에서 가장 최근 작업을 되돌리는 기능이다. 감사 로그는 누가 언제 무엇을 변경했는지 장기간 조회하기 위한 기록이다.

type AuditLog = {
  id: string;
  documentId: string;
  userId: string;
  action: string;
  createdAt: Date;
};

감사 로그에서는 특정 날짜의 기록을 검색하거나 여러 사용자의 작업을 시간순으로 조회해야 한다. 중간 기록을 직접 찾아야 하므로 마지막 항목만 다루는 스택으로는 충분하지 않다. 데이터베이스에 저장된 변경 이벤트나 별도의 이력 테이블이 더 잘 맞는다.

실행 취소 스택의 생명주기도 결정해야 한다. 브라우저 탭이 닫히면 사라져도 되는 로컬 편집 기록이라면 메모리에 둘 수 있다. 로그인한 사용자가 다른 기기에서도 편집을 이어가야 한다면 서버에 저장하거나 문서 버전으로 관리해야 한다. 스택을 어디에 저장할지는 자료구조보다 제품 요구사항에서 결정된다.

공동 편집에서는 범위가 더 중요하다. 모든 사용자의 작업을 하나의 전역 스택에 쌓으면 크리스가 실행 취소를 눌렀을 때 다른 사용자의 최근 작업이 사라질 수 있다. 일반적인 공동 편집기라면 사용자별 작업 범위를 구분하고, 다른 변경과 충돌하지 않는 방식으로 되돌려야 한다. 이 단계에서는 단순한 스택만으로 충분하지 않을 수 있다.

스택을 선택하기 전에 확인할 질문

  1. 가장 최근에 생성된 상태부터 처리해야 하는 이유가 분명한가?
  2. 각 항목에는 작업을 되돌리는 데 필요한 이전 상태가 포함되어 있는가?
  3. 실행 취소 후 새로운 작업이 들어오면 다시 실행 기록을 어떻게 처리할 것인가?
  4. 스택이 비어 있을 때의 동작을 정의했는가?
  5. 기록은 브라우저 세션까지만 유지하면 되는가, 서버에 저장해야 하는가?
  6. 사용자별 실행 취소와 전체 변경 이력을 구분했는가?
  7. 기록이 계속 쌓이지 않도록 최대 개수나 보관 기간을 정했는가?

최근 상태부터 풀어야 하는 흐름에 스택이 맞는다

문서 편집기의 실행 취소가 스택과 잘 맞는 이유는 마지막 작업이 가장 최근 상태를 만들었고, 그 상태부터 역순으로 해제해야 이전 상태를 안전하게 복원할 수 있기 때문이다. 이때 스택 항목에는 작업 이름뿐 아니라 복구에 필요한 데이터가 있어야 하며, 다시 실행과 새 작업의 관계도 함께 정의해야 한다.

반대로 특정 시점의 기록을 검색하거나 모든 변경을 장기간 보관하려는 목적이라면 감사 로그나 버전 이력이 더 적합하다. 스택을 선택하는 기준은 데이터를 넣고 빼는 모양이 아니라, 최근 상태를 먼저 처리해야 하는 서비스 규칙이 존재하는지다.

다음 글에서는 큐를 통해 요청이 들어온 순서와 실제 처리 순서를 어떻게 통제하는지 살펴본다.

profile
Vision eXperience Developer

0개의 댓글