profile image

L o a d i n g . . .

728x90

최근에 제가 속한 팀에서는 유저 모으기를 위한 프로모션 이벤트를 준비했습니다. 해당 이벤트를 위한 프로모션은 준비 기간이 짧았지만, 개발 스펙이 작지 않아 모두 고생했던 기억이 있습니다. 처음에는 개발 스펙을 받았을 때는 무수히 많은 에지 케이스가 숨어있음을 알지 못했습니다. 유저가 이벤트에 참여하면 유저의 참여 정도에 따라 상태가 변화하는데, 각 상태마다 무수한 에지 케이스가 있어 이를 어떻게 효과적으로 컨트롤할 수 있을지 고민했습니다. 그 결과 개별 상태와 상태별로 가능한 동작을 효과적으로 제어하기 위해 state machine pattern을 활용하기로 결정했습니다.

State machine pattern은 소프트웨어 설계 패턴으로, 상태를 관리하는 데 사용됩니다. 특히 상태의 수가 정해진 경우에 매우 유용합니다. State machine을 통해 모든 가능한 상태를 정의하고 다른 상태로의 전이를 정의할 수 있습니다. 이벤트에서 유저의 각 상태는 다음과 같이 나타낼 수 있습니다:

각 단계별 유저의 상태

 

우선은 상태를 나타낼 수 있는 인터페이스를 정의합니다. 이 인터페이스에는 상태 전이를 수행할 수 있는 메서드를 정의하며, 각 메서드는 기본적으로 지원하지 않는 동작임을 나타내기 위해 exception을 던지도록 설정합니다.

interface EventState {
    fun participate(): EventState {
        throw UnsupportedOperationException("참여가 불가능한 상태입니다")
    }

    fun accomplish(): EventState {
        throw UnsupportedOperationException("미션을 달성할 수 없는 상태입니다")
    }

    fun requestReward(): EventState {
        throw UnsupportedOperationException("보상 요청이 불가능한 상태입니다")
    }

    fun rewarded(): EventState {
        throw UnsupportedOperationException("보상이 지급될 수 없는 상태입니다")
    }

    fun cancel(): EventState {
        throw UnsupportedOperationException("취소가 불가능한 상태입니다")
    }
}

 

위에서 정의한 메서드를 상태 다이어그램에 표시하면 아래와 같습니다. 

상태 다이어그램

 

각 상태를 정의하고, 해당 상태에서 호출 가능한 메서드를 오버라이딩을 통해 구현하면 됩니다. 오버라이딩하지 않은 메서드는 기본적으로 exception을 던지기 때문에 상태 변경이 불가능함을 나타냅니다. 각 상태의 구현은 아래와 같습니다:

class 참여_EventState : EventState {
    override fun accomplish(): EventState {
        return 보상_요청_가능_EventState()
    }

    override fun cancel(): EventState {
        return 취소_재시작_가능_EventState()
    }
}

class 보상_요청_가능_EventState : EventState {
    override fun requestReward(): EventState {
        return 보상_지급_요청_EventState()
    }

    override fun cancel(): EventState {
        return 취소_재시작_가능_EventState()
    }
}

class 보상_지급_요청_EventState : EventState {
    override fun rewarded(): EventState {
        return 보상_지급_EventState()
    }

    override fun cancel(): EventState {
        return 취소_재시작_불가능_EventState()
    }
}

class 보상_지급_EventState : EventState {
    override fun cancel(): EventState {
        return 취소_재시작_불가능_EventState()
    }
}

class 취소_재시작_가능_EventState : EventState {
    override fun participate(): EventState {
        return 참여_EventState()
    }
}

class 취소_재시작_불가능_EventState : EventState {
}

 

정상적인 상태 변경은 아래와 같이 수행됩니다. 

val 참여 = 참여_EventState()

val 보상_요청_가능 = 참여.accomplish()

val 보상_지급_요청 = 보상_요청_가능.requestReward()

val 보상_지급 = 보상_지급_요청.rewarded()

 

만약 비정상적인 상태 변경이 요청되면 어떻게 될까요? 예를 들어 참여 상태에서 보상 지급을 요청하는 경우 참여 상태는 requestReward()를 오버라이딩하지 않았기 때문에 에러가 발생합니다. 

val 참여 = 참여_EventState()

val 보상_지급_요청 = 참여.requestReward()

val 보상_지급 = 보상_지급_요청.rewarded()

 

자, 그렇다면 state machine pattern을 활용하면 어떻게 인지 부하를 최소화할 수 있을까요? 유저가 이벤트에 참여한 상태와 관련된 코드를 작성한다고 생각해 보겠습니다. 만약 state machine pattern을 사용하지 않았다면, 유저의 상태는 어떠한 메서드에서든 모두 변경될 수 있습니다.

State machine pattern을 활용하지 않았더라면 우리는 user.status = "참여"와 같은 코드로 유저의 상태를 변경했을 확률이 높습니다. 

 

그리고 유저의 상태를 변경하는 모든 코드에서 예외 처리를 해주어야 합니다. 이는 유저의 상태 변경과 예외 처리가 소스 코드 여러 곳에 산재되면서 관리가 어려워지고 오류 추적이 매우 어려워집니다.

그러나 state machine pattern을 사용한다면 상황은 달라집니다. 상태의 변경과 예외 처리는 각 상태 클래스에 포함되어 있기 때문에 변경과 예외 처리와 관련된 코드가 응집성 있게 모이게 됩니다. 이러한 응집도 높은 코드 덕분에 에지 케이스를 식별하기 쉬워지고 에러 처리도 용이해집니다. 또한, 유저 상태를 캐싱하는 기능을 추가한다고 가정했을 때, 캐시의 적용과 캐시 무효화 코드를 어디에 추가하면 되는지 판단이 쉬워집니다. 

State machine pattern의 또 다른 장점은 커뮤니케이션 비용을 낮출 수 있다는 점입니다. 개발이 완료되면 프론트와의 연동, QA, 데이터 분석가 및 기획자들과 지속적으로 커뮤니케이션을 진행해야 합니다. State machine에서 정의한 각 state를 공용 용어로 사용하면 그 커뮤니케이션 비용이 훨씬 적어집니다. 또한, state machine을 표현한 다이어그램을 함께 제공함으로써 스스로 상태 플로우를 이해하고 에러가 어떤 상황에서 발생할 수 있는지 이해할 수 있다는 장점이 있습니다.

짧은 개발 기간이었지만 state machine pattern을 잘 활용함으로써 견고한 애플리케이션을 개발할 수 있는 기회였습니다. 상태의 수가 유한하고 다양한 에지 케이스를 철저하게 방지하고 싶다면 해당 패턴을 적용해 보는 것을 추천드립니다.

728x90
복사했습니다!