(111)

uWSGI buffer-size와 502, 서버가 죽었나?

서론여느 때처럼 평화롭게 개발을 하던 어느날, 프로덕트의 dev 환경에서 502가 계속해서 발생했습니다. 지표도 정상이고 헬스 체크도 정상이고, 도대체 무슨 문제인지 긴가민가 했습니다. 로그를 확인 해 본 결과 처음보는 낯선 에러가 발생하더군요 요약하자면 Python WSGI 애플리케이션 서버인 uWSGI의 buffer-size 설정 값을 초과했기 때문이었습니다. 찾아보니 이 buffer-size는 요청 헤더의 사이즈의 설정 값이라고 하더군요. Express를 사용했을 때를 떠올려보면 발생하지 않았던 문제였습니다. 요청 헤더가 커서 요청이 Drop된 적이 없었기 때문입니다.이에 관련된 CS적인 지식은 기초 수준의 간단한 것입니다만, Django 진영의 웹 서버는 왜 이런 방식을 선택했는지 궁금해서, 기록..

주니어 개발자의 Nest + BullMQ 기반 실시간 채팅의 성능/구조 개선기

내가 어떤 조직에 속하게 되었을 때, 조직에서 관리하는 애플리케이션을 한 번씩 사용자 관점에서 돌아보고, 개발자 관점에서 돌아보고 문제점을 리스트업하는 습관이 있다. 이를 통해 당장의 애플리케이션에 대한 이해를 넘어서, 어느 정도의 주인의식과 우선적으로 해결해야하는 과제는 무엇인지 선정하는 연습(?)을 같이 하고 있다. 이 포스팅은, 속했던 조직에서 가장 먼저 개선해야한다고 판단했던 실시간 채팅 기능의 개선기이며, 2년차인 현재 시점에서 더 개선할 부분은 없었는지가 첨가된 포스팅이다. 모자란 내용에 혹여 더 좋은 의견 남겨주시면 성장에 큰 도움이 됩니다. 감사합니다! 문제 파악하기속했던 조직은, 커머스 비스무리한(?) 서비스를 운영하고 있었지만, 도메인 특성상 결제는 곧 예약이었다.결제 후 오프라인으..

소나큐브(Sonarqube) 도입하기

배경최근 몇 차례의 코드 리뷰 경험들로 공유하는 문화가 얼마나 큰 이펙트가 있는지 경험할 수 있었다. 하지만 현재 조직에서는 홀로 서버 개발을 진행하다보니 내가 작성하고 있는 코드가 과연 좋은 방향으로 나아가고 있는 것인지 혹은 구조적이나 가독성 등 좋은 코드를, 올바른 설계를 하고 있는 것일까? 라는 의구심이 생겼다. 또한, 여러 번 기술 면접을 다녀왔는데 많은 조직에서 내부적으로 소나 큐브를 정적 코드 분석 도구로 활용하고 있었다.소나큐브를 통해 받을 수 있는 코드 리뷰를 통해 코드 품질 향상과 더불어, 코드 일관성 등의 컨벤션도 정립하고자 도입하게 되었다.   설치 Installing from Docker | SonarQube DocsExplains how to install the SonarQub..

의존성 제어 2 - HTTP 클라이언트 의존성 낮추기

[NestJS] 의존성 제어 1 - 레이어간 의존성 낮추기(Layered Architecture)최근 인프랩 면접에서 얻게 된 코드 리뷰를 통해 현업에서 사용하고 있는 코드들에 대해 되짚어 보고 있다. 값진 경험을 통해 메타인지를 할 수 있게 되었고, 오답이라고 생각하는 부분에 대한 mag1c.tistory.com 1편의 연장선으로, 실제 기술 면접의 코드 리뷰에서 mysql2에 의존적인 코드를 어떻게 변화에 유연하게 만들것이냐는 질문을 받았다. 레이어간 역할을 명확하게 하는데 그치지 않고 데이터베이스에 엑세스하는 레이어를 두 단계로 나눠서, 레포지토리 레이어는 단순 쿼리 실행 레이어로 인프라 레이어를 데이터베이스와 연결하는 레이어로 두어 인프라 레이어만 교체하게끔 할 수 있다고 생각했다. ORM, DB..

Docker에서 PM2로 전환하여 배포 시간 단축하기

전환 이유레퍼런스들을 찾아보면, 대부분이 PM2를 통해 간편하게 인스턴스를 띄워 사용하다가, 컨테이너 단위로 전환하고 노드 환경에서는 추가로 pm2-runtime을 도커 컨테이너 내부에서 적용하는 경우를 많이 봤던 것 같다. 나는 반대로, 현재 운영중인 서비스를 계속해서 개발하면서 겪은 문제들을 개선하기 위해 배포 파이프라인을 Git Actions - Docker에서 Git Actions - PM2로 전환하였다. 1. 긴 배포 시간 (길면 3~5분, 평균 1분 후반대)2. 간헐적인 서비스 장애 탐지의 위치 이동 서버와 백엔드 코드를 혼자 관리하다 보니 빈번하게 코드를 업데이트해야 한다. 테스트 같은 안전 장치의 통과를 위해 소요되는 시간은 언제나 수긍하지만, Docker 프로세싱의 긴 소요 시간은 점차 ..

의존성 제어 1 - 레이어간 의존성 낮추기

최근 인프랩 면접에서 얻게 된 코드 리뷰를 통해 현업에서 사용하고 있는 코드들에 대해 되짚어 보고 있다. 값진 경험을 통해 메타인지를 할 수 있게 되었고, 오답이라고 생각하는 부분에 대한 리팩토링을 진행하고 있다. 특히 이번에는 면접 당시에 서비스 레이어와 인프라(DB) 레이어 사이의 의존성에 대해 피드백을 들었던 부분을 바탕으로 실제 코드를 개선하고 그 과정에서 꼬리에 꼬리를 무는 의문들을 정리해보는 시간을 가지고자 한다.  import { Injectable } from '@nestjs/common';import { InjectRepository } from '@nestjs/typeorm';import { Repository } from 'typeorm';import { User } from './u..

복합 인덱스로 쿼리 튜닝하기

[데이터베이스] 인덱스(index) 정리인덱스 목차, 색인, 책갈피와 같은 기능을 하는 인덱스는, 데이터베이스 분야에서는 어떤 데이터를 검색할 때 속도를 높여주는 자료 구조로메모리 영역에 생성되는 일종의 책갈피이다. mag1c.tistory.com 위 글의 복합 인덱스를 통한 튜닝 부분을 따로 옮긴 포스팅입니다. 분명 일정한 기준이 있을 텐데 왜 얘기들이 조금씩 다른 것일까? DB의 버전 때문일까 옵티마이저가 무조건적으로 100% 맞다는 보장이 없어서일까? 잘 모르겠다. 그래서 직접 쿼리 튜닝의 경험들을 복기하며 복합 인덱스를 생성할 때에는 어떤 순서로 인덱스를 구성해야 하는지 알아보았다. 서비스 내부에는 모든 유저의 장바구니(앞으로 견적함이라 부름)를 볼 수 있는 기능이 존재하는데 업종으로 필터링할 경..

[사이드 프로젝트] 운동 기록앱 "오운완" 제작기 - 1

어느덧 30이 되어가는 나이에, 2년 가까이 공부를 핑계로 몸관리에 소홀해졌다.어디가서나 얘기하는 사소한 것도 기록하는 습관이 장점이에요!!! 는 살다보니 기록하지 않으면 수행하지 않는다. 가 되어버렸다.2년 내내 헬스장에 등록은 되어 있었으나, 관장님들이 가장 좋아하는 스타일(?)인 유령회원이 되어버렸다. 1달에 1번 갈까말까.. 내가 직접 만든 기록 서비스를 사용하면 더 열심히 운동할 것만 같았고직접 헬스장에서 운동을 하면서 기록을 하기 위해 모바일 앱으로 가닥을 잡고 작업을 시작하게 되었다. 3월 중순부터 1달 반 정도의 제작 기간을 거쳐 작업했고, 현재는 어떤 이슈들 때문에 릴리즈 전 단계에서 테스트중인나의 첫 애플리케이션에 대해 간단하게 기록하고 나아갈 방향에 대해 서술해보고자 한다. 더보기  ..

웹뷰로 기존 NextJS를 모바일 앱으로 배포하기

초년차 백엔드 개발자의 사이드 프로젝트 앱 배포 경험기입니다.(기술적 서술이 없습니다.) NextJS로 구성된 웹앱을 React Native + Expo를 사용해 안드로이드 앱으로 전환하고 배포하는 과정의 기록입니다.(기본 도메인 세팅이 완료되었고, Vercel로 배포해놓은 상태입니다.)    개발서버기존 NextJS로 구성된 프론트를 React Native로 EXPO를 통해 테스트용 APK를 만들어보기로했음.  expo-cli는 deprecated되었고, node 17v 이상과 호환성 문제가 발생할 수 있다고 합니다.프로젝트 내 자체 cli를 설치하여 사용하는 걸 권장한다고 합니다.expo build명령어도 eas build로 대체되었다고 하네요# expo cli 설치npm install expo-cl..

uWSGI buffer-size와 502, 서버가 죽었나?

삽질/트러블슈팅 2026. 4. 2. 23:05
728x90
728x90

서론

여느 때처럼 평화롭게 개발을 하던 어느날, 프로덕트의 dev 환경에서 502가 계속해서 발생했습니다. 지표도 정상이고 헬스 체크도 정상이고, 도대체 무슨 문제인지 긴가민가 했습니다. 로그를 확인 해 본 결과 처음보는 낯선 에러가 발생하더군요

 

요약하자면 Python WSGI 애플리케이션 서버인 uWSGI의 buffer-size 설정 값을 초과했기 때문이었습니다. 찾아보니 이 buffer-size는 요청 헤더의 사이즈의 설정 값이라고 하더군요.

 

Express를 사용했을 때를 떠올려보면 발생하지 않았던 문제였습니다. 요청 헤더가 커서 요청이 Drop된 적이 없었기 때문입니다.

이에 관련된 CS적인 지식은 기초 수준의 간단한 것입니다만, Django 진영의 웹 서버는 왜 이런 방식을 선택했는지 궁금해서, 기록 겸 포스팅을 남깁니다.

 

 

 

원인

위에서 정리했듯이, 원인은 HTTP 요청 헤더의 사이즈 초과입니다. 

 

 

uWSGI 관련 공식 문서를 보면, 버퍼 사이즈는 매우 작게 설정되어있으며, 로그에서 이상 현상이 감지된다면 더 큰 사이즈로 변경이 필요할 수도 있다고 언급되어 있습니다. 말은 쉽지만 실제 환경에서는 충분히 문제가 발생할 수 있고 저 또한 겪었습니다. 핫픽스로 배포를 나가도 물려있는 CICD 때문에 10여분 넘게 다운타임이 발생할 수 있는 것이죠 (실제로는 서버 다운은 아니고 UX적인 다운타임이겠습니다.)

 

정확한 원인을 추적하기 위해 좀 더 깊게 파악해보겠습니다.

 

일반적인 Python 웹 서비스는 요청이 리버스 프록시(nginx)를 통해 uWSGI를 거쳐 Django로 들어오는 흐름입니다. 리버스 프록시는 클라이언트로부터 요청을 받아 uWSGI로 패킷을 전달 할 때 uwsgi 바이너리 프로토콜로 변환해서 보내게 됩니다.

 

 

# nginx 설정
location / {
  uwsgi_pass unix:///tmp/uwsgi.sock;
  include    uwsgi_params;
}


uwsgi_pass를 쓰면 nginx가 HTTP 헤더들을 uwsgi 프로토콜의 key-value 쌍으로 변환하고, 이걸 하나의 uwsgi 패킷으로 직렬화해서 전송합니다. uwsgi 프로토콜 스펙 자체가 하나의 패킷을 전달 받아야만 하는 스펙으로 설계가 되어있기 때문입니다. uwsgi 프로토콜은 패킷 헤더에 datasize라는 변수를 16비트 정수로 담습니다.

struct uwsgi_packet_header {
  uint8_t modifier1;
  uint16_t datasize;
  uint8_t modifier2;
};

 

 

패킷 헤더의 datasize가 뒤따르는 전체 데이터 크기를 나타내는 구조라서, 요청 메타데이터를 여러 패킷으로 나눠 보내는 개념이 없습니다. 그래서 수신 측인 uWSGI도 이 패킷을 고정 버퍼에 통째로 받는 구조가 됩니다.

 

즉, 버퍼 사이즈를 초과하는 요청이 들어오면 uWSGI는 Django에 요청을 넘기지 않고 Drop하게 됩니다. 서버가 죽은 것이 아니라 요청이 앱에 도달하기 전에 버려지게 되어 헬스 체크는 200인데 UX는 다운된 것처럼 보였던 것입니다.

 

 

 

 

Express

이전에 썻던 Node의 Express는 파싱 방식과 기본값에서 차이가 있었습니다.

Node의 HTTP 파서인 llhttp는 헤더를 고정 버퍼에 담지 않습니다. 들어오는 데이터를 바이트 단위로 스트리밍하면서, 누적 크기만 카운터로 추적합니다.

 

nodejs의 http parser

 

고정 버퍼에 통째로 담는 uWSGI와 달리 파싱하면서 카운터만 올리는 방식입니다. 그리고 결정적으로 기본 제한값이 16KB로 크기 때문에 대부분의 상황에서 발견하지 못했습니다. 더불어 uwsgi처럼 바이너리 프로토콜 변환 없이 raw HTTP를 직접 파싱하기 때문에 프로토콜 크기의 제약도 없습니다.

 

즉, Express를 사용했을 때는 같은 요청이여도 서버가 받아들이는 구조가 다르기 때문에 한 번도 겪어본 적이 없었습니다.

 

 

 

 

해결

원인을 알았으니 해결책은 명확하겠죠..?

 

근본 원인인 헤더가 커진 이유를 조사하기 시작했습니다. 짐작하시겠지만, 프로덕트가 커져가면서 구워지는(?) 쿠키도 많아졌습니다. 구체적으로는 A/B 테스트나 지표 추적을 위해 이벤트를 심는 양이 늘어남에 따라 쿠키가 같이 늘어났습니다. 디버깅 결과 전체 쿠키의 70% 이상이 해당 지표 추적을 위한 쿠키였고, 계속해서 늘어나고 있었습니다. 브라우저가 요청마다 이 쿠키를 전부 실어 보내는 구조였기 때문에 점점 헤더 사이즈가 커지면서 결국 터져버렸던 것입니다.

 

저희는 일단 버퍼 사이즈를 늘리는 것으로 선조치를 했습니다. 하지만 쿠키가 계속 늘어난다면 또 터질테니 근본 해결책은 아니겠죠.

 

 

 

 

CloudFront Origin Request Policy에서 쿠키를 필터링하거나 로컬 스토리지로 쿠키를 마이그레이션, 쿠키 도메인을 분리하는 등의 방안을 고려중에 있습니다. 각각 하나씩 간단히 살펴보면서 마무리하겠습니다.

 

CloudFront가 오리진(nginx/uWSGI)에 요청을 전달할 때 필요한 쿠키만 화이트리스트로 전달하도록 설정이 가능합니다.

브라우저는 쿠키를 다 보내지만 CF가 걸러줄 수 있도록 설정이 가능하다고 합니다. 프론트 코드 변경 없이 인프라 설정만으로 적용이 가능하지만, CF를 통하지 않고 직접 접근할 때는 효과가 없습니다.

 

서버에 보낼 필요가 없는 데이터를 쿠키에서 로컬 스토리지로 마이그레이션하는 방법도 있습니다.

이 방법은 서버가 읽어야 하는 쿠키는 이동이 불가능하고, 프론트 코드 변경이 필요할 수도 있습니다.

 

마지막으로 쿠키 도메인을 분리하는 방법입니다.

.example.com에 쿠키가 설정되면 모든 서브도메인에 전송됩니다. 이걸 api.example.com으로 분리하고 쿠키 도메인을 서브도메인 별로 격리하면, API 요청에는 해당 서브도메인 쿠키만 실리게 되겠죠. 가장 구조적인 해결이 될 것이라 생각하는데, 서드파티 스크립트인 GA나 이벤트 도구 등이 최상위 도메인에 쿠키를 설정해야한다면, 통제가 어려울 수도 있겠습니다. (아직 확인해보진 않았습니다.)

 

 

 

 

마무리

오랜만에, 정말 오랜만에 AI에서 벗어나 실제 문제를 좀 깊게 파볼 수 있는 시간이었습니다. 

 

 

 

 

Ref

https://peps.python.org/pep-3333/

https://uwsgi-docs.readthedocs.io/en/latest/Protocol.html

https://uwsgi-docs.readthedocs.io/en/latest

https://github.com/nodejs/llhttp

https://github.com/nodejs/node/blob/main/src/node_http_parser.cc#L1023-L1030

https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/controlling-origin-requests.html

https://datatracker.ietf.org/doc/html/rfc6265#section-5.4

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

주니어 개발자의 Nest + BullMQ 기반 실시간 채팅의 성능/구조 개선기

삽질/트러블슈팅 2025. 5. 14. 16:38
728x90
728x90

 

내가 어떤 조직에 속하게 되었을 때, 조직에서 관리하는 애플리케이션을 한 번씩 사용자 관점에서 돌아보고, 개발자 관점에서 돌아보고 문제점을 리스트업하는 습관이 있다. 이를 통해 당장의 애플리케이션에 대한 이해를 넘어서, 어느 정도의 주인의식과 우선적으로 해결해야하는 과제는 무엇인지 선정하는 연습(?)을 같이 하고 있다.

 

이 포스팅은, 속했던 조직에서 가장 먼저 개선해야한다고 판단했던 실시간 채팅 기능의 개선기이며, 2년차인 현재 시점에서 더 개선할 부분은 없었는지가 첨가된 포스팅이다.

 

모자란 내용에 혹여 더 좋은 의견 남겨주시면 성장에 큰 도움이 됩니다. 감사합니다!

 

 

 

문제 파악하기

속했던 조직은, 커머스 비스무리한(?) 서비스를 운영하고 있었지만, 도메인 특성상 결제는 곧 예약이었다.

결제 후 오프라인으로 상품을 직접 소비(?)하는 특징과 더불어 상품들이 우리가 자주 소비하는 필수 소비재들의 성격이 아닌, 특정 니즈에 따라 대부분 일회성으로 구매하는 상품들이기 때문에 결제 전/후로 채팅을 통한 상담이 서비스의 코어였다.

 

이런 핵심 기능인 채팅에서 응답 속도가 평균 3초 정도로 매우 느리게 동작했고, 이는 시간을 갈아넣어서라도 반드시 해결해야하는 최우선 과제라고 판단했다. 

 

최초에 파악했던, 채팅 전반의 플로우를 그림으로 나타내보았다. 메시지를 전송하면, 기본적인 메시지 관련 데이터베이스 I/O 작업과 더불어 메시지 전송에 처리되어야 할 모든 기능들이 함께 동기적으로 처리 되고 있었다. 근본적으로 응답 속도가 느릴 수 밖에 없는 구조였다.

 

 

 

더불어 아이러니하게도 이 실시간 채팅을 포함해 애플리케이션 내부에서 MyISAM 엔진을 사용하고 있었다. 동시성 제어를 위해 테이블 락 매커니즘을 사용하는 MyISAM의 특성상 쓰기 작업이 느릴 수 밖에 없었다. 여기저기 쓰기 작업을 하게 되는데, 메시지가 많아지면 많아질 수록 여러 테이블에서 서로 쓰기 작업을 위해 기다리는 현상이 기하급수적으로 늘어날 수 밖에 없다.

 

 

효과적인 테스트와 구현을 위해, 당시 최대 TPS를 산정해서 예상 최대 지점까지 고려했다면 어땟을까?

여기까지 생각이 미치지 않았다보니, 워커의 처리량보다 큐에 작업 유입량이 많을 때 어떻게 대처할지 등의 비동기 작업의 안전성을 보장하지 못했다고 생각한다.

 

 

 

 

 

 

비동기 처리를 위해

스토리지 엔진의 한계 외에도 단순 하나의 로직에 이리저리 얽혀있는 여러 비즈니스 로직들을 살펴보고, 메시지 전송 과정에서 반드시 수행되어야 할 로직과 아닌 로직들을 분리 했다. 메시지 전송이 성공했다. 라는 의미는 메시지를 저장하는 chat_message 테이블에만 입력을 보장하면 된다고 판단했고, 나머지 로직들을 전부 분리했다.

 

 

 

이 분리한 로직들을 다시 네 개의 구간들로 나눴고, 원자성을 보장해야하는 구간을 추가 DB 입력 구간인 추가 I/O와 업무 알림으로, 실패해도 괜찮다고 판단되는 부분들을 푸시알림과 SMS전송으로 구분했다.

 

메시지 전송 로직과 분리하여 비동기 처리를 수행하기 위해 BullMQ라는 메시지 큐를 사용했다. Kafka나 RabbitMQ 등도 Nest의 공식 문서에 UseCase등을 문서화해뒀는데 사용하지 않았다. 이들을 사용하기에는 발톱의 때 만큼의(?) TPS였다. 또 Nest에서 기본적인 Queue 사용문서 에 친절하게 언급되어있는 BullMQ의 실패 시 재시도와 스케줄링과의 연동, 이벤트 기반 처리와 이벤트 리스너를 통한 통합 로깅 등을 구현하기에 용이했기 때문에 BullMQ를 사용했다.

 

@Processor('chat-message-side-effect')
export class ChatMessageSideEffectWorker extends WorkerHost {
  constructor(
    private readonly chatService: ChatService,
    private readonly notificationService: NotificationService,
    private readonly crmService: CrmService,
    private readonly fcmService: FcmService,
  ) {
    super();
  }
  async process(
    job: Job<{ jobId: string; payload: { userId: number; message: string } }>,
  ): Promise<void> {
  
    // 메시지 전송 추가작업
    await this.chatService.processAfterMessage(job.data.payload);
    
    // 업무 알림
    await this.notificationService.sendNotification(job.data.payload.userId);

    // 고객 알림 (SMS, FCM)
    await Promise.all([
      this.crmService.sendCRM(job.data.payload.userId),
      this.fcmService.sendFCM(job.data.payload.userId),
    ]);
  }
}

 

 

처리량보다 작업량이 많아지는 상황을 고려했다면..?

TPS를 산정했을 때, 최대 TPS는 1.55였다. 단일 워커에서 DB I/O와 외부 API의 연동 작업들의 평균 latency가 1초대라고 가정하더라도 큐에는 작업이 계속 쌓이게된다. 위에서 언급한 것 처럼, 이러한 상황들을 먼저 가정하고 접근했더라면 워커의 concurrency를 늘리는 등의 동시 처리 방법까지 자연스레 고려할 수 있었을 것 같다.

 

 

 

 

원자성 보장을 위해

메시지의 추가 I/O는 단일 테이블의 insert라고 하더라도, 업무 알림은 여러개의 테이블을 insert/update하는 연속적인 과정이다.

MyISAM은 항상 auto-commit한 쓰기 작업을 보장한다. 롤백을 무시하며 트랜잭션을 지원하는 엔진이 아니기 때문에, 이런 연속적인 과정에서 트랜잭션을 보장하기 위해서는 소위 transaction-like한 무언가를 직접 구현해야했다.

 

type InsertRecord = {
  table: string;
  id: number;
  deleteFn: (id: number) => Promise<void>;
};

class JobTransactionContext {
  private inserts: InsertRecord[] = [];

  recordInsert(record: InsertRecord) {
    this.inserts.push(record);
  }

  async rollback() {
    for (const record of this.inserts.reverse()) {
      await record.deleteFn(record.id);
    }
  }
}

 

이를 해결하기 위해 위처럼 트랜잭션 컨텍스트 객체를 사용해서, 트랜잭션을 보장해야하는 로직에 활용하게 되었다.

 

 

 

 

실패 후속 처리

워커에서 작업을 실패할 경우 원자성을 보장해야하는 경우는 실패로 간주되지만, 위에서 말했던 것 처럼 실패했을 경우에도 운영 상에 지장이 없다고 판단했던 SMS 발송과 푸시 알림은 실패로 간주되지 않는다.

 

위 정책에 따라 분리하여 트랜잭션이 롤백되는 상황에서만 실패로 간주하여 작업을 종료시키고, 실패 시 재시도 전략을 수립했다.

재시도는 BullMQ에서 기본으로 구현되어있는 지수 백오프(Exponential Backoff)를 사용했다. 

모든 재시도에 지수 백오프만 사용할 경우 모든 실패한 작업들이 동시에 백오프 될 경우도 고려해야한다.
실패한 작업에 대해 재시도를 분산하기 위해 사용하는 전략임에도 재시도 과정에서 다시 요청이 몰리는 것은 똑같다.
이를 해결하기 위해 AWS에서는 지연 변이(Jitter)라는 개념의 추가 전략을 통해 일정의 랜덤 시간을 추가로 부여하여 재시도의 동시성을 분산했다고 한다.
(자세한 내용은 AWS의 공식 포스팅1 / 포스팅2 를 참조)

 

 

 

 

 

추가 개선

최근에 이 내용들을 복기하면서 추가로 고려하지 못했던 사항들이 무엇이었는지, 내가 1년 반 전과 비교해서 어디까지 고려하는 개발자가 되어있었는지 확인해보고 싶었다. 위에 잠깐 언급했던 트러블 슈팅을 위한 TPS 산정을 포함해서 정리한 피드백 내용은 다음과 같다.

  1. 위에서 언급한 TPS를 조기에 산정했더라면
  2. 큐에 메시지가 계속 쌓인다면? (처리량보다 유입량이 많은 경우)
  3. BullMQ의 심장(?)인 Redis에 장애가 발생한다면?

 

위 세 가지 상황이 모두 연관이 있는 것 같다. 1번을 조기에 고려하지 못해서 자연스레 2번 문제를 캐치하지 못했고, 2번 문제를 계속 방치하다보면 결국 최종에는 Redis에도 문제가 생기지 않을까? 추가 개선을 위해, BullMQ는 어떻게 Redis를 활용해서 Job을 입력하는지 알아보는 게 좋겠다.

 

 

 

BullMQ는 어떻게 메시지를 넣는가

import { Queue } from 'bullmq';
const queue = new Queue('queueName');

async addJob() {
    await queue.add('jobName', { payload: ... });
}

await addJob();

 

BullMQ에서 구현한 Queue의 인스턴스를 활용하여 큐의 이름을 설정하고, (기본적으로) HSET을 활용해서 job의 이름과 데이터들을 넣는다. 아래 코드는 Nest의 공식문서를 따라 큐에 적재하기 위한 코드의 예시이다.

@Injectable()
export class ChatProducer {
  constructor(
    @InjectQueue('chat-message-side-effect') private readonly queue: Queue,
  ) {}

  async sendMessage(userId: string, message: string) {
    const jobId = `${Date.now()}-${Math.floor(Math.random() * 1000)}`;
    const payload = {
      userId,
      message,
    };

    await this.queue.add(
      'chat-message-side-effect',
      { payload },
      {
        jobId,
      },
    );
  }
}

 

 

여기서 생각해보아야할 부분은, Redis의 SET은 중복된 키값이 있다면 내부 데이터를 덮어 쓰는 방식으로 동작 한다는 점이다. 이해를 돕기 위해, 실제 Job 등록에 사용되는 여러 자료구조 중 Hashes를 직접 CLI를 통해 입력해본 결과를 아래에 서술해두었다. 결과를 보면 중복 방지를 디폴트로 수행하지 않는다는 것을 알 수 있다.

127.0.0.1:6379> HSET user-1 name test
(integer) 0
127.0.0.1:6379> HGETALL user-1
1) "name"
2) "test"
127.0.0.1:6379> HSET user-1 name test2
(integer) 0
127.0.0.1:6379> HGETALL user-1
1) "name"
2) "test2"

 

 

 

그렇다면 BullMQ를 사용하는 우리 개발자들은 중복 처리를 사전에 확인하는 모듈을 따로 구성해야할까? 그렇지 않다. BullMQ에서는 편의를 위해 중복된 Job은 등록이 되지 않도록 처리해두었다. 우선 BullMQ의 소스 코드를 실제로 분석해 본 후 동작 과정에 대한 간략한 플로우를 그려봤다.

 

 

Redis에 데이터를 등록하기 위해 실행되는 add....Job-*.lua 스크립트에서 입력 전 중복 확인에 대한 로직이 같이 수행된다.

else
    jobId = args[2]
    jobIdKey = args[1] .. jobId
    if rcall("EXISTS", jobIdKey) == 1 then
        return handleDuplicatedJob(jobIdKey, jobId, parentKey, parent,
            parentData, parentDependenciesKey, KEYS[5], eventsKey,
            maxEvents, timestamp)
    end
end

 

추가적으로 priority나 delayed 작업을 위한 ZSET 활용, 작업 로그를 위한 Streams등에 추가로 입력하지만, 이 부분은 현재 주안점에 벗어나니 생략하겠다. 관심 있으신 분들은 BullMQ 소스코드를 참고하면 될 것 같다.

 

이제 이러한 이해들을 바탕으로, 추가 개선을 어떻게 해야하는지 한 번 생각해보았다.

 

 

 

처리량 < 유입량

우선 처리량보다 유입량이 많은 경우부터 따져보자.

 

워커의 처리 속도가 생산 속도를 따라가지 못할 경우 큐에는 자연스레 Job이 쌓이게 된다.

이 상황이 지속되면 메모리 부담은 물론이고(Redis) 뒤에 들어온 Job의 처리 시간은 기하급수적으로 증가하게 된다.

위의 비즈니스 흐름을 예시로 절망적인 상황을 들어보자면 고객님이 어제 채팅을 보냈는데, 담당자는 오늘 업무 알림을 받아볼 수도 있다.

@Processor('chat-message-side-effect', { concurrency: 3 })

 

큐에 작업이 원활하게 처리되지 못하는 상황을 해결하기 위해 워커에서 동시 처리량을 제어할 수 있다. TPS가 최대 1.55였기 때문에 645ms당 1개의 요청이 발생한다고 가정하고, 워커의 처리 속도는 외부 API에 의해 최대 2초가 걸린다고 가정한다면 concurrency는 3~4정도가 적당할 것이다. 이처럼 적절한 동시 처리나 워커 자체를 늘리는 방향도 고려해볼 수 있다.

 

하지만 동시성을 제어할 경우에는 현재 사용중인 리소스, 여기서는 데이터베이스의 총 Connection과 평균 활성 Connection, 현재 서버의 Connection Pool과 할당된 메모리 자원 등을 고려하는 것이 필수이다. 이 모든 리소스간의 밸런스를 고려하는 엔지니어링이 개발자의 필수 덕목인 것 같다.

 

 

유입량이 많은 경우 중 또 고려해야할 부분은, 처리해야할 메시지가 중복해서 들어오는 경우이다.

하지만 위의 BullMQ의 기본 Job 적재 방식에 대한 이해를 바탕으로 중복 방지에 선 조회 후 early-return하는 코드는 오히려 추가 I/O가 발생할 것이라는 것을 짐작할 수 있다.

 

 

 

 

Redis 장애 대응을 위해

근본적으로 Redis 장애 발생 시 당연히 BullMQ는 더이상 메시지를 받을 수 없다. 또한 이미 enqueued된 작업조차 Redis의 휘발성이라는 특성 때문에 손실될 수 있다.

 

개발자로서 이런 현상을 미리 대응할 수 있도록 설계하여 이미 enqueued된 작업을 복구할 수 있도록 구성할 수 있어야한다.

 

아직까지 서비스에서 사용중인 Redis에 장애가 발생한 적은 없지만, 혹시 모를 Fail Over에 대비한 전략이 하나도 구성되어있지 않다는 것을 인지했다. 서비스 도메인 특성상 트래픽이 엄청나게 성장할 일은 없다고 판단해서 Sentinel로 FailOver 시 노드 승격 전략과 장애 발생 알림 처리를 구성했다. 서비스 레벨에서 Redis 연결 재시도를 허용하여 마스터 노드 전환 시에도 워커가 자동으로 재연결되도록 처리했다.

 

이와 더불어 꾸준히 큐들의 작업 개수를 주기적으로 수집하여 모니터링하고 대기열이  일정 수치를 초과하면 알림을 받아볼 수 있도록 구성하여 장애 징후를 빠르게 감지할 수 있도록 했다.

 

 

 

 

마무리

당시의 개선 방향과 현재 시점에서 생각나는 추가 개선 사항들을 정리하여 쭉 정리해보았다.

 

점진적으로 이런저런 시도를 해보면서 현재 트래픽을 감당하기 여유로운 상황이다보니 엣지 케이스들을 또 고려하지 못했나 싶기도 하다.

조금씩 알면 알수록 더 어려운 빌어먹을 엔지니어링의 세계 ㅡㅡ.. 외부 레퍼런스들을 많이 찾아보면서 실제 개선 사례들을 대입해보면서 무엇을 놓쳤는지, 지금 방식이 최적이었는지 계속 생각해보고있다. 언젠가 이 글을 다시 꺼내먹는 날 예전의 내가 한심해질지도..?

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

소나큐브(Sonarqube) 도입하기

삽질/업무 2024. 11. 12. 18:45
728x90
728x90

 

 

배경

최근 몇 차례의 코드 리뷰 경험들로 공유하는 문화가 얼마나 큰 이펙트가 있는지 경험할 수 있었다.
 
하지만 현재 조직에서는 홀로 서버 개발을 진행하다보니 내가 작성하고 있는 코드가 과연 좋은 방향으로 나아가고 있는 것인지 혹은 구조적이나 가독성 등 좋은 코드를, 올바른 설계를 하고 있는 것일까? 라는 의구심이 생겼다.
 
또한, 여러 번 기술 면접을 다녀왔는데 많은 조직에서 내부적으로 소나 큐브를 정적 코드 분석 도구로 활용하고 있었다.
소나큐브를 통해 받을 수 있는 코드 리뷰를 통해 코드 품질 향상과 더불어, 코드 일관성 등의 컨벤션도 정립하고자 도입하게 되었다.
 
 
 

설치

 

Installing from Docker | SonarQube Docs

Explains how to install the SonarQube server from the Docker image.

docs.sonarsource.com

 

(사용중인 호스트 서버는 ubuntu 20.04.1 이며, 도커를 이용하여 세팅했다.)
 

docker run -d --name sonarqube -p 9000:9000 sonarqube

 
 
위 명령어를 통해 바로 설치와 실행을 해준다. docker run 명령을 실행할 때 로컬에 이미지가 없으면 도커 허브에서 자동으로 이미지를 다운로드하고 실행시킨다. (특정 버전을 사용해야한다면 이미지를 특정 버전으로 설치한 후 실행한다.)

소나 큐브의 웹 콘솔은 기본적으로 9000 포트를 사용하는 것 같다. 현재 해당 포트를 사용하지 않기 때문에, 포트포워딩을 통해 9000번 포트로 접근할 수 있도록 설정하였다.
 
 
 

세팅

admin/admin으로 접속할 수 있었고, 로컬 프로젝트를 바로 생성해주었다.
간단하게 과정들을 캡처했는데, 간단하게 세팅할 수 있었고 안내를 너무 잘해줘서 쉽게 세팅할 수 있었다.
 

 
 
 

 

 

 
 
 
설정을 마치고나면, 분석 방법에 대한 설정을 하게 된다.
현재 배포 파이프라인이 Actions을 통해 구성되어있으므로 Actions를 선택했다.
 
 

 
 

 
 
기존 배포 프로세스와 겹치지 않게, 별도의 Actions 스크립트로 구성해서 셋팅을 완료했다.
 
 
 

분석받기

아무 설정도 하지 않았을 때, 현재 코드가 Passed된 모습이고
보안, 신뢰성, 유지보수성, 코드 중복, 커버리지 등 상세하게 문제지점을 볼 수 있다.
Measures 탭이 있는 걸 보니, 뭔가 새로운 코드가 반영됐을 때 이전 상태와 비교해서 개선이 되었는지도 측정해주는 것 같다. 
 
 
누르고 싶게 생긴 빨간 Security Hotspots를 살펴봤다.
 

 
 
위 코드는 테스트나 로컬 환경에서의 여러 스키마를 synchronized하는 설정 코드이다. 로컬 / 테스트 환경에서만 동작하는 코드이기 때문에 딱히 수정할 필요가 없을 수 있다.
 
 
아래 코드는 이미지를 랜덤으로 가져오는 코드이며, 단순 10장 중 랜덤 한장을 보여주기만 하기 때문에 Math.random을 사용해도 PRNG 사용 등의 가능성을 고려하지 않아도 된다.
 
 

// sonar-ignore-start
connection.query(`CREATE DATABASE IF NOT EXISTS \`${databaseName}\``);
// sonar-ignore-end

 

(분석기가 경고하지 않게 만들 수도 있다고....)
 
 


 
 
 
간단하게 도커 컨테이너로 소나큐브를 구성하고, 현재 서비스의 메인 브랜치 코드를 관리할 수 있게 되었다.
과하게 의존하진 않더라도, 소나큐브가 제공하는 코드 분석의 결과가 긍정적이라면, 적극적으로 수용하여 계속해서 나은 코드, 설계에 대한 방향성을 고민하고 적용해나갈 수 있게 되었다. 소나큐브의 분석 스타일을 커스터마이징하여 프로젝트 내의 일관적인 컨벤션을 유지할 수 있게 구성해서 사용해보아야겠다.
 

 

 

 

커버리지 세팅

 

11월 14일 현재, 어느정도 커버리지에 대한 세팅을 하여 사용중이다.

테스트 커버리지와 동일한 커버리지를 받아볼 수 있으면서, 테스트 코드 자체는 분석이 되게 구성하였다.

 

# 프로젝트 기본 정보
sonar.projectKey=iwedding-api-v2
sonar.projectName=IWedding API v2
sonar.projectVersion=1.0

# 분석 경로 설정
sonar.sources=src
sonar.tests=test

# 커버리지 경로 설정
sonar.javascript.lcov.reportPaths=coverage/lcov.info

# 커버리지에서 제외할 파일
sonar.coverage.exclusions=**/*.config.ts, **/*.module.ts, \
  **/*.interface.ts, **/*.dto.ts, **/*.constant.ts, **/*.enum.ts \
  **/*.error.ts, **/*.exception.ts \
  src/**/*.error.ts, src/**/*.exception.ts, \
  src/infra/database/**, \
  src/main.ts, \
  src/**/entity/**/*.ts, \
  src/**/enum/**/*.ts, \
  src/**/interface/**/*.ts, \
  src/**/type/**/*.ts, \
  src/**/*.config.ts, \
  src/**/*.const.ts, \
  src/common/error/**/*.ts, \
  test/**/*.ts  # 테스트 파일 커버리지 제외

# 소스 인코딩
sonar.sourceEncoding=UTF-8

# TypeScript 설정
sonar.typescript.tsconfigPath=tsconfig.json

 

 

실제 서비스의 백엔드 프로젝트의 루트 경로에 위와 같은 프로퍼티 파일을 생성해서 소나큐브가 읽을 수 있게 구성하여 사용중이다. 소나큐브의 커버리지는 jacoco와 연동해서 많이들 사용하던데, node이기 때문에 Jest를 사용하였다.

 

소나큐브 Actions 스크립트에서 test를 돌리기 위해서는 DB와 기타 서드파티를 도커로 구성하여 띄워야 소나큐브에서도 테스트를 돌려 커버리지 파일을 동적으로 활용할 수 있을텐데, 그 작업은 뒤로 미루고 메인 브렌치에서 pre-commit 단계에서 실행되는 테스트에 커버리지 파일을 생성하여 coverage/lcov.info는 ignore하지 않고 레포에 같이 올려서 사용중이다.

 

 

 

(더 추가적인 세팅이 완료되면, 1차적으로 해당 포스팅에 이어 작성하고, 분량이 많아지면 정리하여 새로운 포스팅으로 정리하자.)

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

의존성 제어 2 - HTTP 클라이언트 의존성 낮추기

삽질/업무 2024. 11. 1. 17:57
728x90
728x90

 

[NestJS] 의존성 제어 1 - 레이어간 의존성 낮추기(Layered Architecture)

최근 인프랩 면접에서 얻게 된 코드 리뷰를 통해 현업에서 사용하고 있는 코드들에 대해 되짚어 보고 있다. 값진 경험을 통해 메타인지를 할 수 있게 되었고, 오답이라고 생각하는 부분에 대한

mag1c.tistory.com

 
 
1편의 연장선으로, 실제 기술 면접의 코드 리뷰에서 mysql2에 의존적인 코드를 어떻게 변화에 유연하게 만들것이냐는 질문을 받았다.
레이어간 역할을 명확하게 하는데 그치지 않고 데이터베이스에 엑세스하는 레이어를 두 단계로 나눠서, 레포지토리 레이어는 단순 쿼리 실행 레이어로 인프라 레이어를 데이터베이스와 연결하는 레이어로 두어 인프라 레이어만 교체하게끔 할 수 있다고 생각했다.
 
ORM, DB 라이브러리와 비슷하게 실무에서 많이 쓰는 외부 의존성이 무엇이 있을까 생각해보다가, 실제로 운영중인 서비스에서 많이 활용중인 Nest의 HttpModule을 바탕으로 의존성을 제어해보고자 했다. 현재 운영중인 서비스의 HTTP 클라이언트 라이브러리 사용 구간은 전환이 이루어지지 않은 일부를 비롯하여 샌드버드, 웹 훅, SNS 회원가입 시 약관 정보를 가져오기 위해 사용하는 등 여러 구간에서 애플리케이션의 핵심적인 기능을 담당하고 있다.
 
 
 

기존 모듈의 한계

 
위 코드는 공식 문서의 예제, 아래 코드는 실 사용중인 샌드버드 API의 일부이다.

 
공식 문서에 따르면 AxiosResponse는 axios에서 내보낸 인터페이스이며, 모든 HttpService 메서드들은 Observable로 래핑된 AxiosResponse를 반환한다고 한다.
 

export class HttpService {
  constructor(
    @Inject(AXIOS_INSTANCE_TOKEN)
    protected readonly instance: AxiosInstance = Axios,
  ) {}

  get<T = any, D = any>(
    url: string,
    config?: AxiosRequestConfig<D>,
  ): Observable<AxiosResponse<T, D>> {
    return this.makeObservable<T>(this.instance.get, url, config);
  }
  
  get axiosRef(): AxiosInstance {
    return this.instance;
  }

  protected makeObservable<T>(
    axios: (...args: any[]) => AxiosPromise<T>,
    ...args: any[]
  ) {
    return new Observable<AxiosResponse<T>>(subscriber => {
      const argsCopy = [...args];
      const configIdx = argsCopy.length - 1;
      const config: AxiosRequestConfig = { ...(argsCopy[configIdx] || {}) };
      argsCopy[configIdx] = config;

      let cancelSource: CancelTokenSource;
      if (!config.cancelToken) {
        cancelSource = Axios.CancelToken.source();
        config.cancelToken = cancelSource.token;
      }

      axios(...argsCopy)
        .then(res => {
          subscriber.next(res);
          subscriber.complete();
        })
        .catch(err => {
          subscriber.error(err);
        });
      return () => {
        if (config.responseType === 'stream') {
          return;
        }

        if (cancelSource) {
          cancelSource.cancel();
        }
      };
    });
  }
}

 
 
실제로 소스를 들여다보면, get 메서드는 Axios Promise -> Observable을 반환하게 되어있다.
현 상황에서 Observable의 이점을 활용할 수 없다는 판단이 섰다. 유연한 처리(요청 취소, 체이닝 등)를 할 필요가 없는 상황. 단순 응답 데이터를 받아 그대로 활용하는 상황에서 굳이? 라는 생각을 했다.
 
역시나 현명하신 프레임워크 개발 팀(?)에서 다이렉트로 Axios Response를 활용할 수 있게 공식문서에 예제를 남겨주셨다.
 

"아! 그럼 axiosRef를 사용하면 되는구나~"

 
하고 개발을 완료했었는데, 최근 인프랩 면접을 기점으로 코드를 보는 시각이 조금 달라졌다.
항상 구현에서 그치지 않고 유연한 설계를 지향하기 시작했다.
 
axiosRef는 axios 그 자체이다. AxiosInstance는 axios를 상속받은 구현체이다.
그럼 나는 불필요하게 axios를 사용하기 위해서 특정 프레임워크에 종속되어 있는건가? 왜 굳이 HttpService를 사용해야하지?
 
라는 생각들이 마구 들어 직접 HTTP 클라이언트 라이브러리를 활용하여 HttpService를 구현해보고자 했다.
추가로 axios에서 fetch로, got, undici로 HTTP 클라이언트 라이브러리가 교체되더라도 유연한 구조를 설계해보고 싶었다.
 
 
 
 

독립적인 HTTP 모듈 구성

우선 생각한 구조는 다음과 같다.
HTTP 메서드들을 추상화한 인터페이스를 활용해서, HttpService에서 이를 상속받아 인터페이스의 메서드들을 구현하고 이 서비스에서 직접적으로 HTTP 클라이언트 라이브러리에 의존하기 때문에, 라이브러리가 교체되더라도 HttpService만 교체하면 되는 상황을 설계하고자 했다.
 

// http-client.interface.ts
export interface IHttpClient {
    get<T>(url: string, config?: HttpConfig): Promise<HttpResponse<T>>;
    post<T>(url: string, data: any, config?: HttpConfig): Promise<HttpResponse<T>>;
    //...put, patch, delete, ......
}
import { Injectable } from '@nestjs/common';
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { IHttpClient, IHttpRequestConfig, IHttpResponse } from './interface/http.interface';

@Injectable()
export class AxiosHttpService implements IHttpClient {
    private readonly instance: AxiosInstance;

    constructor(config: IHttpRequestConfig) {
        this.instance = axios.create(this.transformConfig(config));
    }
    async get<T = any>(url: string, config?: IHttpRequestConfig): Promise<IHttpResponse<T>> {
        const axiosResponse = await this.instance.get<T>(url, this.transformConfig(config));
        return this.transformResponse(axiosResponse);
    }

    async post<T = any>(url: string, data?: any, config?: IHttpRequestConfig): Promise<IHttpResponse<T>> {
        const axiosResponse = await this.instance.post<T>(url, data, this.transformConfig(config));
        return this.transformResponse(axiosResponse);
    }
    
    //...put, patch, delete, ......
    
    private transformConfig(config?: IHttpRequestConfig): AxiosRequestConfig {
        return { ...config };
    }

    private transformResponse<T>(axiosResponse: AxiosResponse<T>): IHttpResponse<T> {
        return {
            data: axiosResponse.data,
            status: axiosResponse.status,
            headers: axiosResponse.headers as Record<string, string>,
        };
    }
}
// http-client.types.ts
export interface IHttpRequestConfig {
    baseUrl?: string;
    timeout?: number;
    headers?: Record<string, string>;
    params?: Record<string, any>;
}

export interface HttpResponse<T = any> {
    data: T;
    status: number;
    headers: Record<string, string>;
}

 

생각나는대로 바로 구현했던 코드들이다.
위에서 말한 것 처럼 인터페이스 클라이언트에서 메서드들을 정의하고, HttpService에서 이를 구현했다. Axios를 사용하니 AxiosHttpService로 지었다.
 
요청에 필요한 Config을 직접 정의하고, 응답 객체 또한 직접 만들어 해당 라이브러리에 종속되지 않도록 했다.
(요청 Config는 각 라이브러리들 마다 포맷이 있을 것 같아 아래에서 실제 라이브러리에 의존하도록 교체해주었다.)
 
 

// http.module.ts
@Global()
@Module({
    providers: [
        {
            provide: 'HTTP_CLIENT',
            useFactory: () => {
                return new AxiosHttpService({
                    timeout: 5000,
                });
            }
        }
    ],
    exports: ['HTTP_CLIENT']
})
export class HttpModule {}
constructor(
    @Inject('HTTP_CLIENT')
    private readonly axiosHttpService: IHttpClient,
){}

async getChannels(webId: string): Promise<IHttpResponse<any>> {
    const params = webId
        ? {
              members_include_in: webId,
              show_member: true,
              limit: 100,
          }
        : {
              show_member: true,
              limit: 100,
          };

    return this.axiosHttpService.get(`${this.BASE_URL}/group_channels`, {
        params,
        headers: {
            'Content-Type': 'application/json',
            'Api-Token': this.API_TOKEN,
        },
    });
}

 
 
이제 Nest의 주입 포맷에 맞게, 주입해서 사용해주면 된다. 원하는 대로 설계는 잘 이루어졌고 제대로 동작했다.
 
관련해서 레퍼런스들을 찾다가, 올해 코딩적 영감을 가장 많이 받은 인프랩의 기술 블로그에 같은 주제의 포스팅이 있었다.
포스팅을 서너번 읽어봤는데 코드의 가독성이 훨씬 더 좋고, 내부 서버에서 사용할 클래스들로 유연하게 변경시켜주는 차이점이 있었다.
 
 
나는 위 설계를 바탕으로 코드를 작성한지 2시간 반 만에 리팩토링을 해야만 했다.
아니 반드시 하고 싶었다.
 
 
 

리팩토링

응답 데이터로 유연하게 객체 생성하기

우선, 응답받은 데이터를 바로 내부에서 사용하는 객체로 변환시켜주고자 했다.
처음엔 단순 class-transformer의 plainToInstance만을 사용하려고 했으나, 응답받는 데이터가 Snake Case이거나, 불규칙적인 형태일 경우 모두 고려해야했고, 현 상황에서 모두를 아우를 수 있는 형태로 발전하게 되었다.
 
(class-transformer는 class-validator와 더불어 시너지를 내기도 좋고, nest라는 프레임워크를 사용하는 이유 중 하나라고 생각을 하기 때문에, 적극 사용중이다.)
 

import { ClassConstructor, plainToInstance } from 'class-transformer';

export class HttpResponseFactory {
    /**
     * HTTP response data를 엔티티로 변환
     * @param path - 데이터를 가져올 경로 (e.g. "channels")
     */
    static toEntities<T>(entityClass: ClassConstructor<T>, plainObject: any, path: string): T[] {
        const nestedData = path.split('.').reduce((obj, key) => obj?.[key], plainObject);

        // 경로의 데이터가 배열이 아니라면 배열로 감싸 단일 객체도 처리
        const dataArray = Array.isArray(nestedData) ? nestedData : [nestedData];

        return plainToInstance(entityClass, dataArray, {
            enableImplicitConversion: true,
            excludeExtraneousValues: true,
        });
    }
}
/**
 * HTTP response
 */
export class HttpResponse {
    constructor(
        private readonly data: any,
        private readonly statusCode: number,
    ) {}

    /**
     * HTTP response data를 엔티티로 변환
     */
    toEntities<T>(entityClass: ClassConstructor<T>, path: string): T[] {
        return HttpResponseFactory.toEntities(entityClass, this.data, path);
    }

    getData(): any {
        return this.data;
    }

    getStatusCode(): number {
        return this.statusCode;
    }
}

 
HttpResponse에서 사용할 객체 변환 팩토리를 하나 구성했고, HttpResponse도 알맞게 변경해주었다.
카멜케이스로의 전환은 class-transformer의 @Expose로, 불규칙적인 데이터를 확인하여 변환해주는 작업은 @Type으로 처리하였다.
 

//channel.dto.ts
import { Expose, Type } from 'class-transformer';
import { ChannelMemberState } from '../enum/enum';

/**
 * 채널 생성자
 */
export class ChannelCreatedBy {
    @Expose({ name: 'user_id' })
    userId!: string;

    @Expose({ name: 'nickname' })
    nickname!: string;

    @Expose({ name: 'profile_url' })
    profileUrl!: string;
}

/**
 * 채널에 있는 멤버들의 정보
 */
export class ChannelMembers {
    @Expose({ name: 'user_id' })
    userId!: string;

    @Expose({ name: 'nickname' })
    nickname!: string;

    @Expose({ name: 'profile_url' })
    profileUrl!: string;

    @Expose({ name: 'state' })
    state!: ChannelMemberState;

    @Expose({ name: 'is_active' })
    isActive!: boolean;

    @Expose({ name: 'is_online' })
    isOnline!: boolean;
}

/**
 * 채널 정보
 */
export class ChannelResponse {
    @Expose({ name: 'channel_url' })
    channelUrl!: string;

    @Expose({ name: 'cover_url' })
    coverUrl!: string;

    @Expose()
    name!: string;

    @Expose({ name: 'created_at' })
    createdAt!: number;

    @Expose({ name: 'member_count' })
    memberCount!: number;

    @Type(() => ChannelCreatedBy)
    @Expose({ name: 'created_by' })
    createdBy!: ChannelCreatedBy;

    @Type(() => ChannelMembers)
    @Expose()
    members!: ChannelMembers[];
}

 
 
이제, 위 DTO를 활용해서, 샌드버드 채널 정보를 가져오는 수많은 데이터를 입맛에 맞게 내부적으로 변환하여 사용할 수 있게 되었다.
 
 

메서드 체이닝

async get<T = any>(url: string, config?: IHttpRequestConfig): Promise<IHttpResponse<T>>
{
    const axiosResponse = await this.instance.get<T>(url, this.transformConfig(config));
    return this.transformResponse(axiosResponse);
}

 
기존에는 실제 axios와 똑같이, get메서드 하나에 모든 파라미터를 다 집어넣어서 데이터를 뽑아내는 방식이었다.
취향 차이일지 모르겠으나 내 기준에서는 위 기술 블로그의 메서드 체이닝 방식이 훨씬 읽기 편하고, 원하는 메서드만 골라서 사용할 수 있어 훨씬 유연할 것 처럼 보였다. 
 

import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import { HttpBodyFactory, MediaType } from './factory/request-body.factory';
import { HttpResponse, IHttpClient } from './interface/http.interface';

export class AxiosHttpService implements IHttpClient {
    private readonly _config: AxiosRequestConfig = {};

    constructor(baseConfig: AxiosRequestConfig) {
        this._config = baseConfig;
    }

    uri(uri: string): this {
        this._config.url = uri;
        return this;
    }

    header(headers: Record<string, string>): this {
        this._config.headers = { ...this._config.headers, ...headers };
        return this;
    }

    contentType(mediaType: MediaType): this {
        this._config.headers = { ...this._config.headers, 'Content-Type': mediaType };
        return this;
    }

    query(params: Record<string, any>): this {
        this._config.params = { ...this._config.params, ...params };
        return this;
    }

    body<T>(body: HttpBodyFactory<T>): this {
        this._config.data = body.getData();
        this.contentType(body.getMediaType());
        return this;
    }

    get(): this {
        this._config.method = 'get';
        return this;
    }

    post(): this {
        this._config.method = 'post';
        return this;
    }

    put(): this {
        this._config.method = 'put';
        return this;
    }

    patch(): this {
        this._config.method = 'patch';
        return this;
    }

    delete(): this {
        this._config.method = 'delete';
        return this;
    }

    head(): this {
        this._config.method = 'head';
        return this;
    }

    options(): this {
        this._config.method = 'options';
        return this;
    }

    async fetch<T = any>(): Promise<HttpResponse> {
        const axiosResponse = await axios.create(this._config).request<T>({
            ...this._config,
        });
        return this.transformResponse(axiosResponse);
    }

    private transformResponse<T>(axiosResponse: AxiosResponse<T>): HttpResponse {
        return new HttpResponse(axiosResponse.data, axiosResponse.status);
    }
}
//http-body.factory.ts
export enum MediaType {
    APPLICATION_JSON = 'application/json',
    APPLICATION_FORM_URLENCODED = 'application/x-www-form-urlencoded',
    TEXT_PLAIN = 'text/plain',
}

export class HttpBodyFactory<T> {
    private constructor(
        private readonly _mediaType: MediaType,
        private readonly _data: T,
    ) {}

    static fromJSON(json: Record<string, unknown>) {
        return new HttpBodyFactory(MediaType.APPLICATION_JSON, json);
    }

    static fromFormData(form: Record<string, unknown>) {
        return new HttpBodyFactory(MediaType.APPLICATION_FORM_URLENCODED, form);
    }

    static fromText(text: string | Buffer) {
        return new HttpBodyFactory(MediaType.TEXT_PLAIN, text);
    }

    getMediaType(): MediaType {
        return this._mediaType;
    }

    getData(): T {
        return this._data;
    }
}

 
메서드 체이닝을 사용할 수 있게, 기존의 인터페이스와 더불어 HttpService를 변경하고, 필요한 구간들도 다 정리해주었다.
기존에 body를 넣는 구간이 없이 모두 한 번에 사용했으니 Body를 생성하는 팩토리도 만들어 사용하게 되었다.
 
 

@Injectable()
export class SendbirdService {
    private readonly APP_ID = process.env.SENDBIRD_APP_ID!;
    private readonly API_TOKEN = process.env.SENDBIRD_API_TOKEN!;
    private BASE_URL: string;

    constructor(
        @Inject('HTTP_CLIENT')
        private readonly httpService: IHttpClient,
    ) {
        this.BASE_URL = `https://api-${this.APP_ID}.sendbird.com/v3`;
    }

    async getChannels(webId: string): Promise<ChannelResponse[]> {
        const params = webId
            ? {
                  members_include_in: webId,
                  show_member: true,
                  limit: 100,
              }
            : {
                  show_member: true,
                  limit: 100,
              };
        return this.httpService
            .uri(`${this.BASE_URL}/group_channels`)
            .header({
                'Content-Type': MediaType.APPLICATION_JSON,
                'Api-Token': this.API_TOKEN,
            })
            .query(params)
            .get()
            .fetch()
            .then((response) => {
                return response.toEntities(ChannelResponse, 'channels');
            });
    }
}

 
 
최종 형태의 SendbirdService가 완성되었다. 위 코드에서, 어디에도 axios에 의존적인 구간은 없는 것으로 보인다. 설계한 의도대로 잘 흘러간 것 같다.
 
 

정리

HTTP 클라이언트 라이브러리를 사용하기 위해 추가적인 프레임워크의 특정 모듈에 의존적이어야 하는 상황.
이 상황을 너무 종속적이고 유연하지 못하다고 판단했고, 실제 구현한 HttpService 구현체를 통해 다른 라이브러리로의 교체가 이루어지더라도 해당 서비스만 교체하면 될 것 처럼 설계했다.
 
라이브러리를 교체해도 모듈 내부만 교체하면 된다. 외부에서는 교체됐는지 알 필요도, 알 수도(???) 없다.
추가로, 인터페이스 자체를 모킹해서 테스트에도 용이할 것이다.
 
 

유연한지 확인하기 위한 라이브러리 교체

import fetch, { RequestInit, Response } from 'node-fetch';
import { HttpBodyFactory, MediaType } from './factory/request-body.factory';
import { HttpResponse, IHttpClient } from './interface/http.interface';

export class FetchHttpService implements IHttpClient {
    private _config: RequestInit = {};
    private _url: string = '';

    constructor(baseConfig: RequestInit) {
        this._config = { ...baseConfig };
    }

    uri(uri: string): this {
        this._url = uri;
        return this;
    }
    header(headers: Record<string, string>): this {
        this._config.headers = { ...this._config.headers, ...headers };
        return this;
    }

    contentType(mediaType: MediaType): this {
        this._config.headers = { ...this._config.headers, 'Content-Type': mediaType };
        return this;
    }

    query(params: Record<string, any>): this {
        const query = new URLSearchParams(params).toString();
        const url = new URL(this._url);
        url.search = query;
        this._url = url.toString();
        return this;
    }

    body<T>(body: HttpBodyFactory<T>): this {
        this._config.body = JSON.stringify(body.getData());
        this.contentType(body.getMediaType());
        return this;
    }

    get(): this {
        this._config.method = 'GET';
        return this;
    }

    post(): this {
        this._config.method = 'POST';
        return this;
    }

    put(): this {
        this._config.method = 'PUT';
        return this;
    }

    patch(): this {
        this._config.method = 'PATCH';
        return this;
    }

    delete(): this {
        this._config.method = 'DELETE';
        return this;
    }

    head(): this {
        this._config.method = 'HEAD';
        return this;
    }

    options(): this {
        this._config.method = 'OPTIONS';
        return this;
    }

    async fetch(): Promise<HttpResponse> {
        const response = await fetch(this._url, this._config);
        return this.transformResponse(response);
    }

    private async transformResponse(response: Response): Promise<HttpResponse> {
        const data = await response.json();
        return new HttpResponse(data, response.status);
    }
}
// http.module.ts
@Global()
@Module({
    providers: [
        {
            provide: 'HTTP_CLIENT',
            useFactory: () => {
                return new FetchHttpService({
                    timeout: 5000,
                });
            }
        }
    ],
    exports: ['HTTP_CLIENT']
})
export class HttpModule {}

 
 
아무것도 건들지 않고, axios만 제거한 뒤 node-fetch를 설치했다. (config에 url이 세팅되지 않아서 url은 따로 필드화하여 사용했다.)
위처럼, 설계한 HTTP Module만 갈아끼워버리면 끝이다.
 
서비스 레이어에는 아무 변경사항 없이, 라이브러리 교체로 인한 라이브러리에 의존적인 모듈 내부만 교체하여, 외부에서는 교체됐는지도 알 수 없는, 변화에 유연한 설계가 완성되었다.
 
 
 
 
 
 
 
 
 

참조

https://docs.nestjs.com/techniques/http-module
https://github.com/nestjs/axios
https://github.com/axios/axios
https://tech.inflab.com/20230723-pure-http-client/
 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Docker에서 PM2로 전환하여 배포 시간 단축하기

삽질/업무 2024. 10. 29. 17:44
728x90
728x90

전환 이유

레퍼런스들을 찾아보면, 대부분이 PM2를 통해 간편하게 인스턴스를 띄워 사용하다가, 컨테이너 단위로 전환하고 노드 환경에서는 추가로 pm2-runtime을 도커 컨테이너 내부에서 적용하는 경우를 많이 봤던 것 같다.

 

나는 반대로, 현재 운영중인 서비스를 계속해서 개발하면서 겪은 문제들을 개선하기 위해 배포 파이프라인을 Git Actions - Docker에서 Git Actions - PM2로 전환하였다.

 

1. 긴 배포 시간 (길면 3~5분, 평균 1분 후반대)

2. 간헐적인 서비스 장애 탐지의 위치 이동

 

서버와 백엔드 코드를 혼자 관리하다 보니 빈번하게 코드를 업데이트해야 한다. 테스트 같은 안전 장치의 통과를 위해 소요되는 시간은 언제나 수긍하지만, Docker 프로세싱의 긴 소요 시간은 점차 불쾌하게 다가왔다. 얕은 지식과 레퍼런스들을 바탕으로 이미지 최적화를 수행해도 1~2분은 기본적으로 걸렸다. 메인 브렌치에 코드를 하루에도 많게는 십 수번 씩 반영해야하는 환경이라 배포 속도가 중요하게 다가왔다.

 

또한 처음 입사 후에 간헐적으로 배포 후에 발생되는 서비스 장애를 방지하기 위해 코드 통합 이후 health check를 추가했다. 테스트 코드를 조금씩 적용해나가면서, 현재의 환경에서 굳이 배포 사이클에 테스트를 돌릴 필요가 없다고 판단이 되었다. 그래서 lint를 포함한 테스트와 관련된 사이클은 pre-commit 단계에서 수행할 수 있도록 husky를 도입했다.

 

이러한 문제들을 개선하기 위해 기존 사용하던 Docker Compose를 통한 BLUE / GREEN 배포 대신 PM2와 심볼릭 링크를 활용해 새로운 배포 방식으로 변경하게 되었다.

 

 

BLUE / GREEN 배포

 

 

새 배포 프로세스 구조

BLUE / GREEN 배포

아래의 스크립트는, 같은 BLUE / GREEN 방식으로 current 심볼릭 링크가 가리키는 폴더를 기준으로 반대편 폴더에 새로운 코드를 배포한 후 심볼릭 링크를 전환하는 구조로 작성했다. PM2 인스턴스는 current 심볼릭 링크를 통해 해당 디렉토리의 코드만을 실행하게 된다.

 

name: iwedding_api_v2_deploy

on:
    push:
        branches:
            - 'main'

jobs:
    SSH_DEPLOY:
        if: contains(github.event.head_commit.message, 'skip') != true
        runs-on: [self-hosted, api_v2_action]

        steps:
            - name: SSH scripts
              uses: appleboy/ssh-action@master
              with:
                  host: ${{ secrets.IWD_HOST }}
                  username: ${{ secrets.IWD_USERNAME }}
                  password: ${{ secrets.IWD_PASSWORD }}
                  script: |
                      echo "=========================================================="
                      echo "🤝 Integration: Pull latest code and install dependencies"
                      echo "=========================================================="
                      source /home/svc/.nvm/nvm.sh
                      cd /home/svc/api/v2
                      nvm use # .nvmrc

                      echo "============================================="
                      echo "🔎 Determine Target for Blue/Green Deployment"
                      echo "============================================="
                      if ls -l /home/svc/api/v2/current | grep -q 'blue'; then
                          TARGET_FOLDER="green"
                      else
                          TARGET_FOLDER="blue"
                      fi

                      echo "========================================="
                      echo "📁 Pulling latest code for $TARGET_FOLDER"
                      echo "========================================="
                      cd /home/nodesvc/api/v2/$TARGET_FOLDER
                      git pull origin main
                      
                      yarn install

                      echo "========================================"
                      echo "🛠️ Compile application in $TARGET_FOLDER"
                      echo "========================================"
                      yarn build

                      # 테스트를 main branch에서도 한 번 더 실행해야 할지 고민중
                      #   echo "=================="
                      #   echo "✅ Test: Run tests"
                      #   echo "=================="
                      #   yarn test

                      echo "============================================"
                      echo "🚀 Switching symbolic link to $TARGET_FOLDER"
                      echo "============================================"
                      ln -sfn /home/svc/api/v2/$TARGET_FOLDER /home/nodesvc/iwedding-api/v2/current

                      echo "==================================="
                      echo "🚀 Restarting application with PM2"
                      echo "==================================="
                      pm2 restart /home/svc/api/v2/current/ecosystem.config.js

 



 

기존의 도커 방식은 블루 인스턴스와 그린 인스턴스에 포트를 서로 다르게 부여한 후 리버스 프록시에서 proxy_pass로 최신 버전의 인스턴스를 읽는 방식이다. 오래된 인스턴스는 백업 컨테이너로 copy된 후 삭제된다.

 

이러한 일련의 과정들을 모두 간소화하여 배포 시간은 30초 대로 단축되었고, 기존의 BLUE / GREEN에 따른 관련 설정들을 따로 독립적으로 구성하고 관리할 필요가 없게 되었다.

 

 

 

로그 관리

로그 관리를 위해, PLG를 프로덕션에 적용해놓은 상태이다. (관련 포스팅들)

Docker 환경에서는, 볼륨 마운트로 로그를 수집했지만, PM2로 전환하면서 심볼릭 링크가 변동이 되기 때문에 인스턴스에서 current 디렉토리가 아닌 특정 경로에 고정된 방식으로 로그를 내보내게 되었다. Promtail에서 해당 경로만 지정해준다면, 문제 없이 기존처럼 로그를 용이하게 수집하고 관리할 수 있다.

 

// winston settings
const dailyOption = (level: string) => {
    const directory =
        process.env.NODE_ENV === 'production' ? `/home/svc/api/v2/logs/${level}`
        	: `logs/${level}`;

    return {
        level,
        filename: `%DATE%.${level}.log`,
        datePattern: 'YYYY-MM-DD',
        dirname: directory,
        maxSize: '20m',
        zippedArchive: true,
        maxFiles: 30,
    };
};

 

 

 

 

 

RESTORE:: 백업 인스턴스로 급 전환

급하게 반영된 프로덕션의 코드가 잘못되어 롤백해야할 때, restore을 사용해서 도커 컨테이너의 포트를 바꿔버리는 스크립트를 사용했다.

 

기존 액션 스크립트의 심볼릭 링크를 확인하는 명령어를 기반으로, 자바스크립트 코드로 심볼릭 링크를 교체해주면 기존의 restore와 동일하게 사용할 수 있었다.

당연한 말이지만, 내부 IP 혹은 내 노트북과 같은 특정 IP에서만 접근할 수 있도록 방화벽 설정을 호스트 서버 구성해두었다.

exec(`ln -sfn /home/svc/api/v2/${newDir} /home/svc/api/v2/current`, (switchError, switchStdout, switchStderr) => {
    if (switchError) {
        console.error(`스위치 오류: ${switchError.message}`);
        res.end('스위칭 중 오류가 발생했습니다.');
        return;
    }

    res.end(`스위칭 성공: ${newDir} 디렉토리로 전환되었습니다.`);
});

 

 

 

 

 


 

 

 

 

정리

 

 

 

기존 배포 프로세스(위)에서, 현재의 방향(아래)으로 교체한 뒤, 가장 큰 문제였던 배포 시간을 2분 내외에서 30초 내외로 크게 단축시킬 수 있었다. 메인 브렌치에 마구잡이로 핫픽스를 하면 안되겠지만, 현재의 조직 특성상 맞는 방향으로 배포 파이프라인을 교체하였고, 바꾼지 이틀 되었는데 이미 크게 체감이 되는 모양이다. 본인을 포함하여, 다른 구성원들까지 빨리 반영된다고 좋아하는 것 같다.

 

정답은 없지만 오답은 있다는 말처럼, 오답이 아닐까 노심초사 전환기를 남겨둔다. 현재 상황에 맞는 여러 방법 중 배포 시간을 줄이자!!! 라는 방향만 본 극단적인 선택일 수 있지만, 나중에 내가 다시 봤을 때 최적의 선택은 무엇이었을까? 하고 돌아볼 수 있지 않을까..

 

글 마무리가 조금 이상한데,,, 여튼 배포 시간을 3~4배 단축시킨 것에 현재는 만족한다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

의존성 제어 1 - 레이어간 의존성 낮추기

삽질/업무 2024. 10. 19. 09:22
728x90
728x90

 
최근 인프랩 면접에서 얻게 된 코드 리뷰를 통해 현업에서 사용하고 있는 코드들에 대해 되짚어 보고 있다.
 
값진 경험을 통해 메타인지를 할 수 있게 되었고, 오답이라고 생각하는 부분에 대한 리팩토링을 진행하고 있다.
 
특히 이번에는 면접 당시에 서비스 레이어와 인프라(DB) 레이어 사이의 의존성에 대해 피드백을 들었던 부분을 바탕으로 실제 코드를 개선하고 그 과정에서 꼬리에 꼬리를 무는 의문들을 정리해보는 시간을 가지고자 한다.
 


 

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>,
  ) {}

  findAll(): Promise<User[]> {
    return this.usersRepository.find();
  }

  findOne(id: number): Promise<User | null> {
    return this.usersRepository.findOneBy({ id });
  }

  async remove(id: number): Promise<void> {
    await this.usersRepository.delete(id);
  }
}

 
위 코드는 NestJS 공식문서의 Database 섹션의 예제 코드이다.
 
이런 예제를 바탕으로, 현업에서의 유저 정보를 가져오는 코드 또한 아래처럼 작성되어 있었고, 그대로 사용중이었다.

@Injectable()
export class WeddingUserService {
  constructor(
    @InjectRepository(WeddingUser)
    private readonly wUserRepository: Repository<WeddingUser>,
    @InjectRepository(UserExtra)
    private readonly userExtraRepository: Repository<UserExtra>,
  ) {}

  async me(userId: string): Promise<UserInfoDto> {
    const wUser: WeddingUser | null = await this.findOne({ where: { id: 'test1' }, relations: ['UserExtra'] });
    
    //custom exception
    if (!wUser) throw new NotFoundException(ErrorCode.User, ErrorMessage.User);
    
    return new UserInfoDto.create(wUser);
  }
}

 
 
많은 기술 블로그들이나 Nest 사용자들이 서사하고 있지만, 서비스 레이어에서 직접적으로 DB에 의존적인 이 예제 코드들은 비즈니스만을 담당해야하는 서비스 레이어의 책임에 위배되고 과한 책임과 ORM에 대한 직접적인 의존을 하게 된다.
 
예를 들어, TypeORM에서 Prisma로 교체해야 할 때, 서비스 레이어에서 데이터베이스 접근도 직접적으로 수행하고 있기 때문에, 서비스의 데이터베이스 엑세스 코드를 모두 바꿔주어야한다.
 

@Injectable()
export class WeddingUserService {
  constructor(private readonly prisma: PrismaService) {}

  async me(userId: string): Promise<UserInfoDto> {
    const wUser: WeddingUser | null = await this.prisma
      .weddingUser
      .findUnique({
        where: { id: 'test1' },
        include: { UserExtra: true },
      });

    //custom exception
    if (!wUser) throw new NotFoundException(ErrorCode.User, ErrorMessage.User);
    
    return new UserInfoDto.create(wUser);
  }
}

 
 
 
 
그래서 보통 DB Access 레이어를 구현하여 직접적인 책임을 없애고 DB(ORM)에 대한 의존을 낮춘다.
 

@Injectable()
export class WeddingUserRepository extends Repository<WeddingUser> {
    constructor(private dataSource: DataSource) {
        super(WeddingUser, dataSource.createEntityManager());
    }
    
    async findOneByWedId(webId: string): Promise<WeddingUser | null> {
      return await this.findOne({ where: { id: 'test1' }, relations: ['UserExtra'] });
    }
}
@Injectable()
export class WeddingUserService {
  constructor(
    private readonly wUserRepository: WeddingUserRepository,
  ) {}

  async me(userId: string): Promise<UserInfoDto> {
    const wUser: WeddingUser | null = await this.wUserRepository.findOneByWebId(userId);
    
    //custom exception
    if (!wUser) throw new NotFoundException(ErrorCode.User, ErrorMessage.User);
    
    return new UserInfoDto.create(wUser);
  }
}

 
 
 
더 의존적인 부분을 느슨하게 하기 위해서 레포지토리의 인터페이스를 도입했다.

export interface IWeddingUserRepository {
    findOneOrThrowByWedId(webId: string): Promise<WeddingUser>;
}
@Injectable()
export class WeddingUserRepository
  extends Repository<WeddingUser>
  implements IweddingUserRepository {
    constructor(private dataSource: DataSource) {
        super(WeddingUser, dataSource.createEntityManager());
    }
    
    async findOneByWedId(webId: string): Promise<WeddingUser | null> {
      return await this.findOne({ where: { id: 'test1' }, relations: ['UserExtra'] });
    }
}

 

@Injectable()
export class WeddingUserService {
  constructor(
    private readonly wUserRepository: IWeddingUserRepository,
  ) {}

  async me(userId: string): Promise<UserInfoDto> {
    const wUser: WeddingUser | null = await this.wUserRepository.findOneByWebId(userId);
    
    //custom exception
    if (!wUser) throw new NotFoundException(ErrorCode.User, ErrorMessage.User);
    
    return new UserInfoDto.create(wUser);
  }
}

 
 
최근 면접에서 심도있게 답변을 하지 못했던,
 
단순히 서비스 레이어는 레포지토리의 인터페이스에만 의존하고 있으므로 레포지토리 자체의 구현의 변경에 영향을 받지 않도록 설계했습니다. 를 넘어선 무언가에 대해 생각을 확장해보아야 했다.
 
 
데이터를 Fetch하는 라이브러리의 구현 메서드 자체는 변할지 모르지만, 서비스에서 호출되는 레포지토리의 함수명 자체가 변하지 않는 이상 추상화 하는 것이 정말 의미가 있을까? 또한 해당 인터페이스를 여러 저장소에서 가져온다거나 하는 상황이 실제로 발생할까?
> 현재 서비스는 실제로 서비스 내부의 고객 데이터를 외부에서 사용하는 경우는 있을 수 있지만, 외부의 데이터를 고객 데이터와 합쳐서 처리해야 하는 경우는 없을 것이다.
 
레포지토리가 의존하고 있는 무언가가 변경되더라도 테스트 작성에 더 용이할까?
> 인터페이스에 의존한 Mock 객체를 사용하게 되면, 실제 데이터베이스에 접근하지 않고 단위 테스트를 수행할 수 있지만 데이터 I/O가 없는 단위 테스트에 의미가 있을 뿐, 실제 저장소 연결을 필요로 하는 통합 테스트 등에는 이점이 없는 것 같다. 단위 테스트는 현재 Fixture를 통해 충분히 관리되고 있다.
 
 
 
좀 더 클린한 코드를 작성하기 위한 리팩토링 중에 레이어의 인터페이스 사용은 위와 같은 판단의 근거들을 통해 사용하지 않기로 결정했다.

(관련해서 피드백을 해주신다면 소정의 성의를 돌려드리겠습니다!!! 많관부)

 
 


 
 
 
정답은 없어도 오답은 있다는 말처럼, 면접 간에 내가 선택하고 작성한 코드에 대해 명확하고 자신감 있게 판단의 근거를 제시하지 못해 아쉬었다. 물론 기술 면접은 통과했었지만, 내면에 정의된 기술적 지식이 조금 더 있었다면 보다 나은 코드 리뷰를 할 수 있지 않았을까. 하는 아쉬움이 남는다.
 

 
 
최근 조영호 님의 오브젝트 책을 구매했다. 객체 지향 쪽에서의 조금 더 나은 구조와 코드 설계를 위해 학습하고 있다.
 
이런 학습들을 하다보면, 기술 선택 뿐 아니라 내가 작성해나가는 코드 한 줄 한 줄에 어떤 판단의 근거를 가지고 작성했는지 조금 더 명확하게 말할 수 있지 않을까. 이런 것들이 쌓이다보면 개발자로서의 중간 목표인 오프라인 세션에서 나의 지식과 경험을 공유하는 사람. 으로 한 발 더 다가갈 수 있으리라 믿는다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

복합 인덱스로 쿼리 튜닝하기

삽질/트러블슈팅 2024. 7. 26. 16:30
728x90
728x90

[데이터베이스] 인덱스(index) 정리

인덱스   목차, 색인, 책갈피와 같은 기능을 하는 인덱스는, 데이터베이스 분야에서는 어떤 데이터를 검색할 때 속도를 높여주는 자료 구조로메모리 영역에 생성되는 일종의 책갈피이다.   

mag1c.tistory.com

 

위 글의 복합 인덱스를 통한 튜닝 부분을 따로 옮긴 포스팅입니다.

 
 


 
 
 
분명 일정한 기준이 있을 텐데 왜 얘기들이 조금씩 다른 것일까?
DB의 버전 때문일까 옵티마이저가 무조건적으로 100% 맞다는 보장이 없어서일까? 잘 모르겠다.
그래서 직접 쿼리 튜닝의 경험들을 복기하며 복합 인덱스를 생성할 때에는 어떤 순서로 인덱스를 구성해야 하는지 알아보았다.
 
 

 
서비스 내부에는 모든 유저의 장바구니(앞으로 견적함이라 부름)를 볼 수 있는 기능이 존재하는데 업종으로 필터링할 경우의 집계를 위한 쿼리의 일부이다.
(사진과 드레스를 선택했을 경우를 예시로 platform_A 테이블에 대한 인덱스 생성만 예시로 든다.)

SELECT cart_group_no
FROM platform_A.cart c
INNER JOIN service.product p
    ON p.no = c.product_no
WHERE p.category IN ("사진", "드레스")
    AND c.cart_group_no != 0
    AND c.option != 1;

 

 
 
COUNT(DISTINCT) 쿼리로 간단하게 카디널리티를 확인해 본 결과는 위와 같았고, 당연히 나는 아래와 같이 인덱스를 생성했다.

CREATE INDEX IDX_REALTIME_QUOTATION
ON platform_A (cart_group_no, product_no, option_product);

 
 

 

 

 

쿼리 실행 시간이 어느 정도 눈에 보이는 수치로 감소했다. 하지만 여전히 Fetch는 비슷했다.

 
 
인덱스 생성 전에는, 해당 테이블에서 풀스캔(ALL)을 했다. 전체 테이블 row를 스캔한 것으로 보인다.
인덱스 생성 후에는 INDEX RANGE SCAN을 통해 조회하였고 테이블 row의 스캔 개수도 20%가량 줄어들었다.
추측건대 fetch time이 개선되지 않은 이유는 스캔하는 row가 여전히 많기 때문이라고 판단했다.
 
쿼리에서는 조인을 먼저 수행하지만 쿼리 실행 계획을 보면 조인 시 인덱스를 효율적으로, 아니 전혀 사용하지 못하는 것처럼 보였다. 
 
왜 이런 결과가 나올까 생각해 보다가 쿼리 실행 순서를 바탕으로 인덱스 순서를 변경해 보았다.

CREATE INDEX IDX_REALTIME_QUOTATION
ON platform_A (product_no, cart_group_no, option_product);

 

 

p의 인덱스 길이는 category가 varchar(150)이기 때문인 것 같다

 
 
변경 후에도 결과가 다소 개선되었는데 Duration도 개선되었지만 Fetch Time이 대폭 개선되었음을 볼 수 있다. 실제로 스캔하는 row의 수가 100배 가까이 줄었기 때문이라고 유추해 볼 수 있었다. 추가로 c 테이블의 KEY LENGTH가 1이 줄었는데, 이 쿼리의 결과에서 option_product을 참조하지 않았다고 판단할 수 있다. (option_product는 tinyint이다)
 
카디널리티는 cart_group_no가 두 배 높았지만, 실제로 product_no를 앞에 사용해 줘야 올바르게 튜닝이 된 것을 확인할 수 있었다.
 

위 쿼리에 대해 모든 인덱스를 참조하게 되는데, 이런 인덱스를 커버링 인덱스라고 한다.

 
 
실제 쿼리를 튜닝하는 과정을 통해 카디널리티가 낮더라도 첫 번째 조건 절에서 사용된 컬럼을 인덱스 컬럼으로 사용하지 않는다면 올바르게 동작하지 않는다는 것을 알 수 있었다. 
 
정리하자면 선행 조건 컬럼은 반드시 인덱스 선행 컬럼이 되어야 하고, 다음과 같은 조건절이 있을 때 

WHERE col1 = ?
    AND col2 = ?
    AND col3 BETWEEN ? AND ?
    AND col4 = ?
    AND col5 = ?
CREATE INDEX ON IDX ON TB1 (col1, col2, col3, col4, col5);

 
 
인덱스를 위와 같이 생성했다면 col4, col5는 인덱스를 타지 않고 조건 필터링만 수행하게 되며 쿼리 실행 순서와 마찬가지로 WHERE 조건 절은 ORDER BY 컬럼보다 우선한다.
 

모든 인덱스를 참조하게 하고 싶다면 col1, col2, col4, col5, col3 순으로 생성해야 한다.

 
 
 
 
 

향로님 포스팅 일부 발췌

 
 
 
이왕이면 카디널리티도 위의 규칙을 지키면서 고려하면 좋다고 생각했지만, 카디널리티는 복합 인덱스에서 고려할 사항이 아니라는 자료도 있고 아예 선행 조건절이 일치한다면 후행 조건절에서는 순서가 유의미하지 않다는 글도 있다.
 
최근엔 이전과 같이 꼭 인덱스 순서와 조회 순서를 지킬 필요는 없는 것 같다. 인덱스 컬럼들이 조회 조건에 포함되어 있는지가 중요하고, 내가 사용하고 있는 데이터베이스 엔진에 맞게 인덱스를 활용하고 튜닝할 줄 아는 게 중요한 것 같다.
 
 
 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[사이드 프로젝트] 운동 기록앱 "오운완" 제작기 - 1

삽질/사이드 프로젝트 2024. 5. 20. 14:33
728x90
728x90

 

어느덧 30이 되어가는 나이에, 2년 가까이 공부를 핑계로 몸관리에 소홀해졌다.

어디가서나 얘기하는 사소한 것도 기록하는 습관이 장점이에요!!! 는 살다보니 기록하지 않으면 수행하지 않는다. 가 되어버렸다.

2년 내내 헬스장에 등록은 되어 있었으나, 관장님들이 가장 좋아하는 스타일(?)인 유령회원이 되어버렸다. 1달에 1번 갈까말까..

 

내가 직접 만든 기록 서비스를 사용하면 더 열심히 운동할 것만 같았고

직접 헬스장에서 운동을 하면서 기록을 하기 위해 모바일 앱으로 가닥을 잡고 작업을 시작하게 되었다.

 

3월 중순부터 1달 반 정도의 제작 기간을 거쳐 작업했고, 현재는 어떤 이슈들 때문에 릴리즈 전 단계에서 테스트중인

나의 첫 애플리케이션에 대해 간단하게 기록하고 나아갈 방향에 대해 서술해보고자 한다.

 

더보기

 

 

애플리케이션 구성

많은 개발자들이 말하는 JavaScript의 장점 중 빠른 개발을 체감해보고자 프론트는 Next로, 백엔드는 Nest로 구성했다.

백엔드를 Nest로 구성한 이유는 Node위에 기본적인 ORM, Auth, 소셜로그인에 대한 부분들을 좀 더 편하게 사용하기 위해서였다.

 

프론트는 Vercel, 백엔드는 AWS EC2에 배포되어 있으며

모바일 애플리케이션은 WebView를 사용하여 URL을 바인딩하고, EAS(Expo)를 활용하여 IOS, Android용으로 빌드 및 배포하였다.

 

 

 

 

1.

필요 시 개발 컨테이너를 올려서 테스트를 했다.

프로젝트 내에서는 이미지 I/O 작업에서, 실제 서버 도메인을 필요로 했었기 떄문에 해당 경우에 사용하였다.

 

2.

AWS 과금 줄이기 포스팅 에서의 경험을 토대로, 나의 경우에서는 

ALB에서 Nginx에 대한 트래픽 분산 처리를 하지 않기 때문에 ALB는 사용하지 않았다

기존 ACM을 통한 SSL인증 처리를 Nginx로 이전하고, 로드밸런싱 또한 차후에 필요 시 컨테이너의 대수를 늘려 사용할 계획이다.

 

3.

백엔드 컨테이너는 무중단 배포를 Git Action의 Workflow를 통해 구축했으며, 헬스 체크를 통한 백업 컨테이너로의 restore가 적용되어 있다.

 

4.

필요한 로그들은 Discord Webhook을 통해 개인 채널에 메세지 형태로 받아볼 수 있게 구성하였다.

 

 

 

 

진행상황

나를 위한 기록 어플을 타겟으로 삼았기 때문에, 우선 내가 중점적으로 필요한 체중과 운동기록 부분만 우선적으로 구현했다.

 

간단하게 소셜 로그인을 거치고나면, 운동과 체중을 기록할 수 있게 구성했다.

앱 아이콘은 미리캔버스에서 사각형 크기와 색상을 변경해가며 덤벨 아이콘으로 제작했고

디자인 부분은 관련 분야에서 이미 많은 사용자를 보유하고 있는 PLANFIT, FLEEK을 보고 영감을 얻어 제작했다.

(자세하게 보고 싶다면 위의 앱 이미지 보기를 클릭해보길)

 

 

 

비용 문제

작업을 완료하여 베타버전을 릴리즈하여, 사용자를 조금이라도 더 다양하게 모아 테스트를 받아보고 싶었으나 과금 요소들이 많이 부담이 되었다.

 

첫 번째로, 애플리케이션 릴리즈를 위한 개발자 계정의 과금 문제다.

찾아본 바로는 앱을 릴리즈하기 위해서, 안드로이드, IOS 진영 각각의 개발자 계정을 등록해야하는데

안드로이드는 25달러(최초) , 애플은 99달러(연간)가 발생한다고 했다.

 

 

 

두 번째로, 또, 앱에서 사용하는 운동 이미지에도 과금적인 요소가 추가될 수 있었다.

(결론적으로 계산을 해보니,300달러정도가 필요했다.)

 

운동 동작별로 운동 수행동작과 타겟부위를 보여줄 수 있는 이미지를 반드시 추가하고 싶었고, 찾아보니 LYFTA라는 앱의 이미지를 사용해보고 싶었다.

 

귀사의 운동 이미지를 비상업적인 단순 운동 기록을 위해 사용할 것인데 사용 가능한지? 아니라면 얼마의 금액을 부과해야하는지, 혹시 귀사의 이미지가 아니라면 출처가 어디인지? 와 같은 질문을 메일로 보냈고, 감사하게도 출처를 받아볼 수 있었으며 직접 해당 이미지의 판매처와 연결도 해주었다.

 

 

 

 

대량 구매 시 할인을 제공해준다고 했지만, 개발자 계정과 이미지 가격을 합쳐 4~50만원이 넘는 돈이 필요했다.

현재로서는 많이 부담이 되었다.

 

 

과금적인 문제들로 인해 현재는 지인들에게만 직접 apk, ipa파일을 제공하여 10명 남짓한 베타 테스터를 보유한(?) 서비스를 운영중이다.

 

 

 

테스트 개선사항

테스터(?)들 덕분에 추가 요청사항이나, 개선 사항들을 모아볼 수 있었다.

 

1.

앱 스플래시 이미지가 없어진다.

웹뷰로 NextJS를 바인딩할 때(포스팅), 스플래시 이미지 관련 코드도 추가했는데 이 부분에서 문제가 발생한 것 같다.

현재 증상은 스플래시 이미지는 등장하자마자 사라지고, 흰 화면이 설정한 3초동안 렌더링된다고 한다.

 

2.

자주 사용하는 운동의 그룹화가 필요하다.

"나 3분할하는데 루틴 돌 때마다 계속 새로 추가해야해 ㅡㅡ, 루틴 저장했다가 불러올 수 있게 해줘" 라는 요청을 받았다.

나는 해당 부위 운동을 할 때도 순서를 계속 바꿔가면서 운동하기 때문에 상관없다고 판단했지만, 자주하는 운동을 묶어서 바로 가져올 수 있게 기능을 추가할 필요가 있겠다는 생각이 들었다.

 

3.

식단은?

내 애플리케이션의 정체성은 운동과 체중에 대한 것만 기록하기로 했었다. 이건 좀 차후에 생각해보기로 했다.

 

4.

디자인이 구려

미.....미안해 ㅠㅠ 이건 어쩔 수 없어...

 

 

 


 

이번 프로젝트를 만들어가면서, 개발의 속도와 효율성에 대해 생각해보았다. 

짬짬이 개발을 했기 때문에 전체 개발에 어느정도 시간을 잡아먹었다.

하지만 하나의 기능을 개발할 때 프론트, 백엔드 모두 공통의 언어로 빠르게 개발할 수 있었다. 이것이 자바스크립트의 장점인가!!!

 

 

생각보다 앱 하나를 배포하는데, 과금적인 요소를 무시할 수 없는 거구나. 특히 개인 프로젝트는 더 부담스럽게 다가왔다.

회사에서는 과금 요소보다는 성능 최적화와 좋은 코드 작성에 더 집중하게 되지만, 이번 경험을 통해 과금 요소까지 고려할 수 있는 개발자가 되는 게 더 좋은 개발자가 되는 길일 것 같았다. 앞으로의 개발이 더 재밌어질 것 같다.

 

테스터들의 개선 사항을 반영하고, 요구 사항을 어느정도 맞춰가면서 최대한 과금적인 요소를 줄일 수 있는 방향으로 릴리즈하는 것을 목표로 달려나가야겠다.

 

조금 더 보완해서, 릴리즈 후의 경험을 조금 겪고 관련된 포스팅으로 돌아오겠습니다. 감사합니다!

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

웹뷰로 기존 NextJS를 모바일 앱으로 배포하기

삽질/사이드 프로젝트 2024. 4. 30. 13:56
728x90
728x90

출처: 위키미디어

 
 
 
초년차 백엔드 개발자의 사이드 프로젝트 앱 배포 경험기입니다.
(기술적 서술이 없습니다.)
 
NextJS로 구성된 웹앱을 React Native + Expo를 사용해 안드로이드 앱으로 전환하고
배포하는 과정의 기록입니다.
(기본 도메인 세팅이 완료되었고, Vercel로 배포해놓은 상태입니다.)
 
 
 
 

개발서버

기존 NextJS로 구성된 프론트를 React Native로 EXPO를 통해 테스트용 APK를 만들어보기로했음.
 
 
expo-cli는 deprecated되었고, node 17v 이상과 호환성 문제가 발생할 수 있다고 합니다.
프로젝트 내 자체 cli를 설치하여 사용하는 걸 권장한다고 합니다.
expo build명령어도 eas build로 대체되었다고 하네요

# expo cli 설치
npm install expo-cli

# expo project 생성
expo init APKTest
cd APKTest

# 네이티브 웹뷰 설치
expo install react-native-webview

 
 
App.js의 코드를 아래와 같이 수정하고
 
 
 

//App.js
import React from 'react';
import { WebView } from 'react-native-webview';

export default function App() {
  return <WebView source={{ uri: 'https://your-nextjs-app.com' }} style={{ marginTop: 20 }} />;
}

 
 

npx expo start

 
이제 다됐으니 개발서버를 실행시켜본다.
 

 
 
OS에 맞는 Expo를 설치하고
(Android: Expo)
(IOS: Expo Go)
 
QR코드를 찍으면 아래와같이 게이지바가 올라가며, 빌드 되는것을 볼 수 있음

 

QR 스캔 시 반드시 같은 로컬 네트워크여야 한다.
 
 
 
 
아래 코드는, 안드로이드의 뒤로가기버튼 기능과(안하면 바로 종료됨)
모바일 상태표시줄을 앱이 삼켜버리는, 가려버리는 것을 해결하기 위한 간단한 기능들을 적용한 코드다.

(IOS, Android에 따라 다른 코드들이 좀 있나보다)

import SplashScreen from 'expo-splash-screen';
import React, { useEffect, useRef, useState } from 'react';
import { BackHandler, View } from 'react-native';
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
import { WebView } from 'react-native-webview';

async function delaySplash() {
  try {
    await SplashScreen.preventAutoHideAsync();
    await sleep(3000);
  } catch (e) {
    console.warn(e);
  } finally {
    await SplashScreen.hideAsync();
  }
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

const App = () => {
  const webviewRef = useRef(null);
  const [canGoBack, setCanGoBack] = useState(false);

  useEffect(() => {
    delaySplash();
  }, []);

  useEffect(() => {
    const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
      if (canGoBack) {
        webviewRef.current.goBack();
        return true;
      } else {
        BackHandler.exitApp();
      }
      return false;
    });

    return () => backHandler.remove();
  }, [canGoBack]);

  return (
    <SafeAreaProvider>
      <AppContent webviewRef={webviewRef} canGoBack={canGoBack} setCanGoBack={setCanGoBack} />
    </SafeAreaProvider>
  );
};

const AppContent = ({ webviewRef, canGoBack, setCanGoBack }) => {
  const insets = useSafeAreaInsets();

  return (
    <View style={{ paddingTop: insets.top, paddingBottom: insets.bottom, backgroundColor: '#343a40', flex: 1 }}>
      <WebView
        ref={webviewRef}
        source={{ uri: 'https://app.ounwan.net' }}
        onNavigationStateChange={(navState) => setCanGoBack(navState.canGoBack)}
      />
    </View>
  );
};

export default App;

 
 
 
 
 
 

APK 빌드

실제 빌드를 해보기로 했음..
 

expo build:android (x)
npx expo build:android (x)

 
위에서도 언급했듯이, build 명령어가 교체되어 사용이 안된다.

# 택일
npm install -g eas-cli
eas build --platform android

npm install --save-dev eas-cli
npx eas build --platform android

 
빌드를 진행하게되면, 이메일과 패스워드를 물으니 Expo에 가서 회원가입을 하자.
 

 

 
잠시 기다리니 빌드가 완료되었으며, Expo에서 확인할 수 있었다.
배포는 됐으나 expo doctor?? 여기서 뭔가 경고가 발생했다.

 

 
expo-permission이 사용자의 위치, 연락처와 같은 민감한 정보에 편하게 엑세스하기위해 사용한다고 되어있는데
버전 충돌이 일어나기도 하고, 필요없을 것 같아 삭제해주었다.
 
위의 경고창에서 안내된것처럼 check를 하였더니
알아서 모듈 업그레이드나 다운그레이드 및 삭제와 같은 작업들을 진행해주었다.

 

 
로컬에서 의사선생님(?)을 다시 불러서 문제없음을 확인했고

 
 

 
 
APK가 정상 배포됨을 확인할 수 있었다.
 
 
 
 
 
 

IPA 빌드

eas build --platform ios

 
IOS는 애플 ID를 묻는다.
그리고 애플 개발자 계정이 있어야하니 가입해주자.
 
가입할때, 애플쪽은 개발자계정 등록을 위해 과금까지 마쳐야하는 것 같던데,
13만원 결제하라고 되어있어서, 빌드는 살포시 접어두었다.
프리티어 같은게 있는 것 같은데, 개인 개발자는 불가능하다고해서 여기까지....
 
 

 

Apple Developer Program 연회비 면제 - 지원 - Apple Developer

포럼 Apple 엔지니어 및 다른 개발자에게 개발 주제에 관해 질문하고 이야기를 나눌 수 있습니다. 포럼 보기(영문)

developer.apple.com

 
 
 
 
 

기타(개발자 계정 가격)

 

 
 
 
 
 
 

마무리

많이 헤맬 줄 알았는데 생각보다 엄청 빨리 마무리된 것 같다.
 
실제 앱을 배포하는게 목적인데
프로젝트 주제에 맞게, 운동을 좋아하는 지인분들께 공유드려보고
전반적인 서비스의 문제점들을 수정, 보완하여 꼭 배포까지 사이클을 돌려
실제 서비스에서 발생하는 이슈들에 대응해봐야겠다.
 
실제 서비스 후 이슈들에 대응하는 글로 돌아올 수 있길!!
 
 
 
 
 
 

참조

 

Troubleshoot build errors and crashes

A reference for troubleshooting build errors and crashes when using EAS Build.

docs.expo.dev

 

Expo build failed for android

I'm building a react native app that is using the ble plx library. For that I think it got ejected. Now I'm trying to publish a internal test in the google play store. I ran the command expo build -p

stackoverflow.com

 

Build error after updating to 5.2.10 · Issue #7981 · react-navigation/react-navigation

Current Behavior updated @react-navigation/bottom-tabs, native, stack to latest version just now. Getting above error. Already tried deleting the cache, but no luck Your Environment software versio...

github.com

 

[앱만들기] 웹개발자가 가장 쉽게 어플만들기③ (EXPO로 AFK 만들기)

준비 앞전 구름 IDE를 통해 NodeJS React Native Expo 개발환경을 구축했으며, Expo를 통해 내가 제작할 어플리케이션을 미리보기로 확인했습니다. 이번에는 미리 본 어플리케이션을 실제 APK로 변환해보

tyrannocoding.tistory.com

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록