시작하며

프로젝트가 시작된지 얼마 되지 않은 경우에 소스는 비교적 일관성을 가지고 있습니다. 하지만 시간이 지남에 따라 여러 사람이 참여하고, 비슷한 새로운 프로젝트가 만들어지면서 점점 일관성이 떨어지게 됩니다. (문서나 리뷰 과정이 있으면 비교적 낫지만, 완전히 방지하기는 어려운 것 같습니다) 또한 새로운 기술이 생기면서 (예를 들어 React Hook) 기존에 설정한 구조가 전혀 적합하지 않게 되는 경우가 생깁니다.

그런 의미에서 주기적으로 프로젝트 구성에 관한 가이드를 주기적으로 점검하고 갱신할 필요성이 있습니다. 이번 글에서는 2021년 7월 현재 React 프로젝트의 컴포넌트 구성에 대한 가이드를 설명하려고 합니다. (항상 예외 상황이 있기 마련이고, 이에 따른 변형을 허용하기에 가이드라는 용어를 쓰고 있습니다.)

React 자체는 UI 구성을 위한 라이브러리이기 때문에 구성에 아무 제약이 없습니다. 반면 카카오스타일의 일부 프로젝트에서는 Next.js를 쓰고 있는데, 이는 프레임워크이기 때문에 여러가지 규칙이 있습니다. 전체적인 통일성을 위해 Next.js를 사용하지 않는 프로젝트에서도 Next.js와 유사한 구성을 하도록 가이드를 정했습니다.

라우트

Next.js 에서는 라우트를 pages 디렉토리에서 정하고 있습니다. 다만 카카오스타일에서는 pages를 프로젝트 루트에 두지 않고, src 밑에 모아두는 것을 선택했습니다. 비 Next.js 프로젝트에서도 마찬가지로 pages 밑에 두지만, 라우트 연결은 수동으로 해야 합니다.

다음은 비 Next.js에서의 라우트 설정 예입니다.

import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
import HomePage from 'pages';
import LoginPage from 'pages/login';
import SignupPage from 'pages/signup';
import QnaQuestionMainPage from 'pages/qna/questions';
import QnaQuestionNewPage from 'pages/qna/questions/new';
import QnaQuestionDetailPage from 'pages/qna/questions/[question_id]';
import ChatRoomMainPage from 'pages/chat/rooms';
import ChatRoomDetailPage from 'pages/chat/rooms/[room_id]';

function App() {
  return (
    <Router>
      <Switch>
        <Route exact path='/'>
          <HomePage />
        </Route>
        <Route exact path='/login'>
          <LoginPage />
        </Route>
        <Route exact path='/signup'>
          <SignupPage />
        </Route>
        <Route exact path='/qna/questions'>
          <QnaQuestionMainPage />
        </Route>
        <Route exact path='/qna/questions/new'>
          <QnaQuestionNewPage />
        </Route>
        <Route path='/qna/questions/:question_id'>
          <QnaQuestionDetailPage />
        </Route>
        <Route exact path='/chat/rooms'>
          <ChatRoomMainPage />
        </Route>
        <Route path='/chat/rooms/:room_id'>
          <ChatRoomDetailPage />
        </Route>
      </Switch>
    </Router>
  );
}

export default App;

Next.js에서는 같은 위치에 페이지 파일이 있으면 자동으로 라우트 설정이 됩니다.

페이지를 실제 구성하는 컴포넌트

간단한 뷰는 페이지 파일에 넣을 수 있겠지만 복잡한 경우 여러 컴포넌트로 나누어야 하는데 이를 pages 밑에 두면 Next.js 라우트로 인식되므로 별개의 위치에 둬야 합니다. 따라서 이를 components 디렉토리에 두기로 했습니다. 이때 RESTful한 주소를 갖도록 위치한 페이지와 달리 도메인별로 묶어서 구성합니다.

QnaQuestionMainPage(/pages/qna/questions/index.tsx)에 대응하는 컴포넌트는 /components/qna/question/main/index.tsx에 위치하고, ChatRoomDetailPage(/pages/chat/rooms/[room_id]/index.tsx)에 대응하는 컴포넌트는 /components/chat/room/detail/index.tsx에 위치합니다. 컴포넌트 이름은 각각 QnaQuestionMain과 ChatRoomDetail입니다. 파일명을 컴포넌트 이름과 일치시킬지 여부를 내부에서 논의할 결과 진입점은 index.tsx로 통일하는 것으로 결정했습니다.

예를 들어 QnaQuestionMainPage는 대략 다음과 같은 형태가 됩니다.

import { FC } from 'react';
import QnaQuestionMain from 'components/qna/question/main';

const QnaQuestionMainPage: FC = () => {
  return <QnaQuestionMain />;
};

export default QnaQuestionMainPage;

데이터 가져오기

Next.js 프로젝트와 비 Next.js 프로젝트는 데이터를 가져오는 시점이 다릅니다. Next.js는 클라이언트에 내보내기 전에 React와 무관하게 데이터를 가져오는데 반해, 비 Next.js 프로젝트는 클라이언트 로딩이 끝난 후 데이터를 가져와야 합니다. 어느 경우에든 비슷하게 구성하기 위해 데이터 가져오는 것을 fetchData라는 함수로 분리해 컴포넌트쪽에 둡니다. 각 컴포넌트는 상위 페이지로 부터 데이터를 props로 받아옵니다. 이렇게 구성하면 컴포넌트에 대한 스토리북 구성시 데이터를 다르게 부여하기 편하다라는 장점도 있습니다. 단점으로는 데이터가 고정이 아닌 경우(예를 들어 필터 설정, 정렬 옵션등에 의해 페이지 이동 없이 바뀌어야 하는 경우), 상위 페이지로 이를 전달하는 방법을 고민해 봐야 한다는 점이 있습니다.

다음은 위 규칙에 따라 구성한 컴포넌트 내용입니다.

interface Question {
  id: string;
  title: string;
  date: number;
}

export interface Props {
  total_count: number;
  question_list: Question[];
}

const QnaQuestionMain: FC<Props> = (props) => {
  return (
    <div>
      {props.question_list.map((question) => (
        <QuestionView question={question} key={question.id} />
      ))}
    </div>
  );
};

export default QnaQuestionMain;

export async function fetchData(context: GetServerSidePropsContext | undefined): Promise<Props> {
  const result = await fetch(...);
  return {
    total_count: result.total_count,
    question_list: result.question_list,
  };
}

Next.js 프로젝트에서는 getServerSideProps 메소드에서 fetchData를 호출합니다.

import type { GetServerSideProps } from 'next';
import QnaQuestionMain, { fetchData, Props } from 'components/qna/question/main';

export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
  return {
    props: await fetchData(context),
  };
};

const QnaQuestionMainPage: FC<Props> = (props) => {
  return <QnaQuestionMain {...props} />;
};

export default QnaQuestionMainPage;

반면 비 Next.js 프로젝트에서는 useEffect 안에서 호출합니다.

import QnaQuestionMain, { fetchData, Props } from 'components/qna/question/main';

const QnaQuestionMainPage: FC = () => {
  const [props, setProps] = useState<Props>({ total_count: 0, question_list: [] });

  useEffect(() => {
    const run = async () => {
      setProps(await fetchData());
    };
    run();
  }, []);

  return <QnaQuestionMain {...props} />;
};

export default QnaQuestionMainPage;

자잘한 규칙

컴포넌트를 내보낼 때는 default export를 사용하고 있습니다. 이렇게 하면 내부적으로 컴포넌트 이름을 바꿔도 사용하는 쪽에 영향이 없다는 장점이 있습니다. (예를 들어 jotai를 적용하면서 Provider로 감쌀 필요가 있었습니다) 다만 정의하는 쪽과 사용하는 쪽의 이름을 다르게 줄 수 있어서 찾기 어려워지는 단점도 있습니다. (이는 컴포넌트 이름을 잘 부여하고 주의깊게 사용하면 되긴 합니다) 여기에 스토리북에서 컴포넌트 Props를 제대로 인식하지 못하는 작은 문제도 있습니다. (default export를 사용하고 파일 이름이 컴포넌트와 다른 index.tsx일 경우 발생)

한 페이지 컴포넌트를 작게 쪼갠 경우 그 컴포넌트 사이에는 상대 경로로 참조하면 되지만, pages → components 처럼 멀리 떨어진 컴포넌트를 참조할 경우에는 절대 경로로 참조하는게 좋습니다. 이렇게 구성해야 파일을 /pages/qna/questions/index.tsx 에서 /pages/questions/index.tsx로 옮겨도 import 변경이 필요없습니다. 상대 경로로 참조 가능한 범위에 대해서는 사람마다 다르게 판단하기도 합니다.

절대 경로로는 src 밑의 디렉토리들(예 components, pages, hooks)을 사용하고 있습니다. 저는 @/components/qna/question/main 형태를 제안했는데, components/qna/question/main를 쓰는 것으로 정해졌습니다.

규모가 커지면 커질 수록 자동화된 워크플로우는 필수라고 생각합니다. 하지만 부끄럽게도 크로키닷컴은 잘 구축된 편은 아닙니다.

유닛 테스트는 초기부터 있었지만 그걸 PR, 머지마다 자동으로 수행하지는 못했습니다. 그러다가 2017년 중반 겨우 Jenkins를 세팅해서 자동 테스트만은 수행했습니다. 하지만 그게 워크플로우와 잘 어우러지지 못했습니다. 2019년에는 CodeBuild로 전환을 했고 비로서 PR 생성시 자동 테스트를 수행해 실패하면 머지를 할 수 없도록 구성이 됐습니다.

그럭저럭 아쉬운 대로 쓰고는 있었지만 매 테스트마다 수십분씩 걸리고 수정도 어려웠습니다. 가장 대중적으로 널리 쓰이는 Jenkins, 저희가 만든 OSS에 연결해 둔 Travis CI, CircleCI등을 계속 두드려봤지만 썩 마음에 드는게 없었습니다.

가장 방해가 되던건 저희가 마이크로서비스 아키텍처로 서비스들이 잘게 쪼개져 있는데, 저장소는 단일 저장소(monorepo)라는 점이였습니다. 그러나 사람이 늘면서 도저히 단일 저장소로는 감당이 안 되어 저장소를 분리하기 시작했고, 분리된 저장소에서 새로운 자동화 시스템을 고민하는데 그 때 눈에 띈 것이 GitHub Actions였습니다. 작성이 쉬우면서도 확장성이 좋아서 그 뒤로 여러가지 워크플로우에 GitHub Actions를 사용하고 있습니다.

이번 글에서는 현재 저희 팀이 세팅한 GitHub Actions의 workflow 파일을 공유하려고 합니다. 이 내용이 독자분들에게 도움이 됐으면 합니다.

테스트 자동화

PR이나 머지 완료시 테스트를 수행합니다.

name: 테스트 실행

# PR이 만들어졌거나 master 브랜치에 머지되어 올라갈 때 수행합니다.
on:
  push:
    branches:
      - master
  pull_request:
    branches:
      - "**"

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      # MySQL 데몬을 띄웁니다.
      # service container를 쓸 수도 있습니다.
      - name: Setup MySQL
        uses: mirromutth/mysql-action@v1.1
        with:
          host port: 7777
          container port: 3306
          mysql version: '5.7'
          mysql database: testdb
          mysql user: 'croquis_test'
          mysql password: 'test_password'

      # 소스를 가져옵니다.
      - name: Checkout code
        uses: actions/checkout@v2

      # 실행 속도를 빠르게 하기 위해 설치된 Node 모듈을 캐시하도록 설정합니다.
      - name: Cache node modules
        uses: actions/cache@v2
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
                        ${{ runner.os }}-node-

      # Node 14.x를 사용합니다.
      - name: Use Node.js
        uses: actions/setup-node@v1
        with:
          node-version: '14.x'

      # 모듈을 설치합니다.
      - name: Install packages
        run: npm ci

      # 테스트를 수행합니다.
      - name: Run unit test
        run: npm run test

      # 중간에 실패한 경우 슬랙으로 알려줍니다.
      # GitHub 저장소나 조직의 Secrets 항목에 슬랙 Webhook URL을 등록해야 합니다.
      - name: Notify failure
        uses: 8398a7/action-slack@v3
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
        with:
          status: ${{ job.status }}
          username: github-actions
          channel: '#github'
        if: failure()

문서 빌드 후 업로드

현재 Docusaurus를 사용해 코드와 같은 저장소에서 문서를 작성하고 있습니다. 이를 자동으로 문서 공유 웹사이트에 올립니다.

PR시에도 문서를 생성해 별도 경로로 올립니다. 잘 구성하면 하나의 step으로 만들 수도 있을 것 같은데 빠르게 구현하느라 if를 써서 별도 step으로 구현했습니다.

name: 문서 빌드

# PR이 만들어졌거나 master 브랜치에 머지되어 올라갈 때 수행합니다.
on:
  push:
    branches:
      - master
  pull_request:
    branches:
      - "**"

env:
  # 마이크로서비스 이름입니다.
  SERVICE: user
  BRANCH: ${{ github.head_ref }}
  # 문서 업로드를 위한 AWS Access Key / Secret Key를 Secrets에 등록해둡니다.
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_UPLOAD_DOC_ACCESS_KEY }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_UPLOAD_DOC_SECRET_KEY }}

jobs:
  doc:
    runs-on: ubuntu-latest

    steps:
      # 소스를 가져옵니다.
      - name: Checkout code
        uses: actions/checkout@v2

      # 실행 속도를 빠르게 하기 위해 설치된 Node 모듈을 캐시하도록 설정합니다.
      - name: Cache node modules
        uses: actions/cache@v2
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
                        ${{ runner.os }}-node-

      # Node 14.x를 사용합니다.
      - name: Use Node.js
        uses: actions/setup-node@v1
        with:
          node-version: '14.x'

      # 모듈을 설치합니다.
      - name: Install packages
        run: npm ci

      # 문서를 빌드합니다. (PR인 경우)
      - name: Build documentation (PR)
        # Docusaurus가 문서를 절대 경로로 생성하기 때문에 경로를 알려줘야 합니다.
        run: npm run doc /branches/${SERVICE}/${BRANCH}/
        if: github.ref != 'refs/heads/master'

      # 문서를 업로드합니다. (PR인 경우)
      - name: Upload documentation (PR)
        run: |
                    aws s3 sync out-doc s3://doc.mysite.com/branches/${SERVICE}/${BRANCH} --delete --cache-control max-age=0
        if: github.ref != 'refs/heads/master'

      # 생성된 주소를 커밋 상태에 설정해서 바로 열어볼 수 있도록 합니다. (PR인 경우)
      - name: Set documentation url status (PR)
        uses: actions/github-script@v3
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            github.repos.createCommitStatus({
              context: 'documentaion',
              owner: context.repo.owner,
              repo: context.repo.repo,
              sha: context.eventName === 'pull_request' ? context.payload.pull_request.head.sha : context.sha,
              state: 'success',
              target_url: `https://doc.mysite.com/branches/${process.env.SERVICE}/${process.env.BRANCH}/index.html`,
            });            
        if: github.ref != 'refs/heads/master'

      # 문서를 빌드합니다. (주 브랜치에 푸시한 경우)
      - name: Build documentation (master)
        run: npm run doc /${SERVICE}/
        if: github.ref == 'refs/heads/master'

      # 문서를 업로드합니다. (주 브랜치에 푸시한 경우)
      - name: Upload documentation (master)
        run: |
                    aws s3 sync out-doc s3://doc.mysite.com/${SERVICE} --delete --cache-control max-age=0
        if: github.ref == 'refs/heads/master'

      # 생성된 주소를 커밋 상태에 설정해서 바로 열어볼 수 있도록 합니다. (주 브랜치에 푸시한 경우)
      - name: Set documentaion url status (master)
        uses: actions/github-script@v3
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            github.repos.createCommitStatus({
              context: 'documentaion',
              owner: context.repo.owner,
              repo: context.repo.repo,
              sha: context.eventName === 'pull_request' ? context.payload.pull_request.head.sha : context.sha,
              state: 'success',
              target_url: `https://doc.mysite.com/${process.env.SERVICE}/index.html`,
            });            
        if: github.ref == 'refs/heads/master'

      # 중간에 실패한 경우 슬랙으로 알려줍니다.
      # 저장소나 조직의 Secrets 항목에 슬랙 Webhook URL을 등록해야 합니다.
      - name: Notify failure
        uses: 8398a7/action-slack@v3
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
        with:
          status: ${{ job.status }}
          username: github-actions
          channel: '#github'
        if: failure()

웹 클라이언트 배포

현재 웹 클라이언트는 S3에 올려 운영하고 있습니다. 빌드해서 S3에 올리는 workflow입니다.

name: '백오피스 사이트 배포'

# 배포는 GitHub Actions 화면에서 수동으로 실행시켜야 합니다.
on:
  workflow_dispatch:

jobs:
  release:
    runs-on: ubuntu-latest

    steps:
      # 소스를 가져옵니다.
      - name: Checkout code
        uses: actions/checkout@v2

      # 실행 속도를 빠르게 하기 위해 설치된 Node 모듈을 캐시하도록 설정합니다.
      - name: Cache node modules
        uses: actions/cache@v2
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
                        ${{ runner.os }}-node-

      # Node 14.x를 사용합니다.
      - name: Use Node.js
        uses: actions/setup-node@v1
        with:
          node-version: '14.x'

      # 모듈을 설치합니다.
      - name: Install packages
        run: npm ci

      # 결과물을 만듭니다.
      - name: Build
        run: npm run build

      # S3에 결과물을 업로드합니다.
      - name: Deploy
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_DEPLOY_FRONT_ACCESS_KEY }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_DEPLOY_FRONT_SECRET_KEY }}
        run: |
                    aws s3 sync dist s3://backoffice.website.mysite.com/

      # 배포가 성공한 경우 알립니다.
      # Secrets 항목에 슬랙 Webhook URL을 등록해야 합니다.
      - name: Slack notification success
        uses: Ilshidur/action-slack@2.1.0
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
          SLACK_USERNAME: ${{ github.actor }}
          SLACK_CHANNEL: release
        with:
          args: '{{ GITHUB_REF }}({{ GITHUB_SHA }}): 백오피스 사이트를 배포했습니다.'

      # 배포가 실패한 경우 알립니다.
      # Secrets 항목에 슬랙 Webhook URL을 등록해야 합니다.
      - name: Slack notification failure
        uses: Ilshidur/action-slack@2.1.0
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
          SLACK_USERNAME: ${{ github.actor }}
          SLACK_CHANNEL: release
        with:
          args: '{{ GITHUB_REF }}({{ GITHUB_SHA }}): 백오피스 사이트 배포에 실패했습니다.'
        if: failure()

출시 후보 준비

현재 저희는 개발 브랜치로 작업을 하다가 특정 시점에 출시 후보(Release Candidate)를 준비해서 최종 검증 후 출시를 하는 프로세스를 해보고 있습니다. 이를 자동으로 준비하는 workflow입니다.

이 workflow는 기본 브랜치 기준으로 실행되므로 개발 브랜치가 기본 브랜치여야 합니다.

name: 출시 후보 준비

# 새벽에 출시를 위한 후보 브랜치를 준비합니다.
on:
  schedule:
    - cron: '0 19 * * WED' # 목요일 새벽 4시

jobs:
  create_rc:
    runs-on: ubuntu-latest

    steps:
      # RC 브랜치를 생성합니다.
      - name: Create RC branch
        uses: peterjgrainger/action-create-branch@v2.0.1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          branch: 'rc'

      # RC 브랜치를 가지고 출시용 PR을 생성합니다.
      # JavaScript 코드로 GitHub API를 자유롭게 사용할 수 있는 github-script 모듈을 사용합니다.
      - name: Create PR
        id: cpr
        uses: actions/github-script@v3
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          result-encoding: string
          script: |
            const target = new Date(); // UTC 기준 수요일 저녁
            const month_start = new Date(target.getFullYear(), target.getMonth(), 1);
            const month_week = Math.ceil(( ( (target - month_start) / 86400000) + 1)/7); // 주차 계산
            const title = `${target.getFullYear()}년 ${target.getMonth() + 1}월 ${month_week}주`;
            try {
              // rc에서 main으로의 PR을 생성합니다.
              const result = await github.pulls.create({
                owner: context.repo.owner,
                repo: context.repo.repo,
                title,
                head: 'rc',
                base: 'main',
              });
              return `${context.repo.repo} 저장소에 대해 ${title} RC PR을 생성했습니다. ${result.data.html_url}`;
            } catch (error) {
              return `${context.repo.repo} 저장소에 대해 변경 내용이 없어서 RC PR을 생성하지 않았습니다.`;
            }            

      # 슬랙 채널에 내용을 공유합니다.
      - name: Slack notification success
        uses: Ilshidur/action-slack@2.1.0
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
          SLACK_USERNAME: ${{ github.actor }}
          SLACK_CHANNEL: 'release'
        with:
          args: ${{ steps.cpr.outputs.result }}

응용프로그램을 작성하다 보면 여러 가지 환경 설정(configuration)이 필요합니다. 이번 글에서는 TypeScript에서 환경 설정을 관리하는 저희의 방법을 설명합니다.

프로젝트 초기에는 환경 설정을 코드 중간에 넣어서 개발하는 경우가 많을 것으로 생각합니다. 그러다가 규모가 커지면 여러 가지 이유로 코드에서 분리해서 관리할 필요성이 늘어납니다. 첫 번째는 보안 측면의 이슈입니다. 암호나 접근키(access key) 등을 코드에 넣는 것은 보통 좋은 방향이 아닙니다. 이런 값은 보통 환경 변수(environment variable)에 담아서 사용하는 경우가 많습니다. 프로세스 구동 시에 환경 변수를 설정해두는 것은 꽤 귀찮은 작업이기 때문에 dotenv 같은 모듈을 사용하기도 합니다. 두 번째는 상황에 따라 다른 값을 적용해야 하는 경우입니다. 개발 중, 운영 환경, 자동화 테스트 등의 상황마다 다른 값이 필요한 경우가 많습니다. 이런 경우를 지원하는 것으로 config라는 모듈이 있습니다.

크로키의 경우 첫 번째 이슈는 그렇게 크지 않았습니다. 운영 환경에 적절한 환경 변수를 설정하는 것도 꽤 번거롭기 때문에 AWS Instance Role이나 Parameter Store 같은 방법을 써서 회피하고 있습니다. 하지만 환경에 따라 설정이 달라지는 경우는 많았기 때문에 config 모듈은 사용하고 있었습니다. config 모듈은 필요한 기능을 제공했지만, 오타 등의 이유로 개발 중에는 잘 동작하는데 운영 환경에서는 잘못된 설정이 적용되거나 하는 실수가 종종 발생했습니다. 그래서 저희가 사용하는 TypeScript 언어의 장점을 살려 설정도 타입 검사가 이루어졌으면 하는 바람이 생겼습니다. node-config-ts라는 모듈이 있었지만, 원하는 형태와는 조금 거리가 있었습니다.

여러 가지를 고민하던 중에 외부 모듈 없이도 타입 안정성 있는 설정을 구현할 수 있다는 생각이 들었습니다. 저희는 처음부터 설정 파일을 JSON이 아닌 JavaScript로 관리하고 있었습니다. 이를 그대로 TypeScript로 전환하면 그 자체가 타입 정의가 될 것 같았습니다. 이렇게 만들어진 저희 설정 파일 형식은 다음과 같습니다.

우선 설정의 기본 구조는 config/default.ts에 정의합니다. 이후 다른 코드에서는 이 파일의 내용을 기반으로 타입 검사가 이루어집니다.

const Config = {
  test_mode: false,
  database: {
    host: 'mydb.mydomain.com',
    port: 3306,
    user: 'myuser',
    password: 'mypassword',
  },
};

export default Config;

다른 환경에 대한 설정은 다른 부분에 대해서만 정의하면 됩니다. 다음은 테스트 환경을 위한 config/test.ts 파일입니다.

const Config = {
  test_mode: true,
  database: {
    host: 'localhost',
    port: '5678',
  },
};

export default Config;

이제 이를 묶어서 다른 코드에 제공할 config/index.ts 파일을 정의합니다.

import _ from 'lodash';
import BaseConfig from './default';

const Config = _.cloneDeep(BaseConfig);

if (process.env.NODE_ENV) {
  try {
    const EnvConfig = require(`./${process.env.NODE_ENV}`).default;
    _.merge(Config, EnvConfig);
  } catch (e) {
    console.log(`Cannot find configs for env=${process.env.NODE_ENV}`);
  }
}

export { Config };

이 설정 파일은 다음과 같이 사용하면 됩니다. 이 코드는 NODE_ENV에 따라 다른 결과를 출력합니다. 물론 타입 검사도 완벽히 동작합니다.

import { Config } from './config';

console.log(Config.database);

다만 이대로는 부족한 점이 있습니다. test.ts 파일에서 오타(예 test_mode -> testmode)나 타입 오류가 있어도 알려주지 않습니다. 이는 다음 방법으로 해결할 수 있습니다. default.ts에 다음 내용을 추가합니다.

// from https://github.com/krzkaczor/ts-essentials
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends Array<infer U>
  ? Array<DeepPartial<U>>
  // tslint:disable-next-line:no-shadowed-variable
  : T[P] extends ReadonlyArray<infer U>
  ? ReadonlyArray<DeepPartial<U>>
  : DeepPartial<T[P]>
};

export type BaseConfigType = DeepPartial<typeof Config>;

이제 test.ts를 다음과 같이 수정합니다.

import { BaseConfigType } from './default';

const Config: BaseConfigType = {
  ...
};

export default Config;

처음 정의 시 port의 타입이 달랐는데(string vs number) 이제는 다음과 같은 컴파일 오류가 발생합니다.

config/test.ts(5,3): error TS2322: Type '{ host: string; port: string; }' is not assignable to type 'DeepPartial<{ host: string; port: number; user: string; password: string; }>'.
  Types of property 'port' are incompatible.
    Type 'string' is not assignable to type 'number | undefined'.

저희는 이런 식으로 설정을 정의해서 잘 사용하고 있습니다. 다만 아쉬운 점이 아예 없지는 않습니다. lodash의 merge를 사용하기 때문에 default 설정에서 정의한 값을 undefined로 덮어씌우지는 못합니다. 또 default 설정의 타입이 기준이 되기 때문에 환경마다 설정의 형태가 매우 다르다면 default에서 타입을 명시적으로 써줘야 할 수도 있습니다.

// default
import Redis from 'ioredis';

const Config = {
  database: {
    password: 'mypassword' as string | null,
  },
  cache: {
    ...
  } as Redis.RedisOptions,
};

// test
const Config = {
  database: {
    password: null,
  },
};

제 생각에 JavaScript 생태계에서 TypeScript는 뺄 수 없는 부분이 된 것 같습니다. 이 글이 TypeScript를 사용하시는 데 도움이 되었으면 합니다.