wan의 개발일기
RoomCraft #22. Undo/Redo, 복제, 벽 스냅 - 세 기능 한 번에 넣고 본 버그들 본문
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();
}
}
ExecuteCommand와 RecordCommand를 나눈 이유는, 드래그 이동처럼 "이미 화면에서 벌어진 일"과 삭제처럼 "지금 이 순간 실행해야 하는 일"이 다르기 때문이다. 드래그는 매 프레임 위치가 바뀌다가 마우스를 뗀 시점에야 최종 위치가 확정되니, 그때 가서 "이만큼 움직였다"고 기록만 하면 된다. 반대로 삭제는 커맨드가 직접 파괴까지 담당해야 한다.
버그 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를 누르면 아무 일도 안 일어났다. 원인은 CreateFurnitureCommand가 RecordCommand() 경로(이미 실행된 생성을 기록만 하는 경우)로 쓰일 때는 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);
벽 두께를 RoomBuilder의 public 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 문서 정리 후 다음 기능 논의 |
'1인 게임개발 활동 > 가구배치 시뮬레이터' 카테고리의 다른 글
| RoomCraft #21. 저장 목록에 미리보기 넣기 - 레이아웃 비교의 첫걸음 (0) | 2026.07.10 |
|---|---|
| RoomCraft #20. 가구도 즐겨찾기가 되나요 - 프리셋 시스템 (0) | 2026.07.10 |
| RoomCraft #19. 있는 척했던 기능 만들기 — 1인칭 시점 (0) | 2026.07.09 |
| RoomCraft #18. 첫 배포 — WebGL + GitHub Pages, 그리고 아직 못 고친 한글 버그 (0) | 2026.07.08 |
| RoomCraft #17. 매 프레임 도는 코드 최적화하기 — 그리고 최적화하다 만든 버그 (0) | 2026.07.08 |