지금 현 회사에서는 SLLM 을 사용 한 챗봇을 만들고 있다. llm 과 sllm 이 무엇인지 프론트도 알아야 한다는 생각이 들었다.

협업을 진행 시 어떤식으로 더 나은 방향으로 이끌어 갈 수 있는지 고민을 하려면 우선 LLM 에 대한 기본 지식이 바탕이 되어야한다고 생각을 했다. 아래는 내가 현 회사에서 어떤식으로 흘러가는 지 이해하고 다방면으로 어떤식으로 해결을 해나가면 되는지 

고민하는 과정을 적어봤다. 

 

 

아래는 백엔드 측에서 플로우를 어떤식으로 진행을 하는지 정리한 내용이다.

( 데이터를 추출(Extract) 하고, 변환(Transform) 한 다음, 적재(Load) 하는 데이터 처리 파이프라인 정리)

 

문서 수집

백엔드/데이터 파이프라인내부 문서, PDF, 데이터베이스 등 도메인 특화 지식을 수집하고 정리.

ETL/스크래핑

문서 분할 (Chunking)

문서분할(Chunking)은 요즘 검색, RAG, 인덱싱, 임베딩 같은 시스템에서 성능을 결정짓는 핵심 단계 중 하나

문서분할은 긴 문서(텍스트, PDF 등)를 의미 있는 단위로 잘게 나누는 과정이다.

위에서 나눈 단위를 Chunk(청크) 라고 함

 

문서를 나누는 이유는

  • 임베딩(Embedding) 모델의 입력 토큰 제한에 걸림 (예: 4096 tokens 제한)
  • 검색 정확도가 떨어짐 (검색어와 문서 전체의 관련성을 계산하기 어렵기 때문)
  • 메모리 사용량이 커짐

문서를 작은 의미 단위(Chunk) 로 쪼개서 각각을 인덱싱하고 검색할 때 사용

 

벡터화(Embedding)

텍스트(문장, 단어 등)를 고차원 벡터(숫자 배열) 로 변환하는 과정

임베딩 모델 (e.g., BERT, BGE, Text Embeddings API)

 

"고양이는 귀엽다" → [0.12, -0.33, 0.88, ...]
"강아지는 사랑스럽다" → [0.14, -0.30, 0.85, ...]


그리고 이 벡터들을 비교하면,
두 벡터의 거리(distance) 가 가깝다 → 의미가 비슷하다
멀다 → 의미가 다르다
의미 기반 검색, RAG, 추천 시스템 등이 가능하다.

 

마무리로는 벡터 DB 에 저장을 한다.

 

현회사 플로우

파일 업로드 (프론트엔드 / 업로드 서버)

  • 사용자가 파일(예: PDF, CSV, 이미지 등)을 업로드
  • 서버에서 파일을 스토리지(S3, GCS, NAS 등) 에 저장
  • 업로드 완료 후, 파일 메타데이터 (파일명, 경로, 상태 등) 를 DB에 기록

ETL 중 Extract(추출) 단계의 준비 과정 -> 데이터가 들어오는 지점을 만드는 단계

 

인덱싱 처리 (Python 서비스)

  • Python이 업로드된 파일을 감시(watch)하거나, 업로드 완료 이벤트를 받아서 실행
  • 파일 내용을 읽고(예: 텍스트 추출, OCR, 파싱 등)
  • 내용 기반으로 인덱스 생성 (예: Elasticsearch, OpenSearch, Vector DB 등)
  • 인덱스 정보를 검색 서버나 DB에 저장

Transform + Load 단계에 해당 -> 데이터를 가공하고 저장소에 적재하는 과정

 

순서 요약

  • 사용자가 파일 업로드 (Extract)
  • Python 인덱싱 서버가 파일을 읽고 텍스트 추출 (Transform 시작)
  • Chunking으로 문서를 의미 단위로 나눔
  • 각 Chunk를 Embedding 모델로 숫자 벡터로 변환
  • 이 벡터를 벡터 DB (예: Pinecone, FAISS, Qdrant, Elasticsearch Vector) 에 저장 (Load)

 

사용처

모델 주요 사용처
LLM 광범위하고 복잡한 작업: 일반 목적의 챗봇, 창의적인 콘텐츠 생성 (소설, 시), 복잡한 코드 생성/디버깅, 다국어 번역, 고급 추론 및 분석.
SLLM 특정 도메인 및 경량 작업: 온디바이스 비서 (e.g., 스마트폰), 특정 분야 고객 지원 챗봇, 실시간 음성 인식/요약, 내부 문서 검색(RAG와 결합), 간단한 텍스트 분류/요약.

LLM/SLLM의 주요 기법

기법 설명 주요 활용 목적
프롬프트 엔지니어링 (Prompt Engineering) 모델에 최적의 질문/지시를 설계하여 원하는 결과 유도 (Fine-tuning보다 쉽고 빠름). 기본 모델의 성능 향상, 특정 형식 출력 유도.
검색 증강 생성 (RAG: Retrieval-Augmented Generation) 외부 지식 기반(DB, 문서)에서 관련 정보를 검색하고, 이를 프롬프트에 넣어 모델이 정확한 답변을 생성하도록 지원. 환각(Hallucination) 감소, 최신 정보/도메인 특화 지식 활용.
CoT (Chain-of-Thought) 모델이 최종 답변 전에 단계별 추론 과정(사고 과정)을 생성하도록 유도하는 프롬프트 기법. 복잡한 추론 문제 (수학, 논리) 해결 능력 향상.
지식 증류 (Knowledge Distillation) 대형 모델(Teacher)의 지식을 경량 모델(Student)에게 전이시켜, 소형 모델이 대형 모델에 가까운 성능을 내도록 학습시키는 기법. SLLM 제작 및 모델 경량화.
양자화 (Quantization) 모델의 가중치(Weights)를 32bit에서 8bit, 4bit 등 더 낮은 정밀도로 변환하여 모델 크기와 메모리 사용량을 줄이는 기법. SLLM 배포 시 추론 속도 및 효율 극대화 (CPU, 엣지 장치).

 

프론트엔드 개발자가 알아야 할 중요한 포인트!! 이게 핵심!!

적을 알아야 이길 수 있다. ! 항상 하는일만 하는 프론트엔드 개발자가 아니라 뒷단이 어떤식으로 돌아가고 어떤 제약이 있는지 미리 파악을 해서 문제해결을 해나가야한다고 생각한다. 가장 중요한 부분은

 

프론트엔드 개발자가 LLM/SLLM 기반 서비스를 구축할 때 고려해야 할 핵심 사항은 성능, 비용, 사용자 경험(UX)이다.

 

기존 HTTP 로 응답값을 받으면 한 번의 받는 방식이라 실시간 스트리밍으로 이벤트 기반으로 받는 방법을 사용한다 이 이유는

응답 지연 (Latency) 및 스트리밍 처리를 위해서 이고 API 호출 제한을 둬서 LLM 서비스는 호출당 비용이 발생하므로 프론트엔드에서 무분별한 API 호출을 방지하고 요청 수 제한(Rate Limiting)을 두는 것이 중요하다. 

 

SLLM의 온디바이스 배포

경량 모델 활용: SLLM을 사용하는 경우 WebAssembly (Wasm) 또는 WebGPU와 같은 기술을 활용하여 모델의 추론을 사용자 브라우저에서 직접 실행할 수 있다. WebGPU 는 2023년에 나온 브라우저에서 gpu 를 사용해서 그래픽 연산이 가능하다고 한다. 해당 부분을 활용하면.. 공공기관 같은 폐쇄형 프로젝트에 엄청난 속도 개선이 가능하지 않을까ㅣ? 라고 생각한다. 여러가지 가능성을 염두에 두고 고민을 해봐야하는 부분 같아 보인다.

프론트엔드 개발자의 RAG 최적화 포인트

전처리 단계 최적화를 진행 한다. RAG 파이프라인의 핵심인 청크 생성은 백엔드에서 이루어지지만, 프론트엔드에서 업로드되는 파일의 품질을 관리함으로써 백엔드 청크 처리의 효율을 높일 수 있다.

 

( 대용량 파일 제한, 필요 없는 정보 제거 안내)  따로 좀 더 생각 하면서 고민 한 부분이지만 

 

텍스트 추출 및 검토 UI 제공하여 청크의 품질을 높이는 건 어떤가 좀 고민을 해봤다 아래와 같은내용으로 진행을 한다면

좀 더 빠르고 정확한 품질 좋은 답변이 오지 않을까 생각해봤다.

 

사용자가 업로드한 PDF/DOCX 파일에서 텍스트만 미리 추출하여 프론트엔드에 보여주고, 사용자가 직접 RAG에 포함할 필요가 없는 텍스트(예: 목차, 법적 고지 등)를 제거할 수 있는 전처리 UI를 제공할 수 있습니다. 이를 통해 백엔드는 이미 정제된 텍스트만 받게 된다. 위화 같이 한다면 가장 골치덩어리였던... sllm 의 정확하지 못한 부분의 답변을 좀 더 품질을 높일 수 있지 않을까 생각했다.

 

위와 같이 sllm 과 llm 어떤식으로 바라보면 프론트가 정말 할일이 없어 보이지만 사실상 프론트에서도 많은 일을 개선시킬 수 있는 중요한 역할을 한다고 생각을 한다.

젠킨스로 수동 빌드를 해야하는 일이 발생하였다. 

기존 회사의 데브옵스 팀에서 제공하는 프로그램으로 자동 빌드를 하는 시스템이 사용 할 수 없는 일이 발생 하였다.

해당 부분으로 인해 개발팀에서 데브옵스를 담당하는 직원과 함께 젠킨스 배포과정에서 생겨나는 에러를 해결해야하는 일이 발생하였다.

 

현 회사에서는 vue3 node 20 버전과 node18 node12 버전의 msa 구조로 나눈 프론트 프로젝트들이 있다.

기존의 node12 에서 20으로 리팩토링한 버전 외 vue2 레거시 코드로 돌아가는 프로젝트가 아직 존재하였다.

 

기존 node 12 버전 담당하는 개발자가 해결을 하지 못해서 나한테 까지 전달이 되어서 왔다.

 

아래는 에러 캡쳐였다.

 

 

위 문제는 webpack 3 + Babel 6 예전의.. 내가 겪었던 구버전에서 생겼던 이슈가 생각났다.

계속 팀원들에게도.. vue2 사용을 오래하면 문제가 생긴다 리팩토링해야한다고 강조를 했지만 ㅠ 다들 모르쇠하고 있는 분위기여서 

넘어가다보니 생겨난 문제였다.

 

가장 큰 문제점은 

번들러 쪽에서 파싱을 하다가 뻗어버렸다.

webpack 3 + Babel 6  구버전이기에 

에러 포인트: async-generator-function/index.mjs(ESM) 내부의 ES2018 문법 async function 파싱 실패가 떴다.

최신 문법을 이해를 못해서 모듈 파싱 에러가 나면서 번들러(webpack)가 로더 체인으로 파일을 넘기기 전에 또는 넘겼어도 적절히 트랜스파일되지 않아 구문을 못 읽어서 죽은 상황 이었다.

 

챗지피티나 커서에서는 해당 에러를 다르게 해석을 했는지 팀원이 4시간동안 고치지 못하고 있어서 내가 직접 분석 파악하면서 

구글링해서 찾아냈다. 

 

  • webpack 3은 .mjs/ESM 지원이 미흡.
  • Babel 6은 기본 preset만으로 async generator를 커버 못 함(플러그인 필요).

위에 문제점을 해결 하기위해서

아래와 같이 해결 점을 찾아서 진행 하였다.

 

먼저 웹팩 베이스 설정 파일에서

1) webpack 설정(webpack 3 기준)

mjs 에대한 부분을 추가해주고

2) Babel 6 플러그인 추가 (async generator 지원)

npm i -D babel-plugin-transform-async-generator-functions babel-plugin-transform-regenerator
npm i -S babel-runtime@^6.26.0

 

3) 클린 설치 & 빌드

rm -rf node_modules package-lock.json
npm i
npm run build
 
위와같이 해결하여서 정상적으로 로컬에서 빌드가 되는 부분을 확인 할 수 있었다.
 

 

로컬에서 빌드 확인 후 데브옵스 측에다가 전달 후 확인결과 아주 잘 돌아간다.

 

 

ㅎㅎ 데브옵스 개발자에게서 칭찬 까지 받았다 ㅋㅋ

 

뿌듯한 일 마무리였다 ㅎㅎ

 

RAG (파인튜닝)을 사용하여 커스텀 마이징된 부분으로 솔루션 개발을 해야하는 부분이었다.( 문서 생성 _ 템플릿 형태에 맞게 llm 이 만들어주는 부분)

내가 한 일은 프론트에서 백엔드 측으로 안정화 된 컨텐트를 넘기거나 파일 아이디 및 질의어를 던져

백엔드가 청크를 절약하여 상품의 품질을 끌어올리는 작업을 진행했다.

 

백엔드 파이프라인 설명을 간단히 하면

업로드 -> 파일매니저 저장 -> 인덱싱 서버가 가져가서 파싱 임베딩 업서트 RAG 에서 리트리브 + 리랭크 ( 파이썬) -> llm 답변-> 프론트 표시 ( 파일은 올릴 수 있거나 아닐 수 있다.)

위에 과정을 걸치면서 속도가 굉장히 느리거나 답변에 품질이 떨어지는 경우가 있어서 프론트도 전처리 과정에서 도움이 될만한 부분을 찾아서 진행을 했다. 파일을 올리는 과정에서도 추가적으로 제안을 해 MD 파일로 올리는게 어떻냐고 백엔드측에 협업을 요청하여

마크다운 형태가 좀 더 llm 이 제대로 알아 볼 수 있는 부분같아서 파일을 서버의 업로드 후 md 파일에 내용을 받을 수 있도록 한 후 
사용자가 저장을 클릭하거나 업로드 시 md 파일로 전환하여 서버에 저장을 시킨 후 RAG API 를 호출하였다.

 

md 파일을 제대로 알아보는 부분이 크다고 파이썬 백엔드쪽 답변이 왔을 때 ㅎㅎ 제대로 자료를 찾아본 것 같아서 기분이 좋았다.

 

가장 예를 먹은 부분은 아래와 같은 비즈니스 로직을 수정하는 부분이었다.

프런트(에디터)에서 수정→다시 보내기 플로우

해당 부분은 전에 대화를 멀티턴 작업을 하기전에 다시 보내서 RAG가 작업을 좀 더 잘 할 수 있게 보안이 되고자

다시 경량적으로 전처리를  해서 보내는게 필요한 작업이었다.

 

에디터 내용을 사용자가 수정 후

경량 전처리(프런트)

  • 규칙 기반 필터링(정규식/불용어/중복)
  • 코드·표·로그 head/tail 축약 (중간 ...omitted로 명시)
  • 마크다운/유니코드 sanitize (펜스/개행/제로위드 문자 보정)
  • 문단으로 청크 분할 + mini TF/IDF 프리필터(리랭크 없이 상위 N만 추림) 프론트에서는 리랭크를 할 수 없기에 다른 방법으로 경량시킴

처리를 진행 하였고 구글링 및 AI 도움을 받아 최대한 프론트에서 작은 전처리를 진행하도록 했다.

위 작업을 진행 후 RAG 를 호출 하여 헤더/요약 우선 + 선별한 문단만 백엔드에 전달 하여 토큰/ 지연/ 비용 절감을 할 수 있었다.

 

응답이 올경우 간혹가다가 llm 이 좋은 모델을 쓰는게 아니라면 마크다운 규칙위반과 포맷이 깨짐이 생기면 후처리를 진행하였다.

 

축약하면 안 되는 경우

  • 핵심 정보가 들어있는 본문은 최대한 보존 시키고
  • 숫자 표 로그 등은 최대한 중간 줄 생략을 하지 않게 만들고

 

축약해도 되는 경우

  • 중복문장, 불필요한 인사말/ 푸터 -> 의미 없는 부분등 백엔드와 규칙을 세워서 없애거나
  • 이미 업로드 되어있는 부분과 비교하여 이미 샘플을 백엔드가 가지고 있을 경우 생략 됨으로 표시를 넣어서 백엔드에게 알려줌
  • 형식만 있는 구간 긴 구분선, 공백라인 ㅡ 템플릿 문구등은 제거 함

프론트에서 일단 가볍게 재정리를 진행 한다 중복제거 와 노이즈제거 그리고 긴 문장을 다시 보내지 않고 사용자가 수정한 부분은 체크해서 다시 넣은 후 백엔드에게 관련 있는 문단만 우선 추려서 넘긴 후 백엔드 리랭커가 최종적으로 중요한 청크를 고르게 정확도를 확보 할 수 있도록 지원을 해준다. 프론트에서는 너무 과도한 축약을 하지 않고 토큰 절약 + 맥락 보존 균형을 유지하게 해서 협업을 진행하여

RAG 쪽에서 품질을 높일 수 있도록 도구 역할을 추가해주는 느낌으로 작업을 해서 품질에 대한 부분을 높이려고 노력했다.

 

해당 부분으로 백단에서도 많은 작업을 해주지만 프론트에서 미리 알 수 있는 정보  예: 사용자가 수정을 한 부분

등을 llm 모델이 성능이 많이 떨어지는 모델이어서 도움을 줄 수 있도록 작업을 처리해서 완료했다.

 

오늘 주제는 chat Ai 파인튜닝하는 중견회사로 이직해서 프론트개발자로서 작업 한 내용을 올려보고자 한다.

 

이번 프로젝트는 중견기업의 Chat AI 작업이었는데, 내가 맡은 부분은 프론트엔드.
AI 모델 성능은 백엔드/데이터팀에서 다루고 있었고, 나는 이걸 어떻게 사용자 입장에서 편하고 자연스럽게 쓸 수 있게 만들까? 에 집중했다.

 

일단 실시간 스트리밍 작업이 꽤 많았고 파일 업로드 진행 하는 부분들도 꽤나 많고 단순해보였지만 정말 많은 기능들이 들어가 있었다.

처음 접해보는 도메인 지식에 꽤나 당황했고 낮설게 다가왔다.

 

사용자 페이지와 관리자 페이지가 따로 분리되어 있었으며 시나리오 마다 가지고 있는 옵션 값들도 차이가 있었고 

그 내용은 서로 얽혀있었고.. 정말 많은 정보들이 있어서 꽤 파악하는데 진을 정말 많이 뺐다.

 

 문제점

초기 프론트 구조는 SI 스타일 그대로라서 이런 문제들이 있었디.

  1. AI 응답이 한 번에만 떨어져서 긴 답변 대기 시간이 너무 김
  2. 로딩 UI는 그냥 스피너만 돌아가서 UX 별로
  3. Vue2 Options API 기반이라 코드 구조 뒤엉킴 → API, UI, 로직이 한데 섞임
  4. 팀원 간 API는 어디 넣냐 같은 불필요한 논쟁이 생기고
  5. es6, es7 최신 문법을 쓰지 않아서 몇천줄이 넘는 코드 로직..(명령형으로 태스크 고려하지 않고 짠 코드...) 비동기 동기를 제대로 파악하지 않고 쓴 코드..
  6. 프론트에서 너무 많은 잔버그로 인해서 퀄리티가 낮아지는 문제점
  7. 큰 플로우가 정리 되어있지 않고 흩어져있어서 어떤식으로 나아가야할지 문제가 있었다.

그리고... 응답이 느릴 경우 태스크를 신경 쓰지 않고 마구 잡이로 진행한 덕에

느려진 UI 정말 힘들었다..어디서부터 어떻게 끌고 갈지 고민하고 고민한 끝에

UI/UX 기획적으로 플어나 갈 수 있는 방안을 먼저 고려해서 작업을 실행 하기로 했다.

 

코드의 답이 있겠냐만 최소한의 규칙과 낮은 결합도와 높은 응집도를 생각해서 SOILD 원칙에 기반하여 작성한다면..

요새 챗 AI 를 시켜서 일 을 할 때 최고의 효율성으로 일하고 빠른 유지보수 및 기능 개발이 가능하다고 생각한다.

 

구조가 있다면 AI 는 더 쉽게 접근하여 파일 단위로 파악을 진행하여 더 정확한 답변을 주는 부분을 알게됐다.

 

어쨋건 해당 부분처럼 따로 분리하여 작업을 진행 하면서 다른 팀원과 타부서 몇백명 이상과.. 협업을 진행 하다보니 

Saas 설치형으로 나아가다 보면 본질을 자꾸 흐리게 되고 빠르게만 코드의 퀄리티를 떨어트리는 부분이 분명이 크다는 점을 느꼈다.

 

SI 업체 출신 개발자들을 싫어하는건 아니지만.. 너무 체계가 없고 무조건 빠르게빠르게를 외치시니 작업 하는 부분이 꽤 많이 힘들었다..

오늘도 다시 되 돌아 보게된다. 잘 하는 개발자는 빠른속도만 있는게 아니라 체계적이고 레거시 코드가 되어도 구조와 왜 이렇게 구현을 했는지 파악이 가능한 개발자라는걸 AI 발전속도에 우리의 직업이 위협을 받는게 아니라 생존해나가면서 전체 플로우를 판단하고 구조적인 부분을 체계적으로 이끌어나가고 기획적인 부분과 더불어 사용자 편의성을 고려하는 프론트엔드 개발자는 살아 남을 수 있을거라고 판단을 한다.

 

아래는 .. 기존 레거시 코드(규칙없이 믹스인으로 만들어둔 코드) -> send 의 너무 높은 결합도를 지니고 있어서 어디서 사용 하는지 모르는부분도 있었고 응집도는 낮아서 어디서 버그가 나는지 알 수 없는 형태였다. 아래의 형태를 차근차근 리팩토링 하는 과정을 작성 해보겠다.

 methods: {
    async onGoBottom(behavior) {
      let scrollContainer = document.querySelector(".body-wrapper")
      if(!scrollContainer){return;}
      await this.wait(500);
      scrollContainer.scrollTo({ 
        top: scrollContainer.scrollHeight,
        behavior: behavior
      })
    },
    async send(q, linkedIntent, startQuery, calendarParameters, accordionParameters, req_ai_response, sbData, msgObj, isInit) {
      clearTimeout(this.checkSocketInterval)
      if(this.classMode){
        return this.classSendMessage(q, linkedIntent)
      }

      if(this.liveChat){
        let that = this;
        if( q == that.$t('components.chatting.builde.chattinginner.endconversationwithagent') || q == this.$t('components.chatting.builder.chattinginner.consultationtermination') || q == that.$t('components.chatting.builde.chattinginner.endconversation')){
          return this.endConv()
        }
        let query = q;
        let components = [];
        if(this.agentChat){
          query = '';
          components = [{"name":"SIMPLE_RESPONSE","content":{"displayText":"","ssml":"","textToSpeech": q }}];
        }
        this.sendMessage({

 

시작하기 전 미리 머리에 그려놓고 플로우 다이어그램을 먼저 작성 후 문제점을 파악해서 UI UX 적으로 풀어나간다.

일단 가장 큰 부분인 DocumentAnsertWrapper.vue 에 작성을 한다.

<template>
  <div class="document-answer-wrapper">
    <div class="document-answer-box">
      <div class="anwser-wrapper">
        <div class="doc-answer-content">
          <div class="doc-answer-select">
            <button class="btn" @click.stop="toggleDropItem($event, 'template')">
              {{ currentTemplate.title }}
              <BaseIcon name="ArrowFullDown" :size="16" />
            </button>

            <DropDown
              :top="chatDropPosition.y"
              :left="chatDropPosition.x"
              :isOpenDrop="isTemplateDrop"
              @close="isTemplateDrop = false"
              dropdownName="template-menu-wrapper"
            >
              <template #content>
                <div class="conversation-menu-wrapper">
                  <ul>
                    <li v-for="template in renderList" :key="template.id">
                      <button class="nav-btn" ref="answerTitle" @click.stop="selectedTemplate(template, true)">
                        <BaseIcon name="SummaryDocType" :size="18" :mr="8" />
                        {{ template.title }}
                      </button>
                    </li>
                  </ul>
                </div>
              </template>
            </DropDown>
          </div>

          <!-- 버블 넣는 곳 -->
          <ChatBubbleList
            :chatList="chatList"
            :renderMarkdownStream="renderMarkdownStream"
            :formatDate="formatDate"
            @mounted="onBubbleMounted"
            @open-editor="handleOpenEditor"
          />

          <FileList
            v-if="fileList.length"
            :fileList="fileList"
            @deleteFile="deleteFile"
            class="mt8"
          />

          <user-prompt-gallery
            :show="showGallery"
            :chatbotCode="session.chatbotCode"
            @update:show="showGallery = $event"
            @select="applyPrompt"
          />

          <!-- 파일 업로드 필수 + 업로드 완료 후 RAG 호출 -->
          <QueryInputer
            ref="queryInputer"
            id="answerQueryInputer"
            class="doc-answer-inputer"
            :placeholder="$t('answerwrapper.answerwrapper_key4')"
            :sessionId="session.session_id"
            :fileList="fileList"
            :maxFiles="1"
            :docsBtnFilesZeroDisabled="fileList.length === 0"
            :abortSignal="uploadAbortCtrl.signal"
            :isQueryLoading="isChatLoading"
            :isInteractionHidden="true"
            :noShadow="true"
            @openGallery="openGallery"
            @submit="suggestSendChat"
            @fileIds="handleFileIds"
            @fileUpload="handleFileUpload"
            @fileError="onFileUploadError"
            @clearFiles="clearAllFiles"
            @fileProcessed="onFileProcessed"
          />
        </div>
      </div>

      <DocsEditor
        :key="editorKey"
        :isEditor="isEditor"
        :editorTitle="editorTitle"
        :contentEditor="contentEditor"
        :quillFormats="quillFormats"
        :showEditorOverlay="showEditorOverlay"
        :isDocListDrop="isDocListDrop"
        :chatDropPosition="chatDropPosition"
        :docList="docList"
        @update:isDocListDrop="val => (isDocListDrop = val)"
        @toggle-related="toggleRelatedDocumentList"
        @select-doc="selectedTemplate"
        @editor-ready="onEditorReady"
        @save-txt="saveTxt"
        @toggleDropItem="toggleDropItem"
      />
    </div>

    <div class="related-document-list-wing" v-if="isRelatedDocumentListOpen">
      <div class="related-document-list-wrapper">
        <div class="related-document-list-header">
          <div class="related-document-list-header-title">
            <BaseIcon name="file" :size="18" />
            참고자료
          </div>
          <button class="btn" @click="toggleRelatedDocumentList">
            <BaseIcon name="Close" :size="18" />
          </button>
        </div>
        <div class="related-document-list conversation-menu-wrapper">
          <ul>
            <li v-for="doc in relatedDocumentList" :key="doc.id">
              <button class="nav-btn">
                <BaseIcon name="SummaryDocType" :size="20" :mr="8" />
                <div class="doc-title">
                  {{ doc.title }}
                  <div class="doc-desc">{{ doc.desc }}</div>
                </div>
              </button>
            </li>
          </ul>
        </div>
      </div>
    </div>

  </div>
</template>

<script setup>
import { reactive, ref, computed, onMounted, defineEmits, watch, nextTick, onBeforeUnmount } from 'vue';
import { onBeforeRouteLeave, useRoute } from 'vue-router';
import { v1 as uuidv1 } from 'uuid';
import { useStore } from 'vuex';
import { useToast } from 'vue-toastification';

import BaseIcon from '@/component/BaseIcon.vue';
import QueryInputer from '@/component/QueryInputer.vue';
import DropDown from '@/component/DropDown.vue';
import FileList from '@/views/fileManage/component/FileList.vue';
import UserPromptGallery from '@/domains/prompt/presentation/UserPromptGallery.vue';
import { usePromptClipboard } from '@/composables/usePromptClipboard';

import { useI18n } from 'vue-i18n';
import { useChatView } from '@/domains/chat/application/useChatView';

import { doSseChatCall } from '@/domains/docsChat/application/useChatStream';
import { stripExt, getFileIconName, formatDate } from '@/composables/chatUtils';
// import { doRagReportGet } from '@/domains/docsChat/application/useReportFetch';
import { getDomainInfo, getQueryList } from '@/domains/chat/infrastructure/chatApi';
import { useReportTemplates } from '@/domains/report/application/useReportTemplates';
import * as chatApi from '@/domains/chat/infrastructure/chatApi';
import ChatBubbleList from '@/domains/docsChat/presentation/ChatBubbleList.vue';
import DocsEditor from '@/domains/docsChat/presentation/DocsEditor.vue';
import { useFileAttachments } from '@/composables/useFileAttachments';
import { useHtmlToText } from '@/composables/useHtmlToText';
import { renderMdToHtml } from '@/composables/markdown';
import { uploadDocTxt } from '@/domains/docsChat/infrastructure/docsApi';
// import { useDownloader } from '@/composables/useDownloader';

const marked = require('marked');

/* =========================
 * Reactive state / store
 * ========================= */
const route = useRoute();
const store = useStore();
const toast = useToast();
const { t } = useI18n();
const emit = defineEmits(['answerEnd']);
const session = computed(() => store.getters.session || {});
const isFileLoading = computed(() => store.getters.getIsFileLoading);
const isDocsType = computed(() => store.getters.getDocsType);
const { load, templates } = useReportTemplates();
const { onGoBottomRef } = useChatView();

/* =========================
 * 파일 txt 관련 함수, html 변환
 * ========================= */
const {
  fileList,
  uploadedFileIds: serverFileIds,
  handleFileUpload,
  handleFileIds,
  onFileUploadError,
  clearAllFiles,
  onFileProcessed,
  deleteFile,
} = useFileAttachments();

const { htmlToText } = useHtmlToText({
  linkStyle: 'inline',      // "텍스트 (URL)"
  imageStyle: 'alt',        // "[IMG: alt]"
  tableStyle: 'tsv',        // 탭 구분 표
  headingUppercase: false,
});
// const { downloadTextFile } = useDownloader();

const agentCode = getDomainInfo().chatbotCode || '';
const alive = ref(true);

/* =========================
 * state
 * ========================= */
const queryInputer = ref(null);
const chatDropPosition = reactive({ x: 0, y: 0 });
const isTemplateDrop = ref(false);
const isRelatedDocumentListOpen = ref(false);
const isDocListDrop = ref(false);

const contentEditor = ref('');
const editorTitle = ref('문서명');
const txtFile = ref('');
const isEditor = ref(true);
const isEditorLoading = ref(false);
let editorLoadingShownAt = 0;
let hideOverlayTimer = null;

const showEditorOverlay = computed(() =>
  isEditor.value &&
  (isEditorLoading.value || isChatLoading.value) &&
  !isBootstrapping.value,
);

const localQuery = ref('');
const pendingQuery = ref('');
const showGallery = ref(false);
const chatList = ref([]);
const docHistory = ref([]);
const chatScrollRef = ref(null);
const localSessionId = ref('');
const isChatLoading = ref(false);
const keepFilesAfterSend = ref(false);
const isBootstrapping = ref(false);
const hasConsumedBridge = ref(false);
const editorKey = ref(0);

let chatAbortCtrl = null; // SSE 취소용
const uploadAbortCtrl = new AbortController(); // 업로드 취소용 (미사용이어도 unmount에서 중단)

/* =========================
 * 프롬프트용
 * ========================= */

const { applyPrompt: applyPromptCore } = usePromptClipboard({
  t,
  queryInputerRef: queryInputer, // QueryInputer의 ref
});

// 갤러리에서 텍스트 선택 시: 입력창에 넣고, 복사까지 하고, 갤러리 닫기
const applyPrompt = (text) => {
  applyPromptCore(text, {
    close: () => {
      showGallery.value = false;
    },
  });
};


/* =========================
 * 에디터용
 * ========================= */
const quillFormats = [
  'header', 'bold', 'italic', 'underline', 'strike', 'blockquote',
  'list', 'bullet', 'indent', 'link', 'code-block', 'image',
];

/* =========================
 * 배열
 * ========================= */
let currentTemplate = reactive({
  id: 'suggestion',
  title: '제안서',
  desc: '비즈니스 제안 내용을 효과적으로 전달하는 문서',
});

const relatedDocumentList = ref([
  { id: 'doc1', title: '문서1', desc: '문서1 내용을 효과적으로 전달하는 문서' },
  { id: 'doc2', title: '문서2', desc: '문서2 내용을 효과적으로 전달하는 문서' },
]);

const docList = ref([
  { id: 'doc1', title: '문서1', desc: '문서1 내용을 효과적으로 전달하는 문서' },
  { id: 'doc2', title: '문서2', desc: '문서2 내용을 효과적으로 전달하는 문서' },
]);

const renderList = computed(() => (templates.value || []).map(t => ({ ...t })));
const quillRef = ref(null)

/* =========================
 * UI 헬퍼 함수들
 * ========================= */
function showEditorLoading() {
  if (hideOverlayTimer !== null) {
    clearTimeout(hideOverlayTimer);
    hideOverlayTimer = null;
  }
  isEditorLoading.value = true;
  editorLoadingShownAt = Date.now();
}

function hideEditorLoadingWithMin(ms = 500) {
  const elapsed = Date.now() - editorLoadingShownAt;
  const remain = Math.max(ms - elapsed, 0);
  if (hideOverlayTimer !== null) {
    clearTimeout(hideOverlayTimer);
    hideOverlayTimer = null;
  }
  hideOverlayTimer = window.setTimeout(() => {
    isEditorLoading.value = false;
    hideOverlayTimer = null;
  }, remain);
}

function openGallery() {
  showGallery.value = true;
}

function toggleRelatedDocumentList() {
  isRelatedDocumentListOpen.value = !isRelatedDocumentListOpen.value;
}

function toggleDropItem(event, type) {
  if (type === 'template') {
    isDocListDrop.value = false;
    isTemplateDrop.value = !isTemplateDrop.value;
  } else if (type === 'docList') {
    isTemplateDrop.value = false;
    isDocListDrop.value = !isDocListDrop.value;
  }
  chatDropPosition.x = event.clientX;
  chatDropPosition.y = event.clientY;
}

/* =========================
 * 맨 위 셀렉트 박스로 어떤 템플릿으로 할건지 선택하는부분
 * ========================= */
async function selectedTemplate(template, forceNewSession = false, withOverlay = true) {
  if (!template) return;

  chatAbortCtrl?.abort?.();

  currentTemplate.id = template.id;
  currentTemplate.title = template.title;
  currentTemplate.desc = template.desc;

  isDocListDrop.value = false;
  isTemplateDrop.value = false;

  isEditor.value = true;
  isChatLoading.value = false;
  if (withOverlay) {
    showEditorLoading();
  } else {
    // 초기 진입 등: 오버레이 강제 OFF 상태로 시작
    isEditorLoading.value = false;
  }

  editorTitle.value = template.title || '문서명';
  contentEditor.value = '';
  editorKey.value += 1;

  chatList.value = [];
  docHistory.value = [];
  fileList.value = [];
  serverFileIds.value = [];
  pendingQuery.value = '';
  showGallery.value = false;

  hideEditorLoadingWithMin(withOverlay ? 400 : 0);
  if (forceNewSession) {
    //  유저가 템플릿을 "새 문서"처럼 선택한 케이스: 진짜 새 세션 발급
    const newId = uuidv1();
    localSessionId.value = newId;
    await store.commit('setSession', commitSessionForDocs(
      editorTitle.value,
      true,          // isNew = true
      newId,
      newId,         // convId 동일 사용
    ));
  } else {
    //  초기 진입 등 자동 선택: 기존 세션 유지
    localSessionId.value = session.value.session_id;
    await store.commit('setSession', commitSessionForDocs(
      editorTitle.value,
      session.value.isNew ?? false,
      session.value.session_id,
      session.value.convId,
    ));
  }
}

/* =========================
 * 마크다운 관련 함수
 * ========================= */

function renderMarkdownStream(letters) {
  if (!letters) return;
  const renderer = new marked.Renderer();
  renderer.link = (href, title, text) => `<a href=${href} target="_blank" style="text-decoration: underline;">${text}</a>`;
  renderer.code = (code, language) => `<pre class="custom-code-block"><code class="language">${language}<br/>${code}</code></pre>`;
  marked.setOptions({
    renderer,
    gfm: true,
    headerIds: false,
    tables: true,
    breaks: true,
    pedantic: false,
    smartLists: true,
    smartypants: false,
  });
  letters = letters.replace(/\|\n\n\|/g, '|\n|');
  return marked(letters);
}


const coerceHtml = raw => (raw == null ? '' : (typeof raw === 'string' ? raw : String(raw)));

function renderMarkdown(md) {
  return renderMdToHtml(md);
}


function onEditorReady() {
  hideEditorLoadingWithMin(400);
}

/* =========================
 * Chat item 헬퍼
 * ========================= */
function setEditorFromResult(r) {
  const html = coerceHtml(r?.report?.html);
  contentEditor.value = html;
  editorKey.value += 1;
  editorTitle.value = r?.report?.fileName || '문서명';
  txtFile.value = htmlToText(html, {
    linkStyle: 'inline', // "텍스트 (URL)"
    imageStyle: 'alt',   // "[IMG: alt]"
    tableStyle: 'tsv',   // 탭 구분
  });
  // const safeName = String(html || '문서')
  //   .replace(/[\\/:*?"<>|]/g, '_'); // 파일명 금지문자 치환
  // downloadTextFile(`${safeName}.txt`, txtFile.value, { bom: true, eol: 'CRLF' });


}

function toChatItem(r, i, sid) {
  return {
    _idx: Date.parse(r?.date) || (Date.now() + i),
    sessionId: r?.session_id || sid,
    query: r?.query || '',
    answer: r?.StreamingAnswers ?? r?.report?.fileName ?? '',
    files: Array.isArray(r?.fileList) ? r.fileList.map(f => f?.name).filter(Boolean) : [],
    _status: 'ready',
    _progress: 100,
    state: '',
    report: r?.report || undefined,
  };
}

/* =========================
 * Abortion - 대화가 끊겼을 경우 대비 /  helpers 함수 모음
 * ========================= */
const normalizePayload = p =>
  (typeof p === 'string' ? p.trim() : (p?.message || '').trim());

const readyToSend = () => {
  if (isFileLoading.value) {
    toast.info('파일 업로드가 진행 중입니다. 업로드 완료 후 전송이 가능합니다.');
    return false;
  }
  if (fileList.value.length && !fileList.value.every(f => f?.status === 'uploaded')) {
    toast.info('파일 업로드가 아직 완료되지 않았습니다. 업로드 완료 후 전송해 주세요.');
    return false;
  }
  return true;
};

const beginEditorRun = () => {
  if (!isEditor.value) isEditor.value = true;
  contentEditor.value = '';
  editorTitle.value = '문서명';
  showEditorLoading();
  isChatLoading.value = true;
  onGoBottomRef(chatScrollRef);
};

const markLastChatCanceled = () => {
  const last = chatList.value[chatList.value.length - 1];
  if (last && last._status === 'pending') {
    last._status = 'canceled';
    last.state = '중단됨';
    last._progress = 100;
  }
};
// 쿼리 저장하기
const buildSavePayload = ({ report, streamingAnswer, fileListArr, queryId, query }) => ({
  agent_id: session.value.chatbotCode,
  query_id: queryId,
  session_id: session.value.session_id,
  email: store.state.loginUser.userEmail,
  seq_no: chatList.value.length,
  query,
  report,
  StreamingAnswers: streamingAnswer,
  isDocsType: isDocsType.value,
  fileList: fileListArr,
});
// 세션 업데이트 하기
const commitSessionForDocs = (query, isNew, session_id, convId) => ({
  id: session_id,
  agent_code: getDomainInfo().chatbotCode,
  chatbotCode: getDomainInfo().chatbotCode,
  user_email: session.value.user_email,
  session_id: session_id,
  convId: convId,
  query,
  title: query,
  created_at: new Date().toISOString(),
  conversationDate: new Date().toISOString(),
  isNew: isNew,
  chatMode: 'docs',
  accessLevel: route.query.accessLevel,
});

/* =========================
 * rag 로 전달하는 스트리밍 함수 + 안에 리포트 파일즈 불러오는 부분도 있음
 * ========================= */
async function suggestSendChat(payload) {
  //  기존 스트림 중단
  if (chatAbortCtrl) {
    try {
      chatAbortCtrl.abort();
    }
      // eslint-disable-next-line no-empty
    catch (e) {
    }
  }
  chatAbortCtrl = new AbortController();

  //  입력
  const query = normalizePayload(payload);
  if (!query) return;
  if (!readyToSend()) return;

  if (session.value.isNew) {
    store.commit('setNewChat', { flag: true, message: query });
  }

  //  에디터 준비
  localQuery.value = query;
  beginEditorRun();

  //  스트리밍
  const queryId = uuidv1();
  let sseResult;
  try {
    sseResult = await doSseChatCall({
      query,
      session,
      localSessionId,
      fileList,
      currentTemplate,
      chatListRef: chatList,
      chatScrollRef,
      onGoBottomRef,
      t,
      toast,
      renderMarkdown,
      isChatLoading,
      emit,
      editor: { contentRef: contentEditor, titleRef: editorTitle },
      queryId,
      signal: chatAbortCtrl.signal,
      // 라우터 SSE 쓸지 선택 (true면 ragRouterUrl, false면 ragEditorSseUrl)
      useRouter: false,
      //   - 도구/번역 등 리포트 불필요한 흐름: false
      shouldFetchReport: true,
      //타자효과 속도
      speedMs: 10,
    })
    isChatLoading.value = false;

  } catch (err) {
    fileList.value = [];
    if (err?.name === 'AbortError') {
      isChatLoading.value = false;
      hideEditorLoadingWithMin(0);
      markLastChatCanceled();
      chatAbortCtrl = null;
      return;
    }
    chatAbortCtrl = null;
    throw err;
  } finally {
    chatAbortCtrl = null;
    isChatLoading.value = false;
    isBootstrapping.value = false;
    hideEditorLoadingWithMin(0);
    fileList.value = [];
  }

  // 저장
  const { report, answer: streamingAnswer, fileListArr } = sseResult || {};
  try {
    await chatApi.saveDocsQuery(
      buildSavePayload({ report, streamingAnswer, fileListArr, queryId, query }),
    );
  } catch (e) {
    console.error('Error saving query:', e);
  }

  //  세션 갱신
  await store.commit('setSession', commitSessionForDocs(query, false, session.value.session_id, session.value.convId));

  // 파일 첨부 정리
  if (!keepFilesAfterSend.value) {
    fileList.value = [];
  } else {
    keepFilesAfterSend.value = false;
  }
}

// /* =========================
//  * 리포트 창 여는 부분
//  * ========================= */
// const isEditorOpen = async (chat) => {
//   if (!isEditor.value) isEditor.value = true;
//   if (hideOverlayTimer !== null) {
//     clearTimeout(hideOverlayTimer);
//     hideOverlayTimer = null;
//   }
//   isEditorLoading.value = false;
//   isChatLoading.value = false;
//
//   if (chat?.report?.query_id) {
//     try {
//       await doRagReportGet({
//         session,
//         localSessionId: localSessionId.value,
//         toast,
//         renderMarkdown,
//         editor: { contentRef: contentEditor, titleRef: editorTitle },
//         queryId: chat.report.query_id,
//       });
//       isEditorLoading.value = false;
//     } catch (e) {
//       isEditorLoading.value = false;
//       console.error(e);
//     }
//   }
// };

/* =========================
 * txt 문서 저장소로 보내는 함수
 * ========================= */

async function saveTxt() {
  // txt 원본(없으면 현 에디터 HTML → TXT로 즉시 변환)
  const content = (txtFile.value && String(txtFile.value)) ||
    htmlToText(String(contentEditor.value || ''), {
      linkStyle: 'inline',
      imageStyle: 'alt',
      tableStyle: 'tsv',
    });

  if (!content.trim()) {
    toast.info('저장할 내용이 없습니다.');
    return;
  }

  const safeTitle = String(editorTitle.value || '문서명').replace(/[\\/:*?"<>|]/g, '_');
  const filename = `${safeTitle}.txt`;
  const sessionId = session.value.session_id || localSessionId.value || uuidv1();
  const agentCode = getDomainInfo().chatbotCode;
  const email = store.state.loginUser.userEmail;
  const userNm = store.state.loginUser.userNm;
  try {
    showEditorLoading();
    await uploadDocTxt({
      sessionId,
      filename,
      content,
      agentCode,
      email,
      userNm,
    });
    toast.success('TXT 문서가 저장되었습니다.');
  } catch (e) {
    console.error('TXT 저장 실패:', e);
    toast.error('TXT 저장에 실패했습니다.');
  } finally {
    hideEditorLoadingWithMin(300);
  }
}


/* =========================
 * watch
 * ========================= */
watch(isChatLoading, (loading) => {
  if (loading) {
    showEditorLoading();
  } else {
    hideEditorLoadingWithMin(600);
    hideEditorLoadingWithMin(0);
  }
});
watch(() => session.value.session_id, (sid, prev) => {
  if (!sid || sid === prev) return;
  initDocsForSession(sid);
});

/* =========================
 * 최초실행과 docs 에서 전달 받은 스토어값을 기존 변수들의 넣는 부분
 * ========================= */
async function initDocsForSession(sid = session.value.session_id) {
  if (!sid || isBootstrapping.value) return;
  isBootstrapping.value = true;
  try {
    if (!templates.value?.length) {
      await load(agentCode, { accessLevel: route.query.accessLevel, page: 1, perPage: 7 });
    }
    localSessionId.value = sid;
    isEditor.value = true;
    isEditorLoading.value = false;

    const { data } = await getQueryList({ email: store.state.loginUser.userEmail, session_id: sid });
    const rows = Array.isArray(data?.result) ? data.result : [];

    if (rows.length === 0) {
      const initTpl = toTemplateShape(isDocsType.value) || currentTemplate;
      await selectedTemplate(initTpl, false, false); // 초기 진입: 오버레이 X
      contentEditor.value = '';
      editorTitle.value = '문서명';
      chatList.value = [];
      return;
    }

    const first = rows[0];

    // 템플릿 먼저 선택(초기화 일어남) - >  그 다음 실제 컨텐츠 주입
    const tplLike = first?.isDocsType || isDocsType.value;
    const resolvedTpl = toTemplateShape(tplLike) || currentTemplate;
    await selectedTemplate(resolvedTpl, false, false);         // 초기화
    setEditorFromResult(first);            // 내용 주입

    store.commit('setDocsType', resolvedTpl);

    chatList.value = rows
      .slice()
      .sort((a, b) => (a?.seq_no ?? 0) - (b?.seq_no ?? 0))
      .map((r, i) => toChatItem(r, i, sid));

    await nextTick();
    onGoBottomRef(chatScrollRef);
  } finally {
    isBootstrapping.value = false;
  }
}

// 템플릿 안전 변환 하도록 하는 헬퍼 함수
function toTemplateShape(tplLike) {
  if (!tplLike) return null;
  if (typeof tplLike === 'string') {
    const found = (templates.value || []).find(x => x.id === tplLike);
    if (found) {
      return {
        id: found.id,
        title: found.title || '문서명',
        desc: found.description ?? found.desc ?? '',
      };
    }
    return { id: tplLike, title: '문서명', desc: '' };
  }
  // 객체 형태일 때
  return {
    id: tplLike.id,
    title: tplLike.title || '문서명',
    desc: tplLike.description ?? tplLike.desc ?? '',
  };
}

//특정 조건이 만족될 때까지 polling 하면서 기다리는 유틸 함수(localSessionId 등 준비될 때까지)
function waitFor(pred, { interval = 30, timeout = 5000 } = {}) {
  return new Promise((resolve, reject) => {
    const start = Date.now();
    const timer = setInterval(() => {
      if (pred()) {
        clearInterval(timer);
        resolve(true);
      } else if (Date.now() - start > timeout) {
        clearInterval(timer);
        reject(new Error('waitFor timeout'));
      }
    }, interval);
  });
}

// 다른 화면에서 준비된 파일·프롬프트·템플릿 정보를 문서 화면 초기 진입 시 복원하고 즉시 실행해주는 브릿지 역할 함수”
async function applyBridgePending() {
  if (hasConsumedBridge.value) return;
  const pending = store.getters['docsBridge/getPending'];
  if (!pending) return;

  await waitFor(() => !!localSessionId.value).catch(() => {
  });
  if (!templates.value?.length) {
    await load(agentCode, { accessLevel: route.query.accessLevel, page: 1, perPage: 7 }).catch(() => {
    });
  }

  if (pending.template) {
    store.commit('setDocsType', pending.template);
    selectedTemplate(pending.template);
  }

  if (Array.isArray(pending.fileList) && pending.fileList.length) {
    fileList.value = pending.fileList.map((f, i) => ({
      id: f.id || crypto?.randomUUID?.() || `${localSessionId.value || 'pending'}:${Date.now()}:${i}`,
      name: f.name, size: f.size, type: f.type, file: f.file,
      status: f.status || 'uploaded',
      fileType: f.fileType || getFileIconName(f.type),
    }));
    const last = fileList.value[fileList.value.length - 1];
    if (last?.name) editorTitle.value = stripExt(last.name);
    if (store.getters.getIsFileLoading) store.commit('setIsFileLoading', false);
  }

  if (Array.isArray(pending.uploadedFileIds)) {
    serverFileIds.value = pending.uploadedFileIds.slice();
  }

  await nextTick();
  keepFilesAfterSend.value = true;

  if (pending.message) {
    await suggestSendChat({ message: pending.message });
  }

  hasConsumedBridge.value = true;
  store.commit('docsBridge/consume');
}

// DOM 준비 후 대입 하기 위한 처리 chatScrollRef 자체가 null 이라서 해당 부분 없으면 에러남
function onBubbleMounted(el) {
  if (!el) return;
  if (!chatScrollRef || typeof chatScrollRef !== 'object') return;
  nextTick(() => {
    chatScrollRef.value = el;
  });
}

function safeDisconnectQuillObserver() {
  const q = quillRef?.value?.getQuill?.();
  q?.scroll?.observer?.disconnect?.();
}

async function handleOpenEditor(chat) {
  //  기존 에디터가 교체될 거면 Observer 먼저 차단
  safeDisconnectQuillObserver();

  isEditor.value = true;


  await nextTick();
  editorTitle.value = chat?.report?.fileName || '문서명';
  contentEditor.value = chat?.report?.html || '';
  editorKey.value += 1;

}
/* =========================
 * 라이프 사이클
 * ========================= */
onBeforeRouteLeave(() => {
  chatAbortCtrl?.abort?.();
  uploadAbortCtrl.abort();
  clearTimeout(hideOverlayTimer);
  isChatLoading.value = false;
  isEditorLoading.value = false;
});
onBeforeUnmount(() => {
  alive.value = false;
  chatAbortCtrl?.abort?.();
  uploadAbortCtrl.abort();
  clearTimeout(hideOverlayTimer);
  isChatLoading.value = false;
  isEditorLoading.value = false;
});
onMounted(async () => {
  await initDocsForSession();
  await applyBridgePending();

});

</script>

 

도메인 폴더에 실제 작업 할 부분을 작성하고 ( 실제 기능 들 UI 요소가 아닌 서버와 통신하는 부분들 )

서버와 통일하여 폴더 아키텍처에 추가를 했다.

현재 프로젝트의 ts의 러닝커브가 심한 팀원들을 위하여 일단 JS 로 작성하여 만들어 놓은다.

 

// # SSE 스트림 처리 (분기/옵션 확장)
import { nextTick } from 'vue'
import { doRagReportGet } from './useReportFetch'
import { ragEditorSseUrl, ragRouterUrl } from '../../../shared/config/endpoints'
import { toNFC } from '../domain/filenameUtils'

/** 안전 스크롤 래퍼 */
function safeGoBottom(chatScrollRef, onGoBottomRef) {
  try {
    if (!chatScrollRef?.value) return
    if (typeof onGoBottomRef === 'function') onGoBottomRef(chatScrollRef)
  } catch (e) {
    console.warn('safeGoBottom error:', e)
  }
}

/** 리스트에서 내 아이템 위치 찾기 */
function findItemIndex(chatListRef, item, queryId) {
  if (!Array.isArray(chatListRef?.value)) return -1
  const arr = chatListRef.value
  let idx = arr.findIndex(c => c === item || c?.queryId === queryId)
  if (idx === -1 && item?._idx) {
    idx = arr.findIndex(c => c?._idx === item._idx)
  }
  return idx
}

export async function doSseChatCall({
                                      // 기존 파라미터
                                      query,
                                      session,
                                      localSessionId,        // ref
                                      fileList,              // ref
                                      currentTemplate,       // reactive { id, ... }
                                      chatListRef,           // ref
                                      chatScrollRef,         // ref(엘리먼트)
                                      onGoBottomRef,         // function
                                      t,                     // i18n t
                                      toast,                 // useToast()
                                      renderMarkdown,        // function (미사용 가능)
                                      isChatLoading,         // ref
                                      editor,                // { contentRef, titleRef } for doRagReportGet
                                      queryId,
                                      signal,
                                      useRouter = false,        // false: ragEditorSseUrl, true: ragRouterUrl
                                      payloadBuilder,        // function(ctx): object  (기본 페이로드 대체)
                                      headers,               // object: 요청 헤더 오버라이드
                                      shouldFetchReport = true, // boolean: 리포트 조회 여부
                                      speedMs = 10,          // number: 타자 효과 속도(ms)
                                    } = {}) {
  // 로딩 시작
  isChatLoading.value = true

  const id = localSessionId.value
  const attachedNames = (fileList.value || []).map(f => f.name)

  // 버블 추가 (pending)
  const item = {
    _idx: Date.now(),
    sessionId: id,
    query,
    queryId,
    answer: '',
    files: attachedNames,
    _status: 'pending',
    _progress: 1,
    state: '',
  }
  chatListRef.value.push(item)
  await nextTick()
  safeGoBottom(chatScrollRef, onGoBottomRef)

  // 진행률 애니메이션
  let timer = setInterval(() => {
    if (item._progress < 90) item._progress += 2
  }, 120)

  let reader
  try {
    const agentCode = session.value?.agentCode || session.value?.chatbotCode
    if (!agentCode) throw new Error('agent_code 없음')

    const url = (useRouter ? ragRouterUrl(agentCode) : ragEditorSseUrl(agentCode))
    const hasFiles = (fileList.value?.length || 0) > 0
    const fileMeta = hasFiles
      ? [{
        role: 'event',
        type: 'file_upload',
        metadata: {
          files: fileList.value.map(f => ({
            doc_title: toNFC(f.name),
            uploaded_at: new Date().toISOString(),
            doc_page: null,
          })),
        },
      }]
      : []

    // ★ 페이로드 빌더: 주입되면 사용, 없으면 기본
    const defaultPayload = {
      messages: [
        ...fileMeta,
        { role: 'user', content: query },
      ],
      stream: true,
      session_id: id,
      prompt_id: currentTemplate?.id,
      query_id: queryId,
    }
    const payload = typeof payloadBuilder === 'function'
      ? payloadBuilder({
        query,
        session,
        sessionId: id,
        fileList: fileList.value || [],
        template: currentTemplate,
        queryId,
        hasFiles,
        fileMeta,
        defaultPayload,
      })
      : defaultPayload

    // ★ 요청 헤더: 오버라이드 가능
    const reqHeaders = {
      'Content-Type': 'application/json',
      'Accept': 'text/event-stream',
      ...(headers || {}),
    }

    const resp = await fetch(url, {
      method: 'POST',
      headers: reqHeaders,
      body: JSON.stringify(payload),
      signal,
    })

    reader = resp.body.pipeThrough(new TextDecoderStream()).getReader()

    // abort 시 스트림 취소
    if (signal) {
      if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
      signal.addEventListener(
        'abort',
        () => { reader?.cancel?.().catch(() => {}) },
        { once: true }
      )
    }

    let dot = 0
    let stopRequested = false

    // 스트림 루프
    // eslint-disable-next-line
    while (true) {
      const { done, value } = await reader.read()
      if (done) break

      const chunk = String(value).replace(/\r\n/g, '\n')
      const lines = chunk.split('\n')

      let currentEvent = 'message'
      let piece = ''

      for (const line of lines) {
        if (line.startsWith('event:')) {
          currentEvent = line.slice(6).trim()
          if (currentEvent === 'stop') stopRequested = true
          continue
        }
        if (line.startsWith('data:')) {
          const data = line.replace(/^data:\s?/, '')
          if (currentEvent !== 'stop') piece += data
          continue
        }
      }

      if (piece.length > 0) {
        // 타자 효과
        await new Promise(r => setTimeout(r, speedMs))

        if (t) {
          let statement = t('answerwrapper.answerwrapper_key24') || '답변 중'
          for (let i = 0; i < (dot % 4); i++) statement += '.'
          dot++
          item.state = statement
        }

        item.answer += piece
        safeGoBottom(chatScrollRef, onGoBottomRef)
      }

      if (stopRequested) break
    }

    // ★ 스트림 끝: 리포트 메타 조회는 옵션
    let report = null
    if (shouldFetchReport) {
      report = await new Promise(resolve => {
        setTimeout(async () => {
          const result = await doRagReportGet({
            session,
            localSessionId: id,
            toast,
            renderMarkdown,
            editor,
            queryId,
          });
          resolve(result);
        }, 0);
      });
    }

    if (timer) clearInterval(timer)
    if (typeof report !== 'undefined') {
      // console.log('원본 report', report)
    }

    // 리스트에서 내 아이템 위치 확인
    let idx = findItemIndex(chatListRef, item, queryId)
    if (idx === -1) {
      isChatLoading.value = false
      return {
        item,
        answer: item.answer,
        report,
        queryId,
        fileListArr: fileList.value
      }
    }

    const cur = chatListRef.value[idx]

    // 서버 에러 케이스
    if (report && report.error) {
      const updated = {
        ...cur,
        _progress: 100,
        _status: 'error',
        state: '',
        answer: '문서 생성 실패',
        report: undefined,
      }
      chatListRef.value.splice(idx, 1, updated)
      toast?.error?.('문서 생성 실패')
      await nextTick()
      safeGoBottom(chatScrollRef, onGoBottomRef)
      return {
        item: updated,
        answer: updated.answer,
        report: undefined,
        queryId,
        fileListArr: fileList.value
      }
    }

    const reportData = shouldFetchReport ? (report ?? null) : null
    const displayState = reportData ? '' : (shouldFetchReport ? '리포트 없음(요약만 생성)' : '')

    const updated = {
      ...cur,
      _progress: 100,
      _status: 'ready',
      state: displayState,
      report: reportData,
    }
    chatListRef.value.splice(idx, 1, updated)
    await nextTick()
    safeGoBottom(chatScrollRef, onGoBottomRef)

    return {
      item: updated,
      answer: updated.answer,
      report: updated.report,
      queryId,
      fileListArr: fileList.value
    }
  } catch (err) {
    console.error('SSE error:', err)
    if (timer) clearInterval(timer)

    // 취소(Abort)
    if (err?.name === 'AbortError') {
      try {
        const idx = findItemIndex(chatListRef, item, queryId)
        if (idx !== -1) {
          const cur = chatListRef.value[idx]
          if (cur?._status === 'pending') {
            const updated = {
              ...cur,
              _progress: 100,
              _status: 'canceled',
              state: '취소됨',
            }
            chatListRef.value.splice(idx, 1, updated)
          }
        }
      } finally {
        await nextTick()
        safeGoBottom(chatScrollRef, onGoBottomRef)
      }
      return {
        item,
        answer: item.answer,
        report: item.report,
        queryId,
        fileListArr: fileList.value,
        aborted: true,
      }
    }

    // 기타 에러
    try {
      const idx = findItemIndex(chatListRef, item, queryId)
      if (idx === -1) {
        toast?.error?.('문서 생성 실패')
        return
      }
      const cur = chatListRef.value[idx]
      if (cur?._status && cur._status !== 'pending') return
      const updated = {
        ...cur,
        _progress: 100,
        _status: 'error',
        state: '',
        answer: '문서 생성 실패',
      }
      chatListRef.value.splice(idx, 1, updated)
    } finally {
      toast?.error?.('문서 생성 실패')
      await nextTick()
      safeGoBottom(chatScrollRef, onGoBottomRef)
    }
  } finally {
    isChatLoading.value = false
  }
}

 

일정이 4일안에 마무리하라고 해서.. 태스크와 마이크로태스크 이벤트 루프 관련성을 신경 쓰지못하고 만들었지만.

그래도 기존 성능대비 사용자 UX를 고려하여 최적으로 완성시켰다.

 

실시간 스트리밍으로 보여주는 부분과 에디터 부분의 중첩으로 쌓이는 데이터에 대한 부분을 해소하고 더 나은 방향으로 이끌어갔다.

1. 왜 시작했냐?

처음 Plex 프로젝트는 Vue2 + Options API로 시작했음.
문제는 팀 구성이었는데, 대부분이 SI 업체에서 오신 분들..!!이라 프론트엔드 폴더 아키텍처나 코드 구조에 대한 개념 자체가 거의 없었음.
그래서 코드가 그냥 되는 대로 짜여 있었고, 점점 이런 문제들이 터짐 정말 힘든 싸움이었음.. 멱살잡고 끌고가는 수 밖에 없다는 판단이 섰음.. :

  • API 호출, 상태 관리, 유틸 함수가 여기저기 흩어져서 중복
  • 어디에 무슨 로직 넣어야 하는지 기준 없음 → 사람마다 다 다르게 짬
  • 새로운 기능 붙일 때 사이드 이펙트 대폭발
  • 코드리뷰마다 “이건 어디다 넣는 게 맞냐” 싸움 반복

한마디로 아키텍처 자체가 없으니 팀워크도 안 돌아가는 상황이었음.


2. 그래서 내가 뭘 했냐?

이대로는 답이 없겠다 싶어서 내가 Vue3 Composition API + DDD 구조를 제안하고 직접 셋업했음.

Vue3로 갈아탄 이유

  • 로직 분리가 깔끔해지고 composable로 공통화하기 쉬움
  • 신규 투입되는 사람도 구조만 보면 이해 가능
  • Options API보다 유지보수성이 훨씬 나음

DDD 아키텍처 적용

폴더 구조를 내가 직접 src/domains/{도메인}/application | domain | infrastructure로 재정리함.

  • application: 유스케이스 (ex: getConnector, sendChatMessage)
  • domain: 순수 비즈니스 로직, 엔티티/밸류
  • infrastructure: API 통신, 외부 연동

이걸 기준으로 잡으니까:

  • 로직이 흩어지지 않고, “이건 infra”, “이건 domain”이라고 명확히 나눌 수 있었음
  • 팀 내 쓸데없는 논쟁도 사라짐
  • UI랑 비즈니스 로직이 완전히 분리돼서 협업 구조가 좋아짐
  • 테스트하기도 훨씬 편해짐

폴더 아키텍처를 구성한 이유(DDD+애자일 결합)

  • 유비쿼터스 언어로, 챗, 메시지 와 같은 이름이 그대로 코드에 박혀서 기획 - 개발 qa 오해가 줄고 재작업 감소 목적
  • 경계가 명확해저서 팀원들간 작업시 맥락을 쪼개서 병렬 개발이 가능 → 충돌/ 의존성 감소됨 병목을 해소를 위한 목적
  • 테스트가 빠름 → 초고속 단위테스트 가능, 버그 조기 검출, QA 사이클 단축
  • 컨텍스트 안에서만 영향이 가서 리스크 최소화
  • 변경 친화적 규칙은 도메인 외부연동은 Infrastructure API 변경 돼도 도메인 안전, 리팩토링 비용이 감소함
  • 온보딩 쉬움 폴더 흐름으로 유추가능 ( views → application → domain → infra). 신규 투입 속도 빨라짐
  • 확장성이 좋아짐 필요 할 때만 컨텍스트 분리 * (레포/패키지)로 단계적 확장 가능함

 

폴더 구조: 기존 → 변경 매핑

  • src/config → src/shared/config (공용 설정 통합: endpoints.ts, env.ts)
  • src/mixins/* → 도메인/애플리케이션 훅로 흡수 (src/domains/*/application)
  • src/views/page/* & src/views/questionAnswer/* → 기능별 화면 유지 단 도메인 로직은 domains로 이동
  • src/views/component / src/component → 공용은 src/shared/component, 특정 기능은 해당 도메인 presentation(뷰)로 배치 가능
  • 네트워크 레이어: 흩어져있던 호출 → src/shared/http/*(+ 도메인별 infrastructure API 모듈)로 변경함
  • 채팅 관련 스크립트/믹스인 → src/domains/chat/{application,domain,infrastructure}로 분리함
  • 채팅 등 인프라 세부 → src/domains/chat/infrastructure/* 세분화시킴

아키텍처 개요(DDD 계층)

의존성 규칙(강제):

  • views → application만 호출 (가능하면 domain 직접 접근 금지)
  • application → domain + infrastructure 사용 가능
  • domain → shared/http 사용 금지, 외부 의존성 없음(순수)
  • infrastructure → shared/http 사용, domain 타입만 참조(비즈니스 로직 금지)
  • shared → 어디서든 참조 가능(단, 도메인 규칙 침범 금지)

변경 포인트 핵심

  • 네트워크 일원화: src/shared/http/httpClient.ts + tokens.ts로 인증/에러 처리 공통화
  • 도메인 분리: chat 예시처럼 application/domain/infrastructure로 책임 분리
  • 팩토리 도입: domain/factory/*로 생성 규칙/불변성 보장
  • 설정/상수 정리: shared/config, shared/constants로 분리 (런타임/빌드 시 분리 가능)
  • 공용 UI: src/shared/component로 재사용 컴포넌트 집약

폴더별 역할(샘플)

  • src/domains/chat/application/*: useChat.js, chatService.js, orchestration/유스케이스
  • src/domains/chat/domain/*: entities, valueObjects, factory/chatFactory.js, createChat.js
  • src/domains/chat/infrastructure/: chatApi.js (HTTP 호출만)
  • src/shared/http/*: httpClient.ts(인터셉터/재시도/토큰), tokens.ts
  • src/shared/config/*: env.ts, endpoints.ts(베이스 URL 등)
  • src/shared/constants/*: 공용 상수
  • src/views/*: 화면/라우팅/상태 최소화, 로직은 application에 위임

코드 합의(품질/확장)

  • Vue3, <script setup>, Node 18
  • 타입은 JS/TS 혼용 가능하되 lang= ts는 금지 → ts는 .ts 모듈에서만 사용 합니다.
  • 도메인 순수성 유지: 계산/검증/정책은 domain, 흐름은 application, 외부연동은 Infrastructure
  • 명명 규칙: usexx(훅), xxService(유스케이스), xxApi(인프라), createxx(팩토리)

핵심 의존성 관계는 다음과 같습니다.

Views 는

  • A(application) 의존
  • sc (Shared/Component) 에 의존

A(애플리케이션)는

  • D(도메인)에 의존
  • I (Infrastructure)에 의존

I (Infrastructure)에 의존 H(Shared/HTTp)에 의존

H(Share/HTTP)는 C(shared/config) 에 의존

위에 내용은 confluence 에 팀원들이 보라고 직접만든 부분이다.

 

테스트/Jest 도입 포인트

  • Domain 테스트 우선: I/O 없이 순수 로직이라 단위 테스트 효율이 좋습니다.
  • Application 테스트: 인프라 목킹으로 유스케이스 흐름 검증가능
  • Infrastructure 테스트: httpClient 인터셉터/에러 경로 테스트 가능
  • 구조가 분리되어 Jest + Vue Test Utils 도입이 쉬움(레이어별 독립 테스트 가능)

3. 바꾸고 나서

  • 팀원 간 정리: “이건 infra로 내려야지”라는 기준 생김 → 더이상 애매하게 끼워넣기 안 함
  • 가독성 상승: 신규 합류한 사람도 구조만 보면 금방 이해 가능
  • 확장성 확보: Chat 도메인에 SSE 붙일 때 infra에 네트워크 코드만 추가하면 끝 → 손쉽게 기능 확장
  • 중복 코드 제거: httpClient, 포맷터 같은 거 infra/application에서 불러다 쓰니 코드 정리됨

4. 결론

Vue3 + DDD 도입은 단순히 최신 기술 써본 게 아니라, 아키텍처 개념이 없던 팀에 기준을 세운 일이었음.
아무도 모르는 상태에서 내가 직접 끌고 와서 구조를 잡아주니까, 코드만 깔끔해진 게 아니라 팀 내 갈등도 줄고 협업 효율도 올라감.
결국 Plex 프로젝트는 그 덕분에 안정성 + 확장성 + 팀워크까지 챙길 수 있었음.

오늘은 프론트엔드에서도 DDD를 왜 적용해야하는지에 대해서 기록을 하고 정리를 하는 글을 작성할 것이다.

프론트엔드에서 DDD를 적용한다고 하면, 흔히 백엔드 개념 아닌가?라는 반응이 나오곤 한다. 하지만 프론트엔드도 점점 복잡한 도메인을 다루게 되면서, 백엔드처럼 체계적으로 도메인 중심 설계를 적용하는 것이 중요해졌다.

 

프론트엔드에서 DDD를 적용해야 하는 이유

  • 도메인 로직이 복잡해진다. → 단순한 UI만 처리하는 것이 아니라, 상태 관리, 도메인 규칙, 비즈니스 로직까지 포함되기 때문이다.
  • 백엔드와의 협업이 중요하다. → 백엔드 DDD와 정합성을 맞추어야 하기 때문이다.
  • 프론트엔드 규모가 커진다. → 컴포넌트와 상태 관리를 효율적으로 분리해야 하기 때문이다.
  • 유지보수성이 증가한다. → 도메인 단위로 설계하면 변경에 강한 구조가 될 수 있기 때문이다.

 

프론트엔드에서 DDD 적용 핵심 개념

1. 도메인 모델링: Entity와 Value Object

프론트엔드에서도 도메인 모델을 정의해야 한다.

  • Entity: 식별자가 있는 객체를 의미한다. (예: User, Order, Product)
  • Value Object: 변경 불가능하고 식별자가 없는 객체를 의미한다. (예: Money, Address, DateRange)

적용 예시

// Value Object: 가격을 다룰 때
class Money {
  constructor(private readonly amount: number, private readonly currency: string) {}

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error("Different currencies cannot be added.");
    }
    return new Money(this.amount + other.amount, this.currency);
  }
}

// Entity: 제품 정보
class Product {
  constructor(public readonly id: string, public name: string, public price: Money) {}
}

const apple = new Product("1", "Apple", new Money(1000, "KRW"));

 

이렇게 하면?

  • 가격 계산 로직이 여기저기 흩어지지 않고 Money 안에 집중될 수 있다.
  • Product의 가격을 직접 수정하는 대신 Money 객체를 활용하여 유지보수성이 높아진다.

2. 애그리게이트(Aggregate) 패턴 적용

애그리게이트는 도메인의 일관성을 유지하는 그룹을 의미한다.

  • 핵심 개념은 **하나의 애그리게이트 루트(Aggregate Root)**를 통해서만 내부 데이터를 변경할 수 있다는 점이다.
  • 예를 들어, Order가 OrderItem을 포함하지만, OrderItem을 직접 수정할 수 없다.

적용 예시

class OrderItem {
  constructor(public readonly id: string, public readonly product: Product, public readonly quantity: number) {}
}

class Order {
  private items: OrderItem[] = [];

  constructor(public readonly id: string, public readonly userId: string) {}

  addItem(product: Product, quantity: number) {
    this.items.push(new OrderItem(`${product.id}-${quantity}`, product, quantity));
  }

  getTotalPrice(): Money {
    return this.items.reduce((total, item) => total.add(item.product.price), new Money(0, "KRW"));
  }
}

const order = new Order("order-123", "user-456");
order.addItem(apple, 2);
console.log(order.getTotalPrice()); // Money { amount: 2000, currency: 'KRW' }

 

이렇게 하면?

  • OrderItem을 Order 내부에서만 관리할 수 있어 도메인의 일관성이 유지된다.
  • 총 가격 계산 로직이 Order 내부에서만 관리될 수 있다.

3. 리포지토리 패턴 적용

프론트엔드에서도 데이터를 효율적으로 관리하기 위해 리포지토리 패턴을 적용할 수 있다.

  • 서버 API를 호출하는 역할을 리포지토리로 분리하면 좋다.
  • 상태 관리(store)와 직접 결합하지 않도록 설계해야 한다.

적용 예시

interface OrderRepository {
  save(order: Order): Promise<void>;
  findById(id: string): Promise<Order | null>;
}

class HttpOrderRepository implements OrderRepository {
  async save(order: Order): Promise<void> {
    await fetch(`/api/orders`, {
      method: "POST",
      body: JSON.stringify(order),
    });
  }

  async findById(id: string): Promise<Order | null> {
    const res = await fetch(`/api/orders/${id}`);
    if (!res.ok) return null;
    const data = await res.json();
    return new Order(data.id, data.userId);
  }
}

const orderRepo = new HttpOrderRepository();
await orderRepo.save(order);

 

이렇게 하면?

  • Order 도메인을 직접 API 호출과 결합하지 않아서 유연한 구조가 될 수 있다.
  • API 변경이 있어도 OrderRepository만 수정하면 되기 때문에 유지보수성이 높아진다.

 


4. 프론트엔드 상태 관리와 DDD의 조합

프론트엔드에서는 상태 관리 라이브러리(Redux, Recoil, Zustand 등)와 DDD를 조합할 수 있다.

  • Entity를 상태로 관리하면 도메인 중심의 설계를 유지할 수 있다.
  • 도메인 서비스와 리포지토리를 분리하면 상태 관리와 비즈니스 로직을 분리할 수 있다.
  • 비즈니스 로직을 도메인 모델 안에 캡슐화하면 유지보수가 쉬워진다.

적용 예시 (Zustand + DDD)

import { create } from "zustand";

interface OrderState {
  orders: Order[];
  addOrder: (order: Order) => void;
}

const useOrderStore = create<OrderState>((set) => ({
  orders: [],
  addOrder: (order) => set((state) => ({ orders: [...state.orders, order] })),
}));

// 사용 예시
useOrderStore.getState().addOrder(new Order("order-001", "user-123"));

 

이렇게 하면?

  • Zustand 상태 관리와 DDD 구조를 자연스럽게 조합할 수 있다.
  • Redux를 사용할 경우 Entity Adapter를 활용하여 관리할 수도 있다.

 

결론: 프론트엔드에서 DDD를 적용하면?

비즈니스 로직이 명확하게 분리된다.
도메인 모델이 명확해지고 재사용 가능해진다.
API 변경에 유연하게 대응할 수 있다.
프론트엔드에서도 백엔드와 일관성 있는 구조를 유지할 수 있다.

이런 방식으로 프론트엔드를 설계하면, 단순히 UI를 만드는 개발자가 아니라 도메인을 깊이 이해하는 프론트엔드 엔지니어로 인정받을 수 있을 것이다. 

+ Recent posts