Application Architecture — HeyMoa
갱신일 2026-08-10 · 기준: 세 저장소 워킹트리 +
docs/develop/MVP1/architecture/application-architecture.md이 문서가 갖는 것: 세 서비스의 내부 구성, 의존 방향 규칙, 그 규칙을 강제하는 장치, 그리고 UI 동작 ↔ API ↔ 저장소의 대응. 여기 없는 것: 화면 트리(information-architecture.md) · 경계별 프로토콜(system-architecture.md) · 인프라(cloud-architecture.md).
1. UI ↔ API ↔ 저장소 대응
대응 관계는 표가 그림보다 정확하다. 한 장에 그리면 노드 60개짜리 그물이 되고, 어느 UI 가 어느 모듈을 부르는지는 오히려 안 보인다. 그림은 의존 방향만 맡는다.
1.1 무엇이 무엇을 부르나
| 화면 영역 | 하는 일 | server 모듈 | 저장소 |
|---|---|---|---|
| 인증 | Google 로그인 · 토큰 갱신 · 로그아웃 | auth · user | User · Session |
| 워크스페이스 셸 | 워크스페이스·프로젝트 CRUD · 노트 목록 · 알림 | workspace · project · notification | Workspace · Member · Invitation |
| 노트 | 조회 · 참여자 편집 · 삭제 | note | Note · Participant |
| 노트 — 요약 | 요약 조회 · 항목 확정/기각 · 근거 점프 | analysis | AnalysisJob · MeetingItem · Evidence |
| 녹음 | 마이크 캡처(PCM16 24kHz) · 세션 시작/중지 · 회의 종료 | transcription (STOMP) · note | TranscriptSegment |
| 챗봇 | 공유·개인 챗 전송 · 스트림 수신 · 도구 승인 | agentchat (SSE) | AgentChat · Message · ToolApproval |
| 설정 | 멤버 초대·역할·추방 | workspace | Member · Invitation |
| 설정 — 연동 | Linear·GitHub 연결·해제 | integration | Integration |
agentcontext 는 표에 없다 — 브라우저가 부르지 않는 유일한 모듈이고, ai 만 호출한다.
1.2 의존 방향 — 화살표는 한쪽으로만
web 은 ai 를 모른다. 생성된 API 클라이언트에 /internal/** 이 아예 없고, ai 에는 CORS 미들웨어도 인증도 없다 — 브라우저가 도달할 수단 자체가 없다.
1.3 저장소는 어느 DB 에 사는가
heymoa — 원본. 지우면 복구할 수 없다.
| 저장소 | 성격 |
|---|---|
| User · Session | 인증 |
| Workspace · Member · Invitation | 조직 |
| Note · Participant | 회의 |
| TranscriptSegment | 전사 |
| AnalysisJob · MeetingItem · Evidence | 분석 결과 |
| AgentChat · Message · ToolApproval | 대화 |
| Integration | 외부 연동 토큰(암호화) |
heymoa_ai — 파생. 통째로 날려도 원본에서 다시 만들어진다.
| 저장소 | 성격 |
|---|---|
| AnalysisResult Cache | 멱등성 캐시 |
| TranscriptEmbedding · pgvector | 검색 인덱스 |
| LangGraph Checkpoint | 그래프 상태 |
파생 데이터를 원본과 같은 database 에 두지 않는 이유: 지워도 되는 것을 구분할 수 없어진다. heymoa_ai 는 통째로 날려도 재생성되지만 heymoa 는 아니다.
2. 세 서비스가 같은 방향을 쓴다
안쪽은 바깥쪽을 모른다. 프레임워크는 항상 바깥에 있다.
| 서비스 | 나누는 단위 | 규칙 | 강제 장치 |
|---|---|---|---|
| heymoa-ai | 레이어 4 | domain ← application ← infrastructure ← presentation. domain·application은 fastapi·langgraph·sqlalchemy·httpx를 import할 수 없다 | ✅ import-linter 계약 6개 — CI가 lint-imports로 검사 |
| heymoa-server | 도메인 모듈 12 | 모듈마다 presentation · application · domain · infra. Service → Handler → Repository 단방향, Service 간 직접 호출 금지 | ⚠️ AGENTS.md + 리뷰. ArchUnit/Konsist는 도입하지 않기로 결정 |
| heymoa-web | 층 6 | route는 prefetch만, feature는 제품 의미를 알고, transport는 연결만 안다 | ⚠️ AGENTS.md + pnpm orval 재생성 + hook. 생성물은 손으로 고치지 않는다 |
ai만 기계가 막고, 나머지 둘은 문서와 리뷰다. 이것이 이 저장소들의 가장 큰 비대칭이다.
3. heymoa-ai — 레이어 4
src/heymoa/
domain/ entities · value_objects ← 프레임워크 금지
application/ services · policies · interfaces · dto
infrastructure/ agent(orchestrator·branches·stream·analysis·context·providers)
tools · external · llm · persistence · observability · rendering · config · server
presentation/ api/v1 · schemas ← infrastructure를 import할 수 없다
main ← 조립은 여기서만
import-linter가 실제로 강제하는 것
| 계약 | 무엇을 막나 |
|---|---|
heymoa의 의존은 안쪽으로만 | main → presentation → infrastructure → application → domain 역류 |
웹 표면은 조립을 모른다 | presentation이 infrastructure를 직접 import — 라우트가 Container를 받기 시작하는 것 |
그래프 → 도구 → 외부 호출 한 방향 | agent ↔ tools 순환 (실제로 발생했고 리뷰에서야 발견됨 — APP-298) |
외부 호출 계층에 프레임워크 금지 | external이 langchain·langgraph·fastapi를 아는 것 |
계측 계층에 그래프 프레임워크 금지 | 관측이 그래프를 물어 모델 계층 교체 시 계측이 따라 바뀌는 것 |
안쪽 레이어에 프레임워크 금지 | domain·application의 fastapi·langgraph 의존 |
승인 게이트는 실행 노드에 둔다. 모델 바인딩 단계에 걸면 모델이 도구 이름을 지어내 부르는 경로로 우회된다. 바인딩과 실행이 같은 집합을 보게 유지한다.
4. heymoa-server — 도메인 모듈 12
| 모듈 | 책임 |
|---|---|
auth | Google OAuth2 로그인, access 30분 JWT + refresh 30일 불투명 토큰, 회전(유예 60초) |
user | 내 정보 조회 |
workspace | 워크스페이스 CRUD, 멤버·역할, 초대 생성·수락·거절 |
project | 워크스페이스 하위 프로젝트 CRUD |
note | 노트 CRUD, 참여자, 회의 상태·시간 계산 |
transcription | 전사 세션 수명주기, STOMP 오디오 중계, 세그먼트 영속화 |
analysis | 잡 생성 → ai 디스패치 → callback 영속화 → 워치독. 비동기 경계 전부를 혼자 진다 |
agentchat | 채팅·메시지 영속화, ai SSE passthrough와 tee, 도구 승인 상태 기계 |
agentcontext | ai 전용 내부 조회 API. 다른 모듈이 이 경로를 늘리지 않는다 |
integration | Linear·GitHub OAuth 토큰 암호화 보관. 단기 토큰만 ai로 나간다 |
notification | 인앱 알림 조회·읽음 |
common | 공통 응답 봉투, Security, 시간·영속·HTTP 지원 |
com/heymoa/<도메인>/
presentation/api/ Controller
presentation/api/dto/ 요청 · 응답 DTO
presentation/ws/ WebSocket Controller · 인터셉터
application/ Service(유스케이스 하나) · Handler(공유 조율) · Dao(조회)
application/dto/ Command · Result · Projection
domain/ 엔티티 · 도메인 규칙
infra/ Properties · Config · 스케줄러
infra/persistence/ Repository · Dao 구현
infra/<외부명>/ 외부 연동
세부 규칙
- 서비스 클래스 하나 = 유스케이스 하나. 진입 메서드는
operator fun invoke UseCase인터페이스를 두지 않는다 (구현 하나뿐인 인터페이스는 보일러플레이트)- 화면 조회는 Querydsl
QueryDao로 분리.Projections.constructor(...)금지 →@QueryProjection - 엔티티는 private 주 생성자 + 정적 팩토리, public setter 금지
- 엔티티 20개에
@OneToMany연관 매핑이 0개 — 전부 raw id + QueryDSL projection. 3초 폴링 화면이라 1+N을 구조적으로 막는다 - 스키마는 Flyway 단방향 (현재
V1~V24),ddl-auto: validate
백그라운드 스케줄러 2개 — AnalysisWatchdogScheduler(1분 주기 / 10분 타임아웃), ToolApprovalWatchdogScheduler(1분 주기). 둘 다 플래그로 끌 수 있다.
5. heymoa-web — 층 6
| 층 | 위치 | 하는 일 |
|---|---|---|
| route | app/ | Server Component. params 해석 · 서버 prefetch · HydrationBoundary만 |
| 전역 provider | app/providers.tsx | MockProvider → QueryClientProvider → RecordingProvider → AuthProvider 순서 고정 |
| feature UI | components/{workspace,notes,transcription,chat,settings,notification,auth}/ | Client Component. 제품 의미를 안다 |
| primitive | components/ui/ · components/heymoa/ | shadcn/ui 기반. 제품 의미를 모른다 |
| 생성 API | lib/api/generated/ | orval 산출물 — 태그별 훅 · 모델 · MSW · faker |
| transport | lib/api/fetcher.ts · lib/api/sse.ts · lib/transcription/socket.ts | REST mutator · SSE-over-POST · STOMP. 연결과 401 재발급만 알고 이벤트 의미는 모른다 |
| protocol | lib/transcription/protocol.ts · lib/chat/stream-protocol.ts | AsyncAPI 계약의 zod discriminated union. 파싱 실패는 에러 |
| 순수 로직 | lib/transcription/transcript-reducer.ts · lib/notes/meeting-state.ts | 이벤트 → 화면 상태 투영, 회의 상태 판정 |
| 목 | lib/mocks/ | MSW REST · WebSocket · SSE + 상태 저장 목 DB |
상태 경계 — 서버 상태는 TanStack Query 단독 소유. 전역 client 상태는 인증(AuthProvider)과 활성 녹음(RecordingProvider) 둘뿐. 20Hz 오디오 레벨은 RecordingMeterContext로 분리해 전사 화면 리렌더를 막는다. 폴링은 refetchInterval + enabled 게이팅만.
Next.js 16 특이사항 — 미들웨어는 proxy.ts다. middleware.ts를 만들면 404 루프가 된다(hook이 막는다). proxy.ts가 SSR 전에 refresh 토큰을 회전하고 Set-Cookie를 재기록한다. Route Handler·Server Action은 0개.
계약에서 생성한 것을 진실로 삼는다. 수기 구현은 생성이 불가능한 3개 operation뿐:
| operation | 왜 못 쓰나 | 대신 |
|---|---|---|
POST /v1/agent-chats/{chatId}/messages | 응답이 text/event-stream | 수기 SSE 리더 |
POST /v1/notes/{noteId}/chat/messages | 위와 같음 | 수기 SSE 리더 |
GET /v1/workspaces/{id}/integrations/{provider}/authorize | 302 리다이렉트 | 브라우저 최상위 이동 |
6. 확장점 — 늘릴 때 고칠 곳 하나
| 늘리고 싶은 것 | 고칠 곳 |
|---|---|
| ai 채팅 도구 | TOOLS 목록 한 줄 — kind가 승인 게이트를, credential이 바인딩 필터를 결정 |
| ai agent 종류 | PROFILES 딕셔너리 한 줄. 그래프를 늘리지 않는다 |
| ai 실행 수단 · 저장소 교체 | 조립 지점 하나 (DI) |
| server 도메인 | 모듈 폴더 하나 (4계층을 그 안에) |
| web API 훅 | 고치지 않는다. 계약을 고치고 pnpm orval로 재생성 |
7. 계약 동기화 사슬
heymoa-server 코드
→ ./gradlew clean openapi3 (restdocs-api-spec · 빌드 생성물)
→ docs/develop/MVP2/contracts/openapi3-server.yml
→ heymoa-web/openapi3.yml (/internal/** 3경로를 손으로 제거한 미러)
→ pnpm orval → lib/api/generated/**
⚠️ clean을 빼면 삭제된 경로가 계약에 되살아난다 — APP-214에서 실제로 meeting-pause·meeting-resume가 부활했다.
⚠️ /internal 제거가 수작업이다. 경로가 늘면 누락될 수 있고, web의 고정 테스트는 사후 탐지이지 예방이 아니다.
| 계약 파일 | 원본 소유 | 검증 |
|---|---|---|
openapi3-server.yml (39경로 · 51 op) | heymoa-server (빌드 생성물) | ✅ 생성 자체가 검증 + ExposedEnumContractTest |
asyncapi-web-server.yml (8채널) | 전사·노트토픽 = server 미러 / 채팅 SSE = 이 파일이 원본 | ✅ 전사·토픽만 (*WebSocketDocsTest) / ⚠️ SSE는 문법 검증뿐 |
asyncapi-server-ai.yml | heymoa-ai (계획 문서, 이벤트는 $ref) | ⚠️ 수동 |
openapi3-ai.yml | heymoa-ai (수기 원본) | ⚠️ 수동 |
web은 어떤 계약도 원본으로 소유하지 않는다. 전부 소비자다.
8. 설계 전제 — 뒤집힐 조건과 함께
| 전제 | 뒤집히는 조건 |
|---|---|
| 프레임워크를 바깥에 둔다. LangGraph와 FastAPI는 둘 다 infrastructure이고 port 뒤에 숨는다 | 이 레포에서 가장 값이 나가는 제약이라 CI가 검사한다. 걷어내려면 계약부터 지운다 |
| 승인 게이트는 실행 노드에 둔다 | 모델 바인딩과 실행이 같은 집합을 보지 못하게 되는 변경이 들어올 때 |
| server는 Service 간 호출을 막는다 — 의존 그래프가 모듈 사이로 번지는 것을 막으려고 | 모듈 수가 늘어 Handler 조율이 병목이 될 때 |
| web은 계약 생성물을 진실로 삼는다 | 생성 불가 operation이 3개를 넘어설 때 |
| 크기 기준은 검사가 아니다 (함수 30줄 · 파일 250줄 · 폴더 8파일) | pytest로 강제했다가 "판단이 사라진다"는 이유로 삭제했다. 다시 기계로 막으려면 그 이유부터 반박한다 |
9. 미확인
| 무엇 | 지금 아는 것 |
|---|---|
| CI가 실제로 돌고 있는가 | heymoa-ai만 ✅. heymoa-server는 Actions 과금 차단으로 중단(로컬 게이트가 유일), heymoa-web은 워크플로 자체가 없다 |
| 커버리지 하한 | 없다. 숫자로 막지 않기로 결정 |
asyncapi 검증 | 세 곳 다 수동 (npx @asyncapi/cli validate) |
| 채팅 SSE 구간의 회귀 방어 | passthrough라 server에 DTO가 없어 생성·검증 모두 불가능. 세 서비스 중 누가 이 구간을 막을지 정해져 있지 않다 |
| ai 수직 슬라이스 전환 (APP-263) | analysis · chat · platform을 최상위로 올리는 계획이 harness rule에만 있다 |
| heymoa-web 위치 이동 (APP-374) | apps/web으로 옮겨 Tauri 데스크톱 자리를 만드는 계획. Todo — Vercel Root Directory도 같이 바뀐다 |
| 채팅 프롬프트 길이 상한 | ai가 ToolMessage를 자르지 않아 대화가 길어지면 프롬프트가 계속 커진다 |