Support-Server는 플랫폼 내 고객지원 시스템을 담당하는 마이크로서비스이다. 신고 관리, 1:1 문의, FAQ 기능을 통해 사용자 지원 업무를 처리한다.
| 기능 | 설명 |
|---|---|
| 신고 관리 | 프로필/게시글/업체 신고 접수 및 처리 |
| 제재 시스템 | 신고 기반 경고/정지/영구정지 제재 적용 |
| 1:1 문의 | 카테고리별 문의 등록 및 답변 관리 |
| FAQ | 인메모리 캐싱 기반 자주 묻는 질문 제공 |
| 신고 통계 | 신고 현황 집계 및 분석 |
| 커서 페이징 | 대용량 데이터 효율적 조회 |
| 구분 | 기술 |
|---|---|
| Framework | Spring Boot 3.5.6 |
| Language | Java 21 (Eclipse Temurin) |
| Database | MariaDB 11.x |
| Cache | Redis 7.x (선택) |
| Query | QueryDSL 5.0.0 |
| ID 생성 | Snowflake Algorithm |
| Build Tool | Gradle 8.x |
| Test Coverage | JaCoCo 0.8.11 |
| Documentation | Swagger/OpenAPI 3.0 |
flowchart TB
subgraph Client
APP[Mobile App]
WEB[Web Client]
ADMIN[Admin Console]
end
subgraph Load_Balancer
NGINX[Nginx :9100]
end
subgraph Support_Service
SUPPORT1[Support Server #1]
SUPPORT2[Support Server #2]
SUPPORT3[Support Server #3]
end
subgraph Data_Layer
MARIA[(MariaDB)]
REDIS[(Redis)]
end
subgraph Downstream_Services
AUTH[Auth Server]
PROFILE[Profile Server]
NOTI[Notification Server]
end
APP --> NGINX
WEB --> NGINX
ADMIN --> NGINX
NGINX --> SUPPORT1
NGINX --> SUPPORT2
NGINX --> SUPPORT3
SUPPORT1 --> MARIA
SUPPORT1 --> REDIS
SUPPORT1 -.-> AUTH
SUPPORT1 -.-> PROFILE
SUPPORT1 -.-> NOTI
flowchart TB
subgraph Presentation["Presentation Layer"]
CTRL[Controllers]
DTO[Request/Response DTOs]
VALID[Validators]
end
subgraph Application["Application Layer"]
SVC[Services]
CACHE[Cache Management]
end
subgraph Domain["Domain Layer"]
ENT[Entities]
REPO[Repositories]
ENUM[Enums]
end
subgraph Infrastructure["Infrastructure Layer"]
DB[(MariaDB)]
MEM[In-Memory Cache]
QDSL[QueryDSL]
end
CTRL --> SVC
SVC --> REPO
SVC --> CACHE
REPO --> DB
REPO --> QDSL
CACHE --> MEM
sequenceDiagram
participant U as User
participant LB as Nginx LB
participant SS as Support Server
participant DB as MariaDB
participant ADMIN as Admin
U->>LB: POST /api/v1/reports
LB->>SS: 신고 요청 전달
SS->>SS: 입력값 검증
SS->>SS: 카테고리 존재 확인 (캐시)
SS->>SS: Snowflake ID 생성
SS->>DB: Report 저장 (PENDING)
SS-->>LB: ReportResponse
LB-->>U: 신고 접수 완료
ADMIN->>SS: PATCH /api/v1/reports/{id}
SS->>DB: 상태 변경 (REVIEWING)
SS->>SS: ReportHistory 자동 생성
SS->>DB: History 저장
ADMIN->>SS: 제재 승인 요청
SS->>DB: 상태 변경 (APPROVED)
SS->>SS: Sanction 생성
SS->>DB: 제재 저장
SS-->>ADMIN: 처리 완료
sequenceDiagram
participant U as User
participant SS as Support Server
participant DB as MariaDB
participant ADMIN as Admin
U->>SS: POST /api/v1/inquiries
SS->>SS: 입력값 검증
SS->>SS: 파일 첨부 검증 (최대 5개)
SS->>DB: Inquiry 저장 (UNANSWERED)
SS-->>U: InquiryResponse
U->>SS: GET /api/v1/inquiries/{id}
SS->>DB: 문의 조회
SS-->>U: 문의 상세 정보
ADMIN->>SS: POST /api/v1/inquiries/answers
SS->>DB: Answer 저장
SS->>DB: Inquiry 상태 변경 (ANSWERED)
SS-->>ADMIN: AnswerResponse
U->>SS: PATCH /api/v1/inquiries/{id}/confirm
SS->>DB: 상태 변경 (CONFIRMED)
SS-->>U: 확인 완료
flowchart TB
START[서버 시작] --> INIT["@PostConstruct<br/>init()"]
INIT --> LOAD[DB에서 FAQ 로드]
LOAD --> CACHE[In-Memory Cache 저장]
REQ[조회 요청] --> LOCK[ReadLock 획득]
LOCK --> READ[캐시에서 조회]
READ --> FILTER{카테고리 필터?}
FILTER -->|ALL| ALL[전체 반환]
FILTER -->|특정| FILTERED[필터링 후 반환]
ALL --> UNLOCK[ReadLock 해제]
FILTERED --> UNLOCK
UNLOCK --> RES[응답]
SCHEDULE["매일 03:00<br/>@Scheduled"] --> WLOCK[WriteLock 획득]
WLOCK --> REFRESH[DB에서 재로드]
REFRESH --> UPDATE[캐시 갱신]
UPDATE --> WUNLOCK[WriteLock 해제]
erDiagram
REPORT ||--o{ REPORT_HISTORY : has
REPORT ||--o| SANCTION : triggers
REPORT }o--|| REPORT_CATEGORY : references
INQUIRY ||--o| ANSWER : has
INQUIRY ||--o{ INQUIRY_FILE : contains
REPORT {
varchar report_id PK "Snowflake ID"
varchar reporter_id "신고자 ID"
varchar reported_id "피신고자 ID"
enum reference_type "PROFILE, POST, BUSINESS"
varchar report_category "신고 카테고리"
varchar reason "신고 사유"
enum status "PENDING, REVIEWING, etc."
datetime reported_at "신고 일시"
}
REPORT_CATEGORY {
enum reference_type PK "신고 대상 타입"
varchar report_category PK "카테고리명"
}
REPORT_HISTORY {
varchar history_id PK "Snowflake ID"
varchar report_id FK "연관 신고"
varchar admin_id "처리 관리자"
enum previous_status "이전 상태"
enum new_status "변경 상태"
enum action_type "액션 타입"
text comment "처리 코멘트"
datetime created_at "생성 일시"
}
SANCTION {
varchar sanction_id PK "Snowflake ID"
varchar report_id FK "연관 신고"
varchar target_id "제재 대상"
enum sanction_type "WARNING, SUSPENSION, etc."
int duration "제재 기간(일)"
varchar reason "제재 사유"
datetime sanctioned_at "제재 시작"
datetime expires_at "제재 만료"
enum status "ACTIVE, EXPIRED, REVOKED"
}
SANCTION_RULE {
varchar rule_id PK "규칙 ID"
enum reference_type "대상 타입"
varchar category "카테고리"
int threshold "임계값"
enum sanction_type "제재 타입"
int duration "기간"
}
REPORT_STATISTICS {
varchar statistics_id PK
varchar target_id "대상 ID"
enum reference_type "대상 타입"
int total_count "총 신고 수"
int approved_count "승인된 수"
datetime last_updated "마지막 갱신"
}
INQUIRY {
varchar inquiry_id PK "UUID"
varchar title "제목"
text contents "내용"
enum category "카테고리"
enum status "UNANSWERED, ANSWERED, etc."
varchar writer_id "작성자 ID"
datetime created_at "생성 일시"
datetime answered_at "답변 일시"
}
ANSWER {
varchar answer_id PK "UUID"
varchar inquiry_id FK "연관 문의"
varchar writer_id "답변자 ID"
text contents "답변 내용"
datetime created_at "생성 일시"
}
INQUIRY_FILE {
varchar inquiry_id PK,FK "문의 ID"
int order_num PK "순서 (0-4)"
varchar file_url "파일 URL"
}
FAQ {
bigint id PK "자동 증가"
enum category "카테고리"
varchar title "제목"
varchar question "질문"
text answer "답변"
}
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| report_id | VARCHAR(100) | Y | Snowflake ID |
| reporter_id | VARCHAR(100) | Y | 신고자 ID |
| reported_id | VARCHAR(100) | Y | 피신고자/대상 ID |
| reference_type | ENUM | Y | PROFILE, POST, BUSINESS |
| report_category | VARCHAR(100) | Y | 신고 카테고리 |
| reason | VARCHAR(100) | Y | 신고 사유 |
| status | ENUM | Y | PENDING, REVIEWING, APPROVED, REJECTED, WITHDRAWN |
| reported_at | DATETIME(6) | Y | 신고 일시 |
유니크 제약조건:
uk_report_per_user: (reporter_id, reference_type, reported_id, status)- 동일 사용자가 같은 대상에 대해 중복 신고 방지
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| reference_type | ENUM | Y | PK, 신고 대상 타입 |
| report_category | VARCHAR(100) | Y | PK, 카테고리명 |
초기 데이터:
| reference_type | report_category | 설명 |
|---|---|---|
| PROFILE | ABUSE | 욕설/음란 |
| PROFILE | INAPPROPRIATE_INFO | 부적절한 닉네임/사진 |
| PROFILE | ETC | 기타 |
| POST | ABUSE | 욕설/음란 |
| POST | SPAM | 도배/스팸 |
| POST | DISPUTE | 분란조장 |
| POST | ADVERTISEMENT | 타업체광고 |
| POST | FRAUD | 사기성 |
| POST | ETC | 기타 |
| BUSINESS | FRAUD | 사기성 |
| BUSINESS | CLOSED | 폐업 |
| BUSINESS | ETC | 기타 |
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| history_id | VARCHAR(100) | Y | Snowflake ID |
| report_id | VARCHAR(100) | Y | FK to Report |
| admin_id | VARCHAR(100) | N | 처리 관리자 ID |
| previous_status | ENUM | Y | 이전 상태 |
| new_status | ENUM | Y | 변경 후 상태 |
| action_type | ENUM | Y | STATUS_CHANGE 등 |
| comment | TEXT | N | 처리 코멘트 |
| created_at | DATETIME(6) | Y | 생성 일시 |
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| sanction_id | VARCHAR(100) | Y | Snowflake ID |
| report_id | VARCHAR(100) | Y | FK to Report |
| target_id | VARCHAR(100) | Y | 제재 대상 ID |
| sanction_type | ENUM | Y | WARNING, SUSPENSION, PERMANENT_BAN |
| duration | INT | N | 제재 기간 (일 단위, 영구정지 시 NULL) |
| reason | VARCHAR(500) | Y | 제재 사유 |
| sanctioned_at | DATETIME(6) | Y | 제재 시작 일시 |
| expires_at | DATETIME(6) | N | 제재 만료 일시 (영구정지 시 NULL) |
| status | ENUM | Y | ACTIVE, EXPIRED, REVOKED |
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| inquiry_id | VARCHAR(36) | Y | UUID |
| title | VARCHAR(200) | Y | 문의 제목 |
| contents | VARCHAR(2000) | Y | 문의 내용 |
| category | ENUM | Y | RESERVATION, CHECK_IN, PAYMENT, REVIEW_REPORT, ETC |
| status | ENUM | Y | UNANSWERED, ANSWERED, CONFIRMED |
| writer_id | VARCHAR(64) | Y | 작성자 ID |
| created_at | DATETIME(6) | Y | 생성 일시 |
| updated_at | DATETIME(6) | Y | 수정 일시 |
| answered_at | DATETIME(6) | N | 답변 일시 |
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| answer_id | VARCHAR(36) | Y | UUID |
| inquiry_id | VARCHAR(36) | Y | FK to Inquiry |
| writer_id | VARCHAR(64) | Y | 답변자 ID (관리자) |
| contents | TEXT | Y | 답변 내용 |
| created_at | DATETIME(6) | Y | 생성 일시 |
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| inquiry_id | VARCHAR(36) | Y | PK, FK to Inquiry |
| order_num | INT | Y | PK, 순서 (0-4) |
| file_url | VARCHAR(500) | Y | 파일 URL |
제약조건: 문의당 최대 5개 파일 첨부 가능
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| id | BIGINT | Y | 자동 증가 PK |
| category | ENUM | Y | ALL, RESERVATION, CHECK_IN, PAYMENT, REVIEW_REPORT, ETC |
| title | VARCHAR(200) | Y | 제목 |
| question | VARCHAR(500) | Y | 질문 |
| answer | TEXT | Y | 답변 |
POST /api/v1/reports
Request
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| reporterId | String | Y | 신고자 ID |
| reportedId | String | Y | 피신고자/대상 ID |
| referenceType | String | Y | PROFILE, POST, BUSINESS |
| reportCategory | String | Y | 신고 카테고리 |
| reason | String | Y | 신고 사유 (최대 500자) |
Request Example
{
"reporterId": "1234567890123456789",
"reportedId": "9876543210987654321",
"referenceType": "PROFILE",
"reportCategory": "ABUSE",
"reason": "욕설이 포함된 프로필입니다."
}Response
{
"reportId": "5555555555555555555",
"reporterId": "1234567890123456789",
"reportedId": "9876543210987654321",
"referenceType": "PROFILE",
"reportCategory": "ABUSE",
"reason": "욕설이 포함된 프로필입니다.",
"status": "PENDING",
"reportedAt": "2025-01-20T14:30:00"
}상태 코드
| 코드 | 설명 |
|---|---|
| 201 | 신고 등록 성공 |
| 400 | 잘못된 요청 형식 |
| 404 | 카테고리를 찾을 수 없음 |
| 409 | 중복 신고 |
GET /api/v1/reports/{reportId}
Response
{
"reportId": "5555555555555555555",
"reporterId": "1234567890123456789",
"reportedId": "9876543210987654321",
"referenceType": "PROFILE",
"reportCategory": "ABUSE",
"reason": "욕설이 포함된 프로필입니다.",
"status": "REVIEWING",
"reportedAt": "2025-01-20T14:30:00",
"histories": [
{
"historyId": "6666666666666666666",
"adminId": "admin123",
"previousStatus": "PENDING",
"newStatus": "REVIEWING",
"actionType": "STATUS_CHANGE",
"comment": "검토 시작",
"createdAt": "2025-01-20T15:00:00"
}
]
}GET /api/v1/reports
Query Parameters
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| status | String | N | - | 상태 필터 |
| referenceType | String | N | - | 대상 타입 필터 |
| reportCategory | String | N | - | 카테고리 필터 |
| sortType | String | N | REPORTED_AT | 정렬 기준 |
| sortDirection | String | N | DESC | 정렬 방향 |
| cursor | String | N | - | 커서 (이전 페이지 마지막 ID) |
| size | Int | N | 20 | 페이지 크기 |
Response
{
"content": [
{
"reportId": "5555555555555555555",
"reporterId": "1234567890123456789",
"reportedId": "9876543210987654321",
"referenceType": "PROFILE",
"status": "PENDING",
"reportedAt": "2025-01-20T14:30:00"
}
],
"cursor": "5555555555555555555",
"hasNext": true,
"size": 20
}PATCH /api/v1/reports/{reportId}
Request
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| status | String | Y | REVIEWING, APPROVED, REJECTED, PENDING |
| adminId | String | Y | 처리 관리자 ID |
| comment | String | N | 처리 코멘트 |
Request Example
{
"status": "APPROVED",
"adminId": "admin123",
"comment": "신고 내용 확인 완료, 제재 적용"
}DELETE /api/v1/reports/{reportId}
Request
{
"reporterId": "1234567890123456789",
"reason": "오해였습니다."
}설명
- 신고자 본인만 철회 가능
- PENDING 상태의 신고만 철회 가능
POST /api/v1/inquiries
Request
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| title | String | Y | 제목 (최대 200자) |
| contents | String | Y | 내용 (최대 2000자) |
| category | String | Y | 카테고리 |
| writerId | String | Y | 작성자 ID |
| files | List | N | 첨부파일 URL (최대 5개) |
Request Example
{
"title": "예약 취소 문의",
"contents": "예약을 취소하고 싶습니다. 환불 절차가 어떻게 되나요?",
"category": "RESERVATION",
"writerId": "1234567890123456789",
"files": [
"https://storage.example.com/images/receipt.jpg"
]
}Response
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "예약 취소 문의",
"contents": "예약을 취소하고 싶습니다. 환불 절차가 어떻게 되나요?",
"category": "RESERVATION",
"status": "UNANSWERED",
"writerId": "1234567890123456789",
"createdAt": "2025-01-20T14:30:00",
"files": [
"https://storage.example.com/images/receipt.jpg"
]
}GET /api/v1/inquiries/{inquiryId}
Response
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "예약 취소 문의",
"contents": "예약을 취소하고 싶습니다.",
"category": "RESERVATION",
"status": "ANSWERED",
"writerId": "1234567890123456789",
"createdAt": "2025-01-20T14:30:00",
"answeredAt": "2025-01-20T15:30:00",
"answer": {
"id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
"contents": "예약 취소는 마이페이지에서 가능합니다.",
"writerId": "admin123",
"createdAt": "2025-01-20T15:30:00"
}
}GET /api/v1/inquiries
Query Parameters
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
| writerId | String | N | 작성자 ID 필터 |
| category | String | N | 카테고리 필터 |
| status | String | N | 상태 필터 |
DELETE /api/v1/inquiries/{inquiryId}?writerId={writerId}
설명
- 작성자 본인만 삭제 가능
POST /api/v1/inquiries/answers
Request
{
"inquiryId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"contents": "예약 취소는 마이페이지에서 가능합니다.",
"writerId": "admin123"
}DELETE /api/v1/inquiries/{inquiryId}/answer
PATCH /api/v1/inquiries/{inquiryId}/confirm?writerId={writerId}
설명
- 문의 작성자가 답변을 확인했음을 표시
- 상태가 CONFIRMED로 변경됨
GET /api/v1/faqs
Query Parameters
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
| category | String | N | ALL | ALL, RESERVATION, CHECK_IN, PAYMENT, REVIEW_REPORT, ETC |
Response
[
{
"id": 1,
"category": "RESERVATION",
"title": "예약 변경",
"question": "예약을 변경하려면 어떻게 해야 하나요?",
"answer": "마이페이지 > 예약 내역에서 변경하실 수 있습니다."
},
{
"id": 2,
"category": "PAYMENT",
"title": "환불 정책",
"question": "환불은 언제 처리되나요?",
"answer": "취소 후 영업일 기준 3-5일 내에 환불됩니다."
}
]POST /api/v1/faqs/refresh
Response
FAQ cache refreshed successfully
GET /health
Response
Server is up
stateDiagram-v2
[*] --> PENDING: 신고 접수
PENDING --> REVIEWING: 검토 시작
PENDING --> WITHDRAWN: 신고자 철회
REVIEWING --> APPROVED: 승인 (제재 적용)
REVIEWING --> REJECTED: 기각
REVIEWING --> PENDING: 보류 처리
APPROVED --> [*]
REJECTED --> [*]
WITHDRAWN --> [*]
stateDiagram-v2
[*] --> UNANSWERED: 문의 등록
UNANSWERED --> ANSWERED: 답변 등록
ANSWERED --> UNANSWERED: 답변 삭제
ANSWERED --> CONFIRMED: 사용자 확인
CONFIRMED --> [*]
| 유형 | 설명 | 기간 |
|---|---|---|
| WARNING | 경고 | 즉시 (기록만) |
| SUSPENSION | 일시 정지 | 1~30일 |
| PERMANENT_BAN | 영구 정지 | 무기한 |
| 상태 | 설명 |
|---|---|
| ACTIVE | 활성 (적용 중) |
| EXPIRED | 만료됨 |
| REVOKED | 취소됨 (관리자 해제) |
| 규칙 | 설명 |
|---|---|
| 중복 신고 방지 | 동일 사용자가 같은 대상에 동일 상태로 중복 신고 불가 |
| 철회 제한 | PENDING 상태의 신고만 철회 가능 |
| 철회 권한 | 신고자 본인만 철회 가능 |
| 히스토리 자동화 | 상태 변경 시 자동으로 이력 기록 |
| 규칙 | 설명 |
|---|---|
| 파일 첨부 제한 | 문의당 최대 5개 파일 |
| 답변 중복 방지 | 문의당 1개 답변만 가능 |
| 삭제 권한 | 작성자 본인만 삭제 가능 |
| 확인 제한 | ANSWERED 상태만 확인 가능 |
flowchart LR
subgraph Application
FAQ_SVC[FaqService]
CACHE[In-Memory Cache<br/>volatile List]
LOCK[ReadWriteLock]
end
subgraph Database
DB[(MariaDB)]
end
FAQ_SVC -->|읽기| CACHE
FAQ_SVC -->|쓰기| LOCK
LOCK -->|갱신| CACHE
CACHE -.->|초기화/갱신| DB
| 작업 | 시점 | 동작 |
|---|---|---|
| 초기화 | 서버 시작 (@PostConstruct) | DB 전체 로드 |
| 자동 갱신 | 매일 03:00 (@Scheduled) | DB 재로드 |
| 수동 갱신 | API 호출 | DB 재로드 |
| 읽기 | 조회 요청 | 캐시에서 조회 |
// 읽기: ReadLock (동시 접근 허용)
lock.readLock().lock();
try {
return new ArrayList<>(cachedFaqs);
} finally {
lock.readLock().unlock();
}
// 쓰기: WriteLock (단독 접근)
lock.writeLock().lock();
try {
cachedFaqs = faqRepository.findAll();
} finally {
lock.writeLock().unlock();
}| 항목 | 설명 |
|---|---|
| 독립 캐시 | 각 인스턴스가 독립적인 로컬 캐시 유지 |
| 분산 락 불필요 | 읽기 전용이므로 분산 락 불필요 |
| 일관성 | 스케줄러로 주기적 갱신하여 최종 일관성 보장 |
// ReportCategoryCache 인터페이스
public interface ReportCategoryCache {
Optional<ReportCategory> get(ReferenceType type, String category);
void refresh();
}
// InMemoryReportCategoryCache 구현
// - 서버 시작 시 초기화
// - 신고 등록 시 카테고리 유효성 검증에 활용-- 신고자별 조회
CREATE INDEX idx_report_reporter_id ON report (reporter_id);
-- 피신고자별 조회
CREATE INDEX idx_report_reported_id ON report (reported_id);
-- 신고 일시 정렬
CREATE INDEX idx_report_reported_at ON report (reported_at);
-- 상태별 필터링
CREATE INDEX idx_report_status ON report (status);
-- 중복 신고 방지 (유니크)
CREATE UNIQUE INDEX uk_report_per_user
ON report (reporter_id, reference_type, reported_id, status);-- 대상별 제재 조회
CREATE INDEX idx_sanctions_target_id ON sanctions (target_id);
-- 상태별 필터링
CREATE INDEX idx_sanctions_status ON sanctions (status);
-- 만료 스케줄러 조회
CREATE INDEX idx_sanctions_expires_at ON sanctions (expires_at);-- 작성자별 조회
CREATE INDEX idx_inquiry_writer_id ON inquiries (writer_id);
-- 카테고리별 조회
CREATE INDEX idx_inquiry_category ON inquiries (category);
-- 상태별 필터링
CREATE INDEX idx_inquiry_status ON inquiries (status);| 코드 | HTTP Status | 설명 |
|---|---|---|
| REPORT_NOT_FOUND | 404 | 신고를 찾을 수 없음 |
| REPORT_CATEGORY_NOT_FOUND | 404 | 카테고리를 찾을 수 없음 |
| 코드 | HTTP Status | 설명 |
|---|---|---|
| INQUIRY_NOT_FOUND | 404 | 문의를 찾을 수 없음 |
| ANSWER_NOT_FOUND | 404 | 답변을 찾을 수 없음 |
| ANSWER_ALREADY_EXISTS | 409 | 이미 답변이 존재함 |
| UNAUTHORIZED_ACCESS | 403 | 접근 권한 없음 |
| INVALID_INQUIRY_STATUS | 400 | 잘못된 문의 상태 |
| 코드 | HTTP Status | 설명 |
|---|---|---|
| VALIDATION_ERROR | 400 | 입력값 검증 실패 |
| INTERNAL_ERROR | 500 | 서버 내부 오류 |
# Database
DATABASE_HOST=localhost
DATABASE_PORT=3306
DATABASE_NAME=support_db
DATABASE_USER_NAME=support
DATABASE_PASSWORD=your_password
# Redis (선택)
REDIS_HOST=localhost
REDIS_PORT=6379
# Snowflake ID
SNOWFLAKE_DATACENTER_ID=1
SNOWFLAKE_WORKER_ID=1
# Spring Profile
SPRING_PROFILES_ACTIVE=devspring:
profiles:
active: dev
---
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:mariadb://${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}
username: ${DATABASE_USER_NAME}
password: ${DATABASE_PASSWORD}
driver-class-name: org.mariadb.jdbc.Driver
jpa:
hibernate:
ddl-auto: validate
show-sql: true
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY SupportServer/gradlew .
COPY SupportServer/gradle ./gradle
COPY SupportServer/build.gradle settings.gradle ./
COPY SupportServer/src ./src
RUN chmod +x ./gradlew && ./gradlew clean bootJar
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/build/libs/*.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ddingsh9/support-server:1.0.0.0 \
--push .version: '3.8'
services:
nginx:
image: nginx:alpine
container_name: nginx-support
ports:
- "9100:80"
depends_on:
- support-server-1
- support-server-2
- support-server-3
support-server-1:
image: ddingsh9/support-server:1.0.0.0
env_file:
- .env.prod
container_name: support-server-1
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 15s
timeout: 5s
retries: 3
support-server-2:
image: ddingsh9/support-server:1.0.0.0
env_file:
- .env.prod
container_name: support-server-2
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 15s
timeout: 5s
retries: 3
support-server-3:
image: ddingsh9/support-server:1.0.0.0
env_file:
- .env.prod
container_name: support-server-3
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 15s
timeout: 5s
retries: 3
networks:
support-network:
driver: bridge
infra-network:
external: trueupstream support_upstream {
server support-server-1:8080;
server support-server-2:8080;
server support-server-3:8080;
}
server {
listen 80;
location / {
proxy_pass http://support_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}| 작업 | 크론 표현식 | 설명 |
|---|---|---|
| FAQ 캐시 갱신 | 0 0 3 * * * |
매일 새벽 3시 갱신 |
| 제재 만료 처리 | 0 0 0 * * * |
매일 자정 만료 상태 변경 |
@Scheduled(cron = "0 0 3 * * *")
public void scheduledRefreshCache() {
log.info("Scheduled FAQ cache refresh started");
refreshCache();
log.info("Scheduled FAQ cache refresh completed");
}com.teambind.supportserver/
├── common/
│ ├── config/
│ │ ├── IdConfig.java
│ │ └── QueryDslConfig.java
│ ├── controller/
│ │ └── HealthController.java
│ ├── handler/
│ │ └── GlobalExceptionHandler.java
│ └── utils/
│ ├── IdGenerator.java
│ └── Snowflake.java
├── report/
│ ├── aop/
│ │ └── PerformanceMonitoringAspect.java
│ ├── controller/
│ │ ├── ReportController.java
│ │ └── CacheManagementController.java
│ ├── service/
│ │ ├── ReportService.java
│ │ ├── ReportServiceImpl.java
│ │ ├── ReportStatisticsService.java
│ │ └── SanctionService.java
│ ├── repository/
│ │ ├── ReportRepository.java
│ │ ├── ReportRepositoryCustom.java
│ │ ├── ReportRepositoryImpl.java
│ │ ├── ReportCategoryRepository.java
│ │ ├── ReportHistoryRepository.java
│ │ ├── ReportStatisticsRepository.java
│ │ ├── SanctionRepository.java
│ │ └── SanctionRuleRepository.java
│ ├── entity/
│ │ ├── Report.java
│ │ ├── ReportCategory.java
│ │ ├── ReportHistory.java
│ │ ├── ReportStatistics.java
│ │ ├── Sanction.java
│ │ ├── SanctionRule.java
│ │ ├── embeddable/
│ │ │ └── ReportCategoryId.java
│ │ └── enums/
│ │ ├── ActionType.java
│ │ ├── ReferenceType.java
│ │ ├── ReportStatus.java
│ │ ├── SanctionStatus.java
│ │ └── SanctionType.java
│ ├── dto/
│ │ ├── request/
│ │ │ ├── ReportRequest.java
│ │ │ ├── ReportSearchRequest.java
│ │ │ └── ReportStatusUpdateRequest.java
│ │ └── response/
│ │ ├── ReportResponse.java
│ │ └── CursorPageResponse.java
│ ├── exceptions/
│ │ ├── ErrorCode.java
│ │ └── ReportException.java
│ └── utils/
│ ├── ReportCategoryCache.java
│ └── InMemoryReportCategoryCache.java
├── inquiries/
│ ├── controller/
│ │ └── InquiryController.java
│ ├── service/
│ │ ├── InquiryService.java
│ │ └── InquiryServiceImpl.java
│ ├── repository/
│ │ ├── InquiryRepository.java
│ │ └── AnswerRepository.java
│ ├── entity/
│ │ ├── Inquiry.java
│ │ ├── Answer.java
│ │ ├── InquiryFile.java
│ │ ├── InquiryCategory.java
│ │ └── InquiryStatus.java
│ ├── dto/
│ │ ├── request/
│ │ │ ├── InquiryCreateRequest.java
│ │ │ └── AnswerCreateRequest.java
│ │ └── response/
│ │ ├── InquiryResponse.java
│ │ └── AnswerResponse.java
│ └── exceptions/
│ ├── ErrorCode.java
│ └── InquiryException.java
└── faq/
├── controller/
│ └── FaqController.java
├── service/
│ └── FaqService.java
├── repository/
│ └── FaqRepository.java
└── entity/
├── Faq.java
└── enums/
└── FaqCategory.java
- 신고 등록/조회/검색 API
- 신고 상태 변경
- 신고 히스토리 자동 기록
- 커서 기반 페이징
- Snowflake ID 생성
- 문의 CRUD
- 답변 등록/삭제
- 답변 확인 기능
- 파일 첨부 지원
- FAQ 조회 API
- 인메모리 캐싱
- 스케줄 기반 캐시 갱신
- 카테고리 필터링
- 제재 엔티티 설계
- 제재 규칙 엔티티
- 신고 통계 엔티티
- Kafka 이벤트 연동
- 제재 자동화 로직
- 관리자 대시보드 API
- Prometheus + Grafana 모니터링
./gradlew test jacocoTestReportJaCoCo 설정:
- 제외 대상: config, entity, dto, exceptions, Application
| 도메인 | 테스트 파일 수 | 주요 테스트 |
|---|---|---|
| Report | 15+ | 엔티티, 리포지토리, 서비스, 컨트롤러 |
| Inquiry | 5+ | 엔티티, 리포지토리 |
| FAQ | 2+ | 서비스, 컨트롤러 |
Support Server는 분산 환경에서 고유 ID를 생성하기 위해 Snowflake 알고리즘을 사용한다.
|-- 1 bit --|-- 41 bits --|-- 10 bits --|-- 12 bits --|
| sign | timestamp | machine id | sequence |
- 64-bit 고유 ID: 충돌 없는 분산 ID 생성
- 시간순 정렬: timestamp 기반으로 자연 정렬 가능
- 초당 4096개: 동일 밀리초 내 최대 시퀀스
// 동적 검색 조건
BooleanBuilder builder = new BooleanBuilder();
if (request.getStatus() != null) {
builder.and(report.status.eq(request.getStatus()));
}
if (request.getReferenceType() != null) {
builder.and(report.referenceType.eq(request.getReferenceType()));
}
// 커서 기반 페이징
if (cursor != null) {
builder.and(report.reportId.lt(cursor));
}| 로직 유형 | 위치 | 예시 |
|---|---|---|
| 상태 전이 | Entity | Report.changeStatus() |
| 연관관계 관리 | Entity | Inquiry.addAnswer() |
| 검증 로직 | Entity | validateFiles() |
| 복잡한 조회 | Repository | ReportRepositoryImpl |
| 트랜잭션 조율 | Service | ReportServiceImpl |
버전: 0.0.1-SNAPSHOT 최종 업데이트: 2025-01-20 팀: TeamBiund Development Team