diff --git a/README.md b/README.md new file mode 100644 index 0000000..e048eb8 --- /dev/null +++ b/README.md @@ -0,0 +1,231 @@ +# TrainUs Backend +> 프로그래머스 클라우드 기반 백엔드 데브코스 최종 프로젝트 최우수상 수상 프로젝트 + +TrainUs는 지역 기반으로 운동 메이트와 트레이너를 찾고 레슨 개설부터 신청, 결제, 리뷰까지 연결하는 운동 클래스 매칭 플랫폼입니다. + +## 사용 스택 + +### Backend + +![Java](https://img.shields.io/badge/Java_21-007396?style=for-the-badge&logo=openjdk&logoColor=white) +![Spring Boot](https://img.shields.io/badge/Spring_Boot_3.5.3-6DB33F?style=for-the-badge&logo=springboot&logoColor=white) +![Spring Security](https://img.shields.io/badge/Spring_Security-6DB33F?style=for-the-badge&logo=springsecurity&logoColor=white) +![JPA](https://img.shields.io/badge/Spring_Data_JPA-6DB33F?style=for-the-badge&logo=spring&logoColor=white) + +### Database / Cache + +![PostgreSQL](https://img.shields.io/badge/PostgreSQL_17-4169E1?style=for-the-badge&logo=postgresql&logoColor=white) +![PostGIS](https://img.shields.io/badge/PostGIS-336791?style=for-the-badge&logo=postgresql&logoColor=white) +![Redis](https://img.shields.io/badge/Redis-DC382D?style=for-the-badge&logo=redis&logoColor=white) + +### Infra / CI/CD + +![Docker](https://img.shields.io/badge/Docker-2496ED?style=for-the-badge&logo=docker&logoColor=white) +![AWS](https://img.shields.io/badge/AWS_EC2/S3/CodeDeploy-232F3E?style=for-the-badge&logo=amazonwebservices&logoColor=white) +![GitHub Actions](https://img.shields.io/badge/GitHub_Actions-2088FF?style=for-the-badge&logo=githubactions&logoColor=white) + +### Monitoring / Test + +![Prometheus](https://img.shields.io/badge/Prometheus-E6522C?style=for-the-badge&logo=prometheus&logoColor=white) +![Grafana](https://img.shields.io/badge/Grafana-F46800?style=for-the-badge&logo=grafana&logoColor=white) +![JUnit5](https://img.shields.io/badge/JUnit5-25A162?style=for-the-badge&logo=junit5&logoColor=white) +![JMeter](https://img.shields.io/badge/JMeter-D22128?style=for-the-badge&logo=apachejmeter&logoColor=white) + +## 프로젝트 소개 + +운동을 꾸준히 하고 싶어도 함께 운동할 사람을 찾기 어렵거나 지역 기반으로 신뢰할 수 있는 트레이너와 레슨을 찾기 어려운 문제가 있습니다. + +TrainUs는 사용자가 자신의 지역을 기준으로 운동 메이트 또는 트레이너를 찾고 레슨 개설과 신청, 결제, 후기 작성까지 하나의 흐름으로 이용할 수 있도록 설계한 서비스입니다. + +### 주요 목표 + +- 혼자 하기 어려운 운동을 함께할 운동 메이트를 지역 기반으로 찾을 수 있도록 합니다. +- 축구, 배드민턴 등 다인원 운동의 참여자를 쉽게 모집할 수 있도록 합니다. +- 무료/유료 운동 레슨을 탐색하고 신청할 수 있는 흐름을 제공합니다. +- 리뷰, 평점, 랭킹을 통해 트레이너와 레슨에 대한 신뢰도 판단 기준을 제공합니다. +- 쿠폰과 결제를 통해 유료 레슨 신청 흐름을 확장할 수 있도록 합니다. + +### 개선 완료 목표 + +- 선착순 레슨 신청처럼 순간 트래픽이 몰리는 시나리오를 Redis Stream 기반 비동기 처리 구조로 고도화했습니다. +- 법정동 중심 검색을 사용자 좌표 기반 반경 검색과 가까운 순 정렬까지 확장했습니다. +- API 서버와 Consumer 서버를 분리해 요청 접수와 DB 반영 작업의 리소스 경합을 줄였습니다. + +## 팀 구성 + +| 구분 | 내용 | +| --- | --- | +| 팀명 | 3성 | +| 협업 방식 | WBS 기반 일정 관리, API 명세 공유, Daily Scrum, PR 리뷰, 피어 리뷰어 지정 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
항목
지민혁
김태호
임창인
김지은
나상연
프로필
역할PO, BEBE 팀장BEBEBE
주요 담당 영역유저 레슨
CI/CD
검색/레슨 선착순 고도화
유저 쿠폰
CI/CD
쿠폰 선착순 고도화
로그인/회원가입
프로필
랭킹
강사용 레슨
관리자 쿠폰
댓글
리뷰
Toss Payments 결제
+ +## 주요 기능 + +### 회원 및 프로필 + +- 이메일 인증 기반 회원가입과 로그인 +- JWT 기반 인증 +- 내 프로필 조회/수정 +- 다른 사용자 프로필 조회 + +### 레슨 + +- 레슨 생성, 수정, 삭제 +- 레슨 상세 조회 +- 지역, 키워드, 카테고리 기반 검색 +- 개설자가 신청자를 승인/거절하는 수락제 레슨 관리 + +**고도화/개선 사항:** + +- 기존 법정동 기반 검색을 사용자 좌표 기반 반경 검색으로 확장했습니다. +- PostGIS `geography` 타입과 GiST 인덱스를 활용해 반경 검색과 가까운 순 정렬을 지원했습니다. + +### 레슨 신청 + +- 수락제 레슨 신청 +- 선착순 레슨 신청 +- 비동기 신청 상태 조회 +- 내 신청 목록 조회 + +**고도화/개선 사항:** + +- 정원이 제한된 선착순 레슨에 다수의 사용자가 동시에 신청하는 상황을 병목 시나리오로 정의했습니다. +- API 서버는 Redis에서 중복 신청과 재고를 먼저 확인한 뒤 접수 ID를 반환하고 DB 반영은 비동기 처리로 분리했습니다. +- Consumer 서버가 Redis Stream 메시지를 batch 단위로 DB에 반영하고 사용자는 접수 ID로 처리 상태를 조회합니다. + +![TrainUs Async Processing](./docs/architecture/trainus-architecture-async-processing.png) + +### 결제 및 쿠폰 + +- Toss Payments 결제 승인/취소 +- 쿠폰 발급, 적용, 복원 +- 쿠폰 상태 스케줄링 + +### 커뮤니티 + +- 댓글/대댓글 +- 리뷰와 평점 +- 리뷰와 평점 기반 트레이너 랭킹 + +## 주요 API + +| 기능 | Method | Endpoint | +| --- | --- | --- | +| 레슨 검색 | GET | `/api/v1/lessons` | +| 위치 기반 레슨 검색 | GET | `/api/v1/lessons/search/nearby` | +| 레슨 상세 조회 | GET | `/api/v1/lessons/{lessonId}` | +| 수락제 레슨 신청 | POST | `/api/v1/lessons/{lessonId}/applications/approval` | +| 선착순 레슨 신청 | POST | `/api/v1/lessons/{lessonId}/applications/open-run` | +| 비동기 신청 상태 조회 | GET | `/api/v1/lessons/apply/status/{requestId}` | +| 레슨 신청 취소 | DELETE | `/api/v1/lessons/{lessonId}/application` | +| 내 신청 목록 조회 | GET | `/api/v1/lessons/my-applications` | +| 개설 레슨 신청자 조회 | GET | `/api/v1/lessons/{lessonId}/applications` | +| 레슨 신청 승인/거절 | POST | `/api/v1/lessons/applications/{lessonApplicationId}` | +| 랭킹 조회 | GET | `/api/v1/rankings` | +| 결제 승인 | POST | `/api/v1/payments/confirm` | +| S3 업로드 URL 발급 | GET | `/api/v1/s3/posturl` | + +Swagger 설정이 포함되어 있어 실행 환경에서 API 문서를 확인할 수 있습니다. + +## 시스템 구조 + +![TrainUs Architecture](./docs/architecture/trainus-architecture.png) + +운영 환경에서는 API 서버와 Consumer 서버를 역할별 profile로 분리합니다. + +| Role | App Port | Actuator Port | 주요 책임 | +| --- | ---: | ---: | --- | +| `api` | 8080 | 8082 | HTTP 요청 수신, 인증, 레슨 검색, 신청 접수 | +| `consumer` | 8081 | 8083 | Redis Stream 소비, DB 반영, 보정 스케줄링 | + +Redis는 목적에 따라 Core Redis와 MQ Redis로 분리해 사용합니다. + +| Redis | 기본 포트 | 용도 | +| --- | ---: | --- | +| `redis-core` | 6379 | 재고, 중복 신청, 대기열, 분산 락 | +| `redis-mq` | 6380 | Redis Stream, 신청 처리 상태 | + +## 배포 자동화 + +배포는 GitHub Actions, S3, CodeDeploy를 사용합니다. +GitHub Actions가 빌드 산출물을 S3에 업로드하고 CodeDeploy가 EC2에서 배포 hook을 실행하는 Pull 방식으로 구성했습니다. + +```text +develop push + -> GitHub Actions build + -> deploy.zip 생성 + -> S3 업로드 + -> CodeDeploy 배포 생성 + -> EC2에서 appspec hook 실행 +``` + +CodeDeploy hook: + +| Script | 역할 | +| --- | --- | +| `scripts/setup.sh` | 배포 디렉터리 준비, S3에서 환경변수 파일 동기화 | +| `scripts/stop.sh` | 기존 애플리케이션 종료 | +| `scripts/start.sh` | 서버 역할에 맞는 Spring profile로 애플리케이션 실행 | + +## 협업 방식 + +- Notion 기반 WBS와 작업 보드로 기능 단위 진행 상황을 관리했습니다. +- API 명세를 공유하며 프론트엔드와 요청/응답 형식을 조율했습니다. +- Git commit convention, code convention, class naming convention을 사전에 정리했습니다. +- PR 리뷰와 피어 리뷰어 지정 방식을 통해 코드 변경 사항을 검토했습니다. +- Daily Scrum과 회의록을 통해 이슈와 의사결정 내용을 기록했습니다. + +## 주요 도메인 구조 + +```text +src/main/java/com/threestar/trainus + ├── domain + │ ├── user # 회원, 이메일 인증 + │ ├── profile # 프로필 + │ ├── lesson # 레슨 생성/검색/신청/선착순 처리 + │ ├── coupon # 쿠폰 생성/발급/사용 + │ ├── payment # Toss 결제/취소 + │ ├── review # 리뷰 + │ ├── comment # 댓글/대댓글 + │ ├── ranking # 랭킹 + │ └── file # S3 URL 발급 + └── global + ├── config # Security, Redis, S3, Swagger, Scheduler + ├── resolver # @LoginUser + ├── aop # Redisson distributed lock + └── exception # 공통 예외 처리 +``` diff --git a/docs/architecture/trainus-architecture-async-processing.png b/docs/architecture/trainus-architecture-async-processing.png new file mode 100644 index 0000000..318d33e Binary files /dev/null and b/docs/architecture/trainus-architecture-async-processing.png differ diff --git a/docs/architecture/trainus-architecture-async-processing.py b/docs/architecture/trainus-architecture-async-processing.py new file mode 100644 index 0000000..7b8dba3 --- /dev/null +++ b/docs/architecture/trainus-architecture-async-processing.py @@ -0,0 +1,49 @@ +from diagrams import Cluster, Diagram, Edge +from diagrams.aws.compute import EC2 +from diagrams.aws.network import ALB +from diagrams.onprem.database import PostgreSQL +from diagrams.onprem.inmemory import Redis + + +graph_attr = { + "fontsize": "18", + "pad": "0.6", + "splines": "polyline", + "nodesep": "1.0", + "ranksep": "1.3", +} + + +with Diagram( + "TrainUs Architecture - Async Processing", + show=False, + filename="trainus-architecture-async-processing", + outformat="png", + direction="LR", + graph_attr=graph_attr, +): + client = ALB("Client / ALB") + + with Cluster("API Server"): + api = EC2("API Server\n(api profile)") + + with Cluster("Redis Core"): + core = Redis("Redis Core\nstock / duplicate") + + with Cluster("Consumer Server - Admission"): + admission = EC2("Consumer Server\n(admission dequeue)") + stream = Redis("Redis Stream\nlesson:apply:stream") + + with Cluster("Consumer Server"): + consumer = EC2("Consumer Server\n(batch insert)") + + with Cluster("Database"): + db = PostgreSQL("PostgreSQL / PostGIS") + + client >> Edge(label="apply request") >> api + api >> Edge(label="duplicate / stock check") >> core + core >> Edge(label="dequeue requestIds") >> admission + admission >> Edge(label="SET PROCESSING + XADD") >> stream + stream >> Edge(label="XREADGROUP batch") >> consumer + consumer >> Edge(label="batch insert") >> db + consumer >> Edge(label="XACK / XDEL", dir="back") >> stream diff --git a/docs/architecture/trainus-architecture.png b/docs/architecture/trainus-architecture.png new file mode 100644 index 0000000..d02ae54 Binary files /dev/null and b/docs/architecture/trainus-architecture.png differ diff --git a/docs/architecture/trainus-architecture.py b/docs/architecture/trainus-architecture.py new file mode 100644 index 0000000..fda1407 --- /dev/null +++ b/docs/architecture/trainus-architecture.py @@ -0,0 +1,149 @@ +from diagrams import Cluster, Diagram, Edge, Node +from diagrams.aws.compute import EC2 +from diagrams.aws.devtools import Codedeploy +from diagrams.aws.network import ALB +from diagrams.aws.storage import S3 +from diagrams.onprem.ci import GithubActions +from diagrams.onprem.client import Users +from diagrams.onprem.database import PostgreSQL +from diagrams.onprem.inmemory import Redis +from diagrams.onprem.monitoring import Grafana, Prometheus +from diagrams.onprem.vcs import Github +from diagrams.programming.framework import Spring + +graph_attr = { + "fontsize": "24", + "pad": "1.0", + "splines": "line", + "nodesep": "1.5", + "ranksep": "1.3", + "margin": "0.5", + "dpi": "170", +} + +node_attr = { + "fontsize": "18", + "margin": "0.14", +} + +edge_attr = { + "fontsize": "15", +} + +cluster_attr = { + "margin": "20", + "fontsize": "18", +} + +instance_cluster_attr = { + "margin": "8", + "fontsize": "15", +} + +asg_cluster_attr = { + "margin": "15", + "fontsize": "18", +} + +with Diagram( + "TrainUs Architecture", + show=False, + filename="trainus-architecture", + outformat="png", + direction="LR", + graph_attr=graph_attr, + node_attr=node_attr, + edge_attr=edge_attr, +): + with Cluster("CI/CD", graph_attr=cluster_attr): + github = Github("GitHub") + actions = GithubActions("Actions") + artifact = S3("S3\nartifact/env") + codedeploy = Codedeploy("CodeDeploy") + github >> actions >> artifact >> codedeploy + + client = Users("Client\nJMeter") + alb = ALB("ALB") + + with Cluster("API Auto Scaling Group", graph_attr=asg_cluster_attr): + with Cluster("Instance 1", graph_attr=instance_cluster_attr): + api_ec2_1 = EC2("EC2") + api_app_1 = Spring("API\nSpring Boot") + api_ec2_1 - api_app_1 + + with Cluster("Instance n", graph_attr=instance_cluster_attr): + api_ec2_n = Node( + "EC2", + shape="box", + style="rounded,dashed", + width="1.42", + height="0.28", + fixedsize="true", + fontsize="15", + ) + api_app_n = Node( + "Spring Boot", + shape="box", + style="rounded,dashed", + width="1.42", + height="0.28", + fixedsize="true", + fontsize="15", + ) + api_ec2_n - api_app_n + + with Cluster("TrainUs-Redis-Server", graph_attr=cluster_attr): + redis_core = Redis("redis-core\nstock/waiting") + redis_mq = Redis("redis-mq\nstream/status") + + with Cluster("TrainUs-Data-Server", graph_attr=cluster_attr): + postgres = PostgreSQL("PostgreSQL\n(PostGIS)") + + with Cluster("Consumer Auto Scaling Group", graph_attr=asg_cluster_attr): + with Cluster("Instance 1", graph_attr=instance_cluster_attr): + consumer_ec2_1 = EC2("EC2") + consumer_app_1 = Spring("Consumer\nSpring Boot") + consumer_ec2_1 - consumer_app_1 + + with Cluster("Instance n", graph_attr=instance_cluster_attr): + consumer_ec2_n = Node( + "EC2", + shape="box", + style="rounded,dashed", + width="1.42", + height="0.28", + fixedsize="true", + fontsize="15", + ) + consumer_app_n = Node( + "Spring Boot", + shape="box", + style="rounded,dashed", + width="1.42", + height="0.28", + fixedsize="true", + fontsize="15", + ) + consumer_ec2_n - consumer_app_n + + with Cluster("Monitoring", graph_attr=cluster_attr): + prometheus = Prometheus("Prometheus") + grafana = Grafana("Grafana") + prometheus << grafana + + client >> Edge(minlen="2") >> alb + alb >> Edge(minlen="2") >> api_ec2_1 + alb >> Edge(minlen="2") >> api_ec2_n + + api_app_1 >> Edge(label="apply request") >> redis_core + api_app_1 >> Edge(label="poll status") >> redis_mq + api_app_1 >> Edge(label="read/write", color="darkgreen") >> postgres + + redis_core >> Edge(label="admit") >> consumer_app_1 + consumer_app_1 >> Edge(label="XREAD / ACK") >> redis_mq + consumer_app_1 >> Edge(label="batch insert") >> postgres + + prometheus << Edge(label="metrics", style="dashed") << [api_app_1, consumer_app_1] + + codedeploy >> Edge(label="deploy api", color="blue", style="dashed", constraint="false") >> api_ec2_1 + codedeploy >> Edge(label="deploy consumer", color="blue", style="dashed", constraint="false") >> consumer_ec2_1