최근 여러 일들이 겹치고 지난번 모임이 취소되면서, 이번 개모임에 참석하기까지 어언 한 달이 훌쩍 넘어버렸다. 사실은 그 사이 개인적으로 힘든 일도 많았다.
그러면서 최근 들어 더욱 느끼는 건, 사람과 사람의 관계에서 꼭 엄청 친하지 않더라도 서로에게 가만히 이야기를 들어주는 것만으로도 큰 힘이 된다는 점이다. 나도 늘 누군가에게 그런 사람이면 좋겠고, 또 누군가가 나에게 그렇게 해주면 고마울 것 같다는 생각을 유독 많이 한 달이었다.
이 글을 읽는 분들 중에도, 혹은 주변에 저마다의 힘듦을 겪고 있는 사람들이 많을 것이라 생각한다. 다들 혼자 너무 애쓰지 말고, 서로 좀 기대면서 단단해지면 좋겠다. (그게 나여도 좋으니 힘들다면 누구든 연락해도 좋다! 🙇🏻♂️)
그래서 뭐 하면서 살았나?
어느덧 백엔드(프론트도 쪼매 함) 개발자로 현업에 뛰어든 지 8~9개월 차를 향해 간다. 돌이켜보면 참 많은 일이 있었다. 회사에 들어오자마자 리드 개발자들이 퇴사하고, 짧은 개발 지식으로 처음 마주한 데이터베이스는 난생처음 보는 수준으로 엉망이라 손댈 엄두도 못 냈다. 비교적 쉬운 푸시 알림 하나 보내는 업무에서도 3 ~ 4단계를 거치며 발생하는 휴먼 에러, 프로덕션 서버의 잦은 튕김, 서버마다 도메인 분리조차 안 된 알림 서버… 정말이지 어지러운 상황의 연속이었다.
그런 혼돈 속에서 더 이상 Strapi로는 개발 못 하겠다고 선언하고, 생전 처음 다뤄보는 NestJS를 들고 와 개발을 시작했던 때가 엊그제 같은데, 이제는 정말 그때와는 완전히 다른 개발팀이 되었다. 우리 팀만의 방향성과 정체성, 그리고 팀원들과 개발 목적에 대해 훨씬 더 깊이 고민하고 얼라인하며, 훨씬 안정적인 느낌으로 개발을 진행해 나가고 있다. 공들여온 NestJS 서버도 어엿이 진가를 발휘하고 있다.
또 마침 올해 3월부터 합류한 프론트엔드 개발자분과 현재까지 정말 열심히 달리고 있다. 다행히도 나와 그분이 추구하는 개발 방향이 놀랍도록 잘 맞고, 나름 그분이 극성(?)이셔서 합류하자마자 기존 프로덕션 코드를 전부 갈아엎는 결정을 대표님을 꼬드겨(?) 관철시켰고, 대수술을 감행했다. (정말 구조 단부터 싹 다 갈아엎었다는 말이 맞다.)
그분 성격이 또 굉장히 체계적이어서 계획 수립에도 큰 어려움이 없었고, 덕분에 예상보다 훨씬 빠르게 프론트엔드 마이그레이션 작업이 마무리되어 간다. 기존 Next.js 12 버전 기반 코드를 Next.js 14로 올리면서 상품 조회, 장바구니, 결제 등 서비스의 모든 플로우를 다시 한번 꼼꼼하게 훑어보았고, 이러면서 도메인 지식이 많이 쌓이는 느낌을 받고 있다.
일단 합류하시자마자 우리 백엔드 쪽의 요청으로 기존 레거시 Strapi의 API Call들을 전부 정리했고, 아래와 같은 방식으로 현황 파악부터 하고 들어갔다.

단순 수치적으로는 어느 정도냐면 프론트엔드의 경우 아래와 같은 집계를 하였다. (초반 파악 단계의 자료이고, 변경/신규 개발 건은 아직 업데이트 전이라 비율은 정확하지 않다. 사실 Strapi Call은 많아도 SQL과 거의 다름없어서, NestJS API 1개가 Strapi 4개 분량의 역할을 하는 경우도 많아 전체 API 개수는 오히려 줄었다.)
[3월] Next.js 12 (JSX) + Strapi(JS) / NestJS(TS) 서버 (3:7 비율)

[현재 QA 중 약(5월)] Next.js 14 (TSX) + NestJS(TS) / Strapi(JS) 서버 (7:3 비율)

단순히 폴더 구조를 앱 라우터로 바꾼 수준이 아니라, TanStack Query를 통한 캐싱 전략 도입, 미들웨어 재정의, WebP 이미지 최적화 등등 거의 새로 만들었다고 봐도 무방하다.
그러면서 나는 지금 프론트 개발자분과 거의 매일 붙어서 "이거 필요해요!", "이렇게 만들어주세요!" 라는 DTO 요구사항과 구현 방법론에 대해 논의하고, 아이디어를 내고, NestJS로 뚝딱 만들고, 로컬과 테스트 환경에서 검증하고, 개발팀이 함께 작성한 QA 시트 기준을 채워나가는 과정을 반복하고 있다. 요즘은 약간 탁구 치는 기분이랄까? '아, 이게 스타트업이지' 하는 생각이 절로 든다.
(그래도 야근이란 건 하지 않는다 ㅎㅎ… 다 잘되어서 스톡옵션 주면 할 듯?. 잘 쉬고 코어타임에 일하는 게 일을 더 효율적이게 되는 게 맞는 것 같다.)
정말 기술 좋은 이 시대에 개발자 한 명 한 명의 영향력이 무궁무진하다는 것을 느낀다.
나름 1분기 지나고 지금까지 백엔드에서 한 일들 중 주요 변경점을 정산해 볼 시간인 것 같다.
최근 기억에 남는 일들은 크게 4개 정도 뽑을 수 있을 것 같다.
사실 하나하나 전부 기술하면 글이 너무 길어질 것 같아서, 몇 가지만 조금씩 풀어보도록 하겠다.
첫 번째 내용 들어가기 전에, 이 제목을 보고 '이게 뭔 소리야?' 싶을 거다. 이건 내가 DB 테이블을 보다가 정말 충격받았던 경험 때문이다. 뭐, 테이블 개수가 많은 건 그렇다 치고, '상품 좋아요'를 나타내는 필드명이 무려 'DIB'이었다. 이 글을 읽는 분들은 'DIB'이 뭔지 알까…? 그 어렸을 때 "이거 먹을 사람 띱!" 하던, 그 '찜꽁한다'는 의미의 단어를 데이터베이스 필드(컬럼)명으로 떡하니 박아놓은 걸 발견했을 때의 그 충격은… ㅋㅋㅋㅋㅋ….
뭐, 여튼 그래서 요즘 DB 관련해서는 어떻게 개발하고 있냐고? 결론부터 말하면 '원점 회귀'다.
"열심히 DB도 마이그레이션하고 있던 거 아니었어?" 하고 의아해할 수도 있는데, 여기에는 크게 두 가지 이유가 있다.
첫째, 몇 년간 운영해 온 서비스다 보니, Strapi가 자동으로 생성한 ManyToMany 관계 때문에 데이터가 정말 수북하게 쌓여있다. 심지어 아직 몇 가지 중요한 API는 Strapi를 그대로 사용 중인데, 문제는 이 Strapi API들에 프론트엔드 라이프사이클 개념(API 호출 전후 로직 등)이 덕지덕지 붙어 있다는 거다. 더 큰 문제는, 이 숨겨진 로직들을 아직 전부 파악하지 못했고, 같은 Strapi 서버끼리는 이 복잡한 테이블 관계를 매우 간단하게 오갈 수 있다는 점이다. 만약 NestJS 쪽에서 데이터베이스 구조를 한 번 더 정제해버리면, 이 수백 개가 넘는 테이블 간의 연결고리를 전부 수동으로 다시 맞춰줘야 하는 대참사가 발생한다.
둘째, cron 관련 작업 대부분이 여전히 Strapi의 cron.js 파일에서 돌고 있고, 대부분의 일반적인 REST API는 NestJS 서버에서 돌고 있다. 즉, 같은 DB를 공유하면서 스케줄링 작업을 수행하는 시스템이 두 개나 존재한다는 의미다. (배송 완료 후 며칠 뒤 추가 적립금을 지급하는 로직 등) 물론 이 문제는 현재 GCP의 Pub/Sub 기능과 Cloud Scheduler 서비스를 이용해 해결하고 있다. 해당 로직을 REST API로 만들고 GCP에서 cron 작업을 예약하는 방식으로 바꿨는데, 이건 나중에 기회가 되면 더 자세히 설명하겠다.
여튼 이런 이유들 때문에 DB 구조를 단번에 깔끔하게 정리하지 못했고, SQL 쪽 데이터베이스는 현재 TypeORM generator 플러그인이 만들어준 raw한 상태의 엔티티를 기준으로, 도메인별로 분리만 해서 개발을 진행하고 있다.
아래 왼쪽 이미지처럼, 전부 raw한 엔티티를 가져와서 도메인별로 모듈을 분리하는 방식으로 작업했다. 정말 슬픈 현실은, 오른쪽 이미지처럼 실제 사용하는 테이블은 4개뿐인데 관계 테이블 링크만 수십 개가 엮여 있다는 거다… 더 심각한 예시도 많다. 우리 회사에 와서 뼈저리게 느낀 점인데, 제발 데이터베이스는 사람이 제대로 설계하고 만들었으면 좋겠다… 기술(특히 ORM도 아닌… 그 무언가)에 너무 의존하는 건 유지보수성을 심각하게 해치는 것 같다…


"그러면 신기능은 어떻게 개발하는데?" 라고 물을 수 있다. 여기서 우리는 구글로부터 지원받는 넉넉한 GCP 크레딧을 활용한다. 신규 기능 데이터는 GCP & MongoDB 조합으로 저장하고 있고, 점차 신기능 비중이 늘어남에 따라 자연스럽게 MongoDB로 데이터를 이전할 계획이다.(거의 MongoDB로 이전하게될 것 같은 양상이다.) 실제로 이런 방식으로 개발해서 추가된 기능들도 꽤 있다.
예전에 개모임 초반에 한번 언급했던 적이 있는데, 꽤 충격적이게도 원래 우리 프로덕트에는 리프레시 토큰(refreshToken)이라는 개념 자체가 없었다.
사실 리프레시 토큰이 없는 것 자체는 큰 문제가 아닐 수도 있다. 하지만 문제는, 내가 일전에 전해 듣기로 약 2년째 고치지 못하고 있던 마이페이지 ‘로그인 스패닝 버그’가 바로 이 단일 액세스 토큰(accessToken) 정책 때문에 발생하고 있었다는 점이다. 액세스 토큰이 만료되어 버리면 사용자에게 아무런 알림(모달 등)도 없이 서비스 곳곳에서 기능이 제대로 동작하지 않는 (예: 장바구니 수량 변경 불가) 심각한 버그가 있었다. 그나마 이 액세스 토큰의 유효 기간 자체가 꽤 길어서 어떻게든 버텨왔던 것 같다.
그래서 이번 Next.js 14와 NestJS 조합으로 마이그레이션하면서, 개발팀 내부 회의를 통해 이 토큰 로직을 '우회'해서 사용하기로 결정했다. 사실 이렇게 결정한 가장 큰 이유는 당장 모든 소셜 로그인과 로컬 로그인 로직을 Strapi에서 NestJS로 완전히 이전하고 프론트엔드와 연결하기에는 QA 및 개발 리소스가 부족했기 때문이다.
그래서 현재 레벨에서 우리가 생각하고 적용한 방법은 다음과 같다.
TypeScript1. 사용자 로그인 시도 (소셜/로컬) │ ▼ 2. Strapi 서버: 기존 로그인 로직 처리 │ └─> Strapi Access Token 발급 │ ▼ 3. 프론트엔드: Strapi Access Token 수신 │ └─> 즉시 NestJS API 호출 (Strapi Access Token 전달) │ ▼ 4. NestJS 서버: 전달받은 Strapi Access Token 검증 │ ├─> 유효하면, 우리 정책 기반의 새로운 Access Token 생성 │ └─> 우리 정책 기반의 새로운 Refresh Token 생성 │ ▼ 5. 프론트엔드: NestJS가 발급한 새로운 Access Token & Refresh Token 수신 및 저장 │ ▼ 6. 이후 모든 API 요청: NestJS 토큰 사용 (필요시 미들웨어에서 자동 갱신/팝업 처리)
결과적으로 프론트엔드 입장에서는 어떤 방식으로 로그인하든 상관없이, 항상 NestJS에서 우리 정책에 따라 생성된 표준 토큰(액세스 + 리프레시)을 사용하게 된다. 이를 통해 토큰 만료 시에도 미들웨어에서 자연스럽게 토큰을 갱신하거나 사용자에게 재로그인 유도 팝업을 띄우는 등, 훨씬 안정적인 사용자 경험을 제공할 수 있게 되었다.
이것도 예전 개모임에서 언급했듯이, 현재 스마일게이트 추천으로 인해 구글로부터 GFS(Google for Startups)라는 정책을 통해 GCP 크레딧을 꽤 많이 지원받고 있다. 마침 또 최근에 원래 사용하고 있던 MSP 업체(사실 우리 규모에 MSP를 쓸 정도인지는 모르겠는데, 대표님께 듣기로는 전에 개발자분이 인프라 지식이 너무 얕아 멘토 격으로 붙였다고 들었다.)와의 정산 이슈도 있어서, 이참에 AWS의 대부분 인스턴스를 GCP로 이전하게 되었다.
사실 이 이전 과정에 내가 많이 기여하진 않았고, 우리 든든한 백엔드 개발자 changsboi님이 대부분을 처리해 주셨다!
따라서 현재는 GCP의 기능을 꽤 활발하게 사용하고 있다.

대부분의 인스턴스는 changsboi님이 GCP로 옮겨주셨고, 나는 Cron 작업을 지금보다 좀 더 효율적이고 유지보수성 좋게 관리하기 위해 CTO 종화님의 조언에 따라 우리팀은 PUB/SUB과 Cloud Scheduler 기능을 적극 도입했다.
사실 PUB/SUB과 NestJS 서버의 연결은 이번에 내가 직접 진행하진 않아서, 추후 시간을 내어 파악하고 유지보수해야 할 것 같다. 또한 이번에 앱 푸시 알림 예약을 위해 GCP Cloud Scheduler를 써보니 굉장히 관리하기 편한 툴이라는 생각이 들었다.
단순히 REST API 엔드포인트를 등록하고 실행 주기를 설정하는 것만으로도 작동하고 (심지어 타임존 선택도 가능하다.), 덕분에 NestJS 코드 내에 @Cron 같은 데코레이터를 덕지덕지 붙이지 않아도 되니 개발 및 배포 관리가 훨씬 용이해졌다. 이 스케줄러 설정 자체도 나중에는 Google API를 통해 백오피스에서 관리할 수 있도록 만들 예정이다.
이번 GCP 이전에서 개인적으로 아쉬운 점?이라면, Route53까지는 미처 옮기지 못했다는 점이다. 또한 AWS의 S3와 CloudFront 설정처럼 GCP의 GCS와 Cloud CDN(부하 분산) 기능 설정이 아직은 좀 매끄럽지 못하게 된 것 같아, 이 부분은 계속 확인하고 개선해야 할 지점이다.
반면, HTTPS 인증서 관리는 GCP가 훨씬 편한 것 같다. GCP에서 발급하고 관리하니 인증서가 구글 이름으로 뜨고, 갱신이나 서버 확장 시에도 훨씬 안정적이라는 느낌을 받았다.

이 부분은 프론트엔드 마이그레이션을 진행하면서 다시금 수면 위로 떠올랐다. 가뜩이나 이미지 업로드 정책이 제대로 정해지지 않아 이미지 로딩이 느린 판국에, 특정 상품의 GIF 파일 로딩 속도가 너무 느리다는 문제가 제기되었다.
물론 지금 Next.js에서 이미지를 띄울 때 자체적으로 최적화를 하긴 하지만, 원본 파일 자체가 크면 한계가 명확했다. 특히 우리 서비스에서 꽤 인기 있는 '홍시궁' 상품 이미지가 GIF여서 더욱 신경 쓰일 수밖에 없었다. (예전에 코딩 유튜버 겸 CTO 제로초님도 사드시고 공유했던 바로 그 상품이다.)
게다가 어쩌다 보니 S3 설정을 날려먹어서(...) 이번에 AWS CloudFront 설정을 다시 하면서 오히려 훨씬 나아진 cloudfront CDN 캐싱을 적용했음에도, 원본 GIF 파일 크기가 14MB에 달하다 보니 로딩 속도는 여전히 답답했다.
따라서 아직 모든 상품에 적용하진 못했지만, GCP의 GCS와 NestJS 서버를 활용하여 이미지 처리 파이프라인을 개선했다. 사용자가 이미지를 업로드하면 NestJS 서버가 이를 받아 GCS 버킷으로 직접 올리면서 WebP 규격으로 압축하고, 변환된 이미지의 CDN URL을 생성하여 DB에 저장(기존 URL 덮어쓰기 포함)하는 로직을 구현했다.
TypeScript// Service: 이미지 변환 및 GCS 업로드 로직 (핵심 단순화) async uploadImageAndConvertToWebp(file: Express.Multer.File, uploadDto: UploadImageDto): Promise<ValidationDto> { const quality = uploadDto.quality || 75; // 기본 품질 75 const originalFileName = file.originalname; // 1. 고유 파일 이름 생성 (해시 + 타임스탬프) const hash = crypto.createHash('sha256').update(originalFileName).digest('hex'); const webpFileName = `${hash}_${Date.now()}.webp`; const gcsPath = `image/product/${webpFileName}`; // GCS 저장 경로 // 임시 파일 경로 const tempWebpPath = path.join(os.tmpdir(), `${crypto.randomBytes(16).toString('hex')}.webp`); try { // 2. Sharp 라이브러리로 WebP 변환 (파일 버퍼 직접 사용) await sharp(file.buffer) .webp({ quality: quality, effort: 4 }) // effort는 압축 속도/품질 트레이드오프 .toFile(tempWebpPath); // 임시 파일로 저장 const stats = await fs.promises.stat(tempWebpPath); const webpFileSize = stats.size; // 3. GCS에 WebP 파일 업로드 await this.storage.bucket(this.bucketName).upload(tempWebpPath, { destination: gcsPath, metadata: { // 원본 파일명 등 메타데이터 추가 가능 metadata: { originalFileName: originalFileName, ... }, }, }); const cdnUrl = `${this.cdnBaseUrl}/${gcsPath}`; // 최종 CDN URL // 4. DB에 파일 정보 저장 (URL, 파일명, 크기 등) - 트랜잭션 사용 // const savedFile = await this.saveFileInfoToDb(webpFileName, cdnUrl, webpFileSize, ...); // 임시 파일 삭제 await fs.promises.unlink(tempWebpPath); return { success: true, data: { url: cdnUrl, fileId: savedFile.id, ... } }; } catch (error) { // 오류 처리 및 임시 파일 정리 if (fs.existsSync(tempWebpPath)) await fs.promises.unlink(tempWebpPath); this.logger.error(`WebP 변환/업로드 실패: ${error.message}`, error.stack); throw new BadRequestException(`이미지 처리 실패: ${error.message}`); } }
결과는 일단 테스트 단계에서 상당히 만족스러웠다. 원래 14MB였던 '홍시궁' GIF 이미지가 NestJS 서버를 통해 WebP로 변환 및 캐싱된 후 3MB 정도로 줄었고, 프론트엔드에서 Next.js 이미지 최적화까지 거치니 최종적으로 2.8MB 정도로 확인되었다. 따라서 이번 배포 이후에는 점진적으로 모든 이미지를 WebP로 변환하고 캐싱하는 방향으로 나아갈 예정이며, 이 변환 기능은 백오피스에도 추가될 것 같다.
정신없이 달려온 지난 달이었다. 새로운 동료와 함께 프로덕트를 밑바닥부터 다시 만들어가는 과정은 분명 쉽지 않았다. DB 구조의 한계, 레거시 시스템과의 씨름, 인프라 이전의 어려움 등 해결해야 할 과제는 여전히 산더미처럼 쌓여있다.
하지만 동시에, 이 과정 속에서 정말 많은 것을 배우고 있다는 것을 느낀다. 특히 프론트엔드 개발자분과의 긴밀한 협업은 시너지 효과를 내며 개발 속도를 높이고, 결과물의 완성도를 끌어올리는 데 큰 도움이 되었다.
사실 '천공 뚫기'라는 거창한 부제목을 붙였지만, 이제서야 겨우 첫 삽을 뜬 기분이다. NestJS와 Next.js 14로 천공을 뚫었으니, 이제 그 내용물들로 단단하게 굳힐 시간인 것 같다. 앞으로도 수많은 기술적 난관과 예상치 못한 문제들이 기다리고 있겠지만, '완벽함'보다는 '작동하는 것'에, 그리고 '보편적인 것'에 집중하며 한 걸음씩 나아가려 한다. 다음 개모임에서는 또 어떤 새로운 이야기들을 풀어놓을 수 있을지, 열심히 또 정진해보겠다.