본문으로 건너뛰기

Megabrain 동아리 블로그 탭 제작기

· 약 11분
서상혁(Singhic)
정회원, 백엔드

안녕하세요, Megabrain에서 백엔드 개발자로 활동하고 있는 Singhic입니다!

현재 보이는 블로그 탭이 어떻게 제작되었는지, 어떤 우여곡절끝에 만들어졌는지 알아보도록 하겠습니다.

글에있는 모든 코드는 Github Repository에서 볼 수 있습니다.

상상

현재 메가브레인 동아리 부원들의 각각의 블로그는 그저 회원 탭에서 한 명을 콕! 집어서 들어가야 블로그 하이퍼링크를 통해 볼 수 있게 되어있습니다.

여기서 한 번 더생각해 보면 굳이 이렇게 해야 하나? 그냥 블로그 탭에 자동으로 최신 글만 받아오게 만들 수 있지 않을까? 라는 상상에서 시작되었습니다.

구상

일단 기본적인 구상부터 시작했습니다. 블로그 탭을 만들려면

  1. 사이드바 - 블로그 탭 만들기
  2. 블로그 파싱해 오기
  3. 가져온 것을 Markdown 파일로 변환하기
  4. Github에 커밋올리기

간단히 이렇게 4개의 단계로 구상했습니다.

그럼 이를 바탕으로, 구체적으로게획을 세운 후 실행으로 증명해야 합니다.

구체적 계획 및 실행

  1. 사이드바 - 블로그 탭 만들기

현재 이 사이트는 Docusaurus 라는 페이스북에서 개발한 오픈 소스 문서화 도구를 사용 중입니다. 또한, React를 기반으로 만들어져있어 docusaurus.config.ts 라는 파일 안에서 쉽게 탭을 만들수있었습니다.

  1. 블로그 파싱해 오기

블로그에 있는 글들을 자동으로 가져와야 합니다. 이를 손수 맛있게 하면 좋겠지만 제가 갈려 나가는지라..... 그래서 fast-xml-parser패키지를 이용하여 파싱하는 Typescript 파일을 생성하였습니다. 또한 같은 문서가 있을 때 자동으로 예외 처리를할 수 있게 try, catch 문으로 작성하였습니다.

async function fetchAndParseXML(): Promise<void> {
try {
// XML 데이터 가져오기
const response = await axios.get(url);
const xmlData = response.data;

// XML 파서 설정
const options = {
ignoreAttributes: false,
attributeNameRaw: true,
};

// XML 파싱
const parser = new XMLParser(options);
const jsonData = parser.parse(xmlData);

if (hasDuplicate) {
console.log('No new files created: Duplicate content found.');
}
} catch (error) {
console.error('Error fetching or parsing XML:', error);
}
}
  1. 가져온 것을 Markdown 파일로 변환하기

2단계에서 가져온 것을 토대로 이제 파일을 만들어야 합니다. Docusaurus에서는 현재 Markdown 파일로 글을 생성, 작성 및 수정하게 되어있습니다. 그럼 이 가져온 글을 .md 파일로 만들어내야 하는데,, 이를 또 어떻게 해야 하나 찾아보는 중!

이게 또 패키지가 있네???

사실 많은 패키지중에서 html-to-md를 선택한 이유는

그냥 다른 방법들과 비교했을때 가장 변환된 결과값이 가장 정확하고, 에러없고, 누가봐도 가독성있게 짤수있었기 떄문!

async function saveDescriptionsAsMarkdown(jsonData: any): Promise<boolean> {
const htmlToMd = require('html-to-md'); // require로 가져오기
let fileIndex = 1;

// JSON에서 item 배열 추출 및 최신 항목부터 역순으로 처리
if (jsonData.rss && jsonData.rss.channel && jsonData.rss.channel.item) {
const items = Array.isArray(jsonData.rss.channel.item) ? jsonData.rss.channel.item : [jsonData.rss.channel.item];
const existingFilesContent: string[] = [];

// 기존 파일 내용 수집
for (let i = 1; ; i++) {
const filePath = `blog/authors_name/authors_name${i}.md`;
if (!fs.existsSync(filePath)) break; // 더 이상 파일이 없으면 중단
existingFilesContent.push(fs.readFileSync(filePath, 'utf-8'));
}

// 최신 항목부터 처리
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
const title = item.title || 'No Title'; // 제목 추출
const pubDate = item.pubDate; // pubDate 추출
const markdown = createMarkdown(item.description, title, pubDate);
const outputPath = `blog/authors_name/authors_name${fileIndex}.md`; // 파일 이름 생성
// 기존 파일 내용과 비교
if (!existingFilesContent.includes(markdown)) {
saveMarkdownFile(markdown, outputPath);
fileIndex++; // 파일 인덱스 증가
} else {
console.log(`Duplicate content found, not creating file: ${outputPath}`);
return true; // 중복 내용이 발견되었으므로 true 반환
}
}
}
return false; // 중복이 없었으므로 false 반환
}

Description을 기점으로 사이에 있는 모든 것을 받아오게 되어있습니다.

지금 생각해 보면 엄청난 돌머리였지만 그대로 코드를 올린 점이 하나 있는데..

주석에 최신 항목부터 처리. 이걸 왜 했을까.. 이것의 의도는 그냥 받아오니 옛날글이 위로 올라오네?? 그럼, 밑으로 내리려면 옛날 거부터 받으면 되잖아? 럭키비키구만 라고 했는데

조금만 더 알아보니 pubDate를 받아와서 적어주면 자동으로 해주더라고요.. 이것은 추후에 리펙터링하는걸로.

그리고, 혹시 기존에 있는 것과 새로 만들려는 파일의 내용이 같으면 굳이 안 만들어도 되니깐 if 문을 통해 확인 후 생성되게 했습니다.

  1. Github에 커밋올리기

이 단계는 따로 서버를 운영하고 있지 않은 동아리 사이트에서 어떻게 돌릴 것이냐, 라는 의문이 있었습니다. 이것을 위해 서버를 만들어? 그건 아니잖아

이것 또한 찾아보니 Github Action을 이용하면 가능하더라고요! Github Action이란 Github가 공식적으로 제공하는 빌드, 테스트 및 배포 파이프라인을 자동화할 수 있는 CI/CD 플랫폼입니다. 여기에서는 .github\workflows라는 폴더안에 .yml확장자로 된 파일을 만들어 워크플로를 자동화할 수 있었습니다.

name: Action name

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: "0 20 * * *"

jobs:
build:

runs-on: ubuntu-latest

steps:
- name: Set up chekcout
uses: actions/checkout@v2

- name: Set up node
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'

- name: Install dependencies
run: |
npm install

- name: Run parser
run: |
node authors-parser.js

- name: Update or add .md file
if: steps.verify.outputs.changed == 'true'
run: |
git pull
git add .
git diff
git config --local user.email "email@email.com"
git config --local user.name "github_nickname"
git commit -m "Commit Message"
git push

일단 1번째로 branch를 설정했습니다. GitHub에서 호스팅하는 Runner 중에서 저는 ubuntu를 사용하였고, step을 기점으로 밑에서부터는 할 일을 시키는 공간입니다. 이름을 적고 어떤 걸 어떻게시킬 것인지를 적어주면 됩니다. 이름은 밑에 사진처럼 나오게 됩니다.

저의 계획은 순차적으로 노드를 가져와 패키지 실행을 위해 npm을 설치하고, 위에서 열심히 만들어둔 파일을 실행한 후 자동으로 커밋을 올리게 됩니다.

하지만 쉽게 된다면 말이 안 되죠. 악당 등장..

문제는 딱 하나. md 파일이 없어서 만들게 없어. 그래도 커밋을 올려야해? 라는 오류로 펑 * ∞ ...

이를 수정하기 위해 이리저리 돌아 아래와 같이 코드를 하나 추가했더니 성공해 버렸습니다

- name: Check if there are any changes
id: verify
run: |
git diff --quiet . || echo "changed=true" >> $GITHUB_OUTPUT

- name: Update or add .md file
if: steps.verify.outputs.changed == 'true'

딱 저 확인하는 절차 하나에 갈려 나갔습니다.

평가

사실 이 프로젝트 아닌 미니(?) 사이드(?) 정도 되는 장난감인데 저는 이렇게 오래 걸릴 줄몰랐거든요.. (사실 이게 5일이나 걸림)

처음으로 이렇게 경험해 보는 거고 군대 전역 후 복학하고 경험해 보고 싶어 이 기능을 구현하게 되었습니다.

주변에 조언도 많이 받고, 이 기능은 레포지터리를 삭제하지 않는 이상 계속 가지고 가는데, 이 글을 끝으로 마무리 짓는 것이 아닌 후에도 문제가 생기면 모두가 처리할 수 있게 리펙터링하고, 주석 자세히 달아두겠습니다.

괴물 메서드 리팩터링과 성능개선하기

· 약 17분
임채성(Puleugo)
2024 팀장, 백엔드

발표 영상

안녕하세요. 왁타버스 게임즈의 백엔드 팀의 임채성입니다. 이번 글에서는 저희 팀의 오랜 과제였던 '구글 스프레드시트 동기화 메서드'의 리팩터링 과정과 성능 개선 방법을 공유하려고 합니다.

왁타버스 게임즈와 백엔드 팀의 역할

먼저 왁타버스 게임즈가 어떤 서비스인지 설명드리고 개발팀에서 달성해야하는 목표를 설명드리겠습니다. 저희 서비스는 유튜버 우왁굳의 메타버스 컨텐츠인 왁타버스의 팬 게임 ⋅ 어플리케이션 플랫폼입니다.

  • 팬 게임과 랭킹 그리고 도전과제 등 다양한 기능
  • 스프레드 시트로 어드민 페이지로 사용하기 때문에 DB와 데이터 일관성 제공
  • 24시간 무중단 운영
  • 팬 게임⋅어플리케이션의 랭킹과 다운로드 수 및 조회수 통계 제공

이러한 특성들을 고려했을 때 저희팀은 B2B와 디지털 플랫폼 비즈니스로 **서류작업과 파트너 팀과의 협업이 많은 팀**이라고 볼 수 있습니다. (엄밀히 따지면 비상업 프로젝트이기에 비즈니스는 아닙니다.)

스프레드 시트 데이터 동기화 메서드란?

플랫폼 비즈니스에서 가장 중요한 건 콘텐츠입니다. 개발 초기부터 스프레드 시트를 사용하여 팀 간의 의존없이 콘텐츠 수집과 서비스 개발이 이루어지며 왁타버스 게임즈 서비스가 빠르게 성장할 수 있었습니다. 따라서 왁타버스 게임즈의 기반이 되는 기능이라고 볼 수 있습니다.
서비스의 기반이 된다는 것은 무슨 의미를 가지고 있을까요? 어떤 기능보다 먼저 개발되었으며 가장 오래된 기능이라는 의미기도 합니다. 왁타버스 게임즈는 올해로 2년차에 접어들었고, 파트너 게임도 약 200개을 넘겼습니다. 트래픽도 초창기에 비해서 훨씬 많아졌습니다. 관리하는 도메인의 추가와 시트 내 필드 수 증가로 해당 메서드를 수정 가능하도록 리팩터링해야하는 상황에 도달했습니다. 그렇다면 데이터 동기화 메서드가 어떤 상태였는지 확인해보도록 하겠습니다.

괴물 메서드의 문제점

레거시 코드 활용 전략이라는 책에서 아래와 같은 문구가 나옵니다.

대규모 메서드는 다루기 힘든 수준이라면, 괴물 메서드는 재앙이라고 부를 만하다. 괴물 메서드는 너무 길고 복잡해서 손대고 싶지 않은 메서드를 의미한다.

왁타버스 게임즈 팀을 운영된 2년동안 이 메서드에는 수많은 도메인들이 추가되었고 복잡한 조건이 추가되면서 끔찍한 괴물 메서드가 되어 있었습니다. 데이터 동기화 메서드 코드의 상황은 다음과 같았습니다.

  • 코드 길이가 600줄을 넘음.
  • 테스트 코드가 없고, 수정 시 큰 부담을 줌.
  • 코드의 동작 범위를 완벽하게 이해하는 사람이 없음.
  • 복잡한 조건이 계속 추가되며 유지 보수가 어려워짐.

수정하는 입장에서 굉장히 부담스럽고 어려운 메서드입니다.
이제 스프레드 시트와 DB의 정보를 동기화하는 메서드를 괴물 메서드라고 부르도록 하겠습니다.

리팩터링하기

리팩터링하기 위해서는 이러한 순서로 진행합니다.

  1. 분석하기
  2. 리팩터링하기
  3. 성능 개선하기

분석하기

가장 중요한 부분입니다. 메서드가 어떻게 동작하는지, 어떤 부분에서 문제가 발생하는지, 이 기능이 이 메서드에 있는 것이 적절한 코드인지, 또한 기존 코드의 동작 기능인지 아니면 버그인지 분류해야합니다. 팀원의 코드라도 맹목적으로 믿지 않아야 합니다.

간단히 살펴봤을 때 문제점은 이 모든 처리가 동기 ⋅ 블로킹으로 동작하고 있다는 것입니다.

  1. 함수 내 모든 코드가 동기 ⋅ 블로킹으로 처리되고 있음.
  2. 3rd party api 호출 시 네트워킹을 불필요하게 반복해서 재연결함.

개선할 부분이 많이 보인다고 해서 이 코드를 바로 변경할 수 없습니다. 리팩터링하는 개발자는 코드의 그 함수의 역사와 영향 범위를 모르고 수정합니다. 이럴 때 필요한게 리팩터링입니다.

리팩터링하기

리팩터링이란 함수의 결과의 변경없이 코드의 구조만을 수정하는 방식을 말함. 하지만 리팩터링 또한 실수할 가능성이 없지는 않습니다. 이렇게 몇백줄이 넘는 코드를 리팩터링하는 경우에는 특히 더 실수가 많을 수 밖에 없습니다. 또한 문서가 없기에 코드 내에 어떤 기능이 동작해야하는 지도 정리되어 있지 않는 상황입니다.
이때 적용할 수 있는 것이 테스트코드입니다. 테스트코드는 리팩터링 시에 다음과 같은 실수를 방지해줍니다.

테스트코드란 정말 심플합니다. 함수가 의도대로 동작하는 지 검사해주는 역할을 합니다.

test('유저의 나이를 증가시킨다.', () => {
let user = new User({age: 1});
user.incrementAge();
expect(user.age).toBe(2); // ✅ user.age == 2
})

발표용 이미지

이를 통해 리팩터링 이 정상적으로 완료되었는 지 지속적으로 확인할 수 있습니다. 하지만 n백줄의 코드 내에 테스트해야하는 기능이 얼마나 있을까요? 굉장히 많을 것입니다. 특히 애플리케이션 외의 DB, Redis, 3rd Party API 등과 커뮤니케이션이 있는 이 함수의 경우 테스트가 더욱 복잡할 수 밖에 없습니다.
그렇기에 이 함수를 테스트하기 쉬운 코드로 분리할 필요가 있습니다. 코드의 테스트 가치를 시각화한 표가 있습니다.

테스트 가치⋅난이도 시각화

현재 리팩터링하고자 하는 코드는 테스트 가치⋅난이도가 높은 '복잡한 코드'에 해당합니다. 이 코드를 리팩터링하기 위해서는 도메인 모델, 의존객체가 많고 간단한 코드로 분리할 필요가 있습니다. (자세히 알아보기: 만개의 테스트를 작성하지 마라. 202번째글)

개선 방안(gif)

이에 대한 해결 방법은 도메인 모델을 구현하여 이 코드 내에 모든 복잡한 로직들을 옮기고 기존의 서비스 레이어는 도메인의 흐름만을 적는 방식으로 개선하는 방안입니다. (자세히 알아보기: [번역] 방금 당신이 작성한 코드는 도메인 로직인가? | What is domain logic? 204번 글)

// Application Service: 계좌 출금 예제
private TakeMoney(amount: number): void {
if(!this.atm.canTakeMoney) { // 인출이 가능한 지 확인한다.
throw AtmHasNotEnoughMoney('인출 불가');
}
const amountWithComission = this.atm.calculateAmountWithComission(amount); // 수수료 포함 금액을 계산한다.
this.paymentGateway.chargePayment(amountWithComission); // 금액을 청구한다.
this.atm.takeMoney(amount); // 인출한다.
this.repository.save(this.atm); // 저장한다.
}

간단히 요약하면:

  • 도메인 레이어: 모든 의사 결정자
  • 서비스 레이어: 도메인 레이어의 의사를 수행

도메인 모델로 분리하기

총 3가지 도메인 모델을 구현하였습니다.

  • Row(행): CSV 1ROW -> JSON, JSON -> DB QUERY, Validate, Numbering etc
  • Rows(행의 1급 콜렉션) -> FILTERING, UPSERT, etc
  • SpreadSheet(스프레드 시트) -> FULL CSV ROW -> Rows Array(3차원 배열)

다음과 같이 사용할 수 있음.

export interface SheetDto // DTO 정의
{
[SheetEnum.GAME]: Rows<GameRow>;
[SheetEnum.APP]: Rows<AppRow>;
// ...
}

// 시트 데이터 가져오기
function async getSheetData(sheetRange: Set<SheetEnum>): Promise<SheetDto>
{
const sheet = new SpreadSheet();

// 요청된 시트 범위가 없다면 초기값 반환
if (sheetRange.size === 0)
return sheet.values;

const rawRows = await this.googleService.getRawSheet(this.sheetId, sheetRange); // 원시값 요청
return sheet.fillRaws(rawRows).value; // 원시값을 가공하여 반환
}

// 메서드 사용
const {
GAME: gameRows, // 게임 행 데이터
APP: appRows, // 어플리케이션 행 데이터
} = this.getSheetData(new Set([SheetEnum.ALL]));
console.log(typeof gameRows); // Rows<GameRow>
console.log(typeof appRows); // Rows<AppRow>

gameRows.filterBy({edited: true}); // 수정된 데이터를 필터링
gameRows.upsert(gameEntities); // 데이터가 존재하면 업데이트, 존재하지 않으면 삽입
this.googleService.updateSheet(gameRows); // 구글 시트에 동기화

쿼리 로직은 영속성 레이어로 분리하기

기존 레거시 코드에서 존재하던 문제점은 비즈니스 레이어에서 쿼리를 작성하는 행위입니다.

Layered Architecture 4Layer

class UserService {
constructor(
@InjectRepository(UserEntity)
private readonly ormRepository: Repository<UserEntity>, // 1️⃣ Framework에서 생성한 Repository의 인스턴스를 주입받은 변수
@Inject(UserRepository)
private readonly userRepository: UserRepository, // 2️⃣ 내가 등록한 UserRepository의 인스턴스를 주입받은 변수
) {}

addUserAge(userId: number)
{
this.ormRepository.createQueryBuilder() // ❌ 영속 레이어에서 처리하세요.
.update().set({ age: () => 'age + 1' })
.where({ id: userId })
.excute();
}

refactoredAddUserAge(userId: number)
{
this.userRepository.addUserAge(userId); // ✅ 복잡한 처리는 영속 레이어가 처리하자.
}
}

@Injectable()
class UserRepository {
constructor(
@InjectRepository(UserEntity)
private readonly ormUserRepository: Repository<UserEntity>,
) {}

addUserAge(userId: number)
{
this.ormRepository.createQueryBuilder()
.update().set({ age: 'age + 1' })
.where({ id: userId })
.excute();
}
}

리팩터링 결과

폴더 구조

wt-games:
├─sheet
│ ├─sheet.module.ts
│ ├─sheet.controller.ts
│ ├─sheet.service.ts # ⭐ 600 -> 200 lines
│ └─/domain # ⭐ NEW
│ ├─spread-sheet.ts
│ ├─rows.ts
│ ├─row.ts
│ ├─game-row.ts
│ ├─application-row.ts
│ └─etc-row.ts

├─game
│ ├─game.module.ts
│ ├─game.service.ts # 500 -> 300 lines
│ └─game.repository.ts # ⭐ NEW

├─application
│ ├─application.module.ts
│ ├─application.service.ts # 500 -> 300 lines
│ └─application.repository.ts # ⭐ NEW
...

메서드 구조

async private syncGame(gameRows: Rows<Game>): Promise<void>
{
// 1️⃣ Spreadsheet에서 삭제된 Game을 DB에서 삭제
const deletedCount = await this.gameRepo.deleteExcludeBy({ids: gameRows.ids});
// 2️⃣ Database에서 추가/변경된 게임을 조회하여 Rows에 동기화
const editedGamesFromDb = await this.gameRepo.findEditedGames();
gameRows.syncWithDbChanges(editedGamesFromDb);

// 3️⃣ DB에 변경된 내용을 반영
const gameEntities = gameRows.toEntities;
await this.gameRepo.upsertMany(gameEntities);

// 4️⃣ Spreadsheet에 변경된 내용을 반영
const updatedGameRows = gameRows.updatedRows;
const updatedRowInfos = updatedGameRows.toRowInfos;
await this.googleService.updateGoogleDocument(this.sheetId, updatedRowInfos);
}

이와 같은 방식으로 리팩터링해줍니다. 복잡한 로직임에도 꽤나 가독성이 향상되었습니다.

성능 개선하기

과도한 API 호출(Excessive API Call) 개선하기

151개의 영상 정보를 얻기 위해 151회 호출

배치 처리(Batch Processing)

유튜브 API 명세상 한번에 50개의 영상 데이터만 조회 가능.

151개의 영상 정보를 얻기 위해 4회 호출

간단하게 계산해보겠습니다. 실제 동작 성능과 정확한 지표는 아니고 가정된 상황에 대한 지표임을 알립니다.

가정

  • 총 조회할 영상의 수: N = 1,000
  • 한 번의 API 호출로 조회할 수 있는 영상의 수: 50
  • 각 API 호출에 소요되는 시간: 0.5초 (네트워크 왕복 시간과 서버 응답 시간 포함)
  • API 호출의 비용: $0.01/1000회 (예시로 설정한 API 요금 기준)

1. 기존 방식 (1회에 1개의 영상 조회)

  • API 호출 횟수: N = 1,000
  • 총 소요 시간: 1,000회 호출 * 0.5초 = 500초
  • 네트워크 비용: 1,000회 호출 * $0.01/1000 = $0.01

2. 개선된 방식 (1회에 50개의 영상 조회)

  • API 호출 횟수: ⌈ N/50 ⌉ = ⌈ 1,000/50 ⌉ = 20
  • 총 소요 시간: 20회 호출 * 0.5초 = 10초
  • 네트워크 비용: 20회 호출 * $0.01/1000 = $0.0002
항목기존 방식개선된 방식개선 비율
API 호출 횟수1,000회20회98% 감소
총 소요 시간500초10초98% 감소
네트워크 비용$0.0.1$0.000298% 감소

동기적 처리로 인한 병목 현상(Synchronous Bottleneck)

비동기적 처리(Asynchronous Processing)

이 방식을 적용해볼 때 중요한 것은 Node.js의 동작원리입니다. Node.js는 특징은 싱글스레드입니다. 데이터베이스와 논 블로킹, 비동기을 적극 사용하면서 훨씬 빠른 실행 결과를 얻을 수 있습니다.

중요한 것은 Promise.all 내부의 DB 커넥션을 고유하게 제공해야합니다. 만약, 하나의 DB 커넥션만 사용한다면 해당 커넥션을 사용중인 함수가 종료될 때까지 Promise Pool에서 대기하여 결과적으로 순차실행이 되기 때문입니다.

async syncSheet()
{
const
{
GAME: gameRows,
APP: appRows,
GAME_GENRE: gameGenreRows,
APP_GENRE: appGenreRows,
ACHIEVE: achieveRows,
BANNER: bannerRows,
GUIDE: guideRows,
BADGE: badgeRows,
} = await this.getSheetData();

await Promise.all
([
syncGames(gameRows, gameGenreRows),
syncApps(appRows, appGenreRows),
syncAchieves(achieveRows),
syncBanners(bannerRows),
syncGuides(guideRows),
syncBadges(badgeRows),
]);

const images = [gameRows.allImageIds, appRows.allImageIds, achieveRows.allImageIds, bannerRows.allImageIds, badgeRows.allImageIds].flat();
await this.s3Service.uploadFiles(images);
}

관련 실험 글 Promise.all 과 Transactions (feat. Node.js).

성능 개선 결과

구동 환경보다 성능이 좋은 환경에서 구동시간 결과입니다.

  • 실행 결과: 3s → 2.7s
  • 약 10%의 속도 개선

KAIST GDSC 해커톤 참여, 발표 회고

· 약 8분
임채성(Puleugo)
2024 팀장, 백엔드

참여 계기

재밌어보였다. 새로운 사람들을 사귀고 대화좀 하고 싶었다.

준비하면서

첫 해커톤 참여라 원활한 협업을 위해 보일러 플레이트 코드를 준비하려고 했다. 그러면서 '이상적인 개발자의 해커톤 준비 방법'이라는 글도 쓰고, 이전 주는 거의 해커톤 준비에만 투자했다.
사람들이 낯을 많이 가렸다. 소통을 위한 디스코드는 개최 일주 전부터 만들어졌는데 아무도 자기소개를 포함한 채팅 치지 않았다. 그래서 분위기를 띄우고 싶어서 자기소개를 길게 써봤다.

제발.. 하트좀..

해커톤 운영경험이 있다보니 열심히 준비하셨을 GDSC 운영진분들을 생각해서 굳이굳이 더 말눈광스럽게 작성한 것도 있다..

팀 매칭

팀 매칭은 GDSC 운영진에서 매칭해준다. 랜덤이지만 참가자 모두 좋은 분들이라 재미있었음.
우리 팀은 백엔드가 셋이었다. 나, 민주님, 윤정님 이렇게 백엔드가 세명이므로.. 팀 상황 상 Java로 진행됐다. (프론트는 민주님이 리드, 준비해간 Node.js Boilerplate 코드는 전혀 사용할 수 없게됐다.)
그리고 디자인, PPT, 기획자를 동시에 맡아주시는 우리들의 본체 성현님까지 4인팀 1팀이었다.

주제와 기획

주제는 UN의 17가지 지속 가능한 발전 목표를 충족할 수 있는 제품이었다. (개인적으로 주제가 여러개니까 기획이 까다로웠음) 다들 생각해온 솔루션이 있었지만 만장일치가 안 나왔습니다.
다들 기획자, 창업의 경험이 있었던지라. 까다로운 판단 기준이 있었습니다.

  • 참여자들의 공감을 받을 수 있는 문제를 해결할 것
  • 나부터 쓸 제품을 만들 것

그러다 10분을 남겨두고 정말 만족스러웠던 기획인 중고서적 교환 서비스를 개발하게 되었습니다.

기획 발표에 띄워뒀던 플로우

참고로 기획 발표는 2분이 주어졌으며 우리 팀은 2-3페이지의 발표자료로 발표했다.

해커톤에서

이번 해커톤에 참여하면서 가졌던 목표는 '좋은 사람들 많이 만나기'와 '재밌는 경험'이었다. 모두 이뤘어서 너무 좋은 경험이었다.
좋은 사람들 많이 만나기는 개발하며 내가 맡은 부분을 끝내면 (보통 2시간 간격정도) 다른 팀원분들과 대화를 10분정도 나눴다. 절반의 팀들과는 대화를 나눠본 것 같다. 너무 재밌었음. 팀원들과도 여러번 같이 걸으면서 다들 갖고 있는 경험, 고민들을 공유하면서 친해졌다.

왁타버스.. 감사합니다..

평소 외향적인 성격이 아닌데도 이번 해커톤에서 대화를 많이해봤던 것 같다. 그 이유를 생각해보니 애초에 왁타버스 관련 개발을한다고 말하니 다른분들이 쉽게 기억해주셨던 것 같기도 하다. 멘토분들이 서로에게 왁타버스를 설명하는것부터 너무 웃겼음.

버튜버 같은 걸 팀으로 운영하는거에요.
..버튜버가 뭐에요?
ㅋㅋㅋㅋㅋㅋㅋㅋㅋ

발표

공업 고등학교 시절 전 몰랐어요.. 3년후에 카이스트에서 발표하고 있을지..

어떻게하면 사람들 기억에 남을지에 집중하면서 발표했던 것 같습니다. 좋은 의도의 기획이지만 프로덕트에 집중하면 당근같은 중고거래 플랫폼과 차별점을 잘 못 느끼실 것 같기도 했고.
시작부터 다른 발표와는 다르게 참여형으로 진행했다.

첫번째 퀴즈

  1. 밤세서 졸리시죠? 조금 재밌게 진행해보기 위해 퀴즈를 가져와봤습니다! (집중을 위한 간략 인사)
  2. 4월 4일이 뭔지 아시나요? (대부분 절대 모를 문제)
  3. ...
  4. 바로 종이 안쓰는 날입니다. 4월 14일이 블랙데이, 짜장면 먹는날인데 이건 아무도 모르시더라구용. 종이에 대한 관심이 적다를 말씀드리고 싶었어요..ㅋㅋ (일부러 언급)

두번째 퀴즈

  1. 그럼 이번에는 다들 쉽게 맞출 수 있는 문제(화제 전환)
  2. 여러분들이 생각하기에 "인류 역사상 가장 훌륭한 발명품은 무엇인가요?"
  3. 에어컨, 화약, 자동차, 책 등등..
  4. 다들 정답입니다. 그런데 지금까지 나온 발명품은 모두 인류의 행위를 도와주는 발명품입니다. 그런데 책만 유일하게 인류의 기억을 도와주는 발명품이다. 그런데 우리는 그런 책을 집에 쌓아두고 방치하고 있지 않느냐?

이후로는 프로덕트 설명..
참여자분들에게 발표평가는 좋았었지만, 심사위원, 멘토분들의 평가 반영률이 90%인지라 조금 발표방식을 바꿨으면 좋았을것 같기도 하다.

후기

재밌있었으니 대만족입니다.. (다음에는 Java로 된 Boileplate도 준비해가겠습니다..)

당신의 블로그, 계왕권을 쓸 수 있다.

· 약 7분
임채성(Puleugo)
2024 팀장, 백엔드

발표준비로 바빠져 10월에 배포 예정입니다.. ㅠㅠ

계왕권이 뭔데..

최근 취업 불황으로 인해 국내 개발자들의 평균 스펙이 향상되었습니다. 그러나 해외 블로그의 기술적 난이도는 국내보다 낮습니다. 예시로 일본의 대표적 기술 블로그 플랫폼인 Qiita 인기 게시글을 보면 이를 확인할 수 있습니다. (08/19 기준)

  • 【완전판】 이것 1개로 React의 기본을 마스터할 수 있다! 초보자 튜토리얼!
  • 【AWS 컨테이너 입문】간단한 Python 앱을 ECS에 배포해 보자!
  • Excel에서 CSV 파일을 '0 내림'이나 '문자화'없이 열기
  • 【Vue】VeeVallidate에 의한 커스텀 밸리데이션의 작성

그래서 '국내 개발자의 글을 해외로 수출하면 수요가 있지 않을까?' 라는 생각이 들었습니다. 대표적 선진국 9개국의 인구수는 한국의 약 44배이므로, 단순 계산으로 당신의 블로그는 44배 이상의 영향력을 얻을 수 있습니다. 드래곤볼의 계왕권을 떠올리며 프로젝트 이름을 이렇게 정했습니다.

딸깍 3배.

[출시 알림 신청을 받고 있습니다. 많은 관심 부탁드립니다!!]
[개발블로그 계왕권] 프로젝트 출시 알림 신청

설계 해보자

저는 초등학교 4학년때부터 11년간 블로그를 운영해 온 블로거입니다. 중학생 시절에는 블로그 마케팅도 경험했고, 네이버 블로그에서 누적 조회수 1,028k를 달성했습니다. (향로님의 약 10% 정도)

블로그 관련 콘텐츠에는 나름의 인사이트가 있다고 자부합니다. 이 관점에서 가장 큰 이슈는 다음과 같습니다.

이슈: 구글은 번역 글을 좋아하지 않는다.

구글은 유사 문서를 굉장히 싫어합니다. 당신이 며칠을 고민해서 작성한 글이라도 글의 내용이 유사하다면 검색결과 우선순위가 하락합니다. 특히 이미지를 동일한 이미지를 그대로 쓰면 우선순위가 하락합니다. 모든 검색엔진이 자체제작 콘텐츠를 좋아해요.

인기글 중 자체 콘텐츠(8/10)

그럼 어떻게 해야할까요?

해결 방안: 상호대체 여부를 명시하라.

사실 우리가 고민하는 내용들은 이미 선대 개발자들이 알잘딱으로 처리해놨을 가능성이 높습니다. 또한, 이번 케이스도 동일합니다. 각 포스트의 html head에 hreflang 태그를 삽입하면 국가에 맞는 게시글을 안내해주며 중복컨텐츠 문제가 해결됩니다. (명시 방식에는 다른 방법도 존재함)

요로코롬 넣어주면 굴루굴루에서 처리해줌.

게시글 작성할 시에 html 편집모드로 script 태그를 추가하면 됩니다.

<script>
document.addEventListener("DOMContentLoaded", function() {
var link = document.createElement('link');
link.rel = "alternate";
link.hreflang = "ja";
link.href = "https://example.com/ja";
document.head.appendChild(link);
});
</script>

내 취향의 기능 구현

  • 접근성은 엑셀이 최고: 모바일에서도 데이터를 조작할 수 있도록 구글 스프레드시트를 통해 데이터를 정리할 것입니다. 엑셀과 DB의 데이터를 CronJob을 사용하여 지속적으로 동기화할거에요.
  • 확장성은 markdown이 최고: html의 ui는 css 의존하므로 너무 의도치 않은 ui 결과가 나올 수 있습니다. 많은 글로벌 블로그에 배포하기 위해서는 github에서 markdown으로 관리 할 것입니다.
  • 서버리스로 작업: 편해지고자 하는 작업인데 서버는 관리하기 귀찮고 비용도 발생할거에요. 모든 처리는 Github Action으로 처리할게요.

아키텍처는 이렇습니다.

특히 마음에 드는 점은 추후에 다른 블로그 플랫폼으로의 마이그레이션에도 열려있습니다. 그래서 대표적으로 필요한 기능은 이렇습니다.

  1. 티스토리 게시글 md 변환
  2. ChatGPT 변역 기능
  3. 블로그 플랫폼 별 게시글 업로드 기능 구현
  4. 게시글 ↔ 엑셀 동기화*(조작할 필요은 적을 수록 가장 이상적)*

출시되면 많은 관심 바랍니다!!
(9월 중 오픈소스로 출시 예정)
[개발블로그 계왕권] 프로젝트 출시 알림 신청

[[개발블로그 계왕권] 프로젝트 출시 알림 신청

- 개인정보 수집 항목: 이메일 - 개인정보 수집 목적: 서비스 출시 시 알림 응답 제출 시 개인정보 수집에 동의하는 것으로 간주합니다.

docs.google.com](https://forms.gle/rQvbYyTxoeLdowmi6)

혹시 좋은 아이디어 있으면 위 구글 폼에 의견 달아주시면 감사하겠습니다.

개발자들아! 내게 아이디어를 조금만 나눠줘!!

감사합니다!!!

개발자의 이상적인 해커톤 준비 방법

· 약 11분
임채성(Puleugo)
2024 팀장, 백엔드

서론

KAIST GDSC에서 개최하는 스파클링톤에 참여하게 되었다. 모교에서 해커톤을 2회 주최한 경험은 있어도 100% 참여자로써의 해커톤 경험은 처음이라 많은 기대를 하고 있다. 해커톤 개최 경험자로써 개발자의 이상적인 준비를 이야기해보고자 한다.

협업에 관한 내용도 개발 실력보다도 아주아주 중요하지만 개발 이야기만 해도 할 말이 많기 때문에 이번 글에서는 생략하겠다.

해커톤 참여자는 이래야 한다.

해커톤은 스펙보다는 재미, 협업 능력 향상, 무료 피자와 콜라를 목표로 하는 것이 옳다. 번뜩이는 아이디어를 빨리 구현해야 하는데 대용량 트래픽, 확장성 같은 경험을 채우고 싶어서 해커톤에 참여하는 것이라면 그다지 좋은 팀원으로 평가되지는 않을 것이다.

그렇다면 어떤 개발자가 좋은 해커톤 팀원일까? 의존하지 않아도 되는 팀원이다. 다음은 이번 해커톤에서 모집된 직무들이다. (4인 1팀)

노드는 직무, 간선은 의존성이다. 채도는 의존도라고 보면 된다. 해커톤 내의 변수가 많으니 이 그래프가 절대적 지표는 아니다.
의존성이 있으니 백엔드 개발자 관점에서 어떤 것에 집중해야할 지 생각해 보자.

  • 기획자와 협업할 때, 해결해야 하는 문제와 솔루션을 확실하게 이해해야 한다. 확실한 이해를 위해 많은 의사소통을 해야 한다. 개발부의 의존성은 전적으로 당신에게 향해있다는 것을 명심하라.
  • 디자이너와 협업할 때, UI에 따라 내가 어떤 정보를 프론트엔드에게 제공해야 하는지, 사용자의 스토리에 따라 어떤 정보가 필요할지도 계속 생각해야 한다. 디자인에 따라 처리해야 하는 알고리즘을 서버에서 처리할지 프론트에서 처리할 지도 이후 회의해야 한다.
  • 프론트엔드 개발자와 협업할 때, 빠르게 API를 제공해야 한다. 사용자와 직접 의사소통하므로 가장 잔손이 많이 가는 작업을 맡으실 텐데 API 제공이 늦어진다면 그만큼 프론트엔드 개발자의 부담이 커진다.

다들 밤을 새우며 정신적으로 피곤해질 수밖에 없다. 해커톤을 개최하면서 싸우는 팀도 꽤나 봤어서 이를 방지하기 위해 좋은 팀원이 되는 것이 중요하다. 결국 참여자 모두 재밌고 행복하기 위해 존재하는 해커톤이니까.

백엔드라면 이것을 준비하라.

먼저, 보일러 플레이트를 준비하라. 보일러 플레이트는 거의 변경 없이 바로 사용 가능한 반복되는 코드를 말한다. 나한테만 도움 되는 것이 아니라 프론트엔드 개발자에게 도움 될 것이다. 예시를 들자면:

  1. API 문서(e.g. 스웨거)
  2. DB 세팅 및 ORM
  3. 배포되어 있는 AWS, S3 컨테이너: 프론트엔드가 서버를 클론해서 로컬에서 작업하는 것은 말도 안 된다.
  4. E2E 테스트: 단일, 결합 테스트는 미리 작성해도 거의 의미 없다. 어차피 기획이 나오면 변경될 내용이다. 해커톤 진행 시에도 E2E 테스트 외에는 개발이 완료되면 진행하자.
  5. Docker Compose를 활용한 빠른 3rd Party 확장
  6. 서버 자동 배포
  7. 게시글 작성
  8. 파일 업로드
  9. 채팅 기능
  10. 인증: 필요로 할 때 아니면 추가하지 말자. 보안 해커톤 같은 경우가 아니라면 프로젝트 개발 속도만 떨어뜨리는 족쇄다.

특히 E2E 테스트를 강조하는 이유는 당신의 신뢰성을 보여주기 때문이다. 해커톤에는 부담도되고 긴장도 되고 즐겁기도 평소와 다른 흥분한 상태일 것이다. 그런 상황에서 내 작은 실수가 크게 느껴질 수 있다.

이를 E2E 테스트가 검증해 줄 것이다. 스웨거 배포, API 동작 확인 등 프론트엔드 개발자에게 보여질 부분을 테스트하라. 또한 망각의 동물인 우리에게 테스트를 먼저 작성함으로써 제약사항이나 팀원끼리 정한 솔루션을 망각하는 실수를 줄일 수 있다.

두 번째로는 오픈소스와 외부 API를 사용해 보라. 해커톤에서 해결해야 하는 방법이 단순히 솔루션만 있다고 해결되지는 않는 경우도 많다. 이런 경우에는 "데이터"나 "복잡한 알고리즘"이 필요할 수 있다. 이런 부분은 오픈소스와 외부 API가 제공해 줄 것이다. 카카오의 구름톤의 교육에서 항상 빠지지 않고 나오는 주제이다.

사용방법을 읽어보는 것 또한 비용이다. 미리 할 수 있는 것은 해커톤 전에 한번 정도 사용해 보는 것이 옳다.

데이터 부분은 이를 참고하라.

외부 API는 이를 참고하라.

Node.js계열을 사용한다면 Nest.js 웹 프레임워크도 권장한다. 의존성을 Module 단위로 관리하기 때문에 보일러플레이트의 추가 기능 관리에 효율적이다.

세 번째로는 시제품(MVP)을 개발한다는 생각으로 프로젝트를 개발하여야 한다.

올바른 MVP

자동차라는 MVP를 만들 때는 자전거부터 시작하는 게 아니라, 최소한 자동차가 구현되어야 한다.

  1. 1번 케이스, 4단계까지 진행될 때까지 협업이 불가능하다.
  2. 2번 케이스, 우리가 만들고자 하는 기능을 확실하게 알라. 이동수단이라면 1단계로도 충분하다. 자동차라면 바로 4단계를 만들어라.
  3. 3번 케이스, 1단계부터 바로 목표였던 자동차를 만들었다.

3번 케이스가 MVP다. 많은 개발자들이 2번으로 착각하고 있는데 MVP는 최소 기능 제품이다. 굳이 최소 기능을 여러 번 분리해서 개발할 이유가 있을까? 바로 시제품을 만드는 것을 목적으로 하라. MVP는 변경될 여지가 적다.

더 중요한 것은 4단계에서는 동작하는 것이 확실하지만 1-3단계까지는 완벽하게 동작하지 않아도 된다는 점이다. 적절한 결과물(e.g. json)부터 빨리 프론트엔드에게 제공하여 개발이 진행되도록 하자. 프론트엔드 개발자도 서버의 API 처리 부분보다는 UI를 먼저 만들라.

네 번째로는 Git 버저닝 전략이다. 대부분의 개발자들이 Git Flow를 사용하는데 빠른 개발을 해야 하는 해커톤에서는 Github Flow 전략을 권장한다. Git Flow는 대규모 팀에서 적합한 반면 Github Flow는 1-3인 정도의 소규모 팀

Github Flow는 Main 브랜치 + n개의 기능 브랜치로 관리하는 방법이다. 운영 관련 브랜치가 포함된 Git Flow보다 훨씬 개발속도가 빠를 것이다.

[번역] 방금 당신이 작성한 코드는 도메인 로직인가? | What is domain logic?

· 약 8분
임채성(Puleugo)
2024 팀장, 백엔드

서론

도메인 로직 vs 비즈니스 로직?

우리는 코드를 작성하기 전에 두가지를 생각합니다. 해결하고자 하는 문제해결 방안입니다.

여기서 실생활에서 해결하고자 하는 문제가 바로 도메인(a.k.a. Problem Domain, Core Domain)이며 해결방안비즈니스 로직(a.k.a. Domain Logic, Business Rule, Domain Knowledge)이라고 부릅니다.

알고보면 쉽지만 찾아보지 않으면 대화조차 안되는 내용이라 학생 입장에는 슬프다.

https://enterprisecraftsmanship.com/posts/what-is-domain-logic/

결론은 도메인 로직과 비즈니스 로직은 동의어이며 위와 같은 의미라는 것만 알면 된다.

도메인 로직

하지만 문제 해결방안(이후 도메인로직)만이 코드에 있으면 너무 좋겠지만 우리들의 코드는 그렇지 않습니다. 예를 들면:

  • DB 로직: DB에 도메인 모델을 저장
  • 3rd Party API 로직: 외부 서비스를 사용
  • UI 로직: UI를 통해 사용자와 상호작용 하는 코드

위 같이 도메인로직이라고 부르기에는 애매모호한 코드도 있을 것입니다. 특히 Transaction Script 아키텍처 패턴을 사용하는 경우에는 더욱 그럴겁니다. (도메인 모델에게 행위를 안주고 서비스가 전부 처리하는 패턴, DB로직이랑 도메인 로직이 짬뽕된다.) Transaction Script가 단순한 코드에서는 가독성이 좋을지 몰라요.

그런데 도메인이 복잡해질 수록 코드 가독성은 떨어지게 돼요. 더 자주 들어본 안티패턴으로는 빈약한 도메인 모델이 있어요. 도메인 로직과 이외의 로직을 구분한다면 도메인 모델을 추출하여 코드에서 명확하게 관심사를 분리할 수 있게 돼요. 그렇게되면 DB나 UI같은 세부 사항에 주의를 기울이지 않아도 어떤 도메인을 처리하고자 추론하는 데 필요한 인지적 부하를 줄일 수 있습니다.

그러면 애플리케이션 서비스 예제에서 도메인 로직을 추출해봅시다. 도메인 로직의 특징비즈니스적인 의미가 있는 결정을 내리는지 여부로 알 수 있습니다. 도메인 모델비즈니스에 중요한 결정을 하고 그 외의 모든 코드들은 도메인 모델이 결정을 따라 어떤 작업을 수행하거나 도메인 모델의 결정을 위한 정보를 제공해주는 것에 불과합니다.

애플리케이션 서비스 로직에서 도메인 로직 분리하기

첫번째 예시: 계좌에서 현금 인출

// Application Service
private TakeMoney(amount: number): void {
if(!this.atm.canTakeMoney) {
throw AtmHasNotEnoughMoney('인출 불가');
}
// 수수료 포함 금액
const amountWithComission = this.atm.calculateAmountWithComission(amount);
this.paymentGateway.chargePayment(amountWithComission);
this.atm.takeMoney(amount);
this.repository.save(this.atm);
}

위 방식은 애플리케이션 서비스 계층의 일부 코드입니다. 도메인 클래스 Atm이 부과할 금액을 결정하고 .canTakeMoney(), .calculateAmountWithComission()를 통해 결정을 위한 정보를 제공받습니다.

이후 애플리케이션 서비스는 그 결정을 듣고 수행하기만 합니다. paymentGatway 인스턴스를 사용하여 금액을 청구하고 데이터베이스를 업데이트합니다.

두번째 예시: 메세지 응답

private chatMessageReceived(message: string) {
const event AuctionEvent = AuctionEvent.from(message);
const command: AuctionCommand = this.auctionSniper.process(event);
if (command != AuctionCommand.None()) {
this.chat.sendMessage(command.toString());
}
}

이 메서드는 애플리케이션 서비스 계층의 일부이기도 합니다. 여기서 실질적인 도메인 객체는 AuctionSniper입니다. 앱 서비스가 하는 일외부 세계로부터 오는 메세지를 의사 결정자가 이해할 수 있는 형식(AuctionEvent)으로 변환하고 해당 메세지를 전달하고 도메인 클래스의 결정에 따라 처리하는 것입니다.

위 두가지 코드 샘플 중 어느 것도 스스로 결정을 내리지 않으며, 둘 다 도메인 모델에 위임합니다. 이것이 바로 적절한 관심사의 분리의 모습니다. 애플리케이션 서비스 계층은 상당히 많은 코드를 포함할 수 있지만 그 중 어느것도 비즈니스에 중요한 결정을 내리는 것과는 관련이 없어야합니다. 결정을 내릴 수 있는 유일한 객체는 도메인 모델입니다.

도메인 로직을 추출했다면 다음으로 해야할 작업은 다른 모든 유형의 로직이 적절히 분리되어있는지 확인하는 것입니다. (DB 로직 등)

요약

  • 도메인 로직(비즈니스 로직, 비즈니스 규칙, 도메인 지식과 동의어)은 비즈니스에 중요한 결정을 내리는 로직이다.
  • 다른 모든 유형의 논리(DB, UI, 애플리케이션 로직 등)는 도메인 모델에서 내린 결정을 처리하고, 데이터를 저장하거나, 사용자에게 보여주거나, 외부 서비스에 전달하는 작업을 처리합니다.
  • 도메인 로직을 다른 로직과 분리하는 것은 중요하며, 전반적인 코드 베이스를 더 간당하게 유지하는 데에 도움이 됩니다.

티스토리에 마진 넣는법

· 약 2분
임채성(Puleugo)
2024 팀장, 백엔드

서론

티스토리는 기본적으로 css에 포함된 마진을 무시합니다. 그래서 대부분의 블로거들이 줄바꿈을 광클떄려놔서 가독성이 떨어집니다.
본 글에서는 마진 제거 css인 tt_article_useless_p_margin 속성을 제거하려고합니다.

문제 분석

대충 네트워크 및 동작 흐름 보면 이렇습니다.

  1. 게시글 API 호출 (text/html)
  2. useLessPMargin.css API 호출
  3. tistory에서 보내줄 html과 css를 로드 완료
  4. CSSOM 마운트
  5. DOM 마운트

방법

jQeury를 사용하여 DOM 마운트 시 CSSOM을 통해 최종적으로 추가된 tt_article_useless_p_margin 클래스를 제거하였습니다.

<body>
<!-- ... 기존 블로그 코드 -->

<!-- jQuery 설치 -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- 지들 멋대로 margin 삭제해버리는 css 제거 -->
<script type="text/javascript">
$("div").removeClass("tt_article_useless_p_margin")
</script>
</body>

만개의 테스트를 작성하지 마라.

· 약 5분
임채성(Puleugo)
2024 팀장, 백엔드

서론

  • 어떤 코드가 유닛 테스트를 작성할 가치가 있는 코드인지 판별하는 방법에 대해 이야기합니다.
  • (어그로 죄송합니다.)

가치있는 테스트란?

단순히 테스트가 많이 작성되어 있는 것이 결코 좋은게 아닙니다. 여러분이 만약 프라모델을 조립한다고 가정했을 때, 핵심만 잘 정리된 설명서 와 별로 중요하지 않은 내용이 포함되어 있는 설명서 중 어떤 문서가 읽기 편하신가요? 아마도 무조건 전자일겁니다.

테스트 또한 문서이기에 핵심만 명확하게 작성되어 있는 것이 훨씬 좋습니다.

테스트의 가치란?

그렇다면 우리가 테스트의 우선순위가 떨어지는 코드에는 무엇이 있을까요? 첫번째로 너무 간단한 로직들도 있을거에요. 굳이 문서를 읽지 않아도 이해할 수 있으니까요. 두번째로는 의존객체가 많은 객체들은 테스트하기에 너무 어려울거에요. 실제 객체를 사용할 수 없어서 Test Double을 고려해야하는 경우도 있을거고 의존하는 객체를 생성할 때 너무 많은 값을 필요로해서 ObjectMother를 필요로도 할거에요.

그럼 반대로 테스트 우선순위가 높은 코드는 무엇이 있을까요? 바로 도메인 로직, 이해하기 어려운 코드, 지나치게 복잡한 코드들입니다. (도메인 로직에 대해서는 다음 글에서 설명합니다.)

이를 시각화하면 다음과 같습니다.

음.. 도메인로직, 간단한 코드, 의존객체가 많고 간단한 코드는 어떻게 테스트를 작성해야할 지 알거같아요. 그런데 복잡한 코드는 테스트할 가치가 높으면서도 테스트하기 어려운데 어떻게 해야할까요.

이 부분은 기존 설계가 잘못되어 있을 가능성이 높습니다. 아마도 테스트하고자 하는 객체가 너무 많은 책임을 가지고 있을 가능성이 높습니다. (웹 서비스에서는 하나의 서비스에 영속성 로직, 도메인 로직, 비즈니스 로직을 다 때려박아놨을 가능성이 높습니다.)

이런 경우에는 위와 같은 방식으로 도메인 모델이나 알고리즘 혹은 간단한 코드 쪽으로 이동하도록 리팩터링하는 것을 권장합니다.

결론

  • 단위 테스트 시에는 가성비를 생각해야한다.
  • 테스트의 가성비는 도메인에서의 가치와 코드의 복잡도를 보고 판단할 수 있다.

추천 글


이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

[Node.js] 트랜잭션을 활용한 테스트 격리 환경 구현하기 (1/2) | 솔루션 찾기

· 약 12분
임채성(Puleugo)
2024 팀장, 백엔드

서론

  • 트랜잭션을 통해 테스트 격리성을 가져가고 싶었습니다.
  • 단, TypeORM에는 세션을 관리해주지 않아, 하나의 테스트만 실행해도 여러개의 세션이 연결됩니다.
  • 위 제약사항에 대한 접근방식 + 해결방법을 공유하겠습니다.

트랜잭션으로 테스트 격리성을 확보한다는게 무슨말이야?

각 테스트 간의 영향을 끼치지 않도록 하는 테스트 방법입니다. 크게 두가지 전략이 있습니다.

  • 트랜잭션 전략: 테스트 시작과 끝에 트랜잭션을 시작하고 롤백하는 전략
  • 클린업 전략: 테스트 종료 후 DB의 모든 행을 제거하는 전략

둘다 처음 들어본다면 향로님의 재미있는 글이 있으니 추천드립니다. (테스트 데이터 초기화에 @Transactional 사용하는 것에 대한 생각, 향로, 2023. 11. 26.)

beforeEach(async () => {
await queryRunner.startTransaction();
});

afterEach(async () => {
await queryRunner.rollbackTransaction();
});

그렇다면 위 코드만으로 해결되지 않을까요? 테스트를 실행해봤습니다.

it(`변경된 게임을 수정한다`, async () => {
// given
await gameFactory.save(GameMother.createGame({ edited: true })); // 1️⃣ DB에 게임 삽입

// when
await service.updateEditedGame(); // ❌ DB에 변경된 게임이 없음!

// then
expect(gameFactory.findBy({ edited: true })).toHaveLength(0); // ❌ 1개 존재함!
},
);

분명히 given절에서 삽입해줬을 게임을 찾을 수 없다고 합니다.
MySQL의 트랜잭션 격리수준이 REPEATABLE READ이기 때문이라고 예상할 수 있지만, 여기서 알 수 있는 점이 한가지 더 있습니다.

트랜잭션의 삽입된 데이터를 못 읽는다는 것은 서로 세션이 다르다는 것입니다.

it(`변경된 게임을 수정한다`, async () => {
// given
await gameFactory.save(GameMother.createGame({ edited: true })); // ️ DB 세션 A

// when
await service.updateEditedGame(); // ️ DB 세션 B

// then
expect(gameFactory.findBy({ edited: true })).toHaveLength(0);
},
);

따라서 Fixture와 Service가 서로 다른 세션을 가지고 있다고 가정할 수 있습니다.

바로 검증을 위한 테스트코드를 작성해보았습니다.

it(`Service와 Fixture의 DB 세션이 동일하다`, async () => {
const factorySession = await gameFactory.query(
'SELECT * '+
'FROM information_schema.PROCESSLIST '+
'WHERE ID = CONNECTION_ID()'
); //
const serviceSession = await service.getCurrentDbSessionInfo();

expect(serviceSession).toStrictEqual.(factorySession); // ❌ 실패! 서로 다른 세션!

// 결과:
// Object {
// "COMMAND": "Query",
// "DB": "test_wtgames",
// - "HOST": "192.168.65.1:53751",
// - "ID": "35", // ️ 서비스의 세션
// + "HOST": "192.168.65.1:53752",
// + "ID": "36", // ️ 팩터리의 세션
// "INFO": "SELECT ID , USER, HOST, DB, COMMAND, TIME, STATE, INFO
// FROM information_schema.PROCESSLIST WHERE ID = CONNECTION_ID()",
// "STATE": "executing",
// "TIME": 0,
// "USER": "user",
// "EXCUTE_ENGINE": "PRIMARY",
});

가정대로 세션이 다릅니다.

그럼 이 문제를 해결하기 위해 어떻게 접근하면 좋을까요?

격리수준만 낮추면 되는거 아닐까?

(진짜 모름)

트랜잭션 내의 INSERT된 데이터를 읽기만 하면 되니까 두 트랜잭션을 모두 READ UNCOMMITTED로 변경해보겠습니다.

  • READ UNCOMMITTED: 커밋되지 않은 데이터를 읽을 수 있는 격리수준
-- TEST_DB
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -- 격리수준 변경
SELECT @@GLOBAL.transaction_isolation, @@GLOBAL.transaction_read_only; -- 격리수준 조회
-- 격리수준 조회결과
+--------------------------------+--------------------------------+
| @@GLOBAL.transaction_isolation | @@GLOBAL.transaction_read_only |
+--------------------------------+--------------------------------+
| READ-UNCOMMITTED | 0 |
+--------------------------------+--------------------------------+

READ UNCOMMITTED으로 변경한다면 삽입된 값을 읽어올 수는 있습니다. 그런데 데드락이 발생하네요.

it(`변경된 게임을 수정한다`, async () => {
// given
await gameFactory.save(GameMother.createGame({ edited: true }));

// when
await service.updateEditedGame(); // ❌ 데드락 발생! 타임 아웃 실패!

// then
expect(gameFactory.findBy({ edited: true })).toHaveLength(0);

// ❌ 결과:
// Error: Lock wait timeout exceeded; try restarting transaction
},
);

.updateEditedGame 메서드는 당연하게 update 쿼리를 사용합니다. 그러면 다음과 같은 순서로 진행됩니다.

서로가 종료되기를 기다리고 있다..

그럼 어떻게 해야할까요? 바로 하나의 세션만을 사용하여 테스트를 진행하면 됩니다.
Spring의 JPA 경우에도 @Transactional AOP가 @Transactional 어노테이션을 만날 때 기존 세션을 사용합니다. 자세한건 트랜잭션 전파를 검색해봅시다.

우선 여기까지가 제가 작성한 테스트가 실패하는 이유였으며, 이제 해결해봅시다.

어떻게 한 테스트에서 하나의 세션만을 사용할까?

TypeORM에는 DataSource라는 커넥션을 관리할 수 있는 객체가 존재합니다. 이를 사용하여 문제를 해결해봅시다.

(ConnectionManager와 관련 Hook은 deprecated되었습니다.)

export async function getDbConnection() {
return await new DataSource({
...options,
name: 'default',
}).initialize();
}

DB와 연결하는 부분에 'default' 세션을 생성하도록 했습니다. Fixture를 위한 Repository 인스턴스를 생성해야합니다.

이렇게 하면 해결될 것 같지만 해결해야할 문제가 하나 더 있습니다.

영속성 레이어가 'default' 세션을 사용하도록 해야합니다.

@Injectable()
export class GameRepo {
constructor(
@InjectRepository(GameEntity)
private readonly repo: Repository<GameEntity>, // &larr; @InjectRepository()를 통해 DI중
) {}

// ...
}

DI는 이번 이슈의 주목할 부분이 아니므로 그냥 아래처럼 해결했습니다. (이런 자잘한건 다음편에서..)

beforeAll(async () => {
queryRunner = (await getDataSource()).createQueryRunner(); // 1️⃣ 커넥션 가져오기

const module: TestingModule = await Test.createTestingModule({
imports: [
getDbModule(),
TypeOrmModule.forFeature([...]),
],
providers: [
SheetService,
{
provide: GameRepoToken,
useValue: new GameRepo(
new Repository(GameEntity, queryRunner.manager, queryRunner),
), // 2️⃣ Repository 인스턴스 생성
},
],
}).complie();

gameFactory = new Repository(GameEntity, queryRunner.manager, queryRunner); // 2️⃣ Fixture 클래스 default 커넥션
});

beforeEach(async () => {
await queryRunner.startTransaction();
})

afterEach(async () => {
await queryRunner.rollbackTransaction();
await queryRunner.release();
})

이렇게하면 하나의 테스트가 하나의 세션을 통해 처리됩니다.

테스트 결과는 다음과 같습니다.

 // ✅
it(`서비스와 팩터리의 DB 세션이 동일하다`, async () => {
const factorySession = await gameFactory.query('SELECT CONNECTION_ID()');

const serviceSession = await gameService.getCurrentDbSession();

expect(serviceSession).toStrictEqual(factorySession);
});

// ✅
it(`팩터리에서 생성한 데이터를 서비스에서 수정한다`, async () => {
await gameFactory.save(GameMother.createGame({ edited: true }));

const result = sheetService.updateEdited();

expect(result).resolves.not.toThrow();
});

성공적으로 테스트가 수행됩니다.

QueryRunner vs DataSource vs Repository

번외로 싱글 커넥션만 가질 수 있는 queryRunner라는 클래스가 존재합니다. DataSource에서 .createQueryRunner()를 사용해서 인스턴스를 얻을 수 있습니다.

 // ❌ 복수의 세션
it(`DataSource에서 얻은 쿼리러너와 팩터리의 DB 세션이 동일하다`, async () => {
const dataSource = getDataSource();
const queryRunner = dataSource.createQueryRunner();

const factorySession = await factory.query('SELECT CONNECTION_ID()');
const queryRunnerSession = await queryRunner.query('SELECT CONNECTION_ID()');

expect(queryRunnerSession).toStrictEqual(factorySession); // ❌ 다른 세션
});

// ✅ ❗ 암묵적인 이슈 존재
it(`쿼리러너와 dataSource의 세션이 동일하다`, async () => {
const dataSource = getDataSource();
const factory = dataSource.getRepository(GameEntity);

const dataSourceSession = await dataSource.query('SELECT CONNECTION_ID()');
const factorySession = await factory.query('SELECT CONNECTION_ID()');

expect(dataSourceSession).toStrictEqual(factorySession); // ⭕ 같은 세션
});


// ❌ 지맘대로 커밋
it(`팩터리가 지맘대로 커밋하지 않는다`, async () => {
const dataSource = getDataSource();
const factory = dataSource.getRepository(GameEntity)

await dataSource.query('START TRANSACTION');
await factory.save(GameMother.createGame()); // ❗ 지맘대로 트랜잭션 생성하고 커밋
await dataSource.qeury('ROLLBACK');

expect(await factory.find()).toHaveLength(0); // ❌ 이미 커밋되서 롤백안됨.
});

// ✅ Best Practice.
it(`쿼리러너를 주입한 팩터리와 쿼리러너의 DB 세션이 동일하다`, async () => {
const queryRunner = getDataSource().createQueryRunner();
const factory = new Repository(
GameEntity,
queryRunner.manager,
queryRunner, // ⭐ 쿼리러너 주입
);

const factorySession = await factory.query('SELECT CONNECTION_ID()');
const queryRunnerSession = await queryRunner.query('SELECT CONNECTION_ID()');

expect(queryRunnerSession).toStrictEqual(factorySession); // ⭕ 같은 세션
});

단, .createQueryRunner 메서드는 무조건 익명 세션을 생성하는 메서드이므로 사용하실 때 참고하셔야합니다.

3번째 테스트 '팩터리가 지맘대로 커밋하지 않는다'는 왜 실패할까요?

// ❌ 롤백 실패
it(`팩터리가 지맘대로 커밋하지 않는다`, async () => {
const dataSource = getDataSource();
const factory = dataSource.getRepository(GameEntity);

await dataSource.query('START TRANSACTION');
await factory.save(GameMother.createGame()); // ❗ 지맘대로 트랜잭션 생성하고 커밋
await dataSource.query('ROLLBACK');

expect(await factory.find()).toHaveLength(0); // ❌ 이미 커밋되서 롤백 실패
});

// ✅ Best Practice. 롤백 성공
// queryRunner 주입방식
it(`팩터리가 지맘대로 커밋하지 않는다`, async () => {
const queryRunner = dataSource.createQueryRunner();
const factory = new Repository(
GameEntity,
queryRunner.manager,
queryRunner, // ⭐ 쿼리러너 주입
);

await queryRunner.startTransaction();
await factory.save(GameMother.createGame());
await queryRunner.rollbackTransaction();

expect(await factory.find()).toHaveLength(0); // ⭕ 롤백 성공
});

// ⭕ 트랜잭션 여부를 알고있다.
it(`queryRunner가 트랜잭션 활성화 여부를 알고 있다..`, async () => {
const queryRunner = dataSource.createQueryRunner();

await queryRunner.startTransaction();

expect(queryRunner.isTransactionActive).toBeTruthy(); // 트랜잭션이 활성화 여부가 true
});

다만, 1번 방식은 repository가 트랜잭션의 활성화 여부를 모르기 때문에 repository.save 같은 몇몇 메서드에서 의도치 않은 커밋을 발생시킵니다. 자세한건 (typeorm/src/persistence /EntityPersistExecutor.ts)


여기까지가 원하는 트랜잭션만 사용해서 테스트를 수행하는 방법이었습니다.

다음편에서는 주제인 테스트 환경을 구축해보겠습니다.

인프런팀도 TypeORM 환경에서 트랜잭션 전략을 사용하고 있다고 알고 있는데, 개인적으로 관련 글을 작성해주셨으면 좋겠습니다.

[번역] Mocks Aren't Stubs | Classicist vs Mockist

· 약 40분
임채성(Puleugo)
2024 팀장, 백엔드

서론

  • Classicist와 Mockist 방식의 테스트 방식, 사고방식의 차이가 정리된 글입니다.
  • 본 글은 대표적인 Classicist TDDer인 martinfowler의 'Mocks Aren't Stubs(2007)'의 글을 기반으로 두고 있습니다.
  • 본문의 예제는 TypeScript(with Jest)의 스타일로 코드를 작성했습니다.
    원문은 Java(with JUnit)로 되어있으니 읽기 편하신 예제로 읽으시면 됩니다.

기본적인 테스트 살펴보기

  • Order(주문), WareHouse(창고) 객체를 사용하는 주문 시스템 예제입니다.
  • Order를 테스트하는 코드입니다.
  • 하지만 order.fill 메서드를 테스트하려면 WareHouse의 인스턴스를 필요로 합니다.
  • 기존 테스트 방식(Classicist)과 Mock 객체를 사용하는 테스트(Mockist) 방식의 예제가 각각 제공됩니다.

다음 예제에서 Order처럼 테스트하고자 하는 객체를 이를 테스트 대상 객체(object-under-test) 혹은 테스트 대상 시스템(SUT, system-under-test)이라고 부릅니다. (본 글에서는 SUT이라고 부르겠습니다.)

기존의 테스트(Classicist 방식)

import {beforeEach, expect, test} from "@jest/globals";
import {WareHouse} from "./wareHouse";
import {Order} from "./order";

const COCA_COLA = '코카콜라'
const warehouse = new WareHouse();

beforeEach(()=> {
warehouse.add(COCA_COLA, 10);
})

test('창고에서 재고가 충분할 시 주문을 채운다.',() => {
const order = new Order(COCA_COLA, 10);
order.fill(warehouse);
expect(order.isFilled).toBeTruthy();
expect(warehouse.get(COCA_COLA)).toBe(0);
})

test('창고에 재고가 불충분할 시 주문을 채우지 않는다.',() => {
const order = new Order(COCA_COLA, 11);
order.fill(warehouse);
expect(order.isFilled).toBeFalsy();
expect(warehouse.get(COCA_COLA)).toBe(10);
})

위 클래식 테스트코드를 Mock 객체를 사용하는 방식으로 변경하면 다음과 같습니다.

Mock을 활용한 테스트(Mockist 방식)

import {expect, jest, test} from "@jest/globals";
import {WareHouse} from "./wareHouse";
import {Order} from "./order";

const COCA_COLA = '코카콜라'

test('창고에서 재고가 충분할 시 주문을 채운다.',() => {
// given - 데이터
const order = new Order(COCA_COLA, 10);
let warehouse: WareHouse;

// given - 기대치, 추가됨
warehouse= {
pop: jest.fn((item: string, count: number) => (item === COCA_COLA && count === 10) ? 10 : 0),
get: jest.fn((item: string) => (item === COCA_COLA) ? 10 : 0),
} as unknown as WareHouse;

// when
order.fill(warehouse);

// then
expect(order.isFilled).toBeTruthy();
// expect(warehouse.get(COCA_COLA)).toBe(0); ❌ 삭제됨
})

test('창고에 재고가 불충분할 시 주문을 채우지 않는다.',() => {
const order = new Order(COCA_COLA, 11);
const warehouse= { // 추가됨
get: jest.fn().mockReturnValue(0),
} as unknown as WareHouse;


order.fill(warehouse);

expect(order.isFilled).toBeFalsy();
// expect(warehouse.get(COCA_COLA)).toBe(11); ❌ 삭제됨
})

살펴보기

SUT(= Order)의 준비단계의 검증단계는 동일합니다. 차이점은 협력자인 WareHouse 객체에 있습니다:

  1. 실제 객체처럼 동작하는 메서드를 주입받아 사용합니다. 동작은 실제 WareHouse와 동일합니다.
  2. mockist 방식에서는 wareHouse의 상태를 확인하지 않습니다.

Mockist방식의 약점은 Mock한 메서드가 변경에 취약하는 점입니다. 이를 고려하여 Mockist 예제 2번째의 기대치 처럼 인자를 받지 않는mockReturnValue 방식으로 설계하면 메서드 변경 시의 마이그레이션이 쉬워집니다.
mockImplement를 사용하지 않는 이유는 변경에 아주 취약하기 때문입니다. Order의 간단한 논리를 변경하더라도 하나의 케이스만 실패하므로 메서드 하나 변경하는데에 수천개의 테스트를 고쳐야하는 사고를 예방할 수 있습니다.

Mock vs Stub

Mock과 Stub의 차이점을 이해하려면 Mock, Stub을 일컫는 개념인 테스트더블을 이해하는 것이 중요합니다. (테스트더블을 처음들어도 괜찮습니다.)

테스트를 하는 경우에는 테스트하고자 하는 단일 요소(이후 SUT)에 집중하게 됩니다. 문제는 단위테스트를 수행하려면 SUT가 의존하는 다른 단위(이후 협력자)도 필요하다는 것입니다. 이전 예제에서는 창고 클래스였습니다. 이 "협력자"를 처리하는 방식을 바로 테스트더블이라고 부릅니다.

테스트 더블이란 테스트를 목적으로 실제 물체 대신 사용되는 모든 종류의 가짜 물체를 말합니다.(WIkipedia)
어원은 영화 제작계에서 사용되는 용어인 스턴트 장면에서 실제 배우를 대신하여 활동하는 개념인 스턴트 더블입니다. 그리고 크게 5가지의 테스트더블 개념이 존재합니다.
(본 글은 테스트더블이 본 주제가 아니기 때문에 간략하게 정리만하고 넘어갑니다.)

  • Dummy: 테스트하는 인터페이스에 필요하지만 테스트케이스에서 사용되지 않는 값.
  • Fake: 실제 객체와 동일하게 작동하지만 더 간단한 구현을 사용합니다. 예를 들어 실제 데이터베이스를 대체하는 인메모리 데이터베이스.
  • Spy: 테스트 대상으로부터 "미리 정해진 답변"을 검증하기 위해서 사용한다. 결과, 호출 정보를 기록해 두는 것으로 테스트 코드 실행 후 값을 얻어 검증할 수 있다.
  • Stub: 테스트 대상에 "미리 정해진 답변"을 제공하는 데 사용 (아직 협력자가 구현되지 않은 경우 사용)
  • Mock: 테스트 대상으로부터 "미리 정해진 답변"을 검증하기 위해서 사용합니다. 테스트코드를 실행하기 전에, 미리 기대하는 결과를 설정해 둔다. 검증은 객체 내부에서 행해진다.

테스트 더블 중에서 Mock만이 동작 검증을 고집합니다. 다른 더블은 상태 검증을 사용할 수 있고 보통 그렇게 합니다. Mock 더블은 실제로 when 단계에서 다른 더블처럼 동작하는데, SUT가 실제 협력자와 소통하고 있는 것처럼 흉내(Mock)내야하기 때문입니다. 하지만 모의 더블은 준비 단계와 검증 단계는 다릅니다.

테스트 더블을 더 이해하기 위해서 예제를 확장해보겠습니다. 많은 사람이 실제 객체가 작업하기 어려울 때만 테스트 더블을 사용하려하는 경향이 있습니다. 테스트 더블의 더 일반적인 사례는 주문을 이행하지 못했을 때 이메일을 보내는 경우입니다. 테스트할 때 마다 고객에게 테스트 이메일을 보낼 수는 없으니 이런 경우에 테스트 더블로 만들어서 이를 제어 조작하면 됩니다.

드디어 Mock과 Stub의 차이를 코드로 살펴보겠습니다. 메일링 서비스의 동작에 대한 테스트를 작성한다면 우리는 다음과 같은 간단한 Stub을 작성할 수 있습니다.

더블로 만들어야하는 협력자

export interface MailService {
send(message: Message): void
}

export class MailServiceStub implements MailService {
private messages: Message[] = [];

send(message: Message): void {
this.messages.push(message);
}

get sentCount(): number {
return this.messages.length;
}
}

이런 요구사항을 Stub과 Mock으로 표현하면 이렇게 됩니다.

Stub

// stub
test('창고에 재고가 불충분할 시 메일을 발송한다.',() => {
const order = new Order(COCA_COLA, 11);
const warehouse = new WareHouse();
const mailer = new MailServiceStub();

order.mailer = mailer;

order.fill(warehouse);

expect(order.isFilled).toBeFalsy();
expect(mailer.sentCount).toBe(1); // ✅상태 검증: 보낸 횟수가 1인가?
})

Mock

// mock
test('창고에 재고가 불충분할 시 메일을 발송한다.',() => {
const order = new Order(COCA_COLA, 11);
const warehouse= {
get: jest.fn().mockReturnValue(0),
} as unknown as WareHouse;

const mailer = {
send: jest.fn(),
} as unknown as MailService;

order.mailer = mailer;

order.fill(warehouse);

expect(order.isFilled).toBeFalsy();
expect(mailer.send).toBeCalledTimes(1); // ✅동작 검증: 1번 이상 수행했는가?
})

두 테스트케이스 모두 테스트 더블을 사용하지만, Stub은 상태 검증을 사용하는 반면 Mock은 동작 검증을 수행합니다.

Stub 방식을 통해 상태 검증을 사용하려면 검증을 위한 추가메서드를 만들어야 합니다. 그 결과 Stub은 MailService를 구현하지만 sentCount()라는 추가 메서드를 구현합니다.

Mock 객체는 무조건 동작 검증을 수행하고, Stub은 양쪽 다 가능합니다. 동작검증을 하는 Stub인 Test Spy도 존재합니다. 차이점은 '더블이 정확히 어떻게 실행되고 검증되는가'에 있습니다.

Classicist와 Mockist 테스트

이분법적으로 나누었지만 Classicist/Mockist냐보다는 '언제 어떤 더블을 사용하는가'가 핵심 문제입니다.

Classicist TDD 스타일은 가능하면 실체 객체를 사용하고 사용하기 불편한 상황이면 더블을 사용하는 스타일입니다. 지금까지 봐왔던 예제들에서 Classicist는 실제 창고와 메일서비스 스텁을 사용했습니다. 더블의 종류는 크게 중요하지 않습니다.

그러나 Mockist TDD 실무자들은 흥미로운 행동을 보이는 모든 객체에 대해 항상 Mock을 사용합니다. 이 경우에는 창고, 메일서비스 모두 Mock을 사용했습니다.


Classicist와 Mokcist의 살펴볼 요소

그래서 어쩌라고..?

지금까지 2가지 차이점을 꾸준히 언급했습니다.

  1. 상태 검증과 동작 검증
  2. Classicist와 Mockist

위 두가지 중 하나를 선택할 때 염두해놔야할 주장은 무엇일까요

상태 검증 vs 동작 검증

가장 먼저 고려해야하는 것은 맥락입니다. 실제 프로젝트에서 주문과 창고같은 쉬운 협업자도 존재하지만 우편서비스 같이 까다로운 협업자도 존재합니다.

구현하기 쉬운 협업자라면 선택은 다음과 같습니다:

  • Classicist: 실제 객체와 상태 검증을 사용하면 됩니다.
  • Mockist: Mock과 동작검증을 사용합니다.

구현하기 까다로운 협업자의 경우 선택은 다음과 같습니다:

  • Classicist: 모든 더블 중 가장 적절한 것을 골라 사용합니다.
  • Mockist:Mock과 동작검증을 사용합니다.

애초에 '상태검증이냐 동작검증이냐'는 어려운 선택지가 아닙니다. 오히려 자연스럽게 따라오는 것에 가깝습니다. 문제는 Classicist와 Mockist TDD에 있습니다. 이 선택지가 결국 상태검증과 동작검증을 선택하게 됩니다. 그 전에 Classicist에게 어려운 케이스의 협업자도 생각해봅시다.

진짜 어려운 협력자. 캐시

캐시의 중요한 점은 상태검증을 통해 캐시가 호출됐는지 상태를 통해 알 수 없다는 것입니다. 이 경우에 극단적인 Classicist라도 행동 검증을 사용하는 것이 현명한 선택입니다. 다 스타일 모두 예외의 케이스가 존재합니다.

이제 드디어 Classicist와 Mockist가 고려할 요소를 살펴봅시다.

Classicist vs Mockist

TDD에 적용되는 방식

TDD 개발자들은 설계를 중요시 합니다. 시스템의 설계는 테스트 작성을 반복하며 진화한다는 생각이 TDD 유저들의 신념이기 때문입니다. 그런데 Mockist들은 이를 특히 더 중요시합니다. Mockist Testing의 근본이 TDD의 부모격인 XP(Extreme Programming, TDD와 애자일 관련 프로그래밍 기법) 커뮤니티이기 때문입니다.

따라서 Mockist들이 특히 Mockist 테스트가 설계에 미치는 영향에 대해 중요하게 생각합니다. 특히 행위 주도 개발(BDD)이라는 스타일을 옹호합니다. 이 스타일에서는 시스템 외부에 대한 첫번째 테스트를 작성하여 사용자의 스토리를 개발하기 시작하고 일부 인터페이스는 SUT으로 만듭니다. SUT와 이웃 간 상호 작용을 찾아가며 SUT이 필요로 하는 협력자의 인터페이스를 효과적으로 설계할 수 있습니다. 이는 곧 오버엔지니어링을 예방하는 좋은 방식입니다.

첫 번째 테스트를 실행하면 각 Mockist 테스트에 대한 기대치가 다음 단계에 대한 사양과 테스트의 시작점을 제공합니다. 각 기대치를 협력자에 대한 테스트로 전환하고 이 과정을 반복합니다. 한 번에 한 SUT씩 시스템으로 작업하는 과정을 반복합니다. 이 스타일을 outside-in라고 부릅니다.(아주 명확한 네이밍입니다.) 계층화된 시스템에서 잘 작동합니다. 먼저 아래의 Mock 계층을 사용하여 UI를 프로그래밍합니다. 이후 다음 하위 계층에 대한 테스트를 작성하여 한번에 한 계층씩 시스템을 점진적으로 살펴봅니다. 이는 매우 체계적이고 관리가능한 접근 방식입니다.

Classicist는 이와 같은 지침을 제공하지 않습니다. Mock대신 Stub된 메서드를 사용하여 유사한 순서의 접근방식을 사용할 수는 있습니다. SUT이 협력자로부터 무언가 필요할 때마다 원하는 응답을 하드코딩하는 방식입니다. 이후 익숙해지면 이를 적절한 코드로 변경합니다.

위 방식이 번거로우면 middle-out(중간에서부터 시작) 방식을 사용할 수 있습니다. 이 방식에서는 특정 기능을 가져와서 이 기능이 작동하도록 도메인에서 무엇이 필요한지 결정합니다. 도메인 객체가 필요한 작업을 시작하고 동작하기 시작하면 그때부터 UI를 계층화합니다. 이렇게하면 아무것도 가짜로 만들 필요가 없을 수도 있습니다. 이 방식이 인기가 많은 이유는 먼저 도메인 모델에 집중하여 도메인 로직이 UI로 세어나가는 것을 방지하기 때문입니다.

Fixture 준비하기

Classicist에서는 SUT뿐만 아니라 SUT의 테스트에 필요한 협력자도 만들어야합니다. 이전 예시에서는 객체가 몇개 뿐이었지만 프로덕션의 테스트의 경우 더 많은 협력자가 존재할 것입니다. 이러한 협력자의 객체들은 각 테스트를 실행할 때마다 생성 및 삭제될 것입니다.

그러나 Mockist는 SUT와 협력자의 모의 테스트만 구현하면됩니다. Mockist의 경우에는 복잡한 Fixture를 만드는 데 필요한 대다수의 작업을 피할 수 있습니다. 혹시라도 Mockist 설정이 어렵다면 도구를 제대로 사용하고 있는 지 검토해볼 필요가 있습니다.

실제로 Classicist들은 복잡한 객체의 Fixture를 재사용하려고 합니다. 가장 간단한 방식은 픽스처의 설정 코드를 테스트 라이브러리의 메서드에 넣는 것입니다.(Jest의 경우 BeforeEach/All, AfterEach/All) 더 복잡한 Fixture의 경우 여러 테스트에서 필요하므로 조금 더 효율적인 Fixture 생성 클래스를 필요로 합니다. 이를 ObjectMother라고 부릅니다. 대규모 Classicist 테스트라면 Mother을 사용하는 것은 필수적이지만, Mother는 유저보수를 해야하는 추가 코드이며 Mother에 대한 모든 변경사항은 테스트 전체에 상당한 영향을 끼칩니다. 또한 Fixture를 삽입하는 데 성능 비용이 발생할 수 있지만 제대로 수행하면 심각한 문제는 발생하지 않을 것입니다. 대부분의 Fixture 객체는 제작 비용이 저렴하지만 그렇지 않은 객체는 일반적으로 더블을 사용합니다.

그 결과 두 방식 모두 상대쪽이 너무 많은 작업이라고 불평합니다. Mockist들은 Fixture를 만드는 것에 시간이 많이 소요된다고 말하지만 Classicist들은 재사용되지만 Mock이 매 테스트마다 작성해야한다며 불평합니다.

테스트 격리하기

Mockist 테스트로 시스템을 관리중일 때 버그가 생기면 버그가 발생한 SUT의 테스트만 실패합니다. 그러나 Classicist 방식을 사용하면 클라이언트 객체에 대한 모든 테스트가 실패할 수 있으며, 버그가 있는 객체가 다른 객체의 테스트에서 협력자로 사용되는 경우 실패로 이어집니다. 결과적으로 많이 사용되는 객체에서 문제가 발생하면 시스템 전체에서 테스트가 실패하게됩니다.

Mockist들은 이를 주된 문제점이라고 말합니다. 오류의 근원을 찾고 해결하기 위해 많은 디버깅이 필요하다고 말이죠. 그러나 Classicist들은 이를 문제라고 생각하지 않습니다. 일반적으로 실패하는 테스트케이스에서 공통적으로 사용되는 객체가 범인이라고 유추할 수 있으므로 어떤 실패케이스에서 개발자의 실수가 발생했는 지 알 수 있습니다. 또한 정기적으로 테스트하는 경우 마지막으로 편집한 부분의 영향으로 문제가 발생했음을 알 수 있으므로 문제를 찾는 것은 그렇게 어렵지 않습니다.

위 문제는 테스트의 세분성에 있습니다. Classic 테스트는 여러개의 실제 객체를 실행하기 때문에 종종 하나의 테스트 객체 클러스터(관련 객체들의 그룹)에 대한 기본 테스트로 사용되는 경우가 많습니다. 클러스터가 여러 객체에 거려 있는 경우 버그의 실제 소스를 찾는 것은 훨씬 더 어려울 수 있습니다. 여기서 발생하는 것은 테스트가 너무 거칠다는 것(테스트가 큰 단위로 이루어져 있음.)입니다.

Mockist 테스트는 이 문제로 인해 어려움을 겪은 가능성이 낮습니다. 관례적으로 고유 객체를 제외한 모든 객체를 Mock하는 것이기 때문에 협력자에게는 더 세분화된 테스트가 필요하다는 것이 분명하기 때문입니다. 그렇기에 지나치게 거친 테스트를 사용한다고 해서 반드시 기술로서의 Classicist 테스트가 실패한 것은 아니며 오히려 Classicist 테스트를 제대로 수행하지 못한 것입니다. 이를 해결하기 위한 좋은 방식은 모든 클래스에 대해 세분화된 테스트를 분리하는 것입니다. 클러스터가 때로는 합리적일 수도 있지만 아주 적은 객체로만 제한해야합니다. 6개를 넘으면 안됩니다. 또한 지나치게 거친 테스트로 인해 디버깅 문제가 발생하는 경우 테스트 중심 방식으로 디버깅하고 진행하면서 세분화된 테스트를 만들어야 합니다.

본질적으로 Classicist 테스트는 단위테스트이면서 동시에 미니 통합 테스트이기도 합니다. 그 결과로 많은 사람들이 클라이언트 테스트가 객체에 대한 주요 테스트에서 놓친 오류, 특히 클래스가 상호작용하는 영역의 오류를 잡을 수 있다는 부분이 장점입니다. Mockist 테스트는 이 부분의 퀄리티를 잃습니다. 또한 Mockist 테스트는 Mock의 기대치를 잘못 입력하여 테스트가 성공으로 표시되지만 내부적으로 오류를 가지고 있는 단위 테스트가 생길 위험도 있습니다.

이 시점에서 강조하는 것은 어떤 유형의 테스트를 사용하던 시스템 전체에서 작동하는 세분화된 인수 테스트와 적용해야 한다는 것입니다. 인수 테스트 늦게 적용하고 후회하는 프로젝트를 자주 봐왔습니다.

구현에 의존하는 테스트

Mockist 테스트를 작성할 때는 SUT의 아웃바운드 호출을 테스트하여 협력자와 제대로 통신하는지 확인합니다. Classicist 테스트는 최종 상태에만 관심있고 그 상태가 어떻게 만들어졌는 지는 신경쓰지 않습니다. 따라서 Mockist 방식은 비교적 메서드 구현에 더 의존하게됩니다. 협력자에 대한 호출의 특성을 변경하면 일반적으로 Mockist 테스트가 실패할 것입니다.

이러한 의존은 우려사항입니다. 중요한 것은 테스트 주도 개발(TDD)에 미치는 영향입니다. Mockist 테스트의 경우 테스트를 작성하면서 동작의 구현에 대해 생각하게 됩니다. 실제 Mockist 테스터들은 이를 장점으로 보기도 합니다. 그러나 Classicist들은 외부 인터페이스에서 발생하는 일에 대해서만 생각하고 구현에 대한 모든 고려사항은 테스트 작성을 마친 후에 생각하는 것이 옳다고 생각합니다.

구현에 대한 의존은 리팩터링을 방해하기도 하는데, 구현내용이 변경되면 구현내용이 변경되면 Mockist 테스트가 훨씬 실패할 가능성이 높기 때문입니다.

이는 Mock 라이브러리의 특성으로도 악화될 수도 있는데, 특정 Mockist 라이브러리는 특정 테스트와 관련이 없더라도 매우 구체적인 메서드 호출과 매개변수를 명시를 요구하기도 합니다. 이러한 경우 메서드 시그니처만 변경되어도 모든 테스트가 실패하게 됩니다.

설계 스타일

이러한 테스트 스타일의 가장 흥미로운 부분은 설계 스타일이 결정에 어떠한 영향을 미치는가 입니다. 두 케이스의 테스터와 이야기를 나누면서 스타일이 장려하는 설계 간 몇가지의 차이점을 알게되었지만 아직은 너무 뻔한 내용만을 이야기했습니다.

위에서 레이어를 관리에 대한 차이점을 언급했었습니다. Mockist 테스트는 외부에서 내부로 접근(outside-in) 방식을 지원하는 반면, 도메인 모델 외부 스타일을 선호하는 개발자들은 Classicist 테스트를 선호하는 경향이 있습니다.

더 작은 수준에서 Mockist 테스터는 값을 반환하는 메서드에서 벗어나 수집 객체에 작용하는 메서드를 선호하는 경향이 있습니다. Cluster에서 보고서 문자열을 만드는 동작의 예를 들어보겠습니다. 이를 수행하는 일반적인 방법은 보고 메서드가 다양한 객체에서 문자열 반환 메서드를 호출하고 결과 문자열을 임시 변수에 어셈블하는 것입니다. Mockist 테스터는 문자열 버퍼를 다양한 객체에 전달하여 다양한 문자열을 버퍼에 추가하도록 할 가능성이 높습니다. 즉, 문자열 버퍼를 매개변수로 취급할 것입니다.

Mockist 테스터는 '열차 사고'를 피하는 것을 많이 이야기합니다. 다음과 같은 메서드 체이닝 getThis().getThat().getTheOther(). 메서드 체이닝을 피하는 것은 디미터 법칙을 따르는 것으로도 알려져 있습니다. 메서드 체이닝은 냄새나지만 전달 메서드로 비대해진 중간자(Middle Men Objects)의 반대 문제도 냄새납니다.(디미터 법칙이라고 부르는 것보다 디미터 제안이라고 불려도 좋을 것이라 생각합니다.)

객체지향 설계에서 사람들이 가장 이해하기 어려운 것 중 하나는 "Tell Don't Ask" 규칙입니다. 간단히 말하면 클라이언트 코드에서 객체의 데이터를 가져온 후 그 정보를 기반으로 작업을 수행하는 것보다는 그냥 하라고 말하는 것을 권장하라는 내용입니다. Mockist들은 Mockist 테스트를 사용하면 이 규칙을 지키며 요즘 코드에 너무 많이 퍼져있는 무지성 Getter 코드를 피할 수 있다고 말합니다. Classicist들은 이러한 장점은 다른 방법에서도 많이 얻을 수 있다고 생각합니다.

상태 기반 검증의 인정된 문제점은 검증을 지원하기 위해 쿼리 메서드를 만들게 될 수 있다는 것입니다. 테스트 목적만으로 객체의 API에 메서드를 추가하는 것은 좀 불편합니다. 동작 검증을 사용하면 이 문제를 피할 수 있긴합니다. 이에 대한 반론은 이러한 수정이 실제로는 보통 규모가 그렇게 크지 않다는 것입니다.

Mockist들은 역할 인터페이스(Role Interface)를 선호하며, 이러한 스타일의 테스트를 사용하면 모듈 간 협업이 별도로 Mock되므로 역할 인터페이스로 전환될 가능성이 높기 때문에 더 많은 역할 인터페이스를 사용하기를 권장합니다. 따라서 이전 예시였던 문자열 버퍼를 사용하여 보고서를 생성하는 경우 Mockist들은 해당 도메인에서 의미가 있는 특정 역할을 구현할 가능성이 더 높으며 이는 문자열 버퍼로 구현될 수 있습니다.

대부분의 Mockist 개발자에게는 이러한 설계 스타일의 차이에서 매력을 느낀다는 점이 중요합니다. TDD의 기원은 진화적 설계을 지원하는 강력한 자동 회귀 테스트를 얻고자 하는 것이 목적이었습니다. 그 과정에서 실무자들은 테스트를 작성하는 것이 설계 프로세스를 크게 개선한다는 것을 발견했습니다. Mockist 개발자는 어떤 종류의 설계 방식이 좋은 설계 방식인지에 대한 강력한 아이디어를 가지고 있으며 주로 사람들이 이 설계 스타일을 개발할 수 있도록 돕기 위해 Mock 라이브러리를 개발하였습니다.


그렇다면 나는 Classicist가 되어야 할까, Mockist가 되어야 할까?

저는 이 질문에 자신있게 답하기 어렵다고 생각합니다. 개인적으로는 저는 항상 Classicist TDD 개발자였으며 지금까지 바꿀 이유가 보이지 않습니다. 저는 Mockist TDD에 대한 설득력 있는 이점을 보지 못하고 테스트를 구현에 결합했을 때의 결과가 걱정됩니다.

특히 Mockist 개발자를 관찰했을 때 눈에 띄였던 것은, 테스트를 작성할 때 동작의 결과에 집중하고 어떻게 하는지에 대해 집중하지 않는다는 점은 마음에 듭니다. 다만 Mockist 개발자는 기대치를 작성하기 위해 SUT가 어떻게 구현될 지 끊임없이 생각합ㄴ디ㅏ. 저는 정말 이부분이 부자연스럽다고 생각합니다.

저는 또한 실제 프로덕션에서 Mockist TDD를 시도하지 않아봤기에 제대로 모르는 것이 있을수도 있습니다. 제가 TDD에서 배웠듯이, 진지하게 시도하지 않고는 기술을 판단하기 어려운 경우가 많습니다. 저는 매우 행복하고 확신에 찬 Mockist 개발자를 많이 알고 있습니다. 그래서 저는 여전히 확신있는 Classicist지만 여러분이 스스로 결정을 내릴 수 있도록 두가지 주장을 가능한 공정하게 제시하고 싶었습니다.

따라서 Mockist 테스트가 매력적으로 느껴진다면 시도해봐도 좋다고 생각합니다. 특히 Mockist TDD가 개선하고자 하는 일부 영역에서 문제가 있는 경우 시도해 볼 가치가 있습니다. Mockist에는 두가지 장점이 있습니다.

  1. 첫번째는 테스트가 깔끔하게 끊어지지 않고 문제가 있는 곳을 알려주지 않아 실패할 때 디버깅에 많은 시간을 소비하는 경우입니다.(또한 세분화된 Cluster에서 Classicist TDD를 사용하여 이를 개선할 수 있습니다.)
  2. 두번째는 객체에 동작이 충분하지 않는 경우 Mockist가 개발팀이 동작이 풍부한 객체를 더 많이 만들도록 할 수 있습니다.

마지막 생각들

단위 테스트, 테스트 라이브러리, TDD에 대한 관심이 커지면서 점점 더 많은 사람들이 Mock을 접하고 있습니다. 많은 경우 사람들은 Mock 프레임워크에 대해 조금 배우지만, 이를 뒷받침하는 Mockist/Classicist의 구분을 완전히 이해하지 못합니다. 그 구분의 어느 쪽에 기대든 , 저는 이러한 관점의 차이를 이해하는 것이 유용하다고 생각합니다. Mockist가 아니더라도 Mock 프레임워크를 편리하게 찾을 수 있지만 소프트웨어의 많은 설계 결정을 안내하는 사고방식을 이해하는 것이 유용합니다.

이 글의 목적은 이러한 차이점을 지적하고 그 사이의 상충 관계를 설명하는 것이었습니다. Mockist의 사고방식에는 제가 설명하지 못한것보다 더 많은 것이 있습니다. 특히 설계 스타일에서 나타나는 결과입니다. 앞으로 몇년 안에 이에 대한 글이 더 많이 나오길 바라며, 그러면 코드 전에 테스트를 작성하는 것의 흥미로운 결과에 대한 이해가 깊어질 것입니다.