제목부터 어그로 가득하고 조금은 오만하기까지 한 이 글을 읽으실 때는 진지함을 조금 덜고 가볍게 훑고 지나가 주세요
ifify는 비브로스에서 공개 배포한 node.js 라이브러리입니다. 이 포스트에서는 ifify 개발 동기와 코드에 대한 접근 방식에 대한 의견을 나누어 보고자 합니다. ifify에 대한 자세한 내용 및 사용법은 npm 페이지에서 확인해보시길 바랍니다.
그 전에, 함수형 프로그래밍에 대한 지식이 있다면 조금 더 이해하시기 쉬울 것으로 생각합니다.
잠깐 저의 이야기
개발자 커리어 3년 차부터였을까... 여러 프로그래머의 포스트들을 참고해 패러다임과 패턴들을 경험하면서 문득 든 생각이 있었습니다. “안정적인 서비스 만드는 거 너무 좋지, 디버깅하기 쉬운 패턴을 만드는 거 너무 좋고, 재사용 가능한 코드를 만드는 거 너무 중요해... 그런데 쉽지 않네".
여러 포스트를 보면서 네이밍 철학, 프로그래밍 철학들을 학습하는데 막상 도입하기에는 팀의 개발 역량, 개발 패턴, 점진적 시간 소요 등 오랜 경력자의 굳어진 개발 경험을 단숨에 바꾸기는 쉽지 않고, 주니어 개발자의 미숙한 개발 경험을 단숨에 끌어올리기도 쉽지 않다는 생각이 많이 들었습니다.
그래서 고민했죠. “어떻게 해야 내가 원하는 코드에 집중할 수 있을까?” 그리고 가장 작은 단위부터 시작했습니다. 바로 조건문이죠.
조건문(conditional)은 프로그래밍을 배울 때 가장 기초적인 지식이면서 그와 동시에 수많은 분기를 통해서 우리의 머리를 헤집어 놓는 애증의 친구입니다. 함수를 실행할 때도, 변수를 정의할 때도, 반복문을 실행하더라도 조건문을 피할 수 없죠. 'ifify'는 여기서부터 시작합니다.
User might be User
ifify로 해결한 첫 번째 문제는 “왜 유저가 유저가 아닐 수도 있는가?”입니다. 이게 무슨 말이지? 싶으신 분들도 계실 것 같은데요. 코드로 예시를 좀 들어보겠습니다.
const user: User | null = await db.users.findById(userId);
if (!user) {
throw new Error('Not Found User');
}
// Now user is a real User.
어떻게 보자면, 너무 당연한 코드일 수도 있겠지만 저에게 당연하지 않은 순간이 어느 날 찾아왔습니다. 바로 Rust를 시작할 때였죠. Rust 프로그래밍을 배우고 연습하다가 자바스크립트와는 너무 다른 패턴을 마주했습니다. 바로 Option<T>
였습니다. 뭐.. Result<T>
도 있는데 일단 Option
만 보시죠.
Option
을 마주하면서 Rust는 null
값이 없다는 것을 처음 알게 되었습니다.(물론 enum
을 통해 비슷하게 만들어낼 수 있겠지만, Rust에서는 null
값 대신 Option
구문을 사용합니다. 그 때문에 굳이 필요 없다고 보는 게 정확할지도 모르겠네요.)
Rust는 Option
이라는 enum
을 통해서 값의 유무(Some value or null value가 아니라, Some value의 유무)에 따라 분기하여 개발할 수 있습니다. 물론, 여기서 if 구문을 사용할 수도 있지만 제가 여기서 설명하고 싶은 건 enum Option
이 가지는 몇몇 method입니다.
/* 아래의 코드는 예시입니다. */
let maybe_user: Option<User> = db.users.find_by_id(userId);
// user가 None, 즉 없다면 메세지와 함께 패닉(throw error)를 발생시킵니다.
let user = db.users.find_by_id(userId)
.expect("not found user");
// user가 None, 즉 없다면 another_user를 대신 돌려줍니다.
let user = db.users.find_by_id(userId)
.unwrap_or_default(another_user);
이 함수들은 저에게 신선한 충격이었습니다. user 변수는 언제나 제가 원하는 User
만 가지게 됩니다. 있는지 없는지 다음 코드에서 고민할 필요가 없었습니다.
그리고 다음 날 회사에 출근했을 때 저는 저의 Typescript 코드를 보고 실망했습니다. 저는 여전히 데이터베이스에서 여러 값을 요청하고 값이 있는지 없는지 if
를 통해 판단하고 있었으니까요.
그래서 저는 ifify의 초기 프로토타입인 if-func
라는 유틸리티 함수를 만들어서 회사 동료분들과 공유했습니다. 대충 이런 식이었습니다.
// components/utils/if-func.ts
export const throwIfIsNil = (err: Error) => (v: any) => {
if (v == null) {
throw err;
}
return v;
};
// 비즈니스 로직에서
export const someRequest = async (userId: string) => {
validate(userId);
const user: User = await db.users.findById(userId)
.then(throwIfIsNil(new Error(NOT_FOUND_USER_MESSAGE));
// user가 필요한 나머지 비즈니스 로직
}
이 작은 함수를 통해서 저는 이제 user가 null인지 검증하기 위한 코드를 추가로 표현하지 않아도 user라는 이름의 변수에 User 값이 있을 것이라고 보장할 수 있습니다.
위 예시를 보기에는 “코드가 한 줄로 줄어든 것 말고 장점이 있나?” 싶을지 모릅니다. 그렇지만 코드에서 statements로 이루어진 표현식을 읽지 않고 값과 로직 자체에만 집중할 수 있는 것은 굉장히 매력적이라고 생각합니다. 짧게는 한 줄에서 Database, Cache 등 많은 데이터를 한 로직에서 다룰수록 효과는 극대화되고 저는 그것을 경험했습니다.
어떤 분들은 이렇게 말씀하실 수도 있을 거 같아요. ‘요청하는 로직을 함수로 분리해서 사용하셔야죠.’
const getUser = async (userId: string): User => {
const user = await db.users.findById(userId);
if (!user) {
throw new Error(NOT_FOUND_USER_MESSAGE);
}
return user;
};
export const someRequest = async (userId: string) => {
const user: User = await getUser(userId);
// 나머지 비즈니스 로직
};
그렇다면, 같은 로직을 갖는 여러 조회 요청과 여러 검증을 거쳐야 한다면 어떻게 해야 할까요? getUser
에서 다 처리한다면 getUser
는 정말 user 조회만 하는 걸까요? 아니면 user 뒤에 다른 검증 함수를 만드실 건가요?
const getUser = async (userId: string) => {
const user = await db.users.findOneById(userId);
if (!user) {
throw new Error(NOT_FOUND_USER_MESSAGE);
}
// Option 1. getUser가 삭제 필드를 확인 하는게 맞는가?
if (Boolean(user.removedAt)) {
throw new Error(NOT_FOUND_USER_MESSAGE);
}
return user;
};
const getPosts = async (postIds: string[]) => {
const posts = await db.posts.findByIds(postId);
posts.map((post) => {
if (!post) {
throw new Error(NOT_FOUND_POST_MESSAGE);
}
});
return posts;
};
// Option 2. 들어온 데이터의 검증을 위해서 부가적인 코드를 더 만들어야 함.
const checkRemovedUser = (user: { removedAt: Date }) => {
if (Boolean(user.removedAt)) {
throw new Error(NOT_FOUND_USER_MESSAGE);
}
};
// Option 2-1. Promise chain으로 사용할 검증 함수
const checkRemovedUser = (user: { removedAt: Date }): User => {
if (Boolean(user.removedAt)) {
throw new Error(NOT_FOUND_USER_MESSAGE);
}
return user;
};
export const someRequest = async (userId: string) => {
// Option 1 getUser 내에서 해결
const user = await getUser(userId);
// Option 2 후속 함수를 실행
checkRemovedUser(user);
// Option 2-1 Promise 체인으로 후속함수를 실행
const user2 = await getUser(userId).then(checkRemovedUser);
const posts = await getPosts(user.posts);
};
저는 이후 위의 코드를 이렇게 작성하게 되었습니다.(예시입니다)
export const someRequest = async (userId: string) => {
const user = await db.users.findOneById(userId)
.then(execIfIsNil(() => cache.users.findOneById(userId)))
.then(throwIfIsNil(new Error(NOT_FOUND_USER_MESSAGE))
.then(throwIf(_.has('removedAt'), new Error(NOT_FOUND_USER_MESSAGE));
const posts = await db.posts.findByIds(user.posts)
.then(throwIf(_.includes(null), new Error(NOT_FOUND_POST_MESSAGE));
};
당연히 모든 비즈니스 로직을 이렇게 해결하지 않습니다. 그리고 이렇게 Error 구문을 다 나열해서 쓰지도 않습니다. 예시로 보여드린 코드만 보자면 빽빽한 문자들 때문에 어지러워 보이기까지 합니다.
그렇지만 이렇게 작성한 이후 저는 변수 이름만 보면 그 뒤에 검증을 위해 어떤 함수를 호출했고 어떻게 처리했는지 함수 참조를 계속 들어갔다 나왔다 하거나, 빼먹은 게 없는지 스크롤을 오르락내리락할 필요가 없어졌습니다. 파일 하나하나가 짧아지게 되니 코드가 한눈에 보이게 되고 각 파일의 비즈니스 로직에 집중하기도 훨씬 좋아졌습니다.
Improve if-func
이렇게 패턴을 만들고 나니 이제 프로젝트에서 변수에 들어가는 값은 좀 더 변수의 이름에 의미적으로나 경험적으로나 본질에 가까워졌습니다. 이렇게 하나의 함수를 만들고 나니 욕심이 생겼습니다. 다양한 케이스에 적용할 수 있도록 만들고 싶었죠. 그래서 이런 함수들을 만들었습니다.
- throwIfIsNil: 들어오는 값이 null, undefined이면 에러를 던집니다.
- throwIfIs: 들어오는 값이 일치하면 에러를 던집니다.
- throwIf: 들어오는 값이 predicate 함수의 true 결과이면 에러를 던집니다.
- execIfIsNil: 들어오는 값이 null, undefined이면 함수를 실행합니다.
- execIfIs: 들어오는 값이 일치하면 함수를 실행합니다.
- execIf: 들어오는 값이 predicate 함수의 true 결과이면 함수를 실행합니다.
아직 배포하지 않은 함수들도 몇몇 있습니다. 아직 시험적인 함수들은 테스트를 거친 후 개선 사항이 없다고 판단되면 추가 feature에 등록할 예정입니다.
마무리
ifify 개발 목적과 과정에 대해서 가볍게 풀어봤습니다. 저는 친구들이 C와 자바를 배우고 있을 때 파이썬이 궁금했고, class 기반 객체지향을 떠나 프로토타입 객체지향에 정착했으며, 함수형 프로그래밍을 즐기고, Go lang보다 Rust를 좋아합니다. 저는 스스로 비주류 개발자라고 생각할 때가 있는데 저의 개발철학을 얼마나 많은 분이 공감하실 수 있을지 모르겠습니다.
비브로스는 아직 저의 두 번째 회사이고 다른 개발팀은 이런 문제를 해결하는지 잘 모르겠습니다. 오히려 다른 개발자 친구들에게 물어본 결과 “그게 당연한 거 아냐?”라는 대답을 들을 뿐이었죠.
다만 해당 개발 방식을 통하여 저만의 개발 철학에 한층 더 부합한 코드를 작성할 수 있게 되었습니다. 저의 방식이 어떤 분들에게도 도움이 되었으면 좋겠습니다.