Back-end/nest.js

Nest.js : Prisma의 Date Return type 문제 (Prisma date to string)

hsloth 2023. 6. 16. 02:25

 

본 포스팅은 https://suloth.tistory.com/107?category=1115342 

 

Digimon project : 프로젝트 시작

내가 평소 즐겨하는 게임인 디지몬 알피지(Digimon RPG, 이하 디알)라는 게임이 있다. 이 게임을 하면서 항상 느끼는 점이 뉴비에게 불친절하다는 점과 어떤 정보를 알려고하면 게임을 오래 즐긴 유

suloth.tistory.com

위의 프로젝트를 진행하는 도중 작성되었습니다.


먼저 나는 Date형식의 값을 string으로 변환해서 취급할 필요가 있다는 걸 깨달았다.

일단 typia를 사용하려면 모든 Date형식은 string으로 변환해서 취급해야 하는 점과

Date는 validation 속도도 string에 비해 느린걸로 알고 있다.

그리고 samchon님의 말씀에 따르면 (내가 제대로 이해한 건지는 모르겠지만) Date객체를 클라이언트로 리턴할 경우, 각 시스템의 locale 설정대로 시간 값이 변환되어 string으로 리턴이 된다고 한다. (쉽게 말하면 Date객체의 시간 값이 바뀐다)

 


그런데 여기서 문제가 발생했다. Prisma로 받아온 객체의 Date관련 칼럼들의 리턴 타입이 무조건 Date였던 것이다...!

그래서 해당 객체의 Date타입들을 모조리 string으로 바꾸기로 마음 먹고 다음 함수를 만들었다.

export function dateNullToStringNull<T extends Date | null>(
  target: T,
): string | null {
  return target?.toISOString() ?? null;
}

위의 함수는 Date | null 타입을 string | null 으로 추론될 수 있게 해주는 함수이다.

그냥 Date타입은 toISOString() 함수를 이용하면 string으로 변환이 되서 따로 만들었다.

 

원래는 object에서 key, value를 꺼내서 value의 타입이 Date이면 string을 반환하도록 object를 통째로 변환하려고 했지만,

이 당시에는 구현 도중 내가 생각 하기에 문제점이 몇 가지 있었다.

  1. 모든 key를 꺼내서 타입을 비교하면 그만큼 불필요한 작업이 추가 되니, 차라리 그냥 해당 속성만 함수를 써서 바꾸면 성능상 더 빠르지 않을까?
  2. 함수를 만들려고 했으나, 함수의 리턴 타입 문제를 해결하지 못했다. 최종적으로 추론되는 타입이 {} 혹은 any로만 추론이 되어서 해당 함수를 사용했을 때 결과 값의 타입이 {}any로 추론되어서 사용이 불가능했다.

하지만, 문제는 내가 다른 테이블을 Join했을 때 발생했다.

User 테이블과 Article 테이블이 있다고 하자.

나는 User정보를 가져오고 싶은데, User정보에는 유저의 생성일자, 업데이트일자와 유저가 작성한 게시글들의 정보까지 가져오려고 한다.

최종적으로는 이런 형태가 될 것이다.

interface UserInfo {
	...
    createdAt: Date | null,
    updatedAt: Date | null,
    ...
    articles: Article[]
}

interface Article {
	...
    createdAt: Date | null,
    updatedAt: Date | null,
    ...
}

 

그래서 저 UserInfo에서 Date칼럼을 string으로 만들려면 다음과 같이 코드를 작성해야한다.

// 정확한 코드는 아니다. 대략적으로 나타낸 코드이니 복붙은 하지말자.
async getUserInfo() {
    const userInfo = await this.prisma.user.findFirst({});
	
    // userInfo에서 Articles만 뽑는다.
    const { Articles, ...others } = userInfo;
    
    return {
    	...others,
        createdAt: userInfo.createdAt.toISOString(), // Date | null이라서 dateNullToStringNull 함수를 사용하는게 맞지만
        updatedAt: userInfo.createdAt.toISOString(), // 일단 읽기 쉬우라고 toISOString() 함수로 나타냈다.
        articles: Articles.map((el, i) => {
          return {
            ...el,
            createdAt: dateNullToStringNull(el.createdAt),
            updatedAt: dateNullToStringNull(el.updatedAt),
            deletedAt: dateNullToStringNull(el.deletedAt),
          };
    	});
    }

 

이런식으로 되어버린다. 내가 직접 일일이 칼럼을 찾아야 한다는 점과, 객체 안에 객체가 들어갈 경우 (객체 배열이 들어가면 더 끔찍하다) 끔찍한 코드가 되어버린다는 점이 너무 불편했다.

 

그래서 처음에 생각했던대로, object를 통째로 변환하는 방법을 구현하기로 했다.

 


DateKeyToString


 

다음 함수는 kakasoo님이 작성해 주신 함수를 토대로, 내가 살짝 수정을 해서 만들었다. (카카수님 감사합니다!)

export function dateKeyToString<T extends object>(
  target: T,
): DateKeyToString<T> {
  try {
    const res = Object.entries(target).reduce((acc, [key, value]) => {
      if (value === null) {
        return { ...acc, [key]: null };
      }

      if (value instanceof Date) {
        return { ...acc, [key]: value.toISOString() };
      }

      if (typeof value === 'object') {
        return { ...acc, [key]: dateKeyToString(value) };
      }

      return { ...acc, [key]: value };
    }, {}) as DateKeyToString<T>;

    return res;
  } catch (err) {
    throw new Error('dateKeyToString function is wrong');
  }
}

// 타입
type DateToString<T> = T extends Date ? string : T;

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

솔직히, as를 써서 약간 타입을 강제한 느낌이 있어서... 써도 되나 싶긴 하지만, try-catch문을 활용해서 일단은 써먹어보려고 한다. (추후에 100% 터질거같긴하다...)

지금부터 어떻게 이 함수가 나오게 되었는지 설명해 보도록 하겠다!

 

먼저, kakasoo님이 보내주신 함수이다.

function dateToString(obj: Object) {
  return Object.entries(obj).reduce((acc, [key, value]) => {
    if (value instanceof Date) {
      return { ...acc, [key]: value.toString() };
    } else if (typeof value === 'object' && value !== null) {
      return { ...acc, [key]: dateToString(value) };
    } else {
      return { ...acc, [key]: value };
    }
  }, {});
}

여기서 내가 해야할 일은, 타입을 덧붙이는 것과 약간의 함수의 수정이다.

 

먼저, 타입추론을 위해 제네릭을 설정해 주었다.

function dateToString<T extends object>(obj: T) {
  return Object.entries(obj).reduce((acc, [key, value]) => {
    if (value instanceof Date) {
      return { ...acc, [key]: value.toString() };
    } else if (typeof value === 'object' && value !== null) {
      return { ...acc, [key]: dateToString(value) };
    } else {
      return { ...acc, [key]: value };
    }
  }, {});
}

 

그리고 함수를 약간 조정해 주었다.

function dateKeyToString<T extends object>(target: T) {
  const res = Object.entries(target).reduce((acc, [key, value]) => {
  
    if (value instanceof Date) {
      return { ...acc, [key]: value.toString() };
    } 
    
    if (typeof value === 'object') {
      return { ...acc, [key]: dateKeyToString(value) };
    }
    
    return { ...acc, [key]: value };
  
  }, {});
  
  return res;
}

 

그리고 타입이 Date면 string으로 바꾸어주는 타입인 DateKeyToString 타입을 만들었다.

type DateToString<T> = T extends Date ? string : T;

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

객체를 또 타고타고 가서 모든 Date를 string으로 변경해야했고, Date | null과 같은 타입또한 string | null로 변경할 수 있는 타입이어야 했다.

 

그리고 해당 타입을 함수의 리턴타입으로 붙여주었다.

// 에러 발생
function dateKeyToString<T extends object>(target: T): DateKeyToString<T> {
  const res = Object.entries(target).reduce((acc, [key, value]) => {
  
    if (value instanceof Date) {
      return { ...acc, [key]: value.toString() };
    } 
    
    if (typeof value === 'object') {
      return { ...acc, [key]: dateKeyToString(value) };
    }
    
    return { ...acc, [key]: value };
  
  }, {});
  
  return res;
}

하지만, 여기서 에러가 발생했다.

res라는 변수의 타입이 {}로 추론이 되어버린 것이다...

 

그래서 reduce의 타입을 살펴보았다.

    reduce<U>(callbackfn: 
        (previousValue: U, currentValue: T, currentIndex: number, array: T[]) => U
    	, initialValue: U): U;

뒤를 보면 initialValue라는 변수가 U면 reduce의 리턴타입도 U가 되버린다.

따라서 우리의 initialValue값은 {} 이므로, res의 타입은 {}로 추론이 되어버린다.

 

여기서 진짜 한참헤맸다. 다른 방식으로 res를 정의해보고, keyof T도 써보고... reduce함수의 정의도 바꿔보려고 하고... 으.. 다 실패했다.

 

그런데 갑자기 머리를 번뜩이는 생각이 있었다... 바로 as!!

근데 as를 써도 되나 고민이 많았는데... 현재의 내 머리로는 방법이 없었다. 그냥 as를 사용해서 구현했다.

function dateKeyToString<T extends object>(target: T): DateKeyToString<T> {
  const res = Object.entries(target).reduce((acc, [key, value]) => {
  
    if (value instanceof Date) {
      return { ...acc, [key]: value.toString() };
    } 
    
    if (typeof value === 'object') {
      return { ...acc, [key]: dateKeyToString(value) };
    }
    
    return { ...acc, [key]: value };
  
  }, {} as DateKeyToString<T>);
  
  return res;
}

처음엔 이렇게 initialValue부분을 DateKeyToString<T>로 정의했는데, 아무래도 res변수 자체를 DateKeyToString<T>로 정의하는게 나을 것 같아서 이렇게 바꿨다.


function dateKeyToString<T extends object>(target: T): DateKeyToString<T> {
  const res = Object.entries(target).reduce((acc, [key, value]) => {
  
    if (value instanceof Date) {
      return { ...acc, [key]: value.toString() };
    } 
    
    if (typeof value === 'object') {
      return { ...acc, [key]: dateKeyToString(value) };
    }
    
    return { ...acc, [key]: value };
  
  }, {}) as DateKeyToString<T>;
  
  return res;
}

 

그러던중, null값이 들어가면 함수가 터진다는 것을 깨달았다.

그래서 다음과 같이 함수를 또 바꿨다.

function dateKeyToString<T extends object>(target: T): DateKeyToString<T> {
  const res = Object.entries(target).reduce((acc, [key, value]) => {
  	if (value === null) {
    	return { ...acc }
    }
  
    if (value instanceof Date) {
      return { ...acc, [key]: value.toString() };
    } 
    
    if (typeof value === 'object') {
      return { ...acc, [key]: dateKeyToString(value) };
    }
    
    return { ...acc, [key]: value };
  
  }, {}) as DateKeyToString<T>;
  
  return res;
}

 

그런데 여기서, null이라는 값은 해당 값이 비어있음을 나타내기 위해 존재하는데, 해당 속성을 내가 무시하면 안된다는 생각이 들었다.

그래서 함수를 또 고쳤다... ㅋㅋㅋ

function dateKeyToString<T extends object>(target: T): DateKeyToString<T> {
  const res = Object.entries(target).reduce((acc, [key, value]) => {
  	if (value === null) {
    	return { ...acc, [key]: null }
    }
  
    if (value instanceof Date) {
      return { ...acc, [key]: value.toString() };
    } 
    
    if (typeof value === 'object') {
      return { ...acc, [key]: dateKeyToString(value) };
    }
    
    return { ...acc, [key]: value };
  
  }, {}) as DateKeyToString<T>;
  
  return res;
}

 

그리고... 해당 함수는 언제 터질지 모르기에... try-catch문을 써서 덮어주었다.

export function dateKeyToString<T extends object>(
  target: T,
): DateKeyToString<T> {
  try {
    const res = Object.entries(target).reduce((acc, [key, value]) => {
      if (value === null) {
        return { ...acc, [key]: null };
      }

      if (value instanceof Date) {
        return { ...acc, [key]: value.toISOString() };
      }

      if (typeof value === 'object') {
        return { ...acc, [key]: dateKeyToString(value) };
      }

      return { ...acc, [key]: value };
    }, {}) as DateKeyToString<T>;

    return res;
  } catch (err) {
    console.log(err);
    throw new Error('dateKeyToString function is wrong');
  }
}

 

자... 이제 완성이다! 해당 함수를 씹고 뜯고 맛보고 즐길 차례다... 더이상 이런 자잘한 고민 그만했으면 좋겠다...

 

해당 함수를 적용한 나의 코드

import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from 'src/database/prisma/prisma.service';
import { AdminRepositoryOutboundPort } from './outbound-ports/admin-repository.outbound-port';
import { AdminOptionsDto } from '../dtos/admin/admin-options.dto';
import {
  FindAdminInfoForCommonDto,
  FindOneAdminExceptPasswordDto,
} from '../dtos/admin/admin.outbound-port.dto';
import typia from 'typia';
import { TypeToSelect } from 'src/utils/types/type-to-select.type';
import { Admin } from '@prisma/client';
import { AdminSignUpInputDto } from '../dtos/admin/admin.inbound-port.dto';
import { DateKeyToString } from 'src/utils/types/date-to-string.type';
import { dateKeyToString } from 'src/utils/functions/date-key-to-string.function';

@Injectable()
export class AdminRepository implements AdminRepositoryOutboundPort {
  constructor(private readonly prisma: PrismaService) {}

  async insertAdmin(
    adminInfo: AdminSignUpInputDto,
  ): Promise<FindOneAdminExceptPasswordDto | null> {
    const admin = await this.prisma.admin.create({
      data: adminInfo,
      select: typia.random<TypeToSelect<FindOneAdminExceptPasswordDto>>(),
    });

    return dateKeyToString(admin);
  }

  async findOneAdminForSign(
    email: string,
  ): Promise<DateKeyToString<Admin> | null> {
    const admin = await this.prisma.admin.findFirst({
      where: { email },
    });

    if (!admin) {
      throw new BadRequestException('email is wrong');
    }

    return dateKeyToString(admin);
  }

  async findOneAdminByOptions(
    options: AdminOptionsDto,
  ): Promise<FindOneAdminExceptPasswordDto | null> {
    const admin = await this.prisma.admin.findFirst({
      select: typia.random<TypeToSelect<FindOneAdminExceptPasswordDto>>(),
      where: options,
    });

    if (!admin) {
      throw new BadRequestException('Incorrect options');
    }

    return dateKeyToString(admin);
  }

  async findOneAdminForCommon(
    options: AdminOptionsDto,
  ): Promise<FindAdminInfoForCommonDto | null> {
    const admin = await this.prisma.admin.findFirst({
      select: typia.random<TypeToSelect<FindAdminInfoForCommonDto>>(),
      where: options,
    });

    if (!admin) {
      throw new BadRequestException('Incorrect options');
    }

    return dateKeyToString(admin);
  }
}

전보다 무척 깔끔해졌다...!

 

 

개발을 하면 이런 자잘한 고민들이 진짜 수십, 수백번씩 든다... 이런 고민들을 해결하면 나 자신이 성장하는 느낌이 들긴하지만... 시간을 너무 잡아먹는다. 나도 얼른 나만의 방식을 정립해야할텐데.