null 없이 상태 관리하기

KINA KIM·2026년 2월 12일

Null Object Pattern — null 없이 상태 관리하기

한 줄 요약

null 대신 { type: 'closed' }라는 "아무것도 아닌 객체"를 만들어서,
?. ?? if (!x) 없이 모든 상태를 동일하게 다룬다.


문제: null이 코드를 복잡하게 만든다

API 요청 결과를 상태로 관리한다고 하자.
성공하면 데이터가 있고, 아직 요청 전이면 null인 구조이다.

type Result =
  | { type: 'success'; data: User[]; updatedAt: number }
  | { type: 'error'; message: string; code: number }
  | null;  // ← 아직 요청 전

const [result, setResult] = useState<Result>(null);

이 순간부터 result를 쓸 때마다 null 체크가 필요하다.

// result가 null일 수 있으니 ?.로 접근
const type = result?.type;
const isSuccess = result?.type === 'success';

// null이면 기본값
const message = result?.type === 'error' ? result.message : '';

// 이전 상태에서 전환할 때도 null 체크
setResult((prev) => {
  if (!prev) return null;  // prev가 null이면 아무것도 못 함
  if (prev.type === 'success') return { ...prev, updatedAt: Date.now() };
  return prev;
});

한두 군데면 괜찮지만, 이 상태를 여러 hook과 컴포넌트에서 참조하면
모든 곳에 ?.와 ??가 퍼진다.

팝업의 열림/닫힘처럼 단순 boolean으로 처리할 수 있는 상태라면 null이어도 상관없다.
하지만 상태에 데이터가 딸려오는 경우 — 성공/에러 데이터, 좌표, 대상 ID 등 —
null은 "데이터가 아직 없다"와 "비활성이다"를 구분하지 못하고,
소비하는 모든 곳에 방어 코드를 강제한다.


해결: "비활성"도 하나의 상태로 만든다

핵심 아이디어는 간단하다.
null 자리에 { type: 'idle' }처럼 명시적인 비활성 객체를 넣는다.

Before:  success | error | null
After:   success | error | idle

Before — null 사용

type Result = Success | Error | null;
//                              ^^^^ 객체가 아니라 "없음"

const result: Result = null;
result?.type    // undefined — ?. 필요
result?.data    // undefined — ?. 필요

After — Idle 타입 사용

type Result = Success | Error | Idle;
//                              ^^^^ 이것도 객체

const result: Result = { type: 'idle' };
result.type     // 'idle' — 안전하게 접근
result.data     // 컴파일 에러! — Idle에는 data가 없다고 알려줌

result가 항상 객체이므로 .type에 바로 접근할 수 있다.
그리고 data 같은 Success 전용 프로퍼티에 접근하면,
TypeScript가 "Idle에는 그 프로퍼티가 없다"고 컴파일 타임에 알려준다.


실제 적용: ContextMenuPopover

타입 정의

// namespace 안에는 타입만 둔다
export namespace ContextMenuPopover {
  type ContextMenu = { type: 'contextMenu'; targetNodeId: string; position: Point };
  type LinkPopup   = { type: 'linkPopup';   targetNodeId: string; position: Point };
  type Closed      = { type: 'closed' };   // ← null 대신

  type State     = ContextMenu | LinkPopup | Closed;
  type OpenState = ContextMenu | LinkPopup;   // ← 열린 상태만 추출
}

State에는 세 가지 경우가 있고, 어떤 경우든 반드시 type 프로퍼티를 가진 객체이다.

닫힘 상수와 타입 가드

// 모듈 스코프에 런타임 값을 둔다 (namespace 밖)
const POPOVER_CLOSED: ContextMenuPopover.Closed = { type: 'closed' } as const;

const isPopoverOpen = (
  state: ContextMenuPopover.State
): state is ContextMenuPopover.OpenState => state.type !== 'closed';
  • POPOVER_CLOSED — 매번 { type: 'closed' } 객체를 새로 만들지 않기 위한 상수
  • isPopoverOpen() — "열려 있는가?"를 물어보는 타입 가드

타입 가드가 하는 일

const activePopover: ContextMenuPopover.State = /* ... */;

// 호출 전: activePopover는 ContextMenu | LinkPopup | Closed 중 하나
if (isPopoverOpen(activePopover)) {
  // 호출 후: TypeScript가 Closed를 제외하고 ContextMenu | LinkPopup으로 좁혀줌
  activePopover.targetNodeId;  // 안전! (ContextMenu, LinkPopup 둘 다 가지고 있음)
  activePopover.position;      // 안전!
}

isPopoverOpen()이 true를 반환하면, TypeScript는 Closed를 자동으로 제외한다.
이후 코드에서 targetNodeId, position에 ?. 없이 접근할 수 있다.


Before / After 비교

1. 초기값

// Before
useState<State>(null)

// After — "닫힘"이라는 의도가 드러남
useState<State>(POPOVER_CLOSED)

2. 닫기

// Before
setActivePopover(null);

// After
setActivePopover(POPOVER_CLOSED);

3. type 접근

// Before — null일 수 있으니 ?.
activePopover?.type === 'contextMenu'

// After — 항상 객체이므로 직접 접근
activePopover.type === 'contextMenu'

4. 프로퍼티 접근 (position)

// Before — null이면 기본값
activePopover?.position ?? { x: 0, y: 0 }

// After — 타입 가드로 안전하게
isPopoverOpen(activePopover) ? activePopover.position : { x: 0, y: 0 }

5. 이전 상태에서 전환

// Before — "prev가 null이면 null 반환"이라는 방어 코드
setActivePopover((prev) => {
  if (!prev) return null;
  return { type: 'linkPopup', targetNodeId: prev.targetNodeId, position: prev.position };
});

// After — 타입 가드의 의도가 명확
setActivePopover((prev) => {
  if (!isPopoverOpen(prev)) return prev;
  return { type: 'linkPopup', targetNodeId: prev.targetNodeId, position: prev.position };
});

6. 유효성 검사

// Before — "있으면서 invalid"를 truthy 체크로 표현
if (activePopover && !isPopoverValid) closePopover();

// After — "열려 있으면서 invalid"가 읽힌다
if (isPopoverOpen(activePopover) && !isPopoverValid) closePopover();

구조 정리

┌─────────────────────────────────────────────────┐
│  namespace ContextMenuPopover (타입 전용)         │
│                                                  │
│  type ContextMenu = { type: 'contextMenu', ... } │
│  type LinkPopup   = { type: 'linkPopup', ... }   │
│  type Closed      = { type: 'closed' }           │
│  type State       = ContextMenu | LinkPopup | Closed │
│  type OpenState   = ContextMenu | LinkPopup      │
└─────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────┐
│  모듈 스코프 (런타임 값)                          │
│                                                  │
│  POPOVER_CLOSED  — 닫힘 상수 (내부 전용)          │
│  isPopoverOpen() — 타입 가드 (export)            │
└─────────────────────────────────────────────────┘
  • namespace에는 타입만 — 프로젝트 규칙(namespace 내 type/interface만 export)을 준수
  • 런타임 값은 모듈 스코프 — React dependency array에도 넣을 필요 없음 (불변 참조)

언제 이 패턴을 쓰면 좋은가

상황추천
열림/닫힘 같은 단순 토글boolean으로 충분
상태가 "있음/없음" 두 가지뿐이고 데이터 없이 한 곳에서만 쓰임null로 충분
상태에 데이터가 딸려오고(좌표, ID 등) 여러 곳에서 참조Null Object 추천
discriminated union에 "비활성" 상태가 필요Null Object 추천
?. ??가 3곳 이상에 퍼져 있음Null Object 추천
나중에 새 상태(예: 'tooltip')가 추가될 수 있음Null Object 추천 (switch exhaustiveness)

핵심 정리

  1. null은 "값이 없다"이고, { type: 'closed' }는 "닫힘이라는 값이 있다"이다.
  2. 모든 상태가 객체이므로 .type에 항상 접근 가능 → ?. 불필요.
  3. 타입 가드 isPopoverOpen()으로 열린 상태를 좁히면 → 나머지 프로퍼티에도 ?. 불필요.
  4. "없음"을 값으로 표현하면, TypeScript가 빠뜨린 케이스를 컴파일 타임에 잡아준다.
profile
까먹지 않으려고 쓰는 기록장

0개의 댓글