백엔드 프레임워크와 ORM을 정리한 글에서, 자주 쓰는 조합은 템플릿 저장소로 만들어 둔다고 적었다. 이 글은 그 템플릿들이 프레임워크와 상관없이 공통으로 갖추는 항목이다. NestJS든 Spring이든 Fastify든, 서비스 기능을 만들기 전에 이 목록부터 채워 두면 운영 단계에서 "그때 넣어 둘걸" 하는 일이 확실히 줄었다.

보안 관련 설정 전반은 운영 서버 보안 체크리스트에 따로 있으니, 여기서는 애플리케이션 코드에서 정하는 규칙에 집중한다.

1. DB 연결: 처음부터 여러 개를 열 수 있게

처음에는 DB가 하나지만 읽기 전용 복제본, 통계용 DB, 레거시 시스템 DB가 나중에 붙는 일이 흔하다. 연결 설정을 단일 객체로 하드코딩하지 말고, 이름을 붙여 여러 개를 등록할 수 있는 구조로 만들어 둔다.

DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=app
DB_PASSWORD=change-me
DB_NAME=app

접속 정보는 전부 환경 변수로 빼고, 저장소에는 값이 빈 .env.example만 올린다. ORM이나 쿼리 빌더는 프로젝트 성격과 중요도를 보고 그때 고르되, 연결 계층은 그 선택과 분리해 둔다.

2. JWT: 리프레시해도 리프레시 만료는 늘리지 않는다

JWT_ISSUER=dailbit-api
JWT_ACCESS_SECRET=...
JWT_REFRESH_TOKEN_SECRET=...
JWT_ACCESS_TOKEN_EXPIRES_TIME=1d
JWT_REFRESH_TOKEN_EXPIRES_TIME=7d
  • 인증 미들웨어(가드)에서 헤더의 토큰을 꺼내 유효성 검사와 권한 검사를 한 번에 처리한다. 권한 값이 숫자인지 문자열인지는 서비스마다 다르므로 비교 로직을 한곳에 모아 둔다.
  • 액세스·리프레시 시크릿은 반드시 다르게 둔다. 같으면 리프레시 토큰을 액세스 토큰으로 쓸 수 있게 된다.
  • 발급한 토큰은 Redis에 저장해 두고 로그아웃·강제 만료 시 지운다. JWT만으로는 서버가 토큰을 취소할 방법이 없기 때문이다.
  • 리프레시 API는 액세스·리프레시 토큰을 둘 다 새로 발급하되, 리프레시 토큰의 만료 시각은 처음 발급한 시각 기준으로 유지한다. 리프레시할 때마다 7일이 다시 늘어나면, 탈취된 리프레시 토큰이 사실상 영원히 살아남는다.
// 새 리프레시 토큰의 exp를 "지금 + 7일"이 아니라 기존 토큰의 exp로 맞춘다.
const { exp } = verifyRefresh(oldRefreshToken);
const refreshToken = signRefresh(payload, { expiresAt: exp });

요청마다 남기는 감사(audit) 정보도 이 단계에서 정한다. 기본은 IP, 기기 ID, 요청 시각이고, 인증 헤더가 있으면 토큰을 디코드한 사용자 정보를 더한다.

3. 파일 저장 경로: 세 가지 중 무엇인지 먼저 정한다

업로드 파일을 어디에 둘지는 나중에 바꾸기 가장 번거로운 결정 중 하나다.

방식 장점 주의점
서버의 절대 경로 단순하다 서버를 늘리거나 옮기면 파일도 옮겨야 한다
API 서버 기준 상대 경로 설정이 가장 적다 컨테이너 재배포 시 사라질 수 있다(볼륨 필요)
외부 저장소(S3, R2 등) 서버와 분리, 확장 쉬움 비용·권한 설정이 추가된다

어느 쪽이든 기본 경로는 환경 변수로 분리하고, DB에는 상대 키만 저장한 뒤 응답할 때 전체 URL을 조합하는 편이 이전에 유리하다.

4. 에러 형식: 코드·상태·메시지 세 가지로 통일

{ "code": "JWT-001", "status": 401, "message": "토큰이 만료되었습니다." }
  • 에러 코드(JWT-001, USER-004)는 클라이언트가 분기할 때 쓰는 값이다. 메시지 문구를 바꿔도 앱이 깨지지 않는다.
  • HTTP 상태 코드는 표준 의미에 맞춘다.
  • 메시지는 사용자에게 보여 줘도 되는 문장으로 쓴다. 스택 트레이스나 SQL은 절대 응답에 넣지 않는다.

커스텀 에러 클래스 하나로 이 형식을 강제하고, 입력값 검증(Validation) 에러와 프레임워크가 던지는 HTTP 에러는 전역 필터에서 같은 형식으로 변환한다. 그리고 처리하지 못한 예외나 500 에러가 나면 Slack 웹훅으로 알림을 보낸다. 사용자가 신고하기 전에 먼저 아는 것만으로도 대응 속도가 크게 달라진다.

5. 요청 로그: 모든 요청에 같은 뼈대

항목 모든 요청 성공 시 실패 시
요청 시각, HTTP 메서드, 경로 ✅
응답 시간 ✅
에러 코드, 상태 코드, 메시지 ✅

형식이 일정해야 나중에 로그 수집 도구에 넣었을 때 "가장 느린 경로", "가장 많이 실패한 에러 코드" 같은 질문에 바로 답할 수 있다. 비밀번호나 토큰이 담긴 요청 본문은 로그에 남기지 않는다.

6. 스케줄러: 인스턴스가 여러 개여도 한 번만

로컬에서는 문제없던 크론 작업이, 서버를 두 대로 늘리는 순간 두 번씩 실행된다. 알림이 두 번 가거나 정산이 두 번 되는 사고가 여기서 나온다. 처음부터 다음 중 하나로 한 곳에서만 돌게 만든다.

  • 환경 변수로 스케줄러를 켤 인스턴스를 하나만 지정한다.
  • Redis 락이나 DB 락을 잡은 인스턴스만 실행한다.
  • 스케줄 작업을 별도 워커 프로세스로 분리한다.

크론 표현식은 라이브러리마다 필드 수가 다르다. 표준 cron은 분 시 일 월 요일 5개 필드이고, 일부 라이브러리는 맨 앞에 초를 더해 6개를 쓴다. 템플릿 주석에 어떤 형식인지 적어 두면 실수가 준다.

7. FCM 푸시: 알림 저장과 발송을 한 묶음으로

푸시 알림은 "보내기"보다 "기록과 배지 숫자 맞추기"가 더 까다롭다.

  • 알림 생성 서비스를 대상별로 나눈다. 특정 회원, 특정 권한, 전체 등. 이들이 공통 배치 메서드를 호출해 알림 DB에 쌓고 FCM으로 발송한다.
  • Firebase 서비스 계정 키는 저장소 밖에 두고 경로만 환경 변수로 넘긴다.
FCM_KEY_JSON_LOCATION=/secrets/fcm-key.json
FCM_PROJECT_ID=my-app
  • iOS 배지 숫자는 서버가 직접 계산해 보내야 한다. 발송할 때 apns.payload.aps.badge에 읽지 않은 알림 수를 넣는다.
  • 사용자가 알림을 읽어 개수가 바뀌면, DB에서 남은 개수를 다시 세어 소리 없는(silent) 푸시로 배지만 갱신한다.
await messaging.send({
  token,
  apns: { payload: { aps: { badge: unreadCount, "content-available": 1 } } },
});

8. 그 밖에 템플릿에 미리 넣는 것

  • Swagger: 개발·스테이징에서만 노출하고 production에서는 꺼 둔다.
  • 메트릭 엔드포인트: 나중에 Prometheus 같은 모니터링을 붙일 수 있게 미리 열어 두되, 외부에서는 접근하지 못하게 한다.
  • CORS: 허용 출처를 환경 변수로 관리한다. *로 시작하지 않는다.
  • 요청 본문·파일 크기 제한: 기본값에 맡기지 말고 명시한다.
  • 글로벌 프리픽스: /api/v1처럼 버전을 포함해 두면 나중에 호환성을 깨는 변경을 할 때 숨 쉴 틈이 생긴다.
  • 요청 수 제한(Throttle): 시작값은 IP당 1분 500회 정도로 두고, 로그인·인증 코드 발송처럼 민감한 경로는 훨씬 낮게 따로 설정한다.
  • Docker: .dockerignore에 .env, node_modules, 로그 파일을 넣고, Dockerfile은 멀티 스테이지로 빌드 도구를 최종 이미지에서 뺀다. DB나 Redis를 함께 띄울 때만 docker-compose.yml을 쓴다.

9. DB 쪽 준비: 슬로우 쿼리 로그 켜기

애플리케이션 로그에서 응답이 느린 경로를 찾았다면, 다음은 어떤 쿼리가 느린지 알아야 한다. MySQL은 슬로우 쿼리 로그가 기본으로 꺼져 있는 경우가 많으니 먼저 현재 값을 확인한다.

SELECT
  @@slow_query_log,
  @@long_query_time,
  @@log_queries_not_using_indexes,
  @@min_examined_row_limit;

시작 단계에서 권장하는 값은 이렇다.

SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = OFF;
SET GLOBAL min_examined_row_limit = 0;
설정 의미 권장 이유
slow_query_log 슬로우 쿼리 기록 여부 켜 둔다
long_query_time 몇 초 이상을 느린 쿼리로 볼지 1초에서 시작해 서비스에 맞게 낮춘다
log_queries_not_using_indexes 인덱스를 쓰지 않은 쿼리도 기록할지 작은 테이블 전체 스캔까지 쌓여 로그가 금방 커진다
min_examined_row_limit 최소 몇 행 이상 검사한 쿼리만 기록할지 처음에는 0으로 모두 본다

모든 SQL을 기록하는 general log는 운영에서 켜지 않는다. 디스크와 성능을 크게 잡아먹고, 파라미터가 그대로 남아 개인정보가 로그에 쌓인다.

SET GLOBAL은 서버를 재시작하면 원래대로 돌아간다. 유지하려면 my.cnf에 같은 값을 적거나 MySQL 8의 SET PERSIST를 쓴다. RDS 같은 관리형 DB는 파라미터 그룹에서 바꾼다.

정리

영역 처음부터 정할 것
DB 다중 연결 구조, 환경 변수 분리
인증 가드에서 유효성+권한, Redis 저장, 리프레시 만료 고정
파일 저장 방식 3가지 중 선택, 경로 환경 변수화
에러 코드·상태·메시지 형식, 500은 Slack 알림
로그 시각·메서드·경로 + 성공/실패별 항목
스케줄러 다중 인스턴스에서 한 곳만 실행
푸시 저장+발송 배치, iOS 배지는 서버 계산
운영 Swagger 비노출, CORS, 크기·요청 수 제한, Docker
DB 진단 슬로우 쿼리 로그 ON, general log OFF

기능 하나 없는 빈 서버에 이것들을 먼저 넣는 게 과해 보일 수 있다. 하지만 서비스가 돌기 시작한 뒤에 에러 형식이나 토큰 정책을 바꾸면 이미 배포된 앱까지 함께 고쳐야 한다. 처음 하루를 더 쓰는 쪽이 훨씬 싸다.

참고 자료