SSOT란?

SSOT(Single Source of Truth)는 시스템 내에서 모든 데이터가 단 하나의 출처에서만 관리되어야 한다는 설계 원칙이다. 같은 정보를 여러 곳에서 중복으로 저장하거나 관리하는 대신, 하나의 권위 있는 출처를 두고 나머지는 그 출처로부터 파생(derive)하는 방식이다.

이 원칙은 데이터베이스 정규화와 같은 맥락에서 출발했지만, 소프트웨어 설계 전반에 걸쳐 적용된다:

  • 데이터베이스: 정규화를 통해 중복 데이터를 제거하고, 외래 키로 참조
  • 상태 관리: 하나의 상태 저장소에서 UI를 파생 (React의 state, Redux store 등)
  • 설정 관리: 환경 변수나 설정 파일을 단일 소스로 유지
  • 코드 내 상수: 매직 넘버 대신 하나의 상수를 정의하고 참조

핵심은 단순하다. 같은 사실을 두 곳 이상에서 관리하지 않는다.

SSOT를 위반하면 생기는 일

같은 정보를 여러 곳에서 독립적으로 관리하면, 시간이 지남에 따라 반드시 불일치가 발생한다.

동기화 부담

값 A를 변경할 때 B와 C도 함께 갱신해야 한다면, 그 동기화 로직 자체가 버그의 온상이 된다. 갱신 순서가 달라지거나, 하나를 빠뜨리거나, 예외 발생 시 일부만 갱신되는 상황이 생긴다.

관찰자 시점의 불일치

여러 소스를 구독하는 관찰자(subscriber)는 각 소스의 갱신 타이밍 차이로 인해 중간 상태(intermediate state)를 볼 수 있다. 예를 들어 라운드 인덱스는 3으로 바뀌었는데, 턴 인덱스는 아직 이전 라운드의 값인 상황이다.

유지보수 비용 증가

상태를 추가하거나 수정할 때마다 “이 상태와 동기화해야 하는 다른 상태가 어디 있지?”를 추적해야 한다. 코드베이스가 커질수록 이 비용은 기하급수적으로 증가한다.

핵심

SSOT 위반의 본질적인 문제는 “어느 값이 진짜인가?”를 코드가 아닌 개발자가 판단해야 한다는 것이다. 이는 지속 가능하지 않다.

실제 사례: 게임 라운드 시스템 리팩토링

Unity 기반 게임 프로젝트에서 버그를 추적하던 중이었다. 디버깅 과정에서 라운드 시스템 내 여러 변수들의 초기화 시점이 미묘하게 다르다는 것을 발견했고, 이상함을 느껴 관련 코드를 처음부터 다시 리뷰했다. 그리고 그 구조를 보고 나서야 근본적인 문제가 보였다.

Before - 5개의 독립적인 상태가 같은 사실을 추적

게임의 “현재 어디까지 진행했는가?”라는 단 하나의 사실을, 5개의 서로 다른 변수가 독립적으로 관리하고 있었다.

RoundEngine.cs (Before)
// 1. 라운드 인덱스 - 별도의 Reactive 변수
public Rx<uint> CurrentRoundIndex { get; } = new(0);
 
// 2. 턴 인덱스 - 또 다른 별도의 Reactive 변수
public Rx<int> CurrentActionTurnIndex { get; } = new(0);
 
// 3. 소비 상태 - 또 다른 별도의 Reactive 변수
public Rx<bool> IsProcessing { get; } = new(false);
 
// 4. 최종 라운드 여부 - 또 다른 별도의 Reactive 변수
public Rx<bool> IsFinalRound { get; } = new(false);
Round.cs (Before)
public abstract class Round {
    // 5. Round 자체적으로도 턴 인덱스를 별도 추적
    private int _currentTurnIndex;
    public Turn? CurrentTurn
        => _currentTurnIndex < _turns.Count ? _turns[_currentTurnIndex] : null;
    public Turn? GetNextTurn() { _currentTurnIndex++; /* ... */ }
}

이것들은 전부 같은 사실 - “현재 라운드/턴 진행 상태” - 을 서로 다른 형태로 표현한 것이다.

당연히 수동 동기화 코드가 곳곳에 필요했다:

RoundEngine.cs (Before)
private void IncreaseActionTurnIndex() {
    if (CurrentTurn is not ActionTurn) return;
    CurrentActionTurnIndex.Value += 1;
    IsFinalRound.Value = CurrentRoundIndex.Value == GameConfig.Instance.FinalRoundOffset;
}

그리고 외부에서 이 상태를 구독하려면, 각각에 대해 별도의 바인딩이 필요했다:

StageController.cs (Before)
// 구독자는 세 개의 바인딩을 각각 따로 해야 한다
public IDisposable BindIsFinalRound(Action<bool> action)
    => IsFinalRound.Bind(action);
public IDisposable BindCurrentRoundIndex(Action<uint> onAction)
    => _roundEngine.CurrentRoundIndex.Bind(onAction);
public IDisposable BindCurrentTurnIndex(Action<int> onAction)
    => _roundEngine.CurrentActionTurnIndex.Bind(onAction);

구독자는 세 개의 콜백을 받으면서, 라운드 인덱스는 바뀌었는데 턴 인덱스는 아직 이전 값인 순간을 목격할 수 있다. 이는 앞서 설명한 “관찰자 시점의 불일치”가 실제로 발생하는 전형적인 패턴이다.

After - 하나의 진실, 나머지는 파생

리팩토링 후, 진행 상태를 표현하는 Source of Truth는 단 하나다:

RoundProgress.cs
public readonly struct RoundProgress {
    public readonly int RoundIndex;
    public readonly int TurnIndex;
}

RoundEngine은 이 하나의 Reactive 값만 관리한다:

RoundEngine.cs (After)
// 단 하나의 Source of Truth
private readonly Rx<RoundProgress> _progress;
 
// 모든 것은 _progress로부터 파생
private readonly Round[] _rounds; // 초기화 시 전체 생성
 
public Round CurrentRound => _rounds[_progress.Value.RoundIndex];
public Turn CurrentTurn => CurrentRound.Turns[_progress.Value.TurnIndex];
public bool IsFinalRound => _progress.Value.RoundIndex == GameConfig.Instance.FinalRoundOffset;

상태 변경은 하나의 메서드에서만 일어난다:

RoundEngine.cs (After)
private void AdvanceRoundProgress() {
    var current = _progress.Value;
    var nextTurnIndex = current.TurnIndex + 1;
 
    if (nextTurnIndex < _rounds[current.RoundIndex].Turns.Count)
        _progress.Value = new RoundProgress(current.RoundIndex, nextTurnIndex);
    else
        _progress.Value = new RoundProgress(current.RoundIndex + 1, 0);
}

구독도 하나로 통합되었다. 구독자는 모든 파생값이 계산된 완전한 스냅샷을 한 번에 받는다:

RoundEngine.cs (After)
public IDisposable BindRoundProgress(Action<RoundSnapshot> onProgressChanged) {
    return _progress.Bind(progress => {
        onProgressChanged(new RoundSnapshot(
            progress.RoundIndex, progress.TurnIndex,
            CurrentActiveTurnIndex, CurrentActionIndex,
            IsFinalRound, CurrentRound, CurrentTurn
        ));
    });
}

RoundTurn도 순수한 데이터 구조로 단순화되었다:

Round.cs (After)
// 자체 상태 없음 - 순수 구조체
public abstract class Round {
    private List<Turn>? _turns;
    public IReadOnlyList<Turn> Turns => _turns ??= CreateTurns();
    protected abstract List<Turn> CreateTurns();
}

변경 요약

BeforeAfter
Source of Truth5개 (RoundIndex, TurnIndex, IsProcessing, IsFinalRound, Round._currentTurnIndex)1개 (Rx<RoundProgress>)
동기화 코드수동 (IncreaseIndex 후 나머지 갱신)불필요 (파생값 자동 계산)
구독 방식3개 별도 바인딩 (불일치 가능)1개 통합 스냅샷 (원자적)
Round/Turn상태 + 실행 로직 혼재순수 데이터 구조

SSOT 적용 체크리스트

코드에서 SSOT 위반을 발견하고 개선할 때 참고할 수 있는 체크리스트다:

  • 같은 사실을 표현하는 변수가 2개 이상 존재하는가? - 있다면, 하나를 원천으로 정하고 나머지는 파생시킨다
  • 값을 변경할 때 다른 곳도 함께 갱신해야 하는가? - 수동 동기화가 필요하다면 SSOT 위반 신호다
  • 파생 가능한 값을 별도로 저장하고 있는가? - 계산으로 구할 수 있는 값은 저장하지 않는다
  • 구독자가 여러 소스를 조합해야 완전한 상태를 알 수 있는가? - 하나의 출처에서 완전한 스냅샷을 제공한다

SSOT는 거창한 아키텍처 패턴이 아니다. “같은 사실을 두 곳에서 관리하지 말라”는, 단순하지만 놀라울 정도로 자주 위반되는 원칙이다.

P.S.

위에서 다룬 사례도 처음부터 저런 구조였던 것은 아니다. 각자 기능을 개발하면서 필요한 값을 하나씩 추가하다 보니, 어느 순간 같은 사실을 표현하는 변수가 여러 곳에 흩어져 있었을 뿐이다. SSOT 위반은 대부분 이렇게 점진적으로 발생한다.

최근 AI agent를 활용한 코딩이 보편화되면서 이 문제는 더 주의가 필요해졌다고 생각한다. AI는 기존 구조를 전체적으로 파악하기보다 당장 요청받은 기능을 동작시키는 데 집중하는 경향이 있고, 그 과정에서 이미 존재하는 값을 파생하는 대신 새 변수를 선언하는 방향으로 흐르기 쉽다. 사람이 직접 작성할 때보다 변수가 무분별하게 늘어날 가능성이 높은 만큼, 코드 리뷰에서 “이 값은 이미 다른 곳에서 관리되고 있지 않은가?”를 의식적으로 확인할 필요가 있다.