본문 바로가기

javascript/이외

[javascript] Ajv 라이브러리

https://ajv.js.org/

 

Ajv JSON schema validator

The fastest JSON schema Validator. Supports JSON Schema draft-04/06/07/2019-09/2020-12 and JSON Type Definition (RFC8927)

ajv.js.org

 Ajv는 자바스크립트 진영에서 많이 사용되는 validator 라이브러리 중 하나로, JSON Schema 또는 JSON Type Definition (JTD)을 이용하여 객체의 타입을 표현한다. 두 표현 방식 모두 표준으로 등록되어 있기 때문에, 해당 표준을 이용한 표기법만 알고 있다면 Ajv를 사용하기는 매우 쉽다.

 사실 자바스크립트 진영에는 class-validator, yup, zod 등 정말 많은 validator 라이브러리가 존재하며, 사용성 자체는 개인적으로 이러한 라이브러리들이 좀 더 좋다고 생각한다. 그럼에도 Ajv의 독보적인 장점이 존재하는데, 바로 성능이다.

https://github.com/icebob/validator-benchmark

 

GitHub - icebob/validator-benchmark: JS validators benchmark

JS validators benchmark. Contribute to icebob/validator-benchmark development by creating an account on GitHub.

github.com

 위 레포지토리는 주기적으로 각 validator 라이브러리들에 대한 벤치마크 결과를 업데이트 해준다. 최근 결과에 따르면 ajv의 성능 순위는 2위로, 요즘 사용자가 늘고 있는 zod와 비교하면 7배 정도의 성능을 보인다.

 요컨대, 성능이 필요한 부분에 대해서는 Ajv를 선택하는 것이 큰 장점이 될 수 있다.


사용법

 나는 JSON Schema나 JTD에 대해 아는게 거의 없다. 그렇지만 Ajv에서 제공하는 타입 및 타입스크립트 인터페이스를 이용하면 정말 쉽게 검증 대상에 대한 스키마를 작성할 수 있다.

 Ajv는 2가지 스키마에 대한 타입을 제공한다.

  • 'ajv': JSONSchemaType
  • 'ajv/dist/jtd': JTDSchemaType

 각각 이름만 봐도 어떤 표현 방식을 지원할지 딱 감이 온다. 두 타입은 제네릭 인자로 우리가 구성할 스키마에 대한 타입 정의를 제네릭 인자로 받는다. 복잡해보이는 스키마 구조와는 달리 타입 추론이 상당히 잘 되는 편이라 ctrl + space 만 계속 눌러도 타입/null 여부 등 간단한 규칙은 거의 자동으로 처리해주는 수준이다.

자동완성 기반으로 스키마를 작성하는 모습

 이것도 귀찮다면, 그냥 인터넷에 typescript to JSON Schema 같은 검색어를 통해 내가 만든 타입을 변환해서 복붙해도 크게 상관 없다. 언급했듯이 JSON Schema 및 JTD 자체가 표준으로 등록되어 있기 때문에 가능한 일이다. 유용한 사이트 링크 하나를 남긴다. JSON Schema -> Typescript나 반대 방향을 지원해서 내가 잘 작성했는지 검사하기 편하다.

https://transform.tools/json-schema-to-typescript

JSON Schema 기반으로 스키마를 작성하는 경우, 'ajv'에서 필요한 것들을 가져오면 된다. Ajv는 validation 함수를 컴파일하여 사용하는 구조를 가지고 있다. ajv.compile( ) 함수는 검증할 스키마 정보를 기반으로 validation 함수를 생성한다.

import Ajv, { JSONSchemaType } from 'ajv';
const ajv = new Ajv({ removeAdditional: 'all' });
const schema: JSONSchemaType<SchemaType> = {
  //... 많은 설정들
  additionalProperties: false,
};
export const validateNewsCommentsObj = ajv.compile(schema);

위 코드에서 removeAdditional: 'all', additionalProperties: false로 지정된 부분을 볼 수 있다. 검증할 JSON 상에 필요 없는 필드가 포함되어 올 수 있는데, additionalProperties를 false로 지정하지 않으면 이러한 필드를 그냥 무시한다. 즉, 검증을 마친 객체에 원하지 않는 프로퍼티가 존재할 수 있는 것이다.

 예를 들어 유저 엔티티에 isAdmin: boolean 이 포함되어 있다고 가정해보자. UpdateUserDto에는 유저 id, 이름. 비밀번호만 수정할 수 있도록 구성되어 있으나, 악의적인 사용자가 isAdmin 프로퍼티를 추가하는 경우 검증 대상이 아니므로 따로 체크하지 않는다. 따라서 isAdmin을 true로 설정한 내용을 그대로 DB에 반영하는 불상사가 발생할 수 있다.

const ajv = new Ajv();
type User = {
  id: string;
  name: string;
  password: string;
  isAdmin: boolean;
}
type UpdateUserDto = Omit<User,'isAdmin'>;

const userSchema: JSONSchemaType<UpdateUserDto> = {
  type:'object',
  required:['id','name','password'],
  properties: {
    id: {type: 'string'},
    name: {type: 'string'},
    password: {type: 'string'}
  }
};

const validate = ajv.compile(userSchema);

const user = {
  id: 'hello',
  name: 'world',
  password: 'test',
  isAdmin: true
};
console.log('is validate?', validate(user));
console.log(user);

isAdmin이 그대로 노출되는 모습

 만약 서버 측에서 Object.assign이나 spread 문법을 사용하여 기존 유저 정보를 덮어쓰는 방식으로 로직을 구현했다면? 악의적인 사용자는 admin이 될 수 있다.

 위와 같은 문제를 방지하기 위해서는 정의되지 않은 프로퍼티가 포함되어 있으면 검증이 실패해야 한다. 해당 동작을 원하는 경우 additionalProperties: false로 지정하면 스키마에 없는 프로퍼티가 객체에 포함될 때 검증에 실패한다.

검증에 실패한 모습

 위 상황에 검증이 실패하는 것도 좋은 방법이지만, 그냥 추가 프로퍼티를 단순히 제거하는 로직을 원하는 경우 removeAdditional 옵션을 이용할 수 있다. removeAdditional: 'all'로 지정되면 추가 프로퍼티를 무조건 제거한다. 다른 옵션들도 존재하는데, 알고 싶다면 공식문서를 참고하자.

검증에 성공한 모습

 removeAdditional 옵션에 의해 검증이 완료된 값은 우리가 구성한 스키마를 따르게 된다. 좋은 방법인지는 모르겠지만, 이 옵션을 거친 객체가 지정한 타입을 가지고 있다는 가정 하에 사용할 수 있게 된다. 다만 removeAdditional 옵션은 JTDSchemaType에서는 동작하지 않는 것 같다. 테스트해보니 'ajv/dist/jtd' 경로에서 가져오는 Ajv 클래스의 경우 이 옵션을 활성화하더라도 파서가 동작하지 않았다. 내가 작성한 코드를 제시한다.

import Ajv, {JTDSchemaType} from 'ajv/dist/jtd';
import { type SqsDataType } from './types.js';

const ajv = new Ajv({removeAdditional: "all"});
const sqsDataSchema: JTDSchemaType<SqsDataType> = {
  properties: {
    keywords: {elements: {type: "string"}},
    news_sources: {elements: {type: "string"}}
  },
  additionalProperties: false
}
export const validateSqsData = ajv.compile(sqsDataSchema);
export const getSqsDataFromBodyStr = ajv.compileParser<SqsDataType>(sqsDataSchema);

위 코드의 removeAdditional은 동작하지 않았다. keywords, news_sources 이외의 다른 프로퍼티를 json 문자열에 추가하면 파서는 객체 대신 undefined을 반환한다. 내가 잘못 알고 있다면 원하는 동작을 만들 수 있을테니 좋을 것 같다.

스키마 작성 방법은 타입과 스키마를 대응해보는 방식으로 알아보자.

// 타입
export interface NewsCommentObject {
  result: {
    commentList: {
      contents: string;
      sympathyCount: number;
      antipathyCount: number;
      modTime: string; // 날짜지만 문자열
    }[],
    morePage?: {
      next: string;
    },
    pageModel: {
      lastPage: number;
    }
  }
}
// 스키마
const NewsCommentObjectSchema: JSONSchemaType<NewsCommentObject> = {
  type: 'object',
  required: ['result'],
  properties: {
    result: {
      type: 'object',
      required: ['commentList', 'pageModel'],
      properties: {
        commentList: {
          type: 'array',
          items: {
            type: 'object',
            required: ['contents', 'sympathyCount', 'antipathyCount', 'modTime'],
            properties: {
              contents: { type: 'string' },
              sympathyCount: { type: 'number' },
              antipathyCount: { type: 'number' },
              modTime: { type: 'string' },
            }
          }
        },
        morePage: {
          type: 'object',
          nullable: true,
          required: ['next'],
          properties: {
            next: { type: 'string' }
          }
        },
        pageModel: {
          type: 'object',
          required: ['lastPage'],
          properties: {
            lastPage: { type: 'number' }
          }
        }
      }
    }
  },
  additionalProperties: false,
};

하나의 객체에 대해 작성해야 하는 내용은 대략 3가지 존재한다.

  • type: 해당 객체 / 프로퍼티의 속성을 의미한다.
  • required: 객체 내 프로퍼티의 optional 속성을 의미한다. 여기에 없는 프로퍼티는 nullable로 취급된다.
  • properties: 해당 객체 내에 있는 프로퍼티들을 명시한다.

위 제시한 타입은 네이버 댓글 API 중 일부 필요한 내용을 추출한 것이다. result의 morePage는 다음 페이지 정보를 의미하는데, 댓글이 0개인 경우 페이징 자체가 성립하지 않는지 해당 프로퍼티가 존재하지 않는다. 위 타입을 묘사해보자.

  • result의 타입은 object이며, commentList, pageModel을 필수로 가진다.
    • commentList의 타입은 array이며, 배열 items로 object를 가진다.
      • 배열 item의 경우 4개 속성을 필수로 가진다.
    • morePage는 object이며, string인 next를 필수로 가진다.
    • pageModel은 object이며, number인 lastPage를 필수로 가진다.

 위와 같은 정보를 타입 추론에 기반하여 곧대로 스키마의 type, required 및 properties에 박아 넣으면 스키마가 완성된다. 앞에서 gif로 보여준 것처럼 한번 사용해보면 쉽게 익힐 수 있을 것이다.

 JTD 방식은 표현법이 좀 다르다. 내가 대략 아는 점만 나열한다.

  • 배열을 표현할 때 items 대신 elements 키워드를 이용한다.
  • 객체는 타입을 명시하지 않는다.
  • required에 프로퍼티를 나열하는 대신 properties / optionalProperties에 분리해서 표현한다.
  • 데이터 타입이 JSON Schema에 비해 세분화 된 느낌이다.

개인적인 생각이지만 JTD 방식이 좀 더 깔끔하게 표현되는 것 같다.

const schema: JTDSchemaType<NewsCommentObject> = {
  properties: {
    result: {
      optionalProperties: {
        morePage: {
          properties: {
            next: {type: 'string'}
          }
        }
      },
      properties: {
        commentList:{
          elements:{
            properties:{
              antipathyCount:{type:'int32'},
              contents:{type:'string'},
              modTime:{type:'string'},
              sympathyCount:{type:'int32'},
            }
          }
        },
        pageModel: {
          properties:{
            lastPage: {type: 'int32'}
          }
        }
      }
    }
  },
  additionalProperties: false
}

JTD 표현법 역시 vscode 기준 ctrl + space만 사용해도 간단한 규칙은 모두 처리할 수 있다.


Ajv 자체는 예전부터 알고 있었지만, 스키마가 너무 복잡해 보여서 사용하기 싫었다. 그런데 실제로 사용해보니 타입스크립트 지원이 생각 이상으로 좋아서 꽤 괜찮은 것 같다. 성능이 중요한 영역에서는 충분히 사용해볼 만 하다.