데이터의 구조

느슨한 아키텍처의 첫 번째 초점

The core premise for React is that UIs are simply a projection of data into a different form of data.

- Sebastian Markbåge, [React - Basic Theoretical Concepts]

데이터는 UI를 구현하는 프론트엔드 프로젝트에서도 여전히 중요하다. 리액트의 초기 개발자들이 생각한 것처럼, 결국 UI도 특정 데이터를 시각 요소로 투사(projection)한 것에 지나지 않기 때문이다.

데이터 아키텍처는 프로그램에 입력된 무형의 데이터가 사용자가 볼 수 있는 UI로 표현되고, 사용자의 입력이 다시 데이터의 출처로 전달되기 까지의 모든 과정에 관한 구조이다. 앞서 설명한 느슨한 아키텍처에서 이는 비즈니스 규칙이라는 가장 안쪽의 레이어, 이를 둘러싼 프로그램 규칙이라는 두 번째 레이어, 그리고 마지막으로는 UI 모듈이라는 가장 바깥의 레이어로 구성되어 있다. 각 레이어의 경계에서 의존의 방향은 엄격히 통제된다: 안쪽 레이어는 바깥쪽 레이어의 구현에 의존할 수 없다. 이러한 레이어의 분리는 개발자가 데이터의 흐름을 추적하고 통제하는 데 큰 도움을 준다.

그렇다면 각 레이어는 어떤 역할을 하고, 왜 이러한 구분이 필요할까? 지금부터 UI와 밀접하게 연결된 마지막 레이어를 제외한, 안쪽의 두 레이어에 대해 살펴보고자 한다. 세 번째 레이어는 느슨한 아키텍처의 다른 한 부분인 '디자인 아키텍처'에 대해 다루면서 함께 설명할 것이다.

레이어 1: 비즈니스 규칙

데이터 아키텍처의 가장 안쪽인 레이어 1에는 서비스의 성격과 정책, 기획의 결정사항 등을 의미하는 비즈니스 규칙이 위치한다. 그렇기에 팀에 새로 합류한 개발자들은 레이어 1의 코드만 읽어도 자신이 어떤 서비스를 만드는 팀에 속해 있는 지 알 수 있을 것이다. 프로그래밍 언어를 대충 읽을 수 있는 기획자나 디자이너라면 레이어 1의 코드를 보고 개발자가 자신과 동일한 목표를 바라보고 있는 지 판단할 수 있다. 이 레이어에는 그만큼 자명하고 핵심적인 정보만이 포함된다.

레이어 1에 위치할 데이터를 구별하는 가장 좋은 방법은, 우리가 동일한 비즈니스를 프로그래밍이 아닌 다른 방식으로 수행한다 가정했을 때에도 정리가 필요한 개념들을 구분하는 것이다. 예를 들어 인터넷 쇼핑 사이트 대신에, 같은 업종의 오프라인 매장을 연다고 해보자. 우리는 여전히 상품의 종류와 핵심 정보(가격, 재고 등)를 정리한 스프레드 시트를 만들고, 주문서를 출력하며, 고객들의 데이터를 메모할 것이다. 여기서의 핵심 개념들: 상품, 주문서, 고객은 모두 첫 번째 레이어에 위치하는 비즈니스 규칙이다.

아래에서는 레이어 1에 들어갈 요소들을 엔티티와 그 외의 것들로 나누어 설명할 것이다. 다만 이러한 분류에 큰 의미가 있는 것은 아니다. 핵심 비즈니스 규칙을 담고 있는 코드라면, 어떠한 형식으로든 레이어 1에 포함되어야 할 것이다.

엔티티(Entity)

엔티티비즈니스의 기본 단위가 되는 객체이다. 서비스의 '사용자'와 같은 일반적인 개념이나, 문서 편집 서비스의 문서, 전자 상거래 서비스의 상품 또는 주문과 같은 핵심 개념들을 표현하는 객체가 이에 해당한다. 엔티티는 프로그램의 동작 과정에서 어떤 데이터를 주고 받을 지에 대한 기준이 된다. 예컨데 User 엔티티는 사용자의 정보를 표시하는 UserCard 컴포넌트의 Prop이면서 동시에 사용자의 정보를 수정하는 patchUserData 요청의 body가 될 수 있다.

1 type UserEntity = {

2 id: ID;

3 name: string;

4 birth: Date;

5 isAdmin?: boolean;

6 }

7

8 const UserCard = (props: { user: UserEntity }) => {

9 // 유저 정보를 표시하는 JSX 컴포넌트

10 }

11

12 const patchUserData = (newUser: UserEntity) => {

13 // 유저 정보를 변경하는 서버 요청

14 }

프론트엔드 프로젝트에서는 엔티티 객체가 속성(property)만 가지고 메서드(method)는 지니지 않는 경우도 많다. 데이터의 생성 / 변경과 같은 로직은 백엔드 서버에 일임하고, 프론트엔드에서는 데이터를 특정 UI로 보여주는 작업을 주로 수행하는 프로젝트가 이에 해당한다. 이 경우 엔티티는 위처럼 간단한 객체 타입으로 작성해도 충분할 것이다. 프로젝트가 더 복잡한 경우, 엔티티는 메서드를 가진 객체나, 타입과 함수들의 조합 등으로 표현된다. 중요한 것은 어떤 문법으로 정의하는 지가 아니라, 비즈니스에서 중요한 개념만을 엔티티로 정의하는 것이다.

상수, 열거형 또는 공용체(Constants, Enums and Unions)

주로 엔티티를 구성하기 위해 사용되는 이 타입들은 비즈니스 규칙을 표현하기 위한 어휘를 제공한다. 방향을 나타내기 위한 'N', 'S', 'W', 'E' 네 개의 문자만을 허용하는 속성의 타입을 문자열(string)로 지정하면 어떤 길이의 문자열이든 전부 허용하는 다른 속성들과 구분할 수 없다. 이럴 때에는 네 개의 문자만을 포함하는 `DIRECTION` 이라는 타입을 새로 선언하여 사용하면 정적 타입 검사도 가능하고 가독성도 좋아진다.

다만 이 타입에 해당하는 모든 데이터가 레이어 1에 위치하는 것은 아니다. 기획에 의해 결정되는 핵심적인 어휘만이 레이어 1에 포함된다. 애플리케이션 서버의 주소는 정말 많은 코드에서 쓰이고 변경도 거의 없는 상수값이지만, 기획과는 독립적인 데이터이므로 레이어 1에 포함하지 않는다. 반대로 주문서라는 엔티티를 이해하기 위해 필요한 배송 방식을 정리한 열거형 타입은 레이어 1에 위치해야 할 것이다.

레이어 2: 프로그램 규칙

레이어 1의 관심이 비즈니스에 있었다면, 레이어 2는 이를 구현한 프로그램의 동작 방식에 대해 관심을 갖는다. 비즈니스적으로는 동일한 결과를 산출하는 프로그램이더라도, 이를 수행하기 위한 데이터의 흐름이나 프로그램의 상태는 전혀 다를 수 있다. 레이어 2의 코드는 이처럼 프로그램 규칙을 표현한다. 프로젝트에 방금 합류한 개발자더라도, 레이어 2의 코드를 읽고 나면 '내가 어떤 역할을 어떤 방식으로 수행하는 프로그램'을 만들게 될 지 이해할 수 있어야 한다.

레이어 2의 구성 요소는 상태 관리자리포지토리로 나뉜다. 둘은 프론트엔드 개발의 정체성이라고 할 수 있을 정도로 중요한 요소인데, 그렇기에 레이어 2가 프로그램 규칙을 표현한다고 확실히 말할 수 있겠다. 앞서 설명한 레이어 1의 코드는 (상황이 맞는다면) 프론트엔드 뿐 아니라 백엔드 서버를 구현하는 코드에서도 동일하게 사용될 수 있다. 그러나 레이어 2는 그렇지 않다.

또 하나 알아두어야 할 사실은 레이어 2까지 여전히 UI에 독립적인 코드의 묶음이라는 것이다. 따라서 레이어 2에는 디자인과 관련된 코드는 물론, 심지어 리액트와 같은 UI 구축용 라이브러리의 코드도 침범하지 않는 것이 이상적이다. 리액트로 구현된 프로젝트에서 리액트 자체를 덜어내는 거의 없다. 그렇지만 만약 리액트를 덜어낸다 해도 레이어 2의 코드를 그대로 사용할 수 있다면, 레이어 2를 의미에 맞게 제대로 분리하여 사용한다고 말할 수 있겠다.

상태 관리자(State Manager)

어떤 프로그램의 상태란 특정 시점에 따른, 또는 시간에 따라 변화할 수 있는 값을 의미한다. 프로그램에 입/출력되는 비즈니스 데이터(레이어 1에서 설명한, 주로 엔티티의 형태를 띄는 데이터)는 사실 프로그램의 동작 시간에 큰 관심이 없다. 프로그램이 작동하고 있다면 어떤 시점에 어떤 경로로든 데이터는 전달될 수 있다. 어떤 코드는 프로그램의 동작 이력과 현재 상황에 근거하여 이 데이터를 프로그램이 알맞게 처리할 수 있는 상태로 변환해야 한다. 이 코드에 상태 관리자라는 이름을 붙여보도록 하자. 즉 상태 관리자는 시간-독립적인 데이터를 시간-의존적인 상태로 변환하는 코드이다.

상태 관리자는 리덕스(Redux)와 같은 리액트의 전역 상태 관리 라이브러리를 사용해 본 사람들에게 익숙한 용어일 것이다. 그러나 상태 관리자가 꼭 특정 라이브러리나 리액트 기반 프로젝트에 국한된 개념은 아니다. 리액트의 useReducer 훅에 전달하는 리듀서 함수나 바닐라 자바스크립트로 간단히 구현한 유한 상태 기계도 모두 상태 관리자의 예시이다. 또 @tanstack/react-query와 같은 data-fetching 라이브러리나 RxJS와 같은 리액티브 라이브러리도 일종의 상태 관리자라 볼 수 있겠다.

이를 일반화하면, 상태 관리자란 아래의 내용을 담고 있는 객체이다.

- 가능한 상태의 종류

- 상태로 변환할 데이터 소스

- 상태 변환 로직

- 현재 상태

그런데 모든 상태 관리자를 데이터 아키텍처의 레이어 2로 분류할 수 있는 것은 아니다. 레이어 2의 상태 관리자는 설계 가능해야 한다. 여기에서 '설계 가능성'이라는 생소한 용어를 사용하는 게 적절한 지는 모르겠지만, 개념 자체는 친숙하니 조금 더 설명을 해보겠다. 객체 지향 패러다임에서의 클래스와 객체들은 설계 가능하다. 이들은 상속이나 합성과 같은 방법으로 서로 관계를 맺기도 하고, 다형성을 바탕으로 재사용 되기도 한다. 이 외에도 수많은 디자인 패턴에 기반하여 다양한 구조를 형성할 수 있다. 즉 객체 지향 패러다임에서의 객체는 설계 가능하다.

많은 상태들, 특히 UI의 일부분에 국한되거나 하나의 로직 내부에서 임시적으로 사용되는 상태 등은 이러한 특성을 보이지 않는다. 재사용되기 어렵거나, 반대로 너무 단순해서 굳이 '설계'를 필요로 하지 않기 때문이다. 그러나 API 요청의 응답 상태나 캐시 데이터를 관리하는 상태 관리자, 또는 복잡한 유저 입력의 진행도를 관리하는 상태 관리자 등은 좋은 설계를 필요로 한다. 그리고 이 프로그램이 데이터를 어떤 정책으로 관리하는 지는, 이러한 상태 관리자의 코드에 잘 드러나게 된다.

1 const todosQuery = queryOptions({

2 queryKey: ['todos'],

3 queryFn: fetchTodos,

4 staleTime: 5000,

5 })

6

7 const todoQueries = {

8 all: () => ['todos'],

9 lists: () => [...todoQueries.all(), 'list'],

10 list: (filters: string) =>

11 queryOptions({

12 queryKey: [...todoQueries.lists(), filters],

13 queryFn: () => fetchTodos(filters),

14 }),

15 }

@tanstack/react-query의 Query는 잘 설계된 상태 관리자이다. 정해진 인터페이스를 통해 상태 관리 정책을 통제할 수도 있고, 팩토리 패턴과 같은 설계를 도입하기도 용이하다.

레이어 2의 상태 관리자가 설계 가능해야 하는 이유는, 그것이 프로그램의 데이터 구조를 결정하기 때문이다. 핵심적인 상태 관리자가 어떻게 설계되어 있는 지는 결국 개발자가 프로그램의 동작 원리를 어떻게 생각하고 있는 지를 보여준다. 이러한 요소들이 바로 데이터 아키텍처의 레이어 2에 담겨야 하는 프로그램 규칙이다.

많은 라이브러리들은 이미 잘 설계된 상태 관리자를 제공한다. 그러나 느슨한 아키텍처를 구축하기 위해서는 우리가 이러한 라이브러리를 설계 가능한 방식으로 사용하고 있는 지 또한 점검해보아야 한다. 상태 관리자를 직접 구현하는 것도 방법이지만, 개발 속도를 위해서는 여러 방식의 상태 관리 라이브러리를 필요에 맞게 사용하는 것도 좋다. 특정 라이브러리에 너무 깊숙히 통합되어 있는 프로젝트은 다른 방식으로 설계된 라이브러리를 도입하기 어려울 수 있는데, 이는 최적의 데이터 아키텍처를 도입하는 데 큰 장애물이 된다.

리포지토리(Repository)

리포지토리 패턴은 프로그램에서 사용되는 데이터를, 그 출처에 상관 없이 동일한 인터페이스로 접근하기 위해 사용하는 패턴이다. 백엔드의 애플리케이션 서버, 웹 스토리지나 서비스 워커, 로컬 파일 시스템, 때로는 개발용으로 로컬에 띄운 모킹 서버 등 클라이언트에 전달되는 데이터의 출처는 다양하고, 때로는 동일한 데이터라도 환경에 따라 출처가 바뀌는 경우가 있다. 리포지토리는 프로그램의 다른 부분이 이러한 외부 환경과의 연결부에 상관하지 않고, 항상 같은 인터페이스로 데이터를 다룰 수 있게 한다.

1 // 이 블로그에서 사용하고 있는 `Index` 데이터 용 리포지토리

2

3 export const IndexRepository = {

4 read: {

5 data,

6 summary,

7 allSummaries,

8 recentSummaries,

9 },

10 create,

11 update: {

12 data: updateData,

13 publish: updatePublish,

14 unpublish: updateUnpublish,

15 },

16 delete: _delete,

17 };

18

19 // IndexRepository.read.data의 구현 예시

20 const data = ({ id }: { id: ID["INDEX"] }) => apiClient.get({ url:`idx/${id}` }).then(ReadIndexDto.parse);

상태 관리자가 프로그램의 내부 동작을 설명하는 코드의 묶음이었다면, 리포지토리는 프로그램이 외부의 코드와 어떻게 상호작용하는 지를 표현한다. 먼저 리포지토리의 인터페이스는 이 프로그램이 어떤 데이터를, 어떤 방식으로 주고 받을 수 있을 지를 보여준다. 위의 예시에서 `IndexRepository`가 제공하는 메서드들을 보면 이 프로그램이 `Index`와 관련된 어떤 종류의 데이터를 수신하고 변경할 수 있는 지 쉽게 이해할 수 있다.

뿐만 아니라 리포지토리 코드의 내부에는 실제로 이 데이터를 받아오는 부분이 구현되어 있는데, 이는 이 프로그램에 연결되어 있는 데이터의 출처들을 파악하는 가장 좋은 방법이다. 이 구현에는 주로 API의 엔드포인트와 원격 프로시저 호출 코드, 각종 외부 SDK 들이 포함된다. 또 통신 시에 지켜야 할 특별한 규약이 있다면 이 또한 리포지토리의 구현에 포함될 것이다.

좋은 데이터 아키텍처를 구축하기 위해서는 이러한 리포지토리들을 잘 구현하고 분류하여 레이어 2에 정리해두어야 한다. 외부와 연결되는 코드가 리포지토리 바깥에 흘려진다면(리포지토리를 거치지 않고 컴포넌트 내부에서 바로 `fetch`를 하는 등), 이는 개발자들이 데이터의 흐름을 추적하는 데 큰 어려움을 줄 것이다.

또한 리포지토리라는 개념을 단순히 서버 데이터를 가져오는 것에 국한하지 않는 것도 중요하다. 프론트엔드 프로그램의 외부가 항상 백엔드 서버인 것은 아니다. 웹뷰의 브릿지나 사용자가 브라우저의 웹 스토리지와 같이 시스템에 동봉되어 있지만 우리가 직접 구현하지 않은 코드들 또한 프로그램의 외부가 될 수 있다. 이러한 데이터에 접근할 때에도