들어가며
IAMBIT 기존 레거시는 각각 store마다 공통 state가 복제되어 있었다...
이를 개선하기 위해 여러 개의 store에서 공통으로 사용하는 state들(현재 코인, 현재 거래소 타입(선물/현물))을 한 곳(mainStore)에서 중앙 관리 처리했다.
최대한 라이브러리 의존성 줄여서 개발했지만 공통 state들과 웹소켓에서 받는 정보들은 필연적으로 전역 상태로 관리해야 했다.
그런데 비교적 복잡한 작업인 거래소 코인 변경의 경우 MobX store 라이브러리에 종속된 버그가 생겼다.
문제 상황
MainStore에서 심볼을 변경하고, 그 다음에 WebsocketManager에서 새로운 심볼에 대한 웹소켓 구독을 해야 하는 상황이었다.
// 비즈니스 로직을 덜어낸 예시 코드
class MainStore {
@observable currentSymbol: string = "비트코인";
@action
async changeSymbol(newSymbol: string) {
runInAction(() => {
this.currentSymbol = newSymbol;
});
}
}
class WebsocketManager {
private prevSymbol: string = MainStore.currentSymbol;
private ws;
async changeSymbol() {
this.ws.unsubscribe(this.prevSymbol);
this.ws.subscribe(MainStore.currentSymbol)
this.prevSymbol = MainStore.currentSymbol;
}
}
MainStore.changeSymbol("이더리움")
WebsocketManager.changeSymbol() // bad: 비트코인 구독
?? await인데 왜 순서보장이 안됨??
이는 MobX의 내부 업데이트 최적화 때문이다.
- MobX은 상태 변경을 업데이트 큐에 넣고 한번에 처리한다(batch 업데이트)
- 실제 업데이트는 다음 이벤트 루프 틱에서 실행된다
- 따라서 상태 변경후 바로 사용하려 하면 아직 변경이 되지 않은 상태일 수 있다.
- await는 함수가 끝나는 것만 기다릴 뿐, MobX의 상태 업데이트가 완료되는 건 기다리지 않는다 (서로 다른 컨텍스트임)
// 예시 코드
class MobxInternal {
queue = [];
scheduleUpdate(update) {
this.queue.push(update);
// 다음 틱에 업데이트 실행
Promise.resolve().then(() => this.processQueue());
}
}
비슷한 예시로 react의 useState가 있다.
아래와 같이 한 count를 1로 바꾸는 비동기 작업을 실행을 할뿐 그 다음 줄에서 업데이트된 상태를 사용하진 않는다.
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(1);
console.log(count); // 여전히 0이 출력됨
someFunction(count); // 여기도 0을 사용
}
MobX when()으로 해결하기
이럴 때 when()을 쓰면 완벽하게 해결된다. when()은 특정 조건이 만족될 때까지 기다렸다가 다음 로직을 실행하게 해준다.
덕분에 같은 스코프 내부에서 완전한 state업데이트가 일어난 이후에 다음 줄을 실행하게 처리할 수 있다.
class WebsocketManager {
private prevSymbol: string = MainStore.currentSymbol;
private ws;
async changeSymbol() {
// MainStore의 심볼이 실제로 변경될 때까지 기다림
await when(() => MainStore.currentSymbol !== this.prevSymbol);
// 이제 MainStore의 상태가 확실히 업데이트 된 후에 실행됨
this.ws.unsubscribe(this.prevSymbol);
this.ws.subscribe(MainStore.currentSymbol);
this.prevSymbol = MainStore.currentSymbol;
}
}
MainStore.changeSymbol("이더리움")
WebsocketManager.changeSymbol() // good: 이더리움 구독
이렇게 하면 MainStore의 상태가 실제로 업데이트 된 후에야 WebSocket 구독이 일어나게 된다.
마무리
일정이 촉박하고 리소스가 부족해 전역상태관리 라이브러리까지 수정하지 못해 처음 사용하게 되었는데 래퍼함수가 유용해서 점점 마음에 들었다.
비즈니스로직이 겁나 복잡해서 결합도가 느슨하지 못하지만 레거시에 비하면 하늘과 땅차이의 가독성이다.
물론 처음부터 합류해서 스택 정할 수 있었으면 절대 안썼겠지만...
++ 2/7) 구조 개선 고민
클린코드에 대해 관심을 가지고 공부하다 갑자기 생각나서 이 구조 개선을 해보려 했다.
사실 이 when()을 쓰는거 자체가 MobX 라이브러리에 의존성 너무 세게 가져가는 부분이라 이걸 해결하고 싶은데
현재 코인, 현재 페이지(선물/현물) 이 두개의 대장(중요) 상태를 다른 전역 store들이 바라보는 구조가 필수적이다.
MobX 생태계에선 이게 최선인거 같고 다른 전역 상태 라이브러리를 사용해볼까? 즉시 업데이트 되는 zustand가 좋겠다.
type MainStore = {
currentSymbol: string
changeSymbol: (newSymbol: string) => Promise<void>
}
export const mainStore = create<MainStore>((set) => ({
currentSymbol: "비트코인",
changeSymbol: async (newSymbol: string) => {
try {
// 웹소켓 연결 상태 확인
const ws = websocketStore.getState().ws
if (!ws) throw new Error('웹소켓이 초기화되지 않았습니다')
// 이전 심볼 구독 취소
await websocketStore.getState().unsubscribe()
// 상태 업데이트
set({ currentSymbol: newSymbol })
// 새 심볼 구독
await websocketStore.getState().subscribe(newSymbol)
} catch (error) {
console.error('심볼 변경 중 오류:', error)
// 실패 시 상태 롤백 등의 처리
throw error
}
}
}))
type WebsocketStore = {
ws: WebSocket | null
prevSymbol: string
subscribe: (symbol: string) => Promise<void>
unsubscribe: () => Promise<void>
initializeWebsocket: () => Promise<void>
}
export const websocketStore = create<WebsocketStore>((set, get) => ({
ws: null,
prevSymbol: '',
initializeWebsocket: async () => {
const ws = new WebSocket('your-websocket-url')
await new Promise((resolve, reject) => {
ws.onopen = () => {
set({ ws })
resolve(void 0)
}
ws.onerror = reject
})
},
subscribe: async (symbol: string) => {
const { ws } = get()
if (!ws) throw new Error('웹소켓 연결이 없습니다')
return new Promise((resolve, reject) => {
try {
ws.send(JSON.stringify({
type: 'subscribe',
symbol
}))
set({ prevSymbol: symbol })
resolve()
} catch (error) {
reject(error)
}
})
},
unsubscribe: async () => {
const { ws, prevSymbol } = get()
if (!ws || !prevSymbol) return
return new Promise((resolve, reject) => {
try {
ws.send(JSON.stringify({
type: 'unsubscribe',
symbol: prevSymbol
}))
resolve()
} catch (error) {
reject(error)
}
})
}
}))
이렇게 batch update로부터 자유로운 전역 상태 라이브러리를 사용해 래퍼함수를 안써도 되게 개선했다
그런데 여전히 전역 상태 store를 사용하고 있으며 각각 store가 순환참조되는 이슈가 남아있다. 이건 구조를 다시 레거시로 회귀해서 event pub/sub 구조로 바꿔야 할거같은데... 사실 중국친구들이 그렇게 구현해둔건 다 이유가 있었던거 같기도하다