Files
irukseo/projects/Didit 분석.md
2026-05-05 21:27:37 +09:00

45 KiB

Didit

본 문서는 Write-docs.md 템플릿에 따라 실제 코드에서 확인된 내용만을 바탕으로 작성되었다. 확인되지 않는 내용은 "확인 필요"로 표기한다.


1. 프로젝트 목적

해결하려는 문제

  • 분산된 팀이 GitHub 이슈 기반으로 협업할 때, 이슈 우선순위 판단이 수동적이고 회의 내용이 체계적으로 기록/공유되지 않는 문제
  • 실시간 화상회의 + 채팅 + GitHub 이슈 관리가 각각 분리되어 워크플로우 단절이 발생하는 문제

주요 사용자

  • GitHub 저장소를 사용하는 개발 팀
  • 실시간 화상회의로 소통하면서 이슈 트래킹이 필요한 애자일/스크럼 팀

프로젝트 목표

  • 하나의 플랫폼에서 화상회의 + 채팅 + GitHub 이슈 관리 + AI 기반 요약/우선순위 분석을 통합 제공

백엔드가 담당하는 핵심 책임

  • RESTful API 제공 (프로젝트 CRUD, 이슈 관리, 회의 관리, 채팅)
  • GitHub OAuth2 인증/인가 처리
  • OpenVidu WebRTC 미디어 서버 연동 (세션/커넥션/녹화 관리)
  • Redis 기반 비동기 작업 큐잉 (AI 분석 요청 → 응답 수신)
  • SSE(Server-Sent Events) 기반 실시간 이벤트 스트리밍
  • Flyway 기반 DB 마이그레이션 관리

인프라 구성이 필요한 이유

  • 5개 이상의 서비스(서버, DB, Redis, OpenVidu, AI Worker, 클라이언트)가 유기적으로 연동되어야 하므로 컨테이너 오케스트레이션이 필요
  • 내부 네트워크 분리로 서비스 간 보안 격리 필요
  • Reverse Proxy를 통한 도메인 라우팅 및 HTTPS 처리 필요

한 줄 설명

"GitHub OAuth 기반의 실시간 팀 협업 플랫폼으로, AI가 이슈 우선순위를 분석하고 회의 내용을 자동 요약한다."


2. 전체 아키텍처

전체 구성 요소

계층 기술 스택 역할
Reverse Proxy Traefik 도메인 라우팅, HTTPS 종단
Frontend React SPA 클라이언트
Backend Java 21 + Spring Boot 4.0 REST API, 인증/인가, SSE
Media Server OpenVidu 2.32.1 WebRTC 화상회의 (SFU)
AI Worker Python (FastAPI) + HuggingFace 이슈 우선순위 분석, 회의 요약 (Whisper STT + Claude API)
Message Broker Redis 7.2 작업 큐, Pub/Sub, SSE client key 저장
Database MySQL 8.0 Source of Truth
검색 엔진 Elasticsearch (의존성만 존재, 실제 사용 확인 필요)

프론트엔드/백엔드/DB/Redis/Storage/Worker/Reverse Proxy 관계

외부 요청 (HTTPS)
  │
  ▼
Traefik (Reverse Proxy)
  ├─► didit-client (React 정적 서빙)
  ├─► didit-server (Spring Boot :8080)
  │     ├─► MySQL 8.0 (:3306, internal 네트워크만)
  │     ├─► Redis 7.2 (:6379)
  │     ├─► OpenVidu (:4443, internal 네트워크만)
  │     └─► SSE Emitter → 클라이언트 직접 연결
  └─► (AI Worker는 HTTP 외부 노출 없음, Redis 큐로만 통신)

외부 요청이 내부 서비스로 전달되는 흐름

  1. 클라이언트 → https://did-it.xyz → Traefik → didit-client (정적 파일) 또는 didit-server (API)
  2. didit-serverinternal 네트워크를 통해 db:3306, redis:6379, openvidu:4443와 통신
  3. AI 분석 요청: 서버가 Redis List(queue:issue:priority:single)에 작업 Push → AI Worker가 polling → 결과 Redis Pub/Sub 발행 → 서버 Listener가 수신 → SSE로 클라이언트 전달

서비스 간 책임 분리

  • didit-server: 모든 비즈니스 로직, 인증, API, SSE, 외부 연동 조율
  • AI Worker: 순수 ML 추론 작업만 수행, HTTP 엔드포인트 없이 Redis로만 통신
  • OpenVidu: WebRTC 미디어 스트리밍만 담당, 시그널링/녹화는 서버가 조율
  • MySQL: 모든 영속 데이터 저장
  • Redis: 캐시/세션보다 비동기 작업 큐 + Pub/Sub 메시징이 주 용도

외부 공개 서비스와 내부 서비스 구분

공개 (외부) 비공개 (내부)
Traefik (443) MySQL (3306)
didit-server (8080, Caddy 네트워크 경유) Redis (6379, 포트는 외부 노출되어 있음 - 개선 여지 있음)
didit-client OpenVidu (4443)
AI Worker

아키텍처 유형

모듈러 모놀리스에 가깝다. 하나의 Spring Boot 애플리케이션이 API + SSE + 보안을 모두 담당하며, AI 분석만 별도 Python Worker로 분리되어 있다. 패키지 구조는 전통적인 3계층(api/service/data)으로 설계되어 있다.

아키텍처 설계 의도

  • SSE + Redis Pub/Sub으로 실시간성을 확보하면서도 WebSocket보다 가벼운 인프라 구성
  • AI Worker를 별도 프로세스로 분리해 ML 추론 부하가 API 응답성에 영향을 주지 않도록 설계
  • GitHub OAuth2 + Session Cookie 방식으로 SPA에서도 안전한 인증 구현

3. API 설계

API 버전 관리

  • URL 경로 기반 버저닝: /api/v1/
  • Base URL: https://api.did-it.xyz/api/v1

Swagger/OpenAPI 문서화

  • SpringDoc OpenAPI 3.0.1 (Swagger UI 연동)
  • openapi.yaml 파일 제공 (/openapi.yaml, /v3/api-docs/swagger-config)
  • 운영 환경(prod)에서는 Swagger UI / API Docs 비활성화

주요 API 목록 (총 30개+ 엔드포인트)

인증 (Auth)

Method Endpoint 인증 Request DTO Response DTO 성공 실패
GET /api/v1/auth/login 불필요 - Redirect 302 -
POST /api/v1/auth/logout 불필요 - - 200 -
GET /api/v1/auth/me 필요 - UserResponse 200 401

프로젝트 (Projects)

Method Endpoint 인증 권한 Request DTO Response DTO
GET /api/v1/projects?page={n} 필요 멤버 - ProjectResponse[]
POST /api/v1/projects 필요 - AddProjectRequest 200 empty
GET /api/v1/projects/{projectId} 필요 멤버 - ProjectResponse
DELETE /api/v1/projects/{projectId} 필요 OWNER - 200 empty
DELETE /api/v1/projects/{projectId}/leave 필요 멤버 - 200 empty
GET /api/v1/projects/recents 필요 - - ProjectRecentResponse[]
GET /api/v1/projects/{projectId}/participants 필요 - - UserResponse[]
DELETE /api/v1/projects/{projectId}/participants/{userId} 필요 OWNER - 200 empty
PATCH /api/v1/projects/{projectId} 필요 OWNER UpdateRepoRequest 200 empty
PATCH /api/v1/projects/{projectId}/owner 필요 OWNER TransferOwnerRequest 200 empty
PATCH /api/v1/projects/{projectId}/name 필요 OWNER UpdateProjectNameRequest 200 empty
POST /api/v1/projects/{projectId}/github/validate 필요 OWNER ValidateGithubRequest GithubRepoResponse

초대 (Invites)

Method Endpoint 인증 권한 Request DTO Response DTO
POST /api/v1/projects/invites 필요 ADMIN AddProjectInviteRequest UUID String
GET /api/v1/projects/invites/{inviteCode} 필요 - - ProjectResponse
POST /api/v1/projects/invites/{inviteCode} 필요 - - 200 empty

회의 (Meetings/Channels)

Method Endpoint 인증 권한 Request DTO Response DTO
POST /api/v1/projects/{projectId}/add-channel 필요 ADMIN CreateMeetingRequest Long (meetingId)
POST /api/v1/projects/{projectId}/book-channel 필요 ADMIN BookMeetingRequest Long (meetingId)
GET /api/v1/projects/{projectId}/channels?status&cursor 필요 - - MeetingResponse[]
GET /api/v1/projects/{projectId}/channels/date?start&end 필요 멤버 - MeetingResponse[]
GET /api/v1/channels/{channelId} 필요 멤버 - MeetingResponse
PATCH /api/v1/channels/{channelId}?title&start&due 필요 멤버 - 200 empty
DELETE /api/v1/channels/{channelId} 필요 멤버 - 200 empty
POST /api/v1/channels/{channelId}/webrtc 필요 멤버(VOICE) - String (token)
DELETE /api/v1/channels/{channelId}/webrtc 필요 멤버(VOICE) - 200 empty
GET /api/v1/channels/{channelId}/webrtc/users 필요 - - UserResponse[]
POST /api/v1/channels/{channelId}/recording/start 필요 멤버(VOICE) - Long (recordId)
POST /api/v1/channels/{channelId}/recording/stop/{recordId} 필요 - - 200 empty

채팅 (Chat)

Method Endpoint 인증 권한 Request DTO Response DTO
POST /api/v1/channels/{channelId}/chats 필요 멤버 SendMessageRequest 200 empty
GET /api/v1/channels/{channelId}/chats?lastId 필요 멤버 - ChatResponse[]
PATCH /api/v1/channels/chats/{chatId} 필요 작성자 UpdateChatMessage 200 empty
DELETE /api/v1/channels/chats/{chatId} 필요 작성자 - 200 empty

SSE (Server-Sent Events)

Method Endpoint 인증 권한 설명
GET /api/v1/channels/{channelId}/stream 필요 멤버 채널 단위 실시간 이벤트
GET /api/v1/projects/{projectId}/stream 필요 멤버 프로젝트 단위 실시간 이벤트

이슈 (Issues)

Method Endpoint 인증 권한 Request DTO Response DTO
GET /api/v1/projects/{projectId}/issues/active 필요 멤버 - IssueResponse[]
GET /api/v1/projects/{projectId}/issues 필요 멤버 - IssueResponse[]
POST /api/v1/projects/{projectId}/issue/analyze 필요 멤버 AnalyzeIssueRequest AiResponse
POST /api/v1/projects/{projectId}/issues 필요 작성자 CreateIssueRequest IssueResponse[]
PATCH /api/v1/projects/{projectId}/issues/{issueId} 필요 작성자 UpdateIssueRequest 200 empty
DELETE /api/v1/projects/{projectId}/issues/{issueId} 필요 작성자 - 200 empty
GET /api/v1/projects/{projectId}/github/issues 필요 멤버 - IssueResponse[]

웹훅 (Webhooks)

Method Endpoint 인증 설명
POST /api/v1/webhooks/openvidu Token 검증 OpenVidu 이벤트 수신 (세션/참가자/녹화 이벤트)

회의 요약 (Meeting Summary)

Method Endpoint 인증 권한
GET /api/v1/projects/{projectId}/summary?page 필요 멤버
PATCH /api/v1/projects/{projectId}/summary/{summaryId} 필요 멤버
DELETE /api/v1/projects/{projectId}/summary/{summaryId} 필요 ADMIN

페이징 방식

  • Spring Pageable (page, size, sort 쿼리 파라미터) - 회의 목록, 채팅 목록
  • 커서 기반 (cursor/lastId) - 일부 엔드포인트에서 사용

공통 응답 패턴

  • 성공: 200 OK + DTO (빈 body인 경우도 있음)
  • 실패: ErrorResponse(statusCode, message) + 해당 HTTP Status

4. 인증/인가 설계

로그인 방식

  • GitHub OAuth2 (Authorization Code Grant)
  • Spring Security의 oauth2Login() 사용
  • 요청 권한 범위(scope): read:user, user:email, repo, read:org

세션 관리

  • Session Cookie (JSESSIONID) 기반
  • Cookie 설정: SameSite=None, Secure=True, HttpOnly=True
  • 로그아웃 시 세션 무효화 + 쿠키 삭제

현재 사용자 식별 방식

  • @AuthenticationPrincipal CustomOAuth2User 어노테이션으로 Controller에서 접근
  • CustomOAuth2User는 Spring의 DefaultOAuth2User를 확장하여 내부 DB PK(id)와 GitHub accessToken을 포함
  • OAuth2 로그인 성공 시 CustomOAuth2UserService에서 사용자 정보를 DB에 저장/갱신 (joinOrUpdate)

Role/Permission 구조

Role 범위 설명
ADMIN 프로젝트 프로젝트 소유자(최초 생성자), 모든 관리 권한 보유
MEMBER 프로젝트 일반 참여자, 읽기/쓰기 가능하나 관리 기능 제한
User Status 설명
PENDING 초대 수락 전 (사용하지 않는 것으로 보임)
ACTIVE 활성 멤버
LEFT 탈퇴/추방된 멤버 (soft 상태 변경)

리소스 소유자 검증 방식

  • 프로젝트 소유자(ADMIN) 검증: project.getOwner().getId().equals(userId) → 실패 시 ForbiddenError("권한이 부족합니다.")
  • 이슈 작성자 검증: entity.getAuthor().getId() != userId → 실패 시 ForbiddenError("권한이 없습니다.")
  • 프로젝트 멤버십 검증: 모든 요청 전 FindProjectUser(userId, projectId) 호출 → 실패 시 NotFoundError 또는 ForbiddenError

권한 검증 위치

  • Controller 계층: @AuthenticationPrincipal으로 사용자 식별
  • Service 계층: 모든 권한 검증이 Service 구현체 내에서 수행됨 (Global Filter/AOP 미사용)

인증 실패/권한 실패 응답 방식

  • 인증 실패: Spring Security 기본 동작 (401 Unauthorized 또는 로그인 페이지로 리다이렉트)
  • 권한 실패: Result.fail(new ForbiddenError("메시지")) → Controller에서 ErrorResponse로 변환 (HTTP 403)

인증/인가 설계 판단

장점: GitHub OAuth2로 별도 회원가입 절차 불필요, 개발자 타겟에 적합 개선 여지:

  • Role이 ADMIN/MEMBER 2개로 단순하나, 요약 삭제(DELETE summary)만 ADMIN 전용이고 대부분 MEMBER에게 개방되어 있어 세분화 부족
  • @PreAuthorize 등 선언적 인가보다 Service 코드 내 if문 검증이 많아 보안 정책이 코드에 흩어져 있음
  • OpenVidu Webhook 엔드포인트만 Token 헤더 검증 (공개 URL + Secret Token)

5. 데이터베이스 설계

주요 Entity 목록 (총 14개 테이블)

Entity Table 설명
UserEntity users GitHub OAuth 사용자
UserGithubAuthEntity user_github_auth GitHub Access Token 저장 (1:1 users)
ProjectEntity projects 프로젝트/워크스페이스
ProjectUserEntity project_users 프로젝트-사용자 멤버십 (N:M)
ProjectInviteEntity project_invites UUID 기반 초대 링크
ProjectRecentEntity project_recents 최근 조회 프로젝트 (최대 4개 노출)
MeetingEntity meetings 회의/채널
MeetingUserEntity meeting_users 회의 참여자
MeetingRecordEntity meeting_records OpenVidu 녹화 기록
MeetingSummaryEntity meeting_summary AI 회의 요약 (버전 관리)
IssueEntity issues GitHub 연동 이슈
IssueAssigneeEntity issue_assignees 이슈 담당자 (N:M)
ChatEntity chats 채팅 메시지 (Soft Delete)
ProjectUserReadEntity project_user_reads 사용자별 읽음 상태

테이블 간 관계 (주요 FK)

users (1) ────── (1) user_github_auth        [FK: user_id, ON DELETE CASCADE]

users (1) ────── (N) projects                 [FK: owner_id, ON DELETE RESTRICT]
users (1) ────── (N) project_users            [FK: user_id, ON DELETE RESTRICT]
projects (1) ──── (N) project_users           [FK: project_id, ON DELETE CASCADE]

projects (1) ──── (N) project_invites         [FK: project_id, ON DELETE CASCADE]

projects (1) ──── (N) meetings                [FK: project_id, ON DELETE CASCADE]
users (1) ──────── (N) meetings               [FK: created_by, ON DELETE RESTRICT]
meetings (1) ──── (N) meeting_users           [FK: meeting_id, ON DELETE CASCADE]
users (1) ──────── (N) meeting_users          [FK: user_id, ON DELETE RESTRICT]

meetings (1) ──── (N) meeting_records         [FK: meeting_id, ON DELETE CASCADE] (추정)

meetings (1) ──── (N) meeting_summary         [FK: meeting_id, ON DELETE CASCADE]

projects (1) ──── (N) issues                  [FK: project_id, ON DELETE CASCADE]
users (1) ──────── (N) issues                 [FK: author_id, ON DELETE RESTRICT]
issues (1) ────── (N) issue_assignees         [FK: issue_id, ON DELETE CASCADE]
users (1) ──────── (N) issue_assignees        [FK: user_id, ON DELETE RESTRICT]

projects (1) ──── (N) chats                   [FK: project_id, ON DELETE CASCADE]
users (1) ──────── (N) chats                  [FK: user_id, ON DELETE RESTRICT]
meetings (1) ──── (N) chats                   [FK: meeting_id, ON DELETE SET NULL]

주요 FK 제약 특징

  • ON DELETE RESTRICT: 사용자(users) 삭제 방지 (관련 엔티티가 있으면 삭제 불가)
  • ON DELETE CASCADE: 프로젝트/회의/이슈 삭제 시 하위 데이터 자동 삭제
  • ON DELETE SET NULL: 채팅의 meeting_id, 요약의 meeting_id/edited_by → 참조 레코드 삭제 시 null

주요 Unique / Index 제약

테이블 Type 컬럼 설명
users UK github_id GitHub 계정 중복 방지
users UK github_login GitHub 로그인 중복 방지
projects UK repo_full_name, active_key 동일 레포지토리 중복 등록 방지 (Soft Delete 고려)
project_users UK (project_id, user_id) 중복 멤버십 방지
issues UK (project_id, github_issue_id) 프로젝트 내 동일 GitHub 이슈 중복 방지
issue_assignees UK (issue_id, user_id) 이슈별 중복 담당자 방지
meeting_summary UK (meeting_id, version) 동일 회의 동일 버전 요약 중복 방지
meeting_records UK recording_session_id 녹화 세션 중복 방지
project_recents UK (user_id, project_id) 최근 조회 중복 방지
project_user_reads UK (project_id, user_id) 읽음 상태 중복 방지

Soft Delete 여부

  • projects: Soft Delete (deleted_at 컬럼 존재, 그러나 ProjectRepository.delete()가 실제 삭제하는 것으로 보임 - 확인 필요)
  • chats: Soft Delete (deleted_at 컬럼 존재, 실제 deleteMessage() 동작은 확인 필요)
  • project_users: 상태 변경 방식 (status = LEFT, left_at 기록) - 실제 삭제 없이 상태만 변경

CreatedAt/UpdatedAt 관리 방식

  • JPA Entity에서 @CreationTimestamp, @UpdateTimestamp 사용
  • DB 레벨: DEFAULT CURRENT_TIMESTAMP, ON UPDATE CURRENT_TIMESTAMP

Migration 관리 방식

  • Flyway 사용
  • 마이그레이션 파일: apps/server/src/main/resources/db/migration/V{번호}__{설명}.sql
  • 총 12개 마이그레이션 파일 (V1~V12)
  • clean-disabled: true (운영에서 실수로 clean 방지)
  • JPA: ddl-auto: validate (Entity-DB 불일치 시 즉시 오류)

데이터 정합성 설계

  • Unique Constraint + FK 제약으로 애플리케이션 레벨 + DB 레벨 이중 방어
  • @Transactional로 서비스 단위 작업 원자성 보장

6. 비즈니스 로직 / UseCase 구조

주요 UseCase 목록

프로젝트 관리

  • AddProject: 프로젝트 생성 + 생성자 ADMIN 등록 + 최근 조회 upsert
  • AddInviteCode: UUID 초대 코드 생성 (ADMIN 전용, 만료일 설정)
  • AddProjectUser: 초대 코드로 프로젝트 참여 (LEFT 상태였으면 ACTIVE로 복원)
  • DeleteProject: 프로젝트 삭제 (OWNER 전용)
  • LeaveProject: 프로젝트 탈퇴 (OWNER 불가), 상태 LEFT로 변경
  • transferOwnership: OWNER 권한 이전 (ADMIN↔MEMBER 역할 교환)
  • removeParticipant: 참여자 추방 (OWNER 전용)

회의 관리

  • createMeeting: 회의 생성 (ADMIN 전용, RUNNING 상태)
  • bookMeeting: 회의 예약 (ADMIN 전용, SCHEDULED 상태, 시간 검증 포함)
  • UpdateMeeting: 회의명/시간 수정
  • DeleteMeeting: 회의 삭제

미디어 관리 (OpenVidu 연동)

  • createConnection: OpenVidu 세션/커넥션 생성 → 토큰 발급
  • closeConnection: 커넥션 종료 + 사용자 left 처리
  • StartRecording: 녹화 시작
  • StopRecording: 녹화 중단

이슈 관리 (GitHub 연동)

  • AnalyzeIssue: 이슈 초안 생성 → Redis 큐에 AI 분석 요청
  • CreateIssue: AI 분석 완료된 초안 → GitHub 실제 이슈 생성 + 우선순위 설정
  • updateIssue: 이슈 수정 (작성자만, GitHub 동기화 포함)
  • deleteIssue: 이슈 삭제 (작성자만, GitHub 이슈는 CLOSE)
  • getGithubIssues: GitHub에서 이슈 동기화 (신규 등록 + 상태 업데이트)

Command/Query 구조

  • service/command/: AddProjectCommand, BookMeetingCommand, UpdateRecordingCommand, UpdateSummaryCommand
  • 요청 DTO → Controller에서 Command 변환 → Service 호출
  • CQS(Command-Query Separation)가 엄격하게 적용되지는 않음 (대부분 Service가 CRUD 혼합)

요청 처리 순서 (예: CreateIssue)

  1. Controller: @AuthenticationPrincipal으로 userId 추출, @RequestBody CreateIssueRequest 바인딩
  2. Service: issueRepository.findById(issueId) → 드래프트 이슈 존재 확인
  3. 작성자 검증 (entity.getAuthor().getId() != userId)
  4. GitHub 인증 정보 조회 (userGithubAuthRepository)
  5. GitHub API 호출: githubService.createIssue(accessToken, ...)
  6. 로컬 Entity 업데이트 (title, body, priority, githubIssueId, issueNo, assignees)
  7. issueRepository.save(entity)

검증 로직

  • Bean Validation: @Valid + Jakarta Validation (@NotBlank, @Size, @URL)
  • Service 내 비즈니스 검증: 존재 확인 → NotFoundError, 권한 확인 → ForbiddenError, 중복 확인 → ConflictError

성공/실패 반환 방식

  • 모든 Service 메서드가 Result<T> 반환
  • Controller는 result.isFailure()ResultError.getResponse() (ErrorResponse DTO + HTTP Status)
  • 성공 시 ResponseEntity.ok(data) 또는 .ok().build() (empty body)

API 계층과 UseCase 계층의 책임 분리

  • Controller: URL 라우팅, @Valid 검증, @AuthenticationPrincipal 추출, Result→HTTP 변환, DTO 변환
  • Service: 모든 비즈니스 로직, 권한 검증, 트랜잭션 관리, 외부 API 호출

7. 트랜잭션과 동시성

트랜잭션이 필요한 기능

  • AddProject: 프로젝트 생성 + ADMIN 등록 (2개 Entity)
  • CreateIssue: 이슈 Entity 업데이트 + Assignee 교체 + GitHub API 호출
  • transferOwnership: OWNER/MEMBER 역할 교환 (3개 Entity 동시 변경)
  • LeaveProject: 상태 변경 (UPDATE only)
  • updateProjectRepo: 프로젝트 레포지토리 정보 변경

트랜잭션 범위

  • @Transactional이 Service 구현체 메서드 단위로 적용
  • ProjectServiceImpl: @Transactional(readOnly = true) (조회성), @Transactional (쓰기)
  • IssueServiceImpl: @jakarta.transaction.Transactional (Jakarta EE, Spring과 혼용 - 개선 여지)

동시 요청 시 발생할 수 있는 문제

중복 참여 (AddProjectUser)

// LEFT 상태 유저 → ACTIVE로 복원, 이미 ACTIVE → ConflictError
if (entity.getStatus() == ProjectUserStatus.ACTIVE) {
    return Result.fail(new ConflictError("이미 존재하는 유저입니다."));
}
  • 동시에 2개 요청이 들어오면 둘 다 LEFT 상태를 읽고 ACTIVE로 변경 → 2개 ACTIVE 레코드 가능성 없음 (UK 제약 (project_id, user_id)이 방어)

중복 초대 코드 사용

  • POST /api/v1/projects/invites/{inviteCode}: 경쟁 상태 가능성 낮음 (멱등성 - ACTIVE로 상태 변경만 수행)

중복 생성 방지 방식

  • UK Constraint: DB에서 중복 생성 방지
    • uk_project_users_project_user: 동일 프로젝트 중복 멤버십
    • uk_issues_project_github_issue_id: 동일 GitHub 이슈 중복
    • uk_issue_assignees_issue_user: 동일 이슈 중복 담당자
  • 사전 중복 체크: existsByRepoFullName(), existsByProject_IdAndUser_IdAndStatus()

Lock / CAS / Optimistic Concurrency

  • 명시적 Lock 사용: 코드에서 확인되지 않음
  • Optimistic Concurrency: MeetingSummaryEntity@Version과 혼동될 수 있는 version 컬럼이 있으나 JPA의 @Version은 아님 (단순 데이터 컬럼)

Idempotency 고려

  • Redis 큐(leftPush)로 AI 작업을 중복 적재할 가능성 있음 (이슈 조회 시 항상 큐에 push)
  • Idempotency key 미사용, 별도 중복 방지 메커니즘 없음 → 개선 여지

8. 예외 처리와 응답 구조

Result 패턴

  • 자체 구현 Result<T> 클래스 사용 (C#의 Result 패턴 차용)
  • 예외를 던지지 않고 성공/실패를 값으로 반환
  • Result.ok(value) / Result.fail(error) / Result.fail(errors)

ResultError 계층

ResultError (인터페이스)
  ├── SimpleError (단순 code + message)
  ├── NotFoundError (404)
  ├── ForbiddenError (403)
  ├── ConflictError (409)
  ├── BadRequestError (400)
  ├── GoneError (410)
  └── ServerError (500)
  • getCode() = HTTP Status Code (예: 404, 403, 409)
  • getStatus() = HttpStatus.valueOf(code)
  • getResponse() = new ErrorResponse(status, message)

Global Exception Middleware

  • 코드에서 확인되지 않음 (@ControllerAdvice 없음)
  • Controller가 직접 result.isFailure()를 체크하고 ErrorResponse로 변환
  • ResultException(runtime)은 존재하나 이를 처리하는 Global Handler 미확인

공통 ErrorResponse 구조

{
  "StatusCode": "NOT_FOUND",   // HttpStatus enum name
  "Message": "..."
}
  • StatusCode 필드: PascalCase 사용 (일관성 이슈)
  • Message 필드: 한글/영문 혼용

에러 코드 체계

  • HTTP Status를 에러 코드로 사용
  • Custom 에러 코드 체계 없음 (예: ERR-001 같은 비즈니스 에러 코드)

로그 레벨 구분

  • log.info: 정상 흐름 (깃허브 데이터, AI 요청 페이로드)
  • log.warn: OpenVidu webhook 파싱 실패
  • log.error: Redis 메시지 처리 실패, 엔티티 미발견, 작업 처리 실패

프론트엔드 일관 처리

  • 모든 실패가 ErrorResponse(statusCode, message) + HTTP Status로 통일 → 일관된 처리 가능
  • SSE Error 이벤트에도 동일한 ErrorResponse 구조 사용

9. 인프라 구성

Dockerfile 존재 여부

  • didit-server: 있음 (apps/server/Dockerfile) - Multi-stage build (Build: eclipse-temurin:21-jdk, Runtime: eclipse-temurin:21-jre)
  • didit-client: 있음 (apps/client/Dockerfile) - (내용 확인 필요)
  • AI Worker: 확인 필요 (Dockerfile 미발견)

Docker Compose 구성

로컬 개발용 (apps/server/docker-compose.yml)

services:
  db:       MySQL 8.0 (3306 노출)
  openvidu: OpenVidu dev 2.32.1 (4443 노출)
  redis:    Redis 7.2-alpine (6379 노출)
  • 백엔드 서버 자체는 로컬 IDE에서 실행
  • healthcheck: mysqladmin ping (MySQL only)

배포용 (infra/docker-compose/docker-compose.yml)

서비스 이미지 네트워크 포트 설명
db mysql:8.0 internal only expose 없음 MySQL, healthcheck 적용
server ghcr.io/pjtdidit/server-image:latest internal + caddy_default expose 8080 Spring Boot
client ghcr.io/pjtdidit/client-image:latest internal + caddy_default - React 정적 서빙
openvidu openvidu/openvidu-dev:2.32.1 internal only expose 없음 WebRTC SFU
redis redis:7.2-alpine internal only 6379 (외부 노출) Message Broker

서비스별 컨테이너 역할

  • db: 영속 데이터 저장 (MySQL 8.0, utf8mb4, Asia/Seoul timezone)
  • server: API 서버 (HTTP API, SSE, Webhook 수신)
  • client: 정적 파일 서빙 (React SPA)
  • openvidu: WebRTC 미디어 서버 (SFU, recording)
  • redis: 작업 큐, Pub/Sub, client key 저장소

내부 네트워크/외부 네트워크 구성

  • internal (bridge): db, server, client, openvidu, redis 간 통신
  • caddy_default (external): server ↔ client ↔ Reverse Proxy 통신용

Volume 마운트 구조

  • mysql-data:/var/lib/mysql (MySQL 데이터 영속화)
  • redis_data:/data (Redis AOF 데이터 영속화)

Healthcheck

  • MySQL: mysqladmin ping (10s interval, 5s timeout, 10 retries)
  • Server: depends_on db(healthy), 자체 healthcheck 없음 → 개선 여지
  • Redis: 별도 healthcheck 없음

depends_on 조건

  • server depends_on db with condition: service_healthy
  • 나머지 서비스 간 의존성 명시 안 됨

포트 노출 정책

  • 배포 환경: 모든 DB/서버 포트가 주석 처리 (외부 직접 접근 불가)
  • 단, Redis 6379가 환경변수로 외부 노출 가능 → 보안상 개선 여지 있음

인프라 구성 설계 의도

  • 단일 서버에 Docker Compose로 올인원 배포 (소규모 프로젝트에 적합)
  • internal 네트워크로 서비스 간 통신 격리
  • caddy_default external 네트워크로 Reverse Proxy와만 API 통신 허용

10. Reverse Proxy / 도메인 / HTTPS

Caddy/Nginx 사용 여부

  • Traefik 사용 (설계 문서상 명시)
  • 실제 배포 docker-compose에는 caddy_default external 네트워크로 연결 → Caddy 사용 가능성도 있음 (네트워크명이 caddy_default)

도메인 라우팅 구조

  • Frontend: https://did-it.xyz
  • API: https://api.did-it.xyz/api/v1
  • CORS 허용 Origin: https://did-it.xyz

subdomain 구성

  • did-it.xyz → 클라이언트
  • api.did-it.xyz → 백엔드 API 서버

HTTPS 적용 방식

  • Traefik/Caddy에서 HTTPS 종단 처리
  • Spring Boot 자체는 HTTP(8080), Cookie Secure=true → Reverse Proxy가 HTTPS 처리하고 있음을 전제

reverse_proxy 대상

  • / 및 정적 리소스 → didit-client
  • /api/v1/*didit-server:8080
  • /oauth2/*didit-server:8080

외부 포트 노출 최소화

  • Docker Compose에서 MySQL, OpenVidu, Server의 포트가 명시적으로 주석 처리되어 외부에서 직접 접근 불가
  • Redis 포트(6379)는 환경변수로 노출되도록 열려 있음 → 개선 필요

11. 환경변수와 설정 관리

.env 구조

  • .env.example 파일 존재 (14개 변수)
  • 주요 변수: MYSQL_*, GITHUB_*, OPENVIDU_*, REDIS_*

개발/운영 설정 분리

  • application.yml (공통)
  • application-prod.yml (운영 전용: Swagger 비활성화, HikariCP Pool 튜닝, Tomcat Thread 설정)
  • SPRING_PROFILES_ACTIVE=prod 환경변수로 활성화

DB 연결 문자열 구성 방식

url: jdbc:mysql://${MYSQL_HOST:db}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:didit}?serverTimezone=Asia/Seoul&characterEncoding=utf8
  • 환경변수 우선, 기본값 fallback (MYSQL_HOST:db)
  • MYSQL_USER, MYSQL_PASSWORD도 환경변수에서 주입

Redis 연결 설정

  • 환경변수: REDIS_HOST, REDIS_PORT, REDIS_PASSWORD
  • timeout: 2s
  • Password 기반 인증 (requirepass)

Secret 관리 방식

  • GitHub Client ID/Secret, OpenVidu Secret: 환경변수로 주입
  • .env.example은 Git에 커밋되어 있으나, 실제 값은 배포 환경에서 별도 관리
  • Vault/Secret Manager 미사용 → 개선 여지

ASP.NET Options 바인딩

  • 해당 없음 (Java / Spring Boot 프로젝트)
  • Spring Boot @ConfigurationProperties 사용 (OpenViduProperties, OpenViduWebhookProperties)

설정 검증

  • Bean Validation을 통한 설정 검증 미확인
  • @Validated + @ConfigurationProperties 조합 사용 가능하나 구현되지 않음

12. 로그 / 모니터링 / 운영

로깅 구조

  • SLF4J + Logback (Spring Boot 기본)
  • @Slf4j (Lombok)으로 Logger 주입

구조화 로그 사용 여부

  • JSON 구조화 로그: 미사용 (일반 텍스트 로그)
  • log.info("깃허브 데이터: {}", attributes)

요청/응답 로그

  • Spring MVC 요청 로깅 확인 필요 (Access Log 미구현 가능성)

예외 로그

  • log.error("Redis 메시지 처리 중 에러 발생: {}", e.getMessage())
  • log.error("워커 실행 중 오류 발생: %s", str(e), exc_info=True) (Python)

로그 레벨 기준

  • INFO: 정상 비즈니스 흐름
  • WARN: 복구 가능한 오류 (webhook 파싱 실패)
  • ERROR: 실패한 작업 처리

운영 도구 사용 여부

  • Dozzle: 미확인
  • Grafana/Loki: 미확인
  • Portainer: 배포 트리거로 사용 (PORTAINER_WEBHOOK_URL)
  • AI Worker: /health, /health/redis Healthcheck 엔드포인트 존재

Healthcheck

  • AI Worker: GET /health, GET /health/redis → FastAPI 제공
  • Server: Spring Boot Actuator Healthcheck 확인 필요 (의존성 없음)
  • DB: docker-compose healthcheck (mysqladmin ping)
  • Redis: 별도 healthcheck 없음

13. CI/CD / 배포 자동화

GitLab CI/CD 구성

.gitlab-ci.yml (Root Pipeline)

  • Trigger: Merge Request Event + Master Push
  • Stages: testbuilddeploy
  • Docker BuildKit 활성화

Server Pipeline (.gitlab/ci/server.yml)

Job Stage Trigger Actions
test_server test MR (apps/server 변경 시) ./gradlew --no-daemon test → JUnit report artifact
build_push_server build Master Push (apps/server 변경 시) Docker build + push to ghcr.io/pjtdidit/server-image:latest

Deploy Pipeline (.gitlab/deploy.yml)

Job Stage Trigger Actions
deploy_portainer deploy Master Push (변경 시) POST to Portainer Webhook → Redeploy

Docker image build

  • Multi-stage Dockerfile (BuildKit 캐시 마운트 활용)
  • Build Stage: Gradle wrapper → bootJar
  • Runtime Stage: eclipse-temurin:21-jre, non-root user (appuser)
  • JVM 옵션: -XX:MaxRAMPercentage=75

Migration 실행 방식

  • Flyway 자동 실행 (애플리케이션 시작 시)
  • CI/CD에서 별도 migration step 없음

브랜치 전략

  • master = 기본 브랜치 (production)
  • Merge Request 기반 코드 리뷰 흐름
  • Revert 이력 존재 (revert-b0a13fa0)

배포 실패 시 대응 방식

  • Portainer Webhook 호출 실패 시 set -eu로 Pipeline 중단
  • Rollback 전략 확인 필요

향후 개선할 배포 구조

  • AI Worker의 CI/CD 미구성 (.gitlab/ci/ai.yml 주석 처리)
  • Client의 CI/CD 존재 (.gitlab/ci/client.yml)
  • Test 단계에서 Testcontainers 사용 → 실제 DB 의존성 없는 테스트 가능

14. 성능 최적화

Pagination 적용 여부

  • 적용: PageRequest.of(page, 10) (프로젝트 목록)
  • Spring Pageable: size 기본값 20

Projection DTO 조회 여부

  • 미적용: Entity 전체 조회 후 stream().map(Response::fromEntity)로 변환
  • SELECT * 형태로 모든 컬럼을 읽어옴 → 개선 여지

AsNoTracking 사용 여부

  • JPA @Transactional(readOnly = true) → Hibernate가 flush 모드를 MANUAL로 설정하나 영속성 컨텍스트는 사용
  • AsNoTracking에 해당하는 명시적 설정 없음

Include/N+1 문제 처리

  • FetchType.LAZY 사용 (기본 전략)
  • findTop5ByProject_IdAndStatusOrderByUpdatedAtDesc: ProjectEntity, AuthorEntity lazy → 응답 변환 시 N+1 가능성 있음
  • findAllByProject_Id: Assignee 컬렉션 @OneToMany → 응답 변환 시 N+1 가능성 있음
  • @EntityGraph 또는 JOIN FETCH 미사용개선 여지

Index 적용 여부

  • 외래키 컬럼 전반에 인덱스 적용: idx_projects_repo_id, idx_issues_project_id, idx_meetings_project_id, idx_chats_project_created_at
  • 만료 시간 조회: idx_project_invites_expires_at
  • 복합 인덱스: (project_id, created_at), (meeting_id, created_at)

Cache 적용 여부

  • 명시적 캐시: 미적용 (Spring Cache, @Cacheable 없음)
  • Redis는 작업 큐/PubSub 용도로만 사용

응답 시간 측정 또는 개선 수치

  • 확인 필요 (APM 도구 없음)

15. 설계 문서화

README 구조

  • readme.md: 저장소 구조, 디렉토리 가이드
  • exec/readme.md: 실행 관련 (빌드/설정/시나리오)

API 문서

  • apps/server/docs/API.md: 상세 API 문서 (엔드포인트, DTO, SSE 이벤트)
  • apps/server/docs/openapi.yaml: OpenAPI 3.0.3 명세
  • docs/API/: Swagger/Redoc 관련 파일

ERD

  • docs/기획/ERD/: Logical ERD Model (Mermaid, SVG)
  • docs/설계/Physical ERD Model.md: Mermaid ERD 다이어그램 + 테이블 상세 명세

아키텍처 다이어그램

  • docs/설계/Architecture.md: 기술 스택 구성도 (SVG)
  • docs/설계/svg/architecture.svg: 전체 구성도
  • docs/설계/svg/PERD.svg: 물리 ERD

시퀀스 다이어그램

  • 확인 필요 (별도 시퀀스 다이어그램 미발견)
  • SSE 이벤트 흐름: docs/컨벤션/svg/sse_arch.svg, sse_flow.svg

기술 선택 이유 문서 / ADR

  • 미발견 (ADR 디렉토리 없음)

Mermaid 다이어그램 사용 여부

  • 사용: Physical ERD Model, SSE 문서 등에서 Mermaid 사용

16. 문제 해결 사례 후보

사례 1: AI 작업 결과의 실시간 클라이언트 전달

문제 상황: AI Worker가 이슈 우선순위 분석 완료 후, 특정 사용자에게만 결과를 전달해야 함 (다중 사용자 환경)

원인 분석: SSE broadcasting만으로는 모든 구독자에게 전파되므로, 특정 사용자에게만 선택적 전달이 필요

해결 방법:

  • Redis에 sse:client_key:{userId} 형태로 clientKey 저장
  • AI 결과 수신 시 RedisSubscribeListener가 Redis에서 clientKey 조회
  • SseHub.broadcastToClient(projectId, clientKey, "ai_analysis_result", ...)사용자 타겟팅

선택 이유: 동일 유저의 다중 탭/디바이스를 구분하면서도, Redis를 활용해 무상태(stateless) 서버 간 확장 가능

결과: AI 분석 결과가 요청한 사용자에게만 실시간 전달, 불필요한 브로드캐스트 방지


사례 2: LEFT 상태 사용자 재참여 처리

문제 상황: 탈퇴했던 사용자가 초대 링크로 재참여 시, UK 제약 위반 없이 기존 레코드를 재활용해야 함

원인 분석: project_usersUK(project_id, user_id)가 있어 새 레코드 INSERT 불가

해결 방법: AddProjectUser 메서드에서:

  1. 기존 레코드 존재 확인 (findByProject_IdAndUser_Id)
  2. ACTIVE 상태면 ConflictError, LEFT 상태면 상태를 ACTIVE로 복원 + role=MEMBER + leftAt=null
  3. 레코드가 없으면 신규 생성

선택 이유: DB 레벨 UK 제약을 유지하면서도, Soft한 상태 변경으로 사용자 이력을 보존 (감사 추적 가능)

결과: 탈퇴했던 사용자도 초대 코드로 원활히 재참여 가능, 중복 참여 방지


사례 3: AI 분석 워크플로우의 비동기 처리

문제 상황: HuggingFace 모델 추론이 수 초~수십 초 소요되어, REST API 요청-응답 사이클 안에서 처리 불가

원인 분석: ML 추론은 HTTP 요청 타임아웃을 초과하는 장기 작업

해결 방법:

  • Redis List를 작업 큐로 사용 (queue:issue:priority:single, queue:issue:priority:sort)
  • 서버는 leftPush로 작업 등록 후 즉시 응답
  • Python AI Worker가 0.5s polling으로 큐 소비
  • 완료 시 Redis Pub/Sub으로 결과 발행 → 서버의 RedisMessageListener가 수신 → DB 업데이트 + SSE 전송

선택 이유: 추가 인프라(RabbitMQ/Kafka) 없이 기존 Redis로 큐/메시징 통합 구현, 운영 단순화

결과: ML 추론 시간과 API 응답성이 완전히 분리됨


사례 4: GitHub 이슈와 로컬 DB의 양방향 동기화

문제 상황: GitHub에서 이슈를 가져올 때, 기존 로컬 DB에 있는 이슈는 업데이트하고 없는 이슈는 새로 생성해야 함

원인 분석: GitHub이 Source of Truth지만, AI 우선순위 등 로컬 메타데이터를 추가로 관리해야 함

해결 방법: getGithubIssues() 메서드에서:

  1. GitHub API로 레포지토리 이슈 일괄 조회
  2. existsByGithubIssueId(ghIssue.getId()) → 존재하면 title/body/status 업데이트
  3. 미존재 시 신규 Entity 생성 (priority 기본값 MEDIUM)
  4. Assignee 정보 GitHub→로컬 UserEntity mapping
  5. 모든 이슈에 대해 AI 단일 분석 + 배치 정렬 큐 추가

선택 이유: Upsert 패턴으로 데이터 중복 없이 동기화, github_issue_id를 natural key로 사용

결과: GitHub과 로컬 DB 간 일관성 유지, 신규 이슈 자동 등록, AI 분석 파이프라인 연계


17. 포트폴리오 문장 초안

프로젝트 개요

"Didit은 GitHub 생태계에 특화된 실시간 팀 협업 플랫폼입니다. GitHub OAuth2 로그인, OpenVidu 기반 화상회의, Redis Pub/Sub 기반 AI 비동기 분석, SSE 실시간 이벤트 스트리밍을 하나의 Spring Boot 서버로 통합한 B2B SaaS 프로젝트입니다."

담당 역할

"백엔드 개발자로서 Spring Boot 4.0 서버의 전체 API 설계 및 구현, 인증/인가 체계 수립, 데이터베이스 스키마 설계(Flyway), Docker Compose 기반 인프라 구성, GitLab CI/CD 파이프라인 구축을 담당했습니다."

주요 기여

  • 30개 이상의 RESTful API 엔드포인트 설계 및 구현
  • GitHub OAuth2 + Session Cookie 인증 체계 구축
  • Redis 기반 AI 작업 큐 + Pub/Sub 메시징 아키텍처 설계
  • SSE(Server-Sent Events) 채널/프로젝트 단위 실시간 이벤트 시스템 구현
  • Flyway + JPA validate 기반 데이터베이스 마이그레이션 관리

사용 기술 및 선택 이유

기술 선택 이유
Java 21 + Spring Boot 4.0 최신 LTS 버전, Virtual Threads 등 최신 기능 활용, 강력한 생태계
MySQL 8.0 FK 제약, UK 제약으로 데이터 정합성 보장, utf8mb4 지원
Redis 7.2 빠른 메모리 기반 작업 큐, Pub/Sub 메시징, 경량 인프라
Flyway 선언적 DB 마이그레이션, 모든 변경 이력 추적, Clean 비활성화로 안전
GitHub OAuth2 개발자 타겟, 별도 회원가입 불필요, repo/org 권한 자연스러운 연계
SSE + Redis Pub/Sub WebSocket보다 가벼운 실시간 통신, Redis로 확장 가능한 이벤트 브로커 역할
Docker Compose 5개 서비스의 로컬/배포 환경 일관성 확보, 단일 서버 올인원 배포
Portainer + GitLab CI/CD Docker 이미지 자동 빌드 → Webhook 기반 배포 트리거, 운영 단순화

아키텍처 설계

"전통적인 3계층(api/service/data) 구조를 채택하여 HTTP/비즈니스/데이터 책임을 명확히 분리했습니다. AI 분석 작업은 별도 Python Worker로 분리하고 Redis를 중간 메시지 브로커로 사용하여, ML 추론 부하가 API 응답성에 영향을 주지 않도록 설계했습니다. 모든 서비스는 Docker Compose로 오케스트레이션되며, internal 네트워크로 보안 격리하고 Traefik Reverse Proxy를 통해 HTTPS를 종단 처리합니다."

API 설계

"RESTful 원칙에 따라 /api/v1/ 기반의 URL 버저닝을 적용하고, Spring Pageable과 커서 기반의 이중 페이징 전략을 사용했습니다. Java의 Result 패턴을 도입하여 성공/실패를 값으로 반환하며, NotFound/Forbidden/Conflict/Gone/ServerError 등 의미 있는 에러 타입을 체계화했습니다. OpenAPI 3.0.3 명세를 Swagger UI와 연동하여 API 문서화를 자동화했습니다."

인프라 구성

"MySQL 8.0(utf8mb4), Redis 7.2(AOF), OpenVidu 2.32.1, Spring Boot Server, React Client 등 5개 서비스를 Docker Compose로 구성했습니다. internal bridge 네트워크로 내부 서비스를 격리하고, MySQL healthcheck에 대한 depends_on 조건을 설정하여 서비스 시작 순서를 보장했습니다. Multi-stage Dockerfile로 이미지 크기를 최적화하고, BuildKit 캐시 마운트로 빌드 시간을 단축했습니다."

문제 해결 사례

  1. AI 결과의 사용자 타겟팅 전달: Redis에 clientKey를 저장하고, SSE Hub에서 대상 사용자에게만 broadcastToClient() 하는 방식으로 해결했습니다.
  2. 탈퇴 사용자 재참여 처리: UK 제약을 유지하면서 LEFT → ACTIVE 상태 변경으로 재참여를 처리하여 데이터 일관성을 보장했습니다.
  3. ML 추론-API 응답 분리: Redis List/ Pub/Sub을 활용한 비동기 작업 큐 패턴으로, 장시간 ML 추론이 API 응답에 영향을 주지 않도록 설계했습니다.
  4. GitHub-로컬DB 이슈 동기화: github_issue_id를 natural key로 Upsert 패턴을 구현하여 GitHub↔로컬 데이터 일관성을 유지했습니다.

프로젝트 성과

  • 단일 서버에서 화상회의 + 채팅 + GitHub 이슈 관리 + AI 분석 통합 제공
  • Flyway로 12회 DB 마이그레이션을 무중단 적용
  • Docker Compose 기반 원커맨드 배포 체계 구축 (CI/CD + Portainer)
  • Spring Boot, Python, MySQL, Redis, OpenVidu 등 5+ 기술 스택 통합 경험

회고

"이번 프로젝트를 통해 단일 백엔드 서버가 API, SSE, Webhook 수신, 외부 API 연동 등 다양한 역할을 수행할 때의 설계적 고려사항을 깊이 있게 경험했습니다. 특히 Redis를 단순 캐시가 아닌 작업 큐+Pub/Sub 메시지 브로커로 활용한 점이 인상적이었습니다. 다만, Global Exception Handler나 선언적 인가(@PreAuthorize), API 응답 시간 모니터링 등 프로덕션 레벨에서 필요한 부가적인 부분들을 더 보강하고 싶습니다."


부록: 본 문서는 apps/server/, apps/ai/, infra/, .gitlab/, docs/ 디렉토리의 실제 코드 및 설정 파일을 분석하여 2026-05-05 기준으로 작성되었습니다.