Skip to main content

ifify: 코드의 본질에 집중하는 기술

· 14 min read
고윤호

제목부터 어그로 가득하고 조금은 오만하기까지 한 이 글을 읽으실 때는 진지함을 조금 덜고 가볍게 훑고 지나가 주세요

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를 좋아합니다. 저는 스스로 비주류 개발자라고 생각할 때가 있는데 저의 개발철학을 얼마나 많은 분이 공감하실 수 있을지 모르겠습니다.

비브로스는 아직 저의 두 번째 회사이고 다른 개발팀은 이런 문제를 해결하는지 잘 모르겠습니다. 오히려 다른 개발자 친구들에게 물어본 결과 “그게 당연한 거 아냐?”라는 대답을 들을 뿐이었죠.

다만 해당 개발 방식을 통하여 저만의 개발 철학에 한층 더 부합한 코드를 작성할 수 있게 되었습니다. 저의 방식이 어떤 분들에게도 도움이 되었으면 좋겠습니다.