Skip to content

Repository files navigation

✈️ 여정

  • 보를 공유하다

여행 이야기를 나누는 익명 한국인 중심 커뮤니티 서비스입니다.

X, Reddit처럼 간단한 텍스트와 몇 장의 이미지로 소통하는 익명 커뮤니티임.

주 카테고리는 여행으로, 추천·후기·질문을 남기는 형식.

주 이용자는 한국인이며, 프론트엔드에서 기기 시간대를 반영해 외국 현지에서도 한국 사용자와 자연스럽게 소통할 수 있도록 설계함.


서비스 규모 가정

여행 이야기는 주로 일과를 마친 18시~24시에 가장 활발하다고 가정하고, 이 트래픽 프로파일을 기준으로 자원과 인프라를 설계함.

지표
MAU 100만
평균 RPS 약 11.6 (100만 ÷ 86,400초)
피크타임 RPS 평시 대비 약 100배
최대 동시접속 약 3,000명
  • CPU: 순간 처리량(RPS)에 비례하므로 피크 기준 약 100배로 산정
  • 메모리: 빠른 API 위주라 동시 연결 수가 낮게 유지됨 → RPS에 비례하지 않아 커넥션 풀 기준으로 조정
  • 자원 산정은 추측이 아닌 crictl stats / htop 실측값에 여유분을 얹는 방식으로 진행

기술 스택

구분 사용 기술
Backend Spring Boot, JPA, MySQL, Redis
Search Elasticsearch (Nori, Fuzzy)
Infra Kubernetes(kubeadm), Envoy Gateway, cert-manager, ArgoCD
Observability Prometheus, Loki, Grafana, Alloy (PLG), Portainer
CI/CD GitHub Actions, Trivy, Docker, ArgoCD (GitOps)


Backend

1. 응답 구조 통일

핸들러를 통해 모든 API의 응답 구조를 하나로 통일함.

상황 처리 흐름
성공 ApiResponse 객체 반환
예상된 실패 서비스 로직에서 CustomException throw → GlobalExceptionHandler가 캐치 → ErrorCode에서 status·message를 꺼내 ApiResponse.fail() 반환
알 수 없는 실패 예상치 못한 예외를 GlobalExceptionHandler가 캐치 → ApiResponse.fail("서버 에러") 반환

필드값이 비어있을 때 500이 나오던 문제

원래는 400 BadRequest가 나야 했지만 헤더 누락 시 MissingRequestHeaderException, 바디 누락 시 HttpMessageNotReadableException이 핸들링 이전 단계에서 던져져 500(서버 에러)으로 처리되고 있었음. 해당 예외를 상속받아 커스텀 처리하여 400으로 정상 응답하도록 수정함.

에러 코드·메시지 상수 풀 관리

공통 예외 메시지를 여러 곳에서 사용하면서 한곳에서 관리할 수 있도록 상수 풀로 설정함.

로그 메세지 통일

PLG Stack에서 좀 더 좋은 모니터링과 과도한 로그 쌓임 방지를 위해 로그 메세지도 통일함

  • 기존 : Hibernate 쿼리 로그만 찍혀 어떤 요청이 들어와 어떤 결과가 됐는지 알 수 없었음
  • 변경 후 : ApiLoggingFilter로 모든 API의 응답 시간·응답 코드를 남기고, 에러는 4xx→warn / 5xx→error로 구분해 상세 메시지를 기록하도록 커스텀함

2. N+1 문제 해결

목록 조회 시 유저 데이터 N+1

아래 세 가지 방식을 비교해 선택했습니다.

방식 특징 선택
Fetch Join 페이지네이션 시 행이 늘어나 LIMIT이 깨질 수 있음
Batch Size 확장성은 좋지만 '조회'만 하는 화면엔 과한 설계
DTO Projection 순수 DTO만 반환해 성능이 좋고 반복 구조 데이터에 적합

게시글 세부 조회 시 좋아요·댓글 수 N+1

좋아요는 댓글보다 변동이 잦아 캐싱도 어렵고, 데이터가 많아지면 매번 카운트하는 것 자체가 비싸다고 판단.

→ 비정규화(컬럼 추가) 방식 채택. 아래는 로컬 MySQL에서 게시글 200개·좋아요 50개·댓글 20개 기준으로 측정한 결과.

방식 결과
일반 카운트 조회 쿼리 수 400 / 196ms
DTO Projection 쿼리 수 2 / 62ms
비정규화(컬럼 +1) **쿼리 수 1 / 3ms
일반 카운트 대비 약 98.06% 감소, DTO Projection 대비 약 93.33% 감소**

좋아요 수가 많아질수록 더 유리해짐. 처리 시간이 일정하게 유지되는 반면, 나머지 방식은 데이터에 비례해 늘어나기 때문임.

  • 좋아요 수 → 비정규화 (변동 잦음, 캐싱 어려움)
  • 댓글 수
    • 목록 조회: 댓글 수만 반환 → 비정규화
    • 세부 조회: 전체 데이터를 한 번에 반환 → DTO Projection

3. 인증 / 인가

  • JWT 토큰 기반 인증/인가
  • Access Token + Refresh Token 사용, RTR(Refresh Token Rotation) 적용
  • 서버를 2대 이상으로 변경하면서 각 서버 로컬에 두었을 때 새로고침 시 캐시 미스로 재로그인이 발생하는 이슈 발생 → Refresh Token은 Redis에 저장하여 세션 공유할 수 있도록 관리함.

4. 고아 이미지 처리

이미지가 교체·삭제될 때 실제 S3 객체가 남아 낭비되는 문제를 두 단계로 처리함.

  • ex) 하루 프로필 이미지 변경이 사용자의 0.1%(약 1천 건)만 일어나도 한 달이면 약 3만 건의 버려지는 데이터가 발생

업데이트 시

  • DB 컬럼은 즉시 삭제
  • 실제 S3 객체는 스케줄러로 매일 새벽에 일괄 삭제
    • S3 즉시 삭제가 아닌 이유
      • 혹시 모를 백업 여지 확보
      • 피크 시간대의 S3 삭제 요청은 커넥션 풀을 낭비 → 사용량이 적은 새벽에 실행

소프트 딜리트 시

  • 소프트 딜리트된 데이터는 일정 시간 후 실제 삭제 (포스트 30일 / 유저 7일)
  • 매일 실행되는 스케줄러가 이때 S3 객체까지 함께 정리 → 고아 데이터까지 처리

5. 검색 API [Elasticsearch]

MySQL의 LIKE는 한글 형태소 검색에 한계가 있음.

FULLTEXT·ngram은 JPA로 설정할 수 없어 직접 쿼리를 써야 하고, 형태소가 아닌 글자 단위 분해라 의미 단위 검색이 어려움.

역색인 기반의 Elasticsearch로 이를 해결.

목표

  • 형태소 분해로 조사 없이도 검색 (아몬의, 아몬과, 아몬이랑 → 모두 아몬으로 매칭)
  • 오타 보정 (여행 특성상 외국어→한글 변환이 사람마다 달라 필요, 아문으로 검색해도 아몬 매칭)

설계 요약

항목 내용
형태소 분석 Nori (decompound_mode: mixed), 조사·어미 등 불용 품사 제거
오타 보정 fuzziness: AUTO:2,5 (짧은 지역명·물품 검색 대응)
검색 대상 제목·내용·댓글 (필드별 가중치 title^4, content^2, comments)
문서 구조 게시글 단위 문서에 댓글을 text 배열로 비정규화 (여행 카페 조사 결과 댓글 20개 내외)
색인 갱신 Dirty 테이블(Outbox) 기반 증분 배치 (5분 주기, 회당 최대 500건)
정렬 정확도순(_score) / 최신순(createdAt), 동점 시 postId로 안정 정렬

k8s 배치: 공식 이미지에는 Nori가 없어 Nori를 포함한 커스텀 이미지를 ECR에 보관하고, 내부 변경 시에만 빌드가 트리거되도록 구성함

성능 검증

MySQL LIKE 검색과 비교해 정확도·속도를 검증함.

검색 정확도 : 직접 검색으로 확인

케이스 Elasticsearch LIKE
Nori 형태소 내용에 일본·숙소·추천이 포함되면 검색됨 일본 숙소 추천 문구가 정확히 없으면 미검색 (일본 단일어는 검색됨)
Fuzzy 오타 보정 일본일번으로 잘못 검색해도 보정되어 검색됨 오타 보정 불가 → 미검색

검색 속도 : 게시글 15개가 걸리도록 테스트, 콜드 스타트 배제 위해 웜업 후 측정

게시글 규모 LIKE(ms) Elasticsearch(ms)
100 4 15
500 4 15
1000 2 14
3000 7 15
10000 20 13
30000 36 13
100000 78 12
  • LIKE는 게시글이 늘수록 소요 시간이 선형으로 증가(2ms → 78ms)
  • ES는 규모와 무관하게 12~15ms로 거의 일정함
  • 약 1만 건 지점부터 ES가 역전, 10만 건에서는 LIKE(78ms) 대비 ES(12ms)가 약 6.5배 빠름
    • ES는 검색으로 id 목록을 받은 뒤 그 id로 DB를 재조회하므로 쿼리가 LIKE보다 1개 더 많음.
      • 이 id 조회는 가벼운 쿼리라, 게시글 양이 많아질수록 ES의 전체 이득이 쿼리 1개 비용보다 커짐


Infra

1. 아키텍처

Kubernetes 롤링 배포 : Docker를 활용한 무중단 방식은 Blue/Green 방식을 사용했으나, k8s로 도입하면서 더 적합한 무중단 배포를 위해 k8s 롤링 배포로 전환하고 Graceful Shutdown을 적용함.

  • 워커 노드 2대 (kubeadm on EC2): 단일 장애 지점 제거 + 롤링 배포 시 자원 여유 확보 (maxSurge: 1, maxUnavailable: 0)
  • Envoy Gateway: nginx(hostPort) → Gateway API로 전환. 벤더 종속이 적고 표준성·확장성이 좋음
  • cert-manager (HTTP-01): Let's Encrypt 인증서 자동 발급·갱신. Docker 시절 nginx에서 수동 발급하던 것을 자동화
  • ArgoCD (GitOps): GitHub의 값을 클러스터에 자동 반영. 순환 의존성을 피하기 위해 기반 리소스는 EC2에서 직접 apply, 앱 티어는 auto-sync
  • Portainer / PLG: 현재 상태 확인용 운영 UI(Portainer) + 장기 관측·분석(PLG) 역할 분리

2. CI : Github Actions Runner 캐시 & 멀티 러너

빌드 시간을 줄이기 위해 CI에 캐시와 멀티 러너를 적용함.

  • 의존성/빌드 캐시 사용
  • 멀티 러너로 CI 작업 병렬 처리
구분 적용 전 적용 후 비교
캐시 1m 33s 45s 약 51.6% ↓
멀티 러너 1m 46s 1m 16s 약 28.3% ↓
  • 멀티 러너 환경에서 total runner 실행 환경을 확인했을 때,
    • 병렬 실행 총 2m 21s → 무료 약 850번 사용 가능
    • 단일 실행 총 1m 44s → 무료 약 1,150번 사용 가능
  • runner 자원을 아끼고자 멀티 러너 사용 하지 않음.

3. Trivy : 이미지 취약점 점검

CD 파이프라인에 Trivy 스캔을 추가함.

대상 최초 조치 후
Ubuntu OS 패키지 0건 0건 ( - )
애플리케이션 JAR 20건 (HIGH 17 / CRITICAL 3) 0건 (100% ↓)

대부분 오래된 Spring Boot(4.0.6)에 종속된 의존성에서 취약점이 발생해서, Spring Boot를 4.0.7로 올려 종속 의존성까지 패치하면서 취약점 0건을 달성함.


4. Claude 코드 리뷰

ci-pr 워크플로에서 단위 테스트 통과 후, 변경된 diff에 대해 Claude가 한국어로 코드 리뷰를 남기도록 구성함.

  • CLAUDE.md에 코드 컨벤션 정리 (패키지 구조 / 네이밍 / 포맷팅 / Lombok / 계층별 설정 / 예외처리 / 주석 / 테스트 / 설계 원칙)
  • 토큰 절약을 위해 Sonnet 모델 사용, diff에만 집중, 파일당 최대 5개 이슈
  • Critical/High만 인라인 코멘트, 나머지는 요약 표로 집계
리뷰 프롬프트
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}

이 PR을 한국어로 리뷰해줘. PR 브랜치는 이미 현재 작업 디렉토리에 체크아웃되어 있어.

## 리뷰 범위 (토큰 절약)
- 변경된 파일의 diff에만 집중해. diff와 무관한 파일이나 전체 코드베이스를 탐색하지 마.
- CLAUDE.md는 스타일 규칙 확인에 필요한 부분만 읽어.
- 파일당 심각도 높은 순으로 최대 5개 이슈까지만 다뤄.

## 확인할 것
- 버그 가능성
- 보안 문제
- CLAUDE.md에 정리된 코드 스타일 위반 여부

## 댓글 방식
- 인라인 코멘트는 Critical/High 이슈에만 사용해. Medium 이하는 요약 표 개수로만 집계해.
- 전체 요약은 아래 형식 그대로 `gh pr comment`로 딱 하나만 남겨.
- 반드시 GitHub 댓글로 남겨. 메시지로만 답하지 마.

## 요약 댓글 형식
## 🤖 Claude AI 코드 리뷰
**리뷰 타입:** 🔍 full
**검토한 파일:** {N}개
**발견된 이슈:** {합계}개

| 심각도 | 개수 | 설명 |
|--------|------|------|
| 🔴 Critical | {n} | 즉시 수정이 필요한 심각한 문제 |
| 🟠 High | {n} | 중요한 문제, 빠른 수정 권장 |
| 🟡 Medium | {n} | 일반적인 개선 사항 |

About

카카오테크 부트캠프 과제 관리 레포지토리 입니다.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages