회사에 복직 후 맡게 된 첫 프로젝트에서 새로운 기능 추가와 더불어 기존 모델을 리팩터링 해보는 게 어떻겠냐는 논의가 팀 내에서 이루어졌다. 으레 배포 일정은 타이트하게 주어지는 게 태반이기에, 처음에는 두 작업을 병렬적으로 가져가는 게 일정 관리 상 괜찮을지 걱정이 앞섰다. 그러나 프로젝트를 잘 마무리하고 보니 이번 업무를 진행한 과정과 구현한 결과물이 꽤 인상 깊게 남아 이 경험을 남겨보고자 글을 쓰게 되었다.
문제 정의 & 해결하고 싶었던 부분
현재 회사에서 제공하는 서비스의 특성상 앱 내에서 사용자가 어떤 값을 설정하는 데 필요한 에디터를 제공하는 UI가 많다. 이번 작업 영역도 에디터와 관련성이 높았는데, 기존에 백엔드에서 내려오는 모델은 아래와 같았다.
type DraftResponse = {
// some fields..
AEventName?: string
AEventQuery?: Query
BEventName?: string
BEventQuery?: Query
CEventName?: string
CEventQuery?: Query
...
}
에디터 초안을 작성하기 위한 모델인데, 여기에는 각기 다른 이벤트 모델이 포함되어 있으며 이것들은 '이벤트'라고 하는 공통된 관심사를 가지고 있다. 그럼에도 응답 내에서는 각 필드들이 1 depth로 내려오고 있어 필드의 목적 파악이 쉽지 않고 응집성도 떨어져 있다.
다만 현재 회사 내의 코드 베이스는 모델링 레이어가 잘 구분되어 있어, 프론트엔드에서 코드를 작성할 때 백엔드 응답 구조의 의존도를 최소화하고 있다. 그래서 프론트엔드에서는 저 모델을 곧이곧대로 쓰지 않고 한 번의 가공을 거쳐 코드 내에서 다룬 뒤에 실제 api를 호출하는 시점에만 다시 백엔드가 원하는 구조에 맞추어 payload를 담아 보낸다.
// 프론트엔드에서 사용할 때
interface Event {
name?: string
query?: Query
}
type AEvent = {} & Event
type BEvent = {} & Event
type CEvent = {} & Event
type DraftModel = {
// some fields...
AEvent: AEvent
BEvent: BEvent
CEvent: CEvent
...
}
그래서 모델, 컴포넌트, 훅 어떤 레이어라도 하나의 관심사를 기준으로 공통화하여 이것을 확장한 개체의 맥락 내에서 사용하는 방식까지 이끌어내는 건 크게 어렵지 않았다.
문제라고 생각되는 지점은 따로 있었다. 이벤트라는 관심사만 아는 레이어는 들어오는 이벤트가 '어떤 이벤트'인지에 대해서는 알 수 없다. 그렇기 때문에 각 이벤트 컴포넌트 맥락에서 공통 이벤트 컴포넌트를 호출할 때 필요한 데이터를 주입해주어야 한다.
function AEventComponent() {
const AEvent = useGetAEvent()
return <EventComponent {...AEvent} />
}
데이터 구조와 컴포넌트가 단순해서 주입한 데이터가 바로 하위 컴포넌트에서 사용된다면 성능이나 코드 작성 방식에 대해 걱정할 건 크게 없다. 하지만 컴포넌트 트리의 계층이 이 단계에서 그치는 경우는 거의 없다. EventComponent가 할 일이 조금이라도 많아지면, 아마 이 컴포넌트 하위에는 무수히 많은 컴포넌트가 곧 들어설 것이다.
여기서 prop drilling을 방지한다고 context api나 클라이언트 상태 관리 도구로 별도의 store를 구성한다고 가정해보자.
function AEventComponent() {
const AEvent = useGetAEvent()
return (
<EventProvider event={AEvent}>
<EventComponent />
</EventProvider>
)
}
EventComponent 하위의 컴포넌트들은 consumer hook으로 데이터에 접근하여 prop drilling을 일차적으로 막고 성능 쪽으로 좀 더 신경 쓴다면 데이터의 변경이 실제적으로 영향을 미치는 컴포넌트만 리렌더링 하도록 만들 수도 있다.
그렇지만 이 방법도 내키지는 않았던 게, 코드 베이스 내에서는 이미 tanstack-query의 QueryCache라는 비동기 상태를 관리하는 저장소가 존재한다. (위 예시에서는 useGetAEvent를 호출할 때 접근할 것이다) 때문에 별도의 처리를 위해 거의 비슷한 데이터를 가진 저장소를 하나 더 둔다는 건 불필요한 레이어로 보인다.
결국 이러한 사고의 흐름으로 리팩터링으로 개선해야 할 요구사항은 아래와 같이 정리가 되었다.
1. 이벤트와 관련한 모든 컴포넌트는 아무리 그 단위가 작더라도 상위 컴포넌트가 아닌 외부 저장소의 데이터에 접근하게끔 한다.
2. 외부 저장소는 별도의 store를 만들지 않고 이미 있는 tanstack-query의 QueryCache만 사용하도록 한다.
해결 방법
앞서 말했듯 이벤트만 알고 있는 레이어는 '어떤 이벤트'가 데이터로 들어오는지 알 수 없다. 그렇기 때문에 사용부에서는 '어떤 이벤트'가 데이터로 필요한지만 알려주는 데에 초점을 맞추었다.
type DraftModel = {
AEventId: string
BEventId: string
CEventId: string
eventMap: Record<string, Event> // key는 각 이벤트의 id
...
}
function AEventComponent() {
const eventId = useGetAEventId()
return (
<EventIdProvider eventId={eventId}>
<EventComponent />
</EventIdProvider>
)
}
function EventComponent() {
const eventId = useEventId()
const event = useGetEventById(eventId)
return ...
}
1. 모델에서 eventMap이라는 id로 접근할 수 있는 이벤트 저장소 관리 (구현하고 보니 id는 단순히 접근자 용도라 추후 event type을 접근자로 사용하게끔 개선 예정이다)
2. 사용부에서는 이벤트 컴포넌트에 자신의 이벤트 id를 내려줌
3. 이벤트 id를 받은 컴포넌트에서는 eventMap에 id로 접근하여 해당하는 이벤트 데이터를 가져옴
변경점은 적어 보이지만, 개선 효과는 만족할 만한 수준이었다. 이벤트 컴포넌트 계층 내에서는 이벤트 id만 주고받아 prop의 변경으로 인한 리렌더링 범위와 빈도를 리팩터링 전과 비교해 보았을 때 눈에 띄게 감소시켰고 모든 컴포넌트에서 이 id를 가지고 외부 저장소에서 맵핑되는 이벤트를 가져와 처리하니 데이터와 뷰가 하나의 컴포넌트 안에 가까이 있어 응집성을 높일 수 있었다.
업무 프로세스와 갈등 지점
이번 프로젝트를 진행하면서 신선하게 느꼈던 지점은 바로 업무 방식이었다. 나 포함 2명이서 작업을 진행했는데, 한 명은 모델 개선 문서를 작성하면 한 명은 그것을 보고 실제 기능을 구현하는 역할을 맡았다. (물론 역할이 완전히 둘로 나뉜 건 아니고 서로 어느 부분에 더 초점을 맞추었는가에 대한 얘기다) 원래는 기존의 한 명이 주욱 작업할 분량이었는데, 내가 중간에 투입되면서 자연스레 이런 양상을 띠었다.
나는 기능 구현 및 실제 리팩터링을 적용하는 역할을 맡았는데, 다른 한 분이 (내가 생각하는 한) 시니어리티가 더 있었고 해당 도메인에 대해 더 잘 알고 있었기 때문에 이러한 역할 분담이 적절하다고 생각했다.
실제 구현하기 전 작성된 모델링 문서를 리뷰하면서 다른 부분에 대해서 이견은 없었지만 하단에 추가된 필드에 대해서는 다소 의문이 들었다.
type Event = {
// some fields...
status: {
// some fields...
isFixed: boolean
}
}
추가된 기획에서는 이벤트의 status를 수정하는 UI가 필요했는데, isFixed는 바로 이 데이터의 수정 가능 여부를 판가름하기 위한 목적으로 된 생긴 필드다. 처음엔 이걸 보고 왜 데이터 모델에서 뷰의 관심사를 알고 있어야 하나 싶었다.
백엔드에 보낼 payload도 아니거니와 데이터 외에 UI와 관련된 관심사를 추가로 두는 게 추후 필드 추가의 기준을 모호하게 만들고 모델의 역할이 비대해지지 않을까 하는 우려가 있었기 때문이다.
그렇지만 당시에 이 부분을 크게 문제 삼지 않고 '구현하다 보면 이해가 되겠지'하면서 넘어갔다. 너무 안일하게 대처해서였을까, 모델링 개선을 제안한 당사자가 예상한 코드 구조와 실제 구현자의 코드 구조가 일치하지 않으니 초반에 코드 리뷰 단계에서 이 싱크를 맞추기 위한 부수적인 논의가 계속 생산되는 느낌을 받았다.
이러한 갈등을 해결하기 위해 팀원에게는 예상했던 구조를 확인할 수 있을 정도의 아주 작은 코드 개선 프로토타입용 PR을 요청했고 내 입장에서는 어떤 필드가 왜 필요한지에 대한 디테일보다는 상대방이 어떠한 대전제를 가지고 모델을 작성한 건지 먼저 이해하기 위해 노력했다.
여러 이야기를 나누다 보니, 팀원은 View Model의 패러다임을 차용하여 구조를 개선해보고 싶었다는 걸 알게 되어 오랜만에 이 개념에 대해 다시 찾아보게 되었다.
View Model이란
Presentation Model, Screen Model이라고도 할 수 있는데, View Model이 프론트엔드 맥락에서는 가장 대중화된 용어이다. UI 계층과 비즈니스 로직 계층 사이의 중간 계층 성격의 모델로 볼 수 있는데, UI를 위한 로직까지 모델 레이어에 캡슐화 하여 View가 쉽게 사용할 수 있는 형태로 제공하는 게 이 모델의 강점이라고 할 수 있다.
도입 시 고려할 점이라고 하면, 도메인 모델의 순수성이 깨질 수 있고 백엔드 응답과 프론트엔드 모델 간 적절히 분리되어 있지 않으면 혼동 우려가 있을 수 있다. 이러한 부분에 있어서는 이미 코드 베이스 내에 기반이 잘 갖춰져 있었기 때문에 크게 우려되지는 않았다.
다시 코드로 돌아가서 만약 현재 리팩터링한 기조 내에서 isFixed가 모델 내에 들어가 있지 않고 UI에서 결정지어야 한다고 생각하면 아래처럼 각 이벤트 컴포넌트에서 prop을 내려보내주어야 한다.
function AEventComponent() {
const eventId = useGetAEventId()
return (
<EventIdProvider eventId={eventId}>
<EventComponent isStatusFixed={true} />
</EventIdProvider>
)
}
EventComponent 내부 어딘가에 있을 status 변경 컴포넌트를 위해 상위에서 내려보내주어야 하는 구조는 처음에 우려했던 문제점과 크게 다르지 않고 개선된 데이터 접근 방식과 비교해 보았을 때 제공처가 별개로 존재하기 때문에 추적이 쉽지 않다.
이러한 과정을 통해 비즈니스 로직 레이어와 뷰 레이어가 잘 분리가 되어 있고 추가하려는 필드가 '모델과 밀접하게 연관이 있고 여러 컴포넌트에서 재사용 가능한 상태'라면 오히려 모델 안에 들어가 있는 것도 괜찮겠다는 생각이 들었다.
한 번 이렇게 싱크를 맞추고 나니, 그 뒤로는 거의 일사천리로 개발이 진행되었던 것 같다. 덕분에 일정 내에 잘 마무리하면서도 코드의 퀄리티, 사용성까지 잘 챙길 수 있었다. 후속으로 진행할 작업도 여러모로 기대가 되는데, 이번 경험을 바탕으로 더 좋은 구조를 고민해 봐야겠다!
'Development' 카테고리의 다른 글
[23.09.26] 프로젝트의 초기 렌더링 성능 향상시키기 (2) | 2024.01.09 |
---|---|
[23.07.29] 프론트엔드에서 Route를 확장성있게 관리하기 (0) | 2024.01.09 |
[22.09.03] 실무 프로젝트에서 Virtual Scroll 활용하기 (1) | 2024.01.09 |
[22.04.21] Data Fetching in Next.js (1) | 2024.01.09 |
[22.01.06] JS의 Map vs Object (0) | 2024.01.09 |