본문 바로가기

FrontEnd

완벽하게 타입 안전한 웹 애플리케이션 개발[Fully Typed Web Apps]

반응형

...의 번역입니다 : https://www.epicweb.dev/fully-typed-web-apps

 

Fully Typed Web Apps

The main thing that makes end-to-end type safety difficult is simple: boundaries. The secret to fully typed web apps is typing the boundaries.

www.epicweb.dev

 
타입스크립트는 환상적입니다.

위 코드도 멋지지만, 저는 더 큰 관점에서 타입스크립트의 좋은 점을 언급하고 싶습니다.

바로 전체 프로그램(프론트엔드와 백엔드 사이 포함)에 걸쳐 흐르는 타입입니다.

바로 전체 프로그램(프론트엔드와 백엔드 사이 포함)에 걸쳐 흐르는 타입입니다.
실제 세계에서 타입이 동작하는 방식입니다.
"남은 좌석 수" 필드를 "총 좌석 수" 및 "판매된 좌석 수" 필드의 조합으로 바꾸겠다고 결정하는 것은 두려운 일입니다.
해당 리팩터링을 안내하는 타입이 없으면 어려움을 겪을 것입니다.
당신에게 확실한 단위 테스트가 있기를 바랍니다.
 
하지만 이 기사를 JavaScript의 타입이 얼마나 훌륭한지 설명하는 데 쓰고 싶지 않습니다.
대신, 나는 엔드 투 엔드(e2e) 타입 안전성이 얼마나 훌륭한 것인지 대한 제 경험을 공유하고,
애플리케이션에서 이를 달성할 수 있는 방법을 보여주고 싶습니다.
 
먼저, e2e 유형 안전성은
백엔드 코드를 통해 데이터베이스에서 UI까지, 그리고 그 반대방향으로 타입 안전성을 갖는 것입니다.
 
사람마다 처한 상황이 다르다는 것을 알고 있습니다.
데이터베이스에 대한 제어 권한이 없을 수 있습니다.
제가 PayPal에 있을 때 여러 팀에서 구축한 12개의 서비스를 사용했습니다. 데이터베이스를 직접 건드린 적이 없습니다.
진정한 e2e 타입 안전을 얻으려면 협력이 필요할 수 있음을 이해합니다.
저는 당신의 상황에서 가능한 한 멀리 갈 수 있도록 올바른 길로 당신을 도울 수 있기를 바랍니다.
 
e2e 타입 안전을 어렵게 만드는 주요 사항은 단순합니다.
바로 경계(boundaries)입니다.
 
웹에는 많은 경계가 있습니다. 이 중 일부는 염두에 두고 있을 수 있고 다른 일부는 고려하지 않을 수 있습니다.
다음은 웹에서 접할 수 있는 경계의 몇 가지 예입니다.
// synchronizing "ticket" state with local storage
const ticketData = JSON.parse(localStorage.get('ticket'))
//    ^? any 😱

// getting values from a form
// <form>
//   ...
//   <input type="date" name="workshop-date" />
//   ...
// </form>
const workshopDate = form.elements.namedItem('workshop-date')
//    ^? Element | RadioNodeList | null 😵

// fetching data from an API
const data = await fetch('/api/workshops').then(r => r.json())
//    ^? any 😭

// getting config and/or conventional params (like from Remix or React Router)
const { workshopId } = useParams()
//      ^? string | undefined 🥴

// reading/parsing a string from fs
const workshops = YAML.parse(await fs.readFile('./workshops.yml'))
//    ^? any 🤔

// reading from a database
const data = await SQL`select * from workshops`
//    ^? any 😬

// reading form data from a request
const description = formData.get('description')
//    ^? FormDataEntryValue | null 🧐​

더 많은 예가 있지만 이것들은 당신이 마주하게 될 몇 가지 일반적인 경계(boundaries)입니다.
여기에 있는 몇 가지 경계는 다음과 같습니다(더 있음).

  1. Local storage
  2. User input
  3. Network
  4. Config-based or Conventions
  5. File system
  6. Database requests
문제는 경계에서 얻는 것이 당신이 기대한 것과 같다고 100% 확신할 수 없다는 것입니다.
이는 불가능합니다.
타입 캐스트 ​​등을 사용하면 "TypeScript를 행복하게" 할 수 있지만 문제를 숨기고 있을 뿐입니다.
파일이 다른 프로세스에 의해 당신 모르게 변경되었을 수 있고,
API가 변경되었을 수 있으며
다른 사용자가 수동으로 DOM을 수정했을 수 있습니다.
그 경계를 넘어 당신에게 온 결과가 당신이 기대했던 것과 같다는 것을 확실하게 알 수 있는 방법은 없습니다.

 

그러나 이를 회피할 수 있는 몇 가지 방법이 있습니다. 다음 중 하나를 수행할 수 있습니다.
  1. 타입 가드/ 타입 단언 함수 작성
  2. 타압 생성 도구 사용(99% 신뢰도 제공)
  3. 컨벤션(convention)/ 설정(configuration)으로 타입스크립트 돕기

따라서 이러한 전략을 사용하여 웹 앱의 경계를 해결함으로써
엔드 투 엔드 타입 안전성을 얻는 방법을 살펴보겠습니다.

타입 가드/ 타입 단언 함수 작성

이것은 경계를 넘어 얻은 것이 당신이 예상한 것인지 확인하는 가장 효과적인 방법입니다.
당신은 이를 확인하기 위해 말 그대로 코드를 작성합니다!

다음은 타입 가드의 간단한 예입니다.

이 시점에서 TypeScript 컴파일러를 "달래야" 하는 것에 짜증 나는 분들이 있을 것입니다.
workshopId가 예상한 대로 될 것이라고 확신한다면 오류를 던지세요.
(이 잠재적인 문제를 무시했을 때 발생하는 오류보다 더 도움이 될 것입니다).
 
제가 코드를 좀 더 보기 좋게 만들기 위해 거의 모든 프로젝트에서 사용하는 편리한 유틸리티가 있습니다.

tiny-invariant의 README에서 발췌했습니다.

invariant 함수는 값을 취하며 값이 거짓이면 invariant 함수가 throw합니다
값이 참이면 throw하지 않습니다.
이런 코드를 추가해야 하는 것은 성가신 일입니다.
TypeScript가 규칙이나 구성을 모르기 때문에 이것은 까다로운 문제일 뿐입니다.
즉, TypeScript에 우리의 규칙과 구성에 대해 알려주면 조금 도움이 될 수 있습니다.
 
다음은 이 문제를 해결하는 몇 가지 프로젝트입니다.

 

  • Stratulat Alexandru의 routes-gen과 Wei Zhu의 remix-routes는 모두 Remix 설정/컨벤션 경로를 기반으로 타입을 생성합니다(이에 대해서는 나중에 자세히 설명하겠습니다).
  • (WIP) 모든 유틸리티(예: useParams)가 사용자가 정의한 경로에 액세스할 수 있도록 하는 Tanner Linsley의 TanStack Router

 

이것은 URL 경계를 지정하는 라우터 관점의 예일 뿐이지만,
TypeScript를 가르치는 아이디어는 규칙을 다른 영역에도 적용할 수 있습니다.
타입 가드의 더 복잡한 또 다른 예를 살펴보겠습니다.

비교적 단순한 타입의 경우에도 많은 작업을 해야 하는 것처럼 보입니다.
이런 작업들을 위해 zod와 같은 도구를 사용하는 것이 좋습니다.

zod에 대한 나의 가장 큰 관심사(항상 사용하지 않는 이유)는 번들 크기가 상당히 크다는 것입니다.
압축 안하면 42kb(42KB uncompressed) 입니다.
걱정되면, 서버에서만 사용하면 됩니다. 이정도 용량을 감안할 가치가 충분히 있습니다.
 
tRPC를 이용해 서버에서 zod로 정의된 타입을 클라이언트 측 코드와 공유하여
네트워크 경계에서 타입 안전성을 활용할 수 있습니다.
Remix를 사용할 때 개인적으로 tRPC를 사용하지 않지만(원하는 경우 확실히 사용할 수 있음),
Remix를 사용하지 않는다면 100% 이 기능을 위해 tRPC를 사용할 생각입니다.

타입 생성 도구 사용

Remix의 기존 경로에 대한 타입을 생성하는 두 가지 도구에 대해 이야기했습니다. (라우터)
이는 e2e 타입 안전성 문제를 해결하는 데 도움이 되는 타입 생성의 한 형태입니다.
 
이 해결 방법의 또 다른 인기 있는 예는 Prisma(내가 가장 좋아하는 ORM)입니다.
많은 GraphQL 도구도 이 작업을 수행합니다.
아이디어는 스키마를 정의할 수 있도록 하는 것이며
Prisma는 데이터베이스 테이블이 해당 스키마와 일치하는지 확인합니다.
그런 다음 스키마와 일치하는 TypeScript 타입 정의도 생성합니다.
타입과 데이터베이스를 효과적으로 동기화합니다.
 
예를 들어 다음과 같습니다.
const workshop = await prisma.user.findFirst({
   // ^? { id: string, title: string, date: Date } 🎉
  where: { id: workshopId },
  select: { id: true, title: true, date: true },
})
 
스키마를 변경하고 마이그레이션 스크립트를 생성할 때마다 prisma는 node_modules 디렉토리에 있는 타입을 업데이트하므로
prisma ORM과 상호 작용할 때 타입이 현재 스키마와 일치합니다.
다음은 kentcdodds.com에 있는 User 테이블의 실제 예입니다.

그리고 이것은 위의 스키마로부터 생성된 것입니다.

이것은 환상적인 개발자 경험을 제공하며 백엔드에서 내 애플리케이션을 통해 흐르는 내 타입의 시작점 역할을 합니다.
이 방법의 가장 큰 위험 요소는 데이터베이스 스키마와 데이터베이스의 데이터가 어떻게든 동기화되지 않는 경우입니다.
그러나 Prisma를 사용하면서 아직 경험하지 못했고 매우 드물 것으로 예상하므로
데이터베이스 상호 작용에 대해 assertion 함수를 추가하지 않는 것에 대해 꽤 확신을 갖고 있습니다.
Prisma와 같은 도구를 사용할 수 없거나 데이터베이스 스키마를 담당하는 팀이 아닌 경우에도,
스키마를 기반으로 데이터베이스에 대한 타입을 생성하는 방법을 찾는 것이 좋습니다.
혹은 데이터베이스 쿼리 결과에 assertion 함수를 추가할 수 있습니다.
 
 

TypeScript를 위해 이 작업을 수행하는 것이 아닙니다.
TypeScript가 없더라도 애플리케이션의 경계를 이동하는 데이터가 예상대로라는 확신을 갖는 것이 좋습니다.

"TypeScript는 당신의 삶을 더 나쁘게 만드는 것이 아닙니다. 당신의 삶이 이미 얼마나 나쁜지를 보여주고 있을 뿐입니다."

- 저는 지금 워크샵에서 폼의 런타임 타입 안정성을 얻는 것이 얼마나 어려운지 설명하고 있습니다.

컨벤션 / 설정으로 타입스크립트 돕기

더 어려운 경계 중 하나는 네트워크 경계입니다.
서버가 UI를 보내는지 확인하는 것은 까다롭습니다.
fetch는 제네릭 지원을 하지 않으며, 지원하더라도 스스로에게 거짓말을 할 것입니다.
제네릭에 대한 더러운 비밀을 알려드리도록 하겠습니다...
이 작업을 효과적으로 수행하는 거의 모든 함수는 아마도 나쁜 생각일 것입니다.
Whatever(타입 캐스트)를 볼 때마다 다음과 같이 생각해야 합니다.
"이것은 TypeScript 컴파일러에 거짓말을 하는 것입니다."
이것은 때때로 작업을 완료하는 데 필요한 것이지만 위의 getData 함수처럼 이 작업을 수행하지 않는 것이 좋습니다.
 
우리에게는 두 가지 선택이 있습니다.

두 경우 모두 TypeScript(그리고 자신)에게 거짓말을 하고 있지만, 첫 번째 경우는 거짓말임을 숨기고 있습니다.
자신에게 거짓말을 하려는 경우, 최소한 자신이 거짓말을 하고 있다는 사실을 알아야 합니다.

 

그렇다면 우리 자신에게 거짓말을 하고 싶지 않다면 어떻게 해야 할까요?
당신의 임포트에 대한 강력한 규칙을 설정한 다음 그 규칙을 TypeScript에 알릴 수 있습니다.
remix는 해당 기능을 지원합니다.
다음은 이에 대한 간단한 예입니다.

useLoaderData 함수는 Remix 로더 함수 타입을 받아들이고 가능한 모든 JSON 응답을 결정할 수 있는 제네릭입니다(이 기여에 대해 zod의 작성자 Colin McDonnell에게 큰 감사를 표합니다).
로더는 서버에서 실행되고 WorkshopRoute 함수는 서버와 클라이언트 모두에서 실행되지만
네트워크 경계를 넘어 이러한 타입을 가져오는 것은 제네릭을 이해하는 Remix의 로더 규칙 덕분에 발생합니다.
Remix는 로더에서 반환된 데이터가 useLoaderData에서 반환되도록 합니다.
모든 것이 하나의 파일에 있습니다. API 경로가 필요하지 않습니다 🥳.
 
이것은 놀라운 경험입니다.
UI에 price 필드를 표시하기로 결정했다고 상상해 보십시오.
데이터베이스 쿼리의 select를 업데이트하는 것만 큼 간단합니다.
불필요하게 다른 것을 변경하지 않고 UI 코드에서 사용할 수 있습니다. 완전한 타입 세이프 입니다.
그리고 더 이상 description이 필요하지 않다고 결정하면 select에서 해당 필드(description)을 제거하면
이전에 desscription을 사용했던 모든 곳에서 빨간색 물결선(타입 검사 오류)이 발생하여 리팩터링에 도움이 됩니다.
 
이 모든것이 네트워크 경계를 넘어 동작합니다!
 
 
우리 UI 코드의 date 속성은 백엔드에선 Date임에도 불구하고 문자열 타입이라는 것을 눈치채셨을 것입니다.
데이터가 네트워크 경계를 통과해야 하고 그 과정에서 모든 것이 문자열로 직렬화되기 때문입니다(JSON은 Date를 지원하지 않음).
타입 유틸리티는 뛰어난 이 동작을 적용합니다.
 
해당 날짜를 표시하려는 경우 앱이 사용자 컴퓨터에서 수화될 때 시간대 이상을 피하기 위해 보내기 전에 로더에서 타입을 지정해야 합니다. 이것이 마음에 들지 않으면
Matt Mueller 및 Simon Knott의 superjson 또는 Michael Carter의 remix-typedjson과 같은 도구를 사용하여
해당 백엔드 데이터 타입을 UI에서 복원할 수 있습니다.
 
Remix에서는 액션에서도 타입 안전성을 얻습니다. 다음은 그 예입니다.
우리의 액션이 반환하는 것은 useActionData가 참조하는 타입(직렬화됨)이 됩니다.
저는 타입 안전한 속성도 포함하는 remix-validity-state를 사용하고 있습니다.
또한 제출된 데이터는 내가 제공한 스키마에 따라 remix-validity-state에 의해 안전하게 구문 분석되므로,
submitFormData 타입을 통해 내 데이터를 바로 사용할 수 있습니다.
이를 위한 다른 라이브러리도 있지만,
요점은 몇 가지 간단한 유틸리티를 사용하여 경계를 넘어 환상적인 타입 안전성을 얻을 수 있고
배포에 대한 자신감을 높일 수 있다는 것입니다.
유틸리티 API는 간단합니다. 물론 때로는 유틸리티 자체도 매우 복잡합니다 😅

 

이 타입 안전성은 다른 Remix 유틸리티에서도 동작한다는 점을 언급하고 싶습니다.
meta export 또한 useFetcher 및 useMatcher와 같이 타입 안전하게 사용할 수 있습니다.

로더도 마찬가지입니다.

모든것이 하나의 파일에 존재합니다.

결론

요점은 타입 안전성이 가치가 있을 뿐만 아니라 경계를 넘어 끝에서 끝까지(e2e로) 달성할 수 있는 것이라는 것입니다.
마지막 로더 예제는 데이터베이스에서 UI까지 계속 진행됩니다.
그 데이터는 데이터베이스 → 노드 → 브라우저에서 타입 안전하며
엔지니어로서 저를 엄청나게 생산적으로 만듭니다.
 
당신이 작업 중인 프로젝트가 무엇이든 간에
타입 캐스트 거짓말을 어떻게 제거할지,
제가 여기에 제공한 몇 가지 제안을 사용하여 더 진정한 타입 안전성으로 나아갈 지 고민해 보세요.







 

반응형