wan의 개발일기

RoomCraft #22. Undo/Redo, 복제, 벽 스냅 - 세 기능 한 번에 넣고 본 버그들 본문

1인 게임개발 활동/가구배치 시뮬레이터

RoomCraft #22. Undo/Redo, 복제, 벽 스냅 - 세 기능 한 번에 넣고 본 버그들

wan_ 2026. 7. 11. 10:22
소스코드 → github.com/Jywan/RoomCraft

들어가며

레이아웃 비교(썸네일)까지 끝내고 나니, 정작 배치 자체를 만지는 손맛이 아쉬웠다. 가구 하나 잘못 옮기면 처음부터 다시 배치해야 하고, 비슷한 가구를 여러 개 놓으려면 매번 새로 만들어야 하고, 벽에 딱 붙여놓고 싶어도 픽셀(정확히는 미터) 단위로 손으로 맞춰야 했다. 이번엔 이 세 가지 — 실행 취소/다시 실행, 가구 복제, 벽 스냅 — 를 한 번에 밀어붙이기로 했다. 세 개를 한꺼번에 진행한 이유는 단순한데, 이 프로젝트에서 "기능 하나 추가 → 씬 연결 하나 빠뜨림 → 재검수" 패턴이 하도 많이 반복돼서, 차라리 작은 단위로 쪼개서 순서대로 빠르게 돌리는 게 낫겠다고 판단했기 때문이다. 그리고 실제로 이번 편에도 그 패턴이 여러 번 나온다.


1. 실행 취소/다시 실행 (Undo/Redo)

Ctrl+Z(맥은 Cmd+Z)로 되돌리기, Ctrl+Shift+Z로 다시 실행. 별도 버튼은 없고 RoomToolbar.Update()에서 키 입력을 감지해서 처리한다. Command 패턴으로 갔다. 동작 하나(생성/이동/회전/삭제)를 Execute()/Undo() 두 메서드를 가진 객체로 만들어서 스택에 쌓는 방식이다. 새 디렉토리 Scripts/UndoRedo/를 따로 파서 기존 Furniture/Room/UI 분류와 안 섞이게 했다.

public interface ICommand
{
    void Execute();
    void Undo();
}

public class UndoManager : MonoBehaviour
{
    private readonly Stack<ICommand> undoStack = new Stack<ICommand>();
    private readonly Stack<ICommand> redoStack = new Stack<ICommand>();

    // 지금 바로 실행하면서 기록 (예: 삭제)
    public void ExecuteCommand(ICommand command)
    {
        command.Execute();
        undoStack.Push(command);
        redoStack.Clear();
    }

    // 이미 실행된 동작을 기록만 함 (예: 드래그로 이미 옮겨진 가구)
    public void RecordCommand(ICommand command)
    {
        undoStack.Push(command);
        redoStack.Clear();
    }
}

ExecuteCommandRecordCommand를 나눈 이유는, 드래그 이동처럼 "이미 화면에서 벌어진 일"과 삭제처럼 "지금 이 순간 실행해야 하는 일"이 다르기 때문이다. 드래그는 매 프레임 위치가 바뀌다가 마우스를 뗀 시점에야 최종 위치가 확정되니, 그때 가서 "이만큼 움직였다"고 기록만 하면 된다. 반대로 삭제는 커맨드가 직접 파괴까지 담당해야 한다.

버그 1: 커맨드 추가하다가 드래그가 통째로 죽음

Undo 기록 코드를 HandleDrag()에 끼워넣는 과정에서, 실제 마우스를 따라 가구를 옮기는 로직(Raycast + MoveTo + 그리드 스냅 + 경계 검사)이 통째로 주석 처리되고 그 자리에 무조건 return이 들어가 버렸다. 게다가 Undo 기록 코드도 "마우스를 뗐을 때"가 아니라 매 프레임 실행되는 위치에 들어가 있었다. 결과적으로 가구를 클릭은 되는데 드래그는 전혀 안 되는 상태였다.

private void HandleDrag()
{
    if (!hasSelection || !isDragging) return;

    if (Input.GetMouseButtonUp(0))
    {
        isDragging = false;
        // ... 되돌리기 처리 ...
        return;
    }

    // 실제로 위치가 바뀌었다면 Undo 기록
    Vector3 finalPos = selectedFurniture.transform.position;
    if (hasUndoManager && finalPos != dragStartPosition)
        undoManager.RecordCommand(new MoveFurnitureCommand(...));

    return;   // 여기서 매 프레임 무조건 빠져나감
    // Raycast + MoveTo 로직은 전부 주석 처리되어 이 아래는 죽은 코드
}

원래 이동 로직을 복원하고, Undo 기록은 Input.GetMouseButtonUp(0) 블록 안, 되돌리기 처리 바로 다음으로 옮겼다. 마우스를 뗀 그 프레임에 딱 한 번만 실행되는 게 맞다.

버그 2: Redo 한 다음 Undo가 꼬임

Redo()가 실행한 커맨드를 redoStack에 도로 넣고 있었다. 되감기(Undo)로 스택을 옮겼으면 되돌리기(Redo)는 그 반대인 undoStack으로 다시 옮겨야 하는데, 복사하다가 변수명을 잘못 짚은 흔한 실수였다.

public void Redo()
{
    if (redoStack.Count == 0) return;
    ICommand command = redoStack.Pop();
    command.Execute();
    undoStack.Push(command);   // redoStack이 아니라 undoStack
}

버그 3: 생성 직후 Undo를 눌러도 반응 없음

가구를 새로 만들고 바로 Ctrl+Z를 누르면 아무 일도 안 일어났다. 원인은 CreateFurnitureCommandRecordCommand() 경로(이미 실행된 생성을 기록만 하는 경우)로 쓰일 때는 Execute()가 아예 호출되지 않는다는 점이었다. 그런데 파괴 대상 참조(created 필드)는 오직 Execute() 안에서만 채워지도록 짜여 있었다 — Undo()가 실행돼도 created가 계속 null이라 파괴할 대상이 없었던 것.

public CreateFurnitureCommand(FurnitureInteraction interaction, FurnitureData data,
    Vector3 position, float rotationY, FurnitureObject alreadyCreated = null)
{
    // ...
    this.created = alreadyCreated;   // 이미 생성된 오브젝트를 직접 받을 수 있게
}

생성자에 이미 만들어진 가구를 직접 넘길 수 있는 선택적 파라미터를 추가해서, RecordCommand 경로에서도 created가 채워지도록 했다.

버그 4: 선택된 가구를 지웠더니 콘솔이 폭주함

가장 재밌었던(사실 제일 무서웠던) 버그. 선택 중인 가구를 Undo/Redo가 Object.Destroy()로 직접 파괴하면, FurnitureInteraction이 들고 있던 "지금 선택된 가구" 참조는 아무도 정리해주지 않는다. Unity의 MonoBehaviour는 파괴돼도 C# 레퍼런스 자체는 null이 되지 않고 "죽은 오브젝트를 가리키는 참조"로 남는데, 매 프레임 이 참조에 접근하던 FurnitureToolbar.Update()MissingReferenceException을 계속 던지기 시작했다. 한 번이 아니라 Update()가 도는 한 영원히.

하필 1인칭 모드로 들어갈 때 눈에 띄었는데, 알고 보니 우연이 아니었다. 1인칭 진입 시 호출하는 DeselectAll()도 내부에서 이 죽은 참조에 접근하려다 예외를 던지고, 그 바람에 선택 상태를 지우는 코드 자체가 중간에 끊겨서 영원히 정리가 안 되는 상태로 남는 것이었다.
// FurnitureInteraction.cs
public void ClearSelectionIfMatches(FurnitureObject obj)
{
    if (hasSelection && selectedFurniture == obj)
        DeselectCurrent();
}

// DeleteFurnitureCommand.cs
public void Execute()
{
    interaction.ClearSelectionIfMatches(targetFurniture);   // 파괴 직전에 먼저
    Object.Destroy(targetObject);
}

가구를 파괴하는 두 지점(CreateFurnitureCommand.Undo(), DeleteFurnitureCommand.Execute()) 모두에서, 파괴하기 직전에 "이게 지금 선택된 애면 먼저 선택 해제"를 거치도록 했다.


2. 가구 복제 (Ctrl+D)

가구를 하나 선택한 상태에서 Ctrl+D(맥은 Cmd+D)를 누르면 복제된다. 선택된 가구를 원본에서 대각선으로 살짝(가로세로 각 0.3m) 띄운 자리에 복제하고, 복제본을 바로 선택 상태로 넘겨서 이어서 드래그할 수 있게 만들었다. 생성 로직은 Undo/Redo에서 이미 만든 SpawnFurniture() + CreateFurnitureCommand 조합을 그대로 재사용했다.

버그: 한 번 눌렀는데 수십 개가 복제됨

Ctrl+D를 눌렀더니 원본에서 대각선으로 가구가 줄줄이 늘어서는 진풍경이 벌어졌다. 범인은 macOS의 키 반복(OS auto-repeat)이었다. Input.GetKeyDown()은 원칙적으로 키가 눌리는 그 프레임에 딱 한 번만 true를 반환해야 하는데, OS가 키를 누르고 있는 동안 보내는 반복 입력에도 반응해서 매 반복마다 새로 "눌림"으로 잡혔다. 복제는 매번 직전 복제본을 기준으로 대각선으로 이동하니, 반복될수록 원본에서 점점 멀어지는 사선이 그려진 것이다.

private bool duplicateKeyHeld = false;

private void HandleDuplicate()
{
    if (!hasSelection) return;

    bool modifier = Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.LeftCommand) // ...;
    bool dKeyPressed = Input.GetKey(KeyCode.D);

    if (modifier && dKeyPressed && !duplicateKeyHeld)
    {
        DuplicateSelected();
        duplicateKeyHeld = true;
    }
    else if (!dKeyPressed)
    {
        duplicateKeyHeld = false;
    }
}

GetKeyDown 대신 GetKey(지금 물리적으로 눌려있는지 raw 상태)를 쓰고, 직접 관리하는 duplicateKeyHeld 플래그로 "키를 뗄 때까지 다시 실행 안 함"을 보장했다. OS가 몇 번을 반복해서 보내든 실제 복제는 딱 한 번만 일어난다.

회전(Q/E/R)이나 삭제(Delete)도 이론상 같은 문제가 있을 텐데, 결과가 눈에 덜 띄어서(회전 한 번 더 됐다고 표시가 확 나지는 않으니) 지금까지 아무도 못 알아챘을 뿐이다. 나중에 비슷한 증상이 보이면 같은 패턴으로 고치면 된다.

3. 벽에 스냅

버튼이나 단축키로 "실행"하는 기능이 아니라, 가구를 드래그하는 동안 자동으로 붙는다. 대신 B 키로 이 자동 스냅 자체를 켜고 끌 수 있게 RoomToolbar에 토글을 추가했다(그리드 스냅의 G키와 같은 자리). 동작 방식은, 드래그 중인 가구가 방 외곽선(벽)에 일정 거리 이내로 가까워지면 그 벽에 딱 붙는 위치로 옮기고 방 안쪽을 바라보도록 자동으로 회전시키는 것이다. 새 스크립트 WallSnap.cs가 방 외곽선의 각 변을 순회하면서 가장 가까운 변을 찾고, 거리가 임계값(0.3m) 이내면 그 변의 안쪽 법선 방향으로 가구를 밀어낸다.

버그 1: 벽 속으로 파고듦

테스트해보니 가구가 벽에 붙기는 하는데 절반쯤 벽 속에 파묻혀 있었다. RoomBuilder.CreateWallSegment()를 다시 들여다보니 원인이 명확했다.

wall.transform.localPosition = new Vector3(mid.x, data.height / 2f, mid.y);
wall.transform.localScale = new Vector3(length, data.height, wallThickness);

벽(Cube)이 방 외곽선 좌표를 중심선으로 삼아서 양쪽으로 벽 두께의 절반씩 퍼지는 구조였다. 즉 vertices는 벽의 "안쪽 면"이 아니라 "중심선"인데, WallSnap은 가구를 이 중심선에서 가구 반깊이만큼만 띄우고 있었다 — 벽 두께 절반만큼 실제 안쪽 면에 못 미친 셈이다.

public const float WallThickness = 0.1f;   // RoomBuilder, 외부에서도 참조

// WallSnap.cs
Vector2 snapped2D = alongWall + bestNormal * (halfDepth + RoomBuilder.WallThickness / 2f);

벽 두께를 RoomBuilderpublic const로 빼내서 한 곳에서만 관리하고, 스냅 위치 계산에 그 절반만큼을 추가로 반영했다.

버그 2: 코너에서 옆 벽으로 삐져나감

벽 두께는 해결됐는데, 방 코너(가로/세로 벽이 만나는 지점)에서 유독 가구가 옆 벽을 침범하는 게 보였다. 원인은 스냅 로직이 가구의 깊이(앞뒤) 방향만 벽에서 띄우고, (좌우) 방향은 전혀 검사하지 않고 있었다는 것. 가구를 벽 하나에 딱 맞춰 세워도, 그 가구가 넓으면 옆쪽 끝이 코너를 넘어 인접한 벽 영역까지 뻗어나갈 수 있다는 걸 놓쳤다.

// 벽을 따라(가구 폭 방향) 코너를 넘어가지 않도록 위치를 세그먼트 안쪽으로 고정
Vector2 edgeDir = (bestB - bestA).normalized;
float segmentLength = Vector2.Distance(bestA, bestB);
float t = Vector2.Dot(point - bestA, edgeDir);
float margin = Mathf.Min(halfWidth, segmentLength / 2f);   // 벽이 가구보다 짧으면 가운데로
t = Mathf.Clamp(t, margin, segmentLength - margin);
Vector2 alongWall = bestA + edgeDir * t;

가구가 놓일 위치를 벽 세그먼트의 시작/끝 범위 안으로, 가구 반폭만큼 여유를 두고 클램프했다. 벽이 가구보다 짧으면 억지로 맞추는 대신 그 벽 가운데에 놓이도록 폴백을 뒀다. 코너 근처에서 다시 테스트하니 이제 옆 벽을 넘어가지 않는다.


정리

기능핵심 구현
실행 취소/다시 실행Command 패턴, ICommand + UndoManager(Stack 2개), ExecuteCommand/RecordCommand 구분
가구 복제Ctrl+D, SpawnFurniture() 재사용, GetKey + 자체 플래그로 OS 키 반복 방지
벽 스냅WallSnap.TrySnap(), 벽 두께 보정 + 폭 방향 코너 클램프
발견한 버그드래그 로직 유실, Redo 스택 오류, 생성 직후 Undo 무반응, 파괴된 오브젝트 참조로 예외 폭주, 키 반복으로 인한 중복 실행, 벽 두께/코너 겹침
다음 단계md 문서 정리 후 다음 기능 논의