우당탕탕 우리네 개발생활

[Nestjs] ! 과 ? 그리고 class-validator에 대한 고찰 본문

tech

[Nestjs] ! 과 ? 그리고 class-validator에 대한 고찰

미스터카멜레온 2024. 2. 23. 08:43

저는 Nestjs + typescript 기술 조합으로 백엔드 개발을 하고 있습니다.

 

최근 개발을 하면서 두 가지 궁금증이 생겼습니다.

- class-validator를 사용하여 decorating한 클래스는 어느 상황에서든 validation을 하는가?

- 클래스 멤버 뒤에 붙는 ! 과 ? 는 어떤 역할을 하는가?

 

위 궁금증들을 해결하면서 정리한 생각을 기록할 겸 공유하려고 합니다.

class-validator를 사용하여 decorating한 클래스는 어느 상황에서든 validation을 하는가?

제가 있는 백엔드 팀은 서비스코드의 리턴 값과 타입이 일치하는 mapper 클래스 파일을 만들고 이 mappper 인스턴스에 값을 담은 후 리턴을 하는 규칙이 있습니다.

mapper클래스 내부에는 class-validator 라이브러리를 사용하여 모든 멤버들에 데코레이팅을 합니다. 이에 따라 자연스러운 궁금증이 생겼습니다.

 

http request가 interceptor에 도달할 때 validator가 제대로 동작하는 건 확인했었지만, response에서도 그런 동작이 가능한건가?

 

하지만 몇번의 테스트 결과 제대로 동작하지 않음을 확인할 수 있었습니다. 특히, @IsString() 데코레이터만 사용하고 @IsOptional()을 붙이지 않은 멤버에 null이 매핑되어도 문제가 생기지 않는 상황을 확인하고는 혼란스러워졌습니다. 예시는 아래와 같습니다.

// test.mapper.ts

export class TestMapper {
  @IsInt()
  id!: number;
  
  @IsString()
  name!: string | null;

  @IsDate()
  createdAt!: Date | null;
  
  ...
}

 

우선 class-validator의 오픈소스를 확인해봤습니다.

import { ValidationOptions } from '../ValidationOptions';
import { buildMessage, ValidateBy } from '../common/ValidateBy';

export const IS_STRING = 'isString';

/**
 * Checks if a given value is a real string.
 */
export function isString(value: unknown): value is string {
  return value instanceof String || typeof value === 'string';
}

/**
 * Checks if a given value is a real string.
 */
export function IsString(validationOptions?: ValidationOptions): PropertyDecorator {
  return ValidateBy(
    {
      name: IS_STRING,
      validator: {
        validate: (value, args): boolean => isString(value),
        defaultMessage: buildMessage(eachPrefix => eachPrefix + '$property must be a string', validationOptions),
      },
    },
    validationOptions
  );
}

위와 같이 엄격하게 인스턴스 체크와 타입 체크를 둘 다 하고 있었고, 저 코드상이라면 null은 허용이 되어서는 안됩니다.

 

오픈소스를 확인한 이후 nestjs 프레임워크의 http request interceptor에만 class-validator와 엮여있는 부분이 있을 것이라 추측하게 되었습니다. 아래 코드와 같이 useGlobalPipes를 통해 ValidationPipe의 인스턴스를 사용하고 있는데 이 부분을 깊이 공부해봐야될 것 같습니다.

// main.ts

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule, {
    cors: true,
  });
  
  ...
  
  app.useGlobalPipes(new ValidationPipe({ transform: true }));
  ...
}

 

nestjs response validation interceptor라는 키워드로 서칭을 진행해보니 공식적인 문서(nestjs > validation)에서는 해당 내용을 찾아볼 수 없었고, 다른 개발자분들의 커스텀 interceptor들을 확인할 수 있었습니다.

좋은 사용방법이 있어 공유드립니다.

https://medium.com/@kuba.2001/reponse-validation-in-nestjs-0db70b955a6a

 

Response validation in NestJS

Response validation in NestJS is a crucial aspect of building robust and secure web applications. It ensures that the data being sent to…

medium.com

 

클래스 멤버 뒤에 붙는 !과 ?는 어떤 역할을 하는가?

위 사용했던 mapper의 예시를 다시 사용합니다.

// test.mapper.ts

export class TestMapper {
  @IsInt()
  id!: number;
  
  @IsString()
  name!: string | null;

  @IsDate()
  createdAt!: Date | null;
  
  @IsString()
  grade?: string;
  
  ...
}

 

자세히 보면 멤버들에 ! 와 ? 를 사용했음을 볼 수 있습니다.

 

저는 우선 아래와 같은 사용 예시를 통해 ! 를 non-null assertion operator라는 용어로 알고 있었습니다.

const group: Group | null = await prisma.group.findFirst({
	where: {
    	groupId: 123,
    },
    select: {
    	member: true,
    },
});
// group이 null이 아니라고 type checker에게 장담하는 효과입니다.
// 타입에러만 방지할 뿐 런타임에러 발생 시 책임은 해당 코드를 작성한 개발자에게 있습니다.
const member = group!.member;

 

공식사이트엔 non-null assertion operator를 아래와 같이 정의하고 있습니다.

TypeScript also has a special syntax for removing null and undefined from a type without doing any explicit checking. Writing ! after any expression is effectively a type assertion that the value isn’t null or undefined.
Just like other type assertions, this doesn’t change the runtime behavior of your code, so it’s important to only use ! when you know that the value can’t be null or undefined.

 

즉, Type checker가 ! 를 post-fix에 달고 있는 멤버에 대해 null 또는 undefined가 되지 않을 것이라고 믿어주겠다는 것입니다.

주의해야할 점은 런타임 전에 엄격한 타입 에러를 넘어가준다는 얘기지 런타임 상황에서 null값이나 undefined가 해당 멤버와 매핑이 되게 되었을 때 발생할 수 있는 런타임에러는 책임을 지지 않습니다.

 

그렇다면 첫번째 예시(클래스 멤버)에서의 ! 와 두번째 예시(non-null assertion)에서의 ! 는 같은 것일까? 

 

우선 궁금증을 남긴 채 ?(optional property)에 대해 추가적으로 알아보겠습니다. ? 는 ! 보다 개인적으로 사용해본 경험이 많았기 때문에 익숙했습니다. 역시 공식사이트를 확인했고 아래와 같이 정의되어 있습니다.

Object types can also specify that some or all of their properties are optional. To do this, add a ? after the property name. In JavaScript, if you access a property that doesn’t exist, you’ll get the value undefined rather than a runtime error. Because of this, when you read from an optional property, you’ll have to check for undefined before using it.

 

자바스크립트에서는 optional로 만들고 싶은 프로퍼티 뒤에 ? 를 붙이면 되고 그 프로퍼티가 실제로 존재하지 않는 상태에서 접근하려고 하면 런타임에러 대신에 undefined를 얻게될 것이라고 하고 있습니다. 추가적으로 타입스크립트에 초점을 맞추면 특정 property가 타입이 불명확한 상황(specific type | null)에서 ? 를 붙이지 않는다면 타입 에러가 발생하게 됩니다.

아래 예시를 보면 쉽게 이해할 수 있습니다.

function test(b: { a: string } | null) {
    console.log(b?.a); // ok
    console.log(b.a);  // 'b' is possibly null 
}

 

그래서 위 예시에서의 ? 는 클래스 멤버에서 사용된 ? 와 같은 역할을 하는가?

 

! 와 ? 는 저에게 낯설진 않았기에 클래스에서 멤버의 post-fix로 사용되고 있는 것들도 위 정의와 같은 역할을 하겠거니 생각했습니다. 하지만 기대했던 것과 다르게 동작하는 것을 보면서 혼란스러워졌습니다.

 

! 가 붙은 멤버에 어떻게 undefined값이나 null이 대입될 수 있지?

! 를 뒤에 붙인 멤버에 null 타이핑을 하는 게 말이 되는건가?

 

실제로 null이 대입되는 모습과 null 타이핑을 해도 타입 에러조차 발생하지 않는 모습이 낯설었습니다. 그래서 공부하기 시작했습니다.

 

! 는 definite assignment assertion operator라고 한다고 합니다.

이는 클래스의 initialize와 관계가 있는 데, 타입스크립트에선 클래스를 정의할 때 생성자를 통해 값을 주입하는 코드를 작성하지 않으면 멤버선언부분에서 타입 에러가 발생합니다. 생성자를 작성하지 않으면서 타입에러를 방지하는 방법으로 위 definite assignment assertion operator를 사용합니다. 각 멤버 뒤에 ! 를 붙이게 되면 컴파일러에게 이 멤버가 사용되기 전까지는 initialize가 됐는지 여부는 확인할 필요가 없다고 얘기하는 역할을 한다고 합니다. 하지만 선언된 멤버들은 클래스를 인스턴스로 만들 때 반드시 값이 할당이 되어야합니다. 아래 예시는 이해를 돕기 위해 간단히 작성했습니다.

class Test {
    constructor(id: number) {
        this.id = id;
    }
    id: number; // ok
    name!: string; // ok
    grade: string; // Property 'grade' has no initializer and is not definitely assigned in the constructor.(2564)
}

 

그렇기 때문에 앞서 non-null assertion과는 역할 자체가 다름을 알 수 있습니다. 마치 null과 undefined를 사용하면 안될 것 같다는 생각이 들지만 심지어 null과 undefined도 직접적인 관련이 없습니다. 그저 컴파일러에게 해당 멤버가 사용되기 전에 initialize가 되었는지에 대한 여부를 확인할 필요가 없다고 알리는 역할을 합니다.

아래 예시를 보면 쉽게 상황을 파악할 수 있습니다. ! 를 post-fix로 달고 있는 멤버들에 undefined, null 값을 대입해봤습니다. 이로 인해 undefined와 null 값이 각 멤버들의 값이 되었습니다. 

class Test {
    id!: undefined | null;
    name!: string | null;
    grade!: string | null;

    static of({
        id,
        name,
        grade,
    }: {
        id: undefined | null,
        name: string | null,
        grade: string | null,
    }): Test {
        return {
            id,
            name,
            grade,
        }
    }
}

const test = {
    id: undefined,
    name: null,
    grade: null,
}
Test.of(test);

아주 간단히 정리하자면 클래스에서 ! (definite assignment assertion operator)의 사용은 initialize를 선택적으로 피하기 위함의 목적성을 가진다고 볼 수 있겠습니다.

 

? 는 optional class property 라고 합니다.

위에서 definite assignment assertion operator 를 자세히 설명해놨기 때문에 비교설명을 하기 수월해졌습니다. 각 멤버 뒤에 ? 를 붙이게 되면 컴파일러에게 이 멤버가 사용되기 전까지는 initialize가 됐는지 여부는 확인할 필요가 없다고 얘기하는 역할을 똑같이 하지만 다른 점은 ? 를 달고있는 선언된 멤버들은 클래스를 인스턴스로 만들 때 값의 할당이 선택적일 수 있다는 것입니다. 

아래 예시를 통해 이해를 쉽게 하실 수 있습니다.

class Test {
    id?: undefined | null;
    name!: string | null;
    grade!: string | null;

    static of({
        id,
        name,
        grade,
    }: {
        id: undefined | null,
        name: string | null,
        grade: string | null,
    }): Test {
        return {
            // 이 부분에 id를 명시하지 않았으나 에러가 발생하지 않음
            name,
            grade,
        }
    }
}

const test = {
    id: undefined,
    name: null,
    grade: null,
}
Test.of(test);

 

두 가지 궁금증을 정리하고 기록하면서 개념이 더 명확해졌습니다. 같은 문제를 고민하는 분들께 조금이나마 도움이 되었으면 좋겠습니다.

 

감사합니다.

 

참고자료

https://medium.com/@kuba.2001/reponse-validation-in-nestjs-0db70b955a6a

https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#non-null-assertion-operator-postfix-

https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-0.html#optional-class-properties

https://stackoverflow.com/questions/47942141/optional-property-class-in-typescript

https://stackoverflow.com/questions/67302118/non-null-assertion-in-class-property