중고 거래 플랫폼 — MSA(Microservices Architecture) 기반으로 설계된 풀스택 중고 거래 서비스입니다. Docker Compose 한 명령으로 전체 시스템을 기동할 수 있습니다.
운영 환경 (AWS EKS) 아키텍처 설계는 infra.md, 단계별 배포 런북은 eks-deploy.md 를 참고하세요. CI/CD 브랜치 전략, 파이프라인, 배포 흐름은 cicd.md 를 참고하세요.
- MSA · DB per Service — 도메인별 PostgreSQL 4개 인스턴스로 분리, 서비스 간 DB 직접 공유 없음
- 분산 트랜잭션 — 거래 제안 수락을 2-Phase + 보상 트랜잭션으로 처리해 서비스 간 정합성 확보
- 데이터 비정규화 + 동기화 — cross-domain JOIN 제거용 생성 시점 스냅샷, 변경 시 내부 HTTP로 동기화
- 실시간 채팅 — WebSocket + Redis pub/sub, 서비스 수평 확장에도 메시지 브로드캐스트
- 분산 추적 — OpenTelemetry + Jaeger로 요청 하나의 서비스 fan-out을 end-to-end 추적
- 전문 검색 — PostgreSQL FTS(GIN 인덱스) + Redis Sorted Set 기반 실시간 인기 검색어
- 2-tier 배포 — 로컬은 Docker Compose, 운영은 AWS EKS (Istio · Karpenter · ArgoCD GitOps)
동일한 MSA 구성을 로컬은 Docker Compose, 운영은 AWS EKS 두 타깃으로 배포합니다.
Mermaid 다이어그램
flowchart TB
Browser(["클라이언트\n브라우저"])
subgraph AWS["AWS"]
direction LR
CF["CloudFront CDN\n이미지 서빙"]
S3[("S3\nreboot-images")]
CF --- S3
end
subgraph Platform["Docker Compose"]
direction TB
GW["Nginx :3333\nAPI Gateway"]
subgraph FrontEnd["프론트엔드"]
FE["Next.js 14\n:3000"]
end
subgraph Services["백엔드 서비스 · FastAPI · Python 3.12"]
direction LR
US["user-service\n:3001\n회원·JWT·인증"]
LS["listing-service\n:3002\n상품 CRUD·이미지"]
OS["offer-service\n:3003\n제안·수락·거절"]
SS["search-service\n:3004\nFTS·인기검색어"]
CS["chat-service\n:3006\n실시간 채팅\nWebSocket+Redis pub/sub"]
AS["admin-service\n:3005\n대시보드·관리\ndual DB pool"]
end
subgraph DataStore["데이터 저장소"]
direction LR
PGU[("postgres-user\n:5433\nreboot_user")]
PGL[("postgres-listing\n:5434\nreboot_listing")]
PGO[("postgres-offer\n:5435\nreboot_offer")]
PGC[("postgres-chat\n:5436\nreboot_chat")]
RD[("Redis 7\n:6379\n캐시 · pub/sub\n인기검색어")]
end
subgraph Observability["관측성"]
JAE["Jaeger\n:16686\n분산 추적 UI"]
end
end
%% ── 클라이언트 진입 ──────────────────────────────────────────
Browser -- "페이지 · API 호출" --> GW
Browser -- "이미지 로드" --> CF
Browser -. "직접 PUT 업로드\n(presigned URL)" .-> S3
%% ── API Gateway 라우팅 ───────────────────────────────────────
GW -- "/*" --> FE
GW -- "/api/auth\n/api/users" --> US
GW -- "/api/listings" --> LS
GW -- "/api/offers" --> OS
GW -- "/api/search" --> SS
GW -- "/api/chat\n/ws/chat (WebSocket)" --> CS
GW -- "/api/admin" --> AS
%% ── DB / Cache ───────────────────────────────────────────────
US --- PGU
US --- RD
LS --- PGL
LS --- RD
OS --- PGO
OS --- RD
SS --- PGL
SS --- RD
CS --- PGC
CS --- RD
AS --- PGU
AS --- PGL
%% ── 이미지 스토리지 ──────────────────────────────────────────
LS -. "presigned URL 발급\n(boto3)" .-> S3
%% ── 서비스 간 내부 HTTP (JWT 없음, Docker 내부망) ───────────
OS -- "GET /internal/listings/{id}\nPATCH /internal/listings/{id}/status" --> LS
OS -- "GET /internal/users/{id}" --> US
OS -- "POST /internal/chat/rooms" --> CS
CS -- "GET /internal/listings/{id}" --> LS
CS -- "GET /internal/users/{id}" --> US
US -- "PATCH /internal/listings/seller-nickname" --> LS
AS -- "GET /internal/offers/stats\n today · activity · trend" --> OS
%% ── 분산 추적 ────────────────────────────────────────────────
US & LS & OS & SS & CS & AS -. "OTel OTLP :4317" .-> JAE
%% ── 스타일 ───────────────────────────────────────────────────
classDef svcStyle fill:#dbeafe,stroke:#3b82f6,color:#1e3a8a,rx:6
classDef dbStyle fill:#fef9c3,stroke:#ca8a04,color:#713f12
classDef awsStyle fill:#fce7f3,stroke:#db2777,color:#831843
classDef gwStyle fill:#dcfce7,stroke:#16a34a,color:#14532d
classDef obsStyle fill:#f3e8ff,stroke:#9333ea,color:#4c1d95
class US,LS,OS,SS,CS,AS svcStyle
class PGU,PGL,PGO,PGC,RD dbStyle
class CF,S3 awsStyle
class GW,FE gwStyle
class JAE obsStyle
Route 53 → ALB (TLS 종료) → Istio Ingress Gateway → EKS 서비스 Pod 구조로, Karpenter 노드 오토스케일링과 ArgoCD GitOps 배포를 사용합니다. 리소스 구성·설계 의도는 infra.md, 단계별 배포 절차(Terraform → Helm 런북)는 eks-deploy.md 를 참고하세요.
① 클라이언트 → listing-service POST /api/listings/presigned-url
② listing-service → S3 (boto3) presigned PUT URL 생성 (TTL 1h)
③ 클라이언트 → S3 이미지 직접 PUT 업로드
④ 클라이언트 → listing-service POST /api/listings/{id}/images (object_key 저장)
⑤ 화면 표시 ← CloudFront https://{CLOUDFRONT_DOMAIN}/{object_key}
| 호출자 | 피호출자 | 엔드포인트 | 목적 |
|---|---|---|---|
| offer-service | listing-service | GET /internal/listings/{id} |
offer 생성 시 상품 정보 캡처 |
| offer-service | listing-service | PATCH /internal/listings/{id}/status |
offer 수락 시 상품 → reserved |
| offer-service | user-service | GET /internal/users/{id} |
offer 생성 시 buyer 닉네임 캡처 |
| offer-service | chat-service | POST /internal/chat/rooms |
offer 생성 시 자동 채팅방 생성 (fire-and-forget) |
| chat-service | listing-service | GET /internal/listings/{id} |
채팅방 생성 시 상품 정보 조회 |
| chat-service | user-service | GET /internal/users/{id} |
채팅방 생성 시 닉네임 조회 |
| user-service | listing-service | PATCH /internal/listings/seller-nickname |
닉네임 변경 동기화 |
| admin-service | offer-service | GET /internal/offers/stats,today,activity,trend |
대시보드 집계 |
| 홈 · 상품 그리드 & 통합 검색 | 상품 목록 · 카테고리 필터 / 정렬 |
|---|---|
![]() |
![]() |
| 상품 상세 · 거래 제안 | 실시간 채팅 · WebSocket + Redis pub/sub |
![]() |
![]() |
| 거래함 · 보낸/받은 제안 (예약중 처리) | 관리자 대시보드 · KPI & 7일 추이 차트 |
![]() |
![]() |
거래 제안 생성(POST /api/offers) 요청 하나가 offer-service → listing · user · chat 세 서비스로 fan-out되는 과정을 OpenTelemetry로 추적한 화면입니다. 단일 트랜잭션이 4개 서비스 · 31개 span에 걸쳐 실행되며, 각 서비스 간 내부 HTTP 호출과 DB / Redis 쿼리까지 하나의 타임라인에서 확인할 수 있습니다.
- 핵심 특징
- 서비스 아키텍처
- 화면 미리보기
- 기술 스택
- 디렉토리 구조
- 서비스별 API 엔드포인트
- DB 스키마 (DB per Service)
- 환경 변수
- 로컬 실행 가이드
- 주요 설계 결정사항
- Redis 키 패턴
- 프론트엔드 페이지 구성
| 항목 | 기술 | 버전 |
|---|---|---|
| 런타임 | Python | 3.12 |
| 웹 프레임워크 | FastAPI + uvicorn | 0.115 / 0.30 |
| DB 클라이언트 | asyncpg (비동기 PostgreSQL) | 0.29 |
| 캐시 / 세션 | redis-py (asyncio) | 5.x |
| 인증 | python-jose[cryptography] (HS256 JWT) | 3.3 |
| 비밀번호 해싱 | passlib[bcrypt] + bcrypt | 1.7.4 / 4.0.1 |
| 유효성 검사 | Pydantic v2 | 2.x |
| 설정 관리 | pydantic-settings | 2.4 |
| Rate Limiting | slowapi | 0.1.9 |
| 이미지 스토리지 | boto3 (S3 presigned URL) | 1.35 |
| 서비스 간 통신 | httpx (비동기 HTTP 클라이언트) | 0.27 |
| 항목 | 기술 | 버전 |
|---|---|---|
| 프레임워크 | Next.js (App Router) | 14.x |
| 언어 | TypeScript | 5.x |
| 스타일링 | Tailwind CSS | 3.x |
| 서버 상태 | TanStack Query (React Query) | 5.x |
| 클라이언트 상태 | Zustand | 4.x |
| HTTP 클라이언트 | Axios | 1.x |
| 폼 관리 | React Hook Form + @hookform/resolvers | 7.x |
| 유효성 검사 | Zod | 3.x |
| 차트 | Recharts | 2.x |
| 아이콘 | lucide-react | — |
| 항목 | 기술 |
|---|---|
| 컨테이너 | Docker + Docker Compose |
| API Gateway | Nginx 1.25 (WebSocket 업그레이드 지원) |
| 데이터베이스 | PostgreSQL 16 × 4 인스턴스 (DB per Service) |
| 캐시 / Pub-Sub | Redis 7 |
| 오브젝트 스토리지 | AWS S3 (ap-northeast-2) |
| CDN | AWS CloudFront |
| 분산 추적 | OpenTelemetry SDK + Jaeger 1.60 |
reboot-market/
├── docker-compose.yml # 전체 서비스 오케스트레이션
├── .env.example # 환경 변수 템플릿
├── .env # 실제 환경 변수 (git 제외)
│
├── docker/ # Docker Compose 로컬 환경 설정
│ ├── nginx/
│ │ └── nginx.conf # API Gateway 라우팅 설정 (WebSocket 프록시 포함)
│ ├── postgres/
│ │ ├── init-user.sql # users 테이블 스키마
│ │ ├── init-listing.sql # categories, listings, listing_images 스키마
│ │ ├── init-offer.sql # offers 테이블 스키마 (비정규화 컬럼 포함)
│ │ └── init-chat.sql # chat_rooms, messages 스키마
│ └── redis/
│ └── redis.conf # Redis 설정 (maxmemory, eviction policy 등)
│
├── services/
│ ├── user-service/ # 회원가입·로그인·JWT 발급·프로필 (포트 3001)
│ │ └── app/
│ │ ├── main.py
│ │ ├── config.py # LISTING_SERVICE_URL 포함
│ │ ├── database.py # postgres-user 커넥션 풀
│ │ ├── telemetry.py # OTel SDK 초기화 (FastAPI/httpx/asyncpg/redis)
│ │ ├── routers/
│ │ │ ├── auth.py
│ │ │ ├── users.py
│ │ │ └── internal.py # GET /internal/users/{id}
│ │ └── services/
│ │ ├── auth.py # JWT 페이로드에 nickname 포함
│ │ └── users.py # 닉네임 변경 시 listing-service 비동기 PATCH
│ │
│ ├── listing-service/ # 상품 CRUD + S3 presigned URL (포트 3002)
│ │ └── app/
│ │ ├── telemetry.py # OTel SDK 초기화 (FastAPI/httpx/asyncpg/redis)
│ │ ├── routers/
│ │ │ ├── listings.py
│ │ │ └── internal.py # GET /internal/listings/{id}
│ │ │ # PATCH /internal/listings/{id}/status
│ │ │ # PATCH /internal/listings/seller-nickname
│ │ └── services/
│ │ ├── listings.py # seller_nickname 비정규화, active+reserved 목록 반환
│ │ └── storage.py # S3 presigned URL 생성
│ │
│ ├── offer-service/ # 거래 제안·수락·거절·취소 (포트 3003)
│ │ └── app/
│ │ ├── config.py # LISTING_SERVICE_URL, USER_SERVICE_URL, CHAT_SERVICE_URL
│ │ ├── telemetry.py # OTel SDK 초기화 (FastAPI/httpx/asyncpg/redis)
│ │ ├── routers/
│ │ │ ├── offers.py
│ │ │ └── internal.py # GET /internal/offers/stats,today,activity,trend
│ │ └── services/
│ │ └── offers.py # 제안 생성 시 listing+user HTTP 조회 (비정규화)
│ │ # 중복 제안 시 기존 pending 제안 가격 업데이트
│ │ # accept: Phase1(로컬 트랜잭션) + Phase2(HTTP → listing)
│ │ # 제안 생성 시 chat-service로 자동 채팅방 생성 (fire-and-forget)
│ │
│ ├── search-service/ # FTS 검색·인기검색어·카테고리 트리 (포트 3004)
│ │ └── app/
│ │ ├── telemetry.py # OTel SDK 초기화 (FastAPI/asyncpg/redis)
│ │ ├── routers/
│ │ │ └── search.py # 검색 API 엔드포인트
│ │ └── services/
│ │ └── search.py # postgres-listing 직접 접근 (read-only)
│ │
│ ├── chat-service/ # 실시간 채팅 (포트 3006)
│ │ └── app/
│ │ ├── config.py # LISTING_SERVICE_URL, USER_SERVICE_URL
│ │ ├── database.py # postgres-chat 커넥션 풀
│ │ ├── telemetry.py # OTel SDK 초기화
│ │ ├── routers/
│ │ │ ├── chats.py # REST API (방 생성/목록/메시지/읽음)
│ │ │ ├── internal.py # POST /internal/chat/rooms (offer-service에서 호출)
│ │ │ └── ws.py # WebSocket /ws/chat/{roomId} (JWT 쿼리 파라미터 인증)
│ │ └── services/
│ │ └── chat.py # Redis pub/sub 실시간 메시징, 안읽은 수 카운터
│ │
│ └── admin-service/ # 관리자 대시보드·회원·상품 관리 (포트 3005)
│ └── app/
│ ├── database.py # pool_user + pool_listing 듀얼 풀
│ ├── config.py # DATABASE_URL_USER, DATABASE_URL_LISTING, OFFER_SERVICE_URL
│ ├── telemetry.py # OTel SDK 초기화 (FastAPI/httpx/asyncpg, redis 제외)
│ ├── routers/
│ │ └── admin.py # 관리자 전용 API 엔드포인트
│ └── services/
│ └── admin.py # asyncio.gather로 두 DB + offer HTTP 병렬 조회
│
├── helm/reboot-market/ # Helm Chart (→ docs/infra.md 참고)
│ ├── Chart.yaml # 차트 메타데이터
│ ├── values.yaml # 서비스별 설정 · 글로벌 기본값
│ └── templates/ # K8s 리소스 템플릿
│ ├── deployment.yaml # 7개 서비스 (range 기반)
│ ├── service.yaml # ClusterIP × 7
│ ├── hpa.yaml # HPA (CPU 70%, min2/max5)
│ ├── pdb.yaml # PDB (minAvailable: 1)
│ ├── ingress-main.yaml # ALB 1 — 메인 서비스 (경로 기반)
│ └── ingress-ops.yaml # ALB 2 — 운영도구 (호스트 기반, IP 제한)
│
├── terraform/ # AWS IaC (→ docs/infra.md 참고)
│ ├── environments/prod/ # 프로덕션 환경 설정
│ └── modules/ # VPC, EKS, RDS, ElastiCache, ECR, IAM, Bastion
│
├── .github/workflows/ # CI/CD (→ docs/cicd.md 참고)
│ ├── ci.yml # PR 검증 (lint/test/docker build, 변경 서비스만)
│ ├── cd.yml # main 머지 → ECR push → ArgoCD 배포
│ └── infra-check.yml # Terraform validate + Helm lint
│
├── docs/
│ ├── images/ # README 다이어그램 · 스크린샷
│ ├── infra.md # 운영 아키텍처 설계 문서 (AWS EKS)
│ ├── eks-deploy.md # EKS 배포 런북 (Terraform → Helm 0~11단계)
│ └── cicd.md # 브랜치 전략 + 파이프라인 문서
│
├── scripts/ # 유틸리티 스크립트
│ └── test.sh # 통합 테스트 스크립트
│
└── frontend/ # Next.js 14 App Router (포트 3000)
└── src/
├── app/
│ ├── page.tsx # 홈 (최신상품 그리드 + 검색)
│ ├── listings/
│ │ ├── page.tsx # 상품 목록 + 필터 (active·reserved 모두 표시)
│ │ ├── new/
│ │ └── [id]/ # 상품 상세 + 거래 제안 + 채팅
│ ├── offers/ # 내 제안 / 받은 제안 (20초 폴링 자동 갱신)
│ ├── chat/
│ │ ├── page.tsx # 채팅 목록 (안읽은 수 뱃지)
│ │ └── [roomId]/ # 채팅방 (실시간 메시지, 읽음 표시)
│ └── admin/
├── components/
│ ├── layout/
│ │ ├── Navbar.tsx # 채팅 뱃지 + 거래함 뱃지 (pending 제안 수, 20초 폴링)
│ │ └── Footer.tsx # 사이트 푸터
│ ├── listings/ # ListingCard, ListingForm, ListingGrid
│ ├── search/ # SearchBar, CategoryFilter, RecentSearchDropdown
│ └── offers/
│ └── OfferForm.tsx
├── hooks/
│ ├── useOffers.ts # refetchInterval: 20s
│ └── useChat.ts # 채팅 API hooks (rooms, messages, unread)
└── lib/
└── api.ts # Axios 클라이언트 (서비스별 baseURL + 토큰 인터셉터)
| Method | Path | 인증 | 설명 |
|---|---|---|---|
GET |
/health |
— | 헬스체크 |
POST |
/api/auth/register |
— | 회원가입 |
POST |
/api/auth/login |
— | 로그인 (access + refresh 토큰 발급) |
POST |
/api/auth/refresh |
— | access 토큰 갱신 |
POST |
/api/auth/logout |
✓ | 로그아웃 (Redis refresh 토큰 무효화) |
GET |
/api/users/me |
✓ | 내 프로필 조회 |
PATCH |
/api/users/me |
✓ | 내 프로필 수정 (닉네임 변경 시 listing-service 비동기 동기화) |
GET |
/api/users/{userId} |
— | 공개 프로필 조회 |
GET |
/internal/users/{userId} |
내부 전용 | offer/chat 생성 시 닉네임 조회 |
JWT 전략:
- Access Token:
python-joseHS256 서명, TTL15m,Authorization: Bearer <token>헤더 - Refresh Token: TTL
7d, Redisrefresh:{user_id}에 저장 - JWT 페이로드:
sub,role,email,nickname(listing-service에서 seller_nickname 사용) - 각 서비스가
JWT_ACCESS_SECRET으로 독립 검증
| Method | Path | 인증 | 설명 |
|---|---|---|---|
GET |
/health |
— | 헬스체크 |
GET |
/api/listings |
— | 목록 조회 (active + reserved 포함, 카테고리·가격 필터, 페이지네이션) |
POST |
/api/listings |
✓ | 상품 등록 (JWT의 nickname → seller_nickname 비정규화) |
GET |
/api/listings/me |
✓ | 내 상품 목록 |
GET |
/api/listings/{id} |
— | 상세 조회 (조회수 자동 증가, Redis 캐시 120s) |
PATCH |
/api/listings/{id} |
✓ 소유자 | 상품 수정 |
DELETE |
/api/listings/{id} |
✓ 소유자 | 상품 soft delete (status='deleted') |
POST |
/api/listings/presigned-url |
✓ | S3 presigned PUT URL 발급 |
POST |
/api/listings/{id}/images |
✓ 소유자 | 이미지 메타데이터 저장 |
GET |
/internal/listings/{id} |
내부 전용 | offer/chat 생성 시 listing 정보 조회 |
PATCH |
/internal/listings/{id}/status |
내부 전용 | offer accept 시 listing 상태 변경 (멱등성 보장) |
PATCH |
/internal/listings/seller-nickname |
내부 전용 | 닉네임 변경 시 비정규화 동기화 |
| Method | Path | 인증 | 설명 |
|---|---|---|---|
GET |
/health |
— | 헬스체크 |
POST |
/api/offers |
✓ | 거래 제안 생성 (기존 pending 제안 시 가격 업데이트) |
GET |
/api/offers |
✓ | 내가 보낸 제안 목록 |
GET |
/api/offers/received |
✓ | 내가 받은 제안 목록 (판매자) |
PATCH |
/api/offers/{id}/accept |
✓ 판매자 | 제안 수락 |
PATCH |
/api/offers/{id}/reject |
✓ 판매자 | 제안 거절 |
PATCH |
/api/offers/{id}/cancel |
✓ 구매자 | 제안 취소 |
GET |
/internal/offers/stats |
내부 전용 | admin 통계 집계 |
GET |
/internal/offers/today |
내부 전용 | admin 오늘 현황 |
GET |
/internal/offers/activity |
내부 전용 | admin 활동 피드 |
GET |
/internal/offers/trend |
내부 전용 | admin 7일 트렌드 |
제안 생성 흐름 (비정규화 + 채팅방 자동 생성):
POST /api/offers
├─ GET listing-service /internal/listings/{id} → listing 정보 캡처
├─ GET user-service /internal/users/{buyer_id} → buyer 닉네임 캡처
├─ 기존 pending 제안 존재? → UPDATE offered_price, message (가격 변경)
├─ 신규? → INSERT offers (비정규화 컬럼 포함)
└─ POST chat-service /internal/chat/rooms → 채팅방 자동 생성 (fire-and-forget)
수락 2-Phase 처리:
Phase 1 (offer-db 로컬 트랜잭션):
BEGIN
SELECT offer FOR UPDATE → 동시 수락 방지
UPDATE offers SET status='accepted'
UPDATE offers SET status='rejected' WHERE listing_id=$1 AND status='pending'
COMMIT
Phase 2 (HTTP):
PATCH listing-service /internal/listings/{id}/status {"status":"reserved"}
실패 시 보상 트랜잭션:
UPDATE offers SET status='pending' WHERE offer_id=$1 ← rollback
| Method | Path | 인증 | 설명 |
|---|---|---|---|
GET |
/health |
— | 헬스체크 |
GET |
/api/search |
— | 전문 검색 (PostgreSQL FTS + Redis 캐시 60s) |
GET |
/api/search/popular |
— | 인기 검색어 Top 10 |
GET |
/api/search/categories |
— | 카테고리 전체 조회 |
쿼리 파라미터: q, category_id, min_price, max_price, sort (latest/price_asc/price_desc/popular), page, limit
검색 구현:
plainto_tsquery('simple', ?)+ GIN 인덱스 (idx_listings_fts)active+reserved상태 모두 검색 결과에 포함- 쿼리 파라미터 MD5 해시 → Redis 캐시 키
- 검색 시
ZINCRBY popular:searches 1 {term}(TTL 1시간)
| Method | Path | 인증 | 설명 |
|---|---|---|---|
GET |
/health |
— | 헬스체크 |
POST |
/api/chat/rooms |
✓ | 채팅방 생성 또는 기존 반환 |
GET |
/api/chat/rooms |
✓ | 내 채팅방 목록 (안읽은 수 포함) |
GET |
/api/chat/rooms/{roomId} |
✓ | 채팅방 상세 |
GET |
/api/chat/rooms/{roomId}/messages |
✓ | 메시지 목록 (페이지네이션, DESC) |
POST |
/api/chat/rooms/{roomId}/messages |
✓ | 메시지 전송 (Redis pub/sub 브로드캐스트) |
PATCH |
/api/chat/rooms/{roomId}/read |
✓ | 읽음 처리 (Redis 카운터 감소) |
GET |
/api/chat/unread |
✓ | 전체 안읽은 메시지 수 |
WS |
/ws/chat/{roomId}?token=... |
JWT 쿼리 | WebSocket 실시간 메시지 수신 |
POST |
/internal/chat/rooms |
내부 전용 | offer-service에서 자동 채팅방 생성 |
실시간 메시징 흐름:
① 메시지 전송: POST /api/chat/rooms/{roomId}/messages
├─ INSERT messages
├─ UPDATE chat_rooms.last_message
├─ Redis PUBLISH chat:{roomId} (메시지 JSON)
└─ Redis INCR chat:unread:{recipient_id}
② WebSocket 수신: /ws/chat/{roomId}?token=JWT
├─ JWT 검증 (쿼리 파라미터)
├─ Redis SUBSCRIBE chat:{roomId}
└─ 메시지 수신 시 클라이언트에 JSON 전송
③ 읽음 처리: PATCH /api/chat/rooms/{roomId}/read
├─ UPDATE messages SET read_at = NOW()
├─ Redis PUBLISH chat:{roomId} (read 이벤트)
└─ Redis DECRBY chat:unread:{user_id}
모든 엔드포인트는
role='admin'JWT 필수
| Method | Path | 설명 |
|---|---|---|
GET |
/health |
헬스체크 |
GET |
/api/admin/stats |
대시보드 지표 (전체 사용자·상품·제안·거래액) |
GET |
/api/admin/stats/trend |
최근 7일 일별 트렌드 |
GET |
/api/admin/monitor/today |
오늘 현황 |
GET |
/api/admin/monitor/activity |
최근 25건 활동 피드 |
GET |
/api/admin/users |
전체 회원 목록 |
PATCH |
/api/admin/users/{id}/role |
역할 변경 (user ↔ admin) |
GET |
/api/admin/listings |
전체 상품 목록 (상태 필터) |
PATCH |
/api/admin/listings/{id}/status |
상품 상태 강제 변경 |
POST |
/api/admin/categories |
카테고리 생성 |
PATCH |
/api/admin/categories/{id} |
카테고리 수정 (이름, 아이콘, 색상) |
DELETE |
/api/admin/categories/{id} |
카테고리 삭제 |
admin-service 데이터 수집 방식:
pool_user(postgres-user) +pool_listing(postgres-listing) 듀얼 커넥션 풀- offer 데이터는
offer-service내부 엔드포인트 HTTP 호출 asyncio.gather()로 3개 소스 병렬 조회
| PostgreSQL 컨테이너 | 호스트 포트 | DB 이름 | 사용 서비스 |
|---|---|---|---|
| postgres-user | 5433 | reboot_user | user-service |
| postgres-listing | 5434 | reboot_listing | listing-service, search-service (read), admin-service (dual pool) |
| postgres-offer | 5435 | reboot_offer | offer-service |
| postgres-chat | 5436 | reboot_chat | chat-service |
-- [postgres-user] reboot_user
users (
user_id UUID PK, email UNIQUE, password_hash,
nickname UNIQUE, role CHECK('user','admin'), created_at
)
-- [postgres-listing] reboot_listing
categories (category_id SERIAL PK, name, parent_id → self FK, icon, color)
listings (
listing_id UUID PK, seller_id UUID,
seller_nickname VARCHAR(100), ← 비정규화 (user-db FK 없음)
category_id → categories, title, description, price,
status CHECK('active','reserved','sold','deleted'),
view_count, created_at, updated_at
)
listing_images (
image_id SERIAL PK, listing_id → listings CASCADE,
object_key VARCHAR(512), sort_order, is_primary
)
-- [postgres-offer] reboot_offer
offers (
offer_id UUID PK, listing_id UUID, buyer_id UUID,
offered_price NUMERIC, status CHECK('pending','accepted','rejected','cancelled'),
message TEXT,
-- 비정규화 컬럼 (생성 시점 캡처, cross-domain JOIN 불필요)
listing_title VARCHAR(200),
listing_price NUMERIC,
listing_seller_id UUID, ← 소유권 확인용
buyer_nickname VARCHAR(100),
primary_image_key VARCHAR(512),
created_at, updated_at
)
-- [postgres-chat] reboot_chat
chat_rooms (
room_id UUID PK, listing_id UUID, buyer_id UUID, seller_id UUID,
-- 비정규화 컬럼 (생성 시점 캡처)
listing_title VARCHAR(200),
listing_price NUMERIC,
primary_image_key VARCHAR(512),
buyer_nickname VARCHAR(100),
seller_nickname VARCHAR(100),
last_message TEXT,
last_message_at TIMESTAMPTZ,
created_at
)
UNIQUE INDEX (listing_id, buyer_id) ← 동일 상품·구매자 1개 방
messages (
message_id UUID PK, room_id → chat_rooms CASCADE,
sender_id UUID, content TEXT,
read_at TIMESTAMPTZ, ← NULL이면 안읽음
created_at
)| 테이블 | 비정규화 컬럼 | 동기화 시점 |
|---|---|---|
listings.seller_nickname |
users.nickname | 닉네임 변경 시 user-service → listing-service HTTP PATCH (fire-and-forget) |
offers.listing_title/price/seller_id/image |
listings 정보 | offer 생성 시 listing-service HTTP 조회 후 스냅샷 |
offers.buyer_nickname |
users.nickname | offer 생성 시 user-service HTTP 조회 후 스냅샷 |
chat_rooms.listing_title/price/image |
listings 정보 | 채팅방 생성 시 listing-service HTTP 조회 후 스냅샷 |
chat_rooms.buyer/seller_nickname |
users.nickname | 채팅방 생성 시 user-service HTTP 조회 후 스냅샷 |
idx_listings_fts— GIN 인덱스, FTS 검색idx_listings_status,idx_listings_created_at,idx_listings_price— 필터·정렬idx_offers_listing_buyer_active— Partial UNIQUEWHERE status = 'pending'(취소·거절·수락 후 재제안 허용)idx_chat_rooms_listing_buyer— UNIQUE (listing_id, buyer_id), 동일 상품·구매자 중복 방 방지updated_at트리거 — listings, offers 자동 갱신
.env.example을 복사하여 .env로 사용합니다.
cp .env.example .env| 변수 | 설명 | 예시 |
|---|---|---|
POSTGRES_USER |
PostgreSQL 사용자 | reboot |
POSTGRES_PASSWORD |
PostgreSQL 비밀번호 | reboot1234 |
JWT_ACCESS_SECRET |
Access Token 서명 키 (32자 이상) | (변경 필수) |
JWT_REFRESH_SECRET |
Refresh Token 서명 키 (32자 이상) | (변경 필수) |
JWT_ACCESS_EXPIRES_IN |
Access Token 만료 | 15m |
JWT_REFRESH_EXPIRES_IN |
Refresh Token 만료 | 7d |
BCRYPT_ROUNDS |
bcrypt 해싱 라운드 | 10 |
AWS_ACCESS_KEY_ID |
AWS IAM Access Key | (필수) |
AWS_SECRET_ACCESS_KEY |
AWS IAM Secret Key | (필수) |
AWS_REGION |
S3 리전 | ap-northeast-2 |
S3_BUCKET_NAME |
S3 버킷 이름 | (필수) |
CLOUDFRONT_DOMAIN |
CloudFront 도메인 | (필수) |
SEARCH_CACHE_TTL |
검색 결과 캐시 TTL(초) | 60 |
POPULAR_TERMS_TTL |
인기 검색어 TTL(초) | 3600 |
OTEL_EXPORTER_OTLP_ENDPOINT |
Jaeger OTLP gRPC 엔드포인트 (비어있으면 비활성화) | http://jaeger:4317 |
로컬 개발/검증은 Docker Compose, 프로덕션 배포는 AWS EKS (Helm + ArgoCD) 로 구성된 2-tier 환경입니다. 운영 배포는 infra.md · cicd.md 를 참고하세요.
- Docker Desktop 4.x 이상
- Docker Compose V2 (
docker compose명령) - AWS 계정 (S3 버킷 + CloudFront 배포) — 이미지 업로드 기능 사용 시
# 1. 레포지토리 클론
git clone <repo-url>
cd reboot-market
# 2. 환경 변수 설정
cp .env.example .env
# .env 파일에서 AWS_*, JWT_*_SECRET 값을 채워넣으세요
# 3. 전체 빌드 및 기동
docker compose up -d --build
# 4. 헬스체크 확인
curl http://localhost:3333/health/user
curl http://localhost:3333/health/listing
curl http://localhost:3333/health/offer
curl http://localhost:3333/health/search
curl http://localhost:3333/health/chat
curl http://localhost:3333/health/admin| 서비스 | URL |
|---|---|
| 서비스 전체 진입점 (nginx) | http://localhost:3333 |
| 프론트엔드 (직접) | http://localhost:3000 |
| user-service (직접) | http://localhost:3001 |
| listing-service (직접) | http://localhost:3002 |
| offer-service (직접) | http://localhost:3003 |
| search-service (직접) | http://localhost:3004 |
| admin-service (직접) | http://localhost:3005 |
| chat-service (직접) | http://localhost:3006 |
| Jaeger UI (분산 추적) | http://localhost:16686 |
| postgres-user | localhost:5433 |
| postgres-listing | localhost:5434 |
| postgres-offer | localhost:5435 |
| postgres-chat | localhost:5436 |
| Redis | localhost:6379 |
초기 DB에는 관리자 계정이 없습니다. 일반 회원가입 후 DB에서 직접 역할을 변경하세요.
# 회원가입 후 관리자로 승격
docker compose exec postgres-user psql -U reboot -d reboot_user \
-c "UPDATE users SET role='admin' WHERE email='your@email.com';"# 컨테이너 중지 (데이터 유지)
docker compose down
# 컨테이너 + 볼륨 전체 삭제 (DB 초기화)
docker compose down -v| 항목 | 결정 | 이유 |
|---|---|---|
| DB 분리 | DB per Service (4개 PostgreSQL 인스턴스) | MSA 원칙, 서비스 독립 배포·스케일링 |
| Cross-domain JOIN 제거 | 비정규화 (seller_nickname, offer/chat 스냅샷 컬럼) | 서비스 간 DB 공유 불가 → 생성 시점 스냅샷 저장 |
| 서비스 간 통신 | httpx 비동기 HTTP (내부 엔드포인트) | Kafka 없이 단순화, 로컬 환경 적합 |
| 분산 트랜잭션 | 2-Phase + 보상 트랜잭션 (Saga 패턴 간소화) | Kafka/사가 없이 일관성 확보, ~200ms 창 허용 |
| JWT 검증 | 각 서비스 독립 검증 | JWT_ACCESS_SECRET 공유로 서비스 간 통신 불필요 |
| 이미지 업로드 | presigned URL → 클라이언트 직접 S3 PUT | 백엔드 대역폭 절감 |
| 전문 검색 | PostgreSQL FTS (GIN 인덱스) | 외부 검색 엔진 없이 구현, simple 사전 |
| 인기 검색어 | Redis Sorted Set (ZINCRBY) |
원자적 증가, ZREVRANGE로 Top N 즉시 조회 |
| 상품 목록 | active + reserved 동시 노출 |
예약중 상품도 탐색 가능, 판매완료만 제외 |
| Offer 중복 처리 | 기존 pending 제안 가격/메시지 UPDATE | 재제안 = 가격 변경, 당근마켓 방식 UX |
| Offer 중복 방지 | Partial UNIQUE WHERE status='pending' |
수락·거절·취소 후 재제안 허용 |
| 실시간 채팅 | WebSocket + Redis pub/sub | 서비스 수평 확장 시에도 메시지 브로드캐스트 가능 |
| 채팅방 자동 생성 | offer 생성 시 fire-and-forget HTTP | 채팅 실패해도 제안은 유지, 느슨한 결합 |
| WebSocket 인증 | JWT 쿼리 파라미터 | WebSocket은 커스텀 헤더 불가 → 쿼리로 전달 |
| 안읽은 메시지 수 | Redis 카운터 + DB fallback | 실시간 카운터 → 빠른 조회, DB 정합성 보장 |
| 실시간 알림 | React Query refetchInterval: 20s |
거래함·채팅 목록 자동 갱신 |
| admin 집계 | asyncio.gather() (dual pool + HTTP) |
Kafka 없이 병렬 조회로 응답 시간 최소화 |
| Soft Delete | status='deleted' |
거래 이력·통계 보존 |
| 키 | 타입 | TTL | 설명 |
|---|---|---|---|
refresh:{user_id} |
String | 7일 | Refresh Token (로그아웃 시 DEL) |
listing:{listing_id} |
String (JSON) | 120초 | 상품 상세 캐시 (수정/삭제 시 DEL) |
search:{md5hash} |
String (JSON) | 60초 | 검색 결과 캐시 (쿼리 파라미터 MD5 해시) |
popular:searches |
Sorted Set | 1시간 | 인기 검색어 (ZINCRBY, ZREVRANGE 0 9) |
offers:buyer:{id}:* |
String (JSON) | 30초 | 내가 보낸 제안 목록 캐시 |
offers:seller:{id}:* |
String (JSON) | 30초 | 내가 받은 제안 목록 캐시 |
chat:unread:{user_id} |
String (int) | 300초 | 안읽은 메시지 총 수 (INCR/DECRBY) |
chat:{room_id} |
Pub/Sub 채널 | — | 실시간 메시지 브로드캐스트 |
| 경로 | 설명 | 인증 |
|---|---|---|
/ |
홈 — 최신 상품 그리드 + 검색창 | — |
/login |
로그인 폼 | — |
/register |
회원가입 폼 | — |
/listings |
상품 목록 + 카테고리 필터 + 정렬 (예약중 배지 포함) | — |
/listings/new |
상품 등록 + 이미지 업로드 (S3 presigned) | ✓ |
/listings/[id] |
상품 상세 + 채팅하기 + 거래 제안 (기존 제안 시 가격 변경 UI) | — |
/listings/[id]/edit |
상품 수정 폼 | ✓ 소유자 |
/offers |
내 제안 / 받은 제안 탭 (20초 폴링 자동 갱신) | ✓ |
/chat |
채팅 목록 (상품 이미지, 상대 닉네임, 마지막 메시지, 안읽은 수 뱃지) | ✓ |
/chat/[roomId] |
채팅방 (메시지 버블, 시간 그룹핑, 읽음 표시, 자동 스크롤) | ✓ |
/profile/[userId] |
공개 프로필 + 판매 상품 목록 | — |
/admin |
관리자 대시보드 (6개 서비스 헬스 모니터링 포함) | ✓ admin |
Navbar:
- 채팅 — 안읽은 메시지 수 뱃지 (20초 폴링)
- 거래함 — pending 받은 제안 수 뱃지 (20초 폴링)
- React Query 쿼리 키 공유 → 페이지와 Navbar 간 중복 요청 없음
관리자 대시보드 탭:
- 대시보드 — 8개 KPI 카드 + 7일 추이 차트 (LineChart / BarChart)
- 회원관리 — 전체 회원 목록 + 역할 변경
- 상품관리 — 전체 상품 목록 + 상태 필터 + 강제 상태 변경
- 카테고리관리 — 카테고리 생성·수정(아이콘/색상)·삭제
- 모니터링 — 오늘 현황 / 6개 서비스 헬스(응답시간) / 인기 검색어 TOP 10 / 활동 피드 / 이상 감지
이 프로젝트는 학습 목적으로 작성되었습니다.








