home

Spring Statemachine을 활용한 디즈니러닝+ 커리큘럼 개발기

들어가며,

안녕하세요 아이들나라 Backend팀 채명철입니다.
지난 번 저희 백엔드 개발팀 민경수님께서 음성인식과 발음평가를 이용한 디즈니러닝+ 서비스 구축기 를 작성해주셨는데요. (아직 안 읽어보셨다면 바로 링크를 타고 확인해보세요)
디즈니러닝+에는 발음평가 외에도 영어 학습을 위한 VOD와 인터렉티브가 있는 활동 콘텐츠가 존재합니다. 이 콘텐츠들은 커리큘럼으로 기획되어 아이들에게 제공되고 있습니다.
이번 글에서는 커리큘럼을 구현하기 위해 도입한 Spring Statemachine의 동작 방식과 운영 팁을 공유하겠습니다.

디즈니러닝+의 커리큘럼

디즈니러닝+의 커리큘럼은 Level, Week, Lesson, Act로 구성됩니다. 간략히 정리해보면 다음과 같습니다.
Level: 아이의 영어 수준에 따른 학습을 위해 총 여섯 개의 레벨로 구성됩니다.
Week: 한 주에 학습헤야 할 콘텐츠를 그룹핑하기 위한 단위입니다. 레벨 당 24개의 Week 가 있습니다.
Lesson: 한 개의 Week 는 세 개의 Lesson 으로 구성됩니다.
Act: 아이가 학습할 콘텐츠를 Act라고 부르고 있습니다. Lesson 당 다섯 개의 Act로 구성됩니다.
디즈니러닝+의 커리큘럼 화면

기술 선정

프로젝트 설계 단계에서 디즈니러닝+ 커리큘럼의 기본 개념을 시작으로, 한 주의 학습을 완료하면 차주 월요일에 다음 학습을 시작하는 등의 여러 서비스 정책을 보면서 학습 상태를 잘 관리해야 할 필요성을 느꼈습니다.
하지만 설계 단계에서 상태 다이어그램을 그려보고 나서는 쉽지 않겠다고 생각했습니다.
상태 지옥에 빠졌습니다..
그린 내용을 바탕으로 상태를 직접 정의하고 상태에 따른 동작이나 이벤트 등을 직접 구현하는 방법도 있었습니다.
하지만, 그렇게 한다면 장기적으로 봤을 때 유지보수하기 쉬운 코드를 작성하기가 힘들 것이라고 판단했습니다.
그래서 이미 검증된 라이브러리를 쓰고자 Spring Statemachine을 선정하게 되었습니다.
공식 문서에 정리가 잘 되어 있긴 하지만, 실제 서비스에 적용한 사례를 찾기는 쉽지 않습니다. 그래서 지금부터는 동작 원리를 최대한 간략하게 설명하고, 실무에 도입하면서 알게된 것들을 소개해 보겠습니다.

Spring Statemachine 요약

기본 개념

SSM의 구성요소는 여러 가지가 있지만, 여기서는 네 가지만 간략히 소개하겠습니다.
상태(States)
비즈니스 로직의 상태를 나타내는 역할을 합니다. 에를 들어, 저희는 레슨을 시작하거나 종료하는 때의 상태를 각각 LESSON_START , LESSON_END 라고 정의하는 등 여러 개의 상태를 만들었습니다.
전이(Transitions)
A 라는 상태에서 B 라는 상태로 갈 수 있다는 개념을 표현합니다. 그래서 코드 상에서는 Source와 Target을 직접 정의하도록 인터페이스가 마련되어 있습니다. Statemachine은 더 이상 이동할 수 없을 때까지 상태를 전이시킵니다. 이를 제어하기 위해서는 바로 다음에 소개할 이벤트를 사용해야 합니다.
이벤트(Events)
상태 변화를 일으키는 트리거입니다. 상태 전이를 구성할 때 Source와 Target만 정의했다면 Source 상태에 진입하면 자동으로 Target으로 상태가 바뀝니다. 하지만 여기에 이벤트를 추가하게 되면 이벤트가 발생하기 전까지는 Target 상태로 이동하지 않고, Source 상태에 머물게 됩니다.
액션(Actions)
액션은 상태 또는 전이에 결합하는데, 어떤 상태에 진입하거나 상태를 이동할 때 액션에 정의한 로직을 실행하게 됩니다.
이 개념들을 이용하여 StateMachineConfigurerAdapter 에 정의하면 Statemachine을 만들 수 있습니다.
구체적인 예시는 Spring statemachine Repository에서 확인하실 수 있어요!
이외에도 더 많은 개념들이 있지만, 이번에 모두 다루기에는 내용이 많기 때문에 보다 자세한 내용은 공식 문서를 참조해주세요.
 Spring Statemachine - Reference Documentation

개발 과정에서 알게된 것들

공식 문서를 참고하여 디즈니러닝+ 커리큘럼을 개발하던 도중, 개선하지 않으면 서비스 운영에 적잖은 영향을 미칠 수 있는 사항들이 있었습니다. 이번에는 이 사례들을 소개해보겠습니다.

유저의 요청을 state machine으로 보내고, 그 결과를 받는 방법

프로젝트를 시작할 때 이 부분부터 난관이었습니다. 고민 끝에 생각을 정리할 수 있었는데, 이 부분을 구현하기 위해서는 아래 두 가지가 필요하다고 생각했습니다.
1.
State machine이 start할 때 사용자 message를 전달할 수 있어야 된다.
2.
State machine이 동작 한 후 그 결과를 받을수 있어야 된다.
그래서 선택한 방법은 다음과 같습니다.
1.
특정 state로 이동하게 되었을 때 상태 전이 결과를 state machine에서 받을 수 있도록 리스너를 등록하고,
2.
StatemachinesendEvent 메소드를 이용하여 유저의 요청을 메시지를 전달한다.
이를 구현한 코드는 다음과 같습니다(실제 코드는 아니지만 유사합니다).
@Component class StateMachineMessageSender( private val stateMachineService: StateMachineService<StatemachineStates, StatemachineEvents>, ) { fun sendMessage( message: Message<StatemachineEvents>, machineId: String, vararg statesToWait: StatemachineEvents, ): StatemachineResult { // Statemachine을 가져옵니다. val stateMachine: StateMachine<StatemachineStates, StatemachineEvents> = stateMachineService.acquireStateMachine(machineId) val future: SettableListenableFuture<StatemachineResult> = SettableListenableFuture() /* * StateMachineListenerAdapter을 직접 구현하여 사용했습니다. * 생성자의 두번째 인자로, 어떤 상태에 진입했을 때 statemachine으로부터 결과를 받을지 정의합니다. */ val listener: StateMachineListener<LmsStates, LmsEvents> = LmsStateMachineListenerAdapter(future, listOf(*statesToWait)) stateMachine.addStateListener(listener) future.addCallback( SuccessCallback { stateMachine.removeStateListener(listener) }, FailureCallback { stateMachine.removeStateListener(listener) } ) // Statemachine에 이벤트를 전달합니다. return if (stateMachine.sendEvent(message)) { runCatching { // Statemachine이 결과를 주기까지 block됩니다. future.get(timeout, TimeUnit.MILLISECONDS) }.onFailure { e -> // 예외 처리 }.getOrThrow() } else { throw EventNotAcceptedException(event = message.payload, currentState = stateMachine.state) } } }
Kotlin
복사
위와 같이 StateMachineService.acquireStateMachine() 을 사용하여 유저의 StateMachine 객체를 가져와서 리스너를 등록하고 해당 리스너에 Wait State(현재 State에서 이동할 수 있는 마지막 State)의 list를 전달하여 Wait State가 되면 future로 결과를 받을 수 있게 했습니다.
그리고 유저의 요청은 stateMachine.sendEvent(message) 으로 전달하도록 구현했습니다.

컨텍스트 직렬화

Kryo Serializer

앞서 JPA를 이용하여 상태와 컨텍스트를 DB에 저장한다고 소개했는데요.
컨텍스트를 DB에 저장하고 불러올 때 컨텍스트 인스턴스를 바이트로 직렬화/역직렬화하여 저장하고 불러오도록 되어있습니다. 이 때 사용되는 것이 Kryo Serializer입니다 ('cry-oh' 라고 발음합니다).
다음은 StateContext 인터페이스입니다.
package org.springframework.statemachine; // import ... public interface StateContext<S, E> { Stage getStage(); Message<E> getMessage(); E getEvent(); MessageHeaders getMessageHeaders(); Object getMessageHeader(Object header); ExtendedState getExtendedState(); Transition<S, E> getTransition(); StateMachine<S, E> getStateMachine(); State<S, E> getSource(); Collection<State<S, E>> getSources(); State<S, E> getTarget(); Collection<State<S, E>> getTargets(); Exception getException(); public static enum Stage { EVENT_NOT_ACCEPTED, EXTENDED_STATE_CHANGED, STATE_CHANGED, STATE_ENTRY, STATE_EXIT, STATEMACHINE_ERROR, STATEMACHINE_START, STATEMACHINE_STOP, TRANSITION, TRANSITION_START, TRANSITION_END; } }
Java
복사
인터페이스 명세를 보면 이벤트는 무엇을 받았는지, 이벤트를 받을 때 전달받은 메시지는 어떤 것이었는지, 현재 상태 전이 중인 상태라면 Source와 Target은 무엇인지 등, Statemachine이 동작하는 동안 필요한 정보들이 이 곳에 보관되고 있는 것을 알 수 있습니다.
이러한 내용들이 Kryo serializer를 통해 Serialization/Deserialization 되고 있는 것입니다.

State, Event enum을 사용할 때 주의할 점

위의 인터페이스를 보면, SE 로 State와 Event를 정의한 타입을 명시할 수 있습니다. Object 를 상속받은 어떤 클래스라도 사용할 수 있지만, 저희는 편하게 관리하기 위해 Enum class를 사용하고 있습니다.
그런데 이렇게 enum을 사용했다면 반드시 해당 enum class들의 serializer를 EnumNameSerializer 로 설정해주는 것이 좋습니다.
class CustomKryoStateMachineSerializationService : KryoStateMachineSerialisationService<StatemachineStates, StatemachineEvents>() { override fun configureKryoInstance(kryo: Kryo) { // ... kryo.addDefaultSerializer(StatemachineStates::class.java, EnumNameSerializer::class.java) kryo.addDefaultSerializer(StatemachineStates::class.java, EnumNameSerializer::class.java) } }
Kotlin
복사
이렇게 별도로 지정하지 않으면 kryo는 enum class를 직렬화할 때 ordinal 값을 사용합니다.
이렇게 되면 추가적인 enum을 반드시 목록의 마지막에 써주어야 역직렬화할 때 문제가 없습니다.
그렇지 않고 중간에 추가했다면 추가한 위치 이후로 ordinal 값이 1씩 밀리기 때문에 역직렬화할 때 본래 enum과 다른 값을 가져올 수 있는 것입니다.
이러한 문제를 사전에 예방하려면 EnumNameSerializer 를 사용하는 것이 편합니다.

직렬화 대상의 하위 호환성 고려

public interface StateContext<S, E> { // ... ExtendedState getExtendedState(); }
Java
복사
public interface ExtendedState { Map<Object, Object> getVariables(); <T> T get(Object key, Class<T> type); // ... }
Java
복사
StateContext 인터페이스에는 getExtendedState() 메소드가 있어 컨텍스트 내에 ExtendedState 를 가져올 수 있도록 되어있습니다.
이 안에는 Map<Object, Object> 로 선언된 variables가 있는데, 이것을 Statemachine 내부에서만 사용할 수 있는 전역변수처럼 활용할 수 있습니다.
저희도 다음 액션에 어떤 값을 전달하기 위해 ExtendedStatevariables를 종종 사용했는데요. 이것도 Kryo의 직렬화 대상이기 때문에 하위 호환성을 주의해야 합니다.
variables 에 넣었던 클래스의 필드가 변화할 경우, 그 전에 DB에 저장되어 있던 컨텍스트의 variables를 역직렬화할 때 에러가 발생할 수 있기 때문입니다.
이와 같은 문제를 예방하는 방법도 앞에서 설명했던 방법과 비슷하게 Serializer를 변경해주면 됩니다.
Kryo 공식 위키에 이미 제공하고 있는 Serializer들을 소개하고 있으니 상황에 맞는 Serializer를 선택하여 설정하시면 됩니다.
저희는 Serializer 중에서 하위 호환성만 보장해주는 TaggedFieldSerializer 를 사용했습니다.
class CustomKryoStateMachineSerializationService : KryoStateMachineSerialisationService<StatemachineStates, StatemachineEvents>() { override fun configureKryoInstance(kryo: Kryo) { kryo.addDefaultSerializer(LearningContextDto::class.java, TaggedFieldSerializer::class.java) // ... kryo.addDefaultSerializer(StatemachineStates::class.java, EnumNameSerializer::class.java) kryo.addDefaultSerializer(StatemachineStates::class.java, EnumNameSerializer::class.java) // ... } }
Kotlin
복사

PostgreSQL Large Objects

spring-statemachine-data-jpa 에 Statemachine 엔티티가 이미 정의되어 있습니다.
@Entity @Table(name = "state_machine") @JsonIdentityInfo(generator=ObjectIdGenerators.IntSequenceGenerator.class) public class JpaRepositoryStateMachine extends RepositoryStateMachine { @Id @Column(name = "machine_id") private String machineId; @Column(name = "state") private String state; @Lob @Column(name = "state_machine_context", length = 10240) private byte[] stateMachineContext; // getter & setter }
Java
복사
여기서 컨텍스트는 @Lob 으로 선언되어 있는데요. 저희는 PostgreSQL을 사용하고 있어 이 엔티티 정의대로 테이블을 만들 때 다음과 같이 스키마를 정의했습니다.
create table state_machine ( machine_id varchar(255) not null, state varchar(255), state_machine_context oid, primary key (machine_id) );
SQL
복사
@Lob 에 대응하는 PostgreSQL의 oid 타입을 사용했는데, 이렇게 하면 state_machine_context 에는 정수형 값이 저장되고 실제 데이터는 pg_largeobject 테이블에 저장됩니다.
state_machine_context 은 실제 데이터를 찾기 위한 키 값으로 사용되는 것입니다.
이렇게하면 컨텍스트를 저장하고 불러오는데는 아무런 문제가 없습니다. 다만 상태가 바뀔 때마다 계속 state_machine 테이블에 저장되기 때문에 pg_largeobject 에 데이터가 계속 쌓이게 됩니다.
일반적으로, UPDATE 문을 사용하여 데이터를 업데이트하게 되면 PostgreSQL에서는 MVCC를 구현하기 위해 내부적으로 원래 있던 튜플에 업데이트하는 것이 아니라 기존 내용과 동일한 튜플을 하나 더 만들고 그곳에 새로운 데이터를 업데이트 하도록 구현되어 있습니다.
그러면 이전 튜플은 더 이상 참조되지 않을 때 dead tuple이 되고, PostgreSQL의 auto vaccum에 의해 적절하게 처리됩니다.
더 자세한 내용은 PostgreSQL Vacuum에 대한 거의 모든 것 | 우아한형제들 기술블로그 에 정리되어 있습니다.
하지만 Large Object는 사정이 다릅니다. state_machine 테이블에 컨텍스트를 업데이트할 때, 컨텍스트를 pg_largeobject 에 새롭게 저장한 뒤 state_machine_context 컬럼이 업데이트합니다.
이전 컨텍스트를 나타내는 Large Object는 더 이상 사용될 일이 없지만, pg_largeobject 테이블에서는 여전히 live tuple로 남아있기 때문에 auto vaccum에 의해 처리되지 않고 불필요한 데이터가 계속해서 DB에 남아있게 됩니다.
그래서 저희는 vacuumlo(vacuumlo) 명령어를 주기적으로 실행하는 배치 작업을 만들어 Argo Workflow에 등록했고, 특정 시간이 되면 K8s에 파드가 생성되어 명령어가 실행되도록 만들었습니다.vacuumlo를 실행하는 동안에는 DB의 Read/Write 작업에 영향을 주기 때문에, 이용자가 가장 적은 시간대에 실행되도록 했습니다.

Scale out도 신경써야 합니다

기능을 거의 완성하고, 성능 테스트와 배포 전략을 세우는 프로젝트의 막바지에 이르렀을 때입니다. 부하 분산을 위해 최소 세 대의 서버를 띄울 계획을 가지고 있었는데요.
여기서 복병을 만나게 됩니다.
Statemachine을 가져오기 위해서는 아래와 같이 코드를 작성하면 된다고 앞서 소개한 바 있습니다.
@Component class StateMachineMessageSender( private val stateMachineService: StateMachineService<StatemachineStates, StatemachineEvents>, ) { fun sendMessage( message: Message<StatemachineEvents>, machineId: String, vararg statesToWait: StatemachineEvents, ): StatemachineResult { // Statemachine을 가져옵니다. val stateMachine: StateMachine<StatemachineStates, StatemachineEvents> = stateMachineService.acquireStateMachine(machineId) ... }
Kotlin
복사
여기서 StateMachineService는 별도로 구현하지 않았다면 SSM에서 제공하고 있는 구현체 DefaultStateMachineService를 사용합니다.
그런데 이DefaultStateMachineService에 문제가 있었습니다.
public class DefaultStateMachineService<S, E> implements StateMachineService<S, E>, DisposableBean { private final static Log log = LogFactory.getLog(DefaultStateMachineService.class); private final StateMachineFactory<S, E> stateMachineFactory; private final Map<String, StateMachine<S, E>> machines = new HashMap<String, StateMachine<S, E>>(); private StateMachinePersist<S, E, String> stateMachinePersist; @Override public StateMachine<S, E> acquireStateMachine(String machineId, boolean start) { log.info("Acquiring machine with id " + machineId); StateMachine<S, E> stateMachine; // naive sync to handle concurrency with release synchronized (machines) { stateMachine = machines.get(machineId); if (stateMachine == null) { log.info("Getting new machine from factory with id " + machineId); stateMachine = stateMachineFactory.getStateMachine(machineId); if (stateMachinePersist != null) { try { StateMachineContext<S, E> stateMachineContext = stateMachinePersist.read(machineId); stateMachine = restoreStateMachine(stateMachine, stateMachineContext); } catch (Exception e) { log.error("Error handling context", e); throw new StateMachineException("Unable to read context from store", e); } } machines.put(machineId, stateMachine); } } // handle start outside of sync as it might take some time and would block other machines acquire return handleStart(stateMachine, start); } @Override public StateMachine<S, E> acquireStateMachine(String machineId) { return acquireStateMachine(machineId, true); } // ... }
Java
복사
DefaultStateMachineService 의 구현 일부분을 가져와봤습니다.
acquireStateMachine 메소드를 보면, Map으로 선언된 machines 에서 state machine을 가져오려고 시도하고, 없으면 만든 뒤 맵에 집어넣고 반환하도록 구현되어 있습니다.
서버를 한 대만 운영한다면 문제가 발생할 일이 없겠지만, 여러 대 운영해야한다면 서버마다 가지고 있는 유저 한 명의 Statemachine 상태가 서로 달라지는 문제가 발생할 수 있습니다. 이렇게 되면 유저의 요청을 여러 번 받았을 때 어떠한 상태가 되어 있을지 전혀 예상을 할 수 없게 됩니다.
이러한 문제를 해결하기 위해 acquireStateMachine 가 호출될 때 반드시 DB를 한 번 읽도록 StateMachineService 구현체를 새로 만들었습니다.
@Service class LmsDefaultStateMachineService<S, E>( // ... ) : StateMachineService<S, E>, DisposableBean { // ... override fun acquireStateMachine(machineId: String, start: Boolean): StateMachine<S, E> { logger.info("Acquiring machine with id $machineId") val stateMachine: StateMachine<S, E> = try { val stateMachineContext = stateMachinePersist.read(machineId) restoreStateMachine(stateMachineFactory.getStateMachine(machineId), stateMachineContext) } catch (e: Exception) { logger.error("Error handling context", e) throw StateMachineException("Unable to read context from store", e) } machines.put(machineId + UUID.randomUUID().toString(), stateMachine) return handleStart(stateMachine, start) } // ... }
Kotlin
복사
분산 환경을 위한 spring-statemachine-zookeeper가 있지만, 프로젝트를 마무리해야 하는 단계라 검토하지 못한 것은 아쉬운 부분이었습니다.
DB에 매번 접근해야 하는게 부담이 있지 않을까 했지만, 성능 테스트 결과 저희가 목표로 한 성능은 충분히 감당할 수 있는 수준이었습니다.

마무리

여러 우여곡절 끝에 디즈니러닝+를 오픈할 수 있었고, 지금까지 별다른 문제없이 잘 운영되고 있습니다. 러닝 커브는 있지만 실제 운영에서 충분히 쓸 수 있다는 것을 보여드린 듯 하네요.
저희 아이들나라 학습 파트는 디즈니러닝+로 대표되는 영어 학습에 이어, 국어와 수학 학습도 준비 중인데요.
커리큘럼 기반으로 하는 학습 서비스와 퀴즈백과 등 아이들나라 내에 존재하는 학습 서비스를 통합하여 관리할 수 있는 LMS를 준비하고 있습니다.
특히, 커리큘럼 통합 관리는 LMS의 주된 기능 중 하나인데요. LMS를 개발할 때 디즈니러닝+ 커리큘럼을 구현할 때 아쉬웠던 점을 보완할 계획을 가지고 있습니다.
기회가 된다면 이 내용도 소개할 수 있었으면 좋겠네요. 긴 글 읽어주셔서 감사합니다!