# CodeAssist 백엔드를 OpenCode + Django 로 — 분석과 인프라 제안 작성 2026-09-16. 코드 변경 전 분석 단계. 결정 5개 확정됨(6번). 다음은 `/speckit-specify` 로 `specs/006-*` 파고 여기 내용은 research 로 승계. ## 0. 한 줄 결론 **프론트 계약은 그대로 두고, CodeAssist 전용 Django 를 새로 만든다.** ABAP_OPENCODE `web/BE` 에서 인증·OpenCode 클라이언트·배포 뼈대를 **복사**해 오고, 그 위에 어댑터(envelope·세션 미러·스트림 변환)를 얹는다. Django 가 OpenCode 서버(CodeAssist 전용 workspace)를 호출하고, OpenCode 이벤트를 프론트가 이미 아는 `POST /chat/stream` SSE(token/title/usage/done/error)로 바꿔준다. 프론트 변경은 env 한 줄 + 토큰 저장 두 줄. ## 1. 지금 상태 — 프론트가 백엔드에 기대하는 것 출처: `2_frontend/src/lib/api/client.ts`, `lib/streaming/streamLLM.ts`, `features/snap/api/*`, `features/auth/api/auth.api.ts`, `types/api.ts`, `specs/005-*/contracts/chat-stream.md`, `docs/superpowers/specs/2026-07-18-snap-backend-connect-design.md`. 현재 백엔드는 별도 repo 의 FastAPI(base-backend, `localhost:8001`). 이 머신엔 없음. | 영역 | 프론트가 기대하는 것 | |---|---| | 베이스 | `VITE_API_BASE_URL` (기본 `/api/v1`, 풀 URL 도 됨). dev 는 Vite proxy 로 same-origin | | 응답 모양 | 전부 envelope `CommonResponse{success,statusCode,code,message,data,counts,errors,timestamp,meta}`. 헬퍼가 `.data` 언랩, `apiList` 는 `meta{currentPage,pageSize,totalItems,totalPages,hasNextPage,hasPreviousPage}` 로 페이지네이션. `success:false` 면 `ApiError` | | 인증 | httpOnly 쿠키 + 401 → `POST /auth/refresh` 자동 재시도. **Bearer seam 이미 있음** — `lib/auth/tokenProvider.ts` 에 토큰 넣으면 axios·SSE 둘 다 `Authorization: Bearer` 로 감 | | 인증 API | `POST /auth/login{email,password}` → `TokenResponse{token,refreshToken,…,user}` · `POST /auth/refresh` · `POST /auth/logout` · `GET /users/me` · Entra `GET /auth/entra/config` (**501 이면 버튼 숨김** — 다른 코드는 에러로 뜸) | | 세션 | `GET /chat/sessions?page&limit` · `POST /chat/sessions` · `GET /chat/sessions/search?query&page&limit` (메시지 본문 ILIKE) · `GET /chat/sessions/{id}/messages` (`isGenerating` true 면 1.5초 폴링) · `POST /chat/sessions/{id}/cancel` | | DTO | `SnapSession{id,title,titleLlm,isGenerating,createdAt,updatedAt}` · `SnapMessage{sessionId,role,content,createdAt,inputTokens?,outputTokens?,costUsd?,elapsedMs?}` | | 스트림 | `POST /chat/stream` body `{sessionId,content,images?[{mediaType,data(dataURL)}],forcedSkill?,explain?}`. 요청 하나 = SSE 하나. `token{delta}` → `usage{used,limit,ratio,elapsed_ms}` → `done{traceId?}`, 중간 `title{title}`, 실패 `error{message,code}` | | 스니펫 | **백엔드 안 씀.** Tauri 브릿지 → 로컬 SQLite (결정 5: 유지) | **패키징 gotcha:** Tauri 빌드는 dist 를 `tauri://` 계열 origin 으로 띄움. `/api/v1` 상대경로는 dev(Vite proxy)에서만 되고 배포본은 **풀 URL** 이어야 함. cross-origin 이면 쿠키는 `SameSite=None; Secure` = https 필수인데 고객사 bare 배포가 http → **Bearer 로 간다** (결정 2). ## 2. ABAP_OPENCODE `web/BE` 에서 복사해 올 것 출처: `web/BE/code/README.md`, `common/opencode_service.py`, `apps/chat/sse.py`, `apps/accounts/*`, `deploy/*`. | 가져올 것 | 어디서 | 손볼 것 | |---|---|---| | Django+DRF+uvicorn(ASGI) 뼈대, settings 의 DB 스위치(SQLite↔PG), CORS | `config/`, `requirements.txt` | 앱 이름·포트만 | | JWT 인증 (simplejwt, Bearer 또는 `?token=`) | `apps/accounts/` | refresh 토큰 발급 추가, 응답을 `TokenResponse` 모양으로, 에러를 envelope 로 | | OpenCode HTTP 클라 + `/event` SSE 파서(60초 무활동 재연결) | `common/opencode_service.py` | 그대로. base URL 만 env | | 세션 owner 매핑 아이디어 | `apps/ownership/` | 미러 테이블(5.3)로 흡수, 파일 소유권·워처는 안 가져옴 | | Dockerfile(be·opencode), compose, bare `run.sh` | `deploy/` | 서비스 3개(be·opencode-ca·mcp)로 줄임 | | FabriX 게이트웨이 `apps_ito/aaf` (OpenAI 호환 중계, 401 재시도) | `apps_ito/` | **1차 안 가져옴.** 개발은 OpenRouter 직결. 고객사 갈 때 통째 복사(설계상 한 줄 include 로 붙게 돼 있음) | | `sap-icf` MCP 서버 | `web/MCP/code` | 복사 안 하고 **같은 VM 이면 기존 :3200 공유**, 아니면 그때 복사 | 안 가져오는 것: `apps/files`(산출물), `apps/usage`, `be-watcher`, ABAP 스킬 4종, `AGENTS.md`(FS 브레인스토밍 규칙 — CodeAssist 랑 정반대). ## 3. 왜 이 안이냐 (대안 비교) | | A. 새 Django + 어댑터 (확정) | B. 프론트를 ABAP_OPENCODE 계약으로 | C. OpenCode 없이 OpenAI 호환 중계만 | |---|---|---|---| | 프론트 변경 | env + 토큰 2줄 | envelope·인증·스트림 모델 대수술 | 스트림 어댑터 새로 | | OpenCode 장점(MCP·세션 컨텍스트·에이전트) | 씀 | 씀 | 못 씀 — 요청 취지 밖 | | 위험 | 이벤트 어댑터 품질, 복사본 관리 | 회귀 범위 큼(테스트 268개) | 단순하지만 요청과 다름 | 프론트는 "seam 만 바꾸면 되게" 짜여 있고(tokenProvider, streamLLM), Django 쪽 부품은 복사하면 되니 어댑터만 새로 짜면 됨. ## 4. 인프라 그림 (확정 반영) ``` [Windows PC] [서버 VM] ← CodeAssist 스택 (ABAP_OPENCODE 와 별개 프로세스) CodeAssist.exe (Tauri) ┌─ nginx(:80) 또는 Django 직접(bare) ├─ WebView2: React dist │ /api/v1/* → codeassist-be │ VITE_API_BASE_URL=http(s)://<서버>/api/v1 │ │ Authorization: Bearer ───────────┼──▶ codeassist-be Django(uvicorn ASGI, :8001) │ (토큰: localStorage — 결정 2) │ ├─ accounts JWT 발급·refresh (web/BE 복사) │ │ ├─ chat 세션·검색·스트림 어댑터 + 미러 DB │ │ └─ common/opencode_service.py (복사) └─ Rust: 핫키·캡처·스니펫 SQLite(로컬) │ ├─ opencode-ca(:4096) OpenCode 전용 인스턴스 (결정 4) │ /workspace/AGENTS.md 짧은 코드 답변 규칙 │ /workspace/opencode.json 모델(dev OpenRouter / 고객사 FabriX), MCP sap-icf │ /workspace/.opencode/agent/codeassist.md ├─ mcp sap-icf(:3200) 같은 VM 이면 ABAP_OPENCODE 것 공유 └─ db SQLite 파일(개발) / PostgreSQL(고객사) — DB_HOST 로 스위치 ``` 포트는 base-backend 가 쓰던 `8001` 그대로 → 프론트 `.env` 의 `VITE_DEV_API_TARGET` 도 안 바꿈. ABAP_OPENCODE 스택(8100/4096/3200)과 같은 VM 에 둘 땐 OpenCode 포트만 4097 로. **Django 코드 위치 (결정 1 = 새로):** 이 repo 안 `5_django_backend/` 로 제안. 이유: 프론트·Tauri·docs-lib·spec 이 다 여기라 계약 맞추기 쉽고, `docs-lib/` 패턴도 그대로. 별도 repo 원하면 폴더째 떼면 됨 — 지금 결정 안 해도 됨. ## 5. 어댑터 — 뭘 만드나 ### 5.1 응답 모양 - DRF Renderer + exception handler 로 전 응답 `CommonResponse` envelope. 목록은 `meta` 채움. - 에러 코드 프론트가 아는 것 유지: `CHAT_GENERATION_IN_PROGRESS`(409), `LLM_ERROR`, `REFRESH_TOKEN_MISSING`(401). ### 5.2 인증 (결정 2·3) - `POST /auth/login` → simplejwt access+refresh 발급, 응답 `TokenResponse{token,refreshToken,tokenExpirationTime,…,user}`. - `POST /auth/refresh` body `{refreshToken}` (쿠키 아님). `POST /auth/logout` 은 refresh 블랙리스트. `GET /users/me`. - `GET /auth/entra/config` → **501 고정** (결정 3: Entra 안 함. 프론트가 버튼 숨김). `entra/login` 라우트 자체 없음. - **프론트 변경 (유일):** 로그인 응답 `token`/`refreshToken` 을 `setAccessToken()` + localStorage 에. 401 인터셉터 refresh 를 Bearer 모드에서도 돌게(body 에 refreshToken). 지금 `// TODO(host)` 자리. ### 5.3 세션·메시지 — 미러 테이블 OpenCode 는 페이지네이션·본문 검색·`isGenerating`·메시지별 usage 를 안 줌 → Django 에 미러: - `chat_session(id=opencode session id, user, title, title_llm, is_generating, created, updated)` - `chat_message(session, role, content, created, input_tokens, output_tokens, cost_usd, elapsed_ms)` - 스트림 시작: user 메시지 insert + `is_generating=true`. `session.idle`: OpenCode `GET /session/{id}/message` 로 마지막 assistant 본문·`tokens`/`cost` 가져와 insert, `is_generating=false`. - 목록 = 미러 `-updated` + page/limit. 검색 = 미러 `icontains`. 대화 컨텍스트 원본은 계속 OpenCode. ### 5.4 스트림 어댑터 `POST /chat/stream` 순수 Django async 뷰(ASGI): 1. owner 확인, `is_generating` 이면 409. 2. 프로세스당 하나인 OpenCode `/event` 구독 태스크(복사한 `iter_event_jsons`)에서 세션별 `asyncio.Queue` 로 fan-out. 3. 메시지 전송은 블록 안 되게 태스크로(`sync_to_async(send_message)` 또는 OpenCode 비동기 프롬프트 엔드포인트 — **docs-lib 로 버전 확인**). `agent: "codeassist"`. 4. 큐: `message.part.delta`(text) → `event: token`, `session.updated` title → `event: title`, `session.idle` → `usage` → `done`. 에러 → `event: error`. 5. 이미지: OpenCode file 파트(mime + data URL). **파트 스키마 docs-lib 확인 후** 구현. 6. cancel → OpenCode `abort` + `is_generating=false`. 7. 프론트가 스트림 잃어도 `isGenerating` 폴링이 미러를 보니 답변 복구 — 기존 동작 그대로. ### 5.5 OpenCode workspace `5_django_backend/opencode/` - `AGENTS.md`: 한국어, ABAP/SAP 코드 즉답, 코드펜스 언어 태그, 절차 질문 금지, `explain` 이면 배경 설명. - `opencode.json`: dev = OpenRouter, 고객사 = FabriX(게이트웨이 경유, 2차). `tools.question=false`. MCP `sap-icf`. - `.opencode/agent/codeassist.md`: 시스템 프롬프트. `forcedSkill` 1차 무시. ### 5.6 배포 - compose: `be`·`opencode-ca`·(옵션)`mcp` 3개. 고객사 bare 는 `run.sh` 복사해 프로세스 2개. - 프론트 `.env.production`: `VITE_API_BASE_URL=http(s)://<서버>/api/v1`. 서버 `CORS_ORIGINS` 에 Tauri origin(`tauri://localhost`, `http://tauri.localhost`) 추가, `CORS_ALLOW_HEADERS` 에 Authorization. ## 6. 결정 (2026-09-16 확정) | # | 항목 | 결정 | 영향 | |---|---|---|---| | 1 | Django 위치 | **새로 만듦** (ABAP_OPENCODE 에 안 붙임) | accounts·opencode_service·deploy 복사. 게이트웨이(FabriX)는 고객사 갈 때 복사 | | 2 | 토큰 저장 | **localStorage** (테스트 단계) | 프론트 2줄. 고객사 배포 전 Tauri 저장소로 옮길지 그때 결정 | | 3 | Entra | **안 함** | `entra/config` 501 고정, 다른 Entra 코드 안 짬 | | 4 | OpenCode | **분리** 인스턴스 | 전용 workspace·AGENTS.md·포트 | | 5 | 스니펫 | **로컬 SQLite 유지** | 백엔드 범위 밖 | ## 7. 단계 (spec 파면 이 순서로 tasks) | 단계 | 내용 | 검증 | |---|---|---| | 0 | `5_django_backend/docs-lib/` 에 OpenCode 서버 API(세션·메시지 parts·event), DRF, simplejwt 문서 떨어뜨림 | 카탈로그 README | | 1 | 뼈대 복사 + envelope renderer + accounts(login/refresh/logout/me) + entra 501 | 프론트 로그인 통과 | | 2 | 미러 테이블 + sessions list/create/messages/search | 목록·검색 화면 | | 3 | opencode-ca workspace + compose + `OPENCODE_BASE_URL` | curl 로 세션 생성·프롬프트 | | 4 | 스트림 어댑터 + cancel + isGenerating | Snap 스트리밍·중단·재진입 복구 | | 5 | 이미지 파트 + `explain` | 캡처→질문 | | 6 | 프론트 토큰 2줄 + `.env.production` + CORS | Tauri 빌드 end-to-end |