
드디어 최종 프로젝트를 끝으로, 6개월 간의 AI 캠프의 여정이 끝났다. 6개월이라는 길고도 짧은 시간에 정말 많은 부분을 배우고, 개발자로서 한층 성장된 나 자신을 마주할 수 있었다. 이번 마지막 회고는 2개월 간의 최종 프로젝트 기간 동안 기획부터 발표 준비까지 모든 과정을 담아보고자 한다.
📅 실제 프로젝트 수행 절차
📋 대주제 선정
최종 프로젝트는 주어진 6개의 프로젝트 주제 중 하나를 선택하고, 선택한 주제를 바탕으로 세부 주제를 정하는 방식이다. 우리팀은 결성이 되자마자, 다같이 모여서 회의를 했다. 우리가 가장 하고 싶었던 주제는 "자체 sLLM 개발을 통한 기업 업무 활용 생성형 AI 플랫폼"이었다. 이 주제는 모든 팀에게 가장 인기가 있다보니, 우리팀이 이 주제를 할 수 있을지 반신반의 했지만, 그래도 가장 빨리 구글폼을 작성하면 1지망이 되지 않을까 전략을 세워보았다.
그리고 대망의 팀 주제선정 발표날,,,정말 감사하게도 우리가 가장 하고 싶었던 주제인 sLLM 개발로 정해졌다. 우리팀은 스타트부터 정말 운이 좋은 팀이라고 생각했다.
📋 세부주제 선정
우리팀은 세부 주제를 제대로 정해야 한다는 한마음을 가졌다보니, 주제를 선정하고 서로 설득하는데 정말 많은 시간이 썼다. 처음에 회의를 하고 나면 진이 다 빠질 정도로 서로 열정을 다해서 토론하는 시간을 가졌다. 처음에는 각자 원하는 세부주제들을 들고 와서 왜 하고 싶은지, 어떤 데이터를 사용하고 싶은지 등을 이야기하는 시간을 가졌다.
결론적으로 우리가 주제를 선정하는 기준은 다음과 같았다.
주제 선정 기준
1. 2달이라는 긴 기간동안 "재밌게" 할 수 있는 주제인가!
2. "다양한 기술"을 활용해볼 수 있는 주제인가!
3. "sLLM"이어야 하는 이유를 잘 납득할 수 있는 주제인가!
그래서 결론적으로 우리가 정한 주제는 "자동차 디자이너를 위한 프로토타입 이미지 생성 플랫폼"이다. 처음엔 이 주제가 팀원들을 설득하지 못했다. 디자이너들이 제품을 만드는 과정에서 User Experience를 향상시키기 위해서는 VoC(Voice of Customer), 디자인 아이덴티티 등 고려해야할 사항들이 많지만, 이를 다 조사해서 반영하고 또 수정하는 과정은 꽤나 복잡한 노드들을 거쳐야 한다는 생각이 들었다. 이를 AI가 해결할 수 있는 방법이 있지 않을까 하는 생각에 낸 주제였지만, 아무래도 처음엔 잘 그려지지 않아서 어려웠던 것 같다. 그래도 계속해서 주제에 대해 생각해보고 이해하는 한 팀원이 있어서 팀원들도 점차 이 주제로 관심을 가지고 다시 토론해볼 수 있었다. 그래도, 생각했던 주제가 세부 주제로 선정되어서 너무 뿌듯했다.
(우리팀은 회의를 할 때 화이트보드를 활용해서 토론하는 방식을 선택했다 ! 화이트보드는 사랑...)
![]() |
|---|
세부 주제를 정하고 난뒤에 기획서를 작성하고 설계하는 것에도 많은 시간을 투자헀다. 이 프로젝트를 왜 하는것인가, 어떤 문제점을 발견하고, 이 부분을 어떻게 해결할 것인가에 대해 팀원들 간의 “개발 목표”를 공유하는 것은 매우 중요했다. 그래서, 초반에는 이 부분에서 의견을 합하는 과정에서 시간과 노력들을 투자했다. 따라서, 관련 시장 조사도 많이하고, 경쟁사들과의 차별성을 두기 위한 노력들도 기획에 녹여냈다. 그리하여...플랫폼 이름은 바로.. JJACKLETTE ! 그리고 개발목표를 한문장으로 정리하면 아래와 같다.
프로젝트 목표
본 프로젝트는 자동차 제품 산업 분야에서 기업에 특화된 sLLM을 개발함으로써 사용자 경험 중심 접근을 기반으로, 자동차 디자이너의 업무 프로세스의 효율성과 창의성을 극대화 하는 이미지 프로토타입 플랫폼을 개발하는 것을 목표로 한다.
JJACKLETTE의 Motto (한번 맛깔나게...모토도 정해보아따 ㅎㅎㅎㅎ)
“Automotive design is no longer just about aesthetics — it's about understanding people, trends, and technology.”
JJACKLETTE의 전반적인 모델링 과정 설계
1. 데이터 수집 및 전처리: 각 모델에 필요한 데이터를 수집하고 학습에 적합한 형태로 가공
2. 모델 파인튜닝: 각 베이스 모델(kanana, InternVL3, FLUX.1, Hunyuan3D-2, LTX-Video)을 수집된 기업 특화 데이터로 파인튜닝
3. 모델 파이프라인 구축: 파인튜닝된 데이터 이외에 추가적으로 RAG 기반의 내부 문서 검색 혹은 모델 활용 경로 설정
4. 모델 통합 및 배포: 파인튜닝된 모델들을 JJACKLETTE 플랫폼에 통합하고 배포하여 실제 서비스에 활용
🛠️ 개발 목표
- 현대자동차 디자인 실무에 맞는, 통합형 AI 프로토타입 생성 플랫폼 개발
- 내부 규정/트렌드/피드백 등 기업 맥락을 반영한 텍스트·이미지 생성
- 프롬프트만으로 다양한 디자인 시안/시각화/3D·4D 모델 생성 지원
- 유연한 수정, 아카이빙, 프로젝트 관리 등 협업 효율화 기능 제공
- 모든 백엔드, 프론트엔드, 데이터베이스, 모델 서버를 Docker 기반 컨테이너로 운영
- docker-compose로 전체 서비스 통합 구동 및 재현 가능성 확보
기획 및 설계를 명확하게 하고 나니, 어떤 데이터를 수집해야 하는지 명확하게 그려졌다. 우리는 크게 RAG시스템과 파인튜닝용 데이터를 따로 구분하여 데이터를 수집하였다. 그리고 이를 제대로 관리하기 위한 스프레드 시트에 한눈에 볼 수 있도록 정리하면서 데이터를 수집했다.
데이터 수집 관리 스프레드 시트 화면
데이터 ERD
- 원문 문서를 청크 단위로 분할(chunking)
- 각 청크를 바탕으로 LLM이 질문–답변(Question–Answer) 3쌍을 생성
- 생성된
_chunks_qa.jsonl입력을 읽어, 각 레코드별로
- 해당 레코드의 청크를 긍정(positive) 컨텍스트로 설정
- 다른 청크들에서 부정(negative) 컨텍스트 2개 샘플링
- positive/negative를 섞어 순서 랜덤화 및 정답 인덱스(positive_index) 부여
- 최종적으로 Reranker 학습 포맷(JSONL)으로 저장
목적: 주어진 질의/문맥에서 올바른 컨텍스트(positive)를 여러 후보(contexts) 중 골라내도록 Reranker를 학습시키는 것.
1) 1차 Chunking: 큰 단위로 분할 (논문의 경우, 소제목별로 / Article 은 기사 내용별로)
def split_by_hyphen(text, delim="----------------------------------------"):
return [c.strip() for c in text.split(delim) if c.strip()]def split_by_topic(text):
splitters = [
"요 약","서 론","실험 설정","실험 및 결과",
"1. 차체 측면 형태에 따른 공력 성능 비교",
"2. 차체 측면 유리창 각도에 따른 공력 성능 비교",
"3. 엔진 후드의 각도 변화에 따른 공력 성능 비교",
"4. 차체의 루프(roof) 각도에 따른 공력 성능 비교",
"4. 후방 디퓨저 적용에 따른 공력 성능 변화",
"결 론",
"2.1 플루이딕 스컬프쳐와 스톰 엣지", "2.2 센슈어스 스포트니스",
"3.1 플루이득 스컬프쳐와 스톰엣지 미의식", "3.2 센슈어스 스포트니스 미의식",
"4.1 인지된 미의식과 인식적 환원의 차이",
"4.2 플루이딕 스컬프쳐와 스톰 엣지의 신경학적 해석",
"4.3 센슈어스 스포트니스의 신경학적 해석",
"4.3.1 파라메트릭 다이나믹스","4.3.2 파라메트릭 주얼","4.3.3. 히든라이팅",
"4.3.4 현대자동차 디자인 철학의 신경학적 해석",
"REFLECTIONS IN MOTION","HERITAGE SERIES","PONY",
"COLOR & LIGHT","MATERIAL","A JOURNEY"
]2) 2차 Chunking → 문서의 성격에 따라 다르게 분할함 ! (각 문서마다 가진 특성이 다르기 때문에 이에 맞게 상이하게 분할하는 것이 맞다고 판단함)
🔽 Chunking 종류
- Paragraph
- Sentence
- Q_A쌍
- length
3) 메타데이터/잡음 정리: clean_metadata(text: str) -> str
(...), [...] 내부 제거Q. / A. 제거| 필드 | 타입 | 설명 |
|---|---|---|
question | string | (선행 단계) LLM이 생성한 질문 |
answer | string | (선행 단계) LLM이 생성한 답변 |
chunk_text | string | 이 레코드의 원본 청크 텍스트 |
contexts | string[] | [1] …, [2] …, [3] … 형식의 후보 컨텍스트 리스트 (positive 1개 + negative 2개) |
positive_index | int | contexts 내 정답(positive) 위치 |
| 그 외 | any | 원본에서 보존되는 메타데이터(문서 ID, 청크 ID 등) |
Reranker 입력 시,
question(+answer옵션)와contexts를 함께 넣고, 모델로 하여금positive_index를 맞히도록 학습하는 방식
{
"... 원본 필드 ...": "...",
"contexts": [
"[1] 컨텍스트_문자열",
"[2] 컨텍스트_문자열",
"[3] 컨텍스트_문자열"
],
"positive_index": [1] // 1-based index, contexts 중 정답(positive) 위치
}
# 프롬프트 템플릿
prompt = ChatPromptTemplate.from_template(
dedent("""### Instruction
당신은 텍스트 분석 전문가입니다.
아래 자동차 기사를 "title", "car_name", "category", "tags", "brand"로 나눠서 json 형태로 만들어주세요.
### 주의사항
1. title 은 제목입니다.
2. car_name 은 본문에서 차의 세부 이름을 추출하여 기입하세요. 단, 추출이 불가능한 경우 빈 문자열로 대체합니다.
3. category 는 ["review", "news", "article"] 중 하나로 판단하여 기입하세요.
4. "review"는 고객 리뷰, "news" 는 최신 뉴스 및 동향 등, "article" 은 논문 내용입니다.
5. tags 는 키워드를 리스트 형태로 담습니다. 가능한 많이 추출하세요.
6. brand 는 "현대", "기아", "쌍용" 과 같은 꼴입니다. 단, 추출이 불가능한 경우 빈 문자열로 대체합니다.
7. page_content 는 본문의 내용을 기입합니다.
8. json, ``` 등과 같은 불필요한 문자는 절대 입력하지 않습니다.
### Few Shot
{{
"metadata": {{
"title": "현대차, 신형 그랜저 공개…럭셔리 대형세단 시장 공략",
"car_name": "그랜저",
"category": "news",
"tags": ["그랜저", "현대자동차", "신차발표"],
"brand": "현대"
}},
"page_content": "현대차, 신형 그랜저 공개…럭셔리 대형세단 시장 공략\n신형 그랜저가 드디어 공개됐다. 이번 모델은 디자인 혁신과 첨단 기술로 럭셔리 대형세단 시장을 정조준한다.\n태그: 그랜저, 현대자동차, 신차발표"
}},
{{
"metadata": {{
"title": "기아 EV6 롱텀 시승기 – 전기차의 새로운 기준",
"car_name": "EV6",
"category": "review",
"tags": ["EV6", "전기차", "시승기", "기아"],
"brand": "기아"
}},
"page_content": "기아 EV6 롱텀 시승기 – 전기차의 새로운 기준\n지난 2주간 기아 EV6를 직접 타보았다. 주행감과 충전 인프라 모두 만족스럽다.\n태그: EV6, 전기차, 시승기, 기아"
}}
### Input Data
{article}""")
)
다음으로 피그마를 활용해서 우리의 기능이 들어갈 수 있는 웹 화면을 설계하였다. 예전에도 피그마 툴을 많이 써봤다보니, 익숙하게 잘 만들 수 있었다. 캠프에 들어와서, 예전부터 묵혀두었던 디자인 역량을 최대치로 발휘해야 하는 순간이 많아 예전에 학부와 대학원 때 열심히 하길 잘했다는 생각을 했다.
![]() | ![]() |
|---|
우리는 모델을 정하고 학습시키는 과정을 반복하면서, 성능이 제대로 나올 때까지 고군분투의 과정을 거쳤다.
텍스트 모델(LGAI-EXAONE/EXAONE-4.0-1.2B -> kakaocorp/kanana-1.5-8b-instruct-2505)
텍스트 모델의 경우, 초기에는 Exaone모델(LGAI-EXAONE/EXAONE-4.0-1.2B)을 사용했었는데, 이 모델의 경우는 ondevice를 위한 모델이다보니, 1.2B밖에 되지 않아서 성능을 아무리 높여도 크게 향상되지 않았다. 아니면, 32B모델을 사용해야 하는데, 이건 또 너무 커서 제한된 리소스를 가지고 있는 우리에겐 너무 버거운 모델이었다. 그래서 바꾸게 된 모델이 지금의 Kanana 모델(kakaocorp/kanana-1.5-8b-instruct-2505)인데, 이 모델은 파라미터도 Exaone보다 크고 instruct모델이다 보니, 한국어와 영어를 모두 지원하여 이미 base 모델만으로도 사용자의 지시를 잘 따랐다.
2D 모델(Stable Diffusion → FLUX)
우리 플랫폼에서 가장 중요한 기능을 해야 하는 2D모델은 단순히 정량적으로 이미지가 잘 나온다고 평가할 수 없었다. 얼만큼 기업의 아이덴티티를 잘 반영하고 있는지 혹은 자동차 디자인을 제대로 뽑아내고 있는가 또한 중요하기 때문에, 사실상 정성적인 평가가 훨씬 중요하다. 그래서, 어떤 기준으로 평가를 할 것인지를 프로젝트를 제대로 이해하고 설정하는가는 몹시 중요하다는 생각이 들었다. 초반에 사용했던 Stable Diffusion 말고, FLUX를 선택한 이유는 크게 두가지다. 첫번째, FLUX는 inpaint 기능까지도 지원한다는 점이다. 보통 이미지 생성 모델을 사용해보면 알겠지만, 이미지를 주고 수정을 하려고 해도, 부분 수정을 정확히 하는 모델들은 사실상 굉장히 드물다. 그러다보니 FLUX모델은 부분 수정에 특화되어 있는 모델이라서 이전 이미지 파일을 PIL image 형태로만 주면, 프롬프트에 따라 수정사항을 잘 반영하고 했다. 그리고 두번째로는 자동차 모델을 너무나 자연스럽게 잘 만들어준다는 점이었다. 이전 모델에 비해 월등히 잘 만들고, 이미 어느정도 현대자동차에 대한 이해도 있는 모델이었다.
3D 모델(Trellis -> shapE -> Hunyuan3D-2)과 Video 모델(Stable Diffusion video-> LTX video)
두 모델은 앞서 텍스트와 2D모델에 비해 많은 시간을 투자하지 않았지만 막판에 모델도 꽤나 많이 바꿨다. 나의 담당은 이쪽은 아니였지만, 이 부분을 comfyUI에 올려서 배포하는 과정도 꽤나 복잡하고 어려웠다....고생 많았다 ㅜㅜ
Finetuning을 할 때 어떻게 학습을 시킬지에 대한 방법은 정말 다양하다. 우리가 활용한 방식은Positive/Negative context 기반의 supervised reranker fine-tuning이다. 앞서 데이터셋을 구성하는 것에서 볼 수 있듯이 이 방식은 reranker로 질문–문맥 쌍을 깊게 cross-encoding해서, 진짜 정답(positive)을 negative보다 높은 점수로 올려줌으로써 모델이 정확한 문서를 상위에 노출하게 된다. 또한, Ranking Robustness (순위 안정성)을 보장해서 여러 후보 문맥이 유사해도, 학습 과정에서 positive와 negative를 구분하는 기준을 배우게 되고, Noise가 많은 상황에서도 더 안정적인 문맥 선택이 가능하다.
LLM 파인튜닝 결과(BERT score, Cosine Similarity
1. 초기 의도 분류
우선 처음 사용자가 챗봇 화면을 켰을 때, 어떤 질문과 기능을 사용할 것인지 첫번째 intent classifier 를 정하는 것부터 시작했다.
사용자 시나리오를 그려봤을때, 크게 이미지 생성 노드와 지식 질문 노드로 나눠볼 수 있었다. 그리고, 이미지 생성 안에서도 체크리스트를 기반으로 생성하는지 혹은 바로 이미지 쿼리를 보내서 생성하는지, 이미지 수정 요청으로 바로 갈것인지 등을 나누었다. 또한, 사용자가 이미지를 생성하지 않고, 단순히 디자인 철학이나 지식 등을 물어볼 수 있다고 생각하면 아래와 같이 시나리오를 4가지로 나눴다.
🎯 4가지 사용자 시나리오
시나리오 1: 체크리스트 기반 이미지 생성
1. 페이지 접속 → 자동 환영 메시지
2. 사용자: "이미지 생성"
3. AI: "단계별로 만들어줘" vs "바로 만들어줘" 구분
4. 사용자: "단계별로 만들어줘"
5. AI: 체크리스트 안내 및 단계별 가이드
시나리오 2: 직접 이미지 생성
1. 페이지 접속 → 자동 환영 메시지
2. 사용자: "빨간색 SUV 이미지 만들어줘"
3. AI: 바로 이미지 쿼리 생성
시나리오 3: 이미지 수정
1. 페이지 접속 → 자동 환영 메시지
2. 사용자: "이미지 수정"
3. AI: 이미지 업로드 및 수정 요청 안내
시나리오 4: 지식 질문
1. 페이지 접속 → 자동 환영 메시지
2. 사용자: "현대자동차의 디자인 철학이 뭐야?"
3. AI: RAG 기반 전문 답변
1) 시나리오를 반영하기 위한 Intent classifier AI 프롬프트
INITIAL_INTENT_CLASSIFICATION_PROMPT = """다음 사용자 질문의 의도를 분류해주세요:
사용자 질문: {user_query}
의도 분류 옵션:
1. rag: 현대자동차나 자동차에 대한 구체적인 지식 질문 (기술, 디자인, 철학, 역사 등)
2. image_generation: 새로운 자동차 이미지 생성 요청 (예: "자동차 이미지 만들어줘", "새로운 디자인 생성해줘", "이미지 생성")
3. image_modification: 이미지 수정 요청 (예: "이미지 수정해줘", "이 차 색깔 바꿔줘", "이미지 업로드해서 수정", "이미지 수정")
4. general_conversation: 일반적인 일상 대화 (인사, 날씨, 개인적인 질문, 자동차와 무관한 일반적인 대화 등)
반드시 다음 중 하나의 키워드만 답변하세요: rag, image_generation, image_modification, general_conversation"""
2) 초기 분류를 위한 노드 함수 정의
def classify_and_apply_intent(state: PipelineState) -> Dict[str, Any]:
print(f"[분기] classify_and_apply_intent 노드 접근")
user_query = state.get("user_query", "")
final_intent = _classifier.classify_initial_intent(user_query)
print(f"[처리] 의도 분류 완료: '{user_query[:30]}...' -> '{final_intent}'")
# image_generation 경로로 갈 때 image_mode에서 interrupt 발생
if final_intent == "image_generation":
state["waiting_node"] = "image_mode"
return {"initial_intent": final_intent}
def route_top(state: PipelineState) -> str:
it = _norm(state.get("initial_intent"))
print(f"[라우팅] route_top - 의도: '{it}'")
if it == "image_generation":
print(f"[라우팅] 이미지 생성 경로로 라우팅")
return "image_generation"
elif it == "image_modification":
print(f"[라우팅] 이미지 수정 경로로 라우팅")
return "image_modification"
elif it == "rag":
print(f"[라우팅] RAG 질문 경로로 라우팅")
return "rag"
else:
# Interrupt 걸리기 전, 첫 방문 시
if state["waiting_node"] != "route_top":
interrupt({"is_loading": True, "generation_type": "text"})
else:
print(f"[라우팅] 일반 대화 경로로 라우팅")
return "general_conversation"
3) Langgraph conditional edge 정의
g.add_edge("process_welcome_input", "classify_and_apply_intent")
g.add_conditional_edges(
"classify_and_apply_intent",
route_top,
{
"image_generation": "image_mode",
"image_modification": "mod_intro",
"rag": "rewrite_query", # RAG는 rewrite_query부터 시작
"general_conversation": "handle_general_conversation",
},
)
2. RAG 시스템
위 의도분류에서 자동차나 디자인 관련 지식을 물어봤을 때는 RAG시스템으로 가도록 설계되어 있는데, RAG 안에서도 사용자 쿼리를 기반으로 정확도를 높이기 위해 "쿼리 재작성", "품질 라우팅", "LLM Fallback" 처리를 해두었다.
쿼리 재작성은 Hyde방식을 통해, 검색 쿼리르 재구성하여 보다 명확한 답변을 찾아올 수 있도록 했다. 또한, 품질 라우팅을 통해 관련성과 충분성이 적절하게 있는지 스스로 판단할 수 있는 Agent RAG를 활용하였다.
1) Rewrite the Query
1. 기능: RAG가 사용자의 쿼리 의도를 정확하게 파악 (보통은 친절하게 쿼리를 안쓰기 때문에)
2. 방식: HyDE + 키워드 압축 + 검색 쿼리 생성
out = kanana_llm_model.generate_response(prompt, max_length=360).strip()
pseudo = _pick(out, r"가상답변\s*:\s*(.+)")
keys = _pick(out, r"키워드\s*:\s*(.+)")
q = _pick(out, r"검색쿼리\s*:\s*(.+)") or re.sub(r"\s+", " ", keys.replace(",", " "))
return q[:512], pseudo
pseudo → 모델 prompt에 context로 넣거나 log로 기록.2) Qdrant vector DB 검색 입력(embedding 모델은 vectorDB에 넣을 때 사용했던 동일한 모델인 BAAI/bge-m3 사용).
lass BabsimRAGAdapter:
"""Babsim Vector DB를 현재 파이프라인 RAG와 연결하는 어댑터"""
def __init__(self):
self.qdrant_client = QdrantClient(host="localhost", port=6333)
self.collection_name = "babsim_rag_db"
self.embedding_model = None # 지연 로딩
def search_relevant_documents(self, query: str, k: int = 5) -> List[Dict[str, Any]]:
"""babsim Vector DB에서 관련 문서 검색"""
try:
# 지연 로딩
if self.embedding_model is None:
self.embedding_model = SentenceTransformer('BAAI/bge-m3')
# 쿼리 임베딩 생성
query_vector = self.embedding_model.encode(query).tolist()
# 검색 실행
search_result = self.qdrant_client.search(
collection_name=self.collection_name,
query_vector=query_vector,
limit=k,
with_payload=True
)
# 결과 변환
results = []
for result in search_result:
results.append({
'content': result.payload.get('page_content', ''),
'metadata': {k: v for k, v in result.payload.items() if k != 'page_content'},
'score': result.score
})
logger.info(f"babsim Vector DB에서 {len(results)}개 문서 검색 완료")
return results
3) Langgraph - RAG 처리 플로우 코드
g.add_edge("rewrite_query", "generate_rag_response")
g.add_edge("generate_rag_response", "evaluate_answer")
g.add_edge("evaluate_answer", "retry_or_accept")
g.add_conditional_edges(
"retry_or_accept",
route_retry_ok,
{
"handle_llm_fallback": "handle_llm_fallback",
"finalize": "finalize",
},
)
g.add_edge("handle_llm_fallback", "finalize")
3. 텍스트 파이프라인 for 이미지 쿼리 생성 (여기서부터 꽤나 복잡한...파이프라인)
이미지를 생성하기 위해서는 어떻게 이미지를 생성할 것인지 쿼리를 보내야 한다. 사용자가 직접 바로 생성해서 쿼리를 보낼 수 있지만, 자동차를 생성하는데는 고려해야 할 요소들이 매우 많기 때문에 이를 한번에 쿼리로 만들어서 보내는 것은 쉽지 않다. 그래서,아래와 같이 디자인 체크리스트를 받아서 체크리스트 답변들을 바탕으로 이미지 쿼리를 생성해서 이미지 모델로 보내는 방식으로 파이프라인을 구성하였다. 그리고 체크리스트를 채우지 않고 direct로 보낼 수 있는 노드도 있어서 바로 이미지 생성으로 진행될 수 있게끔 만들었다.
이미지 쿼리 생성을 위한 체크리스트 화면
체크리스트를 받는 과정에서 Langgraph의 주요한 기능인 Human in the Loop 방식을 사용했는데,
Human in the Loop란?
AI가 모든 과정을 자동으로 처리하는 것이 아니라, 중요한 의사결정 지점에서 사용자가 개입할 수 있도록 설계된 구조를 의미한다. 사람의 정보 입력과 전문 지식을 머신러닝(ML) 및 인공지능 시스템의 수명 주기에 통합하는 공동작업 접근 방식이다. 사람은 ML 모델의 학습, 평가, 운영에 적극적으로 참여하여 귀중한 가이드, 피드백, 주석을 제공합니다. HITL은 이러한 협업을 통해 사람과 머신의 고유한 기능을 활용하여 ML 시스템의 정확성, 신뢰성, 적응성을 향상하는 것을 목표로 한다.
그래서 처음 invoke를 하고 난 뒤에, interrupt를 걸면 그때 사용자로부터 받은 답변을 그 다음 노드로 넘겨주는 방식이다. 우리 파이프라인 코드들을 보면 다음과 같다. 아래 코드는 이미지 생성을 하고자 할때, 체크리스트를 받아서 할 것인지 혹은 직접 프롬프트를 작성해서 자유형식으로 할 것인지 선택할 수 있도록 사용자의 개입이 이루어진다.
def image_mode(state: PipelineState) -> PipelineState:
"""이미지 생성 방식을 선택하도록 요청"""
print(f"[분기] image_mode 노드 접근")
msg = (
"""이미지 생성을 시작하겠습니다! 🎨
어떤 방식으로 진행하시겠습니까?
1️⃣ **체크리스트 기반 단계별 가이드**
- 11가지 카테고리를 차근차근 채워가며 상세한 디자인 생성
2️⃣ **직접 이미지 생성**
- 원하는 디자인을 자유롭게 설명하면 바로 이미지 생성
- 체크리스트 기반: "체크리스트" 또는 "단계별"
- 바로 생성: "바로" 또는 "직접"
- 건너뛰기: "건너뛰기", "skip", "다음", "next" 를 통해 바로 이미지 생성"""
)
user_query = state.get("user_query", "")
# image_mode 노드 첫 방문 시
if state.get("waiting_node", "") != "image_mode":
interrupt({"query": msg})
else:
print(f"[처리] 이미지 생성 방식 선택 요청 메시지 전송")
return {"user_query": user_query, "response": msg}
그리고 interrupt를 통해 받은 사용자 답변은 checkpointer 를 통해 상태를 저장하고 복원하는 과정을 반복한다.
여기서 checkpoint는 다음과 같은 역할을 한다.
checkpointer의 핵심 역할
1) 상태 저장 (State Persistence)
사용자가 특정 지점에서 개입(HITL)을 하면, 그 시점의 대화 상태(state)를 checkpointer에 기록
예: 사용자가 "체크리스트" 라고 답변하고 멈춘 지점 → 그 시점의 query, context, intermediate outputs 등을 저장.
2) 상태 복원 (State Resume)
사용자가 입력을 마치고 다시 실행하면, checkpointer가 저장된 상태를 불러와서 중단된 시점 이후부터 파이프라인을 이어서 실행 (덕분에 처음부터 다시 돌릴 필요 없이, 멈췄던 부분부터 재개가 가능)
3) 세션 관리 (Thread / Session Continuity)
여러 명이 동시에 쓰거나, 한 사용자가 여러 세션을 진행할 때도 checkpointer가 각각의 상태를 기억
예: MemorySaver(thread_id=thread_id)처럼 설정하면 세션별로 상태가 분리 관리됨.
4) HITL 루프 제어 (Human Feedback Loop)
사람이 개입하는 노드(예: ask_modify, guided_llm_chat)에서 입력을 기다릴 때, 그 지점을 정확히 기록.
이후 사람이 응답을 주면, 기록된 지점에서 Command(resume=state) 형태로 재실행이 가능
그래서 간단하게 실제 동작 흐름은 다음과 같다. 첫 실행 때 checkpointer가 상태를 저장하고, Human input 후 Command(resume=…)로 다시 불러오면서 이어가는 형태다.
checkpointer = MemorySaver()
# 최초 실행 → HITL 지점 도달 → 멈춤
result = pipeline.invoke(initial_state, config=config, checkpointer=checkpointer)
# 사용자가 답변을 준 후 → 저장된 상태 불러와서 재개
pipeline_result = pipeline.invoke(
Command(resume=initial_state),
config=config,
checkpointer=checkpointer
)
4. 이미지 파이프라인
이미지 파이프라인의 경우 수정여부나 어떤 2D, 3D 혹은 video인지에 따라 HITL 방식을 똑같이 적용해서 사용자가 원하는 방식대로 할 수 있게끔 노드를 짰다. 그리고 중요한 것은 모든 과정에서 수정이 필요할 때 마다 수정을 할 수 있게끔 노드를 만들었다는 점이다.
2) 3D, 4D 파이프라인
def run_3d_generation(state: PipelineState) -> Dict[str, Any]:
"""3D 생성 노드"""
print(f"[분기] run_3d_generation 노드 접근")
s3_url = state.get("s3_url", "")
if not s3_url:
return {"error": "이미지 URL이 없습니다."}
# 3D 생성기 사용
result = generator_3d.generate_3d_model(s3_url)
if "error" in result:
return {"response": f"3D 생성 실패: {result['error']}"}
# 결과를 상태에 저장
state["s3_url_3d"] = result["s3_url_3d"]
state["generation_type"] = result["generation_type"]
return {
"s3_url_3d": result["s3_url_3d"],
"generation_type": result["generation_type"],
"response": f"3D 모델이 생성되었습니다! 🎉\n\n3D 모델: {result['s3_url_3d']}",
"waiting_node": "show_3d_4d_result"
}
def run_4d_generation(state: PipelineState) -> Dict[str, Any]:
"""4D 생성 노드"""
print(f"[분기] run_4d_generation 노드 접근")
s3_url = state.get("s3_url", "")
if not s3_url:
return {"error": "이미지 URL이 없습니다."}
# 4D 생성기 사용
result = generator_4d.generate_4d_model(s3_url)
if "error" in result:
return {"response": f"4D 생성 실패: {result['error']}"}
# 결과를 상태에 저장
state["s3_url_4d"] = result["s3_url_4d"]
state["generation_type"] = result["generation_type"]
return {
"s3_url_4d": result["s3_url_4d"],
"generation_type": result["generation_type"],
"response": f"4D 모델이 생성되었습니다! 🎉\n\n4D 모델: {result['s3_url_4d']}",
"waiting_node": "show_3d_4d_result"
}
def show_3d_4d_result(state: PipelineState) -> PipelineState:
"""3D/4D 결과 표시 및 후속 액션 노드"""
generation_type = state.get("generation_type", "")
s3_url_3d = state.get("s3_url_3d", "")
s3_url_4d = state.get("s3_url_4d", "")
if generation_type == "3d" and s3_url_3d:
model_url = s3_url_3d
model_type = "3D"
elif generation_type == "4d" and s3_url_4d:
model_url = s3_url_4d
model_type = "4D"
else:
state["response"] = "생성된 모델이 없습니다."
return state
message = (
f"🎉 {model_type} 모델 생성 완료!\n\n"
f"\n\n"
f"다음 중 하나를 선택해주세요:\n\n"
f"1️⃣ **{model_type} 재생성** - 다른 설정으로 다시 생성\n"
f"2️⃣ **4D 생성** - 애니메이션 모델로 변환 (3D인 경우)\n"
f"3️⃣ **다른 작업** - 이미지 수정이나 새로운 이미지 생성\n"
f"4️⃣ **완료** - 작업 종료\n\n"
f"원하는 옵션을 선택해주세요."
)
user_query = interrupt({"query": message})
return safe_merge_user_query(user_query, response=message)
# 3D/4D 생성 후 결과 표시
g.add_edge("run_3d_generation", "show_3d_4d_result")
g.add_edge("run_4d_generation", "show_3d_4d_result")
g.add_conditional_edges(
"show_3d_4d_result",
route_3d_4d_result_choice,
{
"run_3d_generation": "run_3d_generation",
"run_4d_generation": "run_4d_generation",
"modify_image": "mod_intro",
"image_generation": "image_mode",
"finish": "finalize"
},
)
파이프라인 전체를 다 설명하고 싶지만 주요 파이프라인 코드만 1487줄에다가.. 이외에 components로 만들어둔 파일만 14개 정도 되다보니 다시 봐도 너무 복잡하다.. chatGPT같은 거대한 챗봇 플랫폼들은 이 모든 노드들을 어떻게 처리하는걸까??... 갑자기 너무 대단하게 느껴진다.
전체 파이프라인 1487줄..
우리의 전체 시스템 아키텍처는 아래와 같다. EC2 서버에 올려서 서비스를 배포하게 되는데, 특히 파인튜닝된 모델이나 이외에 다른 모델들을 활용할때는 vLLM endpoint를 활용해서 Runpod에서 모델을 서빙하면 그 모델들을 url 형태로 가져와서 사용할 수 있다.파인튜닝된 Kananaa모델과 Flux 모델은 vLLM 엔드포인트로 연결하고 나머지 3D와 비디오 모델은 백엔드가 ComfyUI (Runpod Serverless Endpoint)를 호출하도록 설계했다. 그리고, 작업 안료 후 산출물(mp4/glb/이미지)을 S3로 업로드하여 저장하도록 하였다.
시스템 아키텍처(React UI → EC2/Backend → 데이터 계층 → 모델 엔드포인트 → Runpod GPU → S3 아웃풋)
1) Human-in-the-Loop 평가 방식의 한계
이번 프로젝트를 돌아보면서 가장 먼저 아쉬움이 남는 부분은 평가 방식이었다. 우리는 Human in the Loop가 잘 적용되고 있는지를 사용자 시나리오를 기반으로 검증했지만, 심사 과정에서 “다른 평가 방식도 가능하지 않았는가?”라는 질문을 받았을 때 충분히 답변하지 못했다. 사용자 경험에 집중한 평가는 의미 있었지만, 정량적인 지표나 비교 실험과 같은 다른 방법을 병행하지 못한 것이 아쉬움으로 남았다. 향후에는 사용자 시나리오뿐만 아니라 nDCG, Top-K accuracy 같은 지표를 함께 적용하고, Human in the Loop 적용 여부에 따라 성능을 비교하는 A/B 테스트도 병행해야겠다고 느꼈다.
2) 창의적 자동차 디자인 생성의 한계
이미지 쿼리를 만드는 과정에서 주로 현대자동차의 기존 모델을 반영하다 보니, 전혀 새로운 디자인을 만들어내기에는 한계가 분명했다. 심사위원분께서 말씀하신 것처럼, “창의적이고 새로운 디자인을 어떻게 이끌어낼 수 있을까?”라는 질문에 대해 더 깊이 고민하지 못한 것이 아쉬웠다. 단순히 기존 모델을 재현하는 데 그치지 않고, Multi-Concept Prompting을 활용해 다른 산업 디자인과 결합하는 방식, 혹은 LoRA와 Prompt Mixing을 통해 참신한 결과물을 만들어낼 수 있었을 것이다. 이 부분은 앞으로 반드시 보완하고 싶은 과제다.
3) 파이프라인 관리·운영의 어려움
마지막으로, 파이프라인 관리 방식에서도 한계를 느꼈다. 현재는 코드를 일렬로 작성해둔 상태라 유지보수와 확장이 쉽지 않았다. 파이프라인을 독립적인 모듈처럼 구성했더라면, 훨씬 더 효율적이고 안정적인 운영이 가능했을 것이다. 예를 들어 vLLM을 엔드포인트로 띄워놓고 API 호출 방식으로 사용하거나, LangGraph나 Docker를 통해 노드 단위로 관리했더라면 지금보다 훨씬 깔끔하게 파이프라인을 운영할 수 있었을 것이다. 결국 이 경험은 앞으로 시스템을 설계할 때는 코드 그 자체보다는 재사용성과 확장성을 고려해 툴처럼 사용할 수 있도록 설계하는 것이 중요하다는 점을 일깨워주었다.
1) 팀워크
우리팀은 그 어떤 누구도 대충하는 사람이 없었다. 다들 욕심도 있고, 그리고 기준도 높다보니깐 어떻게 해서든 계획한바를 제대로 그리고 잘하고 싶어하는 사람들이 모였다. 심지어 나이도 다 비슷하다보니, 편안한 분위기에서 토론도 하고 서로 격려도 해주는 분위기였다. 특히, 우리 PM님은 묵묵히 본인의 일을 해내고 도움이 필요하면 붙어서 바로 도와주곤 했다. 그리고 각자 전담해서 하는 역할들이 명확했던 우리 팀원들 ...! 이미지모델, 3D 모델, Video 모델, 그리고 전체 파이프라인까지 정말 복잡하고도 어려운 과정이었지만 끝까지 놓지 않고 해낸 팀원들이 모두 대단하다. 그래서 우수상이라는 좋은 결과도 있었던 것 같다..!! 밥심 취뽀하자~!
![]() | ![]() |
|---|
2) 배우고자 하는 자세!
프로젝트를 하면서 수업 시간에 배웠던 내용들을 최대치로 활용하려고 노력했다. Family AI 캠프 커리큘럼에서 프로젝트가 무려 5번이나 있는건, 배운 내용을 제대로 적용해볼 수 있는 경험의 장을 만들어주신거라고 생각했다. 그래서, 수업을 들으면서 정리해놨던 내용들을 다시 꺼내보고, 코드를 짜기를 반복했던 것 같다. 만약 수업 때 배웠던 것 이외에 문제에 직면하면 많은 부분을 강사님께 여쭤보았다. 우리 강사님께서 정말 너무 친절하시고,,, 심지어 이해하기 쉽게 알려주셔서 프로젝트 내내 너무 많은 도움을 받았다. .드를 짜는 과정에서 어려움도 많고 오류를 해결하기에 시간이 부족할 때마다 강사님께서는 해답에 가깝게 갈 수 있게끔 퍼실리테이터로서의 역할을 끊임없이 해주셨다. (우리 기수는 강사님 운이 정말 좋은것 같다 !)
결론적으로, 이번 프로젝트를 통해, 개발하는 과정에서 배우고자 하는 자세와 끈기만 있다면 주변 자원들을 모두 활용해서 어려운 문제들도 무조건 해결할 수 있다는 믿음이 생겼다. 오류가 해결되지 않는다면.. 너무 답답하겠지만 언젠간 해결이 될거라는 믿음을 가져보자!
3) 바이브코딩 시대! 우리가 갖추어야할 역량
이번 프로젝트를 통해 깨달은 또 하나의 중요한 점은, 우리가 무엇을 개발하는지 명확히 이해하고 그에 맞는 기술을 선택할 수 있는 판단력이 필요하다는 것이었다. 새로운 기술을 빠르게 적용하는 것도 중요하지만, 그것이 실제로 우리의 목표와 맞아떨어지는지, 문제 해결에 기여하는지 끊임없이 되묻는 태도가 더 중요하다는 사실을 경험을 통해 배웠다.
특히 요즘처럼 AI가 코드 작성과 구현을 빠르게 대신해주는 바이브 코딩 시대에는 단순히 코드를 작성하는 능력보다, 전체 그림을 짜는 능력이 더욱 큰 가치를 지닌다. 어떤 데이터를 활용하고, 어떤 모델을 적용하며, 어떻게 아키텍처를 구성할 것인지 큰 틀을 설계하는 일은 여전히 인간의 손길이 필요한 부분이다. AI가 제안한 코드를 그대로 쓰는 것이 아니라, 그 코드가 전체 시스템 속에서 어떤 의미를 갖는지 파악하고 맥락에 맞게 조정하는 능력이 필요하다.
또한 모델이 낸 결과가 정말 유의미한지, 사람에게 어떤 가치를 주는지 판단하는 것은 결국 사람이 맡아야 하는 역할이었다. 정량적인 지표와 정성적인 사용자 평가를 함께 바라보며, 더 나은 개선 방향을 찾아내는 능력이 중요하다는 것을 이번 경험으로 확인할 수 있었다.
결국 바이브 코딩 시대에 우리가 키워야 할 역량은 단순한 코딩 기술이 아니라, 무엇을 만들 것인지 정의하고, 적절한 기술을 선택하며, 전체 그림을 조율하고, 납득할 수 있는 유의미한 결과를 내는 능력이다. 이러한 역할은 앞으로도 인간이 반드시 맡아야 할 부분이며, 이번 프로젝트는 그 점을 더욱 분명하게 일깨워주는 계기가 되었다.