내일배움캠프 Node.js 트랙 33일차
Joi로 유효성 검사하기
왜 유효성 검사에 모듈을 사용해야 할까
내가 Joi를 쓰기로 결심하게 된 건 지난 팀 프로젝트 때문이다.
그때 우리의 프로젝트는 풋살 온라인(피파 온라인을 벤치마킹한 이름)이었고, 내가 맡은 업무는 보유한 선수들을 조회하고, 정렬하고, 출전 선수들을 선발하고, 중복되는 선수들을 강화하는 API를 작성하는 것이었다.
짧게 설명하자면, 이래저래 입력 받아야 하는 값이 다양했다는 의미다. 실제 내가 작성했던 코드를 살펴보자.
router.post('/myTeamMember', authM, async (req, res, next) => {
const { accountId } = req.account;
const { page, orderByThis } = req.body;
const inputs = [accountId, page].map(Number);
try {
// 유효성 검사(1 이상의 정수인가? 빈 값이 들어오진 않았는가? 데이터 형식이 다르지는 않은가?)
const isValidInputs = inputs.every(isValidInput);
if (!accountId) {
return res.status(400).json({
error: '로그인 계정을 찾을 수 없습니다.',
});
}
if (!page) {
return res.status(400).json({
error: '조회하려는 선수 목록 페이지를 입력해주세요.',
});
}
if (!isValidInputs) {
return res.status(400).json({
error: '잘못된 입력입니다.',
});
}
// 유효한 정렬 방식이 아니면 희귀도별 정렬이 default
const validOrderByFields = ['name', 'club', 'rarity'];
const orderField = validOrderByFields.includes(orderByThis)
? orderByThis
: 'upgrade';
// accoutId를 통해 managerId 가져오기
const managerId = await prisma.manager.findFirst({
where: {
accountId: inputs[0],
},
select: {
managerId: true,
},
});
// 예외처리
// (1) 존재하지 않는 accountId인 경우
const isExitAccountId = await prisma.account.findFirst({
where: {
accountId: inputs[0],
},
});
if (!isExitAccountId) {
return res.status(400).json({
error: '존재하지 않는 accountId입니다.',
});
}
// (2) 존재하지 않는 page인 경우
// teamMember 테이블에서 조회하려는 행의 개수를 구한다.
// 행의 개수를 10으로 나눈 숫자를 반올림하고 그 숫자보다 page가 크면 에러 반환
const pageNumber = Math.ceil(
(await prisma.teamMember.count({
where: {
managerId: managerId.managerId,
},
})) / 5
);
if (page > pageNumber) {
return res.status(400).json({
error: '존재하지 않는 페이지입니다.',
});
}
입력받은 값에 대한 유효성 검사와 예외처리만 해주었을 뿐인데 가뿐히 60줄이 넘어가고 있다. 내가 읽는 데에는 무리가 없었지만, 팀 프로젝트인 만큼 코드의 가독성은 매우 중요한 문제였다.
그리하여 찾게 된 답은 Joi라는 패키지 모듈을 이용하는 것이었다. 어떻게 사용할 수 있는지 공식 문서의 내용을 번역하며 살펴보도록 하겠다.
Joi 패키지 모듈 사용법
Joi 패키지 모듈 설치 및 import
일단 Joi를 사용하기 위해선 npm 혹은 yarn과 같은 패키지 매니저를 통해 설치해주어야 한다.
npm install joi
ESM 환경에선 다음과 같이 Joi 모듈을 import해줄 수 있다.
import {Joi} from 'joi';
Joi의 스키마 정의
Joi는 스키마를 정의하여 유효성 검사를 도와주는 모듈이다. 공식 문서의 예시 코드를 뜯어보자.
// 예시 코드 전문
import {Joi} from 'joi';
const schema = Joi.object({
username: Joi.string() // username이라는 이름의 스키마를 정의한다.
.alphanum()
.min(3)
.max(30)
.required(),
password: Joi.string()
.pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')),
repeat_password: Joi.ref('password'),
access_token: [
Joi.string(),
Joi.number()
],
birth_year: Joi.number()
.integer()
.min(1900)
.max(2013),
email: Joi.string()
.email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } })
})
.with('username', 'birth_year')
.xor('password', 'access_token')
.with('password', 'repeat_password');
schema.validate({ username: 'abc', birth_year: 1994 });
// -> { value: { username: 'abc', birth_year: 1994 } }
schema.validate({});
// -> { value: {}, error: '"username" is required' }
// Also -
try {
const value = await schema.validateAsync({ username: 'abc', birth_year: 1994 });
}
catch (err) { }
(1) username 스키마 필드
username: Joi.string()
.alphanum()
.min(3)
.max(30)
.required(),
// ... 중략 ...
})
.with('username', 'birth_year')
1번 라인 : username의 스키마 필드는 문자열 형식을 필수적으로 요구한다.
2번 라인 : 영어+숫자 조합만을 포함해야 한다.
3~4번 라인 : 최소 3글자 이상 최대 30글자 이하여야 한다.
5번 라인 : username 스키마 필드는 전체 스키마를 validate할 때 반드시 포함되어야 하는 필드이다.
8번 라인 : username 스키마 필드는 반드시 'birth_year' 필드와 함께 할당되어야 한다.
여기서 5번 라인에 대한 예시를 위 코드에서도 확인해볼 수 있다.
schema.validate({ username: 'abc', birth_year: 1994 });
// -> { value: { username: 'abc', birth_year: 1994 } }
schema.validate({});
// -> { value: {}, error: '"username" is required' }
username 스키마 필드가 required()로 정의가 되어있기 때문에 validate 메서드를 이용할 때 누락된다면 에러를 발생시키게 된다. 또 .with을 통해 반드시 'birth_year'이 함께 할당되어야 하기 때문에 username만 입력한다면 마찬가지로 에러를 발생시킬 것이다.
위 코드에 따르자면, (조금 의아하지만) username필드를 입력받을 때 password 필드는 필수 입력 조건이 아니다.
(2) password, repeat_password 스키마 필드
그렇다면 password 스키마 필드는 어떻게 정의되어 있는 걸까. 자세히 살펴보자.
password: Joi.string()
.pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')),
repeat_password: Joi.ref('password'),
// ... 중략 ...
.xor('password', 'access_token')
.with('password', 'repeat_password');
2번 라인 : password 스키마 필드는 정규 표현식을 사용하여 패턴을 정의하고 있다.
4번 라인 : repeat_password 스키마 필드처럼 ref메서드를 사용해 특정 참조값과 동일한지 유효성 검사를 진행할 수도 있다.
7번 라인 : password는 access_token 필드와 입력받을 수 없다.
8번 라인 : password는 반드시 repeat_password 필드와 함께 입력받아야만 한다.
(3) access_token 스키마 필드
access_token: [
Joi.string(),
Joi.number()
],
1번 라인 : 특이하게도 배열 안에 스키마 정의가 들어가 있다. 이건 해당 스키마 필드가 optional하단 뜻이다. 즉, access_token 필드는 string 혹은 number 중 하나이기만 하면 입력받을 수 있다.
(4) email 스키마 필드
email: Joi.string()
.email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } })
2번 라인 : Joi 패키지 모듈에는 유효한 이메일 형식에 대한 정의도 미리 포함되어 있다.
→ minDomainSegments를 통해 도메인 주소가 최소 몇 개로 나누어져야 하는지 제한할 수 있다.
(예시 : gmail.com은 2개로 나누어진다. snu.ac.kr은 3개로 나누어진다.)
→ TLD는 Top-Level Domain을 줄인 말이다. 도메인 이름의 마지막 부분에 해당한다.
(예시 : gmail.com은 com으로 끝나므로 유효하다. snu.ac.kr은 kr로 끝나므로 유효하지 않다.)
지금까지 Joi의 기본적인 사용법을 살펴보았다. 잠시 초반에 일러두었던 내용을 재차 언급하자면, 이 모듈이 필수적이라서 이 글을 쓰고 있는 건 아니다. 이미 정규식으로 유효성 검사를 작성한 경험도 있고 해당 방식을 함수화해서 충분히 가독성 좋은 코드를 쓸 수도 있는 것도 알고 있다. 다만 그러는 데 쓰였던 생산성을 다른 곳에 썼다면 결과물이 더 좋았을 텐데, 라는 생각이 들지 않을 수 없다.
하여간 지난 프로젝트에 대한 아쉬움이 남아있기 때문에 스스로 리마인드를 하고자 이 글을 작성하게 되었다. 이런 씁쓸한 경험을 전화위복으로 삼아서, 웹소켓을 이용해 게임 서버를 만들 때에는 좀 더 똑똑하게 코드를 작성해봐야겠다.
'개발일지 > TIL(Today I Learned)' 카테고리의 다른 글
2024-12-16 <OSI 7계층 - 네트워크 계층(IP, 서브넷 마스크, 라우터와 라우팅)> (4) | 2024.12.15 |
---|---|
2024-12-13 (1) | 2024.12.13 |
2024-12-11 (0) | 2024.12.11 |
2024-12-10 <HTTP와 TCP, 그리고 웹소켓> (1) | 2024.12.10 |
2024-12-09 (4) | 2024.12.09 |
댓글