-
Notifications
You must be signed in to change notification settings - Fork 2
3주차 릴리즈 노트
- 단일 Nest 서버 구조로 인한 확장성 한계
- 서비스 분리 및 상태 관리 필요성
- 객체지향 원칙
- 단일책임원칙(SRP): API/WebSocket 서버 분리
- 캡슐화: 각 서버의 독립적 기능 구현
- 낮은 결합도: Stateless 아키텍처 구현
- WebSocket & Redis
- Redis를 통한 상태 관리
- Load Balancer 도입으로 확장성 확보
기존 코드 : 하나의 Nest 서버에서 API 와 WebSocket 연결 역할을 모두 수행
문제 : 유지보수 어려움, 수평 확장이 어려움, 상태 관리 어려움
개선 후 : API 서버와 WebSocket 서버 분리 후 Load-Balancer 서버 구현으로 WebSocket 서버 확장 가능하도록 변경 및 Redis 연결 상태 관리를 통해서 다양한 문제 해결
리팩터링 전 서버의 모습입니다.
위와 같은 구조로 되어있는데요.
내부 구조를 살펴보면
위와 같은 구조로 코드가 작성되어있습니다.
이를 분리하기 위해 이러한 모습을 그려보았습니다.
WebSocket 서버와 API 서버를 분리해보았습니다.
실제로 이렇게 분리하기 위해 여러가지 방법을 고민해보았는데요.
일단 동일한 코드를 실행하고 Nginx를 통해 /ws /api 가 이미 나뉘어져있기 때문에 docker-container와 nginx를 다소 수정해서 실행해보았습니다.
이렇게 되면 중복되는 코드가 많은 문제가 발생하는데요.
필요한 부분만 남아있을 수 있도록 가지치기를 해보았습니다.
이렇게 불필요한 부분을 제거하고 보니 불필요한 모듈이 존재하는 것을 확인하였고
위와 같이 간소화해보았습니다.
두 모습을 비교하면 이러한 모습이 됩니다.
이제 WebSocket 서버를 확장하는 방법에 대해서 살펴보겠습니다.
다음과 같은 로직을 고민해보았습니다.
- 만약에 이미 연결된 방이 있다면 해당 방으로 접속해야한다.
- 연결된 방이 없다면 CPU 사용량이 가장 낮은 서버의 주소로 이동해서 새로운 접속을 해야한다.
이러한 로직을 구현하기 위해 중간에 Load-Balancer 서버가 필요해졌습니다.
그림으로 그려보면 위와 같은 모습이 됩니다.
이렇게 Load-Balancer 서버를 도입하고보니 WebSocKet의 상태를 알아야했었는데요.
중간에 미들웨어(Redis)를 통해 그러한 상태 관리 로직을 구현해보았습니다.
구성은 이렇게 되어있습니다.
각각의 서버는 각각의 책임과 역할을 수행할 수 있게 분리해보았습니다.
이러한 모습이 객체지향이 추구하는 것과 밀접하다는 생각이 들었는데요. 객체지향의 여러가지 원칙 중 단일책임원칙과 캡슐화를 고려하면서 작업을 해보았습니다.
객체 간 관심사가 분리되어 유지보수가 편리했었는데요.
유지보수 이야기를 마지막으로 해보겠습니다.
저희 프로젝트는 각 노드가 WebSocket의 Room으로 되어있습니다.
이때 하위에 연결된 커넥션이 존재하는 경우 삭제했을 때 데이터가 남아있거나, 저장되어서 고아 객체(orphan object)가 됩니다.
고아 객체(orphan object)문제를 해결하기 위해 트리를 삭제 하기전 하위 트리를 모두 조회해서 연결된 커넥션이 있는지 확인을 해야했었는데요.
이때 Redis에서 상태를 저장하고 있기 때문에 Redis를 먼저 조회하고 남아있는 커넥션이 존재하는 경우 삭제를 하지 못하도록 설정하였습니다.
그렇게 될 경우 위와 같은 형태로 서버 아키텍처가 구성됩니다.
만약 기존 아키텍처였다면 SpaceService Class에 의존하고 있는 Class가 많아서 관련 Class를 모두 확인해가면서 작업을 해야했었는데 이렇게 분리가 되고나니 코드의 복잡도가 많이 감소하여서 쉽게 유지보수를 할 수 있었습니다.
- useDragNode와 useMoveNode는 드래그, 이동, 홀딩과 같이 밀접하게 관련된 기능을 다루고 있었지만, 별도의 훅으로 분리되어 있었습니다. 이로 인해 SpaceView 컴포넌트에서 두 훅을 모두 사용해야 했고, 중복되는 로직이 존재하는 문제가 있었습니다.
- 여러 훅을 사용하여 각 노드 컴포넌트에 다수의 이벤트 핸들러를 개별적으로 전달하는 방식은 가독성을 저하시키고, 인터랙션 확장 시 어려움이 발생하는 문제가 있었습니다.
- 단일 책임 원칙 (SRP): 하나의 클래스, 모듈, 함수는 하나의 책임만 가져야 한다는 원칙. -> 두 개 이상의 유사한 기능을 가진 모듈을 하나로 통합
- 추상화 복잡한 내부 구현을 숨기고, 사용자에게는 간단한 인터페이스만 제공. -> 인터랙션 관련 로직을 추상화하여 다른 컴포넌트가 사용할 수 있도록 함
- 위임: 객체가 직접 책임을 수행하는 대신, 다른 객체에게 책임을 위임. -> 이벤트 핸들러 객체 생성을 위임함
- 관심사의 분리 (SoC): 서로 다른 관심사를 가진 코드는 분리하기.
useDragNode+useMoveNode->useSpaceInteractions
기존에는 노드의 드래그 기능을 담당하는 useDragNode 훅과 노드 이동 및 홀딩 기능을 담당하는 useMoveNode 훅이 분리되어 있었습니다. 이로 인해 SpaceView 컴포넌트에서 두 개의 훅을 모두 사용해야 했고, 관련 상태 및 핸들러가 분산되어 코드가 다소 복잡해지는 문제가 있었습니다.
이러한 문제를 해결하기 위해, 두 훅의 기능을 useSpaceInteractions라는 새로운 단일 훅으로 통합했습니다. 이 훅은 드래그, 이동, 홀딩과 관련된 모든 상태와 핸들러를 단일 훅 내에서 관리합니다. 결과적으로 SpaceView 컴포넌트에서는 useSpaceInteractions 훅 하나만 사용하면 되기 때문에, 코드가 훨씬 간결해지고 가독성이 향상되었습니다.
기존에는 HeadNode, NoteNode, SubspaceNode와 같은 노드 컴포넌트에 onDragStart, onDragMove, onDragEnd, onMouseDown 등 다수의 이벤트 핸들러가 개별적으로 전달되었습니다. 이로 인해 컴포넌트의 prop 개수가 많아지고, 가독성이 저해되는 문제가 있었습니다.
이 문제를 해결하기 위해, useSpaceInteractions 훅 내부에 nodeEventHandlers 함수를 추가했습니다. 이 함수는 특정 노드에 대한 모든 이벤트 핸들러를 포함하는 객체를 생성합니다. SpaceView 컴포넌트에서는 이 함수를 호출하여 각 노드에 필요한 이벤트 핸들러 객체를 생성하고, 이를 ... 스프레드 연산자를 사용하여 한 번에 전달하도록 수정했습니다.
<NoteNode
{...nodeEventHandlers(node)}
// ... other props
/>
| 개선 전 | 개선 후 | 결과 |
|---|---|---|
| SpaceView 컴포넌트에서 useDragNode와 useMoveNode 두 개의 훅을 각각 사용 | SpaceView 컴포넌트에서 useSpaceInteractions 하나의 훅만 사용 | 명확해진 훅의 책임, 사용시 간결성 향상 |
| 각 노드 컴포넌트에 onDragStart, onDragMove, onDragEnd, onMouseDown 등 이벤트 핸들러를 개별적으로 전달 | useSpaceInteractions 훅 내부의 nodeEventHandlers 함수를 통해 생성된 이벤트 핸들러 객체를 ... 스프레드 연산자를 사용하여 한 번에 전달 | 간결한 prop 전달로 이벤트 핸들러 관리 용이성 증가 |
useZoomSpace 는 스페이스 화면의 확대, 축소 기능을 구현한 커스텀 훅입니다. 해당 로직은 기능적으로는 문제없이 작동했지만, 확장성, 유지보수성, 유연성 측면에서 한계가 있었습니다. 디자인 패턴 중 하나인 전략(Strategy) 패턴을 활용해 확대/축소 로직을 zoomStrategies.ts와 useZoomSpace.ts로 분리하여 리팩토링을 진행했습니다.
기존 useZoomSpace 훅의 확대/축소 로직에는 다음과 같은 문제가 존재했습니다.
- 유지보수의 어려움: 확대/축소와 관련된 모든 로직이 하나의 훅에서 처리되면서 코드 복잡도가 증가하고, 수정 시 다른 로직에 영향을 줄 가능성이 높았습니다.
- 확장성 부족: 새로운 확대/축소 동작을 추가하거나 기존 동작을 변경하기 위해 많은 코드를 수정해야 했습니다.
- 책임 분리 미흡: 이동(viewport 이동)과 확대/축소(scale 조정) 동작이 혼재되어 있어, 각각의 책임이 명확히 분리되지 않았습니다.
확대/축소와 이동 로직의 경우 디바이스나 브라우저 환경, 이벤트 트리거 조건에 따라 분기가 필요한 기능이었습니다. 여러 디자인 패턴들이 있었지만, 전략 패턴의 경우 여러 행동을 정의한 후 필요에 따라 적절한 전략을 선택해서 적용할 수 있는 유연성을 제공합니다. 전략 패턴을 활용한다면 기존 코드에 영향을 주지 않고 확장성 및 유지보수성을 확대할 수 있을 것이라고 판단했습니다.
따라서 이번 CS 리팩토링에서는 전략 패턴을 활용하여 확대/축소 책임을 별도의 전략 함수로 분리하고, 훅에서는 이를 동적으로 주입받아 사용하는 구조로 리팩토링을 진행했습니다.
리팩토링 진행 과정에서 전략 패턴으로 전환 후에도 호환성이 유지되는 것이 가장 중요했습니다. 이를 위해 초기 파일을 그대로 둔 채 새로운 복제 파일에서 리팩토링 후, 초기 파일을 대체하는 식으로 전환하는 방식을 시도했습니다.
<aside>
- 확대/축소 로직을 훅에서 분리하여 유지보수성과 확장성을 개선
- 전략 패턴을 적용해 유연하게 확대/축소 동작을 확장 가능하도록 구현
- 개별 확대/축소 전략을 독립적으로 테스트 가능하도록 개선 </aside>
기존 useZoomSpace.ts 파일에서 확대/축소 로직과 관련된 모든 처리가 하나의 훅에서 이루어졌습니다. 아래는 기존 코드의 일부 예시입니다.
확대/축소와 이동 로직을 각각의 전략으로 분리하여 zoomStrategies.ts 파일에 정의했습니다.
- 유지보수성 향상: 관심사 분리를 통해 확대/축소 동작을 수정해도 핵심 훅 코드를 변경하지 않아도 됩니다.
- 유연성 증가: 새로운 확대/축소 전략을 추가하려면 인터페이스를 구현한 클래스를 작성하면 됩니다.
- 테스트 용이성: 개별 전략을 독립적으로 테스트할 수 있습니다.
- 확장성 증가: 새로운 확대 모드를 추가하기가 용이해졌습니다.
- 확대/축소 로직이 하나의 훅에 통합되어 있음
- 이벤트 핸들러와 계산 로직이 결합되어 있음
-
zoomStrategies.ts:-
ZoomStrategy인터페이스 및 전략 구현 포함 - 현재는
DefaultZoomStrategy만 있으나, 추후SmoothZoomStrategy와 같은 다른 전략 추가가 가능해짐
-
-
useZoomSpaceNew.ts:- 확대/축소 동작을 전략 인스턴스에 위임
- 상태 관리 및 DOM 상호작용에 집중
확대/축소 로직을 Strategy 패턴으로 리팩토링하면서 기존 구현의 주요 한계를 해결했습니다. 새로운 설계는 모듈화, 확장성, 유지보수성을 강화하여 향후 기능 확장의 기반을 마련했습니다.