타입스트립트 데이터 핸들링 마스터하기 (작성중)
들어가기 전에
데이터. 모든 것이 데이터입니다. DB에 있는 데이터도 데이터이고, 사용자가 input 폼에 입력하는 것도 데이터입니다. 데이터는 다양한 곳에서 다양하게 활용되는 만큼 전략 또한 다양합니다. 저는 특히 비즈니스 로직에서 타입스크립트를 사용한다고 했을 때, 거기서 데이터를 잘 다루는 방법에 대해 적어보고자 합니다.
필자가 생각하는, 데이터를 잘 다루기 혹은 쉽게 하기 등에 대해서 한번 묘사하고 넘어가겠습니다. 이런 감성(?)이 이 글의 방향성이기도 합니다. 타입스크립트에서 데이터를 잘 다룬다고 했을 때에는 다음과 같은 느낌적인 느낌을 지니고 있다고 해도 무방합니다.
타입 확장이 쉬워야 합니다.
비슷한 데이터끼리 논리를 묶는 작업은 프로그래머의 사명입니다. 간단한 논리는 함수를 만들어버린 후 그걸 재사용합니다. 객체가 등장하면서 상황은 훨씬 복잡해졌습니다. 데이터는 타입과 메소드가 서로 묶였습니다. 비슷하지만 다른 틀을 만들어내기 위해 상속에 상속에 상속을 시킵니다. 여러 인스턴스들은 다형성을 지닌채 이곳저곳 돌아다닙니다. 타입시스템이 복잡해지자 디자인패턴이 등장했습니다.
객체지향 프로그래밍 시대를 관통하면서 부상한 다양한 디자인패턴은 React 와 같은 프론트엔드 프레임워크와 잘 맞지 않습니다. 데이터 설계의 폭이 줄어들었습니다. 지엽적인 방법이 많이 생겨났고, 타입스크립트도 등장했지만 여전히 데이터를 잘 다루는 일반적인 방법은 찾기 어렵습니다. 타협점을 그때그때 찾아야 합니다.
데이터 변환은 쉬워야 합니다.
데이터는 어디에나 다양한 형태로 존재하지만, 우리의 비즈니스 로직에서 쉽게 다뤄야 합니다. 그를 위해 하나의 통일된 타입으로 변환하는 작업이 필요하고, 그 작업이 쉬워야 합니다.
예를 들어, 예약 시간을 정하려고 하는 사용자가 날짜와 시간을 <input type="number" >
넣고 숫자로 잘 설정하면, 실제 데이터를 우리가 다루기 위해 Date 의 형태로 잘 변환되어야 합니다. 데이터베이스에 저장된 데이터도 마찬가지입니다. 보통 ISO_8601 형태 (예: 2012-02-08T07:50:43.309Z
)와 같이 문자열처럼 저장되는데, 이를 우리가 다루기 쉬운 Date 형태로 변환해야 합니다.
또한 데이터는 JSON 으로도 쉽게 변환될 수 있어야겠습니다. 클라이언트와 서버 간 소통을 할 때 대부분 JSON 형태로 데이터를 주고 받기 때문입니다.
앞으로 예제로서 Date 형태를 자주 맞닥뜨리게 될 겁니다.
타입 수정은 쉬워야 합니다.
서비스를 만들고 개선시켜나가다 보면 어떤 DB에 컬럼이 새로 생기는 등 데이터 구조에 변화가 생깁니다. 비즈니스 로직을 바꾸기 위해 타입도 바꿉니다. 하지만 빡빡한 타입 시스템이 우릴 자꾸 괴롭힙니다. 새로운 비즈니스 요구 때문에 수정한 데이터 설계에 맞춰 데이터 타입을 잘 변경하고 싶은데, 에러를 자꾸 물고 늘어집니다. 개발의 효율성을 위해 타입스크립트를 사용하는 건데, 그 타입 시스템에 맞추기 위해 아등바등하는 자신의 모습을 보면 갑자기 주객전도가 된 듯한 느낌입니다. 어떻게 쉽게 해결할 수 없는 타입 에러 때문에 as any
와 같은 극약처방이 점점 많아지는 기분입니다.
목표
다시 요약하면, 우리 설계의 다음과 같습니다.
- 타입 확장 쉽게 - 같아야 하는 로직은 하나만, 달라야 하는 로직을 잘 나눌 수 있도록 하기.
- 데이터 변환 쉽게 - Date 다뤄보기
- 타입 변경 쉽게 - 필드 추가해보기
그리고 금지할 것들은 뭐가 있을까요?
any
를 사용하지 않습니다.as
를 사용하지 않습니다.
설계
처음에는 그냥 차근차근 어떤 스토리를 풀어나가면서 설계를 개선해나가는 형태로 글을 작성하고자 했으나, 그렇게 하는 게 많이 힘든 것 같아서... ㅋㅋ 그냥 처음부터 예제와 함께 결론을 내세우겠습니다.
당신은 네이버 블로그 개발자입니다.
그래서 당신은 주어진 데이터를 화면에 잘 보여줘야 합니다. 즉 렌더링을 잘 해야 합니다. 렌더링이란, 데이터로만 있는 것들 (JSON 데이터, CSV 통계 데이터 등)을 실제 사람이 보기 좋게 만들어주는 것입니다. 지금은 블로그 콘텐츠를 잘 보여줘야 하는 입장이니, React 컴포넌트로 적절하게 잘 보여주는 게 목표입니다.
이 데이터의 특징은 다음과 같습니다.
- 순서가 있습니다. 위에서부터 차례대로 콘텐츠가 있습니다.
- 콘텐츠의 종류는 다양합니다. 우선 "인용구", "이미지", "일반 문단" 이렇게 세 가지로 가정합니다.
- 같은 종류의 콘텐츠도 여러번 나올 수 있습니다.
이를 예시를 들어 그림과 표현하면 아래와 같이 보일 것입니다.
이런 상황에서, 앞서 이야기한 세 가지 목표를 구체화하겠습니다.
- 타입 확장 쉽게 : 로직에서 일반적인 부분과 개별적인 부분을 나누어 구현하기 쉬워야 합니다. 즉 모든 콘텐츠는 적절하게 순서에 맞게 렌더링되어야 하지만, 그 동시에 각각 저마다 렌더링되는 방식에 차이가 있습니다. 지금은
render
함수를 구현하는 게 목표입니다. - 데이터 변환 쉽게: 포스팅에서 생성일과 수정일은 데이터베이스 혹은 네트워크로 받을 땐
string
입니다. 이를 핸들링하기 편하도록 비즈니스 로직에서는 Day.js 라이브러리의Dayjs
타입으로 다뤄보도록 하겠습니다. - 타입 변경 쉽게: "이미지" 콘텐츠는 이미지 그 자체의 정보밖에 지니고 있지 않지만, 추후 "설명" 정보를 추가해야 하는 상황을 만들어보겠습니다.
코드 - 타입 확장 및 데이터 변환에 용이한 구조
"use client";import type { Dayjs } from "dayjs";import dayjs from "dayjs";import { useEffect, useState } from "react";import data from "./test.json";interface IQuote { type: "quote"; text: string; source: string;}interface IImage { type: "image"; src: string; width: number; height: number;}interface IParagraph { type: "paragraph"; text: string;}type Content = IQuote | IImage | IParagraph;interface IPost { title: string; createdAt: Dayjs; modifiedAt: Dayjs; contents: Content[];}interface IPostUntrusted { title?: string; createdAt?: string; modifiedAt?: string; contents?: Partial<Content>[];}function createPost(postUntrusted: IPostUntrusted = {}): IPost { return { title: "", ...postUntrusted, modifiedAt: dayjs(postUntrusted.modifiedAt), createdAt: dayjs(postUntrusted.createdAt), contents: postUntrusted.contents ? postUntrusted.contents.map(createContent) : [], };}function createContent(content: Partial<Content> = {}): Content { switch (content?.type) { case "quote": return createQuote(content); case "image": return createImage(content); case "paragraph": return createParagraph(content); default: throw new Error("Invalid content type"); }}function createQuote(content: Partial<IQuote> = {}): IQuote { return { text: "", source: "", ...content, type: "quote", };}function createImage(content: Partial<IImage> = {}): IImage { return { src: "", width: 0, height: 0, ...content, type: "image", };}function createParagraph(content: Partial<IParagraph> = {}): IParagraph { return { text: "", ...content, type: "paragraph", };}function render(content: Content) { switch (content.type) { case "quote": return renderQuote(content); case "image": return renderImage(content); case "paragraph": return renderParagraph(content); }}function renderQuote(content: IQuote) { return ( <blockquote> <p>{content.text}</p> <cite>{content.source}</cite> </blockquote> );}function renderImage(content: IImage) { return ( <img src={content.src} width={content.width} height={content.height} alt="" /> );}function renderParagraph(content: IParagraph) { return <p>{content.text}</p>;}export const Post: React.FC<{ post: IPost;}> = ({ post }) => { const { createdAt, contents, modifiedAt, title } = post; return ( <article> <div>제목: {title}</div> <div>생성일: {createdAt.format("YYYY-MM-DD")}</div> <div>수정일: {modifiedAt.format("YYYY-MM-DD")}</div> {contents.map((content) => render(content))} </article> );};export const PostPage: React.FC = () => { const [post, setPost] = useState<IPost>(createPost()); useEffect(() => { const fetchData = async () => { try { // const response = await fetch("/api/post/1"); // const json = (await response.json()) as IPostUntrusted; const json = data as IPostUntrusted; const post = createPost(json); setPost(post); } catch (error) { console.log("error", error); } }; fetchData(); }, []); return ( <div> <Post post={post} /> <button>리스트로...</button> </div> );};
데이터와 렌더링 결과
{ "title": "[리뷰] 반항하는 인간", "createdAt": "2021-08-01T00:00:00.000Z", "modifiedAt": "2021-08-02T00:00:00.000Z", "contents": [ { "type": "paragraph", "text": "이 책은..." }, { "type": "image", "src": "https://via.placeholder.com/150/FFFF00/000000", "width": 150, "height": 150 }, { "type": "quote", "text": "나는 반항한다, 고로 우리는 존재한다.", "source": "알베르 까뮈" }, { "type": "paragraph", "text": "내 생각에는..." }, { "type": "image", "src": "https://via.placeholder.com/150/FFFF00/000000", "width": 150, "height": 150 } ]}
자 이제 코드 설명 들어가겠습니다.
Discriminated Unions
구별된 유니온, 서로소 유니온 등등 명확하게 번역된 단어는 없습니다.
이 기법을 사용하는 이유는 Narrowing 을 쉽게 하기 위해서입니다. Narrowing 을 하는 이유와 예시는 여기서 다루지 않겠습니다. 한국어로 하면 타입 좁혀나가기라고 이야기할 수 있겠습니다.
https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions
https://basarat.gitbook.io/typescript/type-system/discriminated-unions
https://ahnheejong.name/articles/safe-data-modeling-using-disjoint-union-type/
Content
는 IQuote
, IImage
, IParagraph
를 합친 타입입니다. 이 타입간의 구분은 type
필드를 통하빈다.
IPostUntrusted
는IPost
의 필드 중 일부만을 사용하는 타입입니다. 이 타입은createPost
에서 사용됩니다.