fp-ts 로 데이터 검증하기 2 - 데이터의 적합성 확인

fp-ts 라이브러리로 데이터 검증하는 방법을 알아보자.

지난 글에서

지난 글에서는 다음과 같은 코드를 작성했다.

import * as O from "fp-ts/Option";

interface UserInput {
  username: string;
  password: string;
}

const has = (key: PropertyKey) => O.flatMap(O.fromPredicate((obj: any) => key in obj));
const isUserInput = (body: any): O.Option<UserInput> =>
  pipe(body, O.of, has("username"), has("password"));

isUserInput 함수는 bodyUserInput 타입인지 확인하는 함수이다.
즉, username, password 가 있는지만 확인할 수 있다.
이번 글에서는 해당 속성값들이 유효한 값인지 확인하는 방법을 알아보자.

데이터의 적합성 확인

인터페이스 추가

들어가기 전에 먼저 좀더 유용하게 코드를 작성할 수 있도록 일부 인터페이스를 정의해놓자.

interface Body extends Record<string, string> {}
interface Username extends Body {
  username: string;
}
interface Password extends Body {
  password: string;
}
interface UserInput extends Username, Password {}

Body 는 문자열로 된 키와 값을 가지고 있는 객체이다.
(Record 타입은 객체의 키와 값의 타입을 지정하기 위한 타입으로, 그냥 객체라고 생각해주면 된다.)
Usernameusername 속성을, Passwordpassword 속성을 가지고 있는 Body 이다.
마지막으로 UserInput 은 둘을 합쳐 username, password 속성을 모두 가지고 있다.

fp-ts 를 사용하지 않는다면

먼저 username 값만 확인하는 함수를 작성해보자.
다음과 같은 조건을 상정해보자.

  • 최소 길이 6
  • 최대 길이 20
  • 알파벳과 숫자로만 이루어짐

최대한 단순무식하게 코드를 작성하면 다음 같은 코드로도 충분히 검사는 할 수 있다.

const validateUsernameWithoutFunction = ({username}: ) =>
  username.length >= 6 
  && username.length <= 20
  && /^[a-zA-Z0-9]+$/.test(username);

그럼 이제 각각의 조건들을 함수로 만들어보자.

const minLength = (n: number) => (s: string) => s.length >= n;
const maxLength = (n: number) => (s: string) => s.length <= n;
const includes = (r: RegExp) => (s: string) => s.test(str);
const isAlphaNumeric = includes(/^[a-zA-Z0-9]+$/);

이 함수들로 username 의 유효성을 검사하는 함수를 만들어보자.

const validateUsernameWithoutFpTs = ({username}: Username) =>
  minLength(6)(username) && maxLength(20)(username) && isAlphaNumeric(username);

fp-ts 적용

이제 fp-ts 를 적용해보자.
먼저 Option부터 적용을 해보자.
Record.lookup 함수를 사용해보자.
해당 함수는 키가 존재하면 Option.some 을, 존재하지 않으면 Option.none 을 반환한다.
그리고 지난 글에서 설명했듯이 Option.filter 함수는 boolean 을 반환하는 함수(Predicate)를 인자로 받아 해당 함수의 결과값에 따라 Some 또는 None 을 반환한다.
이 함수들을 이용하면 다음과 같이 작성할 수 있다.

const validateUsernameWithOption = (body: Username) => {
  let username = R.lookup("username")(body);
  username = O.filter(minLength(6))(username);
  username = O.filter(maxLength(20))(username);
  username = O.filter(isAlphaNumeric)(username);
  return O.isSome(username);
}

이제 pipe 함수를 적용해보자.

const validateUsernameWithPipe = (username: Username) =>
  pipe(
    username,
    R.lookup("username"),
    O.filter(minLength(6)),
    O.filter(maxLength(20)),
    O.filter(isAlphaNumeric)
  );

반복을 줄이자

보다시피 O.filter 를 여러번 사용하고 있다.
반복을 줄일 수 있는 함수를 만들어보자.
먼저 Predicate<T> 배열을 받는다.
그리고 해당 함수들을 O.filter 에 넣는다.
그리고 Option<T> 값을 각 함수들에 넣는다.
그리고 그 모든 결과 값을 취합하여 모두 Some 인지 확인한다.

const validateWithoutPipe = <T>(preds: Predicate<T>[]) => (o: O.Option<T>) => {
  const filters = preds.map(O.filter);
  const filtered = filters.map((filter) => filter(o));
  const concated = filtered.reduce((acc, curr) =>(O.isSome(acc) && O.isSome(curr) ? acc : O.none));
  return concated;
}

Array.map

물론 이 함수로도 충분하지만, 좀더 가독성이 좋게 만들어보자.
Array.prototype.map 함수는 fp-ts/Array.map 함수로 대체할 수 있다.

import * as A from "fp-ts/Array";

const validateWithMap =
  <T>(preds: Predicate<T>[]) =>
  (o: O.Option<T>) => {
    const filtered = A.map((filter: (fa: O.Option<T>) => O.Option<T>) =>
      filter(o)
    )(A.map(O.filter<T>)(preds));
    const concated = filtered.reduce((acc, curr) =>
      O.isSome(acc) && O.isSome(curr) ? acc : O.none
    );
  };

function.apply

(f) => f(a)fp-ts/function.apply 로 대체할 수 있다.

import * as apply from "fp-ts/function";

const validateWithApply =
  <T>(preds: Predicate<T>[]) =>
  (o: O.Option<T>) => {
    const filtered = A.map(F.apply(o))(A.map(O.filter<T>)(preds));
    const concated = filtered.reduce((acc, curr) =>
      O.isSome(acc) && O.isSome(curr) ? acc : O.none
    );
  };

Monoid.concatAll

그리고 reducefp-ts/Array.reduce 가 존재한다.
하지만 MonoidconcatAll 이용해서 더 간단하게 작성할 수 있다.
먼저 다음과 같은 함수를 만들어보자.
Option 이 모두 Some 이면 첫번째 Option 을 반환하고, 아니면 None 을 반환한다.

const concatOptionSome = <T>(x: O.Option<T>, y: O.Option<T>) =>
  O.isSome(x) && O.isSome(y) ? x : O.none;

이제 Monoid.concatAll 을 통해 Option 배열을 하나의 Option 으로 만드는 함수를 만들자.
Monoid 를 따로 변수에 할당하지 않은 이유는 함수가 아니면 제네릭 타입을 쓸 수 없었기 때문이다.

const concatOptions = <T>(o: O.Option<T>) =>
  Mono.concatAll<O.Option<T>>({
    concat: (x, y) => (O.isSome(x) && O.isSome(y) ? x : O.none),
    empty: o,
  });

그럼 다음과 같은 함수를 만들 수 있다.

const validateFilterWithoutPipe =
  <T>(preds: Predicate<T>[]) =>
  (o: O.Option<T>) =>
    concatOptions(o)(A.map(O.filter<T>)(preds).map(F.apply(o)));

마지막으로 pipe 를 통해 좀더 읽기 쉽게 만들자.

const validate =
  <T>(preds: Predicate<T>[]) =>
  (o: O.Option<T>) =>
    pipe(preds, A.map(O.filter<T>), A.map(F.apply(o)), concatOptions(o));

이를 통해 최종적으로 다음과 같은 함수를 만들 수 있다.

const validateUsername = validate<string>([
  minLength(6),
  maxLength(20),
  isAlphaNumeric,
]);

따로 빼서 검사하기

이제 validateUsername 함수를 이용해 username 을 검사해보자.

const validateUsernameButReturnString = (username: Username) =>
  pipe(
    username,
    R.lookup("username"),
    validateUsername
  );

그런데 보다시피, R.lookup("username")validateUsername 를 지나면 Option<string> 이 된다.
하지만 그렇게 되면 password 를 검사할 수 없다.
이를 위해 username 을 따로 검사하는 함수를 만들자.
body 에서 username 을 추출해 validateUsername 함수에 넣어 Some 이 나오는지 확인한다.

const validateUsernameFromUserInput = (body: Username): boolean =>
  pipe(body, R.lookup("username"), validateUsername, O.isSome);

이제 validateUsernameFromUserInput 함수를 이용해 username 만 검사해보자.

const validateUsernameOnly = (body: Body) =>
  pipe(
    body,
    O.of,
    has("username"),
    O.filter(validateUsernameFromUserInput),
    has("password"),
  );

password 검사

이제 password 를 검사해보자. 조건은 다음과 같다.

  • 최소 길이 8
  • 최대 길이 20
  • 알파벳과 숫자를 각각 하나 이상 포함

먼저 이 조건들을 함수로 만들어보자.
첫 두 개는 위에서 정의한 minLength, maxLength 함수를 이용하면 된다.
마지막 조건도 위에서 정의한 includes 함수를 이용하면 된다.

const hasAlphaAndNumeric = includes(/^(?=.*?\d)(?=.*?[a-zA-Z]).+$/);

이제 이 함수들을 이용해 password 를 검사하는 함수를 만들어보자.

const validatePassword = validate<string>([
  minLength(8),
  maxLength(20),
  hasAlphaAndNumeric,
]);

username 을 검사했던 것처럼 bodypassword 를 추출하여 따로 검사하는 함수를 만들자.

const validatePasswordFromUserInput = (body: Password): boolean =>
  pipe(body, R.lookup("password"), validatePassword, O.isSome);

마찬가지로 O.filter 를 이용해 UserInput 을 검사하는 과정에 추가하자.

const validateUsernameAndPassword = (body: Body) =>
  pipe(
    body,
    O.of,
    has("username"),
    O.filter(validateUsernameFromUserInput),
    has("password"),
    O.filter(validatePasswordFromUserInput),
  );

그리고 R.lookup 에서 요소 검사가 되기 때문에 has 를 사용하지 않아도 된다.

const validateUsernameAndPassword = (body: Body) =>
  pipe(
    body,
    O.of,
    O.filter(validateUsernameFromUserInput),
    O.filter(validatePasswordFromUserInput),
  );

그럼 또 Option.filter(validate***FromUserInput) 을 반복하게 된다.
공통점을 찾아 함수를 만들어 보자.

const validateFromUserInputWithPipe =
  (key: keyof Body & string) =>
  (validate: (o: O.Option<string>) => O.Option<string>) =>
  (body: Body): boolean =>
    pipe(body, R.lookup(key), validate, O.isSome, O.filter)

그리고 공통적으로 사용된 filter 도 넣어주자.

const validateFromUserInputFilter =
  <A extends Body, B extends A>(key: keyof B & string) =>
  (validate: (o: O.Option<string>) => O.Option<string>) =>
    O.filter<A, B>((body: A): body is B =>
      pipe(body, R.lookup(key), validate, O.isSome)
    );

그런데 filter 안에 화살표 함수까지 넣는 것은 좀 지저분하다.
pipe 대신 flow 를 사용해보자.

const validateFromUserInput =
  <A extends Body, B extends A>(key: keyof B & string) =>
  (validate: (o: O.Option<string>) => O.Option<string>) =>
    O.filter<A, B>(
      flow<[A], O.Option<string>, O.Option<string>, boolean>(
        R.lookup(key),
        validate,
        O.isSome
      ) as Refinement<A, B>
    );

이제 다음과 같은 함수를 만들 수 있다.

const validateBodyIsUsername = validateFromUserInput<Body, Username>(
  "username",
  validateUsername
);
const validateUsernameIsUserInput = validateFromUserInput<Username, UserInput>(
  "password",
  validatePassword
);

이 함수들을 이용해 UserInput 을 검사하는 함수를 만들면 다음과 같다.


const validateUserInput = (body: Body) =>
  pipe(body, O.of, validateBodyIsUsername, validateUsernameIsUserInput);

결과

최종적으로 다음과 같은 코드를 작성했다.

import * as R from "fp-ts/Record";
import * as O from "fp-ts/Option";
import * as A from "fp-ts/Array";
import * as Mono from "fp-ts/Monoid";
import { Refinement } from "fp-ts/Refinement";
import { Predicate } from "fp-ts/Predicate";
import { pipe, apply, flow, flip } from "fp-ts/function";

interface Body extends Record<string, string> {}
interface Username extends Body {
  username: string;
}
interface Password extends Body {
  password: string;
}
interface UserInput extends Username, Password {}

const minLength = (n: number) => (s: string) => s.length >= n;
const maxLength = (n: number) => (s: string) => s.length <= n;
const includes = (s: RegExp) => (str: string) => s.test(str);

const isAlphaNumeric = includes(/^[a-zA-Z0-9]+$/);

const concatOptions = <T>(o: O.Option<T>) =>
  Mono.concatAll<O.Option<T>>({
    concat: (x, y) => (O.isSome(x) && O.isSome(y) ? x : O.none),
    empty: o,
  });

const validate =
  <T>(preds: Predicate<T>[]) =>
  (o: O.Option<T>) =>
    pipe(preds, A.map(O.filter<T>), A.map(apply(o)), concatOptions(o));

const validateUsername = validate<string>([
  minLength(6),
  maxLength(20),
  isAlphaNumeric,
]);

const hasAlphaAndNumeric = includes(/^(?=.*?\d)(?=.*?[a-zA-Z]).+$/);

const validatePassword = validate<string>([
  minLength(8),
  maxLength(20),
  hasAlphaAndNumeric,
]);

const validateFromUserInput = <A extends Body, B extends A>(
  key: keyof B & string,
  validate: (o: O.Option<string>) => O.Option<string>
) =>
  O.filter<A, B>(
    flow<[A], O.Option<string>, O.Option<string>, boolean>(
      R.lookup(key),
      validate,
      O.isSome
    ) as Refinement<A, B>
  );

const validateBodyIsUsername = validateFromUserInput<Body, Username>(
  "username",
  validateUsername
);
const validateUsernameIsUserInput = validateFromUserInput<Username, UserInput>(
  "password",
  validatePassword
);

const validateUserInput = (body: Body) =>
  pipe(body, O.of, validateBodyIsUsername, validateUsernameIsUserInput);

테스트 코드는 다음과 같다.

const exampleUsernames: Record<string, Body> = {
  valid: {
    username: "username",
    password: "password123",
  }, // valid
  hasNoUsername: {
    password: "password123",
  }, // wrong
  hasNoPassword: {
    username: "username",
  }, // wrong
  tooShortUsername: {
    username: "user",
    password: "password123",
  }, // wrong
  tooLongUsername: {
    username: "usernameusernameusernameusername",
    password: "password123",
  }, // wrong
  notAlphaNumericUsername: {
    username: "username!",
    password: "password123",
  }, // wrong
  tooShortPassword: {
    username: "username",
    password: "pass",
  }, // wrong
  tooLongPassword: {
    username: "username",
    password: "passwordpasswordpasswordpassword",
  }, // wrong
  alphaOnlyPassword: {
    username: "username",
    password: "password",
  }, // wrong
  numericOnlyPassword: {
    username: "username",
    password: "123456789",
  }, // wrong
  specialCharButValidPassword: {
    username: "username",
    password: "!password123",
  }, // valid
};

console.log(R.map(flow(validateUserInput, O.toNullable))(exampleUsernames));

결과는 다음과 같다.

{
  valid: { username: 'username', password: 'password123' },
  hasNoUsername: null,
  hasNoPassword: null,
  tooShortUsername: null,
  tooLongUsername: null,
  notAlphaNumericUsername: null,
  tooShortPassword: null,
  tooLongPassword: null,
  alphaOnlyPassword: null,
  numericOnlyPassword: null,
  specialCharButValidPassword: { username: 'username', password: '!password123' }
}

잘 작동하는 것을 확인할 수 있다.


© 2021. All rights reserved.

Powered by Hydejack v7.5.2