Unity - '한 우산 아래'

퍼즐 연결 구조 리팩터링하기

망고와플 2026. 4. 15. 16:27

https://github.com/flint21c1996/Umbrella/tree/main/Assets/Scripts/Puzzles

 

Umbrella/Assets/Scripts/Puzzles at main · flint21c1996/Umbrella

Unity프로젝트 <한 우산 아래>. Contribute to flint21c1996/Umbrella development by creating an account on GitHub.

github.com

 

이번에는 퍼즐끼리 연결되는 구조를 다시 정리했다.

처음에는 무게 발판을 만들고, 발판이 눌리면 문이나 플랫폼이 움직이도록 했다.

구조는 단순했다.

WeightedButton
→ OnPressed
→ PuzzleMover.Activate()

버튼 하나가 문 하나를 여는 정도라면 이 방식도 충분하다.

Unity Inspector에서 OnPressed 이벤트에 문을 연결하면 바로 동작하기 때문이다.

하지만 퍼즐이 조금만 복잡해져도 문제가 보이기 시작했다.

예를 들어 버튼 두 개가 모두 눌려야 문이 열린다면 어떻게 해야 할까?

버튼 A가 눌림
버튼 B가 눌림
둘 다 눌렸을 때만 문 열림

이런 구조에서는 버튼 하나가 문을 직접 여는 방식이 애매해진다.

버튼 A도 문을 열 수 있고, 버튼 B도 문을 열 수 있다면 둘 다 눌렸는지를 어디서 판단해야 할지 애매해진다.

그래서 버튼이 결과를 직접 실행하지 않고, 중간에 조건을 모아서 판단하는 구조가 필요하다고 느꼈다.

기존 구조의 문제

기존에는 WeightedButton 안에 UnityEvent가 있었다.

WeightedButton
- OnPressed
- OnReleased

그리고 Inspector에서 OnPressed에 문을 연결했다.

OnPressed → PuzzleMover.Activate()
OnReleased → PuzzleMover.Deactivate()

처음에는 편했다.

버튼에서 바로 문을 연결하면 됐기 때문이다.

하지만 이 방식은 버튼이 많아질수록 연결이 복잡해진다.

예시)
//가능
버튼 A → 문 A  
버튼 B → 문 B  
버튼 C → 플랫폼 C 연결 

//여기서부터는 가능하다고 확답이 안됨(조건 조합이 추가되니까..), 코드가 계속 추가되면서 더러워질수 있음.
버튼 A + 버튼 B → 문 D
물 저장량 + 버튼 C → 플랫폼 E 연결

이런 식으로 조건 조합이 생기면 버튼이 결과를 직접 실행하는 방식은 점점 관리하기 어려워진다.

그래서 구조를 이렇게 바꿨다.

조건 소스들
→ 조건 그룹
→ 결과 이벤트

새 구조

새 구조에서는 퍼즐을 세 부분으로 나눴다.

PuzzleConditionSource
- 조건 하나를 나타낸다.
- 예: 눌린 버튼, 충분한 물 저장량, 켜진 레버

PuzzleConditionGroup
- 여러 조건을 모아서 검사한다.
- 모든 조건이 만족되면 결과 이벤트를 실행한다.

PuzzleMover
- 실제 문이나 플랫폼을 움직인다.

즉 버튼이 문을 직접 여는 것이 아니라, 버튼은 나는 눌렸는가? 만 알려준다.

조건 그룹은 여러 조건을 보고 모두 만족되었는가? 를 판단한다.

그리고 조건이 모두 만족되면 문이나 플랫폼을 움직인다.

WeightedButton
→ PuzzleConditionGroup
→ PuzzleMover
(아래 스크린샷을 예시로 WeightedButton → PuzzleConditionGroup → WeightDoor 순)

PuzzleConditionSource 만들기

먼저 모든 퍼즐 조건의 공통 기반이 되는 클래스를 만들었다.

// 퍼즐 조건으로 사용할 수 있는 컴포넌트의 공통 기반 클래스.
// 예: 눌린 발판, 켜진 레버, 충분한 물이 담긴 물체.
public abstract class PuzzleConditionSource : MonoBehaviour
{
    // 현재 조건이 만족되었는지 반환한다.
    // 자식 클래스가 자기 방식대로 반드시 구현해야 한다.
    public abstract bool IsSatisfied { get; }

    // 조건 상태가 바뀐 순간에만 발생하는 이벤트.
    public event Action Changed;

    // 자식 클래스가 만족/불만족 상태를 바꾼 뒤 호출한다.
    protected void NotifyChanged()
    {
        Changed?.Invoke();
    }
}

여기서 핵심은 두 가지다.

1. IsSatisfied
2. Changed

IsSatisfied는 현재 조건이 만족되었는지 알려준다.

Changed는 조건 상태가 바뀌었을 때 조건 그룹에게 알려준다.

이 구조를 만들면 조건 그룹은 버튼인지, 레버인지, 물 저장량인지 몰라도 된다.

그냥 IsSatisfied만 읽으면 된다.

SOLID 원칙 고민

이 구조를 만들면서 SOLID 원칙도 조금 고민했다.

특히 단일 책임 원칙, 즉 SRP를 생각하게 됐다.

처음에는 물을 받는 오브젝트인 UmbrellaWaterTarget을 바로 조건으로 만들 수도 있다고 생각했다.

public class UmbrellaWaterTarget : PuzzleConditionSource
{
    public override bool IsSatisfied => ReceivedWater >= requiredWater;
}

이렇게 하면 편하다.

물을 일정량 이상 담은 오브젝트를 바로 PuzzleConditionGroup에 넣을 수 있기 때문이다.

하지만 곰곰이 생각해보면 UmbrellaWaterTarget의 원래 역할은 우산으로부터 물을 받는 대상이다.

(이 부분은 다음 글에서 설멸하겠다.)

이미 이 클래스는 다음 일을 맡고 있다.

물을 받는다.
- 현재 저장량을 관리한다.
- 저장량에 따라 색을 바꾼다.
- 물 무게를 Rigidbody 질량에 반영한다.

여기에 퍼즐 조건으로 동작한다는 책임까지 추가하면 클래스의 의미가 점점 커진다.

그래서 물 저장량을 퍼즐 조건으로 바꾸는 역할은 별도 컴포넌트로 분리하기로 했다.

UmbrellaWaterTarget
- 물을 받고 저장량을 관리한다.

WaterAmountCondition
- UmbrellaWaterTarget의 저장량을 읽는다.
- 그 값이 퍼즐 조건을 만족하는지 판단한다.

이렇게 하면 컴포넌트는 하나 늘어나지만 역할은 더 분명해진다.

public class WaterAmountCondition : PuzzleConditionSource
{
    [SerializeField] private UmbrellaWaterTarget waterTarget;

    public override bool IsSatisfied => IsWaterRequirementMet();

    private bool IsWaterRequirementMet()
    {
        return waterTarget != null && waterTarget.ReceivedWater >= RequiredWater;
    }
}

이 구조는 DIP, 의존성 역전 원칙과도 조금 맞닿아 있다.

PuzzleConditionGroup은 구체적인 버튼, 물 저장량, 레버 클래스를 직접 알 필요가 없다.

[SerializeField] private PuzzleConditionSource[] conditionSources;

조건 그룹은 오직 공통 부모인 PuzzleConditionSource만 바라본다.

나는 이 조건이 버튼인지 물인지 레버인지 몰라도 된다.
IsSatisfied만 알려주면 된다.

나중에 새로운 조건을 추가해도 PuzzleConditionGroup을 수정할 필요가 적다.

예를 들어 나중에 레버 조건을 만든다면 이런 식으로 추가할 수 있다.

public class LeverCondition : PuzzleConditionSource
{
    public override bool IsSatisfied => isOn;
}

이 클래스도 PuzzleConditionSource를 상속하기만 하면, 기존 조건 그룹에 그대로 넣을 수 있다.

결국 이 구조의 핵심은 편의성보다 책임 분리를 선택한 것이다.

UmbrellaWaterTarget을 바로 조건으로 만들면 편하다.
하지만 물 받기와 퍼즐 조건 판정이 한 클래스에 섞인다.

WaterAmountCondition을 따로 만들면 컴포넌트는 하나 늘어난다.
대신 역할이 분리되고 확장하기 쉬워진다.

WeightedButton을 조건 소스로 바꾸기

이제 WeightedButton은 PuzzleConditionSource를 상속한다.

public class WeightedButton : PuzzleConditionSource
{
    [SerializeField] private bool isPressed;
    [SerializeField] private float currentWeight;

    public bool IsPressed => isPressed;

    // ConditionGroup은 이 값을 보고 버튼 조건이 만족되었는지 판단한다.
    public override bool IsSatisfied => IsPressed;
}

버튼 입장에서는 단순하다.

눌렸으면 true
안 눌렸으면 false

그리고 버튼 상태가 바뀌는 순간 NotifyChanged()를 호출한다.

private void SetPressed(bool pressed)
{
    // 상태가 같으면 같은 이벤트를 반복해서 보내지 않는다.
    if (isPressed == pressed)
    {
        return;
    }

    isPressed = pressed;

    // 조건 그룹에게 상태가 바뀌었다고 알린다.
    NotifyChanged();
}

이제 버튼은 더 이상 문을 직접 열지 않는다.

버튼은 자기 상태만 관리한다.

이게 중요한 변화였다.

이전:
버튼이 문을 직접 움직임

이후:
버튼은 조건만 제공함
조건 그룹이 결과를 실행함

PuzzleConditionGroup

조건들을 모아서 판단하는 역할은 PuzzleConditionGroup이 맡는다.

public class PuzzleConditionGroup : MonoBehaviour
{
    [Header("Conditions")]
    [SerializeField] private PuzzleConditionSource[] conditionSources;

    [Header("Events")]
    [SerializeField] private UnityEvent onSatisfied;
    [SerializeField] private UnityEvent onUnsatisfied;

    [SerializeField] private bool satisfied;
}

여기서 conditionSources 배열에 필요한 조건들을 넣는다.

예를 들어 버튼 두 개가 모두 눌려야 문이 열리는 퍼즐이라면:

Condition Sources
- WeightButton
- WeightButton (1)

이렇게 넣으면 된다.

그리고 On Satisfied에는 문을 여는 이벤트를 연결한다.

On Satisfied
→ WeightDoor.PuzzleMover.Activate()

필요하다면 On Unsatisfied에는 문을 멈추거나 닫는 이벤트를 연결할 수 있다.

On Unsatisfied
→ WeightDoor.PuzzleMover.Pause()

매 프레임 검사하지 않기

처음 생각만 하면 조건 그룹이 매 프레임 모든 조건을 검사해도 될 것처럼 보인다.

private void Update()
{
    RefreshSatisfiedState();
}

하지만 굳이 그럴 필요가 없었다.

조건은 매 프레임 바뀌는 것이 아니라, 버튼이 눌리거나 풀리는 순간에만 바뀐다.

그래서 Changed 이벤트를 구독하는 방식으로 만들었다.

private void SubscribeConditions()
{
    if (conditionSources == null)
    {
        return;
    }

    for (int i = 0; i < conditionSources.Length; i++)
    {
        PuzzleConditionSource condition = conditionSources[i];

        if (condition == null)
        {
            continue;
        }

        // 중복 구독을 막기 위해 먼저 제거한 뒤 다시 등록한다.
        condition.Changed -= OnConditionChanged;
        condition.Changed += OnConditionChanged;
    }
}

조건이 바뀌면 OnConditionChanged()가 호출된다.

private void OnConditionChanged()
{
    RefreshSatisfiedState(true);
}

즉, 조건 그룹은 평소에는 가만히 있다가 조건이 바뀌는 순간에만 다시 계산한다.

버튼 눌림
→ WeightedButton.NotifyChanged()
→ PuzzleConditionGroup.OnConditionChanged()
→ 모든 조건 검사
→ 결과 이벤트 실행

이 방식이 더 깔끔하다.

불필요하게 매 프레임 조건을 검사하지 않아도 되고, 조건이 바뀐 순간만 처리할 수 있다.

모든 조건 검사하기

조건 그룹은 모든 조건이 만족되었는지 검사한다.

private bool AreAllConditionsSatisfied()
{
    // 조건이 하나도 없으면 실수로 항상 성공하지 않게 false로 본다.
    if (conditionSources == null || conditionSources.Length == 0)
    {
        return false;
    }

    for (int i = 0; i < conditionSources.Length; i++)
    {
        PuzzleConditionSource condition = conditionSources[i];

        // 하나라도 비어 있거나 만족되지 않았으면 실패.
        if (condition == null || !condition.IsSatisfied)
        {
            return false;
        }
    }

    return true;
}

중요한 점은 조건이 하나도 없으면 false로 처리했다는 것이다.

조건이 비어 있는데 자동으로 성공해버리면, 실수로 문이 열리는 퍼즐이 생길 수 있기 때문이다.

상태가 바뀔 때만 이벤트 실행하기

조건 그룹은 상태가 바뀌었을 때만 이벤트를 호출한다.

private void RefreshSatisfiedState(bool invokeEvents)
{
    bool nextSatisfied = AreAllConditionsSatisfied();

    // 이미 같은 상태라면 이벤트를 반복 호출하지 않는다.
    if (satisfied == nextSatisfied)
    {
        return;
    }

    satisfied = nextSatisfied;

    if (!invokeEvents)
    {
        return;
    }

    if (satisfied)
    {
        onSatisfied.Invoke();
        return;
    }

    onUnsatisfied.Invoke();
}

이렇게 하지 않으면 조건이 만족된 상태에서 같은 이벤트가 계속 반복될 수 있다.

문이 열리는 이벤트가 매번 호출되면, 문이 계속 위로 올라가거나 의도하지 않은 움직임이 생길 수 있다.

그래서 조건이 만족된 순간조건이 풀린 순간에만 이벤트를 실행하게 했다.

이 구조 덕분에 문을 여는 방식도 더 유연해졌다.

On Satisfied
→ Activate()

On Unsatisfied
→ Deactivate()

또는:

On Satisfied
→ Activate()

On Unsatisfied
→ Pause()

이렇게 연결 방식에 따라 퍼즐 반응을 다르게 만들 수 있다.

디버그 연결선

구조를 바꾸면서 F3 디버그 오버레이도 같이 정리했다.

이제 버튼이 문을 직접 연결하지 않기 때문에, 연결선도 이렇게 보이면 된다.

WeightedButton
→ PuzzleConditionGroup
→ PuzzleMover

조건 그룹은 자신에게 연결된 조건과 결과 이벤트를 알고 있으므로, F3를 눌렀을 때 연결 관계를 보여줄 수 있다.

// 조건 소스에서 ConditionGroup으로 향하는 선을 그린다.
// 노란색은 아직 대기 중, 초록색은 만족된 조건이다.
PuzzleDebugOverlay.DrawWorldLine(
    targetCamera,
    conditionPosition,
    sourcePosition,
    color,
    debugLineThickness);

이렇게 해두면 나중에 기획자가 맵을 설계할 때도 연결 관계를 눈으로 확인하기 쉬워진다.

Inspector를 하나씩 열어보지 않아도, 어떤 버튼이 어떤 조건 그룹에 들어가고, 그 조건 그룹이 어떤 문을 움직이는지 볼 수 있다.

하이어라키 세팅 예시

실제로는 이런 식으로 배치할 수 있다.

WeightButton(1), (2)들은 조건이다.

DoorConditionGroup(1)은 조건을 모으는 관리자다.

WeightDoor(3)는 실제 결과다.

이렇게 나누니 각 오브젝트의 역할이 더 분명해졌다.

정리

이번 리팩터링의 핵심은 버튼이 문을 직접 열지 않게 만든 것이다.

이전 구조는 단순했지만, 조건이 여러 개가 되는 순간 확장하기 어려웠다.

그래서 조건과 결과 사이에 PuzzleConditionGroup을 두었다.

이전 구조:
Button → Door

새 구조:
ConditionSource → ConditionGroup → Result

이렇게 바꾸면서 얻은 장점은 다음과 같다.

1. 버튼 여러 개가 동시에 눌려야 하는 퍼즐을 만들 수 있다.
2. 버튼뿐 아니라 다른 조건도 같은 구조에 넣을 수 있다.
3. 조건이 바뀐 순간에만 검사하므로 불필요한 Update가 필요 없다.
4. 결과 이벤트를 Inspector에서 자유롭게 연결할 수 있다.
5. F3 디버그 오버레이로 연결 관계를 확인하기 쉬워졌다.

이번 작업으로 퍼즐 구조가 조금 더 확장 가능해졌다.

이제 버튼뿐 아니라 물 저장량, 레버, 특정 위치 도달 같은 조건들도 같은 방식으로 연결할 수 있다.

다음 글에서는 이 구조를 실제 퍼즐에 적용해서, 물을 일정량 이상 부었을 때 조건이 만족되는 오브젝트를 더 자세히 정리해보려고 한다.