다크 모드
API 설계
truloop-core의 API 설계 원칙, 라우트 규칙, DTO 패턴, 인증 체계를 설명합니다.
라우트 체계
RouteRegistry.configureAllRoutes()에서 전체 API를 인증 수준과 버전별로 구성합니다.
API 경로 구조
| 경로 | 인증 방식 | 용도 |
|---|---|---|
/core/api/v1/* | JWT (auth-jwt) + Public | 표준 API v1 |
/core/api/v3/* | JWT (auth-jwt) + Public | API v3 (Loop, Recap(내부명: Story), Media) |
/core/api/public/v1/* | 없음 (Public) | 외부 공개 API |
/core/internal/* | Bearer API Key (internal-api) | 서비스 간 Internal API |
/core/admin/v1/* | Google Workspace OIDC | 관리자 전용 |
v1 API 라우트
JWT 인증이 필요한 주요 엔드포인트:
/core/api/v1/
├── auth/ # 인증 (토큰 갱신, 로그아웃 등)
├── users/ # 사용자 관리
├── profiles/ # 프로필 관리
├── devices/ # 디바이스 등록
├── blocks/ # 차단
├── contacts/ # 연락처
├── loops/.../eta/ # ETA (예상 도착 시간)
├── content-templates/ # 콘텐츠 템플릿
├── cover-templates/ # 커버 템플릿 (GET 목록/상세, 카테고리 필터, 페이징)
├── short-forms/ # 숏폼 콘텐츠 (미사용/폐기 예정)
├── chats/ # 채팅
├── notification-center/ # 알림 센터
├── live-activities/ # Live Activity
├── missions/ # 미션
└── secretary/ # AI 비서Public 라우트 (인증 불필요):
/core/api/v1/
├── auth/ # 회원가입, 로그인
├── phone-verification/ # 전화번호 인증
├── app-versions/ # 앱 버전 체크
└── users/ # 사용자명 중복 확인 등v3 API 라우트
Loop 중심의 리소스 계층 구조:
/core/api/v3/
├── loops/ # 룹 CRUD, 카테고리, 추천 친구 등
│ ├── categories # 룹 카테고리 목록
│ ├── recommended-friends # 추천 친구
│ ├── seed-members # 시드 멤버 후보
│ ├── recommended-groups # 추천 그룹
│ ├── participants/ # 룹 참여 (join)
│ └── {eid}/
│ ├── participants/ # 참여자 관리
│ ├── invitations/ # 초대 관리
│ ├── media/ # 미디어 관리 + 태그된 사용자
│ │ └── groups/{group_eid}/
│ │ └── representative # 대표 사진 지정/해제
│ ├── comments/ # 댓글
│ ├── cover-selection/ # 커버 선택
│ ├── reports/ # 룹 신고
│ ├── views # 룹 조회 기록
│ ├── availability # 일정 조율 응답
│ ├── personalized-poster # 개인화 포스터 생성
│ └── process # 미디어 배치 처리
├── loops/{loop_eid}/stories/ # 리캡 관리 (내부명: Story)
│ └── {story_eid}/
│ ├── content # 리캡 텍스트 편집
│ ├── regenerate # 리캡 재생성
│ ├── like # 좋아요
│ └── share-link # 공유 링크 생성
└── users/{eid}/reports/ # 사용자 신고GET /core/api/v3/loops — 쿼리 파라미터 상세
| 파라미터 | 타입 | 설명 |
|---|---|---|
next_token | string | 커서 기반 페이지네이션 토큰 |
limit | int | 페이지 크기 |
sort | enum | datetime_asc, datetime_desc, created_at_asc, created_at_desc |
filter | enum | all, created_by_me, invited_to |
status | enum[] | 배열 쿼리 지원 (?status=collecting&status=confirmed). 값: all, waiting, collecting, collected, confirmed |
view | enum | history (기본값, 24시간 전 룹), upcoming (미래 + 조율 중), all |
q | string | 검색어 |
PATCH .../stories/{story_eid}/content — 리캡 텍스트 편집
block_index 기반 partial patching으로 텍스트 블록만 수정합니다. 블록 구조(순서, 개수)는 보존됩니다.
Request:
json
{
"text_edits": [
{ "block_index": 0, "text": "수정된 텍스트" }
]
}Response: 수정된 content_blocks (JsonObject)
- 룹 참여자만 수정 가능 (
isParticipant검증) - 중복
block_index가 있으면InvalidTextEdit에러
GET /core/api/v3/loops/{eid}/media — 그룹 메타데이터 포함 미디어 목록
GetGroupedLoopMediaUseCase를 통해 미디어 목록과 그룹 정보를 함께 반환합니다.
쿼리 파라미터:
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
limit | int | 20 | 페이지 크기 (1~100) |
offset | int | 0 | 오프셋 |
정렬: capturedAt 내림차순 (fallback: createdAt)
응답 최상위 필드: media (미디어 항목 배열)
응답 필드 (각 미디어 항목):
comments_count— 해당 미디어의 활성 댓글 수tagged_users— 태그된 사용자 목록 (UserBaseDto)group— 그룹 메타데이터 (미소속이면 null)group_id: 그룹 식별자is_representative: 대표 사진 여부member_count: 그룹 내 미디어 수member_eids: 그룹 내 미디어 EID 목록
인증: JWT 필수, 룹 참여자만 조회 가능
PUT /core/api/v3/loops/{eid}/media/groups/{group_eid}/representative — 대표 사진 수동 설정
Request:
json
{
"media_eid": "string"
}Response: 204 No Content
검증:
- 룹 존재 확인
- 요청자가 룹 참여자인지 확인
- 그룹이 캐시된 그룹핑 데이터에 존재하는지 확인
- 지정한 미디어가 해당 그룹에 소속되어 있는지 확인
에러:
- 404: 루프/그룹 없음 (
LoopNotFound,GroupingNotFound,GroupNotFound) - 400: 미디어가 그룹에 미소속 (
MediaNotInGroup) - 403: 비참여자 (
NotLoopParticipant)
DELETE /core/api/v3/loops/{eid}/media/groups/{group_eid}/representative — 대표 사진 해제
대표 사진 오버라이드를 제거합니다. 이후 자동 선택(좋아요 수 최다)으로 복원됩니다.
Response: 204 No Content
검증: 룹 존재, 참여자 확인
POST /core/internal/grouping/process-queue — 이미지 그룹핑 배치 처리
Internal API (Bearer API Key 인증). EventBridge에 의해 주기적으로 호출됩니다.
Request:
json
{
"loop_eids": ["string"]
}Response:
json
{
"processed": 3,
"skipped": 1,
"failed": 0
}processed: 그룹핑이 성공적으로 수행된 루프 수skipped: 조건 미달로 건너뛴 루프 수 (미디어 부족, 시간 분산 초과, 동시성 변경 등)failed: 처리 중 에러가 발생한 루프 수
GET /core/admin/v1/users — 유저 목록 조회
Google Workspace OIDC 인증 (admin-google-oidc). 관리자 전용.
쿼리 파라미터:
| 파라미터 | 타입 | 설명 |
|---|---|---|
search | string | username, name, phoneNumber, email ILIKE 검색 |
is_active | boolean | 활성 상태 필터 |
page | int | 페이지 번호 (1-based, 기본값: 1) |
limit | int | 페이지 크기 |
정렬: createdAt 내림차순
응답: 유저 목록 + 페이지네이션 메타 (offset 기반)
GET /core/admin/v1/users/{eid} — 유저 상세 조회
응답 필드: eid, name, username, email, phoneNumber, locale, birthDate, isActive, isProfilePrivate, profileImageUrl, createdAt, updatedAt 등
PATCH /core/admin/v1/users/{eid} — 유저 정보 수정
Partial update — null이 아닌 필드만 업데이트.
Request:
json
{
"name": "string",
"username": "string",
"email": "string",
"locale": "string",
"birth_date": "string (yyyy-MM-dd)",
"is_active": true,
"is_profile_private": false
}검증:
- username: 도메인 유효성 검증 + 중복 체크
- locale: Java Locale 유효성 검증
- birthDate: 미래 날짜 불가
이벤트:
- isActive=false →
UserDeactivatedEvent발행 - isActive=true →
UserReactivatedEvent발행 - 항상
UserUpdatedEvent발행
Public API 라우트
인증 없이 접근 가능한 외부 공개 API:
/core/api/public/v1/
├── loops/ # 초대 토큰 기반 룹 상세 조회
│ └── {loop_eid}/
│ ├── media # 룹 미디어
│ └── comments # 룹 댓글
├── stories/ # 공개 리캡 조회
│ ├── ?username=xxx # 사용자별 공개 리캡 목록
│ └── {story_eid}/
│ └── comments # 리캡 댓글
└── invitations/{token}/ # 초대 토큰 기반 비회원 참여
├── media # 미디어 조회
├── comments # 댓글 조회
├── guest # 비회원 참여 등록
└── check-phone # 전화번호 가입 여부 확인Internal API
서비스 간 통신에 사용되며, Bearer API Key로 인증합니다:
/core/internal/
├── users/ # 사용자 정보 조회
├── contacts/ # 연락처 정보
├── guest-participants/ # 비회원 참여자 (내부명: GuestParticipant)
├── stories/ # 리캡 (내부명: Story) - Worker 콜백 포함
│ └── {story_eid}/
│ └── requests/{request_id} # Worker 완료 콜백
├── grouping/
│ └── process-queue # 이미지 그룹핑 배치 처리 (EventBridge 트리거)
└── loops/ # 룹 CRUD + 참여자 관리
└── {eid}/
└── participants/ # 참여자 추가/제거/수정Admin API 라우트
Google Workspace OIDC 인증이 필요하며, ADMIN_GOOGLE_CLIENT_ID 설정 시에만 활성화됩니다:
/core/admin/v1/
├── validate-token/ # 토큰 검증 (GET, 204 No Content)
├── dashboard/ # 관리자 대시보드
│ ├── summary (GET) # 주요 지표 요약 (총 사용자, DAU, 일별 룹, 회원 변동)
│ └── time-series (GET) # 시계열 데이터 (DAU, 일별 룹 추이, ?days=30)
├── users/ # 유저 관리
│ ├── (GET) # 유저 목록 조회 (search, isActive, page, limit)
│ └── {eid}/
│ ├── (GET) # 유저 상세 조회
│ └── (PATCH) # 유저 정보 수정 (name, username, email, locale, birthDate, isActive, isProfilePrivate)
├── cover-templates/ # 커버 템플릿 CRUD
│ ├── upload-url/ (POST) # 업로드 URL 발급
│ ├── (POST) # 생성
│ ├── (GET) # 목록 조회 (status, category, page, limit 필터)
│ └── {eid}/
│ ├── (GET) # 상세 조회
│ ├── (PATCH) # 수정
│ ├── (DELETE) # 삭제 (204 No Content)
│ └── status (PATCH) # 상태 변경 (DRAFT → PUBLISHED → ARCHIVED, 재게시 가능)
└── sample-loops/ # 샘플 룹 관리
├── resolve-invite/ (POST) # 초대 토큰으로 룹 미리보기 조회
├── (POST) # 샘플 룹 등록
├── (GET) # 샘플 룹 목록 조회
└── {eid}/
├── (PATCH) # 샘플 룹 수정
└── (DELETE) # 샘플 룹 삭제라우트 설계 원칙
Route → Controller → UseCase 패턴
주의
Route 파일(*Routes.kt)에서 UseCase.execute()를 직접 호출하는 것은 금지됩니다. Gradle 가드레일 태스크(verifyRouteUseCaseDelegation)가 이를 CI에서 자동으로 검증합니다.
책임 분리:
| 계층 | 책임 |
|---|---|
| Route | HTTP 관심사만 처리: 경로/쿼리/헤더/바디 파싱, 인증 컨텍스트, 상태 코드/응답 |
| Controller/Handler | Use Case 호출 및 응답 매핑 오케스트레이션 |
| UseCase | 순수 비즈니스 로직 실행 |
네이밍 규칙:
*Controller: 기능 단위 엔드포인트 그룹*Handler: 좁은 범위의 특정 작업 처리
인증 경계 규칙
- 인증 경계 (
authenticate("auth-jwt"),authenticate("internal-api"))는RouteRegistry에서만 설정 - Feature 라우트 함수는 기본적으로 auth-neutral (인증 불가지론적)
- Public/Authenticated 혼합 도메인:
{feature}PublicRoutes()/{feature}AuthenticatedRoutes()로 분리
DTO 패턴
Request/Response DTO
- DTO는
adapter:web모듈에 위치 kotlinx-serialization의@Serializable사용- 도메인 모델과의 매핑은 Presentation Mapper에서 수행
kotlin
// Request DTO (adapter:web)
@Serializable
data class CreateLoopRequestDto(
val datetime: String? = null, // null이면 COLLECTING 상태로 생성
val timezone: String? = null,
val location: CreateLoopLocationDto? = null,
val participants: List<String>, // 참여자 user EID 목록
val title: String? = null,
val description: String? = null,
@SerialName("cover_template_eid") val coverTemplateEid: String? = null,
@SerialName("proposed_dates") val proposedDates: String? = null,
val category: LoopCategoryDto? = null,
@SerialName("availability_deadline") val availabilityDeadline: String? = null, // ISO 8601 UTC
)
// Response DTO (adapter:web)
@Serializable
data class CreateLoopResponseDto(
val eid: String, // 항상 EID (내부 ID 노출 금지)
val title: String? = null,
val datetime: String? = null,
val status: LoopStatusDto,
val timezone: String,
val location: LoopLocationDto,
val participants: List<LoopParticipantDto>,
@SerialName("created_at") val createdAt: String,
)정보
DTO 필드명은 snake_case(@SerialName 사용)로 직렬화합니다. kotlinx-serialization의 @SerialName 어노테이션으로 JSON 필드명을 지정합니다.
핵심 규칙
위험
- 외부 노출 ID는 항상 EID (TSID 기반 문자열). 내부 DB ID는 절대 노출하지 않음
- DTO ↔ Domain 매핑은 Presentation Mapper에서 수행. Domain 모델이 외부 의존성에 종속되지 않도록 함
- 검증은 Presentation 계층에서 수행. Domain Entity는 이미 유효한 상태만 수신
인증 체계
JWT 인증
클라이언트 요청에 대한 사용자 인증:
Authorization: Bearer <JWT_TOKEN>- JWT 발급/검증은
adapter:external/jwt패키지에서 처리 - Security 플러그인 (
configureSecurity)에서 Ktorauthenticate("auth-jwt")설정
Internal API 인증
서비스 간 통신을 위한 API Key 인증:
Authorization: Bearer <INTERNAL_API_KEY>authenticate("internal-api")블록으로 보호- truloop-ai-server, truloop-assistant 등 내부 서비스에서 사용
Admin 인증
관리자 페이지 접근을 위한 Google Workspace OIDC:
authenticate("admin-google-oidc")블록으로 보호ADMIN_GOOGLE_CLIENT_ID환경변수가 설정된 경우에만 활성화- Google OIDC 클레임 검증 로직(
validateGoogleOidcPayload)은 Swagger UI 접근 보호와 공유
응답 형식
성공 응답
json
{
"eid": "ABC123XYZ",
"title": "주말 나들이",
"status": "CONFIRMED",
"created_at": "2026-03-09T10:00:00Z"
}에러 응답
ErrorDetail 구조체로 통일되어 있으며, StatusPagesConfig에서 모든 예외를 변환합니다:
json
{
"type": "ERROR_TYPE_NOT_FOUND",
"display_type": "DISPLAY_TYPE_SNACKBAR",
"message": {
"locale": "ko-KR",
"text": "Loop을 찾을 수 없습니다"
},
"metadata_proto_json": null
}| 필드 | 설명 |
|---|---|
type | 머신 리더블 에러 코드 (i18n 클라이언트 번역용) |
display_type | 클라이언트 UI 힌트 (DISPLAY_TYPE_NONE, DISPLAY_TYPE_SNACKBAR, DISPLAY_TYPE_DIALOG, DISPLAY_TYPE_UNSPECIFIED) |
message | 로캘 정보(locale)와 번역된 텍스트(text)를 포함하는 객체 |
metadata_proto_json | 추가 컨텍스트 정보 (JSON 문자열, nullable) |
예외 → HTTP 상태 코드 매핑
BusinessException sealed class 기반:
| 예외 클래스 | HTTP 상태 코드 |
|---|---|
NotFoundException | 404 Not Found |
ForbiddenException | 403 Forbidden |
BadRequestException | 400 Bad Request |
ConflictException | 409 Conflict |
UnauthorizedException | 401 Unauthorized |
ServiceUnavailableException | 503 Service Unavailable |
InternalException | 500 Internal Server Error |
InfrastructureException(외부 서비스, DB, 네트워크 장애)은 500/502/503으로 변환되며, Sentry로 자동 보고됩니다.
OpenAPI 문서 자동 생성
io.github.smiley4:ktor-openapi + ktor-swagger-ui 플러그인으로 모든 API 엔드포인트의 OpenAPI 스펙을 자동 생성합니다.
| 항목 | 값 |
|---|---|
| Swagger UI | GET {basePath}/swagger-ui |
| OpenAPI JSON | GET {basePath}/swagger-ui/api-docs.json |
| 플러그인 | io.github.smiley4:ktor-openapi, io.github.smiley4:ktor-swagger-ui, io.github.smiley4:schema-kenerator-core |
Swagger UI 접근 보호
ADMIN_GOOGLE_CLIENT_ID 환경변수가 설정된 경우, Swagger UI는 Google OAuth 쿠키 인증으로 보호됩니다. 미설정 시 기존과 동일하게 공개 접근됩니다 (로컬 개발 호환).
라우트 구조 (보호 활성화 시):
| Method | Path | 설명 |
|---|---|---|
GET | {basePath}/swagger-login | Google Sign-In 로그인 페이지 |
POST | {basePath}/swagger-auth/callback | ID 토큰 검증 후 쿠키 설정 |
GET | {basePath}/swagger-ui/** | 쿠키 인증 게이트 (미인증 시 /swagger-login으로 리다이렉트) |
인증 흐름:
아키텍처:
- 구현 위치:
bootstrap/plugins/SwaggerAuth.kt SwaggerTokenValidator— 쿠키에서 ID 토큰을 추출하고 JWKS로 RSA256 서명을 검증한 뒤,validateGoogleOidcPayload()로 클레임을 검증SwaggerCookieAuth— KtorcreateRouteScopedPlugin으로 구현된 라우트 스코프 플러그인./swagger-ui라우트에만 설치- Admin 인증과 동일한
validateGoogleOidcPayload()함수를 공유하여 Google OIDC 검증 로직 중복을 방지 ktor-server-html-builder의존성으로 로그인 페이지를 서버사이드 렌더링
정보
configureSwaggerRoutes()는 adminGoogleOidcConfig가 null이면 기존과 동일하게 인증 없이 Swagger UI를 제공합니다. 로컬 개발 환경에서는 ADMIN_GOOGLE_CLIENT_ID를 설정하지 않으면 됩니다.
DTO 스키마 설명
schema-kenerator-core 라이브러리의 @Description 어노테이션으로 DTO 프로퍼티에 설명을 추가하면 OpenAPI JSON 스키마의 description 필드로 자동 반영됩니다. 한국어 설명을 사용합니다.
kotlin
@Serializable
data class LoopDetailResponseDto(
@Description("루프 고유 식별자")
val eid: String,
@Description("루프 제목")
val title: String?,
@Description("일정 날짜/시간 (COLLECTING 상태에서는 null)")
val datetime: String?,
@Description("루프 상태")
val status: LoopStatusDto,
// ...
)Route 핸들러에서 response { code(HttpStatusCode.OK) { body<ResponseDto>() } } 패턴으로 응답 DTO 타입을 명시하면 Swagger UI에서 응답 스키마를 확인할 수 있습니다.
kotlin
op.response {
code(HttpStatusCode.OK) {
body<LoopDetailResponseDto>()
description = "루프 상세 조회 성공"
}
}인증 스키마
OpenAPI 스펙에 정의된 보안 스키마:
| 스키마 이름 | 타입 | 설명 |
|---|---|---|
auth-jwt | HTTP Bearer (JWT) | 클라이언트 JWT 인증 (기본 보안 스키마) |
internal-api | HTTP Bearer | 내부 서비스 간 API 키 인증 |
admin-google-oidc | OpenID Connect | Google Workspace OIDC 관리자 인증 |
라우트 태그 규칙
각 라우트 파일의 엔드포인트는 tags() 함수로 API 그룹을 지정합니다. 태그명은 경로 prefix 기반입니다 (예: api/loops, api/stories, internal/loops).
변경 이력
| 날짜 | 내용 |
|---|---|
| 2026-03-16 | Admin sample-loops 엔드포인트 5개 추가 (resolve-invite, CRUD) |
| 2026-03-16 | Grouped Media API 응답 스키마 변경 (items→media, commentsCount/taggedUsers 추가), Admin 유저 관리 API 추가 |
| 2026-03-12 | DTO 스키마 설명(@Description) 및 응답 타입 명시(body<ResponseDto>()) 패턴 추가 |
| 2026-03-12 | Swagger UI Google OAuth 쿠키 인증 보호 추가 (ADMIN_GOOGLE_CLIENT_ID 설정 시 활성화) |
| 2026-03-12 | 이미지 그룹핑 API 추가 — v3 미디어 그룹 라우트, 대표 사진 CRUD, Internal 배치 처리 |
| 2026-03-12 | OpenAPI 문서 자동 생성 섹션 추가 (Swagger UI, 인증 스키마, 태그 규칙) |
| 2026-03-12 | CreateLoopRequestDto에 availability_deadline 필드 반영 |
| 2026-03-11 | availabilityDeadline 검증 에러 추가: ERROR_TYPE_DEADLINE_IN_PAST (400), ERROR_TYPE_DEADLINE_AFTER_PROPOSED_DATES (400) |
| 2026-03-11 | Admin API dashboard 엔드포인트 추가 (summary, time-series) |
| 2026-03-10 | Admin API cover-templates 하위 구조 상세화 (CRUD + upload-url + status 변경), 리캡 텍스트 편집 API 상세 추가, 룹 리스트 API 쿼리 파라미터 상세 추가, v1 커버 템플릿 설명 보강 |
| 2026-03-10 | 소스 코드 기준으로 전면 검증: v3 라우트 상세화(stories 경로 수정, 서브 리소스 추가), Public API/Admin API 라우트 추가, 에러 응답 형식을 실제 ErrorDetail 구조체로 수정, DTO 예시를 실제 코드 기반으로 교체, 예외-HTTP 매핑 테이블 추가 |