(75)

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

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

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

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

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

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

클라우드 비용을 줄여보자 (AWS 비용 절감 시도 - 1)

프리티어 만료 며칠 전, AWS에서 메일하나가 왔다. 벌써 AWS 프리티어를 사용한지 1년이 다되어가나보다. 아래는, 프리티어에 대한 총 사용량과, 문제의 지지난달 요금이다. 추가로, 로드밸런서의 로깅용으로 S3를 사용했는데 따로 잡히지는 않은 모습이다. 다른 프리티어 사용자분들께 여쭤보고, 포스팅도 찾아보고 해봤지만, 나처럼 요금이 많이 나오는 경우가 잘 없나보다. 만료가 되고서야 한 3~5만원정도 요금이 잡히는 것 같았다. 처음부터 많이 나오지는 않았는데, 과금이 많이 발생한 시점이 사이드 프로젝트를 프론트, 백엔드 모두 배포하여 사용하면서 개발 컨테이너, 프로덕션 컨테이너까지 분리하여 총 네개의 컨테이너를 사용한 시점부터였다. 관련 기술블로그들을 정독해봤지만, 당최 무슨말인지 이해하려면 너무 오래걸..

[NestJS] Failed to catch error thrown by guard in nestjs in interceptor / guard의 uncaughtException

Guard에서의 uncaughtException 새 프로젝트를 진행중인데, 에러를 캐치하지 못해서 서버가 뻗어버렸다. 바로 본론으로 들어가서, 프로젝트의 에러 핸들링 설계는 아래처럼 구성했었다. 에러 발생 > 인터셉터에서 에러 로깅 및 필요에 따라 WebHook 전송 > 필터에서 클라이언트에 보낼 에러 포맷 정의 이러한 방식의 설계는, NestJS의 요청 응답 사이클과 각 구성요소의 역할에 대해 완전히 이해하지 못했기 때문에 만들어졌다. NestJs req-res lifecycle Interceptor에서 간과한 부분이 있었다. 클라이언트에서 보낸 API 요청이 프로젝트 전역에 설정한 Global Interceptor에서 가로채기 전에 가드에서 에러가 발생했다. 그렇기에 Exception Intercep..

[Grafana Loki] Data source connected, but no labels received. Verify that Loki and Promtail is configured properly

에러 원인 라벨 설정 시 설정했던 라벨이 존재하지 않음. 이는 로키는 제대로 연결되었지만, 로그 파일을 제대로 프롬테일에서 받아오지 못했음을 의미한다. 본인의 경우는 프로젝트의 도커컴포즈 볼륨 설정에서, 경로가 제대로 작성되지 않아 망운트가 제대로 되지 않았음. 걸린 시간 3시간 남짓 에러 해결 프로젝트의 로그생성 docker exec -it containerName /bin/sh 프로젝트 내부에서는, 루트 경로에 logs폴더 내부에서 날짜, 에러레벨에 맞게 분기처리를 하여 로그를 생성했다. 호스트 서버에서, 위 명령어를 통해 컨테이너 내부로 진입하는데, 진입하자마자 logs경로에 로그가 잘 들어오고 있길래 당연히 잘 동작할거라 생각했지만 그라파나에서 로키의 ip:port로 커넥션을 연결하는 과정에서 계..

[NestJS] 코드 리팩토링하기 - 응집도를 높이고 의존성을 명확하게

서론 요즘 좋은 코드 라는 키워드에 대해 특히 변경과 재사용이 용이한, 높은 응집도와 낮은 결합 관계 에 대해 많이 생각하고 있다. 특히 기존 레거시를 모두다 걷어내기에는 시간적으로 애로사항이 있어 틈틈이 관련된 프로젝트에 들어갈 때, 해당 로직에 대한 레거시들을 최대한 바꾸려고 노력하고 있다. [네이버클라우드 개발자 스토리] 좋은 코드란 무엇일까?🤔 #클린코드 이야기 📍 “좋은 코드를 짜야 한다”​ medium.com 특히, 상품의 리뷰를 불러오는 함수를 수정해야 하는 일이 최근에 있었는데, 상품군 7~8개의 하위 상품에 대한 리뷰를 모두 다른 함수에서 불러오는 것을 보고 경악을 금치 못했다. (급한 사항이라 판단되어 우선 프로덕션에 수정해서 반영한 뒤 구조를 수정하였다..) 나도 최근에 신규 프로젝트..

[NestJS] TypeORM 0.3 버전의 CustomRepository 생성, Repository패턴 적용하기

0.2 버전 사내 서비스의 TypeORM버전은 0.2버전대를 사용중이다. 0.2버전대에서는 @EntityRepository 데커레이터를 지원하여, Repository를 커스텀화하여 리파지토리 클래스를 생성할 수 있었고, 이에 따라 Service와 Repository레이어를 분리하여 결합도를 낮출 수 있었다. @Injectable() export class RsvcenterService { constructor( @InjectRepository(CustomRsvRepository) private readonly customRsvRepo: CustomRsvRepository, //DB 관련 로직 예외처리 Provider private readonly customEm: CustomEntityManager, )..

[TypeORM / QueryBuilder] Relation with property path confirms in entity was not found

최근 레거시 코드 중 DB 관련 로직들을 거의 대부분 쿼리빌더로 변경하는 작업을 완료하고, 검수중에 있다. 그 과정에서 발생한 에러들을 하나하나 정리하여 남기려고 한다. 에러 메세지 원인 관계 매핑이 정확하지 않아서 발생했다. 나의 경우는 아래 이유 때문에 발생했는데, TypeORM의 쿼리빌더를 사용하는 과정에서, 커스텀 리파지토리를 생성하여, 해당 리파지토리에서 두 테이블을 조인해서 사용했는데, 처음 INNER JOIN을 시도한 테이블에서, enterprise라는 엔터티에 대한 정의를 내리지 않았기 때문에 발생했다. 해당 엔터티를 살펴보면, export class EasyBookWhichEntEntity { @PrimaryGeneratedColumn({ type: 'int', name: 'no' }) ..

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년차 주니어 개발자.

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

삽질/트러블슈팅 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년차 주니어 개발자.

클라우드 비용을 줄여보자 (AWS 비용 절감 시도 - 1)

삽질/트러블슈팅 2024. 4. 24. 08:24
728x90
728x90

프리티어 만료

 

며칠 전, AWS에서 메일하나가 왔다.

 

 

벌써 AWS 프리티어를 사용한지 1년이 다되어가나보다.

 

아래는, 프리티어에 대한 총 사용량과, 문제의 지지난달 요금이다.

추가로, 로드밸런서의 로깅용으로 S3를 사용했는데 따로 잡히지는 않은 모습이다.

 

 

 

 

다른 프리티어 사용자분들께 여쭤보고, 포스팅도 찾아보고 해봤지만, 나처럼 요금이 많이 나오는 경우가 잘 없나보다. 만료가 되고서야 한 3~5만원정도 요금이 잡히는 것 같았다.

 

처음부터 많이 나오지는 않았는데, 과금이 많이 발생한 시점이 사이드 프로젝트를 프론트, 백엔드 모두 배포하여 사용하면서 개발 컨테이너, 프로덕션 컨테이너까지 분리하여 총 네개의 컨테이너를 사용한 시점부터였다.

 

 

관련 기술블로그들을 정독해봤지만, 당최 무슨말인지 이해하려면 너무 오래걸릴 것 같아서, 우선적으로 할 수 있는것만 진행해보기로했다. 학습을 해나가면서 추가적으로 적용할 수 있는 부분들을 계속 해나가면 될 것이다.

 

[Cloud FinOps] 당신의 Cloud 비용은 안녕하신가요???

 

devocean.sk.com

 

 

 

 

 

VPC IPv4 비용 절감하기

 

공지 – AWS Public IPv4 주소 요금 변경 및 Public IP Insights 기능 출시 | Amazon Web Services

AWS에서 퍼블릭(Public) IPv4 주소에 대한 새로운 요금이 도입됩니다. 2024년 2월 1일부터 서비스 연결 여부에 관계없이 모든 퍼블릭 IPv4 주소에 대해 시간당 IP당 0.005 USD의 요금이 부과됩니다. 계정에

aws.amazon.com

 

 

 

2월 1일부터, 모든 퍼블릭 IPv4주소에 대해 시간, IP당 0.005달러씩 부과된단다.. 1년동안 디폴트로 냅뒀었는데, 이제서야 확인해보니 자동 할당되는 4개의 서브넷을 확인할 수 있었다. 미리 체크하지 못해 돈이 줄줄 새고있었던 모양이다..

 

한달에 14달러면 2만원꼴이다.. ㅋㅋㅋㅋ

 

 

 

 

부랴부랴 서브넷의 퍼블릭 IPv4 자동할당을 해제해줬다.

 

 

 

ALB 사용량 줄이기

타겟그룹 생성 시 디폴트값

 

이제 남은 백엔드 컨테이너들의 과금요소를 줄여보기 위해 우선 사용량이 높은 로드밸런서부터 봐야했다. 각각의 컨테이너마다 타겟 그룹을 생성할 때, 프로덕션과 개발 모두 동일한 간격을 설정해서 사용하다보니, 힐스체크 주기가 짧아서 사용량이 많지 않았나 생각했다.

 

프로덕션은 짧은 주기를 가져가는것이 바람직하다고 생각했고, 가능하다면 힐스체크가 여러번 실패하면, 백업 타겟그룹으로 트래픽을 전환하는 람다를 구성해서 사용할 생각이었다.

개발서버는 프로덕션에 올리기전에 테스트용도로 사용하고 있으니, 간격을 최대한 늘려주었다.

 

 

 

 

프로젝트의 초기 인프라 구성을 간단히 그려봤는데, 그려놓고 보니 로드밸런서를 사용하는 목적에 맞지 않게 구성해서 사용하고 있다는 느낌이 빡 들었다. 따로 스케일아웃을 ALB에서 수행하지않고 nginx에서 구성해서 사용하고 있었다.

헬스 체크와, SSL/TLS의 관리를 위해(ALB생성 시 ACM을 구성) 사용하고 있었으니까.

 

비용 절감을 위해 ALB자체를 걷어내고 nginx에서 직접 SSL/TLS처리를 하는 것이 합리적인 선택일 것 같아 SSL/TLS를 nginx에 직접 구성하여 사용하기로 했다. 

 

이제 로드밸런싱 관련 비용은 제로가 될 것이고, VPC 내 로드밸런서와 관련된 비용 절감도 기대할 수 있다. VPC요금이 줄어드는 것은 지켜볼 예정이다.

 

 

 

 

프론트를 버셀로 배포하기

EC2의 볼륨도 줄일겸, 프론트로의 요청-응답 사이클이 줄어들면 과금이 줄어들 것 같아 사이드의 프론트를 전부 버셀로 전환했다.

 

처음에 AWS에 배포하겠다고 헤매던 것을 생각하면, 버셀은 정말 딸깍 몇번이면 끝이더라, 물론 경험치가 쌓이면서 쉬워진 것도 없지 않겠지만, 로그인 후 깃 레포를 등록하고 기존 Route53 도메인을 사용하기 위한 세팅만 해주었는데 끝이났다.

 

route53 도메인을 사용하기 위해 아래 블로그를 참고했다.

 

route53 을 이용하여 vercel 도메인 변경

nextjs 로 만든 프로젝트를 배포할 때 vercel 만큼 유용한 것이 없다. vercel 로 배포를 할 경우 ssr 을 지원해주며 CI/CD 또한 지원되어 repository 에 push 가 일어날 때 마다 자동으로 배포를 해줘 매우 편

velog.io

 

 

 

 

마무리

언젠가 NestJS 최신버전에서 무엇이 달라졌나요? 라고 물었던 질문에 쭈뼛거리면서 "아마 Node 12버전 이하는 지원하지 않을걸요?" 라는 대답을 했다.

 

저런 질문을 받을 당시에는 그냥 넘어갔는데, 이 포스팅을 하는 내내 내가 사용하고 있는 모든 기술들에 지속적인 관심을 가지고 변경된 것이 무엇이 있는지에 대해 무관심했구나 라는 반성을 하게 된다. 가야할 길은 멀지만, 앞만 보고 달리는 것이 아닌 지금 내 주변을 단단하게 다질 필요가 있다. 그래서 더 좋은 경험이었다고 생각한다.

 

언젠가 다른 이슈로 비용절감 포스팅을 더 할 것 같아 (ex. 람다) 제목에 색인을 남겨둔다.

 

 

 

 

References.

 

24년 2월부터 AWS의 Public IPv4 주소의 요금 정책이 변경됩니다 | DevelopersIO

24년 2월부터 적용되는 퍼블릭 IPv4 주소의 요금 변경 체계에 대하여 설명한 글입니다

dev.classmethod.jp

 

Can I remove the public IP on my instance without terminating it?

I have several instances on a vpc that communicate with each other through their private ips. Each instance was launched sometime ago and assigned a random public IP which is not used for anything....

stackoverflow.com

 

[AWS] 2월부터 늘어난 VPC 비용 - In-use Public IPv4 Address

오늘 2월 Invoice를 받아보니 VPC 비용이 갑자기 애매하게 증가한 사실을 알 수 있었습니다... 상황에 따라 몇 만원이 초과할 수 있어서, 예산 알림을 20$로 정해두어서 알림을 받지 못해서 인보이스

coding-groot.tistory.com

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] Failed to catch error thrown by guard in nestjs in interceptor / guard의 uncaughtException

삽질/트러블슈팅 2024. 4. 9. 16:36
728x90
728x90

 

 

 

Guard에서의 uncaughtException

 

새 프로젝트를 진행중인데, 에러를 캐치하지 못해서 서버가 뻗어버렸다.

바로 본론으로 들어가서, 프로젝트의 에러 핸들링 설계는 아래처럼 구성했었다.

 


에러 발생 > 인터셉터에서 에러 로깅 및 필요에 따라 WebHook 전송 > 필터에서 클라이언트에 보낼 에러 포맷 정의

 

 

이러한 방식의 설계는, NestJS의 요청 응답 사이클과 각 구성요소의 역할에 대해 완전히 이해하지 못했기 때문에 만들어졌다.

 

 

 

NestJs req-res lifecycle

 

 

Interceptor에서 간과한 부분이 있었다.

클라이언트에서 보낸 API 요청이 프로젝트 전역에 설정한 Global Interceptor에서 가로채기 전에 가드에서 에러가 발생했다.

그렇기에 Exception Interceptor을 거치지 않고 바로 Filter으로 에러가 전달되게 된다.

 

실제로 로그를 찍어봐도 아래처럼, Filter로 바로 전달되는 것을 볼 수 있다. 마찬가지로 인터셉터가 동작한 후에는 인터셉터를 거쳐 필터로 향하는 모습도 볼 수 있다.

 

 

또한, Guards쪽을 자세히 보니 아래와 같이, 예외 필터에 의해 처리된다고 한다...

 

 

 

 

 

 

 

그럼 어떻게?

 

 

커스텀 에러를, 일전의 프로젝트였던 채팅 서비스의 샌드버드 전환기에서, 샌드버드의 커스텀 에러 타입이 마음에 들었고, 비슷한 형태로 구현해두었다. 클라이언트단에서는 HttpStatus + Custom Code형태로 반환되게 구현해두었다.

 

import { ArgumentsHost, Catch, ExceptionFilter, Logger } from '@nestjs/common';
import { Response } from 'express';
import { CustomHttpException } from '../error/custom.error';

@Catch(CustomHttpException)
export class CustomHttpExceptionFilter implements ExceptionFilter {
  catch(exception: CustomHttpException, host: ArgumentsHost) {
    console.log('CustomHttpException filter');
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest();
    const status = exception.statusCode;
    const stack = exception.stack;

    let json = {
      errorCode: (status * 1000) + exception.errorCode,
      message: exception.message,
      timestamp: new Date().toISOString(),
      path: request.url,
    }

    if (exception.sql) {
      json['sql'] = exception.sql;
    }

    if (process.env.NODE_ENV !== 'production') json['stack'] = stack;

    if (process.env.NODE_ENV === 'local') {
      new Logger().log(stack);
    }

    response
      .status(status)
      .json(json);
  }
}

 

 

그렇기 때문에, 클라이언트에게 보여질 에러를 포매팅하는 Filter를 설정해두고, 반환시켰는데 계속해서 uncaughtException이 발생했다.

 

나의 경우, 포스팅의 주 목적인 라이프사이클의 복습이나, 요청의 위치에 따른 에러 핸들링이 아니라 전혀 다른곳에서 확인되었다.

 

 

 

import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { UnauthorizeAccessToken } from 'src/common/error/user.error';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
    canActivate(context: ExecutionContext) {
        console.log(super.canActivate(context))
        return super.canActivate(context);
    }

    handleRequest(err, user, info) {
        if(err || !user) {            
            throw new UnauthorizeAccessToken();
        }
        return user;
    }
}

 

 

기본적인 Access Token Strategy를 처리하는 Guard를 작성하면서 canActivate 함수에 Promise | Observable타입의 로깅을 시도했던 흔적이 있는데, 이 부분에서 NestJS의 실행 사이클이 올바르게 동작하지 않은 것 같다.

 

간략하게 설명하자면, 위의 코드에서는 canActivate는 인증처리, handleRequest는 인증 후처리를 담당한다고 보면 되는데, 요청이 들어올 때, 내부적으로 'jwt'로 명시된 Strategy를 활성화하여 요청에 포함된 토큰의 유효성 검사를 실시한다. handleRequest에서는, 유효성 검사 후처리를 진행한다고 보면 된다.

 

 

다시 돌아와서, 단순 Promise객체의 로깅을 시도할 때, 결과를 기다리지 않고 로그를 찍어도 Promise객체 자체가 로그에 찍히기 때문에 문제가 없다. 지식이 여기까지밖에 없어서, AI의 힘을 빌려봤다.

 

왜 로그를 찍었을 때 에러가 발생하는지 > 처리가 완료되지 않은 객체여도 객체 자체가 로그에 찍혀 상관없지 않는지? > Observable의 로깅 시 에러가 발생할 수 있는지의 순서로 물어보면서 대답을 다듬었고 다음과 같은 결론을 받을 수 있었다.

 

 

RxJS도 한번 훑어보기라도 해야할 것 같다.

 

 

 

 

마무리

콘솔 한 줄 때문에 실제 프로덕션에서 전사 시스템이 뻗어버리는 사태가 발생하지 않은 것에 다행이지만(그럴 일이 없겠지..?)

뭔가 마무리 멘트를 정리를 못한 찝찝한 트러블슈팅이었다. 시작은 사용하는 프레임워크의 생명주기를 더 잘 이해하고 적절히 핸들링할 수 있기를 바라면서 작성한 글이었고, 덕분에 관련한 문서를 보며 다시 다잡았다는 긍정적인 결론을 낼 수 있었지만 무언가 찝찝하달까..

 

 

 

 

 

 

 

참조

 

 

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

 

Interceptor not catching error thrown by guard in nestjs

I have a global guard that is registered in the application in common.module.ts which is a global module. const HeaderGuardGlobal = { provide: APP_GUARD, useClass: HeaderGuard ...

stackoverflow.com

 

 

Global interceptor not catch errors · Issue #10751 · nestjs/nest

Is there an existing issue for this? I have searched the existing issues Current behavior I created an interceptor to save the LOGs. I created the app module and set the provider, but I find that w...

github.com

 

NestJS interceptors: Guide and use cases - LogRocket Blog

In this article, we will look at what NestJS interceptors are, how to use them, and some use cases for them.

blog.logrocket.com

 

interceptor not working with expressAdapter with Error in Guard · Issue #3065 · nestjs/nest

Bug Report Hi, I'm having issued with interceptor who aren't triggered when throwing a custom exception in a Guard. The setup of the nest application uses the ExpressAdapter from the nestjs/platfor...

github.com

 

 

NestJS Middleware vs Interceptors vs Filters

Prerequisites: Application(s) written in NestJS

medium.com

 

Rxjs 한번 배워보실래요?

나: "그래서 RxJs를 대체 할 만한게 있을까요?" > 크루: "솔직히 비동기나 시간을 다루는 데에는 Rxjs를 대체 할 만한게 없긴 하죠. 진짜 좋다고 생각해요. ... 배우기 어려워서 그렇지. 웬만한 개발자

velog.io

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[Grafana Loki] Data source connected, but no labels received. Verify that Loki and Promtail is configured properly

삽질/트러블슈팅 2024. 3. 8. 14:05
728x90
728x90

 

 

 

 

에러 원인

라벨 설정 시 설정했던 라벨이 존재하지 않음.

이는 로키는 제대로 연결되었지만, 로그 파일을 제대로 프롬테일에서 받아오지 못했음을 의미한다.

 

본인의 경우는 프로젝트의 도커컴포즈 볼륨 설정에서, 경로가 제대로 작성되지 않아 망운트가 제대로 되지 않았음.

 

 

걸린 시간

3시간 남짓

 

 

 

에러 해결

프로젝트의 로그생성


docker exec -it containerName /bin/sh

 

프로젝트 내부에서는, 루트 경로에 logs폴더 내부에서 날짜, 에러레벨에 맞게 분기처리를 하여 로그를 생성했다.

 

호스트 서버에서, 
위 명령어를 통해 컨테이너 내부로 진입하는데, 진입하자마자 logs경로에 로그가 잘 들어오고 있길래 당연히 잘 동작할거라 생각했지만

그라파나에서 로키의 ip:port로 커넥션을 연결하는 과정에서 계속해서 아래 에러가 발생했다.

 

"Data source connected, but no labels received. Verify that Loki and Promtails is configured properly"

 

라벨을 가져올 수 없는 상황인 것 같았고, 결국 라벨은 로키까지 도달되려면 프로젝트 로그에서 출발한 데이터가 프롬테일을 통해 로키로 전달이 되어야하기 때문에, 프롬테일에서 로그를 받아오기까지의 과정을 확인해볼 수 밖에 없었다.

 

 

프롬테일은 로그를 잘 받아오고 있는가?

 

프롬테일의 로그를보면, 경로는 정확히 추적하고 있는 것으로 보였다.

 

 

로그 파일의 바인드 마운트

프로젝트 내부의 로그는 정상적으로 생성되지만, 호스트 내부의 경로에는 로그파일이 정상적으로 생성되지 않았다.

 

결과론적으로, 프로젝트의 compose설정에서, 볼륨의 마운트 설정이 잘못되어서 해당 위치에 로그파일이 생성되지 않았다.

 

 

컨테이너 내부로 접속했을 때, 바로 logs디렉토리가 있어서 compose 설정을 다음과 같이 작성했기 때문에 마운트가 되지 않았다.

version: '3.7'
services:
  api:
    image: api
    container_name: api
    build: .
    privileged: true
    ports:
      - '45001:5001'
    volumes:
      - /var/log/api:/logs
    environment:
      - PORT=5001
      - NODE_ENV=production

 

 

다른 곳에서 삽질을 많이 했다. 괜히 프롬테일 설정파일, 로키 설정파일도 뜯어보고.

아예 호스트서버에서 프롬테일 로키를 삭제했다 재설치도 해보고.

영문으로된 자료들을 많이 찾아봤다가

 

볼륨 경로가 이상하다는 것을 3시간만에 겨우 파악했고... 볼륨경로를 정확히 지정해주었더니 해결되었다.

volumes:
  - /var/log/api:/app/logs

 

 

 

 

 

참고

 

Troubleshooting | Grafana Loki documentation

Open source Troubleshooting “Loki: Bad Gateway. 502” This error can appear in Grafana when Grafana Loki is added as a datasource, indicating that Grafana in unable to connect to Loki. There may one of many root causes: If Loki is deployed with Docker,

grafana.com

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] 코드 리팩토링하기 - 응집도를 높이고 의존성을 명확하게

삽질/트러블슈팅 2024. 1. 21. 21:59
728x90
728x90

 

 

 

서론

 

요즘 좋은 코드 라는 키워드에 대해

 

특히 변경과 재사용이 용이한, 높은 응집도와 낮은 결합 관계 에 대해 많이 생각하고 있다.

 

특히 기존 레거시를 모두다 걷어내기에는 시간적으로 애로사항이 있어

 

틈틈이 관련된 프로젝트에 들어갈 때, 해당 로직에 대한 레거시들을 최대한 바꾸려고 노력하고 있다.

 

 

개발자라면, 누구나 좋은 코드가 무엇인지는 간략하게라도 알고 있다.

 

 

[네이버클라우드 개발자 스토리] 좋은 코드란 무엇일까?🤔 #클린코드 이야기

📍 “좋은 코드를 짜야 한다”​

medium.com

 

 

 

특히, 상품의 리뷰를 불러오는 함수를 수정해야 하는 일이 최근에 있었는데,

 

상품군 7~8개의 하위 상품에 대한 리뷰를 모두 다른 함수에서 불러오는 것을 보고 경악을 금치 못했다.

 

(급한 사항이라 판단되어 우선 프로덕션에 수정해서 반영한 뒤 구조를 수정하였다..)

 

 


 

 

 

나도 최근에 신규 프로젝트를 진행하면서

 

함수가 많아지고 코드가 길어짐에 따라

 

비즈니스 레이어에 있는 Validation관련 로직이 많아져서

 

Validation관련 함수들만 따로 클래스를 분리하여 Provider로 만들어주는 과정에서 있었던 일들을

 

 

간략하게나마 작성해 이 시기에 이런 고민을 했고, 후에 다른 백엔드 분들과 협업 시에 달라질 코드 스타일과 비교하고자,

마지막으로 지금의 나는 옳은 방향으로 가고 있었는지 미래에 확인해보기 위해 작성 해보려고 한다.

 

 

 


 

 

 

쭉 써내려간 코드

 

아래는, 출퇴 시간을 직접 조정할 수 있는 기능에 대한 코드이다.

 

//출결 등록, 변경 관련
@Injectable()
export class AttendenceService implements AttendanceServiceImpl {

    constructor(
        private readonly offDayPlanRepo: OffDayPlanRepository,
        private readonly offDayPlanCoreRepo: OffDayPlanCoreRepository,
        private readonly offDayPlanOutTimeRepo: OffDayPlanOutTimeRepository,
    ) { }
    
    
    //출퇴근 시간 직접 설정
    async customizeWorkTime(user: User, startTime: Date, endTime?: Date): Promise<CustomRes> {
    
    	//현재 시각의 Date, String
        const { today, todayString } = await dateDataSet();
        
        //오늘 날짜의 Row Check
        await this.existsOffDayPlanCheck(user, 'customIn', today, todayString);

        //부서별 코어 근무시간과 validation
        if (await this.registrationCustomVaildation(user.devision, startTime, endTime)) {
        	
            const result = await this.offDayPlanRepo.updateCustomWorkTime(user.id, startTime, endTime, now());

            if (result.affected) {
                let startMsg = (startTime) ? `${getFullDate(startTime)}` : '';
                let endMsg = (endTime) ? `${getFullDate(endTime)}` : '';
                const returnMsg = startMsg + ' ' + endMsg;
                return TimeRecordSuccess(`update complete : ${returnMsg}`);
            }
        }
    }
    
        
    //출퇴근 시간 직접 설정 시 근무가능시간 Validation
    // 1. 코어근무시간, 2. 근무 가능시간
    async registrationCustomVaildation(devision: string, startTime: Date, endTime?: Date): Promise<boolean> {

        const availableTimeVali = await this.coreTimeValidation(devision, startTime, endTime);
        const coreTimeVail = await this.availableWorkTimeValidation(devision, startTime, endTime);

        if (availableTimeVali && coreTimeVali) {
            return true;
        };

    }

	
    
    //부서별 코어 근무 시간과 비교해
    // 출퇴근 시간 등록 가능하면 바로, 안되면 승인 요청 요구.
    async coreTimeValidation(devision: string, startTime?: Date, endTime?: Date): Promise<boolean> {

        const { startTimeString, endTimeString } = await this.getCoreTimeByDevision(devision);

        if (startTime && startTimeString > getHoursAndMinutes(startTime)) {                        
            throw CoreTimeLangeException(`[request first] request first-1`);
        };
        if (endTime && endTimeString < getHoursAndMinutes(endTime)) {
            throw CoreTimeLangeException(`[request first] request first-2`);
        };

        return true;

    }


    //근무 가능 시간과 비교
    async availableWorkTimeValidation(devision: string, startTime?: Date, endTime?: Date): Promise<boolean> {

        //근무가능시간 시나리오가 나오면 작성 예정
        return true;

    }
    

}

 

 

해당 기능을 포함한 Attendence에 대한 비즈니스 로직, 벨리데이션 로직들을 쭉 써내려가다보니

 

벨리데이션을 분리하여 관리해주고 싶어졌다.

 

 

 


 

 

리팩토링

 

우선, 서비스의 비즈니스 로직에서 벨리데이션을 분리하여 응집도를 높여볼 수 있었다.

 

이는 벨리데이션의 조건이 변경되거나 추가될 때 등

벨리데이션을 관리하고 유지보수하는 데 더 용이하다.

 

//출결 등록, 변경 관련
@Injectable()
export class AttendanceValidationProvider {
    
        
    //출퇴근 시간 직접 설정 시 근무가능시간 Validation
    // 1. 코어근무시간, 2. 근무 가능시간
    async registrationCustomVaildation(devision: string, startTime: Date, endTime?: Date): Promise<boolean> {

        const availableTimeVali = await this.coreTimeValidation(devision, startTime, endTime);
        const coreTimeVail = await this.availableWorkTimeValidation(devision, startTime, endTime);

        if (availableTimeVali && coreTimeVali) {
            return true;
        };

    }

	
    
    //부서별 코어 근무 시간과 비교해
    // 출퇴근 시간 등록 가능하면 바로, 안되면 승인 요청 요구.
    async coreTimeValidation(devision: string, startTime?: Date, endTime?: Date): Promise<boolean> {

        const { startTimeString, endTimeString } = await this.getCoreTimeByDevision(devision);

        if (startTime && startTimeString > getHoursAndMinutes(startTime)) {                        
            throw CoreTimeLangeException(`[request first] request first-1`);
        };
        if (endTime && endTimeString < getHoursAndMinutes(endTime)) {
            throw CoreTimeLangeException(`[request first] request first-2`);
        };

        return true;

    }


    //근무 가능 시간과 비교
    async availableWorkTimeValidation(devision: string, startTime?: Date, endTime?: Date): Promise<boolean> {

        //근무가능시간 시나리오가 나오면 작성 예정
        return true;

    }
    

}

 

 

모듈의 Provider에 ValidationProvider을 추가하고, 서비스로 DI하여 사용하면 되겠다.

 

 

 

 

고 생각하였으나 문제가 발생했다.

 

Validation Provider의 코드를 자세히 보면 아래와 같은 코드가 있다.

 

const { startTimeString, endTimeString } = await this.getCoreTimeByDevision(devision);

 

위의 코드는, 사내 부서별 코어 근무시간과, 근무 가능 시간을 불러오는 코드로, 서비스단에 구현되어 있으며

 

현재는 커스텀 리파지토리를 구현하여 findOneByDevision(devision)을 호출하게 되어있다.

 

/**
 * 부서별 코어 근무시간, 근무 가능시간 조회
 * @param devision 
 * @returns 
 */
async getCoreTimeByDevision(devision: string): Promise<OffDayPlanCoreEntity> {

    const entity = await this.offDayPlanCoreRepo.findOne({ where: { devision: devision } });
    if (!entity) {
        throw CoreTimeRecordNotFoundException([Not Found] `${devision} row data not found`);
    };

    return entity;
}

 

 

Validation Provider에서 다시 서비스단에 의존을 하게 된다면,

 

Validation Provider가 단순 출결 서비스의 벨리데이션을 담당하는 모듈이 아니라 서비스와 동일한 수준의 모듈이 되어버린다. DIP를 위반하게 되는 것이다. (상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안된다)

 

또한 직접 의존하게 될 경우 결합도가 상승하여 로직이 변경될 경우, 서로에게 영향을 끼치게 된다.

최악의 경우 코드를 모두 수정해야할 수도 있다.

 

 

 

또한 Validation 클래스에서 직접 Repository를 호출하는 것 또한 DIP를 위배하고,

Validation만 수행하게 하려고 역할을 분리하여 클래스를 설계했는데, 이에 위배된다고 생각했다.

 

 

 

 

이런 저런 생각들을 통해 아래처럼 코드를 최종 변경할 수 있었다.

//부서별 코어 근무 시간과 비교해
// 출퇴근 시간 등록 가능하면 바로, 안되면 승인 요청 요구.
async coreTimeValidation(entity: CoreTimeEntity, startTime?: Date, endTime?: Date): Promise<boolean> {

    const { startTimeString, endTimeString } = entity;

    if (startTime && startTimeString > getHoursAndMinutes(startTime)) {                        
        throw CoreTimeLangeException(`[request first] request first-1`);
    };
    if (endTime && endTimeString < getHoursAndMinutes(endTime)) {
        throw CoreTimeLangeException(`[request first] request first-2`);
    };

    return true;

}

 

1. coreTimeValidation라는 네이밍에 맞게 코어 근무시간만 Validation하였으며

 

2. entity를 파라미터로 받았다.

 

 

이제 이 Validation 클래스는 이름에 맞게, 출퇴근의 Validation만 담당하게 될 것이며

Validation의 조건이 변경될 경우 해당 Validation만 변경해주거나, 파라미터만 수정해준다면 올바르게 동작할 것이다.

 

 

 

 

 

02.16 추가

감사하게도 댓글의 좋은 피드백을 받아, 한 번의 리팩토링을 더 거칠 수 있게 되었다.

 

1. 사용하지 않는 변수 제거

2. 함수명이 명확한 의미를 가지게 변경,

3. 함수를 boolean타입으로 리턴받아서 추가적으로 핸들링하는 것이 없기 때문에 void

(단순 에러 or 통과)

4. validateCoreTime만 봐도 어떤 부분들을 검증하는지 명확하게 볼 수 있게 내부 함수로 변경

 

특히 2,4번은 간과하고 있던 부분이라고 생각했다.

(다시한번 좋은 피드백을 제공해 주셔서 감사하다는 말씀을 전합니다.)

validateCoreTime(entity: CoreTimeEntity, startTime?: Date, endTime?: Date): void {

    const { startTimeString, endTimeString } = entity;

    if (startTime) {                        
    
    	this.validateStartCoreTime(startTimeString, startTime);
    
    };
    
    if (endTime) {
    
    	this.validateEndCoreTime(endTimeString, endTime);
    
    };
    
}

private validateStartCoreTime(startTimeString: string, startTime: Date): void {

	if (startTimeString > getHoursAndMinutes(startTime)) {
    
    	throw CoreTimeLangeException('[request first] request first-1');
    
    };

};
    

private validateEndCoreTime(endTimeString: string, endTime: Date): void {

	if (endTimeString > getHoursAndMinutes(endTime)) {
    
    	throw CoreTimeLangeException('[request first] request first-2');
    
    };

};

 

 

상위 함수도 변경할 수 있었다.

 

1. 상위 모듈인 서비스에서 entity를 들고오고

2. 네이밍도 명확하게 변경시켜주었다.

3. 또한 하위의 각 함수들이 무슨 역할을 하는지도 명확히 전달될 수 있게 변경해주었고

4. void타입의 함수들이기 때문에 함수의 로직도 변경시켜주었다.

async validateCustomWorkTime(entity: CoreTimeEntity, startTime: Date, endTime?: Date): Promise<void> {

    await this.validateCoreTime(entity, startTime, endTime);
    await this.validateAvailableWorkTime(entity, startTime, endTime);

}

 


 

 

마치며

 

같이 협업하는 백엔드 개발자가 없다보니,

 

코드 구조에 대한 공부를 혼자 해나가며, 이렇게 저렇게 적용해 보는 중이다.

 

 

 

 

또한 완전 새삥 프로젝트이다보니

 

프로젝트 구조며 서버 셋팅 또한 이래저래 해볼 수 있는 시간이 주어졌다.

 

잘 기록해서 기억해두고, 언젠가 누군가에게 피드백받을 수 있는 날이 왔으면 좋겠다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] TypeORM 0.3 버전의 CustomRepository 생성, Repository패턴 적용하기

삽질/트러블슈팅 2024. 1. 5. 10:48
728x90
728x90

 

 

0.2 버전

사내 서비스의 TypeORM버전은 0.2버전대를 사용중이다.

 

0.2버전대에서는 @EntityRepository 데커레이터를 지원하여, Repository를 커스텀화하여 리파지토리 클래스를 생성할 수 있었고,

이에 따라 Service와 Repository레이어를 분리하여 결합도를 낮출 수 있었다.

 

@Injectable()
export class RsvcenterService {

    constructor(
        @InjectRepository(CustomRsvRepository)
        private readonly customRsvRepo: CustomRsvRepository,
        //DB 관련 로직 예외처리 Provider
        private readonly customEm: CustomEntityManager,
    ) { }

    async getRsvList(id: string, rsvFilterQueryDto: RsvFilterQueryDto) {   
        //예약내역 조회
        const rsvData = await this.customRsvRepo.findAllById(id);

        //404 validation
        this.customEm.validateEntity(rsvData);

        return rsvData;
    }
}
@EntityRepository(RsvEntity)
export class CustomRsvRepository extends Repository<RsvEntity> {

    async findAllById(id: string) {
    	return await this.find({ where: { id: id }});
    }
}

 

 

 

0.3 버전

사내 서비스의 버전 관리 일정에 앞서 새로운 프로젝트 일정이 잡혔고,

새로운 프로젝트를 위한 서버 및 CICD 구축,, 등등 할 일이 생겼다. 해당 글의 주제와는 맞지 않으니 넘어가도록 하고.

여튼 새로운 프로젝트 구성을 위해 가급적 높은 버전을 사용해보고, 현재 버전과 다른점이 무엇인지 선파악 해보려했다.

 

그리하여 위의 서비스는 0.2.45버전을 사용중이지만, 신규 서비스의 TypeORM은 0.3.19버전을 사용하게 되었다.

 

 

 

Releases · typeorm/typeorm

ORM for TypeScript and JavaScript. Supports MySQL, PostgreSQL, MariaDB, SQLite, MS SQL Server, Oracle, SAP Hana, WebSQL databases. Works in NodeJS, Browser, Ionic, Cordova and Electron platforms. -...

github.com

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

 

 

기본적으로 릴리즈 노트를 확인하고, 공식 문서들을 찾아봤는데

공식 문서에서 기본적으로 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);
  }
}

 

 

더이상 @EntityRepository 데코레이터를 사용할 수 없게 되었고,

따로 커스텀 데커레이터를 만들까 하다가 원래도 상속받는 TypeORM의 Repository 클래스를 참조하여 작성해 보기로 했다.

 

export declare class Repository<Entity extends ObjectLiteral> {
    readonly target: EntityTarget<Entity>;
    readonly manager: EntityManager;
    readonly queryRunner?: QueryRunner;
    get metadata(): import("..").EntityMetadata;
    
    constructor(target: EntityTarget<Entity>, manager: EntityManager, queryRunner?: QueryRunner);
    
    //(...함수 생략...)
}

 

생성자로 Entity와 EntityManager, 선택사항으로 QueryRunner를 필요로한다는 것을 알아냈다.

이제 위의 코드를 0.3버전에 맞게 수정해보자

 

@Injectable()
export class CustomRsvRepository extends Repository<RsvEntity> {
    constructor( private dataSource: DataSource ) {
        super(RsvEntity, dataSource.createEntityManager());
    }
    
    async findAllById(id: string) {
    	return await this.find({ where: { id: id }});
    }
}

 

EntityManager를 생성하기위해 dataSource를 생성자로 사용했고,

QueryRunner는 단순 조회만 하는 예제이기 때문에 추가하지 않았다. 트랜잭션을 사용할 일이 있으면 추가하면 될 것 같아 보인다.

 

이렇게 작성한 후, 서비스를 아래와 같이 수정하고, 모듈의 Provider에 리파지토리를 추가해주고, 엔터티를 Import시키면 정상적으로 동작하는 것을 확인할 수 있었다. 

@Injectable()
export class RsvcenterService {

    constructor(
        private readonly customRsvRepo: CustomRsvRepository,
        //DB 관련 로직 예외처리 Provider
        private readonly customEm: CustomEntityManager,
    ) { }

    async getRsvList(id: string, rsvFilterQueryDto: RsvFilterQueryDto) {   
        //예약내역 조회
        const rsvData = await this.customRsvRepo.findAllById(id);

        //404 validation
        this.customEm.validateEntity(rsvData);

        return rsvData;
    }
}

 

 

 

 

후기

어떤 모듈을 설치한 후, 공식문서와 모듈 내장 클래스만 보고 직접 무언가를 작성하거나 수정하는 경험이 처음이었다.

만약 내가 작성한 커스텀 Repo가 잘못된 것일수도 있지만, 검색 없이도 공식문서와 내장 코드들을 들여다보며 수정할 수도 있구나 라는 생각에 뿌듯한 경험이 되었다.

 

혹시 더 좋은 코드가 있거나, 수정사항이 있으면 누군가 알려주셧으면 감사하겠습니다.. 꾸벅

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[TypeORM / QueryBuilder] Relation with property path confirms in entity was not found

삽질/트러블슈팅 2023. 12. 8. 11:51
728x90
728x90

 

최근 레거시 코드 중 DB 관련 로직들을 거의 대부분 쿼리빌더로 변경하는 작업을 완료하고, 검수중에 있다.

그 과정에서 발생한 에러들을 하나하나 정리하여 남기려고 한다.

 

 

에러 메세지

 

 

 

원인

관계 매핑이 정확하지 않아서 발생했다.

 

나의 경우는 아래 이유 때문에 발생했는데,

TypeORM의 쿼리빌더를 사용하는 과정에서, 커스텀 리파지토리를 생성하여, 해당 리파지토리에서 두 테이블을 조인해서 사용했는데,

 

처음 INNER JOIN을 시도한 테이블에서, enterprise라는 엔터티에 대한 정의를 내리지 않았기 때문에 발생했다.

 

해당 엔터티를 살펴보면,

export class EasyBookWhichEntEntity {
    @PrimaryGeneratedColumn({ type: 'int', name: 'no' })
    no: number;

    @Column('varchar', { name: 'enterprise_code' })
    enterpriseCode: string;

    @Column('varchar', { name: 'product_no' })
    productNo: string;

    @Column('int', { name: 'easy_book_no' })
    easyBookNo: number;

    @ManyToOne(() => EasyBookEntity, (book) => book.ents)
    @JoinColumn({ name: 'easy_book_no' })
    book: EasyBookEntity;
}

 

EasyBookEntity에 대한 정의만 내려져있지, enterprise에 대한 정의가 내려져있지 않았다.

 

 

해결

이제 해당 엔터티에 조인 컬럼의 정의를 내려주자.

export class EasyBookWhichEntEntity {
    @PrimaryGeneratedColumn({ type: 'int', name: 'no' })
    no: number;

    @Column('varchar', { name: 'enterprise_code' })
    enterpriseCode: string;

    @Column('varchar', { name: 'product_no' })
    productNo: string;

    @Column('int', { name: 'easy_book_no' })
    easyBookNo: number;

    @ManyToOne(() => EasyBookEntity, (book) => book.ents)
    @JoinColumn({ name: 'easy_book_no' })
    book: EasyBookEntity;
    
    @OneToOne(() => WmEnterpriseEntity, (ent) => ent.eBook)
    @JoinColumn({ name: 'enterprise_code' })
    enterprise: WmEnterpriseEntity;
}

 

export class WmEnterpriseEntity {
  @PrimaryGeneratedColumn({ type: 'bigint' })
  no: number;

	(...생략...)

  @OneToOne(() => EasyBookWhichEntEntity, (eBook) => eBook.enterprise)
  eBook: EasyBookWhichEntEntity;
}

 

 

정확히 관계 매핑을 해주고나면, 동작하는 모습을 볼 수 있다.

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록