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차적으로 해당 포스팅에 이어 작성하고, 분량이 많아지면 정리하여 새로운 포스팅으로 정리하자.)
1편의 연장선으로, 실제 기술 면접의 코드 리뷰에서 mysql2에 의존적인 코드를 어떻게 변화에 유연하게 만들것이냐는 질문을 받았다. 레이어간 역할을 명확하게 하는데 그치지 않고 데이터베이스에 엑세스하는 레이어를 두 단계로 나눠서, 레포지토리 레이어는 단순 쿼리 실행 레이어로 인프라 레이어를 데이터베이스와 연결하는 레이어로 두어 인프라 레이어만 교체하게끔 할 수 있다고 생각했다.
ORM, DB 라이브러리와 비슷하게 실무에서 많이 쓰는 외부 의존성이 무엇이 있을까 생각해보다가, 실제로 운영중인 서비스에서 많이 활용중인 Nest의 HttpModule을 바탕으로 의존성을 제어해보고자 했다. 현재 운영중인 서비스의 HTTP 클라이언트 라이브러리 사용 구간은 전환이 이루어지지 않은 일부를 비롯하여 샌드버드, 웹 훅, SNS 회원가입 시 약관 정보를 가져오기 위해 사용하는 등 여러 구간에서 애플리케이션의 핵심적인 기능을 담당하고 있다.
기존 모듈의 한계
위 코드는 공식 문서의 예제, 아래 코드는 실 사용중인 샌드버드 API의 일부이다.
공식 문서에 따르면 AxiosResponse는 axios에서 내보낸 인터페이스이며, 모든 HttpService 메서드들은 Observable로 래핑된 AxiosResponse를 반환한다고 한다.
실제로 소스를 들여다보면, get 메서드는 Axios Promise -> Observable을 반환하게 되어있다. 현 상황에서 Observable의 이점을 활용할 수 없다는 판단이 섰다. 유연한 처리(요청 취소, 체이닝 등)를 할 필요가 없는 상황. 단순 응답 데이터를 받아 그대로 활용하는 상황에서 굳이? 라는 생각을 했다.
하고 개발을 완료했었는데, 최근 인프랩 면접을 기점으로 코드를 보는 시각이 조금 달라졌다. 항상 구현에서 그치지 않고 유연한 설계를 지향하기 시작했다.
axiosRef는 axios 그 자체이다. AxiosInstance는 axios를 상속받은 구현체이다. 그럼 나는 불필요하게 axios를 사용하기 위해서 특정 프레임워크에 종속되어 있는건가? 왜 굳이 HttpService를 사용해야하지?
라는 생각들이 마구 들어 직접 HTTP 클라이언트 라이브러리를 활용하여 HttpService를 구현해보고자 했다. 추가로 axios에서 fetch로, got, undici로 HTTP 클라이언트 라이브러리가 교체되더라도 유연한 구조를 설계해보고 싶었다.
독립적인 HTTP 모듈 구성
우선 생각한 구조는 다음과 같다. HTTP 메서드들을 추상화한 인터페이스를 활용해서, HttpService에서 이를 상속받아 인터페이스의 메서드들을 구현하고 이 서비스에서 직접적으로 HTTP 클라이언트 라이브러리에 의존하기 때문에, 라이브러리가 교체되더라도 HttpService만 교체하면 되는 상황을 설계하고자 했다.
이제 Nest의 주입 포맷에 맞게, 주입해서 사용해주면 된다. 원하는 대로 설계는 잘 이루어졌고 제대로 동작했다.
관련해서 레퍼런스들을 찾다가, 올해 코딩적 영감을 가장 많이 받은 인프랩의 기술 블로그에 같은 주제의 포스팅이 있었다. 포스팅을 서너번 읽어봤는데 코드의 가독성이 훨씬 더 좋고, 내부 서버에서 사용할 클래스들로 유연하게 변경시켜주는 차이점이 있었다.
나는 위 설계를 바탕으로 코드를 작성한지 2시간 반 만에 리팩토링을 해야만 했다. 아니 반드시 하고 싶었다.
리팩토링
응답 데이터로 유연하게 객체 생성하기
우선, 응답받은 데이터를 바로 내부에서 사용하는 객체로 변환시켜주고자 했다. 처음엔 단순 class-transformer의 plainToInstance만을 사용하려고 했으나, 응답받는 데이터가 Snake Case이거나, 불규칙적인 형태일 경우 모두 고려해야했고, 현 상황에서 모두를 아우를 수 있는 형태로 발전하게 되었다.
(class-transformer는 class-validator와 더불어 시너지를 내기도 좋고, nest라는 프레임워크를 사용하는 이유 중 하나라고 생각을 하기 때문에, 적극 사용중이다.)
기존에는 실제 axios와 똑같이, get메서드 하나에 모든 파라미터를 다 집어넣어서 데이터를 뽑아내는 방식이었다. 취향 차이일지 모르겠으나 내 기준에서는 위 기술 블로그의 메서드 체이닝 방식이 훨씬 읽기 편하고, 원하는 메서드만 골라서 사용할 수 있어 훨씬 유연할 것 처럼 보였다.
최종 형태의 SendbirdService가 완성되었다. 위 코드에서, 어디에도 axios에 의존적인 구간은 없는 것으로 보인다. 설계한 의도대로 잘 흘러간 것 같다.
정리
HTTP 클라이언트 라이브러리를 사용하기 위해 추가적인 프레임워크의 특정 모듈에 의존적이어야 하는 상황. 이 상황을 너무 종속적이고 유연하지 못하다고 판단했고, 실제 구현한 HttpService 구현체를 통해 다른 라이브러리로의 교체가 이루어지더라도 해당 서비스만 교체하면 될 것 처럼 설계했다.
라이브러리를 교체해도 모듈 내부만 교체하면 된다. 외부에서는 교체됐는지 알 필요도, 알 수도(???) 없다. 추가로, 인터페이스 자체를 모킹해서 테스트에도 용이할 것이다.
레퍼런스들을 찾아보면, 대부분이 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에 따른 관련 설정들을 따로 독립적으로 구성하고 관리할 필요가 없게 되었다.
Docker 환경에서는, 볼륨 마운트로 로그를 수집했지만, PM2로 전환하면서 심볼릭 링크가 변동이 되기 때문에 인스턴스에서 current 디렉토리가 아닌 특정 경로에 고정된 방식으로 로그를 내보내게 되었다. Promtail에서 해당 경로만 지정해준다면, 문제 없이 기존처럼 로그를 용이하게 수집하고 관리할 수 있다.
급하게 반영된 프로덕션의 코드가 잘못되어 롤백해야할 때, 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배 단축시킨 것에 현재는 만족한다.
@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를 통해 충분히 관리되고 있다.
좀 더 클린한 코드를 작성하기 위한 리팩토링 중에 레이어의 인터페이스 사용은 위와 같은 판단의 근거들을 통해 사용하지 않기로 결정했다.
(관련해서 피드백을 해주신다면 소정의 성의를 돌려드리겠습니다!!! 많관부)
정답은 없어도 오답은 있다는 말처럼, 면접 간에 내가 선택하고 작성한 코드에 대해 명확하고 자신감 있게 판단의 근거를 제시하지 못해 아쉬었다. 물론 기술 면접은 통과했었지만, 내면에 정의된 기술적 지식이 조금 더 있었다면 보다 나은 코드 리뷰를 할 수 있지 않았을까. 하는 아쉬움이 남는다.
최근 조영호 님의 오브젝트 책을 구매했다. 객체 지향 쪽에서의 조금 더 나은 구조와 코드 설계를 위해 학습하고 있다.
이런 학습들을 하다보면, 기술 선택 뿐 아니라 내가 작성해나가는 코드 한 줄 한 줄에 어떤 판단의 근거를 가지고 작성했는지 조금 더 명확하게 말할 수 있지 않을까. 이런 것들이 쌓이다보면 개발자로서의 중간 목표인 오프라인 세션에서 나의 지식과 경험을 공유하는 사람. 으로 한 발 더 다가갈 수 있으리라 믿는다.
팀장님께서 나를 불렀다. "집계 함수를 사용해서 요청사항 집계를 하는 SQL문이 있는데 ~~~ 성능 최적화를 좀 할 수 있을까요?"
기존 쿼리를 받아들고 돌려보았다.
기존 상태
SELECT
YEAR(a.registe_time) AS year,
MONTH(a.registe_time) AS month,
a.code,
count(a.code) AS total_count,
(SELECT
count(DISTINCT a1.id)
FROM
dbname.tbname AS a1
WHERE
a1.code = a.code
AND YEAR(a1.registe_time) = YEAR(a.registe_time)
AND MONTH(a1.registe_time) = MONTH(a.registe_time)
AND a1.code != 0
AND a1.id != ''
) AS dup_count
FROM
dbname.tbname as a
WHERE
YEAR(a.registe_time) = 2023
AND a.code!=0
AND a.id!=''
GROUP BY
YEAR(a.registe_time),
MONTH(a.registe_time),
a.code
order by year asc, month asc, total_count desc;
위는 요청받은 쿼리이다.
위의 쿼리에서, WHERE, GROUP BY, 서브쿼리에 공통으로 들어가는 code, registe_time, id에 대해 단일 인덱스만 생성되어있고, 복합 인덱스는 생성되어 있지 않았다.
복합 인덱스 생성
실제로 code에 따른 조회, 그룹핑에 가장 많은 리소스를 차지했고, id와 registe_time은 크게 순서가 상관이 없다고 판단하여 code, registe_time, id의 순서로 복합 인덱스를 생성했다.
성능이 일부 개선되었지만, 여전히 select절에 사용되는 서브쿼리가 마음에 들지 않았다.
윈도우함수 사용 시도
SQLD, SQLP 공부를 할 때의 기억을 끄집어내서, 아래처럼 변경해주려고 했다.
# 기존
(SELECT
count(DISTINCT a1.id)
FROM
dbname.tbname AS a1
WHERE
a1.code = a.code
AND YEAR(a1.registe_time) = YEAR(a.registe_time)
AND MONTH(a1.registe_time) = MONTH(a.registe_time)
AND a1.code != 0
AND a1.id != ''
) AS dup_count
# window function으로 변경
COUNT(DISTINCT a.id)
OVER (
PARTITION BY a.code,
YEAR(a.registe_time),
MONTH(a.registe_time)
) AS dup_count
결론적으로 개선되어야 할 부분은 ID의 DISTINCT 연산을 한 번만 계산하게끔 유도하는 것이다.
기존 쿼리는, SELECT절에서, RAW마다 DISTINCT연산을 수행하기 때문이다. 다시 정리해봤다.
결국 필요한 데이터는 registe_time, id, code, COUNT(DISTINCT(id)) 이다.
정리를 얼추 하고나니, 방향이 어느정도 잡혔다. 결국 위의 데이터를 중복연산 없이 단 1번만 조회해서 활용하게 하면 된다고 생각했다.
SELECT
YEAR(registe_time) AS year,
MONTH(registe_time) AS month,
code,
COUNT(code) AS total_count
COUNT(DISTINCT id) AS dup_count
FROM
dbname.tbname
WHERE
YEAR(registe_time) = 2023
AND code != 0
AND id != ''
GROUP BY
YEAR(registe_time),
MONTH(registe_time),
code
ORDER BY
year ASC,
month ASC,
total_count DESC;
WITH절(CTE)을 사용해보자
WITH tempTable AS (
SELECT
YEAR(registe_time) AS year,
MONTH(registe_time) AS month,
code,
COUNT(DISTINCT id) AS dup_count
FROM
dbname.tbname
WHERE
YEAR(registe_time) = 2023
AND code != 0
AND id != ''
GROUP BY
YEAR(registe_time),
MONTH(registe_time),
code
)
SELECT
YEAR(a.registe_time) AS year,
MONTH(a.registe_time) AS month,
a.code,
COUNT(a.code) AS total_count,
b.dup_count
FROM
dbname.tbname AS a
JOIN
tempTable AS b ON a.code = b.code
AND YEAR(a.registe_time) = b.year
AND MONTH(a.registe_time) = b.month
WHERE
YEAR(a.registe_time) = 2023
AND a.code != 0
AND a.id != ''
GROUP BY
YEAR(a.registe_time),
MONTH(a.registe_time),
a.code,
b.dup_count
ORDER BY
year ASC, month ASC, total_count DESC;
위의 쿼리는, WITH를 이용해 DISTINCT(id)연산을 아래 메인쿼리의 raw에 맞게, 같은 조건으로 단 한번만 수행할 수 있게 작성한 쿼리이다.
이를 통해 중복 연산을 없앴고, 메인 쿼리에서 임시 데이터를 조인하여 select해주면 간편히 처리가 가능하다!!
실제로 연산 속도도 기존보다 10배나 증가한 모습이다.
끝!!! 이 아니라
잘 처리해놓고 기분좋게 성과를 말씀드려야겠다!!! 하고 전달드리고 난 뒤 몇시간 뒤에 업무일지를 기록하는 도중에 의문이 생겼다.
중복 아이디를 카운트하는데에 SELECT SUBQUERY, WITH 등등의 추가 리소스가 굳이 필요한가..? 에 대한 의문이었다.
SELECT
YEAR(registe_time) AS year,
MONTH(registe_time) AS month,
code,
COUNT(code) AS total_count,
COUNT(DISTINCT id) AS dup_count
FROM
dbname.tbname
WHERE
YEAR(registe_time) = 2023
AND code != 0
AND id != ''
GROUP BY
YEAR(registe_time),
MONTH(registe_time),
code
ORDER BY
year ASC, month ASC, total_count DESC;
실제로 위 쿼리는, 같은 결과를 반환하는 동시에, 조회 성능이 조금이라도 더 좋은 코드가 되었다.
살포시 팀장님께 메세지를 추가해서 보내드렸다..
진짜 끝
데이터의 형태, 쿼리의 목적에 따라 조금 성능이 쳐질 수 있어도 WITH절을 활용한다던지, 프로시저 등의 활용으로 가독성을 높일 수도 있고, 이번 포스팅의 쿼리처럼 단순 성능에 치우쳐 코드를 작성해야할 수도 있는 것 같다.
API, 서버개발만 하는 현 상황에서, 가끔가다 들어오는 데이터 처리 관련 임무는 귀중한 경험이 되는 것 같다.
우선, 프로젝트 배포 시 프로젝트 내부에서 docker-compose를 이용해서, 로키와 프롬테일 모두 빌드시켜주고 있는 기존 프로세스가 잘못됐다고 판단했다. 관리가 너무 불편했다.
프롬테일 재배포해야할 때, 로키만 재배포해야할 때, 간단하게 config파일만 수정하면 되는데 내부에 config가 있기 때문에, runner를 다시 동작하게 푸시를 해줘야했다. 그리고 액션파일에서, 각 상황에 맞게 분기처리마저 해주어야 하기 때문에, 생산성이 떨어진다고 판단했다.
그리하여 서버 내부에 독립적으로 프롬테일, 로키 컨테이너를 생성하게 변경했다.
간단한 테스트 구성
내가 담당하는 서비스는 크게 두가지로 분류된다.
거의 모든 로그를 커스텀화하여 받아봐야하는 서비스 사용자의 동작 중 일부분만 로그를 남기면 되는 서비스
전자의 서비스는, 미들웨어를 통해 중앙집중식으로 관리할 계획이고 후자의 서비스는 특정 서비스 로직의 마지막 지점에만 커스텀 로그를 작성할 수 있는 함수를 구현하여 finally로 남길 것이다. (실패 시 API 실패 로그와 함께, 특정 로그는 무조건적으로 같은 로그로 남아야 하기 때문에 finally에 작성하는 것이 좋다고 생각했다.)
전자의 경우로 로그 테스트를 위해 개발서버에, 로깅 미들웨어를 적용했다. 기본 베이스는 아래와 같고, 상황에 맞게 커스텀해서 사용하면 된다.
// log.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { WinstonModule } from 'nest-winston';
import * as winston from 'winston';
import { LokiDto } from './loki.dto';
@Injectable()
export class LogMiddleware implements NestMiddleware {
private readonly logger = WinstonModule.createLogger({
transports: [
new winston.transports.Console(),
],
});
use(req: Request, res: Response, next: NextFunction): void {
const { userId, type, when } = req.body;
const data = new LokiDto().build(userId, type, when);
this.logger.log('info', 'Request received', data);
res.on('finish', () => {
this.logger.log('info', 'Response sent', {
...data,
});
});
next();
}
}
//app.module.ts
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LogMiddleware } from './log.middleware';
@Module()
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LogMiddleware)
.forRoutes('*');
}
}
그라파나에서 로그 확인
얼추 해당 패널에서, 생성한 커스텀 로그들을 확인할 수 있었다.
개선해야할 부분
LogQL을 사용한 조회
단순히 JSON형태로 로그를 작성하면 LogQL으로 조회도 쉽게 가능할 줄 알았더니 애로사항이 있었다.
해당 유저만 조회하려면? 해당 타입만 조회하려면?
의 이슈가 있다고 생각했다.
{job="info"} |= `userId` |= `type`
위의 사진의 조회를 위한 logQL이다.
유저 아이디가 TEST인 유저를 조회하려면
{job="info"} |= `"userId":"TEST"` |= `type`
이렇게 사용하면 되기야 하겠지만, 단순 Line Contains가 딱 ID, TYPE으로 떨어지게끔 해야 다른 사용자들도 편하게 사용할 수 있지 않을까. 이미 구현되어있지만 내가 못찾은걸까? 나에게 남은 숙제인 것 같다.
쿼리 시간범위 제한의 초과
Status: 500. Message: the query time range exceeds the limit (query length: 2160h0m0.001s, limit: 30d1h)
위의 에러메세지는, 내가 90일 이상의 로그를 조회하려고 했을 때 나타난 에러이다.
현재 서비스들에 필요한 로그는 거의 수 년 단위의 로그를 필요로 할 것으로 예상되는데, 이를 해결할 수 있는지에 대한 조사가 필요할 것 같다. 근본적으로 이걸 해결하지 못한다면, 굳이 서비스에 적용시킬 이유가 없다고 본다. DB에 로깅을 하고, 이를 조회할 수 있는 방법 혹은, 그마저도 안된다면 뷰페이지를 만드는 것이 더 이득일 수도 있을 것 같다.
마무리
현재까지 적용된 두 서비스 중 전자의 서비스의 테스트 대시보드이다.
필요에 따라, 필요한 부분을 LogQL로 조회하는 패널과, 해당 서비스의 트래픽이 몰리는 시간대를 파악할 수 있는 패널을 구성해보았다.
다음 포스팅에선, 개선사항들을 개선하여 반영해보도록 하겠다.
트러블 슈팅
Verify that Loki and Promtails is configured properly
장장 10월말부터 시작된 도입에 대한 얘기가, 일정이 밀리고 밀려 2월 초부터 시작되었다.
밀리고 밀린 일정인데 앞 뒤의 일정들이 모두 빡빡해서, 단기간에 제작해야했다.
다시 정리하자면, 기존 채팅서비스의 가장 큰 크리티컬한 이슈인
모든 작업이 끝난 후 렌더링이 된다는 점 (모든 동작을 수행하는 데 3~10초로 긴 시간)
을 해결하기 위해 소켓 서버에서 메세지 송신 시 동작을 최대한 경량화 했지만, 1~3초로 긴 동작시간을 가질 수 밖에 없었다. 기존 서비스에서, 메세지 기록을 남기고, 사내 고객 담당자들이 업무를 편하게 하기위한 관련 데이터도 따로 저장되어 나오기 때문에 더이상 경량화 할 수없었다.
카카오톡의 선렌더링 후 메세지 전송 실패 시의 UI
카카오톡처럼, 먼저 렌더링 시킨 후 서버 동작에 따라 송신 성공 여부를 보여주면 되지 않냐?
라는 측면에서 봤을 때에도 문제가 있었다.
채팅 리스트, 채팅 내부 코드를 전부 걷어내고 새로운 작업을 해야만 하는데, 내부 일정이 너무 빡빡하여 해당 작업에 시간을 할애할 수 없었던 환경 탓에, 수뇌부 측에서 샌드버드를 도입하자! 라는 결론이 나오게 되었다.
ERD
샌드버드 관련 테이블.
샌드버드 관련 부분만 떼내어 재생성한 테이블 구성도이다.
메세지 관련 작업이 가장 우선시될 것이기에 위와 같이 설계했던 것 같다.
샌드버드 서버 내에 다 저장이 되는 부분들이지만
서비스 특성상 고객을 담당하는 담당자가 배정되기 때문에, 담당자를 위한 기존 시스템에서 해당 채팅들을 불러오고, 기존 기능들이 원활히 동작하게 하기 위해, 혹시모를 백업을 위해 따로 사내 DB에 데이터를 저장하기로 했다.
시스템 아키텍쳐
★직접 구성해보고 그려보는 것이 처음이라 어색하거나 비효율적인 부분은 알려주시면 적극 피드백 수용하겠습니다.
처음 그려보는 구성도가 어색하실 분들을 위해.. 설계 의도 및 구성은 다음과 같다.
1. 개발 코드는 Git을 통해 관리되며, Git Action을 통해 CI/CD됨 2. Action running 시 각각 main / dev 브랜치를 통해 production / staging 컨테이너로 배포됨 3. production은 무중단 배포를 적용 시켜놓았음 (blue/green 배포, 백업/롤백 전략이 있음) 4. 클라이언트에서 nginx를 통해 프론트 컨테이너로 접근하고, 백엔드 컨테이너와 상호 소통을 함. 5. 프론트 단에서 sendbird SDK를 사용해 샌드버드 서버에 접근함. 6. 백엔드 단에서는, 서버 내의 DB 서버와, 필요 시 REDIS를 사용할 수 있게 구성. 7. 백엔드 단에서, 로그 수집과 시각화를 위해 PLG스택을 사용.
아래는 샌드버드의 docs에 있는 아키텍처 구성도.
채팅 서비스만 상세히 설명해놓은 아키텍처 설명은 없었지만, Salesforce에서, salesforce만 빼면 전반적인 채팅에 대한 구성도가 되지 않을까 싶어 참고했다.
후기
sendbird SDK자체는 엄청 개발하는데 있어 사용하기 쉽고 편리했다.
또한 전반적인 작업을 맡은 내가 프론트 개발자가 아님에도 불구하고, 커스터마이징을 한다거나 채팅 혹은 채널이 랜더링 될 때 핸들링해서 추가작업을 하기에도 편했다. (UI KIT 내부를 뜯어보고 하나하나 사용하면 되었다.)
하지만 사내 서비스에 올리는 과정에서, 서비스팀의 업무를 위한 쪽도 고려하면서,
포스팅에 나타낼 수는 없지만, 최대한 레거시랑 분리하기 위해 많은 시도들을 했다. 이 과정에서 필연적으로 수행해야하는 작업들이 있는데, 효율적이지 못한 작업들이라 아쉽다.
하지만 가장 큰 이슈였던 채팅 서비스의 가장 큰 본질인 화면의 빠른 갱신 (채팅의 주고받음에 있어) 이라는 목적은 달성한게 아닌가? 라는 생각에는 긍정적이다.
대부분의 어려움은, PLG를 사내 노드 호스트서버 / 프로젝트 컨테이너 중 어디 빌드해야할지에 대한 것이었다.
PLG를 어디 설치하지?, 또 Promtail-Loki는 Promtail이 로그파일들을 수집해서 Loki로 전송한다는 측면에서 같은 곳에다 빌드하는 게 맞는 것 같은데, Grafana는 호스트서버에 설치해야하나???? 이런 고민들을 많이 했고, 이리저리 부딪히고 깨져가며 빌드를 시도했다.
그리고 전반적인 인프라 ( +도커 )에 대한 이해도가 떨어진다고 느꼈던 시간들이었다. 반드시 공부해야할 것 같다.
repo actions의 일부
여러 컨테이너들의 로그를 한번에 받아볼 수 있게 Grafana는 호스트 서버에 설치했고 Promtail, Loki는 컨테이너 마다 빌드해주었다.
Promtail의 config에서, 컨테이너마다 job 네이밍을 다르게하여 각 서비스별로 구분지었다.
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9096
common:
instance_addr: 127.0.0.1
path_prefix: /tmp/loki
storage:
filesystem:
chunks_directory: /tmp/loki/chunks
rules_directory: /tmp/loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
query_range:
results_cache:
cache:
embedded_cache:
enabled: true
max_size_mb: 100
schema_config:
configs:
- from: 2020-10-24
store: tsdb
object_store: filesystem
schema: v12
index:
prefix: index_
period: 24h
ruler:
alertmanager_url: http://localhost:9093
# By default, Loki will send anonymous, but uniquely-identifiable usage and configuration
# analytics to Grafana Labs. These statistics are sent to https://stats.grafana.org/
#
# Statistics help us better understand how Loki is used, and they show us performance
# levels for most users. This helps us prioritize features and documentation.
# For more information on what's sent, look at
# https://github.com/grafana/loki/blob/main/pkg/analytics/stats.go
# Refer to the buildReport method to see what goes into a report.
#
# If you would like to disable reporting, uncomment the following lines:
#analytics:
# reporting_enabled: false
promtail은, 로그를 수집하는 역할을 하기 때문에, 알맞은 경로와 원하는 Job_name을 적어주었다.
-- 예시 쿼리.. 근무 시작 시간 오름차순으로
-- 알림 보내야할 데이터 SELECT
SELECT
임직원 근태 테이블의 필드들
FROM
임직원 근태 테이블
WHERE
DAY = '오늘'
AND
TYPE NOT IN ('휴무', '공휴일', '경조사', '기타')
ORDER BY
WORK_START_TIME ASC
매 분 단위로, 위 쿼리를 사용해서 가져온 데이터들을 알림보내야 하는 조건에 맞는지 추가 로직을 태우던
쿼리에서 연산을 해서 나오던 매 분단위로 돌아간다고 생각하니 머리가 지끈했다. 데이터 건수가 많아질수록 문제가 분명 커질 것이다.
수행해야 할 것들을 또다시 정리해보았다.
1. Persistance Layer에 요청을 최대한 적게 보내기
2. 연산을 최대한 작게 수행하기 (조건에 반복에 조건에 반복......... 최대한 줄이기)
1번은 레디스를 사용하면 되겠다는 해답이 즉각 나왔지만, 연산을 최대한 적게해서 부하를 줄여주려다 보니 다시 1번으로 돌아올 수 밖에 없었다.
어떻게 효율적으로 key_value를 담아야 연산을 적게할 수 있을까...에 대한 고민을 시작했고, redis의 명령어들에 대한 시간 복잡도나 기타 성능들을 파악하여