Ayden's journal

common-resolver

이번 주, sicilian과 caro-kann 모두 3.1 버전을 공개했다. 하나는 폼 관리 도구이고 다른 하나는 전역 상태 관리 도구라는 차이가 있지만, 이번 업데이트에서는 공통적으로 '런타임 타입 체크' 기능이 추가되었다. sicilian의 경우, 이전에도 내장된 validator를 통해 런타임 타입 검증이 가능했으나, zod, yup, superstruct와 같은 외부 스키마 라이브러리를 활용해 사용자 정의 검증 로직을 통합하는 방법은 존재하지 않았다. 그러나 3.1 버전부터는 이러한 스키마 라이브러리들과의 연동이 간편해졌고, 폼 데이터의 유효성 검사는 물론 구조와 타입까지도 런타임에서 명확하게 검증할 수 있게 되었다.

sicilian에 외부 스키마 라이브러리를 통합하는 것은 오래전부터 품어온 염원이었지만, 실제로는 caro-kann에 이 기능이 먼저 적용되었다. 전역 상태와 스키마를 비교해 검증에 실패하면 단순히 콘솔에 에러 객체를 출력하는 정도였기에, 구현 자체는 한두 시간이면 충분하다고 판단했다. 늘 그렇듯 나는 기능 구현에 있어 나만의 행동 강령을 따랐다. "일단 돌아가게 만들고, 정리는 나중에." 그리하여 caro-kann 내부에 zod, yup, superstruct에 대응하는 resolver 코드를 각각 작성했고, sicilian에도 ─ 훨씬 복잡한 버전으로 ─ 동일한 작업을 진행했다.

 

두 라이브러리가 요구하는 resolver의 형태는 다소 달랐지만, 핵심은 결국 외부 스키마 라이브러리의 검증 결과를 받아 내부 에러 포맷으로 변환하는 작업이었다. 이 과정에서 필요한 것은 복잡한 로직이 아니라, 다양한 케이스를 유연하게 아우를 수 있는 깔끔한 매핑 전략이었다. 나는 공통된 로직을 별도의 유틸로 분리하고, 각 라이브러리의 특성에 맞게 경로를 조정하거나 메시지를 가공해 동일한 인터페이스를 제공하기로 마음먹었다.

 

CEF

CEF(Common ErrorObject Format)는 공통 에러 객체 형식을 의미하며, common-resolver는 각 에러 객체를 우선적으로 CEF 형식으로 변환한다. 이를 통해 다양한 형태의 에러 객체를 일관된 구조로 통합한 뒤, 최종적으로 사용자가 쓰기 편한 CRES(Common-Resolver ErrorObject Structure)로 변환하는 과정을 하나의 흐름으로 구성할 수 있다. 이러한 형식 변환기는 각 resolver 내부에 정의되어있으며, CEF는 아래와 같이 { [path: string]: string }의 형태를 갖는다.

const CEF = {
  agreeToTerms: "모든 약관에 동의해야 합니다",
  agreeToTerms.marketingTerms: "마케팅 수신 동의에 동의해야 합니다",
  email: "이메일 형식이 아닙니다",
  root: '비밀번호가 일치하지 않습니다'
}

 

CRES

앞서 언급된 바와 같이 CRES는 common-resolver가 제공하는 관된 에러 응답 형식으로, 다양한 출처에서 발생하는 에러들을 표준화하여 처리할 수 있도록 설계되었다. 이를 통해 개발자는 각기 다른 서비스나 라이브러리에서 반환하는 복잡한 에러 객체를 일일이 해석하지 않고도, CRES를 기반으로 한 통합된 방식으로 에러를 관리하고 사용자에게 일관성 있는 피드백을 제공할 수 있다. 가령 아래와 같은 값과, 그 값을 검증하기 위한 스키마가 있다고 할 때

const value = {
  email: "test",
  nickname: "test",
  password: "test1234!",
  passwordCheck: "test1234",
  agreeToTerms: {
    theTerms: true,
    personalTerms: true,
    marketingTerms: false,
  },
}

const zSchema = z.object({
  email: z.string().email("이메일 형식이 아닙니다").min(1, "이메일은 필수입니다"),
  nickname: z.string().min(1, "닉네임은 필수입니다"),
  password: z.string().min(1, "비밀번호는 필수입니다"),
  passwordCheck: z.string().min(1, "비밀번호 확인은 필수입니다"),
  agreeToTerms: z.object(
    {
      theTerms: z.boolean().refine(val => val, { message: "이용약관에 동의해야 합니다" }),
      personalTerms: z.boolean().refine(val => val, { message: "개인정보 수집 및 이용에 동의해야 합니다" }),
      marketingTerms: z.boolean().refine(val => val, { message: "마케팅 수신 동의에 동의해야 합니다" }),
    }
  ).refine(val => val.theTerms && val.personalTerms && val.marketingTerms, { message: "모든 약관에 동의해야 합니다" }),
}).refine(data => {
  if (data.password !== data.passwordCheck) {
    return false;
  }
  return true;
}, "비밀번호가 일치하지 않습니다")

 

CRES는 아래와 같이 형성된다. 여기서 각 에러 항목에 포함된 root는 객체나 배열 그 자체에 대한 에러를 의미한다. 만약 root 없이 이를 처리할 경우, agreeToTerms.marketingTerms와 같이 객체가 중첩되는 상황에서 최상위 객체(agreeToTerms)에 대한 에러 메시지를 표현하기 어려워진다. 따라서 root 필드는 특정 하위 속성뿐 아니라 해당 객체 전체에 관련된 일반적인 에러를 명확하게 전달할 수 있는 역할을 하며, 이를 통해 에러 처리 로직이 보다 일관성 있고 유연해진다. 이 구조는 중첩된 데이터 구조에서도 각 수준별로 적절한 에러 메시지를 제공하여 사용자 경험을 개선하는 데 큰 도움이 된다.

const CRES = {
  agreeToTerms: {
    marketingTerms: { root: '마케팅 수신 동의에 동의해야 합니다' },
    root: '모든 약관에 동의해야 합니다'
  },
  email: { root: '이메일 형식이 아닙니다' },
  root: '비밀번호가 일치하지 않습니다'
}

 

하지만 매 경로마다 끝에 root를 붙이는 건 조금 귀찮을 수 있다. 따라서 common-resolver는 CRES를 Proxy 객체로 감싸서 제공하고 있다. 이 Proxy 객체가 하는 일은 몇 가지가 있는데, 우선 에러 메시지에 접근할 때 자동으로 해당 경로의 root 값을 반환하거나, 하위 객체가 있다면 다시 Proxy로 감싸 재귀적으로 접근할 수 있도록 한다. 이를 통해 개발자는 복잡한 중첩 구조를 일일이 확인하지 않고도 간편하게 에러 메시지에 접근할 수 있으며, 직접 root를 명시하지 않아도 항상 적절한 기본 메시지를 얻을 수 있다. 또한, 이 Proxy는 에러 객체의 속성을 변경하거나 삭제하려는 시도를 차단하여, 에러 메시지의 불변성을 보장함으로써 안정적인 에러 처리 환경을 제공한다.

 

이 때문에 CRES의 타입 정의는 최상단에만 root가 선택적으로 존재하고, 그 외 하위 객체들에는 root가 없는 것처럼 취급된다. 이를 위해 RecursivePartial<T> 타입을 사용하여 객체의 모든 속성을 재귀적으로 선택적으로 만들고, 각 속성의 값은 문자열 또는 다시 RecursivePartial 타입이 되도록 하였다. 최상위 레벨에서는 root가 있을 수 있으나, 하위 레벨에서는 root가 별도로 존재하지 않는 구조를 타입 시스템 상에서 명확히 표현함으로써, 실제 사용 시 root 값을 붙이는 번거로움을 줄이고 타입 안정성을 높일 수 있다.

type CRES<T> = RecursivePartial<T> & { "root"?: string };

export type RecursivePartial<T> = {
  [P in keyof T]?: T[P] extends object
    ? RecursivePartial<T[P]>
    : string;
};

 

한 가지 주의할 점은 agreeToTerms.root에 위치한 에러 메시지에 접근할 때에도, 타입스크립트는 이를 string이 아닌 객체로 인식한다는 것이다. 다소 아쉬운 부분이지만, 이는 일정 부분 의도된 동작이다.

 

RecursivePartial 타입을 아래와 같이 수정한다고 가정해보자. 이 경우 점 표기법 등으로 깊숙한 위치의 프로퍼티에 접근할 때, 각 단계에서 해당 값이 string이 아닌 object인지 매번 확인해야 한다. 그렇지 않으면 모든 프로퍼티 접근 시 타입 에러가 발생할 수 있다. 따라서 root 값에 접근하는 경우에 대해 실제 타입을 그대로 반영하기보다는 그때그때 as string으로 단언해버리는 편이 훨씬 덜 번거롭다.

export type RecursivePartial<T> = {
  [P in keyof T]?: T[P] extends object
    ? RecursivePartial<T[P]> | string
    : string;
};

 

Resolver

common-resolver는 sicilian과 caro-kann에 공통된 런타임 타입 체크 기능을 제공하기 위해 만들어졌지만, 공개된 라이브러리인 만큼 다른 사람들도 쉽게 사용할 수 있도록 설계되었다. common-resolver가 제공하는 모든 리졸버는 어떤 스키마 라이브러리를 사용했는지와 관계없이 Resolver라는 공통된 인터페이스를 따르기 때문에, 사용하는 쪽에서는 내부 구현에 신경 쓰지 않고 동일한 방식으로 유효성 검사를 수행할 수 있다.

export type Resolver<T> = {
  validate: (state: T) => {
    valid: true;
    error: null;
  } | {
    valid: false;
    error: CRES<T>;
  };
}

 

그 외

common-resolver에는 isZodSchema와 같이 전달된 스키마가 특정 라이브러리의 형식을 만족하는지 확인하는 커스텀 타입 가드 함수들이 포함되어 있으며, 이를 기반으로 적절한 리졸버를 선택해주는 getResolver 함수도 함께 제공된다. 이를 통해 사용자는 스키마의 종류에 따라 직접 분기 처리를 하지 않아도 되고, 다양한 스키마 라이브러리를 유연하게 지원할 수 있는 구조를 손쉽게 구현할 수 있다.

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기