올해 저희 개발팀에서 기존 인프런 서비스 코드를 신규 스택으로 개편하는 프로젝트를 진행하였습니다.
여러 서비스 중 강의실 페이지 개선이 첫 번째 목표였고 최근 작업이 완료되었습니다.
이번 포스트에서는 프로젝트 진행에서 겪은 경험을 공유하고자 합니다.

개편의 필요성

기존 인프런 프로젝트는 다음과 같은 스택으로 구성되어 있습니다.

  • Node.Js
  • Postgresql
  • Express
  • FxJS
  • MQL
  • FxDOM

FxJS 과 같은 함수형 라이브러리와 FxSQL 이라 불리는 쿼리빌더보다 더 이전에 나온 MQL2 를 사용합니다.

본 포스트에서는 같은 Fx 기반의 라이브러리임을 표시하기 위해 MQL 대신 FxSQL 으로 기술하겠습니다.

대부분의 애플리케이션 로직은 FxJSgo 함수를 활용해 여러 단위의 함수를 합성하는 형태로 이루어져 있습니다.
아래 코드를 보시면 sql 을 실행한 결과로부터 시작해 groupBy, map 같은 함수를 사용해 가공작업을 진행합니다.
유인동님의 함수형 프로그래밍 강의를 수강하신 분들이라면 익숙한 형태일 것입니다.

변수명과 디비 필드명은 임의로 수정하였습니다.

fx js

이와 같은 환경에서 큰 변화 없이 오랜 기간 사용하였고 그만큼 프로젝트 크기도 커졌습니다.
이로 인해 여러 이슈가 발생했고 특히 올해 겪은 큰 장애를 경험하고 개선의 필요성을 느끼게 되었습니다.
이번 포스트에서는 경험한 여러 이슈 중 일부를 소개하고자 합니다.

동적 타입 언어

Javascript 는 대표적인 동적 타입 언어로 변수 선언 시 따로 타입을 지정할 필요가 없으며 런타임에 유연하게 타입을 결정합니다.
특히 저희가 사용하는 FxJS 는 위와 같은 유연성을 기반으로 만들어진 라이브러리입니다.

동적 타입으로인해 사용자의 요청 데이터나 디비 데이터를 가공할 때 내부에 존재하는 필드정보에 대한 IDE 의 지원을 받기 어렵습니다
따라서 개발자가 직접 필드를 추적해야 하다 보니 아래 사항을 항상 주의해야만 했습니다.

  • 필드명 타이핑 시 오타를 내지 않도록 조심해야 합니다.
  • nullable 필드 접근 시 사전 확인 로직을 누락하지 않도록 많은 노력이 필요합니다.

아래 이미지는 실제로 nullable 확인 누락으로 인해 발생했던 에러입니다.

undefined

서버 시작 속도

인프런 프로젝트는 팀 내부에서 엔트맨 으로 불리며 FE, BE 의 코드가 하나의 저장소에 존재합니다.
react 같은 SPA 기반의 페이지가 일부 존재하나 대부분은 PHP, JSP 와 같은 방식의 서버 사이드 렌더링을 사용하고 있습니다.

즉 페이지를 이동할 때마다 서버에서 브라우저에 표시되는 모든 영역을 html 으로 만든 후 응답으로 전송합니다.
다만 페이지 이동이 필요 없는 사용자 이벤트에 대한 요청은 Ajax 로 이루어지며 필요한 부분만 새로 렌더링 합니다.
이를 위해 Jquery 와 같은 Dom selector 와 이벤트 핸들링 방식을 사용합니다.

위와 같은 이유로 개발자들이 로컬에서 서버를 실행하기 위해서 클라이언트를 위한 빌드 과정이 필요합니다.
프로젝트 초기에는 별문제가 없었지만 시간이 흘러 코드의 양이 많아졌고 이로 인해 초기 서버 실행 속도가 느려졌습니다.

server start

위 사진은 초기 서버 실행 속도를 기록한 화면이며 pc 사양에 따라 다르지만 1분, 10초 ~ 20초정도 소요됩니다.
서버 실행 이후에 변경되는 코드에 의한 재시작 속도는 대략 10~20 초 가량 걸립니다.

위와 같이 수정작업을 확인하기 위해 소비되는 시간이 점차 많아졌고 이로인해 생산성도 떨어지는 결과를 낳았습니다.

신규 입사자의 코드 적응

이전 장에서 언급한 것처럼 현재 인프런을 구성하는 코드들의 대부분은 FxJS, FxSQL 라이브러리를 사용해 작성되었습니다.
Javascript 로 함수형 프로그래밍을 가능하게 하는 장점이 있지만, 대부분의 입사자들이 작성해왔던 코드 스타일과 상당 부분이 달라 적응하기 어려운 부분이 있습니다.
함수형 프로그래밍에 익숙한 개발자인 경우 함수의 이름(map, reduce, filter, tap, etc..)으로 동작 과정을 유추할 수 있지만 익숙하지 않은 신규 입사자의 경우 문서를 참고할 필요가 있습니다.

fx js doc

하지만 FxJS의 경우 문서에 대한 예제가 부족하여 기존 코드에서 모르는 함수를 만나게 된다면 코드를 통해 동작을 파악하는 데 시간을 사용하게 됩니다.
또한 이런 형태의 코드 작성은 절차 지향적으로 코드가 작성되어 코드들의 응집도가 떨어져 전체적인 코드 파악이 힘들어집니다.

다음으로 어려움을 겪는 부분은 FxSQL 입니다.
FxSQL 은 SQL injection 공격에 대한 처리와 1:1, 1:N, M:N 형태의 쿼리를 간단하게 표현할 수 있고, 조회된 쿼리 결과에 대한 후처리 (hook) 그리고 모듈로 분리하여 쿼리문의 재사용성을 높일 수 있습니다.

하지만 위 기능은 많은 개발자에게 익숙한 JOIN 형태가 아닌 SELECT IN 방식을 사용하기에 개발 경험이 적은 팀원의 경우 이해하는 데 어려움을 겪을 수 있습니다.
또한 hook 은 여러 테이블에서 가져온 데이터를 가공하기 위해 주로 사용되는데 가져온 데이터에 어떤 타입의 필드가 있는지 추적하기 어렵고 hook 이 중첩되는 경우 데이터 변경 흐름을 파악하기 어려울 수 있습니다.

export const something = a_id => go(
    Promise.all([ActiveDC(a_id), MyGroup(a_id)]),
    ([activeDC, group]) => RDB.AS`
    c_list ${{
        table: $TB.CT,
        query: SQL`${WanD(
            {user_id: a_id},
            SQL`"c_id" in ( select "id" from "c" ${WanD(is_enrollable)} )`,
            SQL`"c_id" not in ( ${myValidSomthing({a_id: a_id})} )`,
        )} ORDER BY "updated_at" desc, "id" desc`,
        hook: pipe(
            ct => go(
                ct,
                uniqueBy(sel('c_id')),
                L.map(sel('c_id')),
                grouping(20),
                C.map(someFn(a_id, activeDC, group)),
                flat,
                cs => go(carts, map(_extend(({c_id}) => ({c: go(cs, findWhere({id: c_id}))}))))),
            filter(sel('_.c._.p_info.is_order'))),
    }}`);

위 예제는 변수명과 함수명을 임의로 수정한 코드이며 로직을 이해하기 보다 코드 구조에 대한 부분만 파악해 주시면 좋을 거 같습니다.

위와 같은 이유로 신규 입사자가 팀에 기여할 수 있을 때까지의 시간이 오래 걸리는 편이었습니다.

강의실

강의실 페이지는 다른 부분과 연관된 항목이 적기에 가장 먼저 개편 대상으로 선정하였습니다.
이번 작업에서는 데이터베이스의 스키마 변경 없이 오직 코드 베이스만 변경하는 것을 목표로 하였습니다.
최종 목표인 장애 전파가 격리된 분산 환경을 위해서는 스키마 변경이 필수였지만 이는 데이터 마이그레이션과 같은 까다로운 추가 작업을 동반하기에 1차로 코드만 개선하기로 하였습니다.

기존 API 분석

현재 강의실 api 는 딱 하나만 존재하며 커뮤니티, 노트, 커리큘럼 의 데이터를 모두 가져와서 응답으로 내려줍니다.
특히 테이블의 모든 필드를 가져오는 코드가 많아서 응답 값 중에 전혀 사용되지 않는 필드가 상당히 많았습니다.
특히 커리큘럼의 개수가 많은 강의의 경우에는 응답 크기가 30kB 가까이 되는 경우도 발생하였습니다.
렌더링을 위한 Html body 의 크기가 아닌 api 요청의 json 응답 크기치고는 굉장히 큰 것을 알 수 있습니다.

json size json body

위의 데이터를 가져오는 데 대략 20 개의 테이블을 사용하지만 주로 사용하는 데이터는 2~3개의 테이블에 존재합니다.

사실 위와 같은 데이터를 가져오는 코드의 라인수 자체는 적은 편입니다.
각 테이블 조회 로직을 하나의 함수로 만들어 여러 곳에서 재사용하고 있으며 FxSQL, FxJS 의 함수를 통해 데이터를 병합하고 가공합니다.
재사용성은 많은 편리함을 주지만 주의하지 않으면 생각보다 많은 양의 데이터를 조회하는 결과를 만들 수 있습니다.

기존 서버와의 연동

강의실 페이지는 로그인 유저의 수강권에 따라 다르게 동작해야 하므로 세션 정보가 필요합니다.
신규 서버가 세션을 직접 처리하도록 할 수 있지만 이렇게 되면 신규 서버는 강의실 도메인 외에 인증에 대한 도메인에 의존하게 됩니다.
따라서 만약 세션 처리 방식을 변경하거나 전용 인증 서버를 만들려고 하면 강의실 서버도 같이 변경돼야 하는 문제가 발생합니다.

이를 해결하기 위해 기존 서버는 인증만 담당하고 api 요청에 대한 처리는 신규 서버에서 맡기도록 하였습니다.
즉 모든 api 요청은 우선 기존 서버로 향하며 기존 서버는 api gateway 역할을 합니다.
자세한 설명을 위해 신규 강의실에 접속하는 요청이 처리되는 흐름을 다이어그램을 통해 살펴보겠습니다.

flow

우선 신규 프론트 스택은 react 를 사용하며 배포 작업 시 빌드 결과물을 cdn 에 업로드합니다.
이후 강의실 페이지 처음 접속 시 기존 서버는 빌드 한 결과물 중에서 index.html 만 cdn 을 통해 가져온 후 응답으로 전달합니다.
해당 파일에는 react 를 위한 추가적인 js, css 파일의 cdn 주소가 있으며 브라우저는 이를 가져와 실행합니다.

이후 react 가 초기화되면서 화면을 그리는데 필요한 데이터를 기존 서버로 요청합니다.
기존 서버는 요청을 받아 신규 서버에 세션 정보만 추가 해서 요청을 보냅니다.
신규 서버는 이를 활용해 기존 서버가 수행했던 DB 조회 로직을 수행한 후 응답을 전달하며 이는 기존 서버를 거쳐 사용자에게 전송됩니다.

NestJs

NestJS 는 node.js 진영의 서버 프레임워크로 다음과 같은 장점이 있습니다.

  • 의존성 주입
  • 테스트 코드 환경 구성
  • 모듈 단위 코드 작성
  • 활발한 커뮤니티
  • 타입스크립트 기반
  • CLI 제공

Nest.js는 Node.js가 해결하지 못한 문제를 효과적으로 해결한 프레임워크로 객체지향적인 코드 작성을 가능하도록 도와줍니다.
특히 의존성 주입과 이를 활용한 테스트 코드 작성을 쉽게 할 수 있다는 점이 큰 매력이라고 생각합니다.
위와 같은 기능을 바탕으로 기존 코드를 layered architecture 기반으로 바꾸는 작업을 진행하였습니다.

MikroORM

MikroORM TypeScript ORM 라이브러리이며 Data Mapper, Unit of Work, Identity Map 패턴을 사용합니다.
이전 랠릿 프로젝트에서 사용한 TypeORM 을 사용하면서 다음과 같은 문제를 겪어 이번에 도입을 시도하였습니다.

  • Transformer 관련 이슈

저희는 날짜를 담는 데이터는 js-jodaLocalDateTime 을 사용하는 데 이를 위해 transformer 를 사용합니다.
하지만 쿼리 빌더의 where 조건에 LocalDateTime 을 넣으면 제대로 transformer 가 동작하지 않는 현상이 발생합니다.
또한 빠른 테스트 속도를 위해서 sqlite 를 사용하려고 해도 transformer 가 동작하지 않아 사용할 수 없는 문제가 있습니다.

  • 라이브러리 관리

TypeORM 은 아직도 메이저 버전이 나오지 않은 상태로 메인테이너가 더 이상 시간을 투자하지 못하여 발전이 더딘 상황입니다.

관련 이슈 - https://github.com/typeorm/typeorm/issues/3267

새로운 기능 추가가 없고 위의 transformer 관련 이슈도 오랜 기간 해결되지 않아 앞으로 라이브러리 미래가 불투명합니다.
반면 MikroORM 은 지속적인 관리를 받고 있으며 위 이슈도 발생하지 않아서 이번에 도입을 결정하였습니다.

회고

이번에는 프로젝트에 대한 경험과 개인적인 생각을 공유하고자 합니다.

테스트 코드

이번 프로젝트에서는 테스트 코드를 항상 작성하는 것을 목표로 개발하였습니다.
기존 로직을 파악하고 그 내용을 테스트 케이스로 추가하여 동일한 결과를 만들어내는지 확인하는 과정에서 안정감을 느꼈습니다.
운영코드에 비해 테스트 코드를 작성하는 시간과 양이 더 많았지만, 이후에 발생할지 모르는 버그를 해결하는 데 소요되는 시간을 고려한다면 장기적으로 더 효율적인 개발 방식이라고 생각합니다.

제가 생각하는 레거시 코드의 정의는 단순히 작성된 지 오래된 코드가 아닌 테스트가 없는 코드 입니다.
아무리 오래전 작성된 코드라도 테스트 코드가 있다면 이후 발생되는 새로운 요구사항을 반영하기 위해 기존 코드를 수정해야 하는 두려움을 줄일 수 있습니다.
또한 테스트 케이스의 내용이 하나의 문서로 동작하기 때문에 신규 입사자의 코드 베이스 적응에 도움이 된다고 생각합니다.

기술 선택

TypeORM 을 사용하면서 느꼈던 아쉬운 점이 해결된 MikroORM 을 프로젝트 초반에는 만족스럽게 사용하였습니다.
하지만 시간이 지나면서 몇몇 아쉬운 점을 발견하게 됩니다.

  • 엔티티 파일에서 연관 테이블 필드는 라이브러리에서 제공하는 wrapper 로 감싸주어야 함
  • TypeORM 에 비해 빈약한 Query Builder 기능
  • Repository 를 사용하는 코드를 보고 실제 수행하는 SQL 을 예상하기 어려운 경우가 발생

각 항목에 대한 자세한 설명은 마지막 장을 참조해주세요.

사실 라이브러리 도입을 검토하는 과정에서 위와 같은 문제의 일부를 인지할 수 있었지만 type-safe 한 개발을 선호하는 개인적인 성향으로 이러한 문제를 간과했다는 생각이 들었습니다.

이를 개선하기 위해 개발 작업을 진행하면서 라이브러리에 의존적인 영역을 최대한 줄이도록 노력하였습니다.
예를 들면 Layered architecture 에서 주로 사용되는 개념인 repository 에만 라이브러리 의존적인 코드를 넣어 라이브러리 교체 시 상위 layer 의 코드는 영향받지 않게 하였습니다.
다만 엔티티 파일과 테스트 코드는 라이브러리에 의존적인 코드가 있어 많은 수정이 필요한 점은 아쉬움으로 남았습니다.
그래도 이번 프로젝트를 통해 기술의 선택에서 개인의 선호보다 현재 팀의 추구하는 방향에 더 중점을 두고 고려해야 함을 배울 수 있었습니다.

지금은 MikroORM 에 대해 아쉬운 점이 있지만 TypeORM 에 비해 계속 관리되고 있기에 앞으로 개선될 가능성이 있다고 생각합니다.
node.js 환경에서 JPA 에서 제공하는 영속성 컨텍스트와 같은 기능을 원한다면 좋은 후보가 될 수 있습니다.

마무리

이번 개편 작업으로 인해 결과적으로 코드 라인수는 이전보다 늘었지만 유지 보수 측면에서는 더 좋아졌다고 생각합니다.
함수형 패러다임을 매우 좋아하고 코드를 최대한 간결하게 작성하는 것을 선호하지만 커져가는 팀과 지속 가능한 코드를 위해서는 많은 개발자에게 익숙한 구조를 유지하려는 노력이 필요하다고 생각합니다.

이번 프로젝트는 새로운 기능 추가보다 기존 기능을 다시 구현하는 리팩토링 성격의 작업이었습니다.
따라서 지금 당장은 비즈니스적 가치는 없지만 앞으로 더 빠르고 안정적인 개발 환경을 통해 기존보다 더 많은 가치를 만들 수 있기를 바라봅니다.

MikroORM 이슈

Query Builder

TypeORM 과 달리 MikroORM 은 Query Builder 의 기능에 제한사항이 있습니다.
설명을 위해 먼저 일반적으로 사용하는 게시글 (post) 와 댓글 (comment) 엔티티가 있다고 가정해 봅시다.
만약 댓글 id 가 123 인 댓글의 좋아요수 (comment.like)와 게시글 제목 (post.title) 을 가져오는 쿼리를 작성한다고 하면 아래와 같은 코드를 작성하게 됩니다.

const result = await commentRepository
    .createQueryBuilder('comment')
    .select(['comment.id', 'comment.like', 'post', 'post.title'])
    .join('comment.post', 'post')
    .where({id: 123})
    .getSingleResult();

select 메서드의 파라미터를 보면 ‘post’ 가 있는데 이는 post 전체가 아닌 post.id 필드를 의미합니다.
코드를 처음 보는 사람이 예상하는 의미와 다르기 때문에 주의가 필요합니다.

실제 실행하는 쿼리를 살펴보면 예상하는 대로 동작합니다.

SELECT `comment`.`id`, `comment`.`like`, `comment`.`post_id`, `post`.`name`
FROM `comment` AS `comment`
         INNER JOIN `post` AS `post` ON `comment`.`post_id` = `post`.`id`
WHERE `comment`.`id` = '123'

하지만 실제 결과를 확인하면 게시글의 제목은 없고 오직 id 만 존재하는 것을 확인할 수 있습니다.

query result

이를 해결하려면 쿼리 빌더의 join 대신 joinAndSelect 를 사용해야 하지만 불필요한 게시글 필드를 모두 조회하는 문제가 발생합니다.

const result = await commentRepository
    .createQueryBuilder('comment')
    .select(['comment.id', 'comment.like']) // post 의 모든 필드를 가져옴
    .joinAndSelect('comment.post', 'post') // joinAndSelect 로 변경
    .where({id: 123})
    .getSingleResult();

라이브러리 내부에서 사용하는 Knex 를 직접 사용해 결과를 json 형태로 가져와 클래스 인스턴스로 매핑해 해결할 수는 있습니다.
하지만 이렇게 사용하면 type-safe 하지 않은 json 을 다루는 코드를 작성해야 하며 이는 실수가 발생할 확률이 늘어남을 의미합니다.

엔티티 선언

댓글 엔티티에 게시글 엔티티를 연결하는 경우를 가정해 봅시다.
TypeORM 이였다면 아래와 같은 코드를 작성하게 됩니다.

@Entity()
export class Comment {
    @ManyToOne(type => Post, post => post.comments)
    @JoinColumn()
    post: Post;
}

이렇게 작성하고 만약 새로운 댓글을 추가하기 위해 comment 인스턴스를 만들어야 한다면 보통 아래와 같이 post id 만 활용해 post 인스턴스를 만들어 post 필드를 설정하게 됩니다.

static create(postId:number, body:string): Post{
    const comment = new Comment();

    comment.post = new Post()
    comment.post.id = postId;
    comment.body = body;

    return comment;
}

하지만 위와 같이 생성한 인스턴스를 save 할 시 에러가 발생합니다.
그 이유는 new Post() 를 통해 직접 만든 인스턴스는 MikroORM 의 영속성 캐시에 관리되는 않는 항목이라 무시되기 때문입니다.
이를 해결하려면 라이브러리에서 제공하는 wrapper 를 사용해야 합니다.

@Entity()
export class Comment {
    @ManyToOne(type => Post, post => post.comments)
    post: IdentifiedReference<Post>; // IdentifiedReference 로 감싸줍니다

    static create(postId: number, body: string): Post {
        const comment = new Comment();

        // 라이브러리에서 제공하는 createFromPK 메소드를 통해 인스턴스를 생성합니다
        comment.post = Reference.createFromPK(Comment, postId)
        comment.post.id = postId;
        comment.body = body;

        return comment;
    }
}

위와 같은 방식은 인스턴스 생성이 라이브러리의 의존적이게 되고 추후에 다른 ORM 으로 변경할 때 수많은 생성 코드를 수정해야 함을 의미합니다.