Backend

TypeScript는 런타임을 지켜주지 않는다 - Zod로 해결하기

eunmiee 2026. 1. 26. 00:57

들어가며


이번 포스팅은 TypeScript에 대한 주제로 가져와보았다!

최종 프로젝트에 들어가게 되면서 기능 구현 자체에만 집중하다 보니 정작 문서화를 많이 못한 것 같았다.

 

오랜만에, 개발 과정에서 고민하고 해결했던 과정들을 블로그에 담아보려고 한다!

매번 잘 돌아가고 있다는 생각으로 그냥 넘어갔던 부분들을 이번 개발 과정에서 깨닫게 되어버렸다..

 

 

 

문제 인식


TypeScript를 도입하여 개발을 진행하던 중, 다음과 같은 문제점을 발견했다.

async getComprehensionStatistics(): Promise<ComprehensionStatistics[]> {
  const rawResults = await this.dataSource.query(`SELECT ...`);
  return rawResults;  // 타입은 ComprehensionStatistics[]라고 했지만...
}
1. TypeScript가 Promise <ComprehensionStatistics[]>라고 선언해도 실제로는 검증 안 함
2. DB에서 totalSolved: "123" (문자열)이 와도 TypeScript는 모름
3. 런타임에 타입 불일치 발생 가능

 

즉, 런타임 환경에서 타입 불일치에 대한 에러가 없이 response로 응답을 던져줄 때, 처음에 Reponse 타입으로 등록했던 number가 아닌 문자열로 반환되는 것을 확인할 수 있었다.

난 분명히 number로 타입 명시했는데 왜 문자열로 반환되니..?

 

이에 대해 왜 다음과 같은 문제가 발생하는지 찾아보게 되었다.

 

타입스크립트는 만능이 아니다.


가장 먼저 알게 된 점은, 타입스크립트에 대한 완전한 오해가 있었다.

타입스크립트의 타입은 컴파일 시에만 존재하고, 런타임에는 완전히 사라진다는 점이다.

왜일까? 이를 알기 위해서는 자바스크립트의 런타임과 타입스크립트의 컴파일 과정에 대해 알아보아야 한다.

컴파일(Compile)이란

  • 개발자가 주로 사용하는 JAVA, C와 같은 언어는 사람이 읽을 수 있는 언어로 고수준언어이다.
  • 해당 언어는 컴퓨터가 바로 읽을 수 있는 형태인 0과 1로 이루어진 저수준언어 즉, 기계어로 변환해 주는 작업이 필요한데 이를 컴파일이라고 한다.
  • 이렇게 소스코드가 기계어로 변환되어 실행 가능한 프로그램이 되는 단계를 컴파일 타임이라고 한다.

런타임(Runtime)이란

  • 프로그램이 실행되는 동안의 시간과 환경을 의미
  • 소스코드의 컴파일이 완료되면 프로그램이 메모리에 적재되어 실행되는데, 이 시간을 런타임이라고 한다.

즉, 컴파일러가 소스코드를 파싱 하여 AST로 만들고, 다시 AST를 바이트 코드로 변환한 것을 런타임이 평가하도록 지시하는 것이다.

JS의 경우 인터프리터 언어로, 실행 중(런타임)에 자주 사용되는 부분들을 감지하여 JIT 컴파일을 진행하게 된다.

그래서 JS의 런타임과 TS의 컴파일을 함께 알아보아야 한다.

 

이 과정에서 알아야 하는 점은, 일반적으로 컴파일 과정을 거쳐 기계어로 번역 되기 전 바이트 코드로 변환하는 방식이 일반적인 반면 타입스크립트는 바이트코드 대신 JS 코드로 반환한다는 점이다!


이때, 소스코드를 파싱하여 AST를 만들고, 타입검사기가 AST를 확인하며 타입 확인 과정을 거치게 된다.

TS 파일을 실행하게 되면 다음과 같은 과정으로 컴파일된다.

  • Node.js 실행 전이나 번들링과 트랜스 파일링 진행 시 컴파일을 진행하게 된다.
    TypeScript 코드 (.ts) 
          ↓ 
    컴파일/트랜스파일 (타입 체크!) 
          ↓ 
    JavaScript 코드 (.js) 
          ↓ 
    실행 (브라우저/Node.js)

[출처] 한 입 크기로 잘라먹는 타입스크립트

 

[출처] 한 입 크기로 잘라먹는 타입스크립트


이후, JS로 변환된 코드 또한 컴파일 과정을 거쳐 바이트 코드로 변환되어 실행하게 되는데 이때에는 타입검증을 진행하지 않는다.

즉, TS가 타입스크립트 코드를 컴파일하여 변환된 자바스크립트 코드는 개발자가 사용한 타입을 확인하지 않는다!

 

결론적으로, 개발자가 코드에 기입한 타입정보는 최종적으로 만들어지는 프로그램에 아무 영향도 주지 않으며, 단순히 타입을 확인하는 용도로 쓰이게 된다.

 

타입스크립트는 컴파일타임에 타입을 검사하기 때문에 에러가 발생하면 프로그램이 실행되지 않는다.
이러한 특성으로 인해 타입 스크립트는 정적 타입 검사기(static type checker)라고 부른다.

이를 통해 TypeScript로 타입 선언만 하면 안전할 것이라고 생각했던 점과 다르게, 컴파일 타임에서만 체크릴 진행하므로 런타임에 대한 검증이 따로 필요하다는 점을 알게 되었다.

 

그렇다면, SQL 쿼리에 대한 타입 검증은 어떻게 할까?


내가 고민한 방식은 두 가지가 있었다.


첫 번째로는 DTO 형식으로 class-validator를 사용한 유효성 검증 방식,
두 번째는 Zod를 활용한 타입 검증 방식이었다.

 

각 방식에 대해 비교를 하다 보니 class-validator는 NestJS 통합형으로 현재 NestJS환경에서 적합한 환경이지만, 용도가 Controller에서의 DTO 타입 검증 시 사용되는 용도였다.
Zod의 경우, 데이터 파싱이나 검증의 용도로 쓰이고 특히 레포지토리나, 외부 API 연동 시 타입검증을 진행할 때 사용하는 것 같았다.

 

코드로 비교했을 때를 보더라도 validator를 사용하는 방식은 변환 검증과정이 복잡하다는 점이었다.
비동기로 동작하다 보니 루프 안에서 await를 활용하여 검증(성능저하)하는 방식으로 구현이 필요해 보였다.
반면에 Zod를 사용하는 경우 방식이 간결하고, parse()라는 메소드를 제공하여 동기처리로 빠르게 검증이 가능하다는 점이었다.

이러한 점들을 미루어 볼 때, Zod를 도입하여 현재 로우쿼리에 대한 결괏값을 타입 검증하는 방식으로 결정했다.

// 스키마 정의

const ComprehensionStatisticsSchema = z
    .object({
        category: z.string(),
        totalSolved: z.number().int().nonnegative(),
        high: z.number().int().nonnegative(),
        normal: z.number().int().nonnegative(),
        low: z.number().int().nonnegative(),
        comprehensionScore: z.number().min(0).max(5),
    }).strict();



export type ComprehensionStatistics = z.infer<typeof ComprehensionStatisticsSchema>;

...

async getComprehensionStatistics(
	userId: number,
): Promise<ComprehensionStatistics[]> {
	const query = `
    ...
    `;


    try {

        const rawResults = await this.dataSource.query(query, [userId]);

        return z.array(ComprehensionStatisticsSchema).parse(rawResults);  // 타입 검증

    } catch (error: unknown) {

        ...

        }

}

 

이처럼 스키마 정의를 통해 타입 검증을 빠르고 간단하게 처리할 수 있었다.
사용해 보니 이를 통해 타입 정의와 검증 용도로 한 번에 사용할 수 있어 추후 타입이 변경되더라도 해당 타입만 변경해 주면 된다는 점이 좋았다.

따로 types로 정의하지 않고도 export를 통해 재사용할 수 있었다!

 

물론 이 방법을 사용하면서 한 가지 불편한 점도 있었다.


로우 쿼리 단에서 지정한 타입을 맞춰서 쿼리문을 작성해야 한다는 점이었다.
이게 불편하다면 매번 쿼리를 통해 나온 결괏값을 지정한 타입에 맞춰 변환하는 코드를 추가해줘야 했는데, 오히려 코드 복잡도가 높아지고, 추후 다른 DB로 바꿔서 진행할 예정이 없었기 때문에 쿼리단에서 타입을 명시하여 보내도록 진행하였다.

 

번외로, postgreSQL의 타입 캐스팅 문법에 대해서도 새롭게 알아갈 수 있었다.
MariaDB를 사용할 때에는 CAST()를 사용한 방식으로 작성했었는데, ::INTEGER와 같은 형태로 사용해 보니 더 쿼리가 간결하고 편리하게 느껴졌다.

이번 통계 쿼리를 짜면서 distinct on과 같은 메소드를 제공하여 기본적인 sql 문으로 작성했다면 서브쿼리나 ROW_NUMBER()로 작성하여 복잡도가 높아질 수 있었던 쿼리를 조금 더 단순하게 짤 수 있었다.

단순히 지식적으로 postgreSQL이 통계에 유리하다는 이야기를 들어왔는데 이번에 개발을 진행해 보며 조금은 공감이 되는 경험이었다.

 

배운 점과 느낀 점


이번 개발과정을 통해 TypeScript의 내부 로직을 함께 이해하고 한계점에 대해 학습할 수 있었다.
TS를 런타임 과정에서 검증할 수 없다는 점에서 충격적이기도 했고, 이걸 검증하기 위한 처리를 따로 해야 한다는 점에서 TS가 불편한 점도 많다는 것을 느꼈다.(TS가 만능은 아니구나...)

하지만, 그동안 이런 기본적인 동작원리도 파악하지 않고 사용하고 있었다는 점에서 많이 반성하게 되는 계기였다.
그래도 실제로 개발하는 과정에서 "왜 반환할 때 number가 아니라 string으로 반환되고 있지?"라는 의문을 갖고 문제점을 해결해 나가는 과정에서 학습을 진행하다 보니 더 오래 기억될 것 같다!

이번에는 까먹지 않으려고 바로 포스팅도 함께 진행해 보았다 ㅎㅎ

 

 

참고

https://young-taek.tistory.com/283

https://medium.com/@1004wipi/typescript%EC%9D%98-%ED%95%9C%EA%B3%84%EB%A5%BC-%EB%9B%B0%EC%96%B4%EB%84%98%EB%8A%94-zod-%EB%9F%B0%ED%83%80%EC%9E%84-%EC%95%88%EC%A0%95%EC%84%B1%EC%9C%BC%EB%A1%9C-%EA%B0%80%EB%8A%94-%EC%97%AC%EC%A0%95-5d6954c444c2