iOS & Swift

Swift 스테이트 패턴(State Pattern)으로 if문 지옥 탈출하기

화면 상태 하나 바꾸려고 if문을 열 개씩 고쳐본 적 있으신가요?

이석우iOS Developer5분 읽기
Swift 스테이트 패턴(State Pattern)으로 if문 지옥 탈출하기 대표 이미지

화면 상태 하나 바꾸려고 if문을 열 개씩 고쳐본 적 있으신가요?

로딩·성공·실패 상태를 전부 if isLoading, else if hasError 로 처리하다 보면 코드가 금세 감당이 안 되죠.

오늘은 Swift 스테이트 패턴으로 if문 지옥을 상태 객체로 정리하는 방법을 실제 경험 위주로 풀어볼게요.

먼저 핵심부터 말씀드리면, 스테이트 패턴은 각 상태를 별도의 객체(또는 enum)로 분리하고 상태 전환 로직을 그 객체 안에 넣는 것입니다. 그러면 조건 분기가 흩어지지 않고 한곳에 모여요.

Swift 스테이트 패턴 적용 전후, 딱 봐도 정리된 느낌이 다르죠
Swift 스테이트 패턴 적용 전후, 딱 봐도 정리된 느낌이 다르죠

이 글에서 얻어갈 것

  1. if문 지옥이 왜 생기는지
  2. Swift에서 상태를 객체로 나누는 두 가지 방법
  3. enum과 protocol 방식 중 언제 뭘 쓸지
  4. 실무에 바로 쓰는 리팩터링 순서

if문 지옥은 왜 생길까요?

처음엔 상태가 두어 개뿐이라 if로 충분해 보여요. 그런데 요구사항이 늘면서 상태가 5개, 6개로 불어납니다. 로딩, 성공, 실패, 빈 화면, 재시도 중… 이런 식으로요.

문제는 이 상태 조합이 여러 메서드에 흩어진다는 점이에요. 버튼 활성화 로직에도 if, 화면 갱신에도 if, 네트워크 콜백에도 if가 반복됩니다. 그래서 상태 하나를 추가하면 그 if 뭉치를 전부 찾아다니며 고쳐야 하고, 하나라도 빠뜨리면 곧장 버그가 돼요.

상태가 흩어지면 버그가 숨고, 상태가 모이면 버그가 드러납니다.

스테이트 패턴은 바로 이 “흩어짐”을 막아주는 구조예요.


Swift에서 상태를 객체로 나누는 법 (enum 방식)

가장 가볍게 시작하는 방법은 enum입니다. 상태를 값으로 정의하고 연관값으로 필요한 데이터까지 담을 수 있어요. 아래는 화면 상태를 enum으로 표현한 예시입니다.

enum ViewState {
    case loading
    case loaded(items: [String]) // 성공 시 데이터 동봉
    case failed(message: String) // 실패 사유 동봉
    case empty
}

이제 화면 갱신은 switch 한 번으로 끝납니다. 연관값 덕분에 “성공인데 데이터가 nil인” 애매한 상태 자체가 생기지 않아요.

switch state {
case .loading:      showSpinner()
case .loaded(let items): render(items)
case .failed(let msg):   showError(msg)
case .empty:        showEmptyView()
}

저는 대부분의 화면 상태는 이 enum 방식으로 충분하더라고요. switch는 케이스를 빠뜨리면 컴파일러가 잡아주니 상태를 추가할 때도 마음이 편해요.

화면 상태를 switch 하나로 모으니 코드가 한결 편해졌어요
화면 상태를 switch 하나로 모으니 코드가 한결 편해졌어요

enum vs protocol, 언제 뭘 쓸까요?

상태마다 “행동”이 크게 다르고 전환 규칙이 복잡하면 protocol 방식이 낫습니다. 각 상태를 타입으로 만들고, 다음 상태를 스스로 반환하게 하는 구조예요. 두 방식의 차이를 정리하면 이렇습니다.

구분 enum 방식 protocol 방식
적합한 상황 상태가 데이터 중심 상태마다 행동이 다름
상태 추가 case 추가 타입 추가
전환 로직 외부에서 switch 상태 객체 내부에 캡슐화
러닝커브 낮음 다소 높음

protocol 방식은 상태 전환을 각 객체가 책임진다는 게 핵심이에요.

protocol PlayerState {
    func play() -> PlayerState  // 다음 상태를 반환
    func pause() -> PlayerState
}

struct PlayingState: PlayerState {
    func play() -> PlayerState { self }        // 이미 재생 중이면 유지
    func pause() -> PlayerState { PausedState() } // 일시정지로 전환
}

이렇게 하면 “재생 중일 때 pause를 누르면?” 같은 규칙이 그 상태 안에만 존재해요. 분기문이 사라지고, 각 상태가 자기 규칙만 알면 됩니다.

상태 스스로 다음 상태를 정하는 protocol 방식 전환도
상태 스스로 다음 상태를 정하는 protocol 방식 전환도

실무 리팩터링, 이 순서로 해보세요

한 번에 다 바꾸려 하면 오히려 겁이 나요. 저는 아래 순서로 조금씩 옮겼습니다.

  1. 흩어진 if가 실제로 표현하는 상태 목록을 종이에 적기
  2. 그 상태들을 enum 하나로 정의하기
  3. 화면 갱신 코드부터 switch로 교체하기
  4. 불가능한 상태 조합(예: 로딩+에러 동시)을 enum으로 아예 제거하기
  5. 전환 규칙이 복잡한 부분만 protocol로 승격하기

포인트는 enum으로 시작해서 필요할 때만 protocol로 올리는 것입니다. 처음부터 protocol로 과하게 설계하면 오히려 코드가 무거워지거든요.

상태를 이렇게 그려보고 나서 enum으로 옮기면 훨씬 쉬워요
상태를 이렇게 그려보고 나서 enum으로 옮기면 훨씬 쉬워요

자주 묻는 질문 (Q&A)

Q. 상태가 3개뿐인데도 패턴을 써야 하나요?

꼭 그럴 필요는 없어요. 다만 상태가 여러 메서드에 흩어져 있다면 개수와 무관하게 enum으로 모으는 게 좋습니다.

Q. SwiftUI에서도 쓸 수 있나요?

네. @Published var state: ViewState 형태로 두고 뷰에서 switch로 분기하면 아주 잘 맞아요.

Q. enum 연관값이 많아지면 지저분하지 않나요?

연관값이 3개를 넘어가면 별도 struct로 묶어서 case loaded(Result) 처럼 담는 걸 추천드려요.


처음엔 낯설어도 상태를 객체로 모으는 감을 한 번 잡으면 이전 코드로 돌아가기 싫어질 거예요. 작은 화면 하나부터 enum으로 정리해보세요. if문 지옥에서 생각보다 빨리 벗어나실 겁니다. 응원할게요!

함께 보면 좋은 글