본문으로 건너뛰기

[AdminJS 에러] Cannot find module '@tiptap/pm/state'

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

Github 이슈에 해답이 없어서 작성합니다.

해결방법

6.8.4 이상의 버전을 사용하면 됩니다.
해결 커밋(버전 반영은 이후에 있습니다.)

CJS 환경을 사용하시는 경우에는 package.json에 아래 코드를 추가해주세요.

    "resolutions": {
"@tiptap/core": "2.0.3",
"@tiptap/pm": "2.0.3",
"@tiptap/starter-kit": "2.0.3"
},

[NestJS] AdminJS 프로덕션 배포하기

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

무엇이 문제인가?

(잘 알고 있으시겠지만) TypeScript로 작성된 파일들을 JavaScript로 컴파일하여 배포해야합니다. 보통 dist 디렉터리에 모아서 배포합니다.

하지만 AdminJS는 빌드해주는 명령어도 공식문서에 없습니다. 개발 환경에서는 생각못했다가 배포할 때 겪는 문제입니다.

어떻게 해야 하는가?

1. 번들링하기

당연하게도 AdminJS 페이지에 해당하는 TypeScript 파일들을 빌드해주면 됩니다. 문서에 안 나와있지만 adminjs에서 지원하는 bundler가 있습니다.

tsconfig.json > compilerOptions.module의 값이

저는 프로젝트가 commonjs이기 때문에 2.0.0 버전을 예시로 듭니다. 큰 차이는 없으니 메서드의 jsDoc을 참고하여 사용하시면 됩니다.
2.0.0 버전의 경우엔 아래와 같이 사용합니다.

//   src/admin/component/index.ts
import { ComponentLoader } from 'adminjs';

export const componentLoader = new ComponentLoader();
export const components = {
NotEditableInput: componentLoader.add('NotEditableInput','./NotEditableInput',),
};


// src/bundler.ts
import { bundle } from '@adminjs/bundler';
import { join } from 'path';

void (async () => {
await bundle({
// yarn run build 시 compoent들이 전부 초기화되는 파일 경로
customComponentsInitializationFilePath: 'src/admin/component/index.ts',
// 초기화된 compoent들을 번들링하여 결과물을 저장할 Directory 경로
destinationDir: 'dist/public',
});
})();

package.json > scripts를 수정

yarn run bundle 시 src/bundler.ts 파일에서 destinationDir로 설정한 위치에 번들링 결과물이 저장됩니다.

2. 번들링 적용하기

AdminJS는 Client Side Rendering 입니다. 때문에 번들링 파일들의 외부 접근을 제공해줘야만 합니다.
이후 번들링 결과물을 정적의 형태로 제공해줘야 합니다. 저는 Vercel에서 Serverless를 기능을 활용하고 있으므로 vercel.json을 아래같이 수정하겠습니다.

{
"version": 2,
"builds": [
{
"src": "dist/main.js",
"use": "@vercel/node",
"config": {
"includeFiles": ["dist/**/*"]
}
},
{
"src": "dist/public/**/*",
"use": "@vercel/static",
"config": {
"outputDirectory": "dist/public"
}
}
],
"routes": [
{ "src": "/public/(.*)", "dest": "/dist/public/$1", "methods": ["GET"] },
{ "src": "/(.*)", "dest": "/dist/main.js" }
]
}

Nest.js만 사용하고 있으시다면 ServeStaticModule을 사용하시면 됩니다.

3. 번들링 파일 불러오기

@Module({
imports: [
AdminJsModule.createAdminAsync({
useFactory: () => ({
adminJsOptions: {
rootPath: '/admin',
assetsCDN: 'https://serverless-adminjs.vercel.app/public/', // 마지막에 /를 꼭 붙여야함
}
}),
}),
],
})
export class AdminModule implements OnModuleInit {
async onModuleInit() {
if (process.env.NODE_ENV === 'development') {
await adminjs.watch();
}
}
}

이렇게 수행하면 끝.

코드 예제

[GitHub - puleugo/nestjs-adminjs-serverless: NestJS 환경에서 AdminJS를 사용하는 예제 코드입니다.

NestJS 환경에서 AdminJS를 사용하는 예제 코드입니다. Contribute to puleugo/nestjs-adminjs-serverless development by creating an account on GitHub.

github.com](https://github.com/puleugo/nestjs-adminjs-serverless)


그 외 이슈:

Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons

AdminJS 라이브러리가 사용하는 React 버전과 설치한 React의 버전이 동일하지 않아서 생기는 경우가 많습니다.
yarn list --depth=1 명령어를 입력해서 AdminJS의 React 버전을 조회해 버전을 통일해줍시다.

서버리스 환경에서 배포 실패

서버리스는 근본적으로 서비스를 불변성으로 관리합니다. AdminJS는 NODE_ENV사전 번들링 여부와 상관없이 항상 임시 파일(./adminjs)에 번들링을 수행합니다.
프로덕션 환경 변수에 ADMIN_JS_SKIP_BUNDLE=true를 추가해주면 문제없이 배포됩니다.

EMS 버전을 사용해도 되나요?

AdminJS 또한 프론트엔드에서의 사용을 지원하기 떄문에 7.0.0 버전 이후로 EMS을 지원합니다. 하지만 Nest.js 같은 Node.js 계열 서버 라이브러리는 아직까지 CJS만을 지원하므로 버전업은 권장하지 않습니다.

@tiptap/pm/state을 찾을 수 없대요..

별도로 작성했스빈다. 이 글을 읽어주세요.

Redis는 항상 옳은가? (Redis vs Another Plans)

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

No Silver Bullet

도입

최근 동아리원분과 캐싱 기능의 구현방식에 대해 이야기를 나누다 Redis에 대한 이야기가 나와서 정리해봅니다.
'Redis 도입이 항상 정답인가?'가 주제였고, 저는 Redis를 서비스 운영 초기부터 도입하는 것은 항상 정답은 아니며 MSA를 도입, 서비스를 복수의 인스턴스로 운영할 때나 의미가 있다. 작은 서비스의 경우, 애플리케이션의 Dictionary 자료구조를 사용하는 것도 좋은 선택지다. 라는 의견입니다.

둘다 취준생이라 누가 옳은지는 그 자리에서 알 수 없었지만 Redis가 정확히 무엇인지를 조사해보고자 합니다.


Redis란 무엇인가?

단순한 Cache Server입니다. 이름도 Remote Dictionary Server취준하면 싹다 Redis를 사용해보라고 하는데 Redis의 대 척점은 없는지, 제목 그대로 항상 Redis는 정답일까요?

먼저, Redis(2011년도) 이전에는 Redis 같은 서비스가 없었을까요?
아뇨. MemCached(2003)가 존재했으며 MemCached 이전에는 MySQL Table을 Cache처럼 사용하기도 했습니다. 아래와 같은 한계들로 인해 Redis가 주류가 되었습니다.

  • MemCached: 가볍지만 큰 서비스에서 사용하기에는 애매하다.
    • Value의 Max Size(1mb): 단점은 아님, 가볍게 사용하는 용도로는 적합.
    • Replication 기능 부재
    • 다양한 데이터 타입의 부재
  • MySQL: 구현하기는 편하지만 느리며 서비스가 커지면 큰 트러블 슈팅 문제 발생.
    • 스케일 이슈: 수평적 확장(분산 방식)에서 문제 발생함.
    • (비교적)느린 속도

Redis와 비교했을 떄 위와 같은 단점이 있습니다. 현실적으로 MySQL은 좋은 선택지가 아니지만 MemCached와 Dictionary 자류구조와 비교를 해보겠습니다.

Redis vs MemCached

둘 다 원격 캐시 서버입니다. 둘을 비교하면 다음과 같습니다.
중요한 내용은 밑줄쳐두었습니다.

기준RedisMemCached
설계 철학데이터 저장소, 고급 캐싱 솔루션빠르고 단순한 캐싱 솔루션
속도빠름Redis보다 더 빠름
데이터 구조 지원다양한 자료구조 지원(Array, Set, Hash, Sorted Set etc.)Key-Value(String) Only
데이터 지속성File 옵션 제공- RDB(Redis Database File): 주기적 데이터 스냅샷 저장- AOF(Append-Only FIle): 쓰기연산을 기록하여 손실 가능성 X휘발생 메모리 Only
메모리 관리압축, LRU, TTLLRU Only
확장성Redis Cluster 기능 지원-
기타Pub/Sub, Transaction etc-

그렇습니다. 이 두 선택지는 케바케입니다.

서비스 확장성과 데이터 영속성의 필요에 따라 갈리는 선택지:

  • 단순히 캐싱만 사용할 계획이다. → MemCached
  • 확장성과 데이터 영속성, 분산 캐시 환경이 필요하다. → Redis

MemCached vs Dictionary 자료구조 기반

간단히 비교해보겠습니다.

기준MemCachedDictionary
데이터 공유여러 서버 인스턴스 간 공유여러 서버의 캐시 공유 불가능
서비스 규모분산 서버, 대규모 트래픽의 적합단일 서버, 소규모 애플리케이션
속도네트워크 지연 가능성 존재, 다만 빠름네트워크 지연 없음, 로컬 메모리로 매우 빠름

확장 가능성에 따라 갈리는 선택지:

  • 단순 로컬 캐시가 필요한 경우 → Dictionary
  • 데이터 공유와 분산 캐시 환경이 필요한 경우 → MemCached

결론

우선, 모든 상황까지는 아니여도 대부분의 경우 Redis가 정답입니다. 서비스 규모가 커질수록 안정성, 성능을 함께 잡을 수 있는 선택지가 Redis입니다.

하지만, 첫 문장처럼 '은총알은 없기'때문에주어진 상황에 따라 적절히 선택하는 것이 올바를 것 같습니다.작은 규모의 서비스라면 MemCached나 Dictinary 방식도 고려해 볼 만한 선택지입니다.

기준DictionaryMemCachedRedisMySQL
속도 순위123
자료구조 지원OXO
분산 지원XOX
File 저장 지원XXOO

상호 존중하는 PR 만들기

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

본 게시글은 주인공들의 이야기, 이한결님과의 인터뷰 내용을 참고하여 작성했습니다.
https://youtu.be/CQj797uQw1U?si=PmCScDRERUUNVmSI

Full Video

도입

최근 팀원에게 아래와 같은 코멘트를 받았습니다.

하나의 PR에 코드가 너무 많아요.
다음에는 조금 작은 단위로 PR을 만들어주세요.

이한결님과의 인터뷰에는 아래와 같은 답이 있었습니다.

  • 가독성 좋은 PR을 만드는 방법
  • 가독성 좋은 Commit을 만드는 방법

무엇이 상호 존중하는 PR인가?

상호 존중하는 PR은 읽기 좋은 PR이며 리뷰어 입장에서 "이거 바로 Approve해도 되겠는데?"라는 말이 나오는 것이 가장 좋습니다.
읽기 좋은 PR은 글쓰기를 생각하면 됩니다. 가독성 좋은 글은 다음과 같이 구성되어있습니다:

  • 각 문단에는 하나의 주제만 설명한다.
  • 각 문장에는 하나의 내용만 설명한다.

PR에 적용해보면 PR에는 하나의 주제만을 Commit에는 하나의 내용만으로 구성하는 것이 가독성을 향상하는 간단한 방법입니다.

구체적으로 좋은 PR을 만드는 방법

1. 업무에 Check Point를 먼저 정하기

해결해야할 업무를 수행하기 전 미리 작업을 분리할 수 있는 단위로 쪼갭니다. 한결님의 예시:

  1. 요구사항 확실하게 하기: 기획자 혹은 팀원과 커뮤니케이션을 통해 요구사항을 명확히 함.
  2. 대략적인 단계 및 세부사항 작성:
    • 세부적으로 모호한 부분 제거.
    • e.g. Refactor와 Feature는 반드시 별도의 단계로 분리되어야 함.
  3. 코딩: 위 문서화를 기반으로 작업하면 팀원이 이해하기 쉬운 PR이 자연스럽게 만들어짐.

너무 큰 작업인 경우 'Stacked PR'이라는 방법을 활용할 수 있습니다.

2. Stacked PR 활용하기

Stacked PR은 하나의 작업을 여러개의 PR을 활용하여 작업을 쪼개 수행하는 방법을 말합니다.
'Stacked'이라는 명칭에 맞게 하나의 큰 PR 내부에 작은 PR을 만든 후 LIFO 순서로 PR이 Merge하는 특징을 가지고 있습니다.

만 번 설명하는 것보다는 보는 게 나을 것 같습니다:

Stacked PR 예시

PR이 어떻게 열리고 닫히는지 참고해주세요.
Main PR에서 작업 내용이 이해하기 쉽도록 핵심 내용만 보여줍니다. 세부적인 내용은 내부 PR 안에서 작업하면 좋습니다.

PR #1 "feat/movie-list-query-search" // Main PR
- Commit "feat: add basic movie list query with pagination"
- Commit "test: add unit tests for basic query functionality"
* Open PR #2 "feat/search-by-director-title-actor"
- Commit "feat: implement search by director name, movie title, and actor ID"
- Commit "test: add tests for search functionality"
- Commit "refactor: optimize search query structure"
* Merged PR #2 into #1

* Open PR #3 "feat/filter-by-category"
- Commit "feat: implement category filter with 'All Categories' option"
- Commit "chore: add category filter validation logic"
- Commit "test: add tests for category filter feature"
* Open PR #4 "refactor/category-filter-query-details" // Detailed PR for #3
- Commit "refactor: move category filter logic into reusable service"
- Commit "test: update unit tests for refactored category filter"
* Merged PR #4 into #3
* Merged PR #3 into #1

* Open PR #5 "feat/sort-by-latest-and-viewers"
- Commit "feat: add sort by latest release date and viewer count"
- Commit "chore: add validation for sort parameters"
- Commit "test: add tests for sorting functionality"
* Merged PR #5 into #1

* Open PR #6 "feat/additional-response-fields"
- Commit "feat: include additional fields (movie ID, category, title, director, price, viewers, created date) in response"
- Commit "test: validate response fields in integration tests"
* Merged PR #6 into #1

3. 작은 Code Change를 유지하기

한결님은 이를 약 400-500줄 정도로 유지하려고 하십니다. 1000줄 이하라면 큰 문제는 없습니다.
테스트코드는 필수적입니다. 한결님이 작성하신 500줄의 코드 변경은 아래와 같이 구성됩니다.

  • 100줄(20%): 기능 변경
  • 400줄(80%): (최대한 많은) 테스트 코드

이러면 외부 클래스/함수 의존성 때문에 테스트가 실패하지 않나요?

(영상에서는 간략하게 언급하고 지나갔지만) 기능없는 빈 메서드를 만들고, '이후 기능이 구현되었을 때 이러한 모습이겠지.' 를 생각하고 테스트를 작성합니다.

상호 존중하지 않는 PR은 Moloco 수석 개발자도 어려워한다.

1,000줄의 코드를 한결님에게 보낸다면 한결님은 다음과 같이 말씀하신다고 합니다.

나는 너의 코드를 보고 문제없다고 말할 자신이 없다..
너무 많은 Change를 한번에 넣었기 때문에.

Unit Test가 이미 많이 작성되어 있다면 이를 쪼개서 보내달라고 부탁합니다.

팀을 위한 좋은 습관

  • 장주영: "상대의 시간을 존중하는 좋은 습관같다."
  • 이한결: "그것도 맞지만, 이는 상호 존중이다. 내가 상대를 존중했을 때 이 존중이 나에게 돌아올 확률이 크다."

마치며

주인공들의 이야기는 학생 개발자로서 배워가기 좋은 채널이다. 누구나 잘하고 싶은 욕구가 있지만 경험없이 노력만으로는 잘하기 힘든 것들이 있다. 프로 개발자들에게 이러한 경험을 배워갈 수 있다는 것 자체가 축복받은 사회다.

이미지 로드 속도 향상하기

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

개요

문제Waktaverse.games 사이트의 이미지 로딩 속도가 느려 사용자 경험에 부정적 영향을 미치고 있었습니다.특히 네트워크가 느린 환경에서는 LCP(Largest Contentful Paint) 시간이 권장사항인 2.5를 초과하여, Fast 4G 환경에서는 4.88초, Slow 4G 환경에서는 28.54초가 소요됐습니다.
해결방안이미지 로딩 성능을 개선하기 위해 Cloudflare를 활용하여 다음과 같은 조치를 취했습니다. WebP 형식으로 압축된 이미지 캐시를 응답했으며 페이지 새로고침 시 서버로 재요청하는 문제를 해결하기 위해 Cache-Control 헤더를 추가했습니다.개선 결과:- Fast 4G 환경: 5.88초 → 2.39초 (약 59.35% 개선)- Slow 4G 환경: 28.54초 → 8.24초 (약 71.13% 개선)
  • waktaverse.games 웹 사이트의 이미지 로드 성능 개선을 수행했다.

너무 느려요. 개선해주세요.

상혁이가 Waktaverse 이미지 로드 속도가 느리다고 문의메일을 보냈다.

동아리 친구에서 이미지 성능개선 작업 해보고싶다고 말하니까, 내가 속한 팀에 메일을 보내줬다.

어느정도로 느린가?

Fast 4G: 5.88 s, Slow 4G: 28.54 s

Chrome Browser의 Performance 기능을 활용하여 성능을 측정해보았다. 네트워크/메모리 성능을 제한하여 측정해볼 수 있으므로 성능 개선 필요 여부를 확인하는데 추천하는 방법이다.

LCP(가장 큰 콘텐츠 페인트) 소요 시간을 측정했다.

  • Fast 4G: 5.88s
  • Slow 4G: 28.54s

참고로 2.5초 이하가 GOOD이다.

해결하기

저희 팀은 Cloudflare CDN을 사용하고 있습니다. 사용하시는 CDN이 다르다면 아래 내용 중 무엇이 왜, 필요한 지만 참고해주시기 바랍니다.

1. 큰 이미지는 압축합시다.

흔히 사용하는 포맷은 png, jpg가 있지만, 웹 성능 향상을 위해 jpg보다 더 효율적인 압축 형식이 있습니다. 주로 WebP, AVIF가 있습니다.

Cloudflare에서 동일 이미지 URL에 대한 원본 이미지에 대한 압축본을 응답해주는 Cloudflare Polish 기능이 존재합니다.

[Cloudflare Polish | Cloudflare Images docs

Cloudflare Polish is a one-click image optimization product that automatically optimizes images in your site. Polish strips metadata from images and reduces image size through lossy or lossless compression to accelerate the speed of image downloads.

developers.cloudflare.com](https://developers.cloudflare.com/images/polish/)

2. 한번 받아온 이미지는 캐싱합시다. Cache-Control

현재 페이지를 새로고침할 경우 이미 로드한 이미지를 다시 불러오는 문제가 존재합니다. 이때 HTTP 응답 헤더 Cache-Control을 사용할 수 있습니다.

Cache-Control은 이미 수신한 리소스의 유효 시간이 지나기 전이라면, 브라우저가 서버로 새로운 요청을 보내지 않고 캐시로부터 리소스를 읽어와서 사용합니다.

리소스가 남아있기에 캐시로부터 리소스를 가져옴.

개선결과

Fast 4G: 2.39 s, Slow 4G: 8.24 s

  • Fast 4G: 5.88s → 2.39s (59.35%)
  • Slow 4G: 28.54s → 8.24s (71.13%)

'어떻게 해야겠다'는 명확했습니다.
Cloudflare가 이렇게 편한줄 알았더라면 훨씬 더 빠르게 작업에 들어갈걸 그랬습니다.

가장 후회하는 블로그 커스터마이징

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

금년 4월 즈음에 다크모드를 대응하여 이미지 색상을 반전하는 기능을 구현하였었는데요.

[css를 활용한 다크모드 이미지 자동 대응

소개다음 영상을 보시면 무슨 말인지 쉽게 이해할 수 있습니다.아이디어https://github.com/joonas-yoon/boj-extended?tab=readme-ov-file GitHub - joonas-yoon/boj-extended: 백준 온라인 저지(BOJ)를 확장된 기능과 함께

ko.puleugo.dev](https://ko.puleugo.dev/190)

다크모드를 굉장히 좋아하는 사람 중 하나로써 제 블로그는 다크모드에 최적화된 환경으로 만들고 싶었습니다. 이는 현재 가장 후회하는 블로그 커스터마이징입니다.

재앙의 시작

뭔가 틀렸다는 것을 느낀 것은 동아리 사이트에 블로그 탭을 구현할 때 였습니다. 크롤링한 게시글의 이미지가 White Mode에서 볼 수 없는 문제가 발생했습니다. 색상 반전이 되지 않아, 이미지도 하얗고 배경도 하야니까요.
이는 곧 콘텐츠가 블로그의 css에 의존하게 되는 기이한 현상이 발생합니다.

이는 계왕권을 사용하여 다른 플랫폼에 글을 배포하게 되도 동일한 문제가 발생합니다.
아래는 Medium 플랫폼에 배포한 이미지입니다.

Medium에 동일한 글을 배포하면 이미지가 안보이는 현상 발생

"뭐.. 다크모드로 보면 되겠네."라고 생각하셨겠다면 Medium은 White Mode만 제공합니다. 우측은 Dark Mode Reader라는 구글 확장프로그램으로 CSS를 다크모드처럼 변경한 화면입니다.

정상적인 방법으로는 이미지를 볼 수 없습니다..

안하느니 못했나?

세상에 그런일이 어딨습니다. 이게 다 경험이니까요.

White Mode 기반으로 색반전 CSS를 변경하고 과도하게 꺠지는 이미지를 변경해야겠습니다.

[계왕권 출시] 당신의 블로그 가치를 44배 향상시켜주는 서비스

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

당신의 게시글을 가치를 44배 향상시켜주는 서비스

프로젝트 소개

계왕권은 자동화 및 게시글 번역 배포 서비스입니다. 대표적 선진국 9개국의 인구수는 한국의 약 44배이므로, 단순 계산으로 당신의 블로그는 44배 이상의 영향력을 얻을 수 있습니다.

1500 조회수

왜 개발하게 되었는가?

저는 프로그래밍을 시작한 이후부터 국내 시니어 개발자들의 경험을 얻기 위해 강연, 스터디를 참여하고자 노력했습니다. 그분들의 공통된 조언이자 후회는 프로그래밍에 쏟은 노력을 외국어 학습에 쏟았다면 더 많은 기회를 얻을 수 있었을 것이라는 것이었습니다.
구글, 페이스북 같은 IT 기업의 헤드헌터에게 연락이 오더라도 영어능력의 부재로 인해 기회를 포기하는 경우도 있었으며, 본인들의 역량을 그들에게 전달하지 못하는 것이 가장 큰 아쉬움이었습니다.
외국어 공부를 대신해주는 것은 아니지만 비슷한 기회를 쉽게 창출할 수 있는 프로젝트입니다.

'계왕권'은 선배들에게 받은 조언 통해
노력을 배로 향상시켜주는 프로젝트입니다.

번역 결과물 미리보기

비교해보기
원글 | 번역글

수상할 정도로 높은 번역 퀄리티

퀄리티 높은 번역 게시글

공식 문서

[소개 | Kaio-ken Docs

Last updated 8 minutes ago

kaio-ken.gitbook.io](https://kaio-ken.gitbook.io/kaio-ken-docs)


QnA

무료로 사용할 수 있나요?

네, Github Action 통해서 사용하실 수 있습니다.

돈이 조금이라도 들 수 있나요?

네, ChatGPT API를 통해 번역하기 때문에 API 이용비가 발생할 수 있습니다.

이 서비스를 쓰면 조회수를 제외하여 구체적으로 어떤 이익이 있을 수 있을까요?

게시글 하단에 게시글 주제에 관련된 프로모션 링크를 삽입하는 방식으로 수익창출을 하려고합니다.
현재 기획으로는 아마존, 이베이, 알리 익스프레스, 클릭뱅크, 애플 어필리에이트, 쿠팡 파트너스가 있습니다. 금전적인 이익보다도 본인의 프로젝트나 PR이 해외에도 노출될 수 있는 것이 가장 큰 메리트입니다.

저도 기여할 수 있나요?

환영합니다. 현재 영어 블로그(Medium)밖에 지원이 안되므로 일어, 중국어, 인도어 등 여러 블로그의 전략패턴의 코드를 작성하는 것을 권장드립니다.

https://github.com/puleugo/kaio-ken

[GitHub - puleugo/kaio-ken: Automated Translation Development Post Distribution Application

Automated Translation Development Post Distribution Application - puleugo/kaio-ken

github.com](https://github.com/puleugo/kaio-ken)

[계왕권 프로젝트] 베타버전 개발기

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

길고 험난했던 베타버전 출시

일일 1389 커밋

꽤나 막혔던 프로젝트였습니다. 새로운 프로젝트를 하는게 오랜만인지라 너무 추상적인 계획만 세우고 작업을 들어가서 구체화 과정에서 멀리 돌아간 작업들이 굉장히 많네요. 대표적인 것들만 정리해보겠습니다.

처음 생각했던 번역 게시글 업로드 후 본문을 수정하여 JS Injection 방식을 사용한 Link 방식은 문제가 많았습니다.

우선, 게시글 본문을 수정해야 하는 문제가 있습니다. 대부분의 블로그 플랫폼(Medium, Dev.to, Qiita, Tistory)의 API는 게시글 수정 기능을 지원하지 않으며 수정기능을 지원한다고 하더라도 JS Injection을 막아둔 경우가 대부분이었습니다.
Tistory의 API를 분석하여 Reverse Engineering을 통해 HTTP 통신만을 활용하여 게시글 수정을 구현하긴하였다만. 이는 너무 난이도가 높았습니다. 사실 Medium이 GraphQL 방식으로 통신하는 것을 보고 포기했습니다.

이방식은 포기하기 다른방식을 찾아봤습니다.

다행히도 Sitemap을 통해서도 Link가 가능하다.

구글에게 페이지의 번역본에 대해 알려주기」를 읽어보면 다른 방식들도 있습니다.

  1. HTML tag에 첨부. (실패)
  2. HTTP Header에 첨부. (플랫폼의 응답을 조작하는 방법이 떠오르지 않아 패스)
  3. Sitemap에 명시하기. ← 이 친구에 대해 알아봅시다.

Sitemap는 구글 크롤러에게 사이트의 페이지, 영상, 기타 파일에 대한 관계를 알려주기 위해 제공하는 파일입니다. 예를 들어 아래와 같은 정보를 제공할 수 있습니다:

  • 번역본 링크
  • 게시글의 마지막 수정일, 제목, 우선순위

굉장히 재미있는 파일입니다. Tistory에서도 제공합니다.
https://puleugo.tistory.com/sitemap

그럼 문제를 정의하면 '티스토리의 사이트 맵을 어떻게 수정하냐?' 일까요? 잠깐.. 아니죠. 문제는 '구글에게 제공할 내 블로그의 Sitemap을 작성하는 방법입니다.'
문제를 다시 정의하니 '개인 도메인을 발급하여 블로그와 연결하는 방법을 떠올렸습니다.' 개인도메인이라면 Sitemap을 마음대로 수정할 수 있을 것 같았거든요. 이는 정답이었습니다.

https://www.puleugo.dev/sitemap.xml

Vercel을 활용하여 Github Repository에 업로드된 sitemap.xml을 도메인에 연결하였습니다. 이러면 Free + Serverless + File System으로 작업 가능해졌네요! (5분정도면 작업가능합니다.)

2. 너무너무 많은 외부의존성

본 프로젝트는 프로젝트 규모 대비 외부의존성이 굉장히 많습니다. 간단히 나열해보자면:

  • Spread Sheet: 블로그에 대한 정보 입력
  • Github: 원 본⋅번역 게시글, 무결성 검증을 위한 Metadata 파일, Sitemap 파일
  • Github Action: 공짜 실행환경
  • ChatGPT: 영어 잘하는 형
  • Vercel: 무료 Sitemap 발사대
  • Blog Platforms:
    • Tistory: 구글 검색의 GOAT
    • Medium: 영어권 개발 플랫폼 GOAT

다시말해 테스트하기 굉장히 빡셉니다요. Github는 널널하기로 유명해서 그냥 Stub 안 만들고 작업했어요. Medium은 하루 사용량 초과로 429 던지는거 보니 Test Double이 시급하겠네요. 베타배포 후에는 테스트코드로 프로젝트를 조금 더 견고하게 만들어봐야겠습니다.

3. 늘 후회하지만 습관화는 안되는 것들 (고쳐야 할 약점)

  1. 코드짜기전에 시뮬레이션 더 많이 돌려보기
  2. 새로접하는 작업하는 것들은 방법 다양하게 찾아보고 가장 효율적인 것 생각하기.
    • Tistory RE한건 너무 무식한 방식이었다고 생각합니다요. (Reverse Engineering인지도 모르겠음.)
  3. 끝까지 테스트 작성하기.
    • 테스트 작성하기 힘들다면 책임분리가 잘못된 것이라는 것을 또다시 느꼈습니다.

마치며

내일 중에 베타버전 출시하겠습니다. 베타버전 배포가 끝나면 본격적인 취준을 시작해야겠네요..ㅋㅋ

Megabrain 동아리 블로그 탭 제작기

· 약 11분
서상혁(Singhic)
2025 팀장장, 백엔드

안녕하세요, 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일이나 걸림)

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

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

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

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

개요

문제저희 팀은 Spreadsheet를 어드민 페이지로 활용하여 데이터를 관리하고 있습니다. 서비스 초기에는 빠른 콘텐츠 수집과 서비스 개발이 가능했지만, 서비스 규모가 커짐에 따라 관리하는 도메인과 필드 수가 증가하여 동기화 메서드가 복잡해졌습니다. 테스트 코드의 부재로 수정 시 부담이 너무 커졌습니다.
해결방안테스트 환경을 구축하고 기존 코드를 분석하여 테스트하기 쉬운 코드로 리팩터링했습니다.테스트라는 개념을 학습하기 위해 Test Double, Domain Model에 대한 게시글을 번역하고 정리하며 학습하였습니다. 테스트 후 문제없이 실서버에 배포되었습니다.

발표 영상


소개

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

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

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

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

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

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

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

괴물 메서드의 문제점

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

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

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

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

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

리팩터링하기

리팩터링하기 위해서는 기능의 요구사항을 쪼개서 이러한 순서의 사이클을 반복합니다.

  1. 분석하기
  2. 테스트코드 작성하기
  3. 리팩터링하기

분석하기

가장 중요한 부분입니다. 메서드가 어떻게 동작하는지, 어떤 부분에서 문제가 발생하는지, 이 기능이 이 메서드에 있는 것이 적절한 코드인지, 또한 기존 코드의 동작 기능인지 아니면 버그인지 분류해야합니다. 이 부분에서 중요한 것은 팀원의 코드를 분석하되 맹목적으로 믿지 않아야 합니다. 코드 내에서 버그를 발생 시킬 수 있을 것 같은 코드는 작업자분에게 여쭤보는 습관이 중요합니다.

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

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

테스트코드 작성하기

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

테스트코드란 정말 간단합니다. 기능이 의도대로 동작하는 지 검사해주는 역할을 합니다.

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

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

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

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

그리고 처음 테스트코드를 작성하는 경우에 private 메서드를 테스트하려고 하는 경우가 있는데, 이는 옳지 않는 방식입니다. 제어 가능한 영역을 추가하거나 함수를 분리하는 방식을 고려해봅시다.

리팩터링하기

잘 분리되었다면, 서비스 레이어는 간단한 코드가 되고 비즈니스의 복잡한 부분은 도메인 모델에게 할당되게 됩니다.

// 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); // 저장한다.
}

간단히 요약하면 다음과 같이 역할이 분리됩니다:

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

이제 리팩터링을 수행해보겠습니다.

리팩터링 적용하기

1. 도메인 모델로 분리하기

저는 총 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); // 구글 시트에 동기화

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

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

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() // ❌ 영속 레이어(UserRepository)에 작성하세요.
.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️⃣ DB에 동기화
const deletedCount = await this.gameRepo.deleteExcludeBy({ids: gameRows.ids});
// 2️⃣ SpreadSheet에 데이터 동기화
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) 개선하기

BULK 처리

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

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

가정

  • 총 조회할 영상의 수: 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%의 속도 개선