bdg.blog란?
개인적으로 공부한 내용을 정리하고, 적용해보기 위해서 만든 서비스입니다. 서비스의 기능 자체는 “블로그 생성”, “수정”, “삭제”, “조회”와 “토이프로젝트 연결”로 매우 단순합니다. 블로그 서비스를 만들면서 다음과 같은 항목들에 대해 고민했고 어떻게 해결했는지 소개합니다.
- 프로그램 구조
- 기능 확장과 유지보수를 쉽게 만들려면 어떤 구조를 사용해야 할까?
- 사용한 도구
- 프론트앤드-백앤드를 같이 작업을 할 때 중복되는 작업을 줄이는 방법이 있을까?
- 하나의 파일로 엔티티를 관리할 방법이 없을까?
- 테스트 방법
- 단위 테스트는 어떻게 수행하는 것이 좋을까?
- E2E(end to end) 테스트는 어떻게 수행할까?
- 프론트 엔드는 어떻게 테스트할까?
- 개발 환경
- 개발 환경과 빌드 환경을 동일하게 만드는 방법
- 개발 및 빌드에만 필요한 환경을 제외하고 배포 이미지를 만드는 방법
소스코드는 아래 깃허브에서 확인하실 수 있습니다.
- github: https://github.com/deagwon97/bdg-blog-v2/
1. 프로그램 구조
기능 확장과 유지보수를 쉽게 만들려면 어떤 구조를 사용해야 할까?
프로그램의 가장 중요한 요소가 ‘요구사항을 만족하는 동작을 수행하는 것’ 이라면, 두 번째로 중요한 요소는 기능 확장성과 유지 보수성입니다. 저는 단순히 블로그를 만들어 보는 것보다는 더 좋은 구조로 블로그를 만들기 위해서 다음과 같은 구조를 적용했습니다.
요구사항이 최상위에 존재하며 하위로 갈수록 구체적인 구현이 만들어지는 구조입니다. 백앤드는 요구사항을 만족하는 서비스 클래스를 가지고 있습니다. 그리고 이 서비스가 제대로 동작하는 데 필요한 레파지토리(DB와 상호작용) 명세, 스토리지(Object Storage와 상호작용) 명세, 컨트롤러 명세를 정의합니다. 하위에 있는 클래스들은 상위 클래스의 명세를 만족하게 만들었습니다.
위 구조를 통해서 post 테이블에 접근하는 로직이 변경되더라도 서비스의 명세를 만족한다면 상위의 다른 클래스에 아무런 영향도 끼치지 않습니다. 단순히 관심있는 영역의 코드만 변경하면 간단하게 기능을 수정할 수 있습니다.
실제로 npm의 dependency-cruiser
모듈을 사용해서 서버 디렉터리 안에 있는 파일들의 의존관계를 시각화 하면 아래와 같은 모습을 보입니다.
모든 클래스는 서비스에 의존하며 서로의 존재를 신경 쓰지 않습니다. 오로지 서비스만이 요구사항을 만족하기 위한 로직을 결정하며 하위 클래스들은 이를 따릅니다.
2. 사용한 도구
프론트앤드-백앤드를 같이 작업을 할 때, 중복되는 작업을 줄일 방법이 있을까?
언어 - Typescript
이전에 작업했던 프로젝트는 프론트는 ReactJS, 백앤드는 DJango나 Golang-gin을 사용해서 만들었습니다. 만약 각 부분별로 팀이 나눠져 있다면 크게 상관을 수도 있지만, 혼자 작업하는 데 프론트와 백앤드의 언어가 다르다는 점은 상당히 불편했습니다.
- 언어별 네이밍 컨벤션 상이
- python: snake_case
- javascript: camelCase, PascalCase
- go: PascalCase, camelCase
- 호환되는 데이터 타입 상이
- python, js: 동적 타입
- go, typescript: 정적 타입
이런 문제들은 보통 django, go-gin과 같은 프레임워크를 쓰면 많은 부분이 해결되지만, 그럼에도 수작업으로 컨버터를 일일이 만드는 경우가 종종 발생합니다. 이를 최소화하기 위해 프론트와 백을 하나의 언어(Typescript)로 개발하기로 결정했습니다.
웹 프레임워크 - NextJS
NextJS란 full-stack 웹 어플리케이션 개발을 위해서 만들어진 리액트 프레임워크입니다. 기본적으로 React의 구성요소들을 사용하면서 다양한 추가 기능을 제공합니다. 특히 API Routes
를 통해서 별도의 백앤드 서버 없이 자체적으로 public API를 만들 수 있습니다. 프론트엔드와 백앤드의 엔티티, 인터페이스를 하나의 프로젝트에 정의하고 하위 모듈이 이를 구현하는 구조를 만들기 위해 NextJS를 선택했습니다.
컨트롤러 tool - telefunc
NextJS를 사용한다고 하더라도, 결국 통신은 http 프로토콜을 사용해야 합니다. 백에서 정의한 함수를 프론트에서 사용하기 위해서는 백앤드가 http 프로토콜에 맞는 end-point를 정의해야 하고, 프론트는 이 api를 사용하는 코드를 추가해야 합니다.
저는 이 과정이 불필요하다고 생각했습니다. 백앤드에서 정의한 함수를 프론트에서 단순히 import
해서 사용하고 싶었고, telefunc이라는 패키지를 발견했습니다. telefunc은 이 요구사항을 충족시켜주는 훌륭한 도구입니다. telefunc은 서버와 클라이언트 사이의 통신을 함수 호출로 추상화 합니다. 개발자는 단순히 서버의 함수를 호출하지만, 실제로는 telefunc이 http 요청을 보내고 응답을 클라이언트로 반환합니다. 또한 추가적으로 필요한 Cookie와 같은 http 헤더들을 getContext
를 통해서 사용할 수도 있습니다. 따라서 아래 예시와 같은 구현이 가능해 집니다.
telefunc을 사용함으로써 http 프로토콜을 전혀 신경 쓰지 않고, 프론트와 백은 하나의 코드로 구현할 수 있었습니다. (하지만 컨트롤러 모듈은 따로 분리했습니다. 만약 특정한 이유로 외부 API를 사용해야 한다면 telefunc에 의존하지 않고 기능을 확장할 수도 있습니다.)
type generator - prisma
prisma는 typescript에서 사용되는 유용한 orm 도구입니다. 하지만 저는 orm 보다도 type과 DDL 생성을 통해서 얻는 이점이 더 크다고 생각합니다. 백앤드와 데이터베이스 사이의 상호작용은 불필요한 작업을 많이 만듭니다. 백앤드에서 DB에 사용할 객체들의 타입을 정의하면 데이터베이스에서 이를 만족하면서 DB의 특성을 고려한 DDL을 다시 작성해야 합니다. prisma는 schema.prisma
하나로 이 문제를 모두 해결해 줍니다. schema.prisma
를 작성하고, prisma db push
커맨드를 실행하면 DDL이 실행면서 원하는 데이터를 저장할 수 있는 테이블들이 자동으로 만들어지고, prisma generate
커맨드로 prisma shema와 호환되는 typescript 타입까지 생성됩니다.
3. 테스트
테스트 코드는 그 자체로 프로그램 명세서 역할을 합니다. 프로그램이 동작하는 실질적인 예시를 제공하기 때문에, 잘 작성된 테스트 코드를 읽어보면 이 프로그램이 어떤 동작을 수행하는지 단번에 알 수 있습니다. 또한, CI/CD 파이프 라인에서 테스트 코드를 추가하면, 자동화된 배포 과정에서 코드를 검증할 수 있기 때문에 안정적인 배포가 가능해집니다.
백엔드는 요소들이 모듈로 캡슐화되어 있습니다. 하위 클래스가 상위 클래스에 의존하는 방식으로 구현되기 때문에 몇 가지 부분만 고려한다면 테스트 코드를 손쉽게 만들 수 있었습니다. 신경 써야 하는 부분은 데이터에 저장되는 영역입니다. 테스트를 위해서 데이터베이스에 값을 수정하면, 실제 서비스에 영향을 끼칩니다. 이 문제를 해결하는 데는 여러 가지 방법이 있습니다.
단위 테스트 - Mock 객체
프론트앤드는 사용자에게 보이는 요소입니다. 물론 프론트앤드를 테스트하는 것만으로도 앱이 잘 동작하는지 확인할 수 있습니다. 하지만 테스트를 통과하지 못했을 경우, 어떤 영역에서 문제가 발생했는지 디버깅하기 어렵다는 단점이 있습니다. 이러한 문제를 해결하기 위해서는 모듈별 단위 테스트가 필요합니다.
단위 테스트를 수행하는 대표적인 방법은 Mock 객체를 사용하는 것입니다. Mock 객체는 원본 객체의 행동을 모방하는 객체로, 테스트할 함수가 사용하는 다른 모듈의 행동을 모조하여 사용할 수 있습니다. 이를 통해 다른 모듈의 동작에 아무런 영향도 주지 않으면서 원하는 모듈만 테스트할 수 있습니다.
하지만, Repository 모듈은 Mock 객체를 통해서 테스트를 수행하면, DB의 특정 동작을 무시하거나, 실제 동작과 불일치할 수 있습니다. 또한 DB의 동작을 모방하는 Mock객체를 만드는 것은 Mock 객체의 설정을 복잡하게 만들어 테스트 코드의 유지보수를 어렵게 하기도 합니다.
저는 이러한 문제점을 고려하여 DB의 transaction을 통해서 repository 모듈을 테스트하고, 차례로 상위 계층을 테스트하는 방법을 선택했습니다.
Repository 테스트 - DB Transaction
transaction은 연속적인 쿼리 동작에 원자성을 부여하기 위해서 사용됩니다. transaction 내에서 쿼리들을 수행하는 도중, 문제가 발생하면 해당 transaction은 실행하기 전으로 DB를 되돌립니다. Repository 모듈을 테스트한 후 Transaction을 롤백하면, 실제 DB의 어떤 값도 변경하지 않으면서 테스트를 수행할 수 있습니다.
저는 아래와 같은 방식으로 testRepoWithRollback
함수를 정의하고 repository 모듈을 테스트했습니다. 더미 데이터를 생성하는 코드만 만들어주면 함수를 테스트할 수 있습니다. 테스트가 끝난 후 throw new Error('prisma rollback')
명령을 실행해서, transaction을 rollback
합니다.
// transaction으로 테스트를 수행하는 함수 정의
export const testRepoWithRollback = async (
testMessage: string,
testFunction: (p: PrismaClient, repo: IRepository) => Promise<void>
) => {
test(testMessage, async () => {
const prisma = new PrismaClient()
const transaction = async () => {
await prisma.$transaction(async (tx) => {
const p = tx as PrismaClient
const repo = repoFactory(p)
await testFunction(p, repo)
throw new Error('prisma rollback')
})
}
await expect(transaction).rejects.toThrow('prisma rollback')
prisma.$disconnect()
})
}
// ----------------
// 실제 테스트 함수
testRepoWithRollback(
'updatePost',
async (p: PrismaClient, repo: IRepository) => {
// 더미 데이터 생성
const dummyPost = await p.post.create({
data: {
title: 'testTitle',
uriTitle: 'testTitle',
content: 'testContent',
thumbnail: 'testThumbnail',
published: true,
category: {
connectOrCreate: {
where: {
name: 'testCategory'
},
create: {
name: 'testCategory'
}
}
}
}
})
// 동작 테스트
const updatedPost = await repo.postRepo.updatePost(
dummyPost.id,
'updatedTitle',
'updatedContent',
'updatedCategory',
'updatedThumbnail',
false
)
const post = await p.post.findUnique({
where: {
id: dummyPost.id
}
})
// 동작 확인
expect(post).not.toBeNull()
expect(post?.title).toBe(updatedPost.title)
expect(post?.content).toBe(updatedPost.content)
expect(post?.categoryName).toBe(updatedPost.categoryName)
expect(post?.thumbnail).toBe(updatedPost.thumbnail)
expect(post?.published).toBe(updatedPost.published)
}
)
E2E 테스트 - 개발용 데이터베이스 분리
단위 테스트는 개별 컴포넌트가 올바르게 동작한다는 것을 보장합니다. 하지만, 컴포넌트 간의 통합에서 발생하는 문제는 찾아내지 못합니다. E2E 테스트는 이러한 문제들을 찾아내고, 애플리케이션 전체가 사용자 관점에서 올바르게 동작하는지 확인할 수 있습니다.
조회
에 해당하는 기능들은 실제 배포되고 있는 서비스에서도 e2e 테스트가 가능합니다. 하지만 DB에 특정 값을 저장하거나, 수정하는 기능은 실제 서비스에 영향을 줄 수 있기 때문에, 개발용 DB를 따로 사용해야 합니다.
향후 생성 및 조회에 관한 e2e 테스트가 필요하다고 느껴지면, 개발용 데이터베이스를 구축할 예정입니다. 지금은 조회에 해당하는 기능만 테스트하고 있습니다.
E2E 테스트 - 프론트엔드 테스트 스크립트 생성 자동화
프론트 코드는 UI 변경이 잦고 테스트 코드를 작성하는데 시간이 많이 필요합니다. 저는 테스트 코드를 자동으로 생성해 주는 playwright를 사용했습니다. 정해진 시나리오를 화면에 직접 클릭하면 playwight는 이 과정을 아래와 같이 코드로 변환해 줍니다.
import { test } from '@playwright/test'
const user = JSON.parse(JSON.stringify(require('./.auth/user.json')))
test('login test', async ({ page }) => {
await page.goto('http://localhost:3000/main')
await page.getByRole('link', { name: 'loginIcon' }).click()
await page.locator('input[type="text"]').click()
await page.locator('input[type="text"]').fill(user.email)
await page.locator('input[type="text"]').press('Tab')
await page.locator('input[type="password"]').fill(user.password)
await page.locator('input[type="password"]').press('Enter')
await page.getByRole('button', { name: 'ALL' }).click()
await page.getByRole('button', { name: '기타' }).click()
})
테스트 구조도
위에서 언급한 내용을 정리하면 아래 이미지와 같습니다. repository 모듈은 transaction을 통해서 테스트했고, service의 함수들은 transaction으로 동작하는 repository 객체를 입력받아서 같은 방식으로 테스트했습니다. 이후 조회 및 로그인 기능에 해당하는 e2e 기능테스트를 playwright로 구현했습니다.
테스트 코드를 작성하는 방식이 매우 간소화됐기 때문에, 빠르게 테스트 코드를 작성하고 기능을 추가할 수 있습니다. 또한, 기능을 변경할 때, 오류 없이 요구사항을 잘 충족하는지 즉각적으로 확인할 수가 있어서 효율적인 프로그램이 가능해 졌습니다. 현재는 service 모듈과 repository 모듈에 존재하는 모든 함수를 테스트하고 있습니다.
4. 개발 환경
개발 환경을 구축하는 것은 중요한 문제입니다. 환경이 제대로 구성되지 않은 상태에서 개발하다 보면 다음과 같은 문제가 발생합니다.
“내 노트북에서는 됐는데…. 왜 서버에서는 안 되지?” |
“내 노트북에서는 잘 보이는데…. 왜 모바일은 안 보이지?” |
이 문제를 해결하기 위해서 개발환경과 빌드환경을 일치시켰습니다. 그리고 배포환경에서는 빌드환경과 유사하게 구성하면서 배포에는 불필요한 환경들을 제외하고 시스템을 구축했습니다.
개발 환경과 빌드 환경을 동일하게 만드는 방법
vscode는 서버에서 실행되는 container에 ssh로 접속하는 기능을 지원합니다. 또한 접속한 컨테이너에서 필요한 플러그인, 워크스페이스 설정 등을 하나의 파일 devcontainer.json으로 관리할 수 있습니다. 빌드 환경과 개발 환경이 완벽히 일치하기 때문에, 프로젝트를 배포할 때, 환경을 걱정할 필요가 없어졌습니다.
또한 서비스의 특정 도메인을 개발 컨테이너의 개발용 포트로 라우팅 했습니다. 이를 통해 라이브 서버를 https 도메인이 붙은 상태로 테스트할 수 있게 구현했습니다.
이러한 환경 구성은 다양한 장점들이 있습니다. 모바일 기기나 윈도우 기기 등, 다양한 환경에서 화면이 어떻게 보이는 지 실시간으로 확인할 수 있어서, 배포 전에 개발된 기능들이 어떻게 동작 하는지 충분히 확인 할 수 있습니다. 더욱이 실제 개발 컨테이너는 서버에서 동작하기 때문에 ssh키만 있다면 어디서든지 개발환경에 바로 접속하여 개발이 가능합니다.
개발 및 빌드에만 필요한 환경을 제외하고 배포 이미지를 만드는 방법
docker의 multi-stage build를 사용하면 빌드 환경과 배포 환경을 유사하게 가져가면서 불필요한 환경을 제외했습니다.
먼저 builder stage에서 프로젝트 개발 및 빌드에 필요한 환경을 구성하고 빌드합니다. 이후 server stage에서 빌드된 파일들을 가져와서 실행합니다.
Reference
- https://stackoverflow.com/questions/73007221/how-to-unit-test-a-transaction-wrapped-function-in-prisma
- https://telefunc.com/
- https://playwright.dev/
- https://nextjs.org/docs
- https://www.prisma.io/
- Martin, Robert C. 2017. Clean Architecture: A Craftsman’s Guide to Software Structure and Design. Prentice Hall.
- Martin, Robert C. 2008. Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall.