상황

결재 시스템에 필요한 상태를 아주 간략하게 표현했다.
사용자가 결재를 저장하고 상신요청을 하게되면 결재 라인을 타게되어 결재자가 기안문서를 볼 수 있다.
이후 결재 라인에 결재자 A, B, C 가 존재한다고 가정하고 정상적인 시나리오만 작성해 본다.
모두 승인하는 케이스
- 사용자가 기안을 올리면 상태는 저장에서 상신요청으로 변경된다.
- A가 결재를 승인하면 결재 상태는 상신 요청에서 진행중으로 변경된다.
- B가 결재를 승인하면 결재 상태는 그대로 진행중이다.
- C가 결재를 승인하면 결재 상태는 진행중에서 완료로 종결된다.
한명이라도 반려하는 케이스
- A가 결재를 승인하면 결재 상태는 상신 요청에서 진행중으로 변경된다.
- B가 결재를 반려하면 결재 상태는 반려로 종결된다.
문제점
위 상태 흐름대로 해피 케이스만 실행되면 좋지만 항상 요구사항은 변하고 이로인한 버그 발생우려가 있다.
- 개발자의 실수로 상신요청에서 다음 상태가 진행중이 아닌 완료로 처리
- 완료(종결) 처리된 상태를 진행중으로 되돌리는 케이스
- 등등..
이렇게 현재 상태를 체크하지 않고 상태를 적용한다면 순서가 엉망이 될 수 있다.
당연히 현재 상태를 체크해서 다음 상태가 무엇이고 적절한지 판단하는 검증 로직을 만들어야 하겠지만,
수많은 분기가 발생하게 되고 코드의 복잡도는 올라간다.
지금 예제는 상태가 5개 뿐이지만 더 많은 상태가 존재한다고 상상해 봤을 때 꾀나 복잡해진다.
이런 문제를 깔끔하게 풀어나갈 디자인 패턴이 있는데 바로 상태 패턴이다.
상태 패턴
객체의 내부 상태가 변경될 때 객체의 행동이 바뀌도록 하는 디자인 패턴이다.
객체가 여러 가지 상태를 가질 수 있고 그 상태에 따라 다른 동작을 해야 할 때 유용하다.
상태 패턴을 구현하는 방식은 여러가지가 있지만 이 포스트에서는 두가지만 소개한다.
1. Enum 사용
사용할 상태를 enum에 나열한다.
enum class ApprovalStatus(
val description: String,
) {
SAVED("임시 저장"),
REQUESTED("상신요청"),
IN_PROGRESS("검토중"),
COMPLETED("승인완료"),
REJECTED("반려"),
;
}
이제 상태마다 각각 동작할 수 있는 메시지를 다음, 완료, 반려로 추상화시켰다.
interface ApprovalStateMachine {
fun next(): ApprovalStatus
fun complete(): ApprovalStatus
fun reject(): ApprovalStatus
}
이제 이 인터페이스를 enum에서 구현하기만 하면된다.
enum class ApprovalStatus(
val description: String,
) : ApprovalStateMachine {
SAVED("임시 저장") {
override fun next() = REQUESTED
override fun complete() = throw IllegalStateException("현재 상태는 $description 상태입니다. 진행중 상태로 변경이 필요합니다.")
override fun reject() = throw IllegalStateException("현재 상태는 $description 상태입니다. 진행중 상태로 변경이 필요합니다.")
},
REQUESTED("상신요청") {
override fun next() = IN_PROGRESS
override fun complete() = throw IllegalStateException("현재 상태는 $description 상태입니다. 진행중 상태로 변경이 필요합니다.")
override fun reject() = throw IllegalStateException("현재 상태는 $description 상태입니다. 진행중 상태로 변경이 필요합니다.")
},
IN_PROGRESS("진행중") {
override fun next() = this
override fun complete() = COMPLETED
override fun reject() = REJECTED
},
COMPLETED("승인완료") {
override fun next() = throw IllegalStateException("이미 승인 완료되었습니다.")
override fun complete() = throw IllegalStateException("이미 승인 완료되었습니다.")
override fun reject() = throw IllegalStateException("이미 승인 완료되었습니다.")
},
REJECTED("반려") {
override fun next() = throw IllegalStateException("이미 반려되었습니다.")
override fun complete() = throw IllegalStateException("이미 반려되었습니다.")
override fun reject() = throw IllegalStateException("이미 반려되었습니다.")
},
;
}
Entity에 적용
@Entity
class Approval(
@Enumerated(EnumType.STRING) // enum 컨버터
var status: ApprovalStatus,
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Int? = null,
) {
fun next() {
this.status = this.status.next()
return toHistory()
}
fun complete(): ApprovalHistory {
this.status = this.status.complete()
return toHistory()
}
fun reject(): ApprovalHistory {
this.status = this.status.reject()
return toHistory()
}
...
}
2. 클래스 사용
- 상태를 그룹화하여 중복을 없앨 수 있다.
- 모든 상태가 한눈에 들어오지 않는다는 단점이 있을 수 있다.
- Converter를 정의해야 한다.
추상 클래스 / 팩토리 정의
abstract class ApprovalState( // enum 클래스와 차이를 두려고 이름 변경
val code: String
) {
abstract fun next(): ApprovalState
abstract fun complete(): ApprovalState
abstract fun reject(): ApprovalState
companion object {
fun from(code: String): ApprovalState {
return when (code) {
"SAVED" -> SavedStatus()
"REQUESTED" -> MiddleStatus.request()
"IN_PROGRESS" -> MiddleStatus.inProgress()
"COMPLETE" -> EndStatus.complete()
"REJECTED" -> EndStatus.reject()
else -> throw IllegalArgumentException("Unknown approval state: $code")
}
}
}
}
각 상태 클래스가 코드를 초기화 할 수 있게 만들었다.
시작 상태
abstract class StartStatus(code: String) : ApprovalState(code) {
override fun complete() = throw IllegalStateException("진행중 상태가 아닙니다.")
override fun reject() = throw IllegalStateException("진행중 상태가 아닙니다.")
}
class SavedStatus : StartStatus("SAVED") {
override fun next() = RequestedStatus()
}
중간 상태
abstract class MiddleStatus(code: String) : ApprovalState(code) {
companion object {
fun request() = RequestedStatus()
fun inProgress() = InProgressStatus()
}
}
class RequestedStatus : MiddleStatus("REQUESTED") {
override fun next() = inProgress()
override fun complete(): ApprovalState = throw IllegalStateException("진행중 상태가 아닙니다.")
override fun reject() = throw IllegalStateException("진행중 상태가 아닙니다.")
}
class InProgressStatus : MiddleStatus("IN_PROGRESS") {
override fun next() = this
override fun complete() = EndStatus.complete()
override fun reject() = EndStatus.reject()
}
종료 상태
abstract class EndStatus(code: String) : ApprovalState(code) {
override fun next() = throw IllegalStateException("이미 종결 되었습니다.")
override fun complete() = throw IllegalStateException("이미 종결 되었습니다.")
override fun reject() = throw IllegalStateException("이미 종결 되었습니다.")
companion object {
fun complete() = CompleteStatus()
fun reject() = RejectStatus()
}
}
class CompleteStatus : EndStatus("COMPLETE")
class RejectStatus : EndStatus("REJECTED")
Converter 등록
@Converter(autoApply = true)
class ApprovalStateConverter : AttributeConverter<ApprovalState, String> {
override fun convertToDatabaseColumn(state: ApprovalState): String {
return state.code
}
override fun convertToEntityAttribute(code: String): ApprovalState {
return ApprovalState.from(code)
}
}
Entity에 적용
@Entity
class Approval(
var status: ApprovalState,
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Int? = null,
) {
fun next() {
this.status = this.status.next()
return toHistory()
}
fun complete(): ApprovalHistory {
this.status = this.status.complete()
return toHistory()
}
fun reject(): ApprovalHistory {
this.status = this.status.reject()
return toHistory()
}
...
}
컨버터 옵션 autoApply를 활성화했기 때문에 필드위에 컨버터를 명시하지 않아도 동작한다.
Enum 사용 테스트 코드
@DisplayName("도메인 - 계약 승인 테스트")
class ApprovalTest : StringSpec({
"'임시저장' 상태의 다음 상태는 '승인요청' 상태가된다" {
SAVED.next() shouldBe REQUESTED
}
"'승인요청' 상태의 다음 상태는 '진행중' 상태가 된다" {
REQUESTED.next() shouldBe IN_PROGRESS
}
"'진행중' 상태의 다음 상태는 그대로이다" {
IN_PROGRESS.next() shouldBe IN_PROGRESS
}
"'진행중' 상태에서 종결시킬 수 있다" {
assertSoftly {
IN_PROGRESS.complete() shouldBe COMPLETED
IN_PROGRESS.reject() shouldBe REJECTED
}
}
"'임시저장' 상태에서 곧 바로 종결시킬 수 없다." {
assertSoftly {
shouldThrow<IllegalStateException> { SAVED.complete() }
shouldThrow<IllegalStateException> { SAVED.reject() }
}
}
})

위 예제는 Enum 방식이지만 클래스 방식을 적용해도 사용법은 동일하다.
이렇게 방향이 정해진 상태를 가진다면 상태패턴을 적용하는 것도 나쁘지는 않을 것 같다.
지금까지 본 예제에서는 임시저장 상태에서도 종결을 시키는 함수를 호출할 수 있고, 종결 상태에서 다음 상태를 반환하는 함수를 호출할 수 있다. 물론 예외가 발생하겠지만, 이 부분은 리스코프 치환 원칙에 위배되고 있다.
간단한 예제라 이렇게 소개했지만 제대로 적용하기 위해서는 상태를 추상화시킬 때 좀 더 세분화시킨 후
다음 단계를 반환하는 함수, 현재가 어떤 상태인지 체크하는 함수만 열어두는 것이 객체지향에 가까워 보인다.
참고하면 좋을 내용
https://spring.io/projects/spring-statemachine
Spring Statemachine
Spring Statemachine is a framework for application developers to use state machine concepts with Spring applications. Spring Statemachine aims to provide following features: Easy to use flat one level state machine for simple use cases. Hierarchical state
spring.io