# 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-server`는 `internal` 네트워크를 통해 `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` 반환 - 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)** ```java // 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`** 클래스 사용 (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 구조 ```json { "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`) ```yaml 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 연결 문자열 구성 방식 ```yaml 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: `test` → `build` → `deploy` - 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_users`에 `UK(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 기준으로 작성되었습니다.