From 82c46f4be462888895e98ae38cc7906417464336 Mon Sep 17 00:00:00 2001 From: how2ice Date: Thu, 28 May 2026 12:45:36 +0900 Subject: [PATCH] chore: test deploy snapshot --- .../server-command/restart-work-server.sh | 86 +- .../work-server/data/e-reader-library.json | 822 ++++++++- .../work-server/src/routes/chat.test.ts | 77 +- etc/servers/work-server/src/routes/chat.ts | 307 +++- .../services/chat-share-room-map-service.ts | 85 + .../src/services/plan-schedule-service.ts | 347 ++-- .../services/server-command-service.test.ts | 109 ++ .../src/services/server-command-service.ts | 91 +- .../server-restart-reservation-service.ts | 5 + .../work-server/src/workers/plan-worker.ts | 6 + .../mainChatPanel/ChatConversationView.tsx | 30 +- src/app/main/mainChatPanel/ChatPromptCard.tsx | 18 +- .../ChatStructuredPreviewCard.tsx | 111 ++ src/app/main/mainChatPanel/chatUtils.ts | 210 ++- src/app/main/mainChatPanel/messageParts.ts | 100 +- .../styles/MainChatPanel.conversation.css | 79 + src/app/main/mainChatPanel/types.ts | 46 +- src/app/main/pages/ChatSharePage.css | 422 +++++ src/app/main/pages/ChatSharePage.tsx | 1600 +++++++++++++++-- .../MainChatPanel.conversation.css | 59 + .../serverCommand/ServerCommandPage.tsx | 2 +- 21 files changed, 4163 insertions(+), 449 deletions(-) create mode 100644 src/app/main/mainChatPanel/ChatStructuredPreviewCard.tsx diff --git a/etc/commands/server-command/restart-work-server.sh b/etc/commands/server-command/restart-work-server.sh index dbc760f..1297b10 100755 --- a/etc/commands/server-command/restart-work-server.sh +++ b/etc/commands/server-command/restart-work-server.sh @@ -180,8 +180,14 @@ const payload = { recoveredRequeuedCount: parseCount(env.DEPLOY_RECOVERED_REQUEUED_COUNT_VALUE) ?? (!shouldResetForNewRun ? parseCount(current.recoveredRequeuedCount) : null), - lastError: env.DEPLOY_LAST_ERROR || (!shouldResetForNewRun ? current.lastError : null) || null, - logExcerpt: env.DEPLOY_LOG_EXCERPT || (!shouldResetForNewRun ? current.logExcerpt : null) || null, + lastError: + env.DEPLOY_STATUS === 'completed' + ? null + : env.DEPLOY_LAST_ERROR || (!shouldResetForNewRun ? current.lastError : null) || null, + logExcerpt: + env.DEPLOY_STATUS === 'completed' + ? null + : env.DEPLOY_LOG_EXCERPT || (!shouldResetForNewRun ? current.logExcerpt : null) || null, steps: stepKeys.map((stepKey) => stepsByKey.get(stepKey)), }; fs.writeFileSync(filePath, JSON.stringify(payload) + '\n', 'utf8'); @@ -278,20 +284,50 @@ server { EOF2 } -wait_for_container_health() { +wait_for_container_runtime_ready() { TARGET_CONTAINER="$1" + TARGET_SLOT="$2" ATTEMPT=0 + STABLE_SUCCESS_COUNT=0 - while [ "$ATTEMPT" -lt 60 ]; do - if docker exec "$TARGET_CONTAINER" node -e "fetch(process.argv[1]).then(async (response) => { if (!response.ok) process.exit(1); process.stdout.write(await response.text()); }).catch(() => process.exit(1));" "$HEALTH_ENDPOINT" >/dev/null 2>&1; then - return 0 + while [ "$ATTEMPT" -lt 90 ]; do + if docker exec "$TARGET_CONTAINER" node -e "const validatePayload = (payload, expectedSlot, requireSlot) => { if (payload?.ok !== true) process.exit(1); if (requireSlot && payload?.slot !== expectedSlot) process.exit(1); if (payload?.draining === true || payload?.canAcceptNewChatRequests === false) process.exit(1); }; Promise.all([fetch(process.argv[1]), fetch(process.argv[2])]).then(async ([healthResponse, runtimeResponse]) => { if (!healthResponse.ok || !runtimeResponse.ok) process.exit(1); const healthPayload = await healthResponse.json(); const runtimePayload = await runtimeResponse.json(); validatePayload(healthPayload, process.argv[3], true); validatePayload(runtimePayload, process.argv[3], false); }).catch(() => process.exit(1));" "$HEALTH_ENDPOINT" "$RUNTIME_ENDPOINT" "$TARGET_SLOT" >/dev/null 2>&1; then + STABLE_SUCCESS_COUNT=$((STABLE_SUCCESS_COUNT + 1)) + if [ "$STABLE_SUCCESS_COUNT" -ge 3 ]; then + return 0 + fi + else + STABLE_SUCCESS_COUNT=0 fi ATTEMPT=$((ATTEMPT + 1)) - sleep 2 + sleep 1 done - echo "health check failed for $TARGET_CONTAINER" >&2 + echo "runtime readiness check failed for $TARGET_CONTAINER slot $TARGET_SLOT" >&2 + return 1 +} + +wait_for_proxy_slot_health() { + TARGET_SLOT="$1" + ATTEMPT=0 + STABLE_SUCCESS_COUNT=0 + + while [ "$ATTEMPT" -lt 90 ]; do + if node -e "const validatePayload = (payload, expectedSlot, requireSlot) => { if (payload?.ok !== true) process.exit(1); if (requireSlot && payload?.slot !== expectedSlot) process.exit(1); if (payload?.draining === true || payload?.canAcceptNewChatRequests === false) process.exit(1); }; Promise.all([fetch(process.argv[1]), fetch(process.argv[2])]).then(async ([healthResponse, runtimeResponse]) => { if (!healthResponse.ok || !runtimeResponse.ok) process.exit(1); const healthPayload = await healthResponse.json(); const runtimePayload = await runtimeResponse.json(); validatePayload(healthPayload, process.argv[3], true); validatePayload(runtimePayload, process.argv[3], false); }).catch(() => process.exit(1));" "$HEALTH_ENDPOINT" "$RUNTIME_ENDPOINT" "$TARGET_SLOT" >/dev/null 2>&1; then + STABLE_SUCCESS_COUNT=$((STABLE_SUCCESS_COUNT + 1)) + if [ "$STABLE_SUCCESS_COUNT" -ge 3 ]; then + return 0 + fi + else + STABLE_SUCCESS_COUNT=0 + fi + + ATTEMPT=$((ATTEMPT + 1)) + sleep 1 + done + + echo "proxy runtime readiness check failed for slot $TARGET_SLOT via $HEALTH_ENDPOINT" >&2 return 1 } @@ -374,28 +410,28 @@ else exit "$BUILD_STATUS" fi -write_deploy_state running verify-target-health "새 슬롯 health 확인을 진행합니다." "verify-target-health" running "대상 컨테이너 ${TARGET_CONTAINER}" -if wait_for_container_health "$TARGET_CONTAINER"; then - write_deploy_state running verify-target-health "새 슬롯 health 확인이 완료되었습니다." "verify-target-health" completed "대상 슬롯 ${TARGET_SLOT} 정상 응답" +write_deploy_state running verify-target-health "새 슬롯 API 준비 상태를 확인합니다." "verify-target-health" running "대상 컨테이너 ${TARGET_CONTAINER}" +if wait_for_container_runtime_ready "$TARGET_CONTAINER" "$TARGET_SLOT"; then + write_deploy_state running verify-target-health "새 슬롯 API 준비 상태 확인이 완료되었습니다." "verify-target-health" completed "대상 슬롯 ${TARGET_SLOT} health/runtime 정상 응답" else - LAST_DEPLOY_ERROR="새 슬롯 health 확인에 실패했습니다." - LAST_DEPLOY_LOG="health check failed for ${TARGET_CONTAINER}" - write_deploy_state failed failed "새 슬롯 health 확인에 실패했습니다." "verify-target-health" failed "대상 슬롯 ${TARGET_SLOT} health 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG" + LAST_DEPLOY_ERROR="새 슬롯 API 준비 상태 확인에 실패했습니다." + LAST_DEPLOY_LOG="runtime readiness check failed for ${TARGET_CONTAINER}" + write_deploy_state failed failed "새 슬롯 API 준비 상태 확인에 실패했습니다." "verify-target-health" failed "대상 슬롯 ${TARGET_SLOT} health/runtime 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG" exit 1 fi write_deploy_state running switch-proxy "프록시를 새 슬롯으로 전환합니다." "switch-proxy" running "활성 ${ACTIVE_SLOT} -> 대상 ${TARGET_SLOT}" write_proxy_config "$TARGET_SLOT" -if ensure_proxy_running; then - write_deploy_state running switch-proxy "프록시 전환을 완료했습니다." "switch-proxy" completed "활성 ${ACTIVE_SLOT} -> 대상 ${TARGET_SLOT}" +if ensure_proxy_running && wait_for_proxy_slot_health "$TARGET_SLOT"; then + printf '%s\n' "$TARGET_SLOT" >"$ACTIVE_SLOT_FILE" + ACTIVE_SLOT="$TARGET_SLOT" + write_deploy_state running switch-proxy "프록시 전환을 완료했습니다." "switch-proxy" completed "프록시 3100 -> 대상 슬롯 ${TARGET_SLOT} 안정 응답 확인" else LAST_DEPLOY_ERROR="프록시 전환에 실패했습니다." - LAST_DEPLOY_LOG="nginx reload failed" - write_deploy_state failed failed "프록시 전환에 실패했습니다." "switch-proxy" failed "활성 ${ACTIVE_SLOT} -> 대상 ${TARGET_SLOT}" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG" + LAST_DEPLOY_LOG="nginx reload or proxy health verification failed" + write_deploy_state failed failed "프록시 전환에 실패했습니다." "switch-proxy" failed "대상 슬롯 ${TARGET_SLOT} 프록시 응답 확인 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG" exit 1 fi -printf '%s\n' "$TARGET_SLOT" >"$ACTIVE_SLOT_FILE" -ACTIVE_SLOT="$TARGET_SLOT" if [ "$PREVIOUS_SERVICE" != "$TARGET_SERVICE" ]; then set_container_draining "$PREVIOUS_CONTAINER" true @@ -420,12 +456,12 @@ if [ "$PREVIOUS_SERVICE" != "$TARGET_SERVICE" ]; then exit "$PREVIOUS_BUILD_STATUS" fi - if wait_for_container_health "$PREVIOUS_CONTAINER"; then - write_deploy_state running rebuild-previous-slot "이전 슬롯을 대기 슬롯으로 복구했습니다." "rebuild-previous-slot" completed "대기 슬롯 ${PREVIOUS_SLOT} 정상 응답" + if wait_for_container_runtime_ready "$PREVIOUS_CONTAINER" "$PREVIOUS_SLOT"; then + write_deploy_state running rebuild-previous-slot "이전 슬롯을 대기 슬롯으로 복구했습니다." "rebuild-previous-slot" completed "대기 슬롯 ${PREVIOUS_SLOT} health/runtime 정상 응답" else - LAST_DEPLOY_ERROR="이전 슬롯 복구 health 확인에 실패했습니다." - LAST_DEPLOY_LOG="health check failed for ${PREVIOUS_CONTAINER}" - write_deploy_state failed failed "이전 슬롯 복구 health 확인에 실패했습니다." "rebuild-previous-slot" failed "대기 슬롯 ${PREVIOUS_SLOT} health 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG" + LAST_DEPLOY_ERROR="이전 슬롯 복구 API 준비 상태 확인에 실패했습니다." + LAST_DEPLOY_LOG="runtime readiness check failed for ${PREVIOUS_CONTAINER}" + write_deploy_state failed failed "이전 슬롯 복구 API 준비 상태 확인에 실패했습니다." "rebuild-previous-slot" failed "대기 슬롯 ${PREVIOUS_SLOT} health/runtime 실패" "$LAST_DEPLOY_ERROR" "$LAST_DEPLOY_LOG" exit 1 fi fi diff --git a/etc/servers/work-server/data/e-reader-library.json b/etc/servers/work-server/data/e-reader-library.json index d434c2c..43bba8e 100644 --- a/etc/servers/work-server/data/e-reader-library.json +++ b/etc/servers/work-server/data/e-reader-library.json @@ -1,5 +1,774 @@ { "items": [ + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-ae00e38b1f4aa3f6", + "title": "[포토] 삼성전자 파업 유보", + "sourceLabel": "전자신문", + "url": "https://n.news.naver.com/mnews/article/030/0003429855", + "lead": "20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 손을 맞잡고 있다.", + "body": "20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 손을 맞잡고 있다.", + "htmlBody": "
\"기사

20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 손을 맞잡고 있다.

김민수 기자 mskim@etnews.com

", + "tags": [ + "네이버뉴스", + "IT/과학" + ], + "publishedAt": "2026-05-20T14:45:09.000Z" + }, + "createdAt": "2026-05-28T03:42:02.566Z", + "updatedAt": "2026-05-28T03:42:02.566Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-3c55deedc156cd74", + "title": "[속보]주한美대사 후보 \"北 많은이들 고통…한미일 강력동맹 필요\"", + "sourceLabel": "뉴시스", + "url": "https://n.news.naver.com/mnews/article/003/0013958253", + "lead": "뉴시스 언론사 구독되었습니다. 메인 뉴스판에서 주요뉴스를 볼 수 있습니다. 보러가기 닫기", + "body": "뉴시스 언론사 구독되었습니다. 메인 뉴스판에서 주요뉴스를 볼 수 있습니다. 보러가기 닫기\n\n[속보]주한美대사 후보 \"北 많은이들 고통…한미일 강력동맹 필요\"\n\n이동 통신망을 이용하여 음성을 재생하면 별도의 데이터 통화료가 부과될 수 있습니다.\n\nCopyright ⓒ 뉴시스. All rights reserved. 무단 전재 및 재배포 금지.\n\n이 기사는 언론사에서 세계 , 정치 섹션으로 분류했습니다.\n\n기사 섹션 분류 안내 기사의 섹션 정보는 해당 언론사의 분류를 따르고 있습니다. 언론사는 개별 기사를 2개 이상 섹션으로 중복 분류할 수 있습니다.\n\n뉴시스 구독하고 메인에서 바로 만나보세요! 구독하고 메인에서 만나보세요!\n\n언론사의 주요 뉴스를 메인에서 바로 만나보세요! 주요 뉴스를 메인에서 만나보세요!\n\n주요뉴스 해당 언론사에서 선정하며 언론사(아웃링크) 로 이동합니다.\n\n'김수현 명예훼손 혐의' 김세의, 26일 법원 구속 심문\n\n'노무현 전대통령 모욕' 리치이기, 페스티벌서도 퇴출\n\n정용진도 사과했는데…최준용 \"커피는 스벅, 멸공커피\"\n\nMC몽, 또 '연예인 실명' 폭로…백현에겐 사과\n\n네이버 메인에서 뉴시스 구독하세요 QR 코드를 클릭하면 크게 볼 수 있어요.\n\nQR을 촬영해보세요. 네이버 메인에서 뉴시스 구독하세요\n\n스타들이 빛나는 순간엔 'N샷' QR 코드를 클릭하면 크게 볼 수 있어요.\n\nQR을 촬영해보세요. 스타들이 빛나는 순간엔 'N샷'\n\n'내란 선전' 이은우 전 KTV 원장, 구속 갈림길…종합특검 1호 영장\n\n\"서울서 전셋집 못 구해 경기로\"…서울 전세난, 경기권으로 '확산'\n\n양향자, 삼성 노조 총파업 유보에 단식농성 잠정 중단\n\n美연준 의사록 \"이란전쟁發 高인플레 지속시 금리 인상\"\n\n김용현 '기피 심리 재판부'도 기피 신청…헌재엔 헌법소원 제기(종합)\n\n기사 추천은 24시간 내 50회까지 참여할 수 있습니다.\n\n서비스 정책에 따라 정치 섹션이 포함된 기사의 본문 하단에는 댓글을 제공하지 않습니다.\n\n삼성전자 '극적 타결'에도 남은 후폭풍…산업계 '과도한 성과급 요구' 도미노 우려\n\n양향자, 삼성 노조 총파업 유보에 단식농성 잠정 중단\n\n공주·부여·청양, 김영빈 38.8% 윤용근 42.4%[에이스리서치]\n\n삼성전자 노사, 한발씩 양보해 상생해법 도출…총파업 파국 피했다\n\n[속보] 삼성전자 노조 \"21일 이후 총파업 기간 중에도 타결 위해 노력\"\n\n\"非반도체 배제하나\" 수면 위 드러난 삼성전자 '노노(勞勞)갈등' 봉합할까\n\n네이버 AI 뉴스 알고리즘 뉴스 추천 알고리즘이 궁금하다면?\n\n한국인 탄 구호선 또 나포·감금… 李 “네타냐후 체포영장 검토하라”\n\n靑 “李대통령 ‘배임죄 폐지-완화’ 확고한 입장”\n\n李, 한인 나포 비판하며 \"네타냐후 체포영장 판단해야\"…野 \"푸틴도 체포할건가\"\n\n이 대통령 \"장애인권리보장법, 참 오래 걸렸다…차별 없는 세상 첫걸음\"\n\n李, 한인 나포 비판하며 \"네타냐후 체포영장 판단해야\"…野 \"푸틴도 체포할건가\"\n\n삼전 노사 합의 불발에 \"선 넘지 마라\" 경고장 날린 李대통령\n\n이란 “美 새 종전안 검토…모든 전선 전쟁 끝내는 데 집중”\n\n이란 “미국 제안한 새 종전안 검토중…자산동결 해제·봉쇄 중단 먼저”\n\n\"이란에 마지막 기회\"‥\"봉쇄 중단이 먼저\"\n\n인천교육감 후보 공약 '3인 3색'…진보 2명·보수 1명 '3파전'\n\n吳, 새벽 배추 나르며 선거운동 시작…\"땀흘려 일하는 분들이 존중받는 서울\"\n\n서울 사는 47세 싱글 남입니다, 제 가족을 보장해 주세요\n\n공식 선거운동 개시…8파전 서울교육감 후보 유세 '다양'\n\n첫날, 정청래도 장동혁도 충청으로 달려간 이유…'중원 잡으면 전국 잡는다'\n\n대통령·장관·당대표까지… 선거 앞두고 연일 스타벅스 때리기\n\n주한미대사 후보자 \"한국 내 미국 기업 차별 안 돼\"\n\n이란 “美 새 종전안 검토…모든 전선 전쟁 끝내는 데 집중”\n\n이란 “미국 새 종전안 검토 중”…미군, 봉쇄 돌파 시도 이란 유조선 저지\n\n인천교육감 후보 공약 '3인 3색'…진보 2명·보수 1명 '3파전'\n\n[6·3 지방선거-인터뷰] 김부겸 “대구, 앞으로 4년이 골든타임… 정부·여당 지원 끌어오겠다”\n\n[6·3 픽] 양향자, 단식 4일차 새벽 전격 병원 이송…장동혁 \"몸 추슬러야\" 설득\n\n이재명 대통령의 SNS는 칼...수술 도구로만 쓰자\n\n장고 끝에 佛 G7 정상회의 가는 트럼프 [김태훈의 의미 또는 재미]\n\n[이충재의 인사이트] 한동훈, '동탄 이준석' 되기 쉽지 않은 이유\n\n이재명 대통령의 SNS는 칼...수술 도구로만 쓰자\n\n서울 사는 47세 싱글 남입니다, 제 가족을 보장해 주세요\n\n다카이치 “술 마시는 것 고민”… 李대통령 “하루 더 머물도록 해볼까요” [청와대 스케치]\n\n시몬스, AI 걷어낸 '영화' 같은 '광고'로 눈길\n\n금융보안원, 日 금융당국·앤트로픽 만나 AI 보안 협력 논의\n\n삼성전자 총파업 D-1…21년 만에 긴급조정권 발동되나\n\n이 기사를 본 이용자들이 함께 많이 본 기사, 해당 기사와 유사한 기사, 관심 기사 등을 자동 추천합니다\n\n[속보]삼성전자 반도체 사업성과 10.5% 상한 없이 전액 자사주 지급···임금인상률 6.2%\n\n“중국인 제발 좀 그만 와” 하더니…‘쑥’ 빠진 관광객에 잘 나가던 일본 관광 어쩌나\n\n‘그래도 믿어야 해?’ 주가 와르르 무너지는데…57만전자·380만닉스까지 나왔다, 왜? [투자360]\n\n“내연남 집에 아내 누드사진, 못참았습니다”…이 남편 동정못한 까닭 [2026 칸영화제]\n\n“연봉 1억이면 성과급 최대 6억”…삼성전자 DS, 보상 규모 얼마나 되나\n\n[단독]이스라엘에 붙잡힌 ‘가자지구 항해’ 한국인들 “팔레스타인과 더 깊이 연대해야”\n\n[단독] 국민의힘 전 대변인, 5·18 당일 '광주 계엄군' 옹호\n\n의류업체 ‘망고’ 창업주 추락사… 용의자는 아들, 2년만에 체포\n\n삼성전자 메모리사업부 직원, 특별성과급 6억 넘게 받는다\n\n양향자, 삼성 노조 총파업 유보에 단식농성 잠정 중단\n\n'쥴리 의혹' 부인한 김건희 \"내 영어 이름은 제니\"\n\n“뱃살 나잇살 다 빠졌죠”… ‘22kg 감량’ 50대 주부, 요요 없는 비결\n\n삼성전자 노사 “적자사업 성과 배분방식 쟁점 잘 마무리”\n\n30년 넘게 가족처럼 지냈는데…남편 죽자 시동생들 “앞으로 연락하지 마” 충격\n\n李 대통령 \"너무 많이 인내\" 작심비판... 이준석 \"자중하시라\"\n\n“마취 후 퇴근한 의사…아내 식물인간” 프리랜서 마취의·집도의 고소에 경찰 수사 착수\n\n‘파업 전날 극적 타결’ 삼성전자 메모리 1인당 6억원…적자 사업부도 최소 1.6억\n\n삼성전자 '극적 타결'에도 남은 후폭풍…산업계 '과도한 성과급 요구' 도미노 우려\n\n삼성전자 노사, 한발씩 양보해 상생해법 도출…총파업 파국 피했다\n\n\"주가 떨어질까 밤잠 설쳤는데\"…삼성전자 극적 타결에 개미들 안도\n\n이 대통령, 이스라엘 한국인 나포에 \"도 지나쳐\"…靑 \"국민 안전 강조 차원\"(종합)\n\n삼성전자 총파업 유보…노동장관 \"노사가 한발씩 양보\"(종합)\n\n삼성전자, 반도체 특별성과급 10년간 '전액 자사주' 지급…\"올해 메모리 1인당 6억 추산\"\n\n두 아이 엄마, 수영장 다이빙 강습 중 전신마비\n\n\"10년 전 1억 투자…집 샀다\" 소유도 담은 '이 종목' 정체는\n\n‘피범벅’ 머리만 잘라 바다에 휙…“상어 도살장, 선원도 울더라”\n\n삼성전자 총파업 멈췄다…손잡은 노사, 성과급 개편안 전격 합의\n\n민주당, 스타벅스 출입 금지령‥침묵하던 국힘 \"잘못된 행동\"\n\n“전자레인지 3분 돌렸더니”…플라스틱 용기 실험서 나노플라스틱 최대 21억개\n\n대통령·장관·당대표까지… 선거 앞두고 연일 스타벅스 때리기\n\n28조 뭉칫돈 넣은 개미들 '환호'…또 시장 예상 넘었다\n\n\"믿었는데 너무 실망\" 80%가 문제였다…안전·효과 떨어진다는 자외선 차단\n\n‘탱크 텀블러’ 든 전두환…“스벅 돈쭐내자” 5·18 조롱 콘텐트 기승\n\n삼성전자 DS '성과급 6억' 시대…적자 사업부도 최소 1.6억\n\n\"40억 건물 대신 '삼전닉스' 샀더라면\"…속내 밝힌 '건물주' 이해인\n\n“지점장부터 90도 인사”…전원주, 하이닉스 ‘초대박’ 근황\n\n삼성전자 '극적 타결'에도 남은 후폭풍…산업계 '과도한 성과급 요구' 도미노 우려\n\n공주·부여·청양, 김영빈 38.8% 윤용근 42.4%[에이스리서치]\n\n北매체, '내고향-韓 수원' 경기 첫 보도…\"이기고 결승 진출\"\n\n세계 최초 수어 아이돌 빅오션, 열린관광 홍보 나선다\n\n삼성전자, 반도체 특별성과급 10년간 '전액 자사주' 지급…\"올해 메모리 1인당 6억 추산\"\n\n스타벅스 든 전두환…5·18 조롱 콘텐츠 기승\n\n기사배열 책임자 : 김수향 청소년 보호 책임자 : 이정규\n\n각 언론사가 직접 콘텐츠를 편집합니다. ⓒ 뉴시스\n\n이 콘텐츠의 저작권은 저작권자 또는 제공처에 있으며, 이를 무단 이용하는 경우 저작권법 등에 따라 법적 책임을 질 수 있습니다.", + "htmlBody": "

var svt = "20260521080635.351"; var timestamp = svt.substr(0, 8); var isLogin = false; var uhv = "";

var service = { news: false, entertain: false, sports: false, mnews: true, newsType: true }; var serviceName = "mnews";

var nilNtmUrl = "https://ntm.pstatic.net/scripts/ntm_b7032129a433.js";

var envPhase = "production"; var isProduction = true;

var gnb_service = "news"; var gnb_logout = encodeURIComponent(location.href); var gnb_template = "gnb_utf8"; var gnb_brightness = 1; var gnb_item_hide_option = 128;

var isDegradeMode = false; var isDegradeN2Mode = false;

\"뉴시스\"

뉴시스 언론사 구독되었습니다.\n메인 뉴스판에서 주요뉴스를\n볼 수 있습니다.\n보러가기\n닫기

뉴시스 언론사 구독 해지되었습니다.\n닫기

[속보]주한美대사 후보 "北 많은이들 고통…한미일 강력동맹 필요"

입력 2026.05.20. 오후 11:56

수정 2026.05.20. 오후 11:57

텍스트 음성 변환 서비스 사용하기

이동 통신망을 이용하여 음성을 재생하면 별도의 데이터 통화료가 부과될 수 있습니다.

\"기사

Copyright ⓒ 뉴시스. All rights reserved. 무단 전재 및 재배포 금지.

이 기사는 언론사에서 세계 , 정치 섹션으로 분류했습니다.

기사의 섹션 정보는 해당 언론사의 분류를 따르고 있습니다. 언론사는 개별 기사를 2개 이상 섹션으로 중복 분류할 수 있습니다.

뉴시스 구독하고 메인에서 바로 만나보세요! 구독하고 메인에서 만나보세요!

언론사의 주요 뉴스를 메인에서 바로 만나보세요! 주요 뉴스를 메인에서 만나보세요!

뉴시스\n\n주요뉴스 해당 언론사에서 선정하며 언론사(아웃링크) 로 이동합니다.

뉴시스 언론사가 직접 선정한 이슈

window.__htExternalUrl = window.__htExternalUrl || {}; window.__htExternalUrl["like"] = "https://static-like.pstatic.net/js/reaction/dist/reaction.min.js"; window.__htLikeOption = { type: "multi", cssId: "news", domain: "https://news.like.naver.com", staticDomain: "https://static-like.pstatic.net", isMobile: true, isHiddenLabel: false, isHiddenCount: false, isHiddenZeroCount: false, isUsedLabelAsZeroCount: false, isDebugMode: false, isDuplication: false, isHiddenLayerAfterSelection: true, clicklog: function(el, name) { nclkWrap.send({ $this: $(el), sArea: name }); } }; window.__htLikeOption["isZeroFace"] = true;

기사 추천은 24시간 내 50회까지 참여할 수 있습니다.

window.__htExternalUrl = window.__htExternalUrl || {}; window.__htExternalUrl["share"] = "https://ssl.pstatic.net/spi/js/release/ko_KR/splugin.js"; window.__htShareOption = { "scriptUrl": window.__htExternalUrl["share"], "evKey": "news", "dimmed": "fixed", "serviceName": "뉴스", "sourceName": "" }; if (isAvailableDarkMode) { window.__htShareOption["darkMode"] = true; }

window.__htExternalUrl = window.__htExternalUrl || {}; window.__htExternalUrl["tip"] = "https://ssl.pstatic.net/dicimg/tip/tip_m.js"; window.__htExternalUrl["pcTip"] = "https://ssl.pstatic.net/dicimg/tip/tip_pc.js";

window.__htExternalUrl["handOff"] = { "category": "news_article", "apiDomain": "https://sidekick.fever.naver.com" };

\"섬네일

스티커 하나만 모아도 네이버페이 행운이!

선거 특집페이지에서 출석 스티커 받고, 최대 15,000P의 행운을 기대해요!

window.__htExternalUrl = window.__htExternalUrl || {}; window._commentCountApi = "/article/template/COMMENT_COUNT";

서비스 정책에 따라 정치 섹션이 포함된 기사의 본문 하단에는 댓글을 제공하지 않습니다.

오전 6시~7시까지 집계한 결과입니다.

\"기사

네이버 AI 뉴스 알고리즘 뉴스 추천 알고리즘이 궁금하다면?

언론사에서 직접 선별한 이슈입니다.

13개 언론사 26,935개 기사

10개 언론사 22,355개 기사

18개 언론사 16,025개 기사

43개 언론사 24,218개 기사

22개 언론사 23,866개 기사

이 기사를 본 이용자들이 함께 많이 본 기사, 해당 기사와 유사한 기사, 관심 기사 등을 자동 추천합니다

자정~오전 7시까지 집계한 결과입니다.

로그인\n\n전체서비스\n\n서비스안내\n\n오류신고\n\n고객센터\n\n기사배열 책임자 : 김수향\n청소년 보호 책임자 : 이정규\n\n각 언론사가 직접 콘텐츠를 편집합니다.\nⓒ 뉴시스\n\n이 콘텐츠의 저작권은 저작권자 또는 제공처에 있으며, 이를 무단\n이용하는 경우 저작권법 등에 따라 법적 책임을 질 수 있습니다.\n\nNAVER

{{#series}} {{value}} 연재 구독을 취소하시겠습니까? {{/series}} {{#journalist}} {{value}} 기자의 기사 구독을 취소하시겠습니까? {{/journalist}}

{{officeName}} 구독이 완료되었습니다. 알림 으로 받아 보시겠습니까?

{{officeName}} 구독이 완료되었습니다. 알림 으로 받아 보시겠습니까?

네이버 앱 푸시알림이 꺼져있어요.

네이버 앱 알림켜기 > 알림탭 > 알림허용

버튼을 눌러 앱 알림 설정을 켜주세요.

{{#content.image}}

{{#content.image.class}}

{{/content.image.class}}

{{/content.image}} {{#isTextType}}

{{#content.image}}

{{#content.image.class}}

{{/content.image.class}}

{{/content.image}} {{#isTextType}} {{content.title}}

{{/isTextType}} {{#isTitleType}}

{{content.headTitle}} {{content.title}}

{{content.description}}

{{content.cancelMessage}}

{{content.confirmMessage}}

{{toastTitle}} {{toastMessage}}

{{officeName}} 언론사 알림이 설정되었습니다.{{#isSupportedNaverappNotificationSetting}} 네이버앱 전체 푸시 알림도 활성화된 상태입니다.{{/isSupportedNaverappNotificationSetting}}

window.ntm = window.ntm || []; var ntmOption = {};

var section = {"id":"104","name":"세계"}; var redirectSection = {"id":"104","name":"세계"};

ntmOption["channelId"] = article.officeId;

// 기타 또는 주요 섹션 이름 var nilSendSectionName = "기타"; var nilSendSectionId = ""; if (/100|101|102|103|104|105|106|107/.test(section.id)) { nilSendSectionName = section.name; nilSendSectionId = section.id; }

// 기사 타입 var htArticleTypeName = { "0": "text", "1": "photo", "2": "tv" }; var articleTypeName = htArticleTypeName[article.type] || "";

ntmOption["hitType"] = "cv"; ntmOption["eventCategory"] = "post_view"; ntmOption["uri"] = "http://news.naver.com/main/read.nhn?oid=003&aid=0013958253"; ntmOption["gdid"] = article.gdid; ntmOption["dimension1"] = encodeURIComponent(nilSendSectionName); ntmOption["dimension4"] = articleTypeName; ntmOption["optional2"] = nilSendSectionId;

var journalistIdList = [];

// 기자 목록 $("._JOURNALIST_CARD ._JOURNALIST_ID").each(function (index, object) { var $this = $(object); var journalistId = $this.attr("data-journalistId");

if (journalistId && journalistIdList.indexOf(journalistId) === -1) { journalistIdList.push(journalistId); } });

if (journalistIdList.length > 0) { ntmOption["dimension3"] = journalistIdList.join("___"); }

if (location.href.match(/ntype=RANKING/)) { ntmOption["dimension5"] = "y"; }

if (service.sports || service.newsType) { ntmOption["dimension2"] = article.sectionInfo.secondSection + "." + article.sectionInfo.thirdSection; }

var seriesComponentId = $("._VALID_SERIES").attr("data-component-id"); ntmOption["dimension6"] = seriesComponentId;

var issueComponentId = $("._ISSUE_ARTICLE_INFO").attr("data-component-id"); ntmOption["dimension8"] = issueComponentId;

if (/[?|&]type=breakingnews(&|$)/.test(location.href) === true) { if (/(m\\.naver\\.com)|(media\\.naver\\.com)|(news\\.naver\\.com\\/?$)|(www\\.naver\\.com)/.test(document.referrer) === true) { ntmOption["dimension7"] = "breaking"; } }

var ntmLeaveOption = $.extend({}, ntmOption); ntmLeaveOption["hitType"] = "event"; ntmLeaveOption["eventCategory"] = "action"; ntmLeaveOption["eventAction"] = "leave";

if (/\\/article\\/comment\\//.test(location.href) == false && $("#contents").length > 0) { if (window.addEventListener) { window.addEventListener("load", function () { ntm.push({ event: "nilSend", ni: ntmOption }); }, false); window.addEventListener("onpagehide" in window ? "pagehide" : "beforeunload", function () { ntm.push({ event: "nilSend", ni: ntmLeaveOption }); window.impression.sendImpressionNlog([], "disable"); }, false); } else if (window.attachEvent) { window.attachEvent("onload", function () { ntm.push({ event: "nilSend", ni: ntmOption }); }); window.attachEvent("onpagehide" in window ? "onpagehide" : "onbeforeunload", function () { ntm.push({ event: "nilSend", ni: ntmLeaveOption }); window.impression.sendImpressionNlog([], "disable"); }); } else { var onUnloadEventName = "onpagehide" in window ? "onpagehide" : "onbeforeunload"; var fnOnUnload = window[onUnloadEventName]; window[onUnloadEventName] = function () { ntm.push({ event: "nilSend", ni: ntmLeaveOption }); window.impression.sendImpressionNlog([], "disable"); if (fnOnUnload) { fnOnUnload(); } };

var fnOnload = window.onload; window.onload = function () { ntm.push({ event: "nilSend", ni: ntmOption }); if (fnOnload) { fnOnload(); } }; } } }

var eventType = "onpageshow" in window ? "pageshow" : "load"; $(window).on(eventType, function() { window.ntm = window.ntm || [];

var pageviewInfo = { event: "nLogPageViewService", nlogEvt: { page_layout: window.layoutName, news: window.getNLogNewsData() } };

pageviewInfo.nLogPageviewSti = "news"; pageviewInfo.nLogPageviewGdid = "88000127_000000000000000013958253"; pageviewInfo.nlogEvt.news.sid1 = "104"; pageviewInfo.nlogEvt.news.oid = "003"; pageviewInfo.nlogEvt.news.aid = "0013958253";

window.ntm.push(pageviewInfo);

// ma 로그 전송 var data = { "service": "news", "baseInfo": { "category": (/\\/article\\/comment\\//.test(location.href) == true) ? "댓글" : "본문" }, "extraInfo": { "articleId": article.articleId, "sectionId": article.sectionId, "officeId": article.officeId, "articleType": article.type } };

if (typeof ma !== "undefined") { ma.init({ api: "https://ma.news.naver.com/l", data: data, contentBodyEl: document.querySelector("._article_content"), contentBodyVideoWrapperSelector: "._VIDEO_AREA" }); }

if (window.matchMedia("(prefers-color-scheme)").media === "not all") { $("html").removeClass("DARK_THEME"); }

setTimeout(function() { $("._OPACITY_FADE_TO").fadeTo(50, 1); }, 3000); });

", + "tags": [ + "네이버뉴스", + "정치" + ], + "publishedAt": "2026-05-20T14:56:53.000Z" + }, + "createdAt": "2026-05-28T03:42:02.317Z", + "updatedAt": "2026-05-28T03:42:02.317Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-8363648aadc14dbc", + "title": "9년 만에 금가분리 풀리나…이억원 \"디지털자산기본법과 연계해 검토\"", + "sourceLabel": "더팩트", + "url": "https://n.news.naver.com/mnews/article/629/0000501342", + "lead": "하나은행, 두나무에 1조원 지분 투자…\"글로벌 변화·2단계 입법 종합 검토\"", + "body": "하나은행, 두나무에 1조원 지분 투자…\"글로벌 변화·2단계 입법 종합 검토\"\n\n이억원 금융위원장이 21일 정부서울청사에서 열린 기자간담회에서 금가분리 원칙과 관련해 발언했다. /박상민 기자\n\n[더팩트ㅣ박지웅 기자] 금융위원회가 지난 2017년부터 유지돼 온 금융권의 가상자산 시장 참여 제한, 이른바 '금가분리(금융-가상자산 분리)' 규제를 가상자산 2단계 입법 과정에서 재검토할 가능성을 시사했다.\n\n이억원 금융위원장은 21일 정부서울청사에서 열린 기자간담회에서 금가분리 원칙과 관련해 \"2017년 말 당시 가상자산 투기에 대한 긴급조치 일환으로 금융사의 가상자산 참여를 제한하는 조치들이 있었다\"고 밝혔다.\n\n이어 \"현재는 글로벌 시장이 변화하고 있고 우리도 가상자산을 제도화하는 입법들을 추진하고 있어 이런 점들을 종합적으로 봐야 한다\"고 말했다.\n\n또 \"글로벌 흐름이 어떻게 가고 있는지, 금융사가 가상자산 시장에 참가할 경우 이용자 보호와 금융 안정 측면을 어떻게 볼 것인지 등을 종합적으로 검토해야 한다\"고 설명했다.\n\n아울러 \"스테이블코인 도입과 가상자산거래소 규율체계 정비를 포함한 2단계 가상자산 입법도 추진 중인 만큼 이런 부분들과 연계해 종합적으로 살펴보겠다\"고 덧붙였다.\n\n금융위는 지난 2017년 말 정부의 가상자산 긴급대책 이후 행정지도를 통해 금융회사의 가상자산 직접 투자 및 시장 참여를 제한해왔다. 당시에는 투기 과열과 자금세탁 우려 등이 주요 배경이었다.\n\n하지만 최근 들어 글로벌 주요국들은 가상자산을 제도권 금융 안으로 편입하는 움직임을 본격화하고 있다. 미국은 현물 비트코인 ETF를 승인했고, 유럽연합(EU)은 디지털자산규제법안(MiCA)를 시행 중이다. 홍콩 역시 은행권과 기관투자자의 디지털자산 사업 참여를 확대하고 있다.\n\n국내에서도 최근 하나은행의 두나무 지분 투자와 함께 증권사·금융지주들의 디지털자산 사업 확대 움직임이 이어지고 있다. 업계에서는 향후 스테이블코인, 토큰증권(STO), 디지털자산 수탁·결제 등 블록체인 기반 금융 서비스 경쟁이 본격화될 가능성이 커지고 있다는 관측이 나온다.\n\n특히 금융당국이 스테이블코인 제도화와 거래소 규율체계 개편 등을 포함한 '가상자산 2단계 입법'을 추진 중인 만큼, 금융권의 시장 참여 범위 역시 핵심 논의 대상으로 떠오를 가능성이 커지고 있다.\n\n발로 뛰는 더팩트는 24시간 여러분의 제보를 기다립니다. ▶카카오톡: '더팩트제보' 검색 ▶이메일: jebo@tf.co.kr ▶뉴스 홈페이지: http://talk.tf.co.kr/bbs/report/write", + "htmlBody": "

하나은행, 두나무에 1조원 지분 투자…"글로벌 변화·2단계 입법 종합 검토"

\"이억원

이억원 금융위원장이 21일 정부서울청사에서 열린 기자간담회에서 금가분리 원칙과 관련해 발언했다. /박상민 기자

[더팩트ㅣ박지웅 기자] 금융위원회가 지난 2017년부터 유지돼 온 금융권의 가상자산 시장 참여 제한, 이른바 '금가분리(금융-가상자산 분리)' 규제를 가상자산 2단계 입법 과정에서 재검토할 가능성을 시사했다.

이억원 금융위원장은 21일 정부서울청사에서 열린 기자간담회에서 금가분리 원칙과 관련해 "2017년 말 당시 가상자산 투기에 대한 긴급조치 일환으로 금융사의 가상자산 참여를 제한하는 조치들이 있었다"고 밝혔다.

이어 "현재는 글로벌 시장이 변화하고 있고 우리도 가상자산을 제도화하는 입법들을 추진하고 있어 이런 점들을 종합적으로 봐야 한다"고 말했다.

또 "글로벌 흐름이 어떻게 가고 있는지, 금융사가 가상자산 시장에 참가할 경우 이용자 보호와 금융 안정 측면을 어떻게 볼 것인지 등을 종합적으로 검토해야 한다"고 설명했다.

아울러 "스테이블코인 도입과 가상자산거래소 규율체계 정비를 포함한 2단계 가상자산 입법도 추진 중인 만큼 이런 부분들과 연계해 종합적으로 살펴보겠다"고 덧붙였다.

금융위는 지난 2017년 말 정부의 가상자산 긴급대책 이후 행정지도를 통해 금융회사의 가상자산 직접 투자 및 시장 참여를 제한해왔다. 당시에는 투기 과열과 자금세탁 우려 등이 주요 배경이었다.

하지만 최근 들어 글로벌 주요국들은 가상자산을 제도권 금융 안으로 편입하는 움직임을 본격화하고 있다. 미국은 현물 비트코인 ETF를 승인했고, 유럽연합(EU)은 디지털자산규제법안(MiCA)를 시행 중이다. 홍콩 역시 은행권과 기관투자자의 디지털자산 사업 참여를 확대하고 있다.

국내에서도 최근 하나은행의 두나무 지분 투자와 함께 증권사·금융지주들의 디지털자산 사업 확대 움직임이 이어지고 있다. 업계에서는 향후 스테이블코인, 토큰증권(STO), 디지털자산 수탁·결제 등 블록체인 기반 금융 서비스 경쟁이 본격화될 가능성이 커지고 있다는 관측이 나온다.

특히 금융당국이 스테이블코인 제도화와 거래소 규율체계 개편 등을 포함한 '가상자산 2단계 입법'을 추진 중인 만큼, 금융권의 시장 참여 범위 역시 핵심 논의 대상으로 떠오를 가능성이 커지고 있다.

발로 뛰는 더팩트는 24시간 여러분의 제보를 기다립니다. ▶카카오톡: '더팩트제보' 검색 ▶이메일: jebo@tf.co.kr ▶뉴스 홈페이지: http://talk.tf.co.kr/bbs/report/write

", + "tags": [ + "네이버뉴스", + "경제" + ], + "listedDate": "2026-05-21", + "publishedAt": "2026-05-21T07:41:08.000Z" + }, + "createdAt": "2026-05-28T03:42:02.191Z", + "updatedAt": "2026-05-28T03:42:02.191Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-bcf6d7cdacb9d91c", + "title": "[이응준의 과거에서 보내는 엽서] [67] 쇄국의 결과", + "sourceLabel": "조선일보", + "url": "https://n.news.naver.com/mnews/article/023/0003977544", + "lead": "1871년 5월 21일 나는 풍도(楓島) 해안가에 정박한 존 로저스 제독의 미국 함대를 바라보고 있다. 저들은 강화도 쪽으로 나아갈 것이고, 6월 1일 손돌목에서 신미양요(辛未洋擾)의 첫 포격전이 벌어질 것이다. 한양으로 올라가는 요충지인 풍도는 1894년 7월 25일 일본군이 청나라 군함을 기습 공격하며 청일전쟁이 사실상 시작된 지점이기도 하다.", + "body": "1871년 5월 21일 나는 풍도(楓島) 해안가에 정박한 존 로저스 제독의 미국 함대를 바라보고 있다. 저들은 강화도 쪽으로 나아갈 것이고, 6월 1일 손돌목에서 신미양요(辛未洋擾)의 첫 포격전이 벌어질 것이다. 한양으로 올라가는 요충지인 풍도는 1894년 7월 25일 일본군이 청나라 군함을 기습 공격하며 청일전쟁이 사실상 시작된 지점이기도 하다.\n\n신미양요의 원인은 1866년 9월 2일 대동강에서 평양 군민(軍民)들이 미국 상선(商船)을 격침시키고 승무원 전원을 죽인 ‘제너럴 셔먼호 사건’에 대한 사죄와 통상 조약 체결 요구 등이었다(북한에서는 김일성의 조부 김응우가 백성들을 지휘해 제너럴 셔먼호를 공격했다고 선전해왔다). 신미양요에서 조선인들은 서양 귀신들을 물리쳤다고 여겼는지 모르지만, 미국 해군 기록에는 한여름에 솜옷 열세 겹을 방탄복 삼아 입고 머리를 풀어헤친 채 치열하게 싸우는 조선 병사들이 귀신처럼 보였노라고 적혀 있다. 당시 미군은 남북전쟁을 치른 지 얼마 안 된, 전쟁에 이골이 난 군인들이었다. 그런데도 어찌나 조선 군인들이 사력을 다해 공격과 저항을 해대는지 학을 뗐다. 조선군 전사자가 수백 명에 이른 반면 미군 전사자는 소수에 불과했고, 강화도의 주요 요새들을 점령한 미군은 조선군의 군기(軍旗)와 무기를 노획했다. 그러나 군사적 승리에도 불구하고 당황한 건 미군이었다. 이게 이렇게까지 문을 닫아걸 일인가? 미국 함대는 결국 한 달여 만에 퇴각했고, 흥선대원군은 전국에 척화비를 세우며 쇄국 정책을 더욱 강화했다.\n\n프랑스, 미국과는 저렇게 죽어가며 싸우더니 훗날 일본에는 총 한 번 못 쏴 보고 나라를 뺏긴다. 일본은 미국에 개항당한 지 약 50년 만에 강대국이 되었더랬다. 조선 쇄국 이념의 기원을 18세기 조선에서가 아니라, 1649년 효종 1년 우암 송시열의 ’기축봉사(己丑封事)’에서 찾는 견해가 있다. 더 솔직하게는, 조선의 지배계급이 신분제를 지키려 그랬다는 분석이 있다.", + "htmlBody": "

1871년 5월 21일 나는 풍도(楓島) 해안가에 정박한 존 로저스 제독의 미국 함대를 바라보고 있다. 저들은 강화도 쪽으로 나아갈 것이고, 6월 1일 손돌목에서 신미양요(辛未洋擾)의 첫 포격전이 벌어질 것이다. 한양으로 올라가는 요충지인 풍도는 1894년 7월 25일 일본군이 청나라 군함을 기습 공격하며 청일전쟁이 사실상 시작된 지점이기도 하다.

신미양요의 원인은 1866년 9월 2일 대동강에서 평양 군민(軍民)들이 미국 상선(商船)을 격침시키고 승무원 전원을 죽인 ‘제너럴 셔먼호 사건’에 대한 사죄와 통상 조약 체결 요구 등이었다(북한에서는 김일성의 조부 김응우가 백성들을 지휘해 제너럴 셔먼호를 공격했다고 선전해왔다). 신미양요에서 조선인들은 서양 귀신들을 물리쳤다고 여겼는지 모르지만, 미국 해군 기록에는 한여름에 솜옷 열세 겹을 방탄복 삼아 입고 머리를 풀어헤친 채 치열하게 싸우는 조선 병사들이 귀신처럼 보였노라고 적혀 있다. 당시 미군은 남북전쟁을 치른 지 얼마 안 된, 전쟁에 이골이 난 군인들이었다. 그런데도 어찌나 조선 군인들이 사력을 다해 공격과 저항을 해대는지 학을 뗐다. 조선군 전사자가 수백 명에 이른 반면 미군 전사자는 소수에 불과했고, 강화도의 주요 요새들을 점령한 미군은 조선군의 군기(軍旗)와 무기를 노획했다. 그러나 군사적 승리에도 불구하고 당황한 건 미군이었다. 이게 이렇게까지 문을 닫아걸 일인가? 미국 함대는 결국 한 달여 만에 퇴각했고, 흥선대원군은 전국에 척화비를 세우며 쇄국 정책을 더욱 강화했다.

프랑스, 미국과는 저렇게 죽어가며 싸우더니 훗날 일본에는 총 한 번 못 쏴 보고 나라를 뺏긴다. 일본은 미국에 개항당한 지 약 50년 만에 강대국이 되었더랬다. 조선 쇄국 이념의 기원을 18세기 조선에서가 아니라, 1649년 효종 1년 우암 송시열의 ’기축봉사(己丑封事)’에서 찾는 견해가 있다. 더 솔직하게는, 조선의 지배계급이 신분제를 지키려 그랬다는 분석이 있다.

", + "tags": [ + "네이버뉴스", + "생활/문화" + ], + "publishedAt": "2026-05-20T14:40:19.000Z" + }, + "createdAt": "2026-05-28T03:42:01.939Z", + "updatedAt": "2026-05-28T03:42:01.939Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-61aa7d8b1ac0cb67", + "title": "평택을 범여권 단일화 난항…민주·혁신당 합당론도 흔들리나", + "sourceLabel": "이데일리", + "url": "https://n.news.naver.com/mnews/article/018/0006287982", + "lead": "민주·혁신당 신경전 격화...보수진영은 단일화 가능성 열어둬 김용남 “후보 동의없는 단일화 불가”…합병론 동력 약화돼 [이데일리 하지나 기자] 6·3 지방선거와 함께 치러지는 경기 평택을 국회의원 재선거에서 후보 단일화 여부가 최대 변수로 떠오른 가운데 진보 진영 내부에서는 오히려 갈등의 골이 깊어지는 모습이다. 이에 따라 후보 단일화는 물론 선거 전 거론됐던 합당 및 정책연대 가능성도 사실상 희박해지는 것 아니냐는 관측이 나온다.", + "body": "민주·혁신당 신경전 격화...보수진영은 단일화 가능성 열어둬 김용남 “후보 동의없는 단일화 불가”…합병론 동력 약화돼 [이데일리 하지나 기자] 6·3 지방선거와 함께 치러지는 경기 평택을 국회의원 재선거에서 후보 단일화 여부가 최대 변수로 떠오른 가운데 진보 진영 내부에서는 오히려 갈등의 골이 깊어지는 모습이다. 이에 따라 후보 단일화는 물론 선거 전 거론됐던 합당 및 정책연대 가능성도 사실상 희박해지는 것 아니냐는 관측이 나온다.\n\n평택을 재선거에는 김용남 더불어민주당 후보, 유의동 국민의힘 후보, 조국 조국혁신당 후보, 김재연 진보당 후보, 황교안 자유와혁신 후보가 출마해 5자 구도를 형성하고 있다. 최근 여론조사에서는 각 후보가 일정 수준의 지지율을 나눠 가지며 혼전 양상을 보이면서 진보·보수 진영 모두 단일화 여부가 최대 변수로 떠오르고 있다.\n\n그러나 범진보 진영의 단일화는 쉽지 않은 분위기다. 민주당 김용남 후보 역시 “후보 동의 없는 단일화는 불가능하다”며 선을 긋고 있다. 특히 민주당과 조국혁신당이 선거 과정에서 거센 신경전을 이어가면서 양측의 감정의 골도 깊어지는 양상이다.\n\n조국혁신당은 보수 정당 출신인 김 후보의 과거 세월호 관련 발언을 문제 삼으며 사과를 요구한 데 이어, 보좌진 폭행 문제 등을 거론하며 공세 수위를 높이고 있다. 김 후보도 지난 21일 유세 과정에서 조 후보를 향해 “파란색이 얼마나 부러우면 얼굴을 시퍼렇게 만들었느냐”고 발언하며 맞받았다. 최근 조 후보가 유리문에 얼굴을 부딪혀 오른쪽 눈 주변에 멍이 든 채 공개 일정에 나선 것을 두고 민주당 상징색인 ‘파란색’에 빗대 표현한 것이다.\n\n이 같은 갈등은 향후 민주당과 조국혁신당 간 합당 논의에도 부담으로 작용할 가능성이 크다. 선거 전에는 양당 간 통합론이 범여권 재편 시나리오 중 하나로 거론됐지만 평택을 단일화 문제를 둘러싼 이해관계 충돌과 상호 견제 심리가 표면화되면서 논의 동력이 약해지는 모습이다.\n\n지지 호소하는 평택을 국회의원 재선거 후보들(사진=연합뉴스)\n\n특히 민주당 내부에서 조국혁신당과의 합당에 대한 반대 기류가 여전한 데다 조 후보의 정치적 위상과 당권 구도까지 맞물려 있어 합당 논의가 선거 이후에도 순탄하게 진행되기는 쉽지 않아 보인다.\n\n결국 평택을 재선거의 핵심 변수는 보수 진영 단일화 여부가 될 전망이다. 유의동 후보와 황교안 후보 간 단일화가 성사될 경우 범여권도 단일화 압박을 피하기 어려울 수 있다는 목소리도 나온다.\n\n현재 보수 진영에서는 후보 단일화 가능성을 열어둔 상태다. 앞서 유 후보는 라디오 인터뷰에서 “‘보수 목소리를 하나로 합쳐야 한다’는 말을 많이 듣고 있다”며 “이 부분에 대해 진지하게 고민의 수준을 높이고 있다”고 말해 황교안 후보와의 단일화 가능성을 시사했다.", + "htmlBody": "

민주·혁신당 신경전 격화...보수진영은 단일화 가능성 열어둬 김용남 “후보 동의없는 단일화 불가”…합병론 동력 약화돼 [이데일리 하지나 기자] 6·3 지방선거와 함께 치러지는 경기 평택을 국회의원 재선거에서 후보 단일화 여부가 최대 변수로 떠오른 가운데 진보 진영 내부에서는 오히려 갈등의 골이 깊어지는 모습이다. 이에 따라 후보 단일화는 물론 선거 전 거론됐던 합당 및 정책연대 가능성도 사실상 희박해지는 것 아니냐는 관측이 나온다.

평택을 재선거에는 김용남 더불어민주당 후보, 유의동 국민의힘 후보, 조국 조국혁신당 후보, 김재연 진보당 후보, 황교안 자유와혁신 후보가 출마해 5자 구도를 형성하고 있다. 최근 여론조사에서는 각 후보가 일정 수준의 지지율을 나눠 가지며 혼전 양상을 보이면서 진보·보수 진영 모두 단일화 여부가 최대 변수로 떠오르고 있다.

그러나 범진보 진영의 단일화는 쉽지 않은 분위기다. 민주당 김용남 후보 역시 “후보 동의 없는 단일화는 불가능하다”며 선을 긋고 있다. 특히 민주당과 조국혁신당이 선거 과정에서 거센 신경전을 이어가면서 양측의 감정의 골도 깊어지는 양상이다.

조국혁신당은 보수 정당 출신인 김 후보의 과거 세월호 관련 발언을 문제 삼으며 사과를 요구한 데 이어, 보좌진 폭행 문제 등을 거론하며 공세 수위를 높이고 있다. 김 후보도 지난 21일 유세 과정에서 조 후보를 향해 “파란색이 얼마나 부러우면 얼굴을 시퍼렇게 만들었느냐”고 발언하며 맞받았다. 최근 조 후보가 유리문에 얼굴을 부딪혀 오른쪽 눈 주변에 멍이 든 채 공개 일정에 나선 것을 두고 민주당 상징색인 ‘파란색’에 빗대 표현한 것이다.

이 같은 갈등은 향후 민주당과 조국혁신당 간 합당 논의에도 부담으로 작용할 가능성이 크다. 선거 전에는 양당 간 통합론이 범여권 재편 시나리오 중 하나로 거론됐지만 평택을 단일화 문제를 둘러싼 이해관계 충돌과 상호 견제 심리가 표면화되면서 논의 동력이 약해지는 모습이다.

\"지지
지지 호소하는 평택을 국회의원 재선거 후보들(사진=연합뉴스)

특히 민주당 내부에서 조국혁신당과의 합당에 대한 반대 기류가 여전한 데다 조 후보의 정치적 위상과 당권 구도까지 맞물려 있어 합당 논의가 선거 이후에도 순탄하게 진행되기는 쉽지 않아 보인다.

결국 평택을 재선거의 핵심 변수는 보수 진영 단일화 여부가 될 전망이다. 유의동 후보와 황교안 후보 간 단일화가 성사될 경우 범여권도 단일화 압박을 피하기 어려울 수 있다는 목소리도 나온다.

현재 보수 진영에서는 후보 단일화 가능성을 열어둔 상태다. 앞서 유 후보는 라디오 인터뷰에서 “‘보수 목소리를 하나로 합쳐야 한다’는 말을 많이 듣고 있다”며 “이 부분에 대해 진지하게 고민의 수준을 높이고 있다”고 말해 황교안 후보와의 단일화 가능성을 시사했다.

", + "tags": [ + "네이버뉴스", + "정치" + ], + "listedDate": "2026-05-22", + "publishedAt": "2026-05-22T05:52:11.000Z" + }, + "createdAt": "2026-05-28T03:42:01.678Z", + "updatedAt": "2026-05-28T03:42:01.678Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-58192760a9fc123f", + "title": "경총 \"삼성전자 최악 상황 피해 다행…불확실성 해소해야\"", + "sourceLabel": "연합뉴스", + "url": "https://n.news.naver.com/mnews/article/001/0016090009", + "lead": "손 맞잡은 노사정 (수원=연합뉴스) 홍기원 기자 = 20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 김영환 고용노동부 장관과 손을 맞잡고 있다. 2026.5.20 [공동취재] xanadu@yna.co.kr", + "body": "손 맞잡은 노사정 (수원=연합뉴스) 홍기원 기자 = 20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 김영환 고용노동부 장관과 손을 맞잡고 있다. 2026.5.20 [공동취재] xanadu@yna.co.kr\n\n(서울=연합뉴스) 김윤구 기자 = 삼성전자 노사가 20일 파업 없이 임금협상 잠정 합의안을 도출한 데 대해 한국경영자총협회가 환영 입장을 밝혔다.\n\n경총은 반도체 경쟁 심화와 글로벌 시장 불확실성 확대 등 엄중한 경영 환경 속에서 파업을 막기 위해 노사가 한발씩 물러나 대화를 통해 접점을 찾았다는 점에서 이번 합의에 의미가 있다고 평가했다.\n\n다만 이번 합의는 삼성전자의 특수한 상황을 반영한 결과인 만큼, 노동계가 이를 일반화해 과도한 성과급 요구를 산업 전반으로 확산해서는 안 된다고 강조했다.\n\n아울러 노사가 이번 합의를 계기로 불확실성을 조속히 해소하고 합리적인 노사관계를 구축해 나가야 한다고 주문했다.", + "htmlBody": "
\"손

손 맞잡은 노사정 (수원=연합뉴스) 홍기원 기자 = 20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 김영환 고용노동부 장관과 손을 맞잡고 있다. 2026.5.20 [공동취재] xanadu@yna.co.kr

(서울=연합뉴스) 김윤구 기자 = 삼성전자 노사가 20일 파업 없이 임금협상 잠정 합의안을 도출한 데 대해 한국경영자총협회가 환영 입장을 밝혔다.

경총은 반도체 경쟁 심화와 글로벌 시장 불확실성 확대 등 엄중한 경영 환경 속에서 파업을 막기 위해 노사가 한발씩 물러나 대화를 통해 접점을 찾았다는 점에서 이번 합의에 의미가 있다고 평가했다.

다만 이번 합의는 삼성전자의 특수한 상황을 반영한 결과인 만큼, 노동계가 이를 일반화해 과도한 성과급 요구를 산업 전반으로 확산해서는 안 된다고 강조했다.

아울러 노사가 이번 합의를 계기로 불확실성을 조속히 해소하고 합리적인 노사관계를 구축해 나가야 한다고 주문했다.

", + "tags": [ + "네이버뉴스", + "IT/과학" + ], + "publishedAt": "2026-05-20T14:46:10.000Z" + }, + "createdAt": "2026-05-28T03:35:07.312Z", + "updatedAt": "2026-05-28T03:35:07.312Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-eab2146d1a48990b", + "title": "'비의료인 문신 시술' 마침내 합법화…대법, 34년 만에 판례 변경", + "sourceLabel": "시사저널", + "url": "https://n.news.naver.com/mnews/article/586/0000129716", + "lead": "[박선우 디지털팀 기자 psw92@sisajournal.com]", + "body": "[박선우 디지털팀 기자 psw92@sisajournal.com]\n\n대법 전원합의체, 문신 시술 의료법 위반 무죄 취지 파기환송 \"의사만큼 의학지식 있어야 성공적 시술 할 수 있는 건 아냐\"\n\n비의료인의 문신 시술을 무면허 의료행위로 처벌할 수 없다는 대법원의 새 판례가 나왔다. 1992년 이후 34년만의 판례 변경으로, 내년 10월 문신사법 시행 전까지의 사법적 혼란을 줄일 수 있게 됐다는 평가다.\n\n21일 법조계에 따르면, 대법원 전원합의체(전합)는 이날 의료법 위반 혐의로 각각 기소된 박아무개씨와 백아무개씨의 상고심에서 전원일치 의견으로 벌금형을 선고한 원심을 깨고 무죄 취지로 사건을 파기환송 했다.\n\n미용실 운영자인 박씨는 2020년 1~12월 두피문신 시술을 한 혐의로, 백씨는 2019년 5월 패션잡화 판매점에서 서화문신(레터링 문신) 시술을 한 혐의로 각각 기소돼 1·2심서 벌금형을 선고받았다. 대법원이 1992년 5월 눈썹 문신시술을 '무면허 의료행위'로 판단했던 것과 같은 취지의 판결들이다.\n\n대법원 전합은 이날 34년만의 판례 변경을 결정했다. 전합은 \"통상적인 미용 문신 행위는 대부분 질병의 예방 또는 치료와 직접적인 관련 없이 이뤄진다\"면서 \"문신 시술은 문신과 관련된 미적인 지식과 기능, 경험 등이 요구되는 영역으로, 반드시 의료인에 버금가는 의학적 전문지식과 경험이 있어야만 성공적인 문신 시술을 할 수 있는 것은 아니다\"라고 짚었다.\n\n이어 \"1992년 판단 이래 기술의 발전과 환경의 변화로 의료서비스 수요자의 의료접근성이 비약적으로 향상됐다\"면서 \"문신시술을 받고자 하는 사람들은 보건위생상 위해 등에 대한 정보를 바탕으로 자기 신체를 통해 개성을 발현하고 행복을 추구하는 수단으로 문신 시술을 받을지 스스로 자유롭게 결정할 수 있는 지위에 있다\"고 지적했다.\n\n아울러 \"문신 시술을 오직 의료인에게만 허용하고, 비의료인에게는 전면 금지하는 것은 헌법 제10조로부터 도출되는 일반적 인격권, 자유로운 인격 발현을 통한 행복추구권, 표현의 자유 등 헌법상 기본권을 침해하는 결과에 이를 수 있다\"고 판시했다.\n\n한편 비의료인의 문신 시술을 허용하는 일명 '문신사법'이 작년 9월 국회를 통과해 내년 10월 시행을 앞두고 있다. 문신 시술을 받으려는 수요는 지속되는 반면 시술자는 의료인보단 비의료인인 경우가 압도적으로 많아 법과 현실 간의 괴리가 크다는 지적이 이어져서다.\n\n내년 10월 문신사법 시행 전에 기소된 문신 시술자들을 처벌하는 게 온당한지에 대한 논란도 있었다. 다만 이날 전합이 비의료인의 문신 시술을 무면허 의료행위로 처벌할 수 없다는 새 법리를 세운 만큼, 현재 재판부마다 유·무죄가 갈리는 관련 사법적 혼란도 줄어들 것으로 기대된다.", + "htmlBody": "

[박선우 디지털팀 기자 psw92@sisajournal.com]

대법 전원합의체, 문신 시술 의료법 위반 무죄 취지 파기환송 "의사만큼 의학지식 있어야 성공적 시술 할 수 있는 건 아냐"

\"대법원

비의료인의 문신 시술을 무면허 의료행위로 처벌할 수 없다는 대법원의 새 판례가 나왔다. 1992년 이후 34년만의 판례 변경으로, 내년 10월 문신사법 시행 전까지의 사법적 혼란을 줄일 수 있게 됐다는 평가다.

21일 법조계에 따르면, 대법원 전원합의체(전합)는 이날 의료법 위반 혐의로 각각 기소된 박아무개씨와 백아무개씨의 상고심에서 전원일치 의견으로 벌금형을 선고한 원심을 깨고 무죄 취지로 사건을 파기환송 했다.

미용실 운영자인 박씨는 2020년 1~12월 두피문신 시술을 한 혐의로, 백씨는 2019년 5월 패션잡화 판매점에서 서화문신(레터링 문신) 시술을 한 혐의로 각각 기소돼 1·2심서 벌금형을 선고받았다. 대법원이 1992년 5월 눈썹 문신시술을 '무면허 의료행위'로 판단했던 것과 같은 취지의 판결들이다.

대법원 전합은 이날 34년만의 판례 변경을 결정했다. 전합은 "통상적인 미용 문신 행위는 대부분 질병의 예방 또는 치료와 직접적인 관련 없이 이뤄진다"면서 "문신 시술은 문신과 관련된 미적인 지식과 기능, 경험 등이 요구되는 영역으로, 반드시 의료인에 버금가는 의학적 전문지식과 경험이 있어야만 성공적인 문신 시술을 할 수 있는 것은 아니다"라고 짚었다.

이어 "1992년 판단 이래 기술의 발전과 환경의 변화로 의료서비스 수요자의 의료접근성이 비약적으로 향상됐다"면서 "문신시술을 받고자 하는 사람들은 보건위생상 위해 등에 대한 정보를 바탕으로 자기 신체를 통해 개성을 발현하고 행복을 추구하는 수단으로 문신 시술을 받을지 스스로 자유롭게 결정할 수 있는 지위에 있다"고 지적했다.

아울러 "문신 시술을 오직 의료인에게만 허용하고, 비의료인에게는 전면 금지하는 것은 헌법 제10조로부터 도출되는 일반적 인격권, 자유로운 인격 발현을 통한 행복추구권, 표현의 자유 등 헌법상 기본권을 침해하는 결과에 이를 수 있다"고 판시했다.

한편 비의료인의 문신 시술을 허용하는 일명 '문신사법'이 작년 9월 국회를 통과해 내년 10월 시행을 앞두고 있다. 문신 시술을 받으려는 수요는 지속되는 반면 시술자는 의료인보단 비의료인인 경우가 압도적으로 많아 법과 현실 간의 괴리가 크다는 지적이 이어져서다.

내년 10월 문신사법 시행 전에 기소된 문신 시술자들을 처벌하는 게 온당한지에 대한 논란도 있었다. 다만 이날 전합이 비의료인의 문신 시술을 무면허 의료행위로 처벌할 수 없다는 새 법리를 세운 만큼, 현재 재판부마다 유·무죄가 갈리는 관련 사법적 혼란도 줄어들 것으로 기대된다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "listedDate": "2026-05-21", + "publishedAt": "2026-05-21T07:41:09.000Z" + }, + "createdAt": "2026-05-28T03:35:06.890Z", + "updatedAt": "2026-05-28T03:35:06.890Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-5736073bb62866fb", + "title": "[삼성 노사합의] 삼전 총파업 멈췄지만…주주 \"성과급 합의 무효\" 소송 예고", + "sourceLabel": "데일리안", + "url": "https://n.news.naver.com/mnews/article/119/0003093139", + "lead": "21일 오전 이재용 사택 인근 집회 진행 \"영업이익 연동 성과급, 주총 승인해야\"", + "body": "21일 오전 이재용 사택 인근 집회 진행 \"영업이익 연동 성과급, 주총 승인해야\"\n\n민경권 대한민국주주운동본부 대표가 21일 서울 한강진역 인근에서 삼성전자 주주총결집 집회를 열고 발언하고 있다. ⓒ데일리안 김하랑 기자 [데일리안 = 김하랑 기자] 삼성전자 총파업을 하루 앞두고 노사가 극적 합의했지만, 주주들은 소송 가능성을 꺼내 들었다.\n\n노사의 성과급 잠정합의안에 위법 소지가 있다는 취지다.\n\n대한민국주주운동본부는 21일 서울 용산구 한강진역 이재용 삼성전자 회장 사택 인근에서 '삼성전자 주주총결집 집회'를 열고 삼성전자 임금협상 잠정합의안에 대한 반대 입장을 내놨다.\n\n앞서 노조는 이날 총파업에 돌입할 예정이었지만, 전날 밤 2026년 임금협상 잠정합의안에 서명했다.\n\n노사는 DS(반도체) 부문 특별경영성과급 지급 기준과 자사주 지급 방식 등에 합의했고, 이에 따라 예정됐던 총파업은 유보됐다.\n\n최종안은 오는 22~27일까지, 공휴일 및 주말을 제외한 총 사흘간 노조 찬반투표를 거쳐 확정될 예정이다.\n\n총파업이 멈췄음에도 주주 측이 집회를 강행한 건 노사 협상 과정에서 주주 의견이 충분히 반영되지 않았다는 판단 때문이다.\n\n이날 집회에서는 노사의 '영업이익 약 12% 수준 성과급 재원 형성' 합의 구조가 핵심 쟁점으로 거론됐다.\n\n주주운동본부는 영업이익 단계에서 일정 비율을 성과급 재원으로 책정하는 방식이 향후 주주 권익과 배당 재원에 영향을 줄 수 있으며, 사실상 주주총회 판단 대상이라고 주장했다.\n\n민경권 대표는 \"노사 합의에서 영업이익 12% 성과급이 최종안이라면 주총 승인 절차가 필요하다\"며 \"승인 없는 협약은 무효라는 점을 강조하기 위해 집회를 열었다\"고 말했다.\n\n본부는 이날 이재명 대통령의 최근 발언을 인용하며 주장에 힘을 실었다.\n\n민 대표는 \"이 대통령께서 지난 20일 국무회의에서 '영업이익에 대해 이익을 배분받는 것은 투자자, 주주가 하는 것'이라고 단호히 천명하신 만큼 그 주체인 주주 일동이 직접 사법체계 내에서 자신의 권리를 행사할 시점\"이라고 강조했다.\n\n본부는 향후 잠정합의안이 집행될 경우 ▲이사회 결의 무효 확인 소송 ▲주주대표소송 ▲효력정지 가처분 ▲단체협약 무효 소송 등을 추진하겠다고 밝혔다.\n\n본부는 노조 찬반투표 부결 등으로 총파업이 재개돼 생산 차질이나 기업가치 훼손이 발생할 경우 노조 집행부와 참여 조합원을 상대로 손해배상 청구를 검토하겠다고 주장했다.\n\n손해 항목으로는 생산 감소와 주가 하락, 향후 배당 재원 축소 등을 제시했다.\n\n실제 총파업에 따른 생산 차질 가능성은 해외 언론과 증권가에서도 언급된 바 있다.\n\n영국 통신사 로이터는 총파업이 현실화될 경우 삼성전자 반도체 공장의 직접 손실 규모가 하루 최대 1조원에 이를 수 있다고 전했다.\n\n장기화 시에는 반도체 생산 차질과 공급망 충격으로 국내 경제 성장률이 최대 0.5%포인트 낮아질 수 있다는 전망도 나왔다.\n\n실제 노사 갈등이 격화됐던 시기 삼성전자 주가 역시 크게 출렁였다.\n\n노조가 총파업 계획을 유지하겠다고 밝힌 지난 15일 삼성전자 주가는 8.61% 급락했다.\n\n반면 총파업 유보 소식이 전해진 이날 주가는 8.51% 상승 마감했다.\n\n총파업 가능성 자체가 투자심리와 기업가치에 영향을 준 셈이다.\n\n금융투자업계 관계자는 \"총파업 자체는 유보됐지만 노사 갈등 과정에서 주가 변동성이 커질 경우 결국 피해는 주주에게 돌아갈 수밖에 없다\"고 말했다.\n\n이어 \"향후 상장사 노사 협상에서도 기업가치와 주주 권익을 고려해야 한다는 요구는 더 커질 가능성이 있다\"고 덧붙였다.", + "htmlBody": "

21일 오전 이재용 사택 인근 집회 진행 "영업이익 연동 성과급, 주총 승인해야"

\"민경권

민경권 대한민국주주운동본부 대표가 21일 서울 한강진역 인근에서 삼성전자 주주총결집 집회를 열고 발언하고 있다. ⓒ데일리안 김하랑 기자 [데일리안 = 김하랑 기자] 삼성전자 총파업을 하루 앞두고 노사가 극적 합의했지만, 주주들은 소송 가능성을 꺼내 들었다.

노사의 성과급 잠정합의안에 위법 소지가 있다는 취지다.

대한민국주주운동본부는 21일 서울 용산구 한강진역 이재용 삼성전자 회장 사택 인근에서 '삼성전자 주주총결집 집회'를 열고 삼성전자 임금협상 잠정합의안에 대한 반대 입장을 내놨다.

앞서 노조는 이날 총파업에 돌입할 예정이었지만, 전날 밤 2026년 임금협상 잠정합의안에 서명했다.

노사는 DS(반도체) 부문 특별경영성과급 지급 기준과 자사주 지급 방식 등에 합의했고, 이에 따라 예정됐던 총파업은 유보됐다.

최종안은 오는 22~27일까지, 공휴일 및 주말을 제외한 총 사흘간 노조 찬반투표를 거쳐 확정될 예정이다.

총파업이 멈췄음에도 주주 측이 집회를 강행한 건 노사 협상 과정에서 주주 의견이 충분히 반영되지 않았다는 판단 때문이다.

이날 집회에서는 노사의 '영업이익 약 12% 수준 성과급 재원 형성' 합의 구조가 핵심 쟁점으로 거론됐다.

주주운동본부는 영업이익 단계에서 일정 비율을 성과급 재원으로 책정하는 방식이 향후 주주 권익과 배당 재원에 영향을 줄 수 있으며, 사실상 주주총회 판단 대상이라고 주장했다.

민경권 대표는 "노사 합의에서 영업이익 12% 성과급이 최종안이라면 주총 승인 절차가 필요하다"며 "승인 없는 협약은 무효라는 점을 강조하기 위해 집회를 열었다"고 말했다.

본부는 이날 이재명 대통령의 최근 발언을 인용하며 주장에 힘을 실었다.

민 대표는 "이 대통령께서 지난 20일 국무회의에서 '영업이익에 대해 이익을 배분받는 것은 투자자, 주주가 하는 것'이라고 단호히 천명하신 만큼 그 주체인 주주 일동이 직접 사법체계 내에서 자신의 권리를 행사할 시점"이라고 강조했다.

본부는 향후 잠정합의안이 집행될 경우 ▲이사회 결의 무효 확인 소송 ▲주주대표소송 ▲효력정지 가처분 ▲단체협약 무효 소송 등을 추진하겠다고 밝혔다.

파업 재개 가능성에 대한 경고도 나왔다.

본부는 노조 찬반투표 부결 등으로 총파업이 재개돼 생산 차질이나 기업가치 훼손이 발생할 경우 노조 집행부와 참여 조합원을 상대로 손해배상 청구를 검토하겠다고 주장했다.

손해 항목으로는 생산 감소와 주가 하락, 향후 배당 재원 축소 등을 제시했다.

실제 총파업에 따른 생산 차질 가능성은 해외 언론과 증권가에서도 언급된 바 있다.

영국 통신사 로이터는 총파업이 현실화될 경우 삼성전자 반도체 공장의 직접 손실 규모가 하루 최대 1조원에 이를 수 있다고 전했다.

장기화 시에는 반도체 생산 차질과 공급망 충격으로 국내 경제 성장률이 최대 0.5%포인트 낮아질 수 있다는 전망도 나왔다.

실제 노사 갈등이 격화됐던 시기 삼성전자 주가 역시 크게 출렁였다.

노조가 총파업 계획을 유지하겠다고 밝힌 지난 15일 삼성전자 주가는 8.61% 급락했다.

반면 총파업 유보 소식이 전해진 이날 주가는 8.51% 상승 마감했다.

총파업 가능성 자체가 투자심리와 기업가치에 영향을 준 셈이다.

금융투자업계 관계자는 "총파업 자체는 유보됐지만 노사 갈등 과정에서 주가 변동성이 커질 경우 결국 피해는 주주에게 돌아갈 수밖에 없다"고 말했다.

이어 "향후 상장사 노사 협상에서도 기업가치와 주주 권익을 고려해야 한다는 요구는 더 커질 가능성이 있다"고 덧붙였다.

", + "tags": [ + "네이버뉴스", + "경제" + ], + "listedDate": "2026-05-21", + "publishedAt": "2026-05-21T07:41:07.000Z" + }, + "createdAt": "2026-05-28T03:35:06.434Z", + "updatedAt": "2026-05-28T03:35:06.434Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-6b598640df7eeb39", + "title": "“좋은 일자리가 도시 경쟁력”… 고양시 미래형 고용정책 속도", + "sourceLabel": "세계일보", + "url": "https://n.news.naver.com/mnews/article/022/0004131283", + "lead": "고양특례시가 지역 맞춤형 고용 정책과 미래형 기술 인재 양성을 앞세워 ‘양질의 일자리 도시’ 조성에 속도를 내고 있다. 청년부터 중장년층까지 촘촘한 고용 안전망 구축과 첨단산업 인재 육성을 통해 지속 가능한 지역 일자리 생태계를 확대하겠다는 구상이다.", + "body": "고양특례시가 지역 맞춤형 고용 정책과 미래형 기술 인재 양성을 앞세워 ‘양질의 일자리 도시’ 조성에 속도를 내고 있다. 청년부터 중장년층까지 촘촘한 고용 안전망 구축과 첨단산업 인재 육성을 통해 지속 가능한 지역 일자리 생태계를 확대하겠다는 구상이다.\n\n고양시가 개최한 중장년일자리박람회 현장 모습. 고양시 제공\n\n28일 고양시에 따르면 최근 ‘2026년 일자리대책 세부계획’을 수립·공시하고 고용률 67.7%, 취업자 수 55만4654명 달성을 목표로 제시했다. 시는 자족도시 조성과 연계한 일자리 창출, 지역 맞춤형 일자리 거버넌스 구축, 균형 있는 고용 환경 조성 등을 핵심 전략으로 삼고 총 438개 사업을 추진할 계획이다. 사업 분야는 직접 일자리부터 직업능력개발훈련, 고용서비스까지 폭넓게 포함됐다. 특히 시는 스마트농업과 원예·화훼산업 등 지역 특화 산업 육성과 함께 바이오·드론·영상 산업 등 미래 첨단산업 기반 확대에 집중하고 있다. 현장 중심 인재 양성을 위한 ‘고양맞춤형 일자리학교’ 사업도 본격화됐다. 시는 총 1억6000만원 규모 예산을 투입해 민간 전문기관과 협력한 직무 중심 교육과정을 운영하고 있다. 교육 분야는 물류 현장실무, 건물종합관리, 바이오산업 등 3개 분야로 총 75명이 참여할 예정이다.\n\n고양시가 주최한 ‘2025 인사담당자와 함께하는 청년 드림데이’ 현장 모습. 고양시 제공\n\n시는 교육에서 멈추는 게 아닌 실제 취업으로 이어질 수 있도록 원스톱 지원 체계를 구축해 지역 산업현장에 필요한 인력을 안정적으로 공급한다는 계획이다. 이에 더해 미래산업 인재 양성을 위한 기반 확충에도 속도를 내고 있다. 시는 최근 경기도일자리재단이 추진한 ‘경기도기술학교 북부 캠퍼스’ 유치 공모에 최종 선정됐다. 북부 캠퍼스는 덕양구 성사동 창조혁신캠퍼스 성사 9층에 약 825㎡ 규모로 조성되며 오는 6~7월쯤 정식 개소를 목표로 하고 있다. 캠퍼스에서는 전기설비와 인공지능(AI) 기반 소프트웨어 자동화 등 현장 중심 교육과정을 운영할 예정이다. 특히 AI 기반 교육과정은 내일꿈제작소에서 사전 운영을 시작하며 미래 산업 수요 대응에 나섰다. 시는 창조혁신캠퍼스 내 유휴공간을 활용해 예산 효율성을 높이고 하반기 약 80명의 교육생 유입에 따른 지역경제 활성화 효과도 기대하고 있다. 시관계자는 “모든 세대가 일터를 통해 꿈을 실현할 수 있도록 지역 특성을 반영한 맞춤형 고용 정책을 확대해 나갈 것”이라며 “양질의 일자리가 선순환하는 지속 가능한 일자리 도시를 만드는 데 최선을 다하겠다”고 말했다.", + "htmlBody": "

고양특례시가 지역 맞춤형 고용 정책과 미래형 기술 인재 양성을 앞세워 ‘양질의 일자리 도시’ 조성에 속도를 내고 있다. 청년부터 중장년층까지 촘촘한 고용 안전망 구축과 첨단산업 인재 육성을 통해 지속 가능한 지역 일자리 생태계를 확대하겠다는 구상이다.

\"고양시가
고양시가 개최한 중장년일자리박람회 현장 모습. 고양시 제공

28일 고양시에 따르면 최근 ‘2026년 일자리대책 세부계획’을 수립·공시하고 고용률 67.7%, 취업자 수 55만4654명 달성을 목표로 제시했다. 시는 자족도시 조성과 연계한 일자리 창출, 지역 맞춤형 일자리 거버넌스 구축, 균형 있는 고용 환경 조성 등을 핵심 전략으로 삼고 총 438개 사업을 추진할 계획이다. 사업 분야는 직접 일자리부터 직업능력개발훈련, 고용서비스까지 폭넓게 포함됐다. 특히 시는 스마트농업과 원예·화훼산업 등 지역 특화 산업 육성과 함께 바이오·드론·영상 산업 등 미래 첨단산업 기반 확대에 집중하고 있다. 현장 중심 인재 양성을 위한 ‘고양맞춤형 일자리학교’ 사업도 본격화됐다. 시는 총 1억6000만원 규모 예산을 투입해 민간 전문기관과 협력한 직무 중심 교육과정을 운영하고 있다. 교육 분야는 물류 현장실무, 건물종합관리, 바이오산업 등 3개 분야로 총 75명이 참여할 예정이다.

\"고양시가
고양시가 주최한 ‘2025 인사담당자와 함께하는 청년 드림데이’ 현장 모습. 고양시 제공

시는 교육에서 멈추는 게 아닌 실제 취업으로 이어질 수 있도록 원스톱 지원 체계를 구축해 지역 산업현장에 필요한 인력을 안정적으로 공급한다는 계획이다. 이에 더해 미래산업 인재 양성을 위한 기반 확충에도 속도를 내고 있다. 시는 최근 경기도일자리재단이 추진한 ‘경기도기술학교 북부 캠퍼스’ 유치 공모에 최종 선정됐다. 북부 캠퍼스는 덕양구 성사동 창조혁신캠퍼스 성사 9층에 약 825㎡ 규모로 조성되며 오는 6~7월쯤 정식 개소를 목표로 하고 있다. 캠퍼스에서는 전기설비와 인공지능(AI) 기반 소프트웨어 자동화 등 현장 중심 교육과정을 운영할 예정이다. 특히 AI 기반 교육과정은 내일꿈제작소에서 사전 운영을 시작하며 미래 산업 수요 대응에 나섰다. 시는 창조혁신캠퍼스 내 유휴공간을 활용해 예산 효율성을 높이고 하반기 약 80명의 교육생 유입에 따른 지역경제 활성화 효과도 기대하고 있다. 시관계자는 “모든 세대가 일터를 통해 꿈을 실현할 수 있도록 지역 특성을 반영한 맞춤형 고용 정책을 확대해 나갈 것”이라며 “양질의 일자리가 선순환하는 지속 가능한 일자리 도시를 만드는 데 최선을 다하겠다”고 말했다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "signals": [ + "주목" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:14:13+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-0a501ab0bbaf549f", + "title": "“1㎞만 뚫자”… 수도권 동북부∼강남 직결 철도망 추진", + "sourceLabel": "세계일보", + "url": "https://n.news.naver.com/mnews/article/022/0004131284", + "lead": "더불어민주당 김병주 국회의원실 주관 수인분당선 추진위 29일 정책협약식", + "body": "더불어민주당 김병주 국회의원실 주관 수인분당선 추진위 29일 정책협약식\n\n왕십리~청량리 1㎞ 구간 연결로 수도권 동북부에서 인천까지 환승 없이 이동할 수 있는 광역철도망 구축을 추진한다. 더불어민주당 김병주 국회의원실과 수인분당선 추진위원회가 공동으로 29일 오전 10시 최현덕 남양주시장 후보 캠프 사무실에서 ‘수도권 동북부 철도 연결 효율화 정책협약식’을 개최한다. 이날 자리에는 민주당 소속의 최현덕 남양주시장·신동화 구리시장·류경기 중랑구청장·최동민 동대문구청장 후보 캠프가 동시에 참여한다.\n\n협약 참여 주체들은 향후 △왕십리~청량리 단선전철 신설 추진 △수인분당선 청량리 연장 운행 확대 △경춘선~수인분당선 직결 추진 △국토교통부·국가철도공단·한국철도공사 등 관계기관 협의 △수도권 동북부 주민 교통권 보장을 위한 공동 대응에 나설 예정이다. 이번 협약은 왕십리~청량리역 약 1km 구간 단선전철 신설을 통해 수인분당선의 청량리 연장 운행을 확대하고 향후 경춘선·수인분당선 직결 운행을 추진하기 위해 마련됐다. 현재 수인분당선은 인천·수원·분당·선릉·왕십리를 잇는 핵심 노선이지만, 청량리까지의 연장 운행은 왕십리~청량리 구간 병목으로 인해 매우 제한적으로 이뤄지고 있다. 결과적으로 남양주·구리·중랑·동대문 등 수도권 동북부 주민들은 강남권과 경기 남부, 인천권으로 이동할 때 환승과 긴 대기시간을 감수해야 하는 실정이다. 김병주 의원실은 이번 협약을 통해 수인분당선 직결 추진을 단순한 지역 민원 차원이 아니라 수도권 동북부 전체의 교통 불균형 해소와 광역철도 효율화 과제로 확장한다는 계획이다. 김 의원은 “수도권 동북부 주민들이 강남·수원·인천으로 이동하기 위해 매일 환승과 대기시간을 감수하고 있다”며 “왕십리~청량리 1km 병목을 해소하는 것은 단순한 철도사업이 아니라 시민의 시간을 돌려드리는 민생사업”이라고 강조했다. 이어 “국가철도공단 검토자료에서도 GTX-B 사업 완료 시 청량리~망우 구간 선로포화도가 96.3%에서 80.4%로 개선될 것으로 전망됐다”며 “이제는 이 여력을 수도권 동북부 주민의 교통권 향상으로 연결해야 한다”고 밝혔다. 또한 “수조 원 규모의 대형 신규 노선만이 답은 아니다. 이미 있는 철도망을 효율적으로 연결하고, 단절된 1km를 해소하는 것만으로도 남양주·구리·중랑·동대문 주민들이 강남·수원·인천까지 환승 없이 이동할 수 있는 길이 열린다”며 “이것이야말로 저비용·고효율의 대표적인 교통혁신 사업”이라고 말했다.", + "htmlBody": "

더불어민주당 김병주 국회의원실 주관 수인분당선 추진위 29일 정책협약식

왕십리~청량리 1㎞ 구간 연결로 수도권 동북부에서 인천까지 환승 없이 이동할 수 있는 광역철도망 구축을 추진한다. 더불어민주당 김병주 국회의원실과 수인분당선 추진위원회가 공동으로 29일 오전 10시 최현덕 남양주시장 후보 캠프 사무실에서 ‘수도권 동북부 철도 연결 효율화 정책협약식’을 개최한다. 이날 자리에는 민주당 소속의 최현덕 남양주시장·신동화 구리시장·류경기 중랑구청장·최동민 동대문구청장 후보 캠프가 동시에 참여한다.

\"더불어민주당
더불어민주당 김병주 국회의원.

협약 참여 주체들은 향후 △왕십리~청량리 단선전철 신설 추진 △수인분당선 청량리 연장 운행 확대 △경춘선~수인분당선 직결 추진 △국토교통부·국가철도공단·한국철도공사 등 관계기관 협의 △수도권 동북부 주민 교통권 보장을 위한 공동 대응에 나설 예정이다. 이번 협약은 왕십리~청량리역 약 1km 구간 단선전철 신설을 통해 수인분당선의 청량리 연장 운행을 확대하고 향후 경춘선·수인분당선 직결 운행을 추진하기 위해 마련됐다. 현재 수인분당선은 인천·수원·분당·선릉·왕십리를 잇는 핵심 노선이지만, 청량리까지의 연장 운행은 왕십리~청량리 구간 병목으로 인해 매우 제한적으로 이뤄지고 있다. 결과적으로 남양주·구리·중랑·동대문 등 수도권 동북부 주민들은 강남권과 경기 남부, 인천권으로 이동할 때 환승과 긴 대기시간을 감수해야 하는 실정이다. 김병주 의원실은 이번 협약을 통해 수인분당선 직결 추진을 단순한 지역 민원 차원이 아니라 수도권 동북부 전체의 교통 불균형 해소와 광역철도 효율화 과제로 확장한다는 계획이다. 김 의원은 “수도권 동북부 주민들이 강남·수원·인천으로 이동하기 위해 매일 환승과 대기시간을 감수하고 있다”며 “왕십리~청량리 1km 병목을 해소하는 것은 단순한 철도사업이 아니라 시민의 시간을 돌려드리는 민생사업”이라고 강조했다. 이어 “국가철도공단 검토자료에서도 GTX-B 사업 완료 시 청량리~망우 구간 선로포화도가 96.3%에서 80.4%로 개선될 것으로 전망됐다”며 “이제는 이 여력을 수도권 동북부 주민의 교통권 향상으로 연결해야 한다”고 밝혔다. 또한 “수조 원 규모의 대형 신규 노선만이 답은 아니다. 이미 있는 철도망을 효율적으로 연결하고, 단절된 1km를 해소하는 것만으로도 남양주·구리·중랑·동대문 주민들이 강남·수원·인천까지 환승 없이 이동할 수 있는 길이 열린다”며 “이것이야말로 저비용·고효율의 대표적인 교통혁신 사업”이라고 말했다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "signals": [ + "주목" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:14:16+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-47684342c09a71ac", + "title": "법조계 “스벅 강제수사 어려울 듯”… 난감한 警", + "sourceLabel": "문화일보", + "url": "https://n.news.naver.com/mnews/article/021/0002794120", + "lead": "모욕·명예훼손 등 혐의 요건 미비해 경찰이 신세계그룹 계열사 스타벅스코리아의 ‘5·18 탱크데이’ 마케팅 관련 고소·고발 사건을 수사하고 있지만, 법조계에서는 정용진 신세계 회장 등에 대한 압수수색을 포함한 강제수사는 어려울 것이라는 평가가 나오고 있다. 28일 경찰 등에 따르면 서울경찰청 광역수사단 공공범죄수사대는 정 회장 등을 모욕·명예훼손, 5·18민주화운동 특별법 위반 혐의 사건 관련 고소·고발인 측 조사를 마치고 정 회장 소환 및 압수수색 가능성을 검토하고 있는 것으로 알려졌다. 법조계에서는 강제수사는 법리상 어렵다는 평가가 지배적이다.", + "body": "모욕·명예훼손 등 혐의 요건 미비해 경찰이 신세계그룹 계열사 스타벅스코리아의 ‘5·18 탱크데이’ 마케팅 관련 고소·고발 사건을 수사하고 있지만, 법조계에서는 정용진 신세계 회장 등에 대한 압수수색을 포함한 강제수사는 어려울 것이라는 평가가 나오고 있다. 28일 경찰 등에 따르면 서울경찰청 광역수사단 공공범죄수사대는 정 회장 등을 모욕·명예훼손, 5·18민주화운동 특별법 위반 혐의 사건 관련 고소·고발인 측 조사를 마치고 정 회장 소환 및 압수수색 가능성을 검토하고 있는 것으로 알려졌다. 법조계에서는 강제수사는 법리상 어렵다는 평가가 지배적이다.\n\n김종민 법무법인 MK 대표변호사는 “압수수색을 하려면 범죄 혐의가 소명돼야 하는데 정 회장에 대한 혐의는 고의성·피해자 특정 등 혐의 요건 자체가 갖춰지지 않았다”고 말했다. 경찰 관계자도 “5·18 특별법은 허위사실 유포 금지 조항만 있어 적용할 수 없고 명예훼손죄가 성립하기도 어렵다”고 설명했다. 다만, 이재명 대통령이 이번 사태를 언급한 만큼 실무자 5명에 대한 강제수사 가능성은 여전하다.", + "htmlBody": "

모욕·명예훼손 등 혐의 요건 미비해 경찰이 신세계그룹 계열사 스타벅스코리아의 ‘5·18 탱크데이’ 마케팅 관련 고소·고발 사건을 수사하고 있지만, 법조계에서는 정용진 신세계 회장 등에 대한 압수수색을 포함한 강제수사는 어려울 것이라는 평가가 나오고 있다. 28일 경찰 등에 따르면 서울경찰청 광역수사단 공공범죄수사대는 정 회장 등을 모욕·명예훼손, 5·18민주화운동 특별법 위반 혐의 사건 관련 고소·고발인 측 조사를 마치고 정 회장 소환 및 압수수색 가능성을 검토하고 있는 것으로 알려졌다. 법조계에서는 강제수사는 법리상 어렵다는 평가가 지배적이다.

김종민 법무법인 MK 대표변호사는 “압수수색을 하려면 범죄 혐의가 소명돼야 하는데 정 회장에 대한 혐의는 고의성·피해자 특정 등 혐의 요건 자체가 갖춰지지 않았다”고 말했다. 경찰 관계자도 “5·18 특별법은 허위사실 유포 금지 조항만 있어 적용할 수 없고 명예훼손죄가 성립하기도 어렵다”고 설명했다. 다만, 이재명 대통령이 이번 사태를 언급한 만큼 실무자 5명에 대한 강제수사 가능성은 여전하다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:14:20+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-46327dd9e6335d92", + "title": "현대차 인니법인 현지생산량 5위 유지..일본 브랜드 압도적 생산량 속 선전", + "sourceLabel": "파이낸셜뉴스", + "url": "https://n.news.naver.com/mnews/article/014/0005527567", + "lead": "인도네시아 서부자바주 브카시군 치카랑 델타마스 지역에 위치한 현대차 생산시설 내부 전경. 현대차 인도네시아 법인 제공", + "body": "인도네시아 서부자바주 브카시군 치카랑 델타마스 지역에 위치한 현대차 생산시설 내부 전경. 현대차 인도네시아 법인 제공\n\n【자카르타(인도네시아)=아울리아 마울리다 함다니 통신원】현대자동차가 인도네시아 자동차 생산 시장에서 5위에 오르며 현지 생산 및 전기차 전략 확대에 속도를 내고 있다. 크레타와 아이오닉5 등 주요 모델 생산을 기반으로 현대차는 인도네시아를 동남아시아 핵심 생산·수출 거점으로 육성한다는 전략이다.\n\n27일 현지 언론에 따르면 인도네시아자동차산업협회(가이킨도)는 현대차가 올해 1~4월 인도네시아에서 총 1만7250대의 차량을 생산했다고 밝혔다.\n\n해당기간 인도네시아 자동차 생산 순위에서는 도요타가 18만802대로 독보적인 1위를 유지하고 있으며, 미쓰비시모터스 5만9289대, 다이하쓰 4만7951대, 스즈키 2만8849대를 기록하는 등 일본 브랜드가 시장을 거의 독식하고 있다.\n\n현대차는 1만7250대를 생산하며 5위에 올랐고, 혼다 1만3420대, 제이쿠 1만1073대, 미쓰비시후소 1만956대, 이스즈 9921대, 울링 6557대 등의 순위를 보이고 있다.\n\n현대차는 지난해 생산량에서도 7만866대로 전체 5위를 기록했다. 도요타는 51만1248대로 1위를 차지했고, 미쓰비시모터스 16만6180대, 다이하쓰 13만441대, 스즈키 8만7945대 순이었다.\n\n현대차는 현지 생산시설을 통해 크레타, 스타게이저, 코나 EV, 아이오닉5 등 전략 모델을 생산하고 있다. 특히 코나 EV와 아이오닉5 등 전기차 생산 확대는 현대차가 추진 중인 인도네시아 전기차 생태계 구축 전략의 핵심 축으로 평가된다.\n\n현대차의 인도네시아 생산 차량은 내수 시장뿐 아니라 해외 수출에도 활용되고 있다. 업계에서는 현대차가 인도네시아를 동남아시아 시장 공략을 위한 핵심 생산·수출 허브로 적극 육성하고 있는 것으로 분석하고 있다.\n\n업계에서는 현대차가 일본 브랜드 중심의 인도네시아 자동차 시장에서 생산 규모와 전기차 투자 확대를 통해 존재감을 빠르게 키우고 있다고 평가했다. 현대차는 향후 전기차 및 배터리 공급망 투자 확대와 현지 부품 조달 강화 등을 통해 인도네시아를 동남아시아 지역 핵심 EV 생산 허브로 육성한다는 전략이다.", + "htmlBody": "
\"인도네시아

인도네시아 서부자바주 브카시군 치카랑 델타마스 지역에 위치한 현대차 생산시설 내부 전경. 현대차 인도네시아 법인 제공

【자카르타(인도네시아)=아울리아 마울리다 함다니 통신원】현대자동차가 인도네시아 자동차 생산 시장에서 5위에 오르며 현지 생산 및 전기차 전략 확대에 속도를 내고 있다. 크레타와 아이오닉5 등 주요 모델 생산을 기반으로 현대차는 인도네시아를 동남아시아 핵심 생산·수출 거점으로 육성한다는 전략이다.

27일 현지 언론에 따르면 인도네시아자동차산업협회(가이킨도)는 현대차가 올해 1~4월 인도네시아에서 총 1만7250대의 차량을 생산했다고 밝혔다.

해당기간 인도네시아 자동차 생산 순위에서는 도요타가 18만802대로 독보적인 1위를 유지하고 있으며, 미쓰비시모터스 5만9289대, 다이하쓰 4만7951대, 스즈키 2만8849대를 기록하는 등 일본 브랜드가 시장을 거의 독식하고 있다.

현대차는 1만7250대를 생산하며 5위에 올랐고, 혼다 1만3420대, 제이쿠 1만1073대, 미쓰비시후소 1만956대, 이스즈 9921대, 울링 6557대 등의 순위를 보이고 있다.

현대차는 지난해 생산량에서도 7만866대로 전체 5위를 기록했다. 도요타는 51만1248대로 1위를 차지했고, 미쓰비시모터스 16만6180대, 다이하쓰 13만441대, 스즈키 8만7945대 순이었다.

현대차는 현지 생산시설을 통해 크레타, 스타게이저, 코나 EV, 아이오닉5 등 전략 모델을 생산하고 있다. 특히 코나 EV와 아이오닉5 등 전기차 생산 확대는 현대차가 추진 중인 인도네시아 전기차 생태계 구축 전략의 핵심 축으로 평가된다.

현대차의 인도네시아 생산 차량은 내수 시장뿐 아니라 해외 수출에도 활용되고 있다. 업계에서는 현대차가 인도네시아를 동남아시아 시장 공략을 위한 핵심 생산·수출 허브로 적극 육성하고 있는 것으로 분석하고 있다.

업계에서는 현대차가 일본 브랜드 중심의 인도네시아 자동차 시장에서 생산 규모와 전기차 투자 확대를 통해 존재감을 빠르게 키우고 있다고 평가했다. 현대차는 향후 전기차 및 배터리 공급망 투자 확대와 현지 부품 조달 강화 등을 통해 인도네시아를 동남아시아 지역 핵심 EV 생산 허브로 육성한다는 전략이다.

", + "tags": [ + "네이버뉴스", + "경제" + ], + "signals": [ + "주목" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:15:12+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-6bb7444b5566d3e8", + "title": "수소 산업계 \"지방선거 후보자 수소경제 육성 공약 환영\"", + "sourceLabel": "아시아경제", + "url": "https://n.news.naver.com/mnews/article/277/0005768957", + "lead": "\"선거 이후 실행계획·예산·제도로 구체화해야\"", + "body": "\"선거 이후 실행계획·예산·제도로 구체화해야\"\n\n한국수소연합, 한국수소연료전지산업협회, 한국수소산업협회, 한국수소환경협회, 한국수소및신에너지학회, 수소소부장연구조합 등 6개 수소 관련 협단체는 오는 6월 3일 치러지는 제 9회 전국동시지방선거 후보자들이 발표한 '수소경제 육성 정책 공약'을 환영하고 추후 이행을 촉구하는 공동 성명서를 28일 발표했다.\n\n수소 산업계는 공동 성명서에서 \"지방선거 후보자들의 청정수소 생태계구축, 수소환원제철, 수소모빌리티 및 연료전지 확대, 수소산업단지 조성, 수소도시 및 항만 인프라 구축 등 수소 산업 육성 공약을 적극 환영한다\"고 밝혔다.\n\n수소 협단체들은 \"수소 산업은 탄소중립 실현과 에너지 안보 강화는 물론, 지역 산업기반 조성, 기업투자 확대, 일자리 창출과 직결되는 지역경제 핵심 미래산업\"이라며 \"철강·석유화학·발전·수송 등 주력산업의 저탄소 전환을 견인할 수 있는 전략산업으로서 지자체의 정책 의지와 실행력이 무엇보다 중요하다\"고 강조했다.\n\n수소 산업계는 \"소산 업계는 해당 공약들이 선거 이후 지역별 실행 계획과 예산·제도 지원으로 구체화해 지역 수소경제 활성화로 이어지기를 기대한다\"며 \"산업계도 투자·기술개발·인프라 구축 및 정책협력에 적극 동참해 수소 산업 육성에 힘을 보탤 것\"이라고 밝혔다.\n\n한국수소연합 관계자는 \"이란 전쟁 장기화로 수소 업계의 누적된 문제들이 심화돼 경영난으로 이어지고 있다\"면서 \"이번 지방선거가 업계의 어려움을 타개하고, 수소경제를 활성화하는 계기가 되길 기대한다\"고 말했다.", + "htmlBody": "

"선거 이후 실행계획·예산·제도로 구체화해야"

\"인천공항

인천공항 제2터미널에 구축된 수소 충전소.

한국수소연합, 한국수소연료전지산업협회, 한국수소산업협회, 한국수소환경협회, 한국수소및신에너지학회, 수소소부장연구조합 등 6개 수소 관련 협단체는 오는 6월 3일 치러지는 제 9회 전국동시지방선거 후보자들이 발표한 '수소경제 육성 정책 공약'을 환영하고 추후 이행을 촉구하는 공동 성명서를 28일 발표했다.

수소 산업계는 공동 성명서에서 "지방선거 후보자들의 청정수소 생태계구축, 수소환원제철, 수소모빌리티 및 연료전지 확대, 수소산업단지 조성, 수소도시 및 항만 인프라 구축 등 수소 산업 육성 공약을 적극 환영한다"고 밝혔다.

수소 협단체들은 "수소 산업은 탄소중립 실현과 에너지 안보 강화는 물론, 지역 산업기반 조성, 기업투자 확대, 일자리 창출과 직결되는 지역경제 핵심 미래산업"이라며 "철강·석유화학·발전·수송 등 주력산업의 저탄소 전환을 견인할 수 있는 전략산업으로서 지자체의 정책 의지와 실행력이 무엇보다 중요하다"고 강조했다.

수소 산업계는 "소산 업계는 해당 공약들이 선거 이후 지역별 실행 계획과 예산·제도 지원으로 구체화해 지역 수소경제 활성화로 이어지기를 기대한다"며 "산업계도 투자·기술개발·인프라 구축 및 정책협력에 적극 동참해 수소 산업 육성에 힘을 보탤 것"이라고 밝혔다.

한국수소연합 관계자는 "이란 전쟁 장기화로 수소 업계의 누적된 문제들이 심화돼 경영난으로 이어지고 있다"면서 "이번 지방선거가 업계의 어려움을 타개하고, 수소경제를 활성화하는 계기가 되길 기대한다"고 말했다.

", + "tags": [ + "네이버뉴스", + "경제" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:15:47+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-00c96b0fd7981f7b", + "title": "\"충북의 미래 결정\" 도지사 후보들 사전투표 참여 호소", + "sourceLabel": "뉴스1", + "url": "https://n.news.naver.com/mnews/article/421/0008970834", + "lead": "신용한 \"자화자찬 공치사에 멈춘 충북 다시 뛰어야\" 김영환 \"지역발전, 누가 해낼 수 있는지 냉정히 판단\"", + "body": "신용한 \"자화자찬 공치사에 멈춘 충북 다시 뛰어야\" 김영환 \"지역발전, 누가 해낼 수 있는지 냉정히 판단\"\n\n신용한 더불어민주당 충북지사 후보가 28일 도청 브리핑룸에서 기자회견을 해 6·3 지방선거 사전투표 참여를 독려하고 있다. 2026.5.28/뉴스1\n\n(청주=뉴스1) 김용빈 기자 = 6·3 지방선거 사전투표를 하루 앞둔 28일 충북지사 후보들이 투표 참여를 독려했다.\n\n신용한 더불어민주당 충북지사 후보는 이날 도청 브리핑룸에서 기자회견을 열어 \"이번 선거는 단순한 선거가 아니라 충북의 앞으로 4년, 나아가 10년 미래를 결정하는 중요한 선택\"이라며 \"화려한 자화자찬 공치사 앞에 멈춰 있는 충북을 다시 뛰게 할 것인지가 이번 선거에 달려 있다\"고 했다.\n\n그는 \"지금 충북은 반도체와 바이오 첨단산업의 성장 가능성을 갖추고 있음에도 그 가능성을 제대로 살리지 못하고 있다\"며 \"도민의 삶의 질을 바꾸는 실력 있는 도지사가 돼 말만 하는 정치가 아니라 결과를 만드는 행정을 하겠다\"고 약속했다.\n\n이어 \"선거운동 마지막 순간까지 단 한 순간도 멈추지 않겠다. 선거운동 마지막 48시간은 무박 2일 연속 선거운동에 돌입하겠다\"며 \"새벽 시장과 골목 상권, 산업단지, 농촌 현장에서 도민의 목소리를 끝까지 듣고 충북 변화를 호소하겠다\"고 강조했다.\n\n그러면서 \"여러분의 한 표가 청년의 일자리를 만들고 지역경제를 살리며, 우리 아이들의 미래를 바꾼다\"며 \"충북의 변화를 위해 내일부터 시작되는 사전투표에 꼭 참여해 소중한 한 표를 행사해 주길 부탁드린다\"고 덧붙였다.\n\n전날 김영환 국민의힘 후보 측이 공개 질의한 재산과 채권 형성 과정 질의에는 \"오래전 코스닥 기업에 투자했고 돌려받지 못했다. 승소 판결에 기초한 채권\"이라며 \"보험 담보 대출 역시 무이자에 가까워 가장 쓰기 좋은 대출로 더 이상 설명할 가치가 없는 의혹 제기\"라고 답했다.\n\n김영환 국민의힘 충북지사 후보가 28일 도청 브리핑룸에서 기자회견을 해 6·3 지방선거 사전투표 참여를 독려하고 있다. 2026.5.28/뉴스1\n\n김영환 후보 역시 같은 자리에서 기자회견을 하고 신 후보를 둘러싼 각종 의혹에 대한 해명을 촉구함과 동시에 사전투표 참여를 독려하기도 했다.\n\n김 후보는 \"신 후보는 미래 비전과 정책 경쟁보다 오로지 대통령만 내세우며 여론 중심의 선거를 치르고 있다\"며 \"대통령 지지율로만 도민 선택을 받으려는 선거는 도민의 정치 참여를 무력화하는 처사\"라고 지적했다.\n\n이어 \"부정선거 의혹과 재산, 세금, 채권 문제 등에 대해 도민들이 궁금해하고 있다\"며 \"눈 가리고 아웅 식으로 넘어갈 것이 아니라 도민께 소상히 밝혀야 한다\"고 했다.\n\n또 \"충분한 검증 없이 선거가 치러지고 이후 문제가 드러난다면 극단적으로 재선거 상황까지 갈 수 있다\"며 \"도민들께서 판단할 수 있도록 정책으로 끝장 토론을 하자\"고 제안했다.\n\n끝으로 김 후보는 \"도민들께서 충북의 미래를 어떻게 만들어 갈 것인지 신중하게 판단해 달라\"며 \"내일부터 이틀간 진행하는 사전투표에 참여해 반드시 소중한 한 표를 행사해 달라\"고 당부했다.\n\n사전투표는 28~29일 오전 6시부터 오후 6시까지 주소지와 관계없이 전국 사전투표소 어디서나 할 수 있다.", + "htmlBody": "

신용한 "자화자찬 공치사에 멈춘 충북 다시 뛰어야" 김영환 "지역발전, 누가 해낼 수 있는지 냉정히 판단"

\"신용한

신용한 더불어민주당 충북지사 후보가 28일 도청 브리핑룸에서 기자회견을 해 6·3 지방선거 사전투표 참여를 독려하고 있다. 2026.5.28/뉴스1

(청주=뉴스1) 김용빈 기자 = 6·3 지방선거 사전투표를 하루 앞둔 28일 충북지사 후보들이 투표 참여를 독려했다.

신용한 더불어민주당 충북지사 후보는 이날 도청 브리핑룸에서 기자회견을 열어 "이번 선거는 단순한 선거가 아니라 충북의 앞으로 4년, 나아가 10년 미래를 결정하는 중요한 선택"이라며 "화려한 자화자찬 공치사 앞에 멈춰 있는 충북을 다시 뛰게 할 것인지가 이번 선거에 달려 있다"고 했다.

그는 "지금 충북은 반도체와 바이오 첨단산업의 성장 가능성을 갖추고 있음에도 그 가능성을 제대로 살리지 못하고 있다"며 "도민의 삶의 질을 바꾸는 실력 있는 도지사가 돼 말만 하는 정치가 아니라 결과를 만드는 행정을 하겠다"고 약속했다.

이어 "선거운동 마지막 순간까지 단 한 순간도 멈추지 않겠다. 선거운동 마지막 48시간은 무박 2일 연속 선거운동에 돌입하겠다"며 "새벽 시장과 골목 상권, 산업단지, 농촌 현장에서 도민의 목소리를 끝까지 듣고 충북 변화를 호소하겠다"고 강조했다.

그러면서 "여러분의 한 표가 청년의 일자리를 만들고 지역경제를 살리며, 우리 아이들의 미래를 바꾼다"며 "충북의 변화를 위해 내일부터 시작되는 사전투표에 꼭 참여해 소중한 한 표를 행사해 주길 부탁드린다"고 덧붙였다.

전날 김영환 국민의힘 후보 측이 공개 질의한 재산과 채권 형성 과정 질의에는 "오래전 코스닥 기업에 투자했고 돌려받지 못했다. 승소 판결에 기초한 채권"이라며 "보험 담보 대출 역시 무이자에 가까워 가장 쓰기 좋은 대출로 더 이상 설명할 가치가 없는 의혹 제기"라고 답했다.

\"김영환

김영환 국민의힘 충북지사 후보가 28일 도청 브리핑룸에서 기자회견을 해 6·3 지방선거 사전투표 참여를 독려하고 있다. 2026.5.28/뉴스1

김영환 후보 역시 같은 자리에서 기자회견을 하고 신 후보를 둘러싼 각종 의혹에 대한 해명을 촉구함과 동시에 사전투표 참여를 독려하기도 했다.

김 후보는 "신 후보는 미래 비전과 정책 경쟁보다 오로지 대통령만 내세우며 여론 중심의 선거를 치르고 있다"며 "대통령 지지율로만 도민 선택을 받으려는 선거는 도민의 정치 참여를 무력화하는 처사"라고 지적했다.

이어 "부정선거 의혹과 재산, 세금, 채권 문제 등에 대해 도민들이 궁금해하고 있다"며 "눈 가리고 아웅 식으로 넘어갈 것이 아니라 도민께 소상히 밝혀야 한다"고 했다.

또 "충분한 검증 없이 선거가 치러지고 이후 문제가 드러난다면 극단적으로 재선거 상황까지 갈 수 있다"며 "도민들께서 판단할 수 있도록 정책으로 끝장 토론을 하자"고 제안했다.

끝으로 김 후보는 "도민들께서 충북의 미래를 어떻게 만들어 갈 것인지 신중하게 판단해 달라"며 "내일부터 이틀간 진행하는 사전투표에 참여해 반드시 소중한 한 표를 행사해 달라"고 당부했다.

사전투표는 28~29일 오전 6시부터 오후 6시까지 주소지와 관계없이 전국 사전투표소 어디서나 할 수 있다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:16:05+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-698f5cda3bc2242e", + "title": "‘한덕수 재판 위증’ 尹 무죄…“처음부터 국무위원 소집 계획”", + "sourceLabel": "중앙일보", + "url": "https://n.news.naver.com/mnews/article/025/0003526371", + "lead": "윤석열 전 대통령과 한덕수 전 국무총리가 2023년 9월 25일 서울 용산 대통령실 청사에서 열린 국무회의에 입장하고 있다.", + "body": "윤석열 전 대통령과 한덕수 전 국무총리가 2023년 9월 25일 서울 용산 대통령실 청사에서 열린 국무회의에 입장하고 있다.\n\n한덕수 전 국무총리의 내란 재판에서 “계엄 국무회의 소집을 계획했었다”는 취지로 위증한 혐의를 받는 윤석열 전 대통령이 무죄를 선고받았다. 재판부는 “기억에 반하는 진술이라 보기 어렵다”고 판단했다.\n\n28일 서울중앙지법 형사32부(부장 류경진)는 윤 전 대통령의 위증 혐의에 대해 “범죄의 증명이 없다”고 판단했다. 윤 전 대통령은 지난해 11월 19일 한 전 총리의 내란중요임무종사 등 혐의 재판에 증인으로 출석해 한 전 총리 건의 전부터 국무회의를 계획한 것처럼 허위로 증언한 혐의로 기소됐다. 재판부는 “한 전 총리 건의와 상관없이 처음부터 국무위원 소집 계획을 가졌을 가능성이 높아 보인다”고 판단했다.\n\n남색 수트를 입은 윤 전 대통령은 왼쪽 가슴에 수인번호 명찰을 단 채 입정했다. 피고인석에 앉은 윤 전 대통령은 옅은 미소를 띠고 방청석을 바라본 뒤 변호인과 대화를 나눴다. 재판부는 윤 전 대통령을 자리에서 일어나게 한 뒤 5분가량 선고문을 낭독했다. 윤 전 대통령은 선고 내내 양손을 옆으로 내린 차렷 자세로 정면을 응시했다.\n\n윤석열 전 대통령이 2025년 11월 19일 서울 서초구 서울중앙지방법원에서 열린 한덕수 전 국무총리의 내란 재판에 증인으로 출석해 한 전 총리가 계엄 선포에 반대했다는 취지로 증언하고 있다. 연합뉴스\n\n“한덕수 건의 상관없이 처음부터 소집 계획”\n\n윤 전 대통령은 계엄 당일 2024년 12월 3일 오후 8시14분부터 한 전 총리, 김용현 전 국방부 장관 등을 집무실로 불러 오후 9시 9분까지 회의했다. 윤 전 대통령은 이 자리에서 계엄 선포 계획을 알렸고 계엄 문건을 교부했다. 이후 오후 9시11분쯤 김정환 전 수행실장에게 최상목 전 경제부총리 겸 기획재정부 장관 등 특정해 대통령실로 부르라고 지시했다.\n\n재판부는 “이 사건 공소사실은 한 전 총리가 첫 회동 당시 국무회의가 필요하다고 건의하자 비로소 의사정족수를 갖추기 위해 계획에 없던 최 전 장관 등 6명을 소집했다는 점을 전제로 한다”고 짚었다. 그러면서 사전에 국무회의를 계획하지 않았다가 당일 오후 9시14분쯤 한 전 총리 제안에 단지 형식적 외관을 갖추기 위해 국무회의를 열었다는 특검팀 주장을 받아들이지 않았다.\n\n재판부는 “한 전 총리가 국무회의 개최를 건의했더라도 최초 회동 이후에 2차로 연락받고 집무실로 온 최 전 장관에게 교부할 계엄 관련 문건이 미리 준비돼있었고, 2차 국무회의 소집 계획을 가지고 있던 것으로 보인다”고 판단했다. 당시 국무위원 중 빨리 도착할 수 있는 4명을 특정한 게 아니라 6명을 특정해 연락한 점도 근거가 됐다. 재판부는 “한 전 총리 등은 오후 9시29분쯤 전까진 추가 국무회의를 소집할 것이라 인지하지 못했다”며 “윤 전 대통령이 한 전 총리 건의 상관없이 처음부터 위원 소집 계획을 가졌을 가능성이 높아 보인다”고 했다.\n\n재판부는 김 전 장관이 계엄 직후인 12월 8일 경찰 조사에서 윤 전 대통령이 계엄에 뭐가 필요한지 묻자 계엄 선포문 안건과 포고령 상정이 필요하다고 진술한 점도 근거로 들었다. 또 이상민 전 행정안전부 장관 등이 공통적으로 “최초 집무실 회동에서는 국무회의 개최 얘기가 없었고 대접견실 회의에서 얘기가 나왔다”고 진술한 점도 인정됐다.\n\n재판부는 “위증죄는 경험한 사실에 관해 기억에 반하는 사실을 진술했을 때 성립하고 주관적 평가는 대상이 되지 않는다”며 “처음부터 의사정족수를 갖춘 국무회의를 소집하려 했다는 윤 전 대통령의 진술은 주관적 평가에 불과하기 때문에 사실관계에 관한 기억에 반하는 진술이라 보기 어려워 위증으로 보기 어렵다”고 판시했다. 다만 재판부는 국무회의가 법률상 심의에 해당할 수 있는지에 대해서는 별도로 판단하지 않았다.\n\n윤 전 대통령은 무죄 선고가 나고 떨떠름한 표정으로 서있었으나 이내 미소를 머금었다. 이후 변호인단과 대화를 나누고 악수한 후 퇴정했다. 윤 전 대통령 변호인단은 “한 전 총리의 거짓 진술에 기대 기소한 결과”라고 말했다.", + "htmlBody": "
\"윤석열

윤석열 전 대통령과 한덕수 전 국무총리가 2023년 9월 25일 서울 용산 대통령실 청사에서 열린 국무회의에 입장하고 있다.

한덕수 전 국무총리의 내란 재판에서 “계엄 국무회의 소집을 계획했었다”는 취지로 위증한 혐의를 받는 윤석열 전 대통령이 무죄를 선고받았다. 재판부는 “기억에 반하는 진술이라 보기 어렵다”고 판단했다.

28일 서울중앙지법 형사32부(부장 류경진)는 윤 전 대통령의 위증 혐의에 대해 “범죄의 증명이 없다”고 판단했다. 윤 전 대통령은 지난해 11월 19일 한 전 총리의 내란중요임무종사 등 혐의 재판에 증인으로 출석해 한 전 총리 건의 전부터 국무회의를 계획한 것처럼 허위로 증언한 혐의로 기소됐다. 재판부는 “한 전 총리 건의와 상관없이 처음부터 국무위원 소집 계획을 가졌을 가능성이 높아 보인다”고 판단했다.

남색 수트를 입은 윤 전 대통령은 왼쪽 가슴에 수인번호 명찰을 단 채 입정했다. 피고인석에 앉은 윤 전 대통령은 옅은 미소를 띠고 방청석을 바라본 뒤 변호인과 대화를 나눴다. 재판부는 윤 전 대통령을 자리에서 일어나게 한 뒤 5분가량 선고문을 낭독했다. 윤 전 대통령은 선고 내내 양손을 옆으로 내린 차렷 자세로 정면을 응시했다.

\"윤석열

윤석열 전 대통령이 2025년 11월 19일 서울 서초구 서울중앙지방법원에서 열린 한덕수 전 국무총리의 내란 재판에 증인으로 출석해 한 전 총리가 계엄 선포에 반대했다는 취지로 증언하고 있다. 연합뉴스

“한덕수 건의 상관없이 처음부터 소집 계획”

윤 전 대통령은 계엄 당일 2024년 12월 3일 오후 8시14분부터 한 전 총리, 김용현 전 국방부 장관 등을 집무실로 불러 오후 9시 9분까지 회의했다. 윤 전 대통령은 이 자리에서 계엄 선포 계획을 알렸고 계엄 문건을 교부했다. 이후 오후 9시11분쯤 김정환 전 수행실장에게 최상목 전 경제부총리 겸 기획재정부 장관 등 특정해 대통령실로 부르라고 지시했다.

재판부는 “이 사건 공소사실은 한 전 총리가 첫 회동 당시 국무회의가 필요하다고 건의하자 비로소 의사정족수를 갖추기 위해 계획에 없던 최 전 장관 등 6명을 소집했다는 점을 전제로 한다”고 짚었다. 그러면서 사전에 국무회의를 계획하지 않았다가 당일 오후 9시14분쯤 한 전 총리 제안에 단지 형식적 외관을 갖추기 위해 국무회의를 열었다는 특검팀 주장을 받아들이지 않았다.

재판부는 “한 전 총리가 국무회의 개최를 건의했더라도 최초 회동 이후에 2차로 연락받고 집무실로 온 최 전 장관에게 교부할 계엄 관련 문건이 미리 준비돼있었고, 2차 국무회의 소집 계획을 가지고 있던 것으로 보인다”고 판단했다. 당시 국무위원 중 빨리 도착할 수 있는 4명을 특정한 게 아니라 6명을 특정해 연락한 점도 근거가 됐다. 재판부는 “한 전 총리 등은 오후 9시29분쯤 전까진 추가 국무회의를 소집할 것이라 인지하지 못했다”며 “윤 전 대통령이 한 전 총리 건의 상관없이 처음부터 위원 소집 계획을 가졌을 가능성이 높아 보인다”고 했다.

재판부는 김 전 장관이 계엄 직후인 12월 8일 경찰 조사에서 윤 전 대통령이 계엄에 뭐가 필요한지 묻자 계엄 선포문 안건과 포고령 상정이 필요하다고 진술한 점도 근거로 들었다. 또 이상민 전 행정안전부 장관 등이 공통적으로 “최초 집무실 회동에서는 국무회의 개최 얘기가 없었고 대접견실 회의에서 얘기가 나왔다”고 진술한 점도 인정됐다.

\"김경진

“尹 진술, 주관적 평가에 불과”

재판부는 “위증죄는 경험한 사실에 관해 기억에 반하는 사실을 진술했을 때 성립하고 주관적 평가는 대상이 되지 않는다”며 “처음부터 의사정족수를 갖춘 국무회의를 소집하려 했다는 윤 전 대통령의 진술은 주관적 평가에 불과하기 때문에 사실관계에 관한 기억에 반하는 진술이라 보기 어려워 위증으로 보기 어렵다”고 판시했다. 다만 재판부는 국무회의가 법률상 심의에 해당할 수 있는지에 대해서는 별도로 판단하지 않았다.

윤 전 대통령은 무죄 선고가 나고 떨떠름한 표정으로 서있었으나 이내 미소를 머금었다. 이후 변호인단과 대화를 나누고 악수한 후 퇴정했다. 윤 전 대통령 변호인단은 “한 전 총리의 거짓 진술에 기대 기소한 결과”라고 말했다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:16:09+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-b951c2d40b02c47d", + "title": "정근식, ‘한만중 진보교육감 후보’ 선거법 위반 고발", + "sourceLabel": "조선일보", + "url": "https://n.news.naver.com/mnews/article/023/0003978914", + "lead": "정근식 서울시교육감 후보가 지난 21일 서울 용산역 광장에서 6·3 서울시교육감 선거 출정식을 열고 지지를 호소하고 있다./뉴시스 정근식 서울시교육감 후보가 같은 진보 진영으로 분류되는 한만중 후보를 공직선거법 위반(허위사실 공표) 혐의로 지난 27일 서울 마포경찰서에 고발했다.", + "body": "정근식 서울시교육감 후보가 지난 21일 서울 용산역 광장에서 6·3 서울시교육감 선거 출정식을 열고 지지를 호소하고 있다./뉴시스 정근식 서울시교육감 후보가 같은 진보 진영으로 분류되는 한만중 후보를 공직선거법 위반(허위사실 공표) 혐의로 지난 27일 서울 마포경찰서에 고발했다.\n\n한 후보 측이 제작·배포한 온라인 홍보물이 한 후보를 진보 진영의 단일 후보로 오인하게 만드는 허위 내용을 담고 있다는 취지다. 한 후보는 지난 25일 자신의 페이스북에 ‘민주진보 교육감 15인’이라는 제목의 이미지를 공유했다. 이 홍보물에는 한 후보의 사진과 함께 ‘민주진보 시민후보’라는 문구가 담겼다.\n\n정 후보 측은 “서울 민주진보 단일 후보는 정근식뿐”이라며 “한 후보가 마치 자신이 진보 진영의 공식 후보인 것처럼 홍보하고 있다”고 주장했다. 앞서 진보 진영 단일화 기구인 ‘2026 서울 민주진보교육감 단일화 추진위원회’는 지난달 23일 시민참여단 투표에서 과반을 득표한 정 후보를 단일 후보로 선출했다고 발표했다.\n\n한 후보는 경선 과정의 공정성 문제를 제기하며 독자 출마를 강행한 상황이다.", + "htmlBody": "
\"정근식

정근식 서울시교육감 후보가 지난 21일 서울 용산역 광장에서 6·3 서울시교육감 선거 출정식을 열고 지지를 호소하고 있다./뉴시스 정근식 서울시교육감 후보가 같은 진보 진영으로 분류되는 한만중 후보를 공직선거법 위반(허위사실 공표) 혐의로 지난 27일 서울 마포경찰서에 고발했다.

한 후보 측이 제작·배포한 온라인 홍보물이 한 후보를 진보 진영의 단일 후보로 오인하게 만드는 허위 내용을 담고 있다는 취지다. 한 후보는 지난 25일 자신의 페이스북에 ‘민주진보 교육감 15인’이라는 제목의 이미지를 공유했다. 이 홍보물에는 한 후보의 사진과 함께 ‘민주진보 시민후보’라는 문구가 담겼다.

정 후보 측은 “서울 민주진보 단일 후보는 정근식뿐”이라며 “한 후보가 마치 자신이 진보 진영의 공식 후보인 것처럼 홍보하고 있다”고 주장했다. 앞서 진보 진영 단일화 기구인 ‘2026 서울 민주진보교육감 단일화 추진위원회’는 지난달 23일 시민참여단 투표에서 과반을 득표한 정 후보를 단일 후보로 선출했다고 발표했다.

한 후보는 경선 과정의 공정성 문제를 제기하며 독자 출마를 강행한 상황이다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:16:12+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-4249b4f01e698d61", + "title": "유니콘경영경제연구원, ‘적대적 M&A와 이사회 방어권’ 전문가 좌담회 개최", + "sourceLabel": "이코노미스트", + "url": "https://n.news.naver.com/mnews/article/243/0000098363", + "lead": "-고려아연 사례 중심으로 적대적 인수 판단 기준 논의 -법률·경영학 관점에서 이사회 방어권 정당성 검토", + "body": "-고려아연 사례 중심으로 적대적 인수 판단 기준 논의 -법률·경영학 관점에서 이사회 방어권 정당성 검토\n\n‘적대적 M&A와 이사회 방어권’ 전문가 좌담회 포스터(유니콘경영경제연구원 제공)\n\n유니콘경영경제연구원이 ‘적대적 M&A와 이사회 방어권’을 주제로 전문가 좌담회를 진행했다.\n\n유니콘경영경제연구원은 지난 27일 서울 강남에서 적대적 M&A의 판단 기준과 이사회 방어권의 정당성을 논의하기 위한 전문가 좌담회를 개최했다고 밝혔다.\n\n이번 좌담회에는 유효상 유니콘경영경제연구원장, 이동현 가톨릭대학교 경영학과 교수, 김희경 법무법인 도영 대표변호사가 참석했다. 참석자들은 고려아연 경영권 분쟁 사례를 중심으로 적대적 인수의 개념, 이사회 동의 여부, 방어권 행사 기준 등을 법률·경영학적 관점에서 논의했다.\n\n유효상 원장은 적대적 M&A 판단 기준과 관련해 “M&A의 본질은 합병이라는 거래 형식 자체보다 실질적인 경영권이 누구에게 이전되는지에 있다”고 설명했다. 그는 글로벌 자본시장에서는 M&A 진행 과정에서 ‘인디커티브 오퍼(Indicative Offer)’, ‘베어 허그(Bear Hug)’ 등 사전 절차가 활용되는 경우가 있다고 덧붙였다.\n\n이동현 가톨릭대학교 경영학과 교수는 경영학 관점에서 적대적 인수의 개념을 설명했다. 이 교수는 “경영학과 자본시장 관점에서 적대적 인수는 이사회와 경영진의 동의 없이 이뤄지는 경영권 취득 시도를 의미한다”며 “핵심 판단 기준은 지분 보유 규모보다 이사회 동의 여부에 있다”고 말했다.\n\n이 교수는 고려아연 사례와 관련해서는 “일반적으로는 경영진의 단기 성과주의를 주주가 견제하는 구조가 논의되지만, 이번 사례에서는 장기 성장을 추진하는 경영진과 배당 요구를 앞세운 대주주 간 갈등이라는 이른바 ‘역전된 대리인 문제’가 나타나고 있다”고 평가했다.\n\n또한 그는 사모펀드가 통상 일정 기간 내 투자 회수를 목표로 하는 구조를 갖고 있는 만큼, 적대적 인수가 장기 투자와 기술 개발, 고용 안정에 미칠 수 있는 영향도 함께 검토해야 한다고 지적했다.\n\n김희경 법무법인 도영 대표변호사는 이사의 위임사무와 선관주의 의무를 중심으로 이사회 방어권의 법률적 근거를 설명했다. 김 변호사는 “이사의 권한은 주주로부터 위임된 경영 권한에 기반한다”며 “경영진의 방어 조치는 주주와 별개의 행동이 아니라 회사와 주주 전체의 가치를 보호하기 위한 직무 수행으로 볼 수 있다”고 말했다.\n\n김 변호사는 이어 “선관주의 의무는 타인의 재산을 맡아 평균적이고 합리적인 책임을 다해 의사결정을 하는 것을 의미한다”며 “적대적 M&A 상황에서 이사회가 방어 조치를 전혀 취하지 않는다면 오히려 선관주의 의무 측면에서 문제가 될 수 있다”고 설명했다.\n\n그는 서울중앙지법이 영풍의 자사주 공개매수 금지 가처분 신청을 기각한 사례를 언급하며, 해당 결정이 이사회 방어 조치의 정당성을 판단하는 데 참고할 수 있는 사례라고 덧붙였다.\n\n유효상 원장은 “고려아연 사례는 한국 자본시장에서 이사회가 기업의 장기적 이익을 보호하기 위해 어떤 역할을 해야 하는지 논의할 계기를 제공한다”며 “적대적 M&A와 이사회 방어권에 대한 사회적 논의가 보다 정확한 개념 이해를 바탕으로 이뤄질 필요가 있다”고 말했다.\n\n참석자들은 향후 국내에서 유사한 경영권 분쟁이 발생할 경우, 이사회가 회사와 전체 주주의 이익을 위해 어떤 기준으로 판단하고 대응해야 하는지에 대한 논의가 중요해질 것이라는 데 의견을 모았다.", + "htmlBody": "

-고려아연 사례 중심으로 적대적 인수 판단 기준 논의 -법률·경영학 관점에서 이사회 방어권 정당성 검토

\"‘적대적

‘적대적 M&A와 이사회 방어권’ 전문가 좌담회 포스터(유니콘경영경제연구원 제공)

유니콘경영경제연구원이 ‘적대적 M&A와 이사회 방어권’을 주제로 전문가 좌담회를 진행했다.

유니콘경영경제연구원은 지난 27일 서울 강남에서 적대적 M&A의 판단 기준과 이사회 방어권의 정당성을 논의하기 위한 전문가 좌담회를 개최했다고 밝혔다.

이번 좌담회에는 유효상 유니콘경영경제연구원장, 이동현 가톨릭대학교 경영학과 교수, 김희경 법무법인 도영 대표변호사가 참석했다. 참석자들은 고려아연 경영권 분쟁 사례를 중심으로 적대적 인수의 개념, 이사회 동의 여부, 방어권 행사 기준 등을 법률·경영학적 관점에서 논의했다.

유효상 원장은 적대적 M&A 판단 기준과 관련해 “M&A의 본질은 합병이라는 거래 형식 자체보다 실질적인 경영권이 누구에게 이전되는지에 있다”고 설명했다. 그는 글로벌 자본시장에서는 M&A 진행 과정에서 ‘인디커티브 오퍼(Indicative Offer)’, ‘베어 허그(Bear Hug)’ 등 사전 절차가 활용되는 경우가 있다고 덧붙였다.

이동현 가톨릭대학교 경영학과 교수는 경영학 관점에서 적대적 인수의 개념을 설명했다. 이 교수는 “경영학과 자본시장 관점에서 적대적 인수는 이사회와 경영진의 동의 없이 이뤄지는 경영권 취득 시도를 의미한다”며 “핵심 판단 기준은 지분 보유 규모보다 이사회 동의 여부에 있다”고 말했다.

이 교수는 고려아연 사례와 관련해서는 “일반적으로는 경영진의 단기 성과주의를 주주가 견제하는 구조가 논의되지만, 이번 사례에서는 장기 성장을 추진하는 경영진과 배당 요구를 앞세운 대주주 간 갈등이라는 이른바 ‘역전된 대리인 문제’가 나타나고 있다”고 평가했다.

또한 그는 사모펀드가 통상 일정 기간 내 투자 회수를 목표로 하는 구조를 갖고 있는 만큼, 적대적 인수가 장기 투자와 기술 개발, 고용 안정에 미칠 수 있는 영향도 함께 검토해야 한다고 지적했다.

김희경 법무법인 도영 대표변호사는 이사의 위임사무와 선관주의 의무를 중심으로 이사회 방어권의 법률적 근거를 설명했다. 김 변호사는 “이사의 권한은 주주로부터 위임된 경영 권한에 기반한다”며 “경영진의 방어 조치는 주주와 별개의 행동이 아니라 회사와 주주 전체의 가치를 보호하기 위한 직무 수행으로 볼 수 있다”고 말했다.

김 변호사는 이어 “선관주의 의무는 타인의 재산을 맡아 평균적이고 합리적인 책임을 다해 의사결정을 하는 것을 의미한다”며 “적대적 M&A 상황에서 이사회가 방어 조치를 전혀 취하지 않는다면 오히려 선관주의 의무 측면에서 문제가 될 수 있다”고 설명했다.

그는 서울중앙지법이 영풍의 자사주 공개매수 금지 가처분 신청을 기각한 사례를 언급하며, 해당 결정이 이사회 방어 조치의 정당성을 판단하는 데 참고할 수 있는 사례라고 덧붙였다.

유효상 원장은 “고려아연 사례는 한국 자본시장에서 이사회가 기업의 장기적 이익을 보호하기 위해 어떤 역할을 해야 하는지 논의할 계기를 제공한다”며 “적대적 M&A와 이사회 방어권에 대한 사회적 논의가 보다 정확한 개념 이해를 바탕으로 이뤄질 필요가 있다”고 말했다.

참석자들은 향후 국내에서 유사한 경영권 분쟁이 발생할 경우, 이사회가 회사와 전체 주주의 이익을 위해 어떤 기준으로 판단하고 대응해야 하는지에 대한 논의가 중요해질 것이라는 데 의견을 모았다.

", + "tags": [ + "네이버뉴스", + "경제" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:16:12+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-ebea26af7df06392", + "title": "[포토] 서울시, 여름철 어린이용품 안전성 검사 발표", + "sourceLabel": "이데일리", + "url": "https://n.news.naver.com/mnews/article/018/0006292233", + "lead": "[이데일리 김태형 기자] 서울시는 해외직구 여름철 어린이용품에 대한 안전성 검사를 28일 발표했다.", + "body": "[이데일리 김태형 기자] 서울시는 해외직구 여름철 어린이용품에 대한 안전성 검사를 28일 발표했다.\n\n알리익스프레스, 테무, 쉬인 등 온라인플랫폼에서 판매 중인 우산 양산 우비 등 32개 제품에 대한 검사에서 10개 제품이 ‘부적합’한 것으로 나타났다고 밝혔다.\n\n이번 검사는 화학물질 검출 여부와 물리적 안전성을 중심으로 진행했으며 납 기준치 초과, 찔림사고 유발, 프탈레이트계 가소제 기준치 초과 검출 등이 나타났다.\n\n서울시는 부적합 제품에 대해 해당 플랫폼에 판매 중단을 요청했다.\n\n안전성 검사 결과는 ‘서울시 누리집’이나 ‘서울시전자상거래센터 누리집’에서 확인할 수 있다.", + "htmlBody": "
\"기사

[이데일리 김태형 기자] 서울시는 해외직구 여름철 어린이용품에 대한 안전성 검사를 28일 발표했다.

알리익스프레스, 테무, 쉬인 등 온라인플랫폼에서 판매 중인 우산 양산 우비 등 32개 제품에 대한 검사에서 10개 제품이 ‘부적합’한 것으로 나타났다고 밝혔다.

이번 검사는 화학물질 검출 여부와 물리적 안전성을 중심으로 진행했으며 납 기준치 초과, 찔림사고 유발, 프탈레이트계 가소제 기준치 초과 검출 등이 나타났다.

서울시는 부적합 제품에 대해 해당 플랫폼에 판매 중단을 요청했다.

안전성 검사 결과는 ‘서울시 누리집’이나 ‘서울시전자상거래센터 누리집’에서 확인할 수 있다.

", + "tags": [ + "네이버뉴스", + "정치" + ], + "signals": [ + "주목" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:16:15+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-ea06a112b488f554", + "title": "안규백 국방 샹그릴라 대화서 \"우방국 연쇄 회담, 실리 안보 승부수 띄운다\"", + "sourceLabel": "파이낸셜뉴스", + "url": "https://n.news.naver.com/mnews/article/014/0005527568", + "lead": "29일부터 싱가포르서 제23차 아시아안보회의 참가 역내 안보 도전과 대한민국의 전략적 대응 주제 연설 미· 일· 호·필리핀 등 국방 수장과 연쇄 회담 가동 K 방산 신뢰도 기반, 유럽·아세안 파트너십 확장 전망", + "body": "29일부터 싱가포르서 제23차 아시아안보회의 참가 역내 안보 도전과 대한민국의 전략적 대응 주제 연설 미· 일· 호·필리핀 등 국방 수장과 연쇄 회담 가동 K 방산 신뢰도 기반, 유럽·아세안 파트너십 확장 전망\n\n안규백 국방부 장관이 지난 11일(현지 시간) 미국 펜타곤에서 피트 헤그세스 미 국방부 장관과 만나 악수하고 있다. 국방부 제공 [파이낸셜뉴스] 대한민국 안보를 책임지는 국방 수뇌부가 아시아태평양 지역 최대 다자안보회의 무대에서 실리 안보와 방산 외교의 승부수를 펼칠 전망이다. 이번 회의는 급변하는 인태 지역 안보 질서 속에서 한미동맹을 굳건히 다지는 동시에, 최근 세계 시장에서 주목받는 K 방산의 신뢰도 자산을 기반으로 유럽 및 아세안 우방국들과의 파트너십을 확장하는 무대가될 전망이다.\n\n국방부는 28일 안규백 국방부장관이 29일부터 오는 31일까지 싱가포르에서 개최되는 제23차 아시아안보회의 일명 샹그릴라 대화에 참가한다고 전했다. 안 장관은 30일 본회의에서 역내 안보 도전과 대한민국의 전략적 대응을 주제로 전 세계 국방 수뇌부 앞에서 기조연설을 할 예정이다. 안 장관은 연설을 통해 최근 북한의 근거리 탄도미사일 섞어쏘기 도발 등 고도화된 비대칭 위협을 규탄하고 억제하기 위한 대한민국 군의 확고한 방위 정책과 인공지능 기반 미래 첨단 과학기술군으로의 혁신 비전을 제시할 것으로 전해졌다.\n\n올해 회의에서는 미국의 대아시아 안보 공약과 대만 해협의 긴장 완화 방안 등이 주로 논의될 전망이다. 특히 피트 헤그세스 미국 전쟁부 장관이 30일 본회의 연설을 통해 인도태평양 지역 동맹국들을 향한 강력한 메시지를 내놓을 예정으로 알려졌다. 이를 통해 한미 연합 자산의 실질적 공조 체제를 다지는 계기가 마련될 지도 주목된다.\n\n반면 둥쥔 중국 국방부장은 지난해에 이어 올해도 회의에 참석하지 않을 것으로 전해졌다. 중국이 자체 출범한 베이징 샹샨포럼을 자국의 안보 입장 제시 플랫폼으로 최근 더 중시하는 추세 속에서, 안 장관은 다자주의 안보 약화의 틈새를 메우는 국방 리더십을 발휘할 기회를 맞이했다는 분석이다.\n\n군사 전문가들은 샹그릴라 대화가 최근 실리 위주의 소다자 동맹 체제 전환으로 다자간 전방위 합의 도출이라는 과거의 영향력은 줄었지만, 여전히 막후 비공식 양자 소통 채널인 트랙 1.5 외교의 매우 유용한 통로라고 진단한다. 이러한 관점에서 안 장관이 기간 중 가동할 미국 상하원 대표단 및 일본·호주·노르웨이·필리핀·태국 등 주요국 국방 수장들과의 연속 양자 회담은 실질적인 안보 국익을 확보할 핵심 승부처로 관측된다.\n\n특히 이번 연쇄 회담은 최근 캐나다 잠수함 수주전 지원과 연계하여 국산 장보고 N사업의 국제법적 투명성을 우방국들에게 설득하고, 천무 다연장로켓 등 국산 무기체계의 글로벌 신뢰도를 토대로 유럽 및 아세안 국가들과 실질적인 군수 협력 마스터플랜을 도출하는 기회로 활용될지도 주목된다. 국방부는 이번 다자 및 양자 안보 외교를 통해 철저한 보안과 안보 기준을 준수하는 가운데 대한민국이 역내 안보 질서를 주도하는 첨단 과학기술군이자 가장 신뢰할 수 있는 안보 파트너로서의 위상을 확고히 다지겠다는 방침이다.\n\n한편 아시아안보회의가 '샹그릴라 대화'로 불린 유래는 지난 2002년 첫 회의가 출범한 이래 매년 싱가포르의 5성급 호텔인 '샹그릴라 호텔'에서 고정적으로 개최되며, 국제 외교가의 관례에 따라 자연스럽게 별칭으로 공식화됐다. 영국의 안보 싱크탱크인 국제전략연구소(IISS)가 아태 지역 국방 수뇌부들의 막후 대화 채널을 구상하면서 싱가포르 정부의 전폭적인 지원을 받아 이 호텔을 본부로 지정했다.", + "htmlBody": "

29일부터 싱가포르서 제23차 아시아안보회의 참가 역내 안보 도전과 대한민국의 전략적 대응 주제 연설 미· 일· 호·필리핀 등 국방 수장과 연쇄 회담 가동 K 방산 신뢰도 기반, 유럽·아세안 파트너십 확장 전망

\"안규백

안규백 국방부 장관이 지난 11일(현지 시간) 미국 펜타곤에서 피트 헤그세스 미 국방부 장관과 만나 악수하고 있다. 국방부 제공 [파이낸셜뉴스] 대한민국 안보를 책임지는 국방 수뇌부가 아시아태평양 지역 최대 다자안보회의 무대에서 실리 안보와 방산 외교의 승부수를 펼칠 전망이다. 이번 회의는 급변하는 인태 지역 안보 질서 속에서 한미동맹을 굳건히 다지는 동시에, 최근 세계 시장에서 주목받는 K 방산의 신뢰도 자산을 기반으로 유럽 및 아세안 우방국들과의 파트너십을 확장하는 무대가될 전망이다.

국방부는 28일 안규백 국방부장관이 29일부터 오는 31일까지 싱가포르에서 개최되는 제23차 아시아안보회의 일명 샹그릴라 대화에 참가한다고 전했다. 안 장관은 30일 본회의에서 역내 안보 도전과 대한민국의 전략적 대응을 주제로 전 세계 국방 수뇌부 앞에서 기조연설을 할 예정이다. 안 장관은 연설을 통해 최근 북한의 근거리 탄도미사일 섞어쏘기 도발 등 고도화된 비대칭 위협을 규탄하고 억제하기 위한 대한민국 군의 확고한 방위 정책과 인공지능 기반 미래 첨단 과학기술군으로의 혁신 비전을 제시할 것으로 전해졌다.

올해 회의에서는 미국의 대아시아 안보 공약과 대만 해협의 긴장 완화 방안 등이 주로 논의될 전망이다. 특히 피트 헤그세스 미국 전쟁부 장관이 30일 본회의 연설을 통해 인도태평양 지역 동맹국들을 향한 강력한 메시지를 내놓을 예정으로 알려졌다. 이를 통해 한미 연합 자산의 실질적 공조 체제를 다지는 계기가 마련될 지도 주목된다.

반면 둥쥔 중국 국방부장은 지난해에 이어 올해도 회의에 참석하지 않을 것으로 전해졌다. 중국이 자체 출범한 베이징 샹샨포럼을 자국의 안보 입장 제시 플랫폼으로 최근 더 중시하는 추세 속에서, 안 장관은 다자주의 안보 약화의 틈새를 메우는 국방 리더십을 발휘할 기회를 맞이했다는 분석이다.

군사 전문가들은 샹그릴라 대화가 최근 실리 위주의 소다자 동맹 체제 전환으로 다자간 전방위 합의 도출이라는 과거의 영향력은 줄었지만, 여전히 막후 비공식 양자 소통 채널인 트랙 1.5 외교의 매우 유용한 통로라고 진단한다. 이러한 관점에서 안 장관이 기간 중 가동할 미국 상하원 대표단 및 일본·호주·노르웨이·필리핀·태국 등 주요국 국방 수장들과의 연속 양자 회담은 실질적인 안보 국익을 확보할 핵심 승부처로 관측된다.

특히 이번 연쇄 회담은 최근 캐나다 잠수함 수주전 지원과 연계하여 국산 장보고 N사업의 국제법적 투명성을 우방국들에게 설득하고, 천무 다연장로켓 등 국산 무기체계의 글로벌 신뢰도를 토대로 유럽 및 아세안 국가들과 실질적인 군수 협력 마스터플랜을 도출하는 기회로 활용될지도 주목된다. 국방부는 이번 다자 및 양자 안보 외교를 통해 철저한 보안과 안보 기준을 준수하는 가운데 대한민국이 역내 안보 질서를 주도하는 첨단 과학기술군이자 가장 신뢰할 수 있는 안보 파트너로서의 위상을 확고히 다지겠다는 방침이다.

한편 아시아안보회의가 '샹그릴라 대화'로 불린 유래는 지난 2002년 첫 회의가 출범한 이래 매년 싱가포르의 5성급 호텔인 '샹그릴라 호텔'에서 고정적으로 개최되며, 국제 외교가의 관례에 따라 자연스럽게 별칭으로 공식화됐다. 영국의 안보 싱크탱크인 국제전략연구소(IISS)가 아태 지역 국방 수뇌부들의 막후 대화 채널을 구상하면서 싱가포르 정부의 전폭적인 지원을 받아 이 호텔을 본부로 지정했다.

", + "tags": [ + "네이버뉴스", + "정치" + ], + "signals": [ + "주목" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:16:17+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-94d25b2e82b8eb7c", + "title": "[포토] 서울시, 해외직구 여름철 어린이용품 검사", + "sourceLabel": "이데일리", + "url": "https://n.news.naver.com/mnews/article/018/0006292234", + "lead": "[이데일리 김태형 기자] 서울시는 해외직구 여름철 어린이용품에 대한 안전성 검사를 28일 발표했다.", + "body": "[이데일리 김태형 기자] 서울시는 해외직구 여름철 어린이용품에 대한 안전성 검사를 28일 발표했다.\n\n알리익스프레스, 테무, 쉬인 등 온라인플랫폼에서 판매 중인 우산 양산 우비 등 32개 제품에 대한 검사에서 10개 제품이 ‘부적합’한 것으로 나타났다고 밝혔다.\n\n이번 검사는 화학물질 검출 여부와 물리적 안전성을 중심으로 진행했으며 납 기준치 초과, 찔림사고 유발, 프탈레이트계 가소제 기준치 초과 검출 등이 나타났다.\n\n서울시는 부적합 제품에 대해 해당 플랫폼에 판매 중단을 요청했다.\n\n안전성 검사 결과는 ‘서울시 누리집’이나 ‘서울시전자상거래센터 누리집’에서 확인할 수 있다.", + "htmlBody": "
\"기사

[이데일리 김태형 기자] 서울시는 해외직구 여름철 어린이용품에 대한 안전성 검사를 28일 발표했다.

알리익스프레스, 테무, 쉬인 등 온라인플랫폼에서 판매 중인 우산 양산 우비 등 32개 제품에 대한 검사에서 10개 제품이 ‘부적합’한 것으로 나타났다고 밝혔다.

이번 검사는 화학물질 검출 여부와 물리적 안전성을 중심으로 진행했으며 납 기준치 초과, 찔림사고 유발, 프탈레이트계 가소제 기준치 초과 검출 등이 나타났다.

서울시는 부적합 제품에 대해 해당 플랫폼에 판매 중단을 요청했다.

안전성 검사 결과는 ‘서울시 누리집’이나 ‘서울시전자상거래센터 누리집’에서 확인할 수 있다.

", + "tags": [ + "네이버뉴스", + "정치" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:16:17+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-93a936b0fc9adcb8", + "title": "6~7월 日금리인상 대세론에…노무라 연구소", + "sourceLabel": "뉴스1", + "url": "https://n.news.naver.com/mnews/article/421/0008970835", + "lead": "\"호르무즈발 물가 우려보다 침체 공포 크면 인상 힘들어\" \"日국채 금리 급등은 오버슈팅일 뿐\"", + "body": "\"호르무즈발 물가 우려보다 침체 공포 크면 인상 힘들어\" \"日국채 금리 급등은 오버슈팅일 뿐\"\n\n일본 도쿄에 있는 미쓰비시 UFJ 파이낸셜 그룹과 MUFG 은행 본사 앞 간판을 한 보행자가 지나가고 있다. 2021.9.22. ⓒ AFP=뉴스1\n\n(서울=뉴스1) 신기림 기자 = 일본 금융시장에서 6월 혹은 7월 기준금리 인상이 기정사실화되는 분위기지만, 이란 전쟁에 따른 고유가 충격이 인플레이션보다 성장을 무너뜨릴 위험을 간과해서는 안 된다는 지적이 나왔다.\n\n27일(현지시간) 블룸버그에 따르면 노무라 자본시장연구소의 사이토 미치오 이그제큐티브 펠로우는 이날 열린 금융 업계 행사에서 \"금리 인상의 시기가 가깝거나 이미 도래한 것은 맞다\"면서도 \"일본은행(BOJ) 정책심의위의 앞날이 순탄치만은 않을 것\"이라며 속도조절론을 제기했다.\n\n과거 재무성에서 국채 정책을 담당했던 사이토는 중동의 요충지인 호르무즈 해협 봉쇄에 주목했다.\n\n그는 호르무즈 해협의 봉쇄가 지속되면 원유와 액화천연가스(LNG) 공급 우려가 커질 것이라며, 일본의 경우 \"단순한 물가 상승을 넘어 경제 활동 자체가 멈춰 설(standstill) 위험이 있다\"고 경고했다. 봉쇄 장기화 시 수입 에너지 의존도가 높은 일본 경제가 급격한 성장 위축(수요 파괴) 직격탄을 맞을 수 있다는 취지다.\n\n경기 침체의 공포가 고물가보다 더 크다면 BOJ가 섣불리 움직이기 어렵다. 실제로 BOJ는 최근 중동 리스크를 반영해 올해 일본의 성장률 전망치를 기존 1.0%에서 0.5%로 반토막 낸 상태다.\n\n사이토 펠로우는 시장의 과열 심리도 지적했다. 최근 일본 10년물 국채 금리가 1996년 이후 최고치인 2.8%까지 치솟았으나, 이는 다른 만기물과의 균형을 고려할 때 과도하게 급등(오버슈팅)한 것이며 적정 범위는 2.0%~2.5% 사이라고 분석했다.\n\n하지만 고유가가 초래할 이른바 나쁜 인플레이션을 잡아야 한다는 쪽으로 당장 금융시장은 기울어져 있다. 현재 스왑시장에 반영된 BOJ의 금리 인상 확률은 6월 75%, 7월 92%에 달할 만큼 대세론이 완강하다.\n\n우에다 가즈오 BOJ 총재는 이번 유가 급등을 제5차 오일쇼크로 규정하며 강한 경계감을 드러냈다. 디플레이션 시절의 유가 상승과 달리 지금은 3년 연속 5%를 웃돈 춘투(임금협상) 결과로 인해 명목 임금이 크게 오른 상태기 때문이다.\n\nBOJ 내부에서 가장 우려하는 핵심은 임금-물가의 악순환적 상승이다. 고유가 기조에 160엔 선을 돌파한 역대급 엔저가 겹치면 수입 물가가 걷잡을 수 없이 뛰어 기대인플레이션을 자극하게 된다. 물가 체제(inflation regime) 자체가 고물가 구조로 굳어지기 전에 선제적으로 금리를 올릴 필요성이 있다고 볼 수 있다.", + "htmlBody": "

"호르무즈발 물가 우려보다 침체 공포 크면 인상 힘들어" "日국채 금리 급등은 오버슈팅일 뿐"

\"일본

일본 도쿄에 있는 미쓰비시 UFJ 파이낸셜 그룹과 MUFG 은행 본사 앞 간판을 한 보행자가 지나가고 있다. 2021.9.22. ⓒ AFP=뉴스1

(서울=뉴스1) 신기림 기자 = 일본 금융시장에서 6월 혹은 7월 기준금리 인상이 기정사실화되는 분위기지만, 이란 전쟁에 따른 고유가 충격이 인플레이션보다 성장을 무너뜨릴 위험을 간과해서는 안 된다는 지적이 나왔다.

27일(현지시간) 블룸버그에 따르면 노무라 자본시장연구소의 사이토 미치오 이그제큐티브 펠로우는 이날 열린 금융 업계 행사에서 "금리 인상의 시기가 가깝거나 이미 도래한 것은 맞다"면서도 "일본은행(BOJ) 정책심의위의 앞날이 순탄치만은 않을 것"이라며 속도조절론을 제기했다.

과거 재무성에서 국채 정책을 담당했던 사이토는 중동의 요충지인 호르무즈 해협 봉쇄에 주목했다.

그는 호르무즈 해협의 봉쇄가 지속되면 원유와 액화천연가스(LNG) 공급 우려가 커질 것이라며, 일본의 경우 "단순한 물가 상승을 넘어 경제 활동 자체가 멈춰 설(standstill) 위험이 있다"고 경고했다. 봉쇄 장기화 시 수입 에너지 의존도가 높은 일본 경제가 급격한 성장 위축(수요 파괴) 직격탄을 맞을 수 있다는 취지다.

경기 침체의 공포가 고물가보다 더 크다면 BOJ가 섣불리 움직이기 어렵다. 실제로 BOJ는 최근 중동 리스크를 반영해 올해 일본의 성장률 전망치를 기존 1.0%에서 0.5%로 반토막 낸 상태다.

사이토 펠로우는 시장의 과열 심리도 지적했다. 최근 일본 10년물 국채 금리가 1996년 이후 최고치인 2.8%까지 치솟았으나, 이는 다른 만기물과의 균형을 고려할 때 과도하게 급등(오버슈팅)한 것이며 적정 범위는 2.0%~2.5% 사이라고 분석했다.

하지만 고유가가 초래할 이른바 나쁜 인플레이션을 잡아야 한다는 쪽으로 당장 금융시장은 기울어져 있다. 현재 스왑시장에 반영된 BOJ의 금리 인상 확률은 6월 75%, 7월 92%에 달할 만큼 대세론이 완강하다.

우에다 가즈오 BOJ 총재는 이번 유가 급등을 제5차 오일쇼크로 규정하며 강한 경계감을 드러냈다. 디플레이션 시절의 유가 상승과 달리 지금은 3년 연속 5%를 웃돈 춘투(임금협상) 결과로 인해 명목 임금이 크게 오른 상태기 때문이다.

BOJ 내부에서 가장 우려하는 핵심은 임금-물가의 악순환적 상승이다. 고유가 기조에 160엔 선을 돌파한 역대급 엔저가 겹치면 수입 물가가 걷잡을 수 없이 뛰어 기대인플레이션을 자극하게 된다. 물가 체제(inflation regime) 자체가 고물가 구조로 굳어지기 전에 선제적으로 금리를 올릴 필요성이 있다고 볼 수 있다.

", + "tags": [ + "네이버뉴스", + "경제" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:16:19+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-ebbeb1ccc4c358a9", + "title": "이 대통령, 내달 8일 1주년 회견… ‘조작기소 특검’ 입장 주목", + "sourceLabel": "문화일보", + "url": "https://n.news.naver.com/mnews/article/021/0002794123", + "lead": "자유 질의·응답 형식 진행 이재명 대통령이 오는 6월 8일 개최되는 취임 1주년 기자회견에서 더불어민주당이 발의한 ‘윤석열 정권 조작기소 특별검사법’ 관련 첫 입장을 내놓을지 주목된다. 앞서 이 대통령은 특검법의 ‘공소취소’ 조항을 둘러싼 논란이 확산하자 여당에 법안처리 시기·절차를 숙의해달라고 요청한 바 있다.", + "body": "자유 질의·응답 형식 진행 이재명 대통령이 오는 6월 8일 개최되는 취임 1주년 기자회견에서 더불어민주당이 발의한 ‘윤석열 정권 조작기소 특별검사법’ 관련 첫 입장을 내놓을지 주목된다. 앞서 이 대통령은 특검법의 ‘공소취소’ 조항을 둘러싼 논란이 확산하자 여당에 법안처리 시기·절차를 숙의해달라고 요청한 바 있다.\n\n28일 청와대에 따르면 이 대통령은 6·3 지방선거 이후인 다음 달 8일 청와대 영빈관에서 취임 1주년 회견을 갖는다. 취임 한 달 및 100일 회견, 올해 1월 신년회견에 이은 네 번째 공식 기자회견이다. 이규연 청와대 홍보소통수석은 전날 “국민주권정부의 1년을 되돌아보고, 국정 2년 차 비전과 주요 과제를 소상히 밝히는 자리가 될 것”이라고 설명했다.\n\n취임 1주년 회견은 기존 회견처럼 자유 질의·응답 형식으로 진행되는 만큼 조작기소 특검법 관련 질문이 나오면 이 대통령이 의견을 내놓을 전망이다. 대장동·백현동 개발비리, 쌍방울 대북송금 등 이 대통령 관련 8개 사건이 수사 대상에 포함된 특검법은 조작기소가 확인될 경우 특검이 공소취소할 수 있는 조항을 담았다. 이 대통령은 민주당 의원 31명이 4월 발의한 이 법안에 대해 공식 입장을 밝히지 않았다. 하지만 보수는 물론 진보 진영에서도 ‘사법질서 훼손’ 비판이 나오고 지방선거를 앞두고 보수 결집이 시작되자 홍익표 청와대 정무수석은 지난 4일 여당에 “구체적인 시기·절차는 의견수렴과 숙의 과정을 거쳐 판단해 달라”며 속도 조절을 주문했다.\n\n이번 회견에서는 검찰개혁 후속 과제의 핵심인 ‘공소청 검사 보완수사권’ 여부도 언급될 가능성이 크다. 이와 함께 이 대통령이 비거주·고가주택 보유세 강화 등 부동산 세제 개편 방향도 제시할 수 있다는 관측이다.", + "htmlBody": "

자유 질의·응답 형식 진행 이재명 대통령이 오는 6월 8일 개최되는 취임 1주년 기자회견에서 더불어민주당이 발의한 ‘윤석열 정권 조작기소 특별검사법’ 관련 첫 입장을 내놓을지 주목된다. 앞서 이 대통령은 특검법의 ‘공소취소’ 조항을 둘러싼 논란이 확산하자 여당에 법안처리 시기·절차를 숙의해달라고 요청한 바 있다.

28일 청와대에 따르면 이 대통령은 6·3 지방선거 이후인 다음 달 8일 청와대 영빈관에서 취임 1주년 회견을 갖는다. 취임 한 달 및 100일 회견, 올해 1월 신년회견에 이은 네 번째 공식 기자회견이다. 이규연 청와대 홍보소통수석은 전날 “국민주권정부의 1년을 되돌아보고, 국정 2년 차 비전과 주요 과제를 소상히 밝히는 자리가 될 것”이라고 설명했다.

취임 1주년 회견은 기존 회견처럼 자유 질의·응답 형식으로 진행되는 만큼 조작기소 특검법 관련 질문이 나오면 이 대통령이 의견을 내놓을 전망이다. 대장동·백현동 개발비리, 쌍방울 대북송금 등 이 대통령 관련 8개 사건이 수사 대상에 포함된 특검법은 조작기소가 확인될 경우 특검이 공소취소할 수 있는 조항을 담았다. 이 대통령은 민주당 의원 31명이 4월 발의한 이 법안에 대해 공식 입장을 밝히지 않았다. 하지만 보수는 물론 진보 진영에서도 ‘사법질서 훼손’ 비판이 나오고 지방선거를 앞두고 보수 결집이 시작되자 홍익표 청와대 정무수석은 지난 4일 여당에 “구체적인 시기·절차는 의견수렴과 숙의 과정을 거쳐 판단해 달라”며 속도 조절을 주문했다.

이번 회견에서는 검찰개혁 후속 과제의 핵심인 ‘공소청 검사 보완수사권’ 여부도 언급될 가능성이 크다. 이와 함께 이 대통령이 비거주·고가주택 보유세 강화 등 부동산 세제 개편 방향도 제시할 수 있다는 관측이다.

", + "tags": [ + "네이버뉴스", + "정치" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:16:21+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-0cf865de37bf3c46", + "title": "한은 총재 \"주식시장", + "sourceLabel": "연합뉴스", + "url": "https://n.news.naver.com/mnews/article/001/0016104405", + "lead": "금융통화위원회 본회의 참석하는 신현송 한국은행 총재 (서울=연합뉴스) 신현송 한국은행 총재가 28일 서울 중구 한국은행에서 열린 금융통화위원회 본회의에 참석하고 있다. 2026.5.28 [사진공동취재단] photo@yna.co.kr", + "body": "금융통화위원회 본회의 참석하는 신현송 한국은행 총재 (서울=연합뉴스) 신현송 한국은행 총재가 28일 서울 중구 한국은행에서 열린 금융통화위원회 본회의에 참석하고 있다. 2026.5.28 [사진공동취재단] photo@yna.co.kr\n\n(서울=연합뉴스) 박수현 이도흔 기자 = 신현송 한국은행 총재는 28일 \"당분간 '빚투(빚내서 투자)'가 시스템 리스크까지 가지 않을 것으로 본다\"라고 밝혔다.\n\n신 총재는 이날 오전 금융통화위원회가 기준금리를 연 2.50%로 동결한 뒤 기자간담회를 열어 \"시스템 리스크가 되려면 다른 부문과 연결돼야 하는데 지금까지 주식시장은 개별 시장으로 봐도 될 것 같다\"라며 이같이 말했다.\n\n신 총재는 \"주가가 단시간에 급하게 올라갈 경우 여러가지 시장을 둘러싼 행태 변화가 생길 수 있고, 가장 대표적으로 얼마나 '빚투' 현상이 생기는가가 중요하다고 생각한다\"라고 했다.\n\n이어 \"빚투는 정상적인 수요 곡선을 바꿔두는 요소가 될 수 있다\"라며 \"수요곡선은 대개 우하향 곡선인데 빚투가 많으면 가격이 내려갈 때 반대매매가 이뤄지고 자금이 회수되기에 수요곡선이 거꾸로 될 우려가 있다\"라고 말했다.\n\n그러면서 \"시장에서는 다른 투자자에게 영향을 미치는 '외부효과'가 어떤 식으로 발생할 수 있는지 봐야한다\"라며 \"빚투가 만연해 작은 충격이 아주 큰 시장 조정으로 이어지면 빚투 안 한 사람도 그만큼 손해를 볼 수 있다\"라고 했다.", + "htmlBody": "
\"금융통화위원회

금융통화위원회 본회의 참석하는 신현송 한국은행 총재 (서울=연합뉴스) 신현송 한국은행 총재가 28일 서울 중구 한국은행에서 열린 금융통화위원회 본회의에 참석하고 있다. 2026.5.28 [사진공동취재단] photo@yna.co.kr

(서울=연합뉴스) 박수현 이도흔 기자 = 신현송 한국은행 총재는 28일 "당분간 '빚투(빚내서 투자)'가 시스템 리스크까지 가지 않을 것으로 본다"라고 밝혔다.

신 총재는 이날 오전 금융통화위원회가 기준금리를 연 2.50%로 동결한 뒤 기자간담회를 열어 "시스템 리스크가 되려면 다른 부문과 연결돼야 하는데 지금까지 주식시장은 개별 시장으로 봐도 될 것 같다"라며 이같이 말했다.

신 총재는 "주가가 단시간에 급하게 올라갈 경우 여러가지 시장을 둘러싼 행태 변화가 생길 수 있고, 가장 대표적으로 얼마나 '빚투' 현상이 생기는가가 중요하다고 생각한다"라고 했다.

이어 "빚투는 정상적인 수요 곡선을 바꿔두는 요소가 될 수 있다"라며 "수요곡선은 대개 우하향 곡선인데 빚투가 많으면 가격이 내려갈 때 반대매매가 이뤄지고 자금이 회수되기에 수요곡선이 거꾸로 될 우려가 있다"라고 말했다.

그러면서 "시장에서는 다른 투자자에게 영향을 미치는 '외부효과'가 어떤 식으로 발생할 수 있는지 봐야한다"라며 "빚투가 만연해 작은 충격이 아주 큰 시장 조정으로 이어지면 빚투 안 한 사람도 그만큼 손해를 볼 수 있다"라고 했다.

", + "tags": [ + "네이버뉴스", + "경제" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:16:22+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-cab82eb12c07136d", + "title": "정동영 “평양 방문해 평화 기여를”… EU의회는 “북 비핵화 목표 헌신”", + "sourceLabel": "문화일보", + "url": "https://n.news.naver.com/mnews/article/021/0002794124", + "lead": "서울 면담서 양측 이견 드러내 EU “北 대러지원은 국제법 위반” 정동영 통일부 장관이 28일 유럽연합(EU)의회 한반도관계대표단과 면담에서 ‘한반도 평화를 위한 역할’을 주문한 반면 EU 측은 북한의 대러시아 군사지원을 강하게 규탄하며 비핵화 및 대북제재 이행을 강조했다.", + "body": "서울 면담서 양측 이견 드러내 EU “北 대러지원은 국제법 위반” 정동영 통일부 장관이 28일 유럽연합(EU)의회 한반도관계대표단과 면담에서 ‘한반도 평화를 위한 역할’을 주문한 반면 EU 측은 북한의 대러시아 군사지원을 강하게 규탄하며 비핵화 및 대북제재 이행을 강조했다.\n\n이날 오전 서울 종로구 남북회담본부에서 열린 면담에서 정 장관은 “EU는 한반도 평화에 그동안 건설적 역할을 해왔다”며 “서울에 왔으니 다음에는 평양도 방문해 주길 바란다”고 말했다. 하지만 EU의회 대표단은 러시아·우크라이나 전쟁에 대해 “국제법과 유엔헌장에 대한 명백한 위반”으로 규정하고 이 과정에서 북한의 러시아 군사지원이 역내 안보를 위협하고 있다고 지적했다. EU의회 대표단은 “유럽 의회는 북한이 러시아에 제공한 군사지원을 강력히 규탄하고, 러시아가 북한의 핵 또는 향후 미사일 기술을 이전할 가능성에 대해 우려를 표명했다”고 밝혔다. 특히 EU의회 대표단은 “우리는 (북한) 비핵화 목표에 계속 헌신하고 있다”며 “유엔 대북제재의 완전한 이행을 지지한다”는 입장을 재확인했다.\n\n정 장관도 우크라이나 전쟁 종식을 언급하며 민간인 피해에 대해 우려를 표했지만 한반도 문제에 대해서는 온도차를 보였다. 그는 “흡수통일은 가능하지도 바람직하지도 않다”고 밝히며 현 정부의 대북정책 3대 원칙으로 △북한 체제 인정·존중 △흡수통일 배제 △적대행위 금지를 제시했다. 정 장관은 지난 4월에도 EU의회 외교위원장 일행과 면담을 갖고 “EU가 중재하는 2+1 남북 정치대화 추진을 검토해 달라”고 요청하는 등, 한반도 문제에 대해 역할을 해줄 것을 당부한 바 있다.", + "htmlBody": "

서울 면담서 양측 이견 드러내 EU “北 대러지원은 국제법 위반” 정동영 통일부 장관이 28일 유럽연합(EU)의회 한반도관계대표단과 면담에서 ‘한반도 평화를 위한 역할’을 주문한 반면 EU 측은 북한의 대러시아 군사지원을 강하게 규탄하며 비핵화 및 대북제재 이행을 강조했다.

이날 오전 서울 종로구 남북회담본부에서 열린 면담에서 정 장관은 “EU는 한반도 평화에 그동안 건설적 역할을 해왔다”며 “서울에 왔으니 다음에는 평양도 방문해 주길 바란다”고 말했다. 하지만 EU의회 대표단은 러시아·우크라이나 전쟁에 대해 “국제법과 유엔헌장에 대한 명백한 위반”으로 규정하고 이 과정에서 북한의 러시아 군사지원이 역내 안보를 위협하고 있다고 지적했다. EU의회 대표단은 “유럽 의회는 북한이 러시아에 제공한 군사지원을 강력히 규탄하고, 러시아가 북한의 핵 또는 향후 미사일 기술을 이전할 가능성에 대해 우려를 표명했다”고 밝혔다. 특히 EU의회 대표단은 “우리는 (북한) 비핵화 목표에 계속 헌신하고 있다”며 “유엔 대북제재의 완전한 이행을 지지한다”는 입장을 재확인했다.

정 장관도 우크라이나 전쟁 종식을 언급하며 민간인 피해에 대해 우려를 표했지만 한반도 문제에 대해서는 온도차를 보였다. 그는 “흡수통일은 가능하지도 바람직하지도 않다”고 밝히며 현 정부의 대북정책 3대 원칙으로 △북한 체제 인정·존중 △흡수통일 배제 △적대행위 금지를 제시했다. 정 장관은 지난 4월에도 EU의회 외교위원장 일행과 면담을 갖고 “EU가 중재하는 2+1 남북 정치대화 추진을 검토해 달라”고 요청하는 등, 한반도 문제에 대해 역할을 해줄 것을 당부한 바 있다.

", + "tags": [ + "네이버뉴스", + "정치" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:16:23+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-e07f56d6a0779193", + "title": "24시간 동안 선박 23척 호르무즈 통과… ‘나무호 피격’ 韓은 25척 ‘무소식’", + "sourceLabel": "문화일보", + "url": "https://n.news.naver.com/mnews/article/021/0002794125", + "lead": "조현, 이란 외교에 항의 계획 선박통과 협상 레버리지 주목 배상청구 등 책임추궁 없을듯 전문가 “대통령 직접 나서야”", + "body": "조현, 이란 외교에 항의 계획 선박통과 협상 레버리지 주목 배상청구 등 책임추궁 없을듯 전문가 “대통령 직접 나서야”\n\n나무호 피격한 탄두 27일 외교부는 지난 4일 호르무즈 해협에 정박 중이던 HMM 나무호를 공격한 비행체가 이란에서 개발된 ‘누르’ 계열 대함미사일일 가능성이 크다고 발표했다. 피격 현장에서 발견된 미사일 탄두(오른쪽 사진)와 피격으로 폭 5m, 길이 7m의 파공이 발생한 나무호 하부 모습(왼쪽). 외교부 제공\n\n정부가 지난 4일 호르무즈 해협에서 피격당한 HMM 나무호 공격 주체로 이란을 사실상 지목한 가운데 조현 외교부 장관이 조만간 이란 외교장관과 통화해 항의할 것으로 확인됐다. 나무호 사태를 호르무즈 해협에 갇힌 한국 선박의 통항을 위한 외교적 레버리지로 활용하기 위해 이란에 대한 대응 수위를 높일 것이라는 분석이다. 이란 이슬람혁명수비대(IRGC)는 27일(현지시간) “지난 24시간 동안 선박 23척이 IRGC 허가를 얻어 호르무즈 해협을 통과했다”고 밝혔다.\n\n28일 복수의 외교 소식통에 따르면 한·이란 양국은 조 장관과 아바스 아라그치 이란 외교장관의 통화를 위해 일정을 조율 중이다. 외교부 고위 당국자는 “고위급 소통을 통해 이란 측에 강력한 규탄 메시지를 전할 것”이라며 “양국 외교장관 통화를 추진하고 있다”고 말했다. 전날 사이드 쿠제치 주한이란 대사를 초치한 데 이어 대응 수위를 끌어올리는 것으로 풀이된다.\n\n정부가 대이란 압박을 강화한 배경은 객관적 증거 조사를 통해 이란을 공격 주체로 특정했기 때문이라는 평가다. 박윤주 외교부 1차관은 전날 나무호를 공격한 것은 이란산 ‘누르’ 계열 대함미사일일 가능성이 크다며 “여러 증거가 이란 쪽을 향하고 있다”고 밝혔다.\n\n다만 정부가 향후 이란에 손해배상 청구 등 실질적 책임 추궁으로 나아갈 가능성은 크지 않다. 호르무즈 해협에 남은 한국 선박 25척의 안전을 고려할 수밖에 없기 때문이다. 호르무즈 해협에 정박 중인 선박 수십 척이 공격당했지만 이란 정부가 책임을 인정한 적이 한 번도 없다는 점도 고려됐다.\n\n외교 소식통은 “계속 문제 제기하면서 한국 선박과 선원의 안전보장 및 자유 통항을 촉구하는 방향이 현실적”이라고 말했다. 나무호 사태를 계기로 국제공조 참여에 탄력이 붙을 것이란 전망도 나온다. 정부는 이번 조사 결과를 이란은 물론 미국을 포함한 우방국과도 공유한 것으로 전해졌다. 정부의 대이란 외교 전략이 지나치게 소극적이란 비판도 제기된다. 장지향 아산정책연구원 수석연구위원은 “대통령이 나서서 성명을 발표하는 등 한국의 입장을 국제사회에 확실히 알릴 필요가 있다”고 말했다.\n\n한편 IRGC는 27일 공식 매체인 세파뉴스를 통해 지난 24시간 동안 유조선과 상선 등을 포함한 선박 23척이 IRGC 해군의 허가와 보호 아래 호르무즈 해협을 통과했다고 밝혔다.", + "htmlBody": "

조현, 이란 외교에 항의 계획 선박통과 협상 레버리지 주목 배상청구 등 책임추궁 없을듯 전문가 “대통령 직접 나서야”

\"나무호
나무호 피격한 탄두 \n27일 외교부는 지난 4일 호르무즈 해협에 정박 중이던 HMM 나무호를 공격한 비행체가 이란에서 개발된 ‘누르’ 계열 대함미사일일 가능성이 크다고 발표했다. 피격 현장에서 발견된 미사일 탄두(오른쪽 사진)와 피격으로 폭 5m, 길이 7m의 파공이 발생한 나무호 하부 모습(왼쪽). 외교부 제공

정부가 지난 4일 호르무즈 해협에서 피격당한 HMM 나무호 공격 주체로 이란을 사실상 지목한 가운데 조현 외교부 장관이 조만간 이란 외교장관과 통화해 항의할 것으로 확인됐다. 나무호 사태를 호르무즈 해협에 갇힌 한국 선박의 통항을 위한 외교적 레버리지로 활용하기 위해 이란에 대한 대응 수위를 높일 것이라는 분석이다. 이란 이슬람혁명수비대(IRGC)는 27일(현지시간) “지난 24시간 동안 선박 23척이 IRGC 허가를 얻어 호르무즈 해협을 통과했다”고 밝혔다.

28일 복수의 외교 소식통에 따르면 한·이란 양국은 조 장관과 아바스 아라그치 이란 외교장관의 통화를 위해 일정을 조율 중이다. 외교부 고위 당국자는 “고위급 소통을 통해 이란 측에 강력한 규탄 메시지를 전할 것”이라며 “양국 외교장관 통화를 추진하고 있다”고 말했다. 전날 사이드 쿠제치 주한이란 대사를 초치한 데 이어 대응 수위를 끌어올리는 것으로 풀이된다.

정부가 대이란 압박을 강화한 배경은 객관적 증거 조사를 통해 이란을 공격 주체로 특정했기 때문이라는 평가다. 박윤주 외교부 1차관은 전날 나무호를 공격한 것은 이란산 ‘누르’ 계열 대함미사일일 가능성이 크다며 “여러 증거가 이란 쪽을 향하고 있다”고 밝혔다.

다만 정부가 향후 이란에 손해배상 청구 등 실질적 책임 추궁으로 나아갈 가능성은 크지 않다. 호르무즈 해협에 남은 한국 선박 25척의 안전을 고려할 수밖에 없기 때문이다. 호르무즈 해협에 정박 중인 선박 수십 척이 공격당했지만 이란 정부가 책임을 인정한 적이 한 번도 없다는 점도 고려됐다.

외교 소식통은 “계속 문제 제기하면서 한국 선박과 선원의 안전보장 및 자유 통항을 촉구하는 방향이 현실적”이라고 말했다. 나무호 사태를 계기로 국제공조 참여에 탄력이 붙을 것이란 전망도 나온다. 정부는 이번 조사 결과를 이란은 물론 미국을 포함한 우방국과도 공유한 것으로 전해졌다. 정부의 대이란 외교 전략이 지나치게 소극적이란 비판도 제기된다. 장지향 아산정책연구원 수석연구위원은 “대통령이 나서서 성명을 발표하는 등 한국의 입장을 국제사회에 확실히 알릴 필요가 있다”고 말했다.

한편 IRGC는 27일 공식 매체인 세파뉴스를 통해 지난 24시간 동안 유조선과 상선 등을 포함한 선박 23척이 IRGC 해군의 허가와 보호 아래 호르무즈 해협을 통과했다고 밝혔다.

", + "tags": [ + "네이버뉴스", + "정치" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:16:24+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-133cc6ed0931594f", + "title": "정청래·한병도 사전투표…장동혁 본투표·송언석 사전투표(종합)", + "sourceLabel": "뉴스1", + "url": "https://n.news.naver.com/mnews/article/421/0008970836", + "lead": "29일 정청래 마포, 한병도 익산 각 지역구서 사전투표 국힘, 전략적 분산투표 건의…\"걱정 말고 사전투표\" 독려", + "body": "29일 정청래 마포, 한병도 익산 각 지역구서 사전투표 국힘, 전략적 분산투표 건의…\"걱정 말고 사전투표\" 독려\n\n정청래 더불어민주당 대표와 한병도 원내대표. ⓒ 뉴스1 유승관 기자\n\n(서울=뉴스1) 서미선 김정률 박기현 장시온 기자 = 여야 지도부가 각 당 전략에 따라 6·3지방선거 및 국회의원 재·보궐선거 사전투표 또는 본투표에 참여한다.\n\n더불어민주당 정청래 대표와 한병도 원내대표는 사전투표에서, 국민의힘 장동혁 대표는 본투표, 송언석 원내대표는 사전투표에서 각각 한 표를 행사한다.\n\n28일 정치권에 따르면 정 대표는 사전투표 첫날인 29일 오전 8시께 자신의 지역구인 서울 마포구에서 사전투표를 할 예정이다.\n\n이후 서울 중구 정원오 민주당 서울시장 후보 선거캠프 사무실로 이동해 정 후보와 함께 선대위 회의를 주재한다.\n\n지역 행보를 이어가고 있는 한병도 공동상임선대위원장도 같은 날 자신의 지역구인 전북 익산에서 사전투표를 할 계획이다. 전북지사 선거는 이원택 민주당 후보와 당에서 제명돼 무소속 출마한 김관영 후보가 치열하게 접전을 벌이고 있다.\n\n장동혁 국민의힘 대표와 송언석 국민의힘 원내대표. ⓒ 뉴스1 신웅수 기자\n\n국민의힘 장동혁 대표는 본투표에, 송언석 원내대표를 비롯한 일부 지도부는 사전투표에 참여한다.\n\n정희용 선거대책본부장은 이날 서울 여의도 중앙당사에서 간담회를 열어 당 지도부에게 '전략적 분산 투표'를 제언했다면서 \"당 지도부 일부는 (사전투표에) 참여하고, 당대표는 전체 과정을 챙기고 본투표에 참여해 줄 것을 건의했다\"고 설명했다.\n\n정 본부장은 \"투표율이 높으면 무조건 (국민의힘이) 유리하다\"며 \"사전투표를 걱정하는 일 없도록 전 과정을 꼼꼼하게 살피고 점검하겠다\"고 투표를 호소했다. 장동혁 지도부는 그동안 사전투표 관리 부실 문제 등을 지적해온 바 있다. 국민의힘 강성 지지층은 관리 부실을 이유로 사전투표보다 본투표를 선호하는 경향이 강한 것으로도 알려져 있다.\n\n6·3 지방선거 사전투표는 29일부터 30일까지 이틀간 오전 6시부터 오후 6시까지 진행된다. 본투표는 내달 3일 오전 6시부터 오후 6시까지다.", + "htmlBody": "

29일 정청래 마포, 한병도 익산 각 지역구서 사전투표 국힘, 전략적 분산투표 건의…"걱정 말고 사전투표" 독려

\"정청래

정청래 더불어민주당 대표와 한병도 원내대표. ⓒ 뉴스1 유승관 기자

(서울=뉴스1) 서미선 김정률 박기현 장시온 기자 = 여야 지도부가 각 당 전략에 따라 6·3지방선거 및 국회의원 재·보궐선거 사전투표 또는 본투표에 참여한다.

더불어민주당 정청래 대표와 한병도 원내대표는 사전투표에서, 국민의힘 장동혁 대표는 본투표, 송언석 원내대표는 사전투표에서 각각 한 표를 행사한다.

28일 정치권에 따르면 정 대표는 사전투표 첫날인 29일 오전 8시께 자신의 지역구인 서울 마포구에서 사전투표를 할 예정이다.

이후 서울 중구 정원오 민주당 서울시장 후보 선거캠프 사무실로 이동해 정 후보와 함께 선대위 회의를 주재한다.

지역 행보를 이어가고 있는 한병도 공동상임선대위원장도 같은 날 자신의 지역구인 전북 익산에서 사전투표를 할 계획이다. 전북지사 선거는 이원택 민주당 후보와 당에서 제명돼 무소속 출마한 김관영 후보가 치열하게 접전을 벌이고 있다.

\"장동혁

장동혁 국민의힘 대표와 송언석 국민의힘 원내대표. ⓒ 뉴스1 신웅수 기자

국민의힘 장동혁 대표는 본투표에, 송언석 원내대표를 비롯한 일부 지도부는 사전투표에 참여한다.

정희용 선거대책본부장은 이날 서울 여의도 중앙당사에서 간담회를 열어 당 지도부에게 '전략적 분산 투표'를 제언했다면서 "당 지도부 일부는 (사전투표에) 참여하고, 당대표는 전체 과정을 챙기고 본투표에 참여해 줄 것을 건의했다"고 설명했다.

정 본부장은 "투표율이 높으면 무조건 (국민의힘이) 유리하다"며 "사전투표를 걱정하는 일 없도록 전 과정을 꼼꼼하게 살피고 점검하겠다"고 투표를 호소했다. 장동혁 지도부는 그동안 사전투표 관리 부실 문제 등을 지적해온 바 있다. 국민의힘 강성 지지층은 관리 부실을 이유로 사전투표보다 본투표를 선호하는 경향이 강한 것으로도 알려져 있다.

6·3 지방선거 사전투표는 29일부터 30일까지 이틀간 오전 6시부터 오후 6시까지 진행된다. 본투표는 내달 3일 오전 6시부터 오후 6시까지다.

", + "tags": [ + "네이버뉴스", + "정치" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:16:34+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-13472cb859421689", + "title": "\"새 황제주 뜬다\"…월가", + "sourceLabel": "한국경제TV", + "url": "https://n.news.naver.com/mnews/article/215/0001253581", + "lead": "스페이스X·오픈AI IPO 앞두고 美 펀드들 현금 비축", + "body": "스페이스X·오픈AI IPO 앞두고 美 펀드들 현금 비축\n\n스페이스X. 사진=연합뉴스 스페이스X와 오픈AI 등 초대형 기업공개(IPO)를 앞두고 미국 대형 펀드들이 기존 '메가캡(초대형주)' 종목 비중을 줄이고 현금 확보에 나서고 있다.\n\n27일(현지시간) 로이터 통신에 따르면 골드만삭스의 존 플러드 글로벌 뱅킹·마켓 부문 전무는 최근 고객 노트에서 \"지난 수십년간 있었던 4대 대형 IPO 직전에도 미국 주식형 뮤추얼펀드들이 선제적으로 현금 잔고를 늘린 바 있다\"며 \"투자자들이 대형 IPO 파이프라인의 시장 영향에 점점 주목하고 있다\"고 밝혔다.\n\n그러면서 \"패시브 펀드의 경우 이들 신규 상장 기업이 주요 지수에 조기 편입될 가능성에 대비해 기존에 보유 중이던 다른 대형주 지분을 줄여야 하는 수급 압박이 시작됐다\"고 진단했다.\n\n실제 이번 움직임은 대형 신규 상장 기업이 나스닥 100 지수와 S&P 500 지수 등 벤치마크 지수들에 조기 편입되는 것을 허용하는 방향으로 규정이 개편되는 것과 맞물려 있다.\n\n가장 주목받는 기업은 스페이스X다. 스페이스X는 기업가치 1조7천500억 달러(약 2천415조원)를 목표로 상장을 추진 중이며, 상장 직후 미국 증시 시가총액 기준 7위권에 오를 것으로 전망된다.\n\n오픈AI와 앤트로픽도 수개월 내 상장을 추진하고 있다. 오픈AI는 기업가치 1조 달러 이상을 목표로 하고 있으며, 앤트로픽도 이에 근접한 수준의 투자 유치 작업을 진행 중인 것으로 알려졌다.\n\n전문가들은 개인 투자자들이 보유한 풍부한 현금 잔고 역시 신규 상장 열풍을 더욱 부채질할 것으로 보고 있다. 도이체방크 애널리스트들은 26일 고객 노트에서 \"주식 시장에 진입하려는 유동성의 여력과 투자 의지는 여전히 매우 강력하다\"며 \"이는 팬데믹 기간 가계가 축적한 막대한 현금 자산이 뒷받침하고 있기 때문\"이라고 분석했다.\n\n다만 신규 상장 기업들의 지수 내 비중이 작아 증시 전반에 미치는 충격은 크지 않을 것이라는 전망도 나온다.\n\n도이체방크는 예상되는 최대 규모 IPO 물량 역시 현재 S&P 500 전체 시가총액의 0.1%를 소폭 웃도는 수준에 불과하다고 평가했다.", + "htmlBody": "

스페이스X·오픈AI IPO 앞두고 美 펀드들 현금 비축

\"스페이스X.

스페이스X. 사진=연합뉴스 스페이스X와 오픈AI 등 초대형 기업공개(IPO)를 앞두고 미국 대형 펀드들이 기존 '메가캡(초대형주)' 종목 비중을 줄이고 현금 확보에 나서고 있다.

27일(현지시간) 로이터 통신에 따르면 골드만삭스의 존 플러드 글로벌 뱅킹·마켓 부문 전무는 최근 고객 노트에서 "지난 수십년간 있었던 4대 대형 IPO 직전에도 미국 주식형 뮤추얼펀드들이 선제적으로 현금 잔고를 늘린 바 있다"며 "투자자들이 대형 IPO 파이프라인의 시장 영향에 점점 주목하고 있다"고 밝혔다.

그러면서 "패시브 펀드의 경우 이들 신규 상장 기업이 주요 지수에 조기 편입될 가능성에 대비해 기존에 보유 중이던 다른 대형주 지분을 줄여야 하는 수급 압박이 시작됐다"고 진단했다.

실제 이번 움직임은 대형 신규 상장 기업이 나스닥 100 지수와 S&P 500 지수 등 벤치마크 지수들에 조기 편입되는 것을 허용하는 방향으로 규정이 개편되는 것과 맞물려 있다.

가장 주목받는 기업은 스페이스X다. 스페이스X는 기업가치 1조7천500억 달러(약 2천415조원)를 목표로 상장을 추진 중이며, 상장 직후 미국 증시 시가총액 기준 7위권에 오를 것으로 전망된다.

오픈AI와 앤트로픽도 수개월 내 상장을 추진하고 있다. 오픈AI는 기업가치 1조 달러 이상을 목표로 하고 있으며, 앤트로픽도 이에 근접한 수준의 투자 유치 작업을 진행 중인 것으로 알려졌다.

전문가들은 개인 투자자들이 보유한 풍부한 현금 잔고 역시 신규 상장 열풍을 더욱 부채질할 것으로 보고 있다. 도이체방크 애널리스트들은 26일 고객 노트에서 "주식 시장에 진입하려는 유동성의 여력과 투자 의지는 여전히 매우 강력하다"며 "이는 팬데믹 기간 가계가 축적한 막대한 현금 자산이 뒷받침하고 있기 때문"이라고 분석했다.

다만 신규 상장 기업들의 지수 내 비중이 작아 증시 전반에 미치는 충격은 크지 않을 것이라는 전망도 나온다.

도이체방크는 예상되는 최대 규모 IPO 물량 역시 현재 S&P 500 전체 시가총액의 0.1%를 소폭 웃도는 수준에 불과하다고 평가했다.

", + "tags": [ + "네이버뉴스", + "경제" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:16:51+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-09fd0972fec9a1d6", + "title": "지난해 상장사 배당금 17% 늘어난 38조..최대 배당 업종은 반도체", + "sourceLabel": "조선일보", + "url": "https://n.news.naver.com/mnews/article/023/0003978915", + "lead": "서울 영등포구 여의도 한국예탁결제원 /뉴스1 국내 증시의 강세 랠리에 힘입어 지난해 국내 증시에 상장된 법인의 배당 규모가 전년보다 17% 늘어난 것으로 나타났다.", + "body": "서울 영등포구 여의도 한국예탁결제원 /뉴스1 국내 증시의 강세 랠리에 힘입어 지난해 국내 증시에 상장된 법인의 배당 규모가 전년보다 17% 늘어난 것으로 나타났다.\n\n28일 한국예탁결제원에 따르면, 지난해 말 결산을 진행한 상장사 가운데 배당을 지급한 1246개사의 배당 총액은 37조7519억원으로 집계됐다. 전년보다 16.9% 급증했다. 코스피 상장사 577개는 34조6802억원, 코스닥 상장사 669개는 3조717억원을 지급했다. 각각 전년 대비 15.6%, 34% 늘었다.\n\n업종별로는 반도체 제조업이 5조6924억원으로 가장 많은 배당금을 지급했다. 이어 지주회사(3조6790억원), 자동차용 엔진 및 자동차 제조업(3조3037억원), 증권 중개업(1조6183억원) 등 순으로 많았다.\n\n코스피 상장사 중 배당 규모가 가장 컸던 기업은 3조7535억원을 지급한 삼성전자였다. 기아(2조6425억원), SK하이닉스(1조3277억원)가 뒤를 이었다.\n\n주주 유형별로 보면 국내 법인이 15조7209억원, 외국인이 11조8860억원을 배당받았다. 국내 개인은 10조1450억원을 수령했다. 개인 중에서는 50대가 3조3789억원으로 가장 많은 배당금을 챙겼다.", + "htmlBody": "
\"서울

서울 영등포구 여의도 한국예탁결제원 /뉴스1 국내 증시의 강세 랠리에 힘입어 지난해 국내 증시에 상장된 법인의 배당 규모가 전년보다 17% 늘어난 것으로 나타났다.

28일 한국예탁결제원에 따르면, 지난해 말 결산을 진행한 상장사 가운데 배당을 지급한 1246개사의 배당 총액은 37조7519억원으로 집계됐다. 전년보다 16.9% 급증했다. 코스피 상장사 577개는 34조6802억원, 코스닥 상장사 669개는 3조717억원을 지급했다. 각각 전년 대비 15.6%, 34% 늘었다.

업종별로는 반도체 제조업이 5조6924억원으로 가장 많은 배당금을 지급했다. 이어 지주회사(3조6790억원), 자동차용 엔진 및 자동차 제조업(3조3037억원), 증권 중개업(1조6183억원) 등 순으로 많았다.

코스피 상장사 중 배당 규모가 가장 컸던 기업은 3조7535억원을 지급한 삼성전자였다. 기아(2조6425억원), SK하이닉스(1조3277억원)가 뒤를 이었다.

주주 유형별로 보면 국내 법인이 15조7209억원, 외국인이 11조8860억원을 배당받았다. 국내 개인은 10조1450억원을 수령했다. 개인 중에서는 50대가 3조3789억원으로 가장 많은 배당금을 챙겼다.

", + "tags": [ + "네이버뉴스", + "경제" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:17:10+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-9aad43bc905b4aae", + "title": "김수현 측 ‘김세의 300억 금융치료’ 예고했다…“작년에 120억 소송 냈는데 현재 피해는 더 커”", + "sourceLabel": "헤럴드경제", + "url": "https://n.news.naver.com/mnews/article/016/0002649297", + "lead": "배우 김수현(좌), 김세의 가로세로연구소 대표(우)[연합]", + "body": "배우 김수현(좌), 김세의 가로세로연구소 대표(우)[연합]\n\n[헤럴드경제=김성훈 기자] 김세의 가로세로연구소 대표가 AI로 증거를 조작해 배우 김수현에 대해 허위 사실을 유포한 혐의로 구속된 가운데, 김수현 측이 김 대표를 상대로 ‘300억원대 손해배상 소송’을 진행할 뜻을 밝혔다.\n\n김수현 측 고상록 변호사는 28일 MBC 라디오 ‘뉴스투데이’에 출연해 “작년에 사건 발생하자마자 소가를 추산해서 120억 원으로 손해배상 소송을 접수했다”라며 “지금 시점에서 산정한 실제 피해 규모는 경제적 손실만 그보다도 훨씬 크다”라고 말했다.\n\n이어 “현 시점에서 손해를 재산정하고 필요하면 소가를 높일 수 있다”라며 “저희가 수사기관에 이런 피해를 입었다고 제출한 자료에 따르면 한 300억 원 정도 손실이 있는 상황”이라고 밝혔다.\n\n김 대표는 김수현에 대해, 배우 고(故) 김새론이 미성년자 시절부터 교제를 했다고 주장했고, 김새론에게 채무 변제를 압박해서 김새론을 죽음에 이르게 했다는 식의 허위 사실을 주장한 혐의 등으로 지난 26일 구속됐다. 김 대표는 허위 사실을 뒷받침하기 위해 카카오톡 대화와 음성 녹취 등 증거도 AI로 조작한 혐의도 받고 있다.\n\n명예훼손 사건임에도 드물게 구속이 이뤄진 것에 대해 고 변호사는 “범죄 혐의가 중대하고 피해가 매우 크다. 그리고 구속 요건 중 제일 중요한 게 증거인멸의 우려인데, 이 사건은 증거인멸의 우려 정도가 아니라 범죄의 요체가 증거 조작인 사건이다. 또 관련자들이 여러 명 있어 말 맞추기 가능성도 있어서 법원이 증거인멸의 우려가 크다고 판단한 걸로 보인다”고 해석했다.\n\n김 대표 측이 ‘음성 녹취는 증거 조작이 아니다’라고 주장하고 있는 것에 대해 고 변호사는 “조작이라고 볼 수밖에 없는 여러 사정이 있다”라며 “녹취를 제공했다고 하는 제보자는 ‘김수현의 사주로 자신이 킬러로부터 습격을 당했고, 이후 배우 원빈이 피습당한 자기를 찾아와서 위로해줬다’ 이런 식의 허황된 주장을 한, 신뢰할 수 없는 인물이다”라고 지적했다. 이어 “이 제보자가 김새론 사망 이후 같은 시기에 여러 버전의 파일을 김 대표 뿐만 아니라 여러 곳에 제공했다. 그런데 각 녹음 파일을 들어보면 대화의 흐름이나 내용이 서로 맞지가 않는다. 게다가 원본 파일도 제출하지 않았고, 제보자는 현재 잠적한 상태다”라며 “수사기관이 이런 정황과 관련 진술 그리고 다른 객관적인 사정들을 종합해서 조작으로 판단한 것으로 이해하고 있다”라고 했다.\n\n김 대표 측이 ‘음성 녹취의 조작 여부에 대해 국립과학수사연구원(국과수)은 판정 불가라고 판단했다’라고 주장하고 있는 것에 대해 고 변호사는 “공식적으로 국과수 감정 보고서가 공개되거나 그 내용이 확인된 사실은 없다”라며 “수사기관과 법원은 녹취에 대한 기술적인 감정뿐만 아니라 녹취의 입수 경위, 녹취의 진술 내용이 진실한지, 기타 객관적인 증거들을 종합해서 판단을 한다. 그래서 기술적인 판정 불가가 곧 진짜라는 뜻은 아니다”라고 반박했다.\n\n고 변호사는 김 대표가 왜 이렇게까지 했는가에 대해서는 말하기 어렵다면서도 “이번이 처음이 아니다. 그동안 수없이 많은 유명인들을 상대로 상습적으로 허위 사실을 유포해 왔다. 자극적인 내용으로 의혹을 계속 제기하고 확대하면서 엄청난 조회수와 사회적 영향력을 얻고 이걸 기반으로 해서 결국은 후원금 수입과 같은 경제적 이득을 얻고자 한 것으로 보고 있다”라고 했다.\n\n고 변호사는 “이 사건은 사이버 렉카가 확인되지 않은 의혹을 퍼뜨리고 서사를 왜곡해서 대중의 인식을 조작했을 뿐 아니라 카카오톡이나 음성 같은 핵심적인 자료까지 조작한 초유의 사건이다”라며 “한 배우의 명예와 인생을 완전히 파괴하려고 한 집단적이고 계획적인 범죄다”라고 강조했다.", + "htmlBody": "
\"배우
배우 김수현(좌), 김세의 가로세로연구소 대표(우)[연합]

[헤럴드경제=김성훈 기자] 김세의 가로세로연구소 대표가 AI로 증거를 조작해 배우 김수현에 대해 허위 사실을 유포한 혐의로 구속된 가운데, 김수현 측이 김 대표를 상대로 ‘300억원대 손해배상 소송’을 진행할 뜻을 밝혔다.

김수현 측 고상록 변호사는 28일 MBC 라디오 ‘뉴스투데이’에 출연해 “작년에 사건 발생하자마자 소가를 추산해서 120억 원으로 손해배상 소송을 접수했다”라며 “지금 시점에서 산정한 실제 피해 규모는 경제적 손실만 그보다도 훨씬 크다”라고 말했다.

이어 “현 시점에서 손해를 재산정하고 필요하면 소가를 높일 수 있다”라며 “저희가 수사기관에 이런 피해를 입었다고 제출한 자료에 따르면 한 300억 원 정도 손실이 있는 상황”이라고 밝혔다.

김 대표는 김수현에 대해, 배우 고(故) 김새론이 미성년자 시절부터 교제를 했다고 주장했고, 김새론에게 채무 변제를 압박해서 김새론을 죽음에 이르게 했다는 식의 허위 사실을 주장한 혐의 등으로 지난 26일 구속됐다. 김 대표는 허위 사실을 뒷받침하기 위해 카카오톡 대화와 음성 녹취 등 증거도 AI로 조작한 혐의도 받고 있다.

명예훼손 사건임에도 드물게 구속이 이뤄진 것에 대해 고 변호사는 “범죄 혐의가 중대하고 피해가 매우 크다. 그리고 구속 요건 중 제일 중요한 게 증거인멸의 우려인데, 이 사건은 증거인멸의 우려 정도가 아니라 범죄의 요체가 증거 조작인 사건이다. 또 관련자들이 여러 명 있어 말 맞추기 가능성도 있어서 법원이 증거인멸의 우려가 크다고 판단한 걸로 보인다”고 해석했다.

김 대표 측이 ‘음성 녹취는 증거 조작이 아니다’라고 주장하고 있는 것에 대해 고 변호사는 “조작이라고 볼 수밖에 없는 여러 사정이 있다”라며 “녹취를 제공했다고 하는 제보자는 ‘김수현의 사주로 자신이 킬러로부터 습격을 당했고, 이후 배우 원빈이 피습당한 자기를 찾아와서 위로해줬다’ 이런 식의 허황된 주장을 한, 신뢰할 수 없는 인물이다”라고 지적했다. 이어 “이 제보자가 김새론 사망 이후 같은 시기에 여러 버전의 파일을 김 대표 뿐만 아니라 여러 곳에 제공했다. 그런데 각 녹음 파일을 들어보면 대화의 흐름이나 내용이 서로 맞지가 않는다. 게다가 원본 파일도 제출하지 않았고, 제보자는 현재 잠적한 상태다”라며 “수사기관이 이런 정황과 관련 진술 그리고 다른 객관적인 사정들을 종합해서 조작으로 판단한 것으로 이해하고 있다”라고 했다.

김 대표 측이 ‘음성 녹취의 조작 여부에 대해 국립과학수사연구원(국과수)은 판정 불가라고 판단했다’라고 주장하고 있는 것에 대해 고 변호사는 “공식적으로 국과수 감정 보고서가 공개되거나 그 내용이 확인된 사실은 없다”라며 “수사기관과 법원은 녹취에 대한 기술적인 감정뿐만 아니라 녹취의 입수 경위, 녹취의 진술 내용이 진실한지, 기타 객관적인 증거들을 종합해서 판단을 한다. 그래서 기술적인 판정 불가가 곧 진짜라는 뜻은 아니다”라고 반박했다.

고 변호사는 김 대표가 왜 이렇게까지 했는가에 대해서는 말하기 어렵다면서도 “이번이 처음이 아니다. 그동안 수없이 많은 유명인들을 상대로 상습적으로 허위 사실을 유포해 왔다. 자극적인 내용으로 의혹을 계속 제기하고 확대하면서 엄청난 조회수와 사회적 영향력을 얻고 이걸 기반으로 해서 결국은 후원금 수입과 같은 경제적 이득을 얻고자 한 것으로 보고 있다”라고 했다.

고 변호사는 “이 사건은 사이버 렉카가 확인되지 않은 의혹을 퍼뜨리고 서사를 왜곡해서 대중의 인식을 조작했을 뿐 아니라 카카오톡이나 음성 같은 핵심적인 자료까지 조작한 초유의 사건이다”라며 “한 배우의 명예와 인생을 완전히 파괴하려고 한 집단적이고 계획적인 범죄다”라고 강조했다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:17:10+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-02d8c179352653e1", + "title": "[포토] 알리 테무 쉬인 어린이용품 일부", + "sourceLabel": "이데일리", + "url": "https://n.news.naver.com/mnews/article/018/0006292235", + "lead": "[이데일리 김태형 기자] 서울시는 해외직구 여름철 어린이용품에 대한 안전성 검사를 28일 발표했다.", + "body": "[이데일리 김태형 기자] 서울시는 해외직구 여름철 어린이용품에 대한 안전성 검사를 28일 발표했다.\n\n알리익스프레스, 테무, 쉬인 등 온라인플랫폼에서 판매 중인 우산 양산 우비 등 32개 제품에 대한 검사에서 10개 제품이 ‘부적합’한 것으로 나타났다고 밝혔다.\n\n이번 검사는 화학물질 검출 여부와 물리적 안전성을 중심으로 진행했으며 납 기준치 초과, 찔림사고 유발, 프탈레이트계 가소제 기준치 초과 검출 등이 나타났다.\n\n서울시는 부적합 제품에 대해 해당 플랫폼에 판매 중단을 요청했다.\n\n안전성 검사 결과는 ‘서울시 누리집’이나 ‘서울시전자상거래센터 누리집’에서 확인할 수 있다.", + "htmlBody": "
\"기사

[이데일리 김태형 기자] 서울시는 해외직구 여름철 어린이용품에 대한 안전성 검사를 28일 발표했다.

알리익스프레스, 테무, 쉬인 등 온라인플랫폼에서 판매 중인 우산 양산 우비 등 32개 제품에 대한 검사에서 10개 제품이 ‘부적합’한 것으로 나타났다고 밝혔다.

이번 검사는 화학물질 검출 여부와 물리적 안전성을 중심으로 진행했으며 납 기준치 초과, 찔림사고 유발, 프탈레이트계 가소제 기준치 초과 검출 등이 나타났다.

서울시는 부적합 제품에 대해 해당 플랫폼에 판매 중단을 요청했다.

안전성 검사 결과는 ‘서울시 누리집’이나 ‘서울시전자상거래센터 누리집’에서 확인할 수 있다.

", + "tags": [ + "네이버뉴스", + "정치" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:17:10+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-cfb94f1ce87b13c1", + "title": "[속보] 신현송 한은 총재 \"물가·성장·환율 등 보면 금리인상의 방향\"", + "sourceLabel": "동행미디어 시대", + "url": "https://n.news.naver.com/mnews/article/417/0001145158", + "lead": "동행미디어 시대 언론사 구독되었습니다. 메인 뉴스판에서 주요뉴스를 볼 수 있습니다. 보러가기 닫기", + "body": "동행미디어 시대 언론사 구독되었습니다. 메인 뉴스판에서 주요뉴스를 볼 수 있습니다. 보러가기 닫기\n\n동행미디어 시대 언론사 구독 해지되었습니다. 닫기\n\n[속보] 신현송 한은 총재 \"물가·성장·환율 등 보면 금리인상의 방향\"\n\n이동 통신망을 이용하여 음성을 재생하면 별도의 데이터 통화료가 부과될 수 있습니다.\n\n[속보] 신현송 한은 총재 \"물가·성장·환율 등 보면 금리인상의 방향\"\n\n이예빈 기자 (yeahvin@sidae.com)\n\nCopyright ⓒ 동행미디어 시대 All Rights Reserved.\n\n이 기사는 언론사에서 경제 섹션으로 분류했습니다.\n\n기사 섹션 분류 안내 기사의 섹션 정보는 해당 언론사의 분류를 따르고 있습니다. 언론사는 개별 기사를 2개 이상 섹션으로 중복 분류할 수 있습니다.\n\n동행미디어 시대 구독하고 메인에서 바로 만나보세요! 구독하고 메인에서 만나보세요!\n\n언론사의 주요 뉴스를 메인에서 바로 만나보세요! 주요 뉴스를 메인에서 만나보세요!\n\n주요뉴스 해당 언론사에서 선정하며 언론사(아웃링크) 로 이동합니다.\n\n\"여동생이 내 남편 아이 낳아\"…유전자 검사 결과 '친자 99.9%' 충격\n\n'나솔' 31기 순자, '왕따설' 직접 폭로…\"예민하다 핀잔, 단톡방 나왔다\"\n\n'부부관계' 거부한 남편…동성 애인 통해 아내를 대리모로? '경악'\n\n\"오늘밤 홍콩 가요\"…'부부의날' 어린이집서 보낸 선물, 학부모들 '황당'\n\n\"김민종 저격한 MC몽, 실형 가능성\"…현직 변호사 분석 보니?\n\n새로운 전쟁의 시대, K방산의 현재와 미래 QR 코드를 클릭하면 크게 볼 수 있어요.\n\nQR을 촬영해보세요. 새로운 전쟁의 시대, K방산의 현재와 미래\n\n동행미디어 시대 QR 코드를 클릭하면 크게 볼 수 있어요.\n\n이재명 대통령 지지율 64%로 반등…민주당 45%·국민의힘 22%\n\n아이유 사는 아파트, '218억' 전액 현금 매입한 89년생 CEO는 누구?\n\n트럼프 \"이란 농축 우라늄 반드시 회수…호르무즈 통행료 용납 불가\"\n\n\"연예인들, 혜택 다 누려놓고 날 사기꾼으로 몰아\"…박나래 주사이모 항변\n\n캄보디아 총리 사촌, 스캠 범죄 기업 지분 소유…\"사업 관여한 적 없어\"\n\n기사 추천은 24시간 내 50회까지 참여할 수 있습니다.\n\n동행미디어 시대 가 이 기사의 댓글 정책을 결정합니다.\n\n안내 댓글 정책 언론사별 선택제 섹션별로 기사의 댓글 제공여부와 정렬방식을 언론사가 직접 결정합니다. 기사 섹션 정보가 정치/선거를 포함하는 경우 정치/선거섹션 정책이 적용됩니다. 언론사의 결정에 따라 동일한 섹션이라도 기사 단위로 댓글 제공여부와 정렬 방식이 달라질 수 있습니다. 단, 일부 댓글 운영 방식 및 운영규정에 따른 삭제나 이용제한 조치는 네이버가 직접 수행합니다. 레이어 닫기\n\n\"소변보는 거 다 보여\"…중년 여성들, 줄 길다고 남자 화장실 점령 '황당'\n\nMASH 임상성공에 주가급등…디앤디파마텍, 간경화 치료제도 띄운다\n\n남의 집 CCTV를 비닐로 '칭칭'…\"빈집인 줄\" 드라마 촬영팀 민폐 논란\n\n[단독]'차량 5부제 할인 특약' 6월로 밀렸다…4월 소급적용은 그대로\n\n내일부터 이틀간 사전투표…신분증 지참, 전국 투표소 어디서나 가능\n\n[특별대담]日안노 \"AI로 국회 속도 10배\"…이준석 \"AI로 연금개혁 숙의\"\n\n네이버 AI 뉴스 알고리즘 뉴스 추천 알고리즘이 궁금하다면?\n\n\"꼴뚜기·망둥이가 방방곡곡 뛴다\"…박지원 \"이러다 윤석열도 함께 뛸 판\"\n\n민주당 '서소문·GTX' 안전 고리로 吳공세…국힘 \"재난 정쟁화\"\n\n6·3 지선 D-6…與는 '이재명 마케팅', 野는 '박근혜 마케팅'\n\n코스피 숨고르기?...외국인은 2조 던지는 중 [fn오전시황]\n\n\"삼전·닉스 2배 사자\"…레버리지 교육 신청 24만명 돌파\n\n누가 밸브를 쥐고 있나... 이래서 전쟁으로 이어졌다\n\n[정책돋보기] 동네 분만병원 전문의 권역모자의료센터 시간제 근무 허용한다 外\n\n李대통령 “집값 다시 오른다는데 대책 세우고 있나…정책 신뢰 중요”\n\n'전면 등판' 박근혜 vs '정중동' 문재인…전직 대통령 '대조'(종합)\n\n\"꼴뚜기·망둥이가 방방곡곡 뛴다\"…박지원 \"이러다 윤석열도 함께 뛸 판\"\n\n민주당 '서소문·GTX' 안전 고리로 吳공세…국힘 \"재난 정쟁화\"\n\n쿠팡 뉴욕 집단소송, 오는 7월 본격 착수 전망\n\n공정위원장 \"쿠팡 김범석 '허위서약' 때문에 총수 지정 강행\"\n\n주병기 공정위원장 “김범석 쿠팡Inc 의장, 허위 사실 입증되면 고발”\n\n이란 매체 “트럼프, 합의 미완인데 일방 발표 가능성”\n\n‘트럼프 따라하기’ 나선 이란…MOU 선제 공개에 백악관 ‘발칵’ [美-이란 전쟁]\n\n\"미군, 이란 군사기지 타격\"‥호르무즈 '폭발음'\n\n서울 원룸 평균 전세보증금 2억1684만원…서초구 가장 높아\n\n'30억 vs 28억' 그림의 떡인데...현금부자 청약경쟁, 비싼 단지 더 몰렸다\n\nLH, '서리풀사업단' 신설…\"착공 앞당겨 2029년 본청약\"\n\n미국 \"이란 드론·지휘소 타격…휴전 유지 위한 방어적 조치\"(종합)\n\n美, 이틀만에 다시 호르무즈 인근 시설·드론 타격…협상 안갯속(종합2보)\n\n종전 협상 도중 美 거센 압박…호르무즈 내 이란 군시설 또 때렸다\n\n[단독]위성락 카자흐 방문…에너지-안보협력 논의할 듯\n\n\"분배\" 주장 노동장관에 조선일보 \"나눠 먹을 생각만\"\n\n李대통령 \"집값 대책 세우나\" 주문…\"6월 이후 규제 윤곽 구체화\"\n\n우원식 국회의장 “개헌 무산 가장 아쉬워…복당 후 당원 역할 할 것”\n\n산업장관 \"대미투자 건설적 논의…캐나다 잠수함은 지켜봐야\"\n\n[단독] ‘李대통령 선거법 사건 무죄 취지 파기환송’ 대법관 고발, 검찰 4년 만에 각하[세상&]\n\n이 기사를 본 이용자들이 함께 많이 본 기사, 해당 기사와 유사한 기사, 관심 기사 등을 자동 추천합니다\n\n[속보] 한동훈 “李대통령, 전작권 속도전 위험…안보·경제 흔들릴 수도”\n\n전재수 45.8% vs 박형준 39.5%…하정우 33.8% 박민식 17.9% 한동훈 40.2% [동아일보]\n\n벤츠 몰다 인도로 돌진 1명 숨지게 한 70대 “차량 급발진” 주장했지만 결국\n\n[속보]정원오 39% vs 오세훈 39%…전재수 40% vs 박형준 39%…김부겸 40% vs 추경호 38%\n\n尹 전 대통령, '한덕수 재판 위증' 혐의 1심 무죄\n\n삼성 최대노조 7만명 아래로…임협 후폭풍에 과반노조도 '위협'\n\n“하루 2잔, 2주의 기적” 혈압 뚝 떨어뜨린 ‘이 주스’…핵심은 의외로 ‘입 속 세균’ [헬시타임]\n\n\"현대차 120만원 간대요\"…77층 갇힌 개미 눈 커질 소식\n\n한동훈 40.7%, 하정우 35.8%, 박민식 17.9%[CBS-KSOI]\n\n오너의 ‘말의 무게’···정용진이 직원들을 진정 위한다면 했어야 할 말[기자메모]\n\n“당장 담배 안 꺼?” 흡연 고교생 훈계하다 때린 50대 결국 징역형 [오늘의 그날]\n\n태풍 '장미' 북상 중…우리나라 영향 가능성은?\n\n\"카톡·페이·송금 다 못 써요?\"...'첫 파업 위기' 카카오 멈추나\n\n\"여긴 남자화장실인데\"…줄 길자 몰려든 중년 여성들에 온라인 시끌\n\n‘8kg 감량’ 박지윤, 막창 먹어도 뱃살 없는 비결?… “많이 먹으면 ‘이 운동’”\n\n윤석열 ‘한덕수 재판서 위증’ 혐의 1심 무죄\n\n'코스피 8000' 국민연금, 170조 매도폭탄 터지나...기금위, 증시 최대 변수로\n\n\"소변보는 거 다 보여\"…중년 여성들, 줄 길다고 남자 화장실 점령 '황당'\n\n[특별대담]日안노 \"AI로 국회 속도 10배\"…이준석 \"AI로 연금개혁 숙의\"\n\n노동장관 \"반도체는 공공재…대기업 초과이익 사회적 재배분 토론할 것\"\n\n유럽서 통한 기아 EV3, 독일 전기차 비교 평가 '종합 1위'\n\n'조정 결렬' 카카오 노조, 6월10일 판교 일대 대규모 집회\n\n[속보] 코스닥, 1135.84…2.71포인트(0.24%) 상승 출발\n\n[속보] 최승호 삼전 노조위원장 “경솔·부적절 발언 사과”\n\n정원오 49.6% vs 오세훈 36.4%… 전재수 45.8% vs 박형준 39.5%… 김부겸 41.8% vs 추경호 45.1%\n\n정청래 “김용남·조국 단일화 현실적으로 어렵게 돼”\n\n“월 200만원 생활? 턱도 없다” 은퇴 전 알아야 할 비용 5가지\n\n“20대 신혼부부가 16억 집 계약, 알고보니 삼전·하닉 커플”…동탄 부동산 들썩\n\n'538% 급등' 진짜 돈 복사기 따로 있었네…삼전닉스 제친 대형주 있다\n\n'이재용 회장도 탄다'…벤츠 S클래스 꺾고 '1위' 등극한 車\n\n탱크에 전두환·나치 문양까지…연이은 사과에도 ‘극우’ 상징된 스타벅스\n\n국민연금 오늘 국내주식 비중 결정‥\"팔자니 충격, 안 팔자니 부담\"\n\n“윤 전 대통령의 진술, 기억에 반한다고 보기 어렵다”…‘한덕수 재판 위증 혐의’ 무죄 선고\n\n\"공고 나와 성과급 6억, 공부 안 시킨 부모님 감사\"…삼전 직원글 '질타'\n\n최승호 삼성전자 노조위원장 \"DX 부문 챙길 것…재신임 투표 진행\"\n\n“라면 끓일 때 수돗물? 생수?” 오랜 논쟁, 단번에 ‘종결’…우리집 물 검사해 보니 [지구, 뭐래?]\n\n[특별대담]日안노 \"AI로 국회 속도 10배\"…이준석 \"AI로 연금개혁 숙의\"\n\n[단독]'차량 5부제 할인 특약' 6월로 밀렸다…4월 소급적용은 그대로\n\nMASH 임상성공에 주가급등…디앤디파마텍, 간경화 치료제도 띄운다\n\n\"소변보는 거 다 보여\"…중년 여성들, 줄 길다고 남자 화장실 점령 '황당'\n\n남의 집 CCTV를 비닐로 '칭칭'…\"빈집인 줄\" 드라마 촬영팀 민폐 논란\n\n기사배열 책임자 : 김수향 청소년 보호 책임자 : 이정규\n\n각 언론사가 직접 콘텐츠를 편집합니다. ⓒ 동행미디어 시대\n\n이 콘텐츠의 저작권은 저작권자 또는 제공처에 있으며, 이를 무단 이용하는 경우 저작권법 등에 따라 법적 책임을 질 수 있습니다.", + "htmlBody": "

var svt = "20260528121717.970"; var timestamp = svt.substr(0, 8); var isLogin = false; var uhv = "";

var service = { news: false, entertain: false, sports: false, mnews: true, newsType: true }; var serviceName = "mnews";

var nilNtmUrl = "https://ntm.pstatic.net/scripts/ntm_b7032129a433.js";

var envPhase = "production"; var isProduction = true;

var gnb_service = "news"; var gnb_logout = encodeURIComponent(location.href); var gnb_template = "gnb_utf8"; var gnb_brightness = 1; var gnb_item_hide_option = 128;

var isDegradeMode = false; var isDegradeN2Mode = false;

\"동행미디어

동행미디어 시대 언론사 구독되었습니다.\n메인 뉴스판에서 주요뉴스를\n볼 수 있습니다.\n보러가기\n닫기

동행미디어 시대 언론사 구독 해지되었습니다.\n닫기

[속보] 신현송 한은 총재 "물가·성장·환율 등 보면 금리인상의 방향"

입력 2026.05.28. 오후 12:14

수정 2026.05.28. 오후 12:15

텍스트 음성 변환 서비스 사용하기

이동 통신망을 이용하여 음성을 재생하면 별도의 데이터 통화료가 부과될 수 있습니다.

\"기사

[속보] 신현송 한은 총재 "물가·성장·환율 등 보면 금리인상의 방향"

이예빈 기자 (yeahvin@sidae.com)

Copyright ⓒ 동행미디어 시대 All Rights Reserved.

이 기사는 언론사에서 경제 섹션으로 분류했습니다.

기사의 섹션 정보는 해당 언론사의 분류를 따르고 있습니다. 언론사는 개별 기사를 2개 이상 섹션으로 중복 분류할 수 있습니다.

동행미디어 시대 구독하고 메인에서 바로 만나보세요! 구독하고 메인에서 만나보세요!

언론사의 주요 뉴스를 메인에서 바로 만나보세요! 주요 뉴스를 메인에서 만나보세요!

동행미디어 시대\n\n주요뉴스 해당 언론사에서 선정하며 언론사(아웃링크) 로 이동합니다.

동행미디어 시대 언론사가 직접 선정한 이슈

window.__htExternalUrl = window.__htExternalUrl || {}; window.__htExternalUrl["like"] = "https://static-like.pstatic.net/js/reaction/dist/reaction.min.js"; window.__htLikeOption = { type: "multi", cssId: "news", domain: "https://news.like.naver.com", staticDomain: "https://static-like.pstatic.net", isMobile: true, isHiddenLabel: false, isHiddenCount: false, isHiddenZeroCount: false, isUsedLabelAsZeroCount: false, isDebugMode: false, isDuplication: false, isHiddenLayerAfterSelection: true, clicklog: function(el, name) { nclkWrap.send({ $this: $(el), sArea: name }); } }; window.__htLikeOption["isZeroFace"] = true;

기사 추천은 24시간 내 50회까지 참여할 수 있습니다.

window.__htExternalUrl = window.__htExternalUrl || {}; window.__htExternalUrl["share"] = "https://ssl.pstatic.net/spi/js/release/ko_KR/splugin.js"; window.__htShareOption = { "scriptUrl": window.__htExternalUrl["share"], "evKey": "news", "dimmed": "fixed", "serviceName": "뉴스", "sourceName": "" }; if (isAvailableDarkMode) { window.__htShareOption["darkMode"] = true; }

window.__htExternalUrl = window.__htExternalUrl || {}; window.__htExternalUrl["tip"] = "https://ssl.pstatic.net/dicimg/tip/tip_m.js"; window.__htExternalUrl["pcTip"] = "https://ssl.pstatic.net/dicimg/tip/tip_pc.js";

window.__htExternalUrl["handOff"] = { "category": "news_article", "apiDomain": "https://sidekick.fever.naver.com" };

\"섬네일

스티커 하나만 모아도 네이버페이 행운이!

선거 특집페이지에서 출석 스티커 받고, 최대 15,000P의 행운을 기대해요!

window.__htExternalUrl = window.__htExternalUrl || {}; window.__htExternalUrl["comment"] = "https://ssl.pstatic.net/static.cbox/js/cbox.core.js"; window.__htCboxOption = {"htMessage":{"template":{"user":{"profile":{"help":{"last":{"help_desc":"최근 30일간 작성한 댓글의 정보를 요약해 보여줍니다. 작성대비 본인의 삭제비율 (본인삭제율) 과 작성글이 받은 피드백 중 공감비율 (받은공감률) 을 표기합니다. 이때, 받은공감률은 댓글 삭제로 변동된 정보를 반영하지 않습니다."}},"title":"뉴스 댓글모음","private_back":"기사로 돌아가기"},"header":{"help":{"content":"댓글목록을 공개한 사용자의 댓글모음이 제공됩니다. 나의 댓글 모음에서 다른 사람에게 공개 또는 비공개 설정을 할 수 있습니다. 제공되는 목록에 삭제댓글/답글은 포함하지 않습니다.","title":"뉴스댓글 도움말"}}},"social_login_message":"계정을 선택하시면 로그인·계정인증 을 통해 댓글을 남기실 수 있습니다. 뉴스서비스에서는 소셜 계정 사용이 불가하며 댓글모음만 확인하실 수 있습니다.","write_placeholder":"댓글을 입력해주세요","sort_favorite":"순공감순","duplicate_caution_link_address_pc":"https://news.naver.com/main/principle.naver","duplicate_caution_link_address_mobile":"https://m.news.naver.com/ombudsman/index?mode=rule&open=comment","duplicate_caution_link_title":"운영규정 자세히 알아보기","duplicate_caution_cont":"반복 등록이 계속될 경우 운영규정에 따라 서비스 이용이 정지될 수 있습니다.","duplicate_caution_title":"동일 내용 댓글의 반복 등록이 확인되었습니다.","write_long_placeholder":"다양한 의견이 서로 존중될 수 있도록 다른 사람에게 불쾌감을 주는 욕설, 혐오, 비하의 표현이나 타인의 권리를 침해하는 내용은 주의해주세요. 모든 작성자는 본인이 작성한 의견에 대해 법적 책임을 갖는다는 점 유의하시기 바랍니다.","help_titles":["댓글이력공개안내","댓글노출정책"],"info_comment_off":" 동행미디어 시대 댓글 정책에 따라 이 기사에서는 댓글을 제공하지 않습니다.","userblock_completed_dialog_content":"댓글모음이나 기사 댓글에서 차단을 해제할 수 있습니다.","userblock_ready_dialog_content":"이제 기사 댓글이나 댓글모음에서 상대방의 댓글을 확인할 수 없습니다.","userblocked_message_content":"댓글모음이나 기사 댓글에서 차단한 이용자들을 관리할 수 있습니다.","userunblock_completed_dialog_content":"상대방의 댓글모음이나 기사 댓글에서 언제든지 차단할 수 있습니다.","userunblock_ready_dialog_content":"이제 기사 댓글이나 댓글모음에서 상대방의 댓글을 확인할 수 있습니다.","view_all":"댓글 더보기","help_contents":["작성자가 삭제한 댓글(작성자 삭제), 운영자가 삭제한 댓글(규정 미준수) 그리고 삭제되지 않은 현재 남은 댓글의 수와 이력을 투명하게 제공합니다.","순공감순은 공감수에서 비공감수를 뺀 수치가 많은 댓글입니다. 비정상적인 방법으로 공감수가 증가하는 경우 제외될 수 있습니다."],"reply_close":"답글 접기","list_empty":"등록된 댓글이 없습니다.","comment_link":"이 기사의 댓글 전체보기"},"stats":{"title":"댓글 작성 통계","view":"통계","fold":"접기","user_env_title":"어디에서 댓글을 썼을까요?"},"alert":{"max_length":"{0}자까지 입력할 수 있습니다.","login_require":"로그인을 하신 후 이용해 주시기 바랍니다.","min_length":"내용을 최소 {0}자 이상 입력해주세요."}},"nPageSize":5,"sObjectId":"news417,0001145158","sLikeItId":"ne_417_0001145158","sTicket":"news","nUserCommentReplyPageSize":10,"sAuthType":"naver","sTemplateId":"view_economy_m1","htErrorHandler":{"3003":"고객님께서는 운영원칙에서 제한하고 있는 글을 작성하셨기 때문에 글쓰기를 포함한 일부 활동이 일정 기간 제한되었습니다. 동일 내용 댓글의 반복작성이 확인된 경우 제한기간은 1일이며, 반복이 계속되면 기간이 연장될 수 있습니다.","3004":"고객님께서는 운영원칙에서 제한하고 있는 글을 다수 작성하셨기 때문에 글쓰기를 포함한 일부​ 활동이 일정 기간 제한되었습니다.","3005":"고객님의 IP는 운영원칙에서 제한하고 있는 글을 작성하셨기 때문에 글쓰기를 포함한 일부​ 활동이 제한되었습니다.","3006":"고객님께서는 운영원칙에서 제한하고 있는 글을 작성하셨기 때문에 무기한 글쓰기를 포함한 일부​ 활동이 제한되었습니다.","3011":"동행미디어 시대 댓글 정책에 따라 이 기사에서는 댓글을 제공하지 않습니다.","4004":"해당되는 댓글이 없습니다.","5003":"정상적으로 반영되지 않았습니다. 화면 새로고침 후 다시 시도해 주세요.","5006":"자신의 글에 공감 할 수 없습니다.","5008":"자신의 글에 비공감 할 수 없습니다.","5010":"댓글/답글은 60초 내에 한 개만 등록하실 수 있습니다.","5026":"공감/비공감은 최근 24시간내 50회까지 가능합니다.","5027":"공감/비공감은 10초 내에 한 번만 클릭하실 수 있습니다.","5029":"기사당 댓글은 3개, 답글은 10개까지 작성할 수 있습니다.","5080":"실명확인을 받지 않은 아이디는 가입 이후 7일이 지난 시점부터 댓글 활동이 가능합니다.","5091":"본인 확인이 되지 않은 계정입니다. 본인 확인 후 다시 시도해주세요."},"bSortCookie":true,"bMobile":false,"bShowReply":false,"bManager":false,"sDomain":"https://ssl.pstatic.net/static.cbox","sCountry":"KR","nUserCommentPageSize":20,"sLanguage":"ko","sDateFormat":"Y.m.d. H:i","sCommentNo":"","sPageType":"more","sHelp":"down","sSort":"FAVORITE","sApiDomain":"https://apis.naver.com/commentBox/cbox5","nFollowSize":5,"nFeedPageSize":50,"bIncludeAllComments":true,"sCountType":"comment","aFormation":["count","graph","write","notice","follow","list","view"],"bByCountry":false,"bUseAgo":false,"nReplyPageSize":20,"sCssId":"news_w","aSortTypes":["FAVORITE","NEW","RELATIVE"]}; window.__htCboxOption["bLogin"] = isLogin; window.__htCboxOption["bMobile"] = false; window.__htCboxOption["sHighlightColor"] = "rgba(124, 255, 182, 0.06)"; window.__htCboxOption["vViewAllHandler"] = function() { window.location.href = "/mnews/article/comment/417/0001145158"; };

동행미디어 시대 가 이 기사의 댓글 정책을 결정합니다.\n\n안내\n댓글 정책 언론사별 선택제\n섹션별로 기사의 댓글 제공여부와 정렬방식을 언론사가 직접 결정합니다. 기사 섹션 정보가 정치/선거를 포함하는 경우 정치/선거섹션 정책이 적용됩니다. 언론사의 결정에 따라 동일한 섹션이라도 기사 단위로 댓글 제공여부와 정렬 방식이 달라질 수 있습니다. 단, 일부 댓글 운영 방식 및 운영규정에 따른 삭제나 이용제한 조치는 네이버가 직접 수행합니다.\n레이어 닫기

오전 11시~정오까지 집계한 결과입니다.

\"기사

네이버 AI 뉴스 알고리즘 뉴스 추천 알고리즘이 궁금하다면?

언론사에서 직접 선별한 이슈입니다.

12개 언론사 10,085개 기사

47개 언론사 29,015개 기사

22개 언론사 24,500개 기사

34개 언론사 53,233개 기사

23개 언론사 31,186개 기사

27개 언론사 45,657개 기사

이 기사를 본 이용자들이 함께 많이 본 기사, 해당 기사와 유사한 기사, 관심 기사 등을 자동 추천합니다

오전 9시~정오까지 집계한 결과입니다.

로그인\n\n전체서비스\n\n서비스안내\n\n오류신고\n\n고객센터\n\n기사배열 책임자 : 김수향\n청소년 보호 책임자 : 이정규\n\n각 언론사가 직접 콘텐츠를 편집합니다.\nⓒ 동행미디어 시대\n\n이 콘텐츠의 저작권은 저작권자 또는 제공처에 있으며, 이를 무단\n이용하는 경우 저작권법 등에 따라 법적 책임을 질 수 있습니다.\n\nNAVER

{{#series}} {{value}} 연재 구독을 취소하시겠습니까? {{/series}} {{#journalist}} {{value}} 기자의 기사 구독을 취소하시겠습니까? {{/journalist}}

{{officeName}} 구독이 완료되었습니다. 알림 으로 받아 보시겠습니까?

{{officeName}} 구독이 완료되었습니다. 알림 으로 받아 보시겠습니까?

네이버 앱 푸시알림이 꺼져있어요.

네이버 앱 알림켜기 > 알림탭 > 알림허용

버튼을 눌러 앱 알림 설정을 켜주세요.

{{#content.image}}

{{#content.image.class}}

{{/content.image.class}}

{{/content.image}} {{#isTextType}}

{{#content.image}}

{{#content.image.class}}

{{/content.image.class}}

{{/content.image}} {{#isTextType}} {{content.title}}

{{/isTextType}} {{#isTitleType}}

{{content.headTitle}} {{content.title}}

{{content.description}}

{{content.cancelMessage}}

{{content.confirmMessage}}

{{toastTitle}} {{toastMessage}}

{{officeName}} 언론사 알림이 설정되었습니다.{{#isSupportedNaverappNotificationSetting}} 네이버앱 전체 푸시 알림도 활성화된 상태입니다.{{/isSupportedNaverappNotificationSetting}}

window.ntm = window.ntm || []; var ntmOption = {};

var section = {"id":"101","name":"경제"}; var redirectSection = {"id":"101","name":"경제"};

ntmOption["channelId"] = article.officeId;

// 기타 또는 주요 섹션 이름 var nilSendSectionName = "기타"; var nilSendSectionId = ""; if (/100|101|102|103|104|105|106|107/.test(section.id)) { nilSendSectionName = section.name; nilSendSectionId = section.id; }

// 기사 타입 var htArticleTypeName = { "0": "text", "1": "photo", "2": "tv" }; var articleTypeName = htArticleTypeName[article.type] || "";

ntmOption["hitType"] = "cv"; ntmOption["eventCategory"] = "post_view"; ntmOption["uri"] = "http://news.naver.com/main/read.nhn?oid=417&aid=0001145158"; ntmOption["gdid"] = article.gdid; ntmOption["dimension1"] = encodeURIComponent(nilSendSectionName); ntmOption["dimension4"] = articleTypeName; ntmOption["optional2"] = nilSendSectionId;

var journalistIdList = [];

// 기자 목록 $("._JOURNALIST_CARD ._JOURNALIST_ID").each(function (index, object) { var $this = $(object); var journalistId = $this.attr("data-journalistId");

if (journalistId && journalistIdList.indexOf(journalistId) === -1) { journalistIdList.push(journalistId); } });

if (journalistIdList.length > 0) { ntmOption["dimension3"] = journalistIdList.join("___"); }

if (location.href.match(/ntype=RANKING/)) { ntmOption["dimension5"] = "y"; }

if (service.sports || service.newsType) { ntmOption["dimension2"] = article.sectionInfo.secondSection + "." + article.sectionInfo.thirdSection; }

var seriesComponentId = $("._VALID_SERIES").attr("data-component-id"); ntmOption["dimension6"] = seriesComponentId;

var issueComponentId = $("._ISSUE_ARTICLE_INFO").attr("data-component-id"); ntmOption["dimension8"] = issueComponentId;

if (/[?|&]type=breakingnews(&|$)/.test(location.href) === true) { if (/(m\\.naver\\.com)|(media\\.naver\\.com)|(news\\.naver\\.com\\/?$)|(www\\.naver\\.com)/.test(document.referrer) === true) { ntmOption["dimension7"] = "breaking"; } }

var ntmLeaveOption = $.extend({}, ntmOption); ntmLeaveOption["hitType"] = "event"; ntmLeaveOption["eventCategory"] = "action"; ntmLeaveOption["eventAction"] = "leave";

if (/\\/article\\/comment\\//.test(location.href) == false && $("#contents").length > 0) { if (window.addEventListener) { window.addEventListener("load", function () { ntm.push({ event: "nilSend", ni: ntmOption }); }, false); window.addEventListener("onpagehide" in window ? "pagehide" : "beforeunload", function () { ntm.push({ event: "nilSend", ni: ntmLeaveOption }); window.impression.sendImpressionNlog([], "disable"); }, false); } else if (window.attachEvent) { window.attachEvent("onload", function () { ntm.push({ event: "nilSend", ni: ntmOption }); }); window.attachEvent("onpagehide" in window ? "onpagehide" : "onbeforeunload", function () { ntm.push({ event: "nilSend", ni: ntmLeaveOption }); window.impression.sendImpressionNlog([], "disable"); }); } else { var onUnloadEventName = "onpagehide" in window ? "onpagehide" : "onbeforeunload"; var fnOnUnload = window[onUnloadEventName]; window[onUnloadEventName] = function () { ntm.push({ event: "nilSend", ni: ntmLeaveOption }); window.impression.sendImpressionNlog([], "disable"); if (fnOnUnload) { fnOnUnload(); } };

var fnOnload = window.onload; window.onload = function () { ntm.push({ event: "nilSend", ni: ntmOption }); if (fnOnload) { fnOnload(); } }; } } }

var eventType = "onpageshow" in window ? "pageshow" : "load"; $(window).on(eventType, function() { window.ntm = window.ntm || [];

var pageviewInfo = { event: "nLogPageViewService", nlogEvt: { page_layout: window.layoutName, news: window.getNLogNewsData() } };

pageviewInfo.nLogPageviewSti = "news"; pageviewInfo.nLogPageviewGdid = "8812805E_000000000000000001145158"; pageviewInfo.nlogEvt.news.sid1 = "101"; pageviewInfo.nlogEvt.news.oid = "417"; pageviewInfo.nlogEvt.news.aid = "0001145158";

window.ntm.push(pageviewInfo);

// ma 로그 전송 var data = { "service": "news", "baseInfo": { "category": (/\\/article\\/comment\\//.test(location.href) == true) ? "댓글" : "본문" }, "extraInfo": { "articleId": article.articleId, "sectionId": article.sectionId, "officeId": article.officeId, "articleType": article.type } };

if (typeof ma !== "undefined") { ma.init({ api: "https://ma.news.naver.com/l", data: data, contentBodyEl: document.querySelector("._article_content"), contentBodyVideoWrapperSelector: "._VIDEO_AREA" }); }

if (window.matchMedia("(prefers-color-scheme)").media === "not all") { $("html").removeClass("DARK_THEME"); }

setTimeout(function() { $("._OPACITY_FADE_TO").fadeTo(50, 1); }, 3000); });

", + "tags": [ + "네이버뉴스", + "경제" + ], + "signals": [ + "주목", + "속보" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:14:59+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-7ddf76597489cdcf", + "title": "한쪽 팔·다리만 빨리 크는 희귀병…뼈 나이도 달랐다", + "sourceLabel": "서울경제", + "url": "https://n.news.naver.com/mnews/article/011/0004625354", + "lead": "신창호 서울대병원 소아정형외과 교수팀 선천성 편측 비대증·편측 저형성증 환아 양측 팔다리의 뼈 나이 차이 정밀 분석 뼈 성숙 속도도 달라짐을 최초로 밝혀내 “성장 예측·맞춤형 수술 시기 결정 가능”", + "body": "신창호 서울대병원 소아정형외과 교수팀 선천성 편측 비대증·편측 저형성증 환아 양측 팔다리의 뼈 나이 차이 정밀 분석 뼈 성숙 속도도 달라짐을 최초로 밝혀내 “성장 예측·맞춤형 수술 시기 결정 가능”\n\n클립아트코리아 신체 한쪽이 다른 쪽보다 크게 자라는 희귀질환자는 단순히 양쪽 팔다리 길이뿐 아니라 뼈가 성숙하는 속도도 다르다는 사실이 국내 의료진에 의해 최초로 밝혀졌다. 길이가 긴 쪽의 뼈가 더 일찍 성장을 마칠 수 있음을 입증하면서 보다 정밀하게 수술 시기를 결정할 수 있는 근거가 마련된 것이다.\n\n서울대병원은 신창호 소아정형외과 교수팀이 선천성 편측 비대증과 편측 저형성증 환아 118명을 대상으로 양측 팔다리의 뼈 나이 차이를 분석한 결과 이같이 나타났다고 28일 밝혔다.\n\n선천성 편측 비대증과 편측 저형성증은 신체의 한쪽이 반대쪽보다 눈에 띄게 크거나 작게 자라는 질환이다. ‘베크위트-비데만 증후군’이나 ‘실버-러셀 증후군’ 같은 유전적 이상이 주된 원인으로 알려졌다. 양쪽 팔다리의 길이 차가 심해지면 몸의 균형이 무너져 보행 장애, 척추 측만, 관절의 퇴행성 변화 등 심각한 합병증으로 이어질 수 있어 수술적 치료가 필요하다. 팔다리 길이 차이를 교정할 때는 흔히 성장판 수술이 시행된다. 진료 현장에서는 환아의 양쪽 뼈 나이가 다를 수 있다는 의문이 꾸준히 제기됐으나, 이를 입증할 객관적 데이터가 없어 한쪽 뼈 나이를 기준으로 남은 성장량을 예측해 왔다.\n\n연구팀은 질환의 특성상 양측 의 성숙 속도 자체에 차이가 있을 수 있다는 가설을 세우고 검증에 나섰다. 2000년 1월부터 2023년 9월까지 베크위트-비데만 증후군, 실버-러셀 증후군, PIK3CA 연관 과성장 증후군 등으로 서울대어린이병원에서 검사를 받았던 환아 118명을 모집한 뒤 한국 표준 골연령 차트와 소아의 손과 손목, 무릎 부위 엑스레이를 토대로 골 성숙도를 평가하는 Fels 체계를 활용해 뼈 나이를 비교 분석했다. 다리 성장의 약 65%가 무릎 주변에서 이뤄지는 점을 고려해 기존처럼 손뼈 나이에만 의존하지 않고 무릎뼈 나이까지 추가로 분석해 예측의 정확도를 높였다.\n\n영상검사에서 확인된 베크위트-비데만 증후군 환아의 비대칭적 뼈 성장 소견. 사진 제공=서울대병원 그 결과 전체 환아군에서 길이가 긴 팔의 뼈 나이가 짧은 쪽보다 평균 1.2개월 더 앞선 것으로 확인됐다. 특히 베크위트-비데만 증후군 환자 34명은 길이가 긴 다리와 팔의 뼈 나이가 각각 평균 7.1개월과 3.2개월 많아 성장의 비대칭성이 가장 두드러졌다. 이는 뼈 성장 속도 차이가 단순히 해부학적 방향(좌·우)의 문제가 아니라, 질환으로 인한 신체 ‘과성장’과 직접적으로 연관돼 있음을 시사한다는 게 연구팀의 해석이다. 반면 실버-러셀 증후군 등 다른 질환군에서는 유의미한 차이가 관찰되지 않았다.\n\n연구팀은 이번 결과가 사지 길이 교정 수술의 정밀도를 크게 높이는 데 기여할 것으로 기대했다. 기존처럼 한쪽 뼈 나이만 기준으로 남은 성장량을 예측하는 대신, 긴 쪽 다리의 뼈가 더 빨리 자라는 특성을 고려해 수술 시기를 계획하면 불필요한 과교정이나 재수술 위험을 대폭 줄일 수 있다는 것이다.\n\n신 서울대병원 소아정형외과 교수는 “선천성 편측 비대증과 저형성증 환자의 팔다리 길이 차이를 치료할 때는 단순히 길이만 비교하는 것이 아니라, 어느 쪽 뼈가 더 빨리 자라고 있는지를 함께 평가해야 한다”며 “이번 연구가 환아들의 성장 예측과 수술 계획 수립에 실질적인 근거를 제공하길 기대한다”고 말했다.\n\n이번 연구는 소아암·희귀질환지원사업단의 지원을 받았고 국제학술지 소아정형외과 저널(Journal of Children‘s Orthopaedics) 최근호에 실렸다.", + "htmlBody": "

신창호 서울대병원 소아정형외과 교수팀 선천성 편측 비대증·편측 저형성증 환아 양측 팔다리의 뼈 나이 차이 정밀 분석 뼈 성숙 속도도 달라짐을 최초로 밝혀내 “성장 예측·맞춤형 수술 시기 결정 가능”

\"클립아트코리아\"

클립아트코리아 신체 한쪽이 다른 쪽보다 크게 자라는 희귀질환자는 단순히 양쪽 팔다리 길이뿐 아니라 뼈가 성숙하는 속도도 다르다는 사실이 국내 의료진에 의해 최초로 밝혀졌다. 길이가 긴 쪽의 뼈가 더 일찍 성장을 마칠 수 있음을 입증하면서 보다 정밀하게 수술 시기를 결정할 수 있는 근거가 마련된 것이다.

서울대병원은 신창호 소아정형외과 교수팀이 선천성 편측 비대증과 편측 저형성증 환아 118명을 대상으로 양측 팔다리의 뼈 나이 차이를 분석한 결과 이같이 나타났다고 28일 밝혔다.

선천성 편측 비대증과 편측 저형성증은 신체의 한쪽이 반대쪽보다 눈에 띄게 크거나 작게 자라는 질환이다. ‘베크위트-비데만 증후군’이나 ‘실버-러셀 증후군’ 같은 유전적 이상이 주된 원인으로 알려졌다. 양쪽 팔다리의 길이 차가 심해지면 몸의 균형이 무너져 보행 장애, 척추 측만, 관절의 퇴행성 변화 등 심각한 합병증으로 이어질 수 있어 수술적 치료가 필요하다. 팔다리 길이 차이를 교정할 때는 흔히 성장판 수술이 시행된다. 진료 현장에서는 환아의 양쪽 뼈 나이가 다를 수 있다는 의문이 꾸준히 제기됐으나, 이를 입증할 객관적 데이터가 없어 한쪽 뼈 나이를 기준으로 남은 성장량을 예측해 왔다.

연구팀은 질환의 특성상 양측 의 성숙 속도 자체에 차이가 있을 수 있다는 가설을 세우고 검증에 나섰다. 2000년 1월부터 2023년 9월까지 베크위트-비데만 증후군, 실버-러셀 증후군, PIK3CA 연관 과성장 증후군 등으로 서울대어린이병원에서 검사를 받았던 환아 118명을 모집한 뒤 한국 표준 골연령 차트와 소아의 손과 손목, 무릎 부위 엑스레이를 토대로 골 성숙도를 평가하는 Fels 체계를 활용해 뼈 나이를 비교 분석했다. 다리 성장의 약 65%가 무릎 주변에서 이뤄지는 점을 고려해 기존처럼 손뼈 나이에만 의존하지 않고 무릎뼈 나이까지 추가로 분석해 예측의 정확도를 높였다.

\"영상검사에서

영상검사에서 확인된 베크위트-비데만 증후군 환아의 비대칭적 뼈 성장 소견. 사진 제공=서울대병원 그 결과 전체 환아군에서 길이가 긴 팔의 뼈 나이가 짧은 쪽보다 평균 1.2개월 더 앞선 것으로 확인됐다. 특히 베크위트-비데만 증후군 환자 34명은 길이가 긴 다리와 팔의 뼈 나이가 각각 평균 7.1개월과 3.2개월 많아 성장의 비대칭성이 가장 두드러졌다. 이는 뼈 성장 속도 차이가 단순히 해부학적 방향(좌·우)의 문제가 아니라, 질환으로 인한 신체 ‘과성장’과 직접적으로 연관돼 있음을 시사한다는 게 연구팀의 해석이다. 반면 실버-러셀 증후군 등 다른 질환군에서는 유의미한 차이가 관찰되지 않았다.

연구팀은 이번 결과가 사지 길이 교정 수술의 정밀도를 크게 높이는 데 기여할 것으로 기대했다. 기존처럼 한쪽 뼈 나이만 기준으로 남은 성장량을 예측하는 대신, 긴 쪽 다리의 뼈가 더 빨리 자라는 특성을 고려해 수술 시기를 계획하면 불필요한 과교정이나 재수술 위험을 대폭 줄일 수 있다는 것이다.

신 서울대병원 소아정형외과 교수는 “선천성 편측 비대증과 저형성증 환자의 팔다리 길이 차이를 치료할 때는 단순히 길이만 비교하는 것이 아니라, 어느 쪽 뼈가 더 빨리 자라고 있는지를 함께 평가해야 한다”며 “이번 연구가 환아들의 성장 예측과 수술 계획 수립에 실질적인 근거를 제공하길 기대한다”고 말했다.

이번 연구는 소아암·희귀질환지원사업단의 지원을 받았고 국제학술지 소아정형외과 저널(Journal of Children‘s Orthopaedics) 최근호에 실렸다.

", + "tags": [ + "네이버뉴스", + "생활/문화" + ], + "signals": [ + "상단노출", + "핵심" + ], + "listedDate": "2026-05-28", + "publishedAt": "2026-05-28T12:17:10+09:00" + }, + "createdAt": "2026-05-28T03:17:18.275Z", + "updatedAt": "2026-05-28T03:17:18.275Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-3cb7223ed34ab504", + "title": "삼성전자 노사 합의에 총파업 유보…\"1년간 적자사업부 배분 방식 유예\"", + "sourceLabel": "아시아경제", + "url": "https://n.news.naver.com/mnews/article/277/0005765856", + "lead": "2026년 성과급 노사 잠정합의안 공식 서명 김 장관 \"대화로 해결하는 K저력 보여줘\" 삼성전자 총파업을 하루 앞두고 김영훈 고용노동부 장관이 직접 중재한 끝에 노사가 극적으로 잠정 합의안 도출에 성공했다. 노조 측은 총파업을 유보하고 합의안을 노조원 투표에 부치기로 했다.", + "body": "2026년 성과급 노사 잠정합의안 공식 서명 김 장관 \"대화로 해결하는 K저력 보여줘\" 삼성전자 총파업을 하루 앞두고 김영훈 고용노동부 장관이 직접 중재한 끝에 노사가 극적으로 잠정 합의안 도출에 성공했다. 노조 측은 총파업을 유보하고 합의안을 노조원 투표에 부치기로 했다.\n\n20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 김영환 고용노동부 장관과 손을 맞잡고 있다. 연합뉴스\n\n김 장관은 \"민주주의를 지지하는 것은 우리 앞에 놓인 공동 과제를 해결하는 대화의 힘을 믿기 때문\"이라며 \"노사 자율협의로 잠정 합의 이르게 돼 감사하다\"고 말했다.\n\n삼성전자 노사는 20일 밤 10시40분쯤 경기지방고용노동청에서 2026년 성과급 노사 잠정합의안에 공식 서명했다.\n\n최승호 삼성그룹 초기업노동조합 삼성전자지부(초기업노조) 위원장은 \"이번 합의안은 초기업노조 및 공동투쟁본부가 지난 6개월여간 혼신을 다해 투쟁해온 결실\"이라면서 \"앞으로 삼성전자 노사 관계가 안정화될 수 있도록 최선을 다하겠다\"고 말했다. 최 위원장은 사측이 1년간 적자사업부 배분 방식을 유예함에 따라 합의가 도출됐다고 설명했다.\n\n여명구 삼성전자 DS 피플팀장은 \"성과가 있는 곳에 보상이 있다는 원칙을 지키면서도 최상의 방안을 아이디어를 내고 대화 통해 찾았다고 보시면 될 것 같다\"면서 \"또 특별보상제도에 대한 제도화를 굉장히 구체화했다고 말씀드릴 수 있겠다\"고 말했다. 이어 \"이번 잠정 합의가 상생 노사문화를 만들어갈 수 있는 출발점이 되도록 하겠다\"면서 \"회사는 이번 합의사항을 성실히 이행하겠다\"고 했다.\n\n중앙노동위원회의 2차 사후조정 불성립 후 다시 노사의 중재자로 나선 김 장관은 \"(이번 합의안은) 무엇보다 어려운 대내외 여건 속에서 가슴 졸이고 지켜보고 계셨을 국민들 덕분\"이라면서\"합의가 잘 이행돼서 삼성전자 구성원들이 다시 한번 일터에서 헌신적으로 일하도록 해달라\"라고 말했다.\n\n그는 \"쟁점이 있었는데 많이 좁혀졌다\"면서 \"분배 방식을 두고 회사는 원칙을 양보하기 힘든 거였고 노조는 노조대로 사정이 있었지만, 노사가 한발씩 양보해 해법을 찾았다\"고 강조했다. 이어 \"어떻게 보면 성장통\"이라면서 \"일찍이 경험하지 못한 (사태를) 대화로 해결하는 K저력을 보여줬다\"고 덧붙였다. 마지막으로 김 장관은 \"기술도, 노사관계도 제일이라는 삼성답게 잘 해결해가길 바란다\"고 말했다.\n\n이로써 21일 예정됐던 총파업은 6월7일까지 유보됐다. 노조는 잠정합의안에 대한 찬반투표를 진행할 예정이다. 이번 노조 찬반투표가 최종 가결되면 6개월 가까이 이어진 삼성전자 노사갈등도 마침표를 찍게 된다.", + "htmlBody": "

2026년 성과급 노사 잠정합의안 공식 서명 김 장관 "대화로 해결하는 K저력 보여줘" 삼성전자 총파업을 하루 앞두고 김영훈 고용노동부 장관이 직접 중재한 끝에 노사가 극적으로 잠정 합의안 도출에 성공했다. 노조 측은 총파업을 유보하고 합의안을 노조원 투표에 부치기로 했다.

\"20일

20일 경기도 수원시 장안구 경기고용노동청에서 열린 삼성전자 임금협상을 마친 후 여명구 삼성전자 DS(디바이스솔루션·반도체 사업 담당) 피플팀장과 최승호 삼성그룹 초기업노동조합 삼성전자지부 위원장이 잠정 합의안에 서명한 후 김영환 고용노동부 장관과 손을 맞잡고 있다. 연합뉴스

김 장관은 "민주주의를 지지하는 것은 우리 앞에 놓인 공동 과제를 해결하는 대화의 힘을 믿기 때문"이라며 "노사 자율협의로 잠정 합의 이르게 돼 감사하다"고 말했다.

삼성전자 노사는 20일 밤 10시40분쯤 경기지방고용노동청에서 2026년 성과급 노사 잠정합의안에 공식 서명했다.

최승호 삼성그룹 초기업노동조합 삼성전자지부(초기업노조) 위원장은 "이번 합의안은 초기업노조 및 공동투쟁본부가 지난 6개월여간 혼신을 다해 투쟁해온 결실"이라면서 "앞으로 삼성전자 노사 관계가 안정화될 수 있도록 최선을 다하겠다"고 말했다. 최 위원장은 사측이 1년간 적자사업부 배분 방식을 유예함에 따라 합의가 도출됐다고 설명했다.

여명구 삼성전자 DS 피플팀장은 "성과가 있는 곳에 보상이 있다는 원칙을 지키면서도 최상의 방안을 아이디어를 내고 대화 통해 찾았다고 보시면 될 것 같다"면서 "또 특별보상제도에 대한 제도화를 굉장히 구체화했다고 말씀드릴 수 있겠다"고 말했다. 이어 "이번 잠정 합의가 상생 노사문화를 만들어갈 수 있는 출발점이 되도록 하겠다"면서 "회사는 이번 합의사항을 성실히 이행하겠다"고 했다.

중앙노동위원회의 2차 사후조정 불성립 후 다시 노사의 중재자로 나선 김 장관은 "(이번 합의안은) 무엇보다 어려운 대내외 여건 속에서 가슴 졸이고 지켜보고 계셨을 국민들 덕분"이라면서"합의가 잘 이행돼서 삼성전자 구성원들이 다시 한번 일터에서 헌신적으로 일하도록 해달라"라고 말했다.

그는 "쟁점이 있었는데 많이 좁혀졌다"면서 "분배 방식을 두고 회사는 원칙을 양보하기 힘든 거였고 노조는 노조대로 사정이 있었지만, 노사가 한발씩 양보해 해법을 찾았다"고 강조했다. 이어 "어떻게 보면 성장통"이라면서 "일찍이 경험하지 못한 (사태를) 대화로 해결하는 K저력을 보여줬다"고 덧붙였다. 마지막으로 김 장관은 "기술도, 노사관계도 제일이라는 삼성답게 잘 해결해가길 바란다"고 말했다.

이로써 21일 예정됐던 총파업은 6월7일까지 유보됐다. 노조는 잠정합의안에 대한 찬반투표를 진행할 예정이다. 이번 노조 찬반투표가 최종 가결되면 6개월 가까이 이어진 삼성전자 노사갈등도 마침표를 찍게 된다.

", + "tags": [ + "네이버뉴스", + "경제" + ], + "publishedAt": "2026-05-20T14:58:41.000Z" + }, + "createdAt": "2026-05-28T03:17:16.185Z", + "updatedAt": "2026-05-28T03:17:16.185Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-ce9e78d965feaf51", + "title": "[진주소식]진주교대, RISE사업 1차년도 연차평가", + "sourceLabel": "뉴시스", + "url": "https://n.news.naver.com/mnews/article/003/0013960769", + "lead": "[진주=뉴시스]진주교육대학교 정문.(사진=진주교대 제공).2026.05.21.photo@newsis.com *재판매 및 DB 금지", + "body": "[진주=뉴시스]진주교육대학교 정문.(사진=진주교대 제공).2026.05.21.photo@newsis.com *재판매 및 DB 금지\n\n[진주=뉴시스] 정경규 기자 = 경남 진주교육대학교는 교육부와 경남도가 추진하는 지역혁신중심 대학지원체계(RISE) 사업 1차년도 연차평가에서 최고 등급인 'A등급'을 받았다고 21일 밝혔다.\n\n진주교대 RISE사업단은 이번 평가에서 지역 교육 현안 해결 중심의 사업 운영과 대학의 초등교육 전문성을 기반으로 체계적인 성과관리 체계를 구축한 점에서 우수한 평가를 받았다.\n\n특히 ▲기초학력 지원체계 구축 및 운영 ▲경남형 늘봄 프로그램 개발 및 운영 ▲교육전문가 양성 ▲지역 기반 협력 거버넌스 구축 등을 중심으로 지역 맞춤형 교육지원 모델을 운영하며 사업 목표를 초과 달성한 점이 높은 평가로 이어졌다.\n\n◇진주교대, 고교교육 지원사업 경남 유일 'S등급' 획득\n\n진주교육대학교는 교육부와 한국대학교육협의회가 주관하는 '2026년 고교교육 기여대학 지원사업' 2차년도 연차평가에서 최고 등급인 'S등급'을 획득했다고 21일 밝혔다.\n\n특히 이번 성과는 경남도 지역 대학 중 유일하게 S등급을 기록한 것으로 공정한 입시 운영과 꾸준한 고교 연계 사업에 대한 인정을 받은 것이어서 그 의미가 더욱 크다.\n\n진주교대는 초등교원 양성 대학의 특성을 살려 고교 교육과정 취지에 부합하는 전형을 운영하고 고교-대학 연계 프로그램을 적극적으로 펼쳐온 점이 이번 경남 유일 S등급이라는 쾌거의 주요 배경으로 분석했다.", + "htmlBody": "
\"[진주=뉴시스]진주교육대학교

[진주=뉴시스]진주교육대학교 정문.(사진=진주교대 제공).2026.05.21.photo@newsis.com *재판매 및 DB 금지

[진주=뉴시스] 정경규 기자 = 경남 진주교육대학교는 교육부와 경남도가 추진하는 지역혁신중심 대학지원체계(RISE) 사업 1차년도 연차평가에서 최고 등급인 'A등급'을 받았다고 21일 밝혔다.

진주교대 RISE사업단은 이번 평가에서 지역 교육 현안 해결 중심의 사업 운영과 대학의 초등교육 전문성을 기반으로 체계적인 성과관리 체계를 구축한 점에서 우수한 평가를 받았다.

특히 ▲기초학력 지원체계 구축 및 운영 ▲경남형 늘봄 프로그램 개발 및 운영 ▲교육전문가 양성 ▲지역 기반 협력 거버넌스 구축 등을 중심으로 지역 맞춤형 교육지원 모델을 운영하며 사업 목표를 초과 달성한 점이 높은 평가로 이어졌다.

◇진주교대, 고교교육 지원사업 경남 유일 'S등급' 획득

진주교육대학교는 교육부와 한국대학교육협의회가 주관하는 '2026년 고교교육 기여대학 지원사업' 2차년도 연차평가에서 최고 등급인 'S등급'을 획득했다고 21일 밝혔다.

특히 이번 성과는 경남도 지역 대학 중 유일하게 S등급을 기록한 것으로 공정한 입시 운영과 꾸준한 고교 연계 사업에 대한 인정을 받은 것이어서 그 의미가 더욱 크다.

진주교대는 초등교원 양성 대학의 특성을 살려 고교 교육과정 취지에 부합하는 전형을 운영하고 고교-대학 연계 프로그램을 적극적으로 펼쳐온 점이 이번 경남 유일 S등급이라는 쾌거의 주요 배경으로 분석했다.

", + "tags": [ + "네이버뉴스", + "사회" + ], + "listedDate": "2026-05-21", + "publishedAt": "2026-05-21T07:40:53.000Z" + }, + "createdAt": "2026-05-28T03:17:15.910Z", + "updatedAt": "2026-05-28T03:17:15.910Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-d26fab4c913d2055", + "title": "노관규 \"조례호수공원 제가…\" 발언에 시민사회 \"허위 사실\" 발끈", + "sourceLabel": "뉴스1", + "url": "https://n.news.naver.com/mnews/article/421/0008961406", + "lead": "\"시민운동의 결과…공개사과 안 하면 허위사실공표 고발\" \"착공과 준공 실행 과정에 대한 책임과 성과 설명한 것\"", + "body": "\"시민운동의 결과…공개사과 안 하면 허위사실공표 고발\" \"착공과 준공 실행 과정에 대한 책임과 성과 설명한 것\"\n\n순천시민사회단체가 22일 조례호수공원에서 기자회견을 열고 노관규 순천시장 후보의 발언에 대한 사과를 요구하고 있다. 2026.5.22 ⓒ 뉴스1 김성준 기자\n\n(순천=뉴스1) 김성준 기자 = 노관규 무소속 순천시장 후보의 조례호수공원 조성 역사 발언을 두고 시민사회단체가 사과를 요구하고 나섰다.\n\n노 후보는 \"시민사회의 공로를 부정하는 것이 아닌 행정이 실제 사업을 완성하기 위해 수행한 역할에 관한 것\"이라고 해명했다.\n\n22일 지역 정가에 따르면 지난 14일 전남CBS 인터뷰에서 조례호수공원 조성사업과 관련 사회자가 \"조충훈 시장이 처음 시작했고\"라고 말하자 노 후보는 \"그것도 처음부터 제가 했어요\"라고 말했다.\n\n해당 발언이 SNS 등을 통해 확산하자 시민사회단체는 '역사 왜곡'이라며 반발하고 나섰다.\n\n전남동부지역사회연구소 등 12개 단체는 22일 조례호수공원에서 기자회견을 열고 \"1991년 3월 순천시의 매립 계획에 맞서 지역 시민단체들이 공원 조성을 위한 불씨를 지폈다\"며 \"전면 호수공원화 결정은 시민운동의 결과\"라고 주장했다.\n\n단체는 \"특정 정치인이 기획하고 만들어낸 전유물이 아니라 시민들이 지켜낸 땀과 눈물의 결정체\"라며 \"노 후보가 발언을 즉각 철회하고 공개 사과하지 않을 경우 '허위사실공표' 등의 혐의로 고발하겠다\"고 말했다.\n\n노 후보는 전날 입장문을 내고 \"조례호수공원 조성의 필요성을 알려온 시민사회와 시민 여러분의 오랜 노력에 깊은 존중의 뜻을 밝힌다\"며 \"최근 인터뷰 발언은 시민운동의 역사나 시민사회의 공로를 부정하려는 취지가 전혀 아니었다\"고 해명했다.\n\n노 후보는 \"민선 4기 재임 당시 사업을 행정적으로 본격 추진하고, 2008년 착공과 2009년 준공으로 이어진 실행 과정에 대한 책임과 성과를 설명한 것\"이라며 \"시민과 행정이 함께 순천을 발전시켜야 한다는 원칙을 지키겠다. 사실과 다른 오해는 바로잡되, 불필요한 갈등은 원하지 않는다\"고 말했다.", + "htmlBody": "

"시민운동의 결과…공개사과 안 하면 허위사실공표 고발" "착공과 준공 실행 과정에 대한 책임과 성과 설명한 것"

\"순천시민사회단체가

순천시민사회단체가 22일 조례호수공원에서 기자회견을 열고 노관규 순천시장 후보의 발언에 대한 사과를 요구하고 있다. 2026.5.22 ⓒ 뉴스1 김성준 기자

(순천=뉴스1) 김성준 기자 = 노관규 무소속 순천시장 후보의 조례호수공원 조성 역사 발언을 두고 시민사회단체가 사과를 요구하고 나섰다.

노 후보는 "시민사회의 공로를 부정하는 것이 아닌 행정이 실제 사업을 완성하기 위해 수행한 역할에 관한 것"이라고 해명했다.

22일 지역 정가에 따르면 지난 14일 전남CBS 인터뷰에서 조례호수공원 조성사업과 관련 사회자가 "조충훈 시장이 처음 시작했고"라고 말하자 노 후보는 "그것도 처음부터 제가 했어요"라고 말했다.

해당 발언이 SNS 등을 통해 확산하자 시민사회단체는 '역사 왜곡'이라며 반발하고 나섰다.

전남동부지역사회연구소 등 12개 단체는 22일 조례호수공원에서 기자회견을 열고 "1991년 3월 순천시의 매립 계획에 맞서 지역 시민단체들이 공원 조성을 위한 불씨를 지폈다"며 "전면 호수공원화 결정은 시민운동의 결과"라고 주장했다.

단체는 "특정 정치인이 기획하고 만들어낸 전유물이 아니라 시민들이 지켜낸 땀과 눈물의 결정체"라며 "노 후보가 발언을 즉각 철회하고 공개 사과하지 않을 경우 '허위사실공표' 등의 혐의로 고발하겠다"고 말했다.

노 후보는 전날 입장문을 내고 "조례호수공원 조성의 필요성을 알려온 시민사회와 시민 여러분의 오랜 노력에 깊은 존중의 뜻을 밝힌다"며 "최근 인터뷰 발언은 시민운동의 역사나 시민사회의 공로를 부정하려는 취지가 전혀 아니었다"고 해명했다.

노 후보는 "민선 4기 재임 당시 사업을 행정적으로 본격 추진하고, 2008년 착공과 2009년 준공으로 이어진 실행 과정에 대한 책임과 성과를 설명한 것"이라며 "시민과 행정이 함께 순천을 발전시켜야 한다는 원칙을 지키겠다. 사실과 다른 오해는 바로잡되, 불필요한 갈등은 원하지 않는다"고 말했다.

", + "tags": [ + "네이버뉴스", + "정치" + ], + "listedDate": "2026-05-22", + "publishedAt": "2026-05-22T05:51:08.000Z" + }, + "createdAt": "2026-05-28T03:17:15.288Z", + "updatedAt": "2026-05-28T03:17:15.288Z" + }, { "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", "article": { @@ -2326,6 +3095,24 @@ "createdAt": "2026-05-24T07:11:20.443Z", "updatedAt": "2026-05-24T07:11:20.443Z" }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "custom-mpe5eul2", + "title": "1년 동안 LLM과 함께 구축하며 배운 점 | GeekNews", + "sourceLabel": "GeekNews", + "url": "https://news.hada.io/topic?id=15268", + "lead": "GeekNews 최신글 예전글 쓰레드 댓글 Ask Show GN⁺ Weekly | 글등록", + "body": "GeekNews 최신글 예전글 쓰레드 댓글 Ask Show GN⁺ Weekly | 글등록\n\n75 P by xguru 2024-06-10 | ★ favorite | 댓글 9개\n\n대규모 언어 모델(LLM)을 사용한 개발이 흥미로운 시기임\n\n지난 1년 동안 LLM이 실제 애플리케이션에 \"충분히 좋은\" 수준이 되었으며, 매년 더 좋아지고 저렴해지고 있음\n\n소셜 미디어의 데모와 함께, 2025년까지 AI에 약 2000억 달러가 투자될 것으로 추정됨\n\n업체들의 API로 인해 LLM이 더 접근하기 쉬워져, ML 엔지니어와 과학자뿐만 아니라 모두가 제품에 인텔리젠스를 구축할 수 있게 됨\n\nAI로 구축하는 진입 장벽은 낮아졌지만, 데모 이상으로 효과적인 제품과 시스템을 만드는 것은 여전히 어려움\n\n우리는 지난 1년 동안 구축해 왔으며, 그 과정에서 많은 어려움을 발견했음\n\n우리의 실수를 피하고 더 빠르게 반복할 수 있도록 우리가 배운 내용을 공유하고자 함\n\nTactical(전술적) : 프롬프팅, RAG, 워크플로우 엔지니어링, 평가 및 모니터링을 위한 몇 가지 실천 사항\n\nLLM으로 구축하는 실무자나 주말 프로젝트를 진행하는 사람들을 위해 작성됨\n\nOperational(운영적) : 제품 출시의 조직적, 일상적 관심사와 효과적인 팀 구축 방법\n\n지속 가능하고 안정적으로 배포하려는 제품/기술 리더를 위한 내용\n\nStrategic(전략적) : \"PMF 전에 GPU 없음\", \"모델이 아닌 시스템에 집중\" 등의 의견을 담은 장기적이고 큰 그림 관점과 � 10년으로 설정하고, 시기별로 구체적인 영업이익 달성 기준을 마련했다. 우선 2026년부터 2028년까지는 매해 DS부문 영업이익 200조 원을 달성했을 때 성과급이 지급되며, 이후 2029년부터 2035년까지는 매해 영업이익 100조 원을 달성할 경우 지급되는 방식이다.

지급 방식에도 변화를 줬다. DS부문 특별경영성과급은 회사가 정한 조건에 따라 세후 전액을 자사주로 지급한다. 지급된 주식의 3분의 1은 즉시 매각이 가능하지만, 나머지 3분의 2는 각각 1년 및 2년간 매각이 제한되는 ‘보호예수’ 조건이 명시됐다. 최고 임원급 이하 CL4(부장급) 직원의 경우, 업적평가 결과에 따라 지급률이 가감된다.

한편, DS부문 중심의 특별성과급 신설에 따른 사내 형평성을 고려해 DX(디바이스경험)부문과 CSS사업팀에 대해서는 600만 원 상당의 자사주를 지급하기로 노사가 뜻을 모았다. 이 외에도 사측은 협력업체 동반 성장, 지역 사회 공헌, 산업 안전 등을 위한 재원 조성 및 운영 계획을 조속히 발표하기로 했으며, 노조와 협의해 건강한 노사관계 발전을 위한 조직문화 개선 등 공동 프로그램을 운영할 방침이다.

업계 안팎에서는 파업 직전 터져 나온 이번 합의로 삼성전자가 최대 위기를 고비를 넘겼다는 평가가 나온다. 다만 이번 잠정 합의안은 초기업노동조합 삼성전자지부 조합원들의 찬반투표를 거쳐야 하는 만큼, 투표 결과가 가결될 경우에만 최종적인 효력을 발하게 된다. 만약 합의안이 부결될 경우 총파업 가능성이 다시 불거질 수 있다는 관측도 존재한다.

", + "tags": [ + "네이버뉴스", + "IT/과학" + ], + "publishedAt": "2026-05-20T14:40:19.000Z" + }, + "createdAt": "2026-05-24T07:11:20.443Z", + "updatedAt": "2026-05-24T07:11:20.443Z" + }, { "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", "article": { @@ -3142,8 +3929,7 @@ "sourceLabel": "파이낸셜뉴스", "url": "https://n.news.naver.com/mnews/article/014/0005525571", "lead": "나홍진 감독 '호프' 경쟁부문 진출했으나 무관 그쳐 러시아 '미노타우로스' 심사위원대상, 감독상 공동 수상자 발표 바브라 스트라이샌드 명예 황금종려상 수상 및 칸영화제 폐막", - "body": "나홍진 감독 '호프' 경쟁부문 진출했으나 무관 그쳐 러시아 '미노타우로스' 심사위원대상, 감독상 공동 수상자 발표 바브라 스트라이샌드 명예 황금종려상 수상 및 칸영화제 폐막\n\n칸영화제에서 황금종려상을 수상한 영화 피오르드의 크리스티안 문주 감독. 사진=연합뉴스\n\n'피오르드' 스틸 컷. 사진=뉴스1 [파이낸셜뉴스]루마니아의 크리스티안 문주 감독이 연출한 '피오르드'가 제79회 칸국제영화제에서 최고상인 황금종려상을 수상했다.\n\n23일(현지시간) 프랑스 칸의 뤼미에르 대극장에서 열린 폐막식에서 '피오르드'가 황금종려상 수상작으로 발표됐다. 크리스티안 문주 감독은 수상 소감에서 영화가 중요한 사회적 문제를 다뤄야 한다는 점을 강조하며, 극단주의에 반대하고 관용과 포용, 공감의 메시지를 담았다고 밝혔다.\n\n'피오르드'는 루마니아계 노르웨이인 부부가 외딴 마을로 이주해 자녀 양육 방식과 종교 문제로 이웃들과 갈등을 겪는 이야기를 그린 작품이다. 크리스티안 문주 감독은 2007년 '4개월, 2주, 그리고 2일'로 한 차례 칸영화제 황금종려상을 받은 바 있으며, 2012년 '신의 소녀들'로 각본상, 2016년 '졸업'으로 감독상을 수상했다.\n\n한국 작품으로는 4년 만에 경쟁부문에 진출한 나홍진 감독의 '호프'는 수상 명단에 오르지 못했다. 나홍진 감독은 배급사 플러스엠 엠터테인먼트를 통해 현재 가장 중요한 것은 한국 관객과의 만남이라며, 개봉 전까지 작품의 완성도를 최대한 끌어올리겠다는 입장을 밝혔다.\n\n심사위원대상은 안드레이 즈비아긴체프 감독의 '미노타우로스'에 돌아갔다. 이 작품은 2022년 러시아를 배경으로 CEO가 회사와 가정에서 겪는 혼란을 그렸다. 안드레이 즈비아긴체프 감독은 '리바이어던'으로 2014년 칸영화제 각본상, 2017년 '러브리스'로 심사위원상을 받은 바 있다.\n\n감독상은 '라 볼라 네그라'의 하비에르 암브로시, 하비에르 칼보 감독과 '파더랜드'의 파베우 파블리코프스키 감독이 공동 수상했다. '라 볼라 네그라'는 스페인 시인 페데리코 가르시아 로르카의 미완성 희곡을 바탕으로 세 남자의 이야기를, '파더랜드'는 1940년대 독일을 배경으로 노벨문학상 수상 작가 토마스 만과 딸의 여정을 다뤘다.\n\n심사위원상은 독일 감독 발레스카 그리스바흐의 '더 드림드 어드벤처'에, 각본상은 에마뉘엘 마레 감독의 '노트르 살뤼'에 각각 돌아갔다. '더 드림드 어드벤처'는 여성 고고학자가 불가리아 국경 도시에서 범죄의 연결고리를 찾는 과정을, '노트르 살뤼'는 1940년대 비시 프랑스 체제 아래 공무원의 삶을 그렸다.\n\n남우주연상은 '카워드'의 에마뉘엘 마키아와 발렌틴 캉파뉴가, 여우주연상은 '올 오브 어 서든'의 비르지니 에피라와 오카모토 다오가 공동 수상했다. '카워드'는 제1차 세계대전 전선에서 만난 병사들의 연극 공연 이야기를, '올 오브 어 서든'은 하마구치 류스케 감독의 신작이다.\n\n명예 황금종려상은 가수 겸 배우 바브라 스트라이샌드에게 수여됐다. 바브라 스트라이샌드는 영상 메시지를 통해 영화의 힘과 열정에 대해 언급했다.\n\n올해 칸영화제는 박찬욱 감독이 심사위원장으로 경쟁부문 심사를 이끌었으며, 데미 무어, 스텔란 스카스가드, 클로이 자오, 라우라 완델 등 9명의 심사위원이 경쟁 부문 진출작 22편을 심사했다.\n\n한편, 단편 황금종려상은 페데리코 루이스 감독의 '파라 로스 콘트린칸테스', 황금카메라상은 마리-클레망틴 뒤사베잠보 감독의 '벤이마나'에 각각 돌아갔다.", - "htmlBody": "

나홍진 감독 '호프' 경쟁부문 진출했으나 무관 그쳐 러시아 '미노타우로스' 심사위원대상, 감독상 공동 수상자 발표 바브라 스트라이샌드 명예 황금종려상 수상 및 칸영화제 폐막

\"칸영화제에서

칸영화제에서 황금종려상을 수상한 영화 피오르드의 크리스티안 문주 감독. 사진=연합뉴스

\"'피오르드'

'피오르드' 스틸 컷. 사진=뉴스1 [파이낸셜뉴스]루마니아의 크리스티안 문주 감독이 연출한 '피오르드'가 제79회 칸국제영화제에서 최고상인 황금종려상을 수상했다.

23일(현지시간) 프랑스 칸의 뤼미에르 대극장에서 열린 폐막식에서 '피오르드'가 황금종려상 수상작으로 발표됐다. 크리스티안 문주 감독은 수상 소감에서 영화가 중요한 사회적 문제를 다뤄야 한다는 점을 강조하며, 극단주의에 반대하고 관용과 포용, 공감의 메시지를 담았다고 밝혔다.

'피오르드'는 루마니아계 노르웨이인 부부가 외딴 마을로 이주해 자녀 양육 방식과 종교 문제로 이웃들과 갈등을 겪는 이야기를 그린 작품이다. 크리스티안 문주 감독은 2007년 '4개월, 2주, 그리고 2일'로 한 차례 칸영화제 황금종려상을 받은 바 있으며, 2012년 '신의 소녀들'로 각본상, 2016년 '졸업'으로 감독상을 수상했다.

한국 작품으로는 4년 만에 경쟁부문에 진출한 나홍진 감독의 '호프'는 수상 명단에 오르지 못했다. 나홍진 감독은 배급사 플러스엠 엠터테인먼트를 통해 현재 가장 중요한 것은 한국 관객과의 만남이라며, 개봉 전까지 작품의 완성도를 최대한 끌어올리겠다는 입장을 밝혔다.

심사위원대상은 안드레이 즈비아긴체프 감독의 '미노타우로스'에 돌아갔다. 이 작품은 2022년 러시아를 배경으로 CEO가 회사와 가정에서 겪는 혼란을 그렸다. 안드레이 즈비아긴체프 감독은 '리바이어던'으로 2014년 칸영화제 각본상, 2017년 '러브리스'로 심사위원상을 받은 바 있다.

감독상은 '라 볼라 네그라'의 하비에르 암브로시, 하비에르 칼보 감독과 '파더랜드'의 파베우 파블리코프스키 감독이 공동 수상했다. '라 볼라 네그라'는 스페인 시인 페데리코 가르시아 로르카의 미완성 희곡을 바탕으로 세 남자의 이야기를, '파더랜드'는 1940년대 독일을 배경으로 노벨문학상 수상 작가 토마스 만과 딸의 여정을 다뤘다.

심사위원상은 독일 감독 발레스카 그리스바흐의 '더 드림드 어드벤처'에, 각본상은 에마뉘엘 마레 감독의 '노트르 살뤼'에 각각 돌아갔다. '더 드림드 어드벤처'는 여성 고고학자가 불가리아 국경 도시에서 범죄의 연결고리를 찾는 과정을, '노트르 살뤼'는 1940년대 비시 프랑스 체제 아래 공무원의 삶을 그렸다.

남우주연상은 '카워드'의 에마뉘엘 마키아와 발렌틴 캉파뉴가, 여우주연상은 '올 오브 어 서든'의 비르지니 에피라와 오카모토 다오가 공동 수상했다. '카워드'는 제1차 세계대전 전선에서 만난 병사들의 연극 공연 이야기를, '올 오브 어 서든'은 하마구치 류스케 감독의 신작이다.

명예 황금종려상은 가수 겸 배우 바브라 스트라이샌드에게 수여됐다. 바브라 스트라이샌드는 영상 메시지를 통해 영화의 힘과 열정에 대해 언급했다.

올해 칸영화제는 박찬욱 감독이 심사위원장으로 경쟁부문 심사를 이끌었으며, 데미 무어, 스텔란 스카스가드, 클로이 자오, 라우라 완델 등 9명의 심사위원이 경쟁 부문 진출작 22편을 심사했다.

한편, 단편 황금종려상은 페데리코 루이스 감독의 '파라 로스 콘트린칸테스', 황금카메라상은 마리-클레망틴 뒤사베잠보 감독의 '벤이마나'에 각각 돌아갔다.

", + "body": "나홍진 감독 '호프' 경쟁부문 진출했으나 무관 그쳐 러시아 '미노타우로스' 심사위원대상, 감독상 공동 수상자 발표 바브라 스트라이샌드 명예 황금종려상 수상 및 칸영화제 폐막\n\n칸영화제에서 황금종려상을 수상한 영화 피오르드의 크리스티안 문주 감�0524083312211.jpg?type=w860\" alt=\"칸영화제에서 황금종려상을 수상한 영화 피오르드의 크리스티안 문주 감독. 사진=연합뉴스\" loading=\"lazy\" referrerpolicy=\"no-referrer\" />

칸영화제에서 황금종려상을 수상한 영화 피오르드의 크리스티안 문주 감독. 사진=연합뉴스

\"'피오르드'

'피오르드' 스틸 컷. 사진=뉴스1 [파이낸셜뉴스]루마니아의 크리스티안 문주 감독이 연출한 '피오르드'가 제79회 칸국제영화제에서 최고상인 황금종려상을 수상했다.

23일(현지시간) 프랑스 칸의 뤼미에르 대극장에서 열린 폐막식에서 '피오르드'가 황금종려상 수상작으로 발표됐다. 크리스티안 문주 감독은 수상 소감에서 영화가 중요한 사회적 문제를 다뤄야 한다는 점을 강조하며, 극단주의에 반대하고 관용과 포용, 공감의 메시지를 담았다고 밝혔다.

'피오르드'는 루마니아계 노르웨이인 부부가 외딴 마을로 이주해 자녀 양육 방식과 종교 문제로 이웃들과 갈등을 겪는 이야기를 그린 작품이다. 크리스티안 문주 감독은 2007년 '4개월, 2주, 그리고 2일'로 한 차례 칸영화제 황금종려상을 받은 바 있으며, 2012년 '신의 소녀들'로 각본상, 2016년 '졸업'으로 감독상을 수상했다.

한국 작품으로는 4년 만에 경쟁부문에 진출한 나홍진 감독의 '호프'는 수상 명단에 오르지 못했다. 나홍진 감독은 배급사 플러스엠 엠터테인먼트를 통해 현재 가장 중요한 것은 한국 관객과의 만남이라며, 개봉 전까지 작품의 완성도를 최대한 끌어올리겠다는 입장을 밝혔다.

심사위원대상은 안드레이 즈비아긴체프 감독의 '미노타우로스'에 돌아갔다. 이 작품은 2022년 러시아를 배경으로 CEO가 회사와 가정에서 겪는 혼란을 그렸다. 안드레이 즈비아긴체프 감독은 '리바이어던'으로 2014년 칸영화제 각본상, 2017년 '러브리스'로 심사위원상을 받은 바 있다.

감독상은 '라 볼라 네그라'의 하비에르 암브로시, 하비에르 칼보 감독과 '파더랜드'의 파베우 파블리코프스키 감독이 공동 수상했다. '라 볼라 네그라'는 스페인 시인 페데리코 가르시아 로르카의 미완성 희곡을 바탕으로 세 남자의 이야기를, '파더랜드'는 1940년대 독일을 배경으로 노벨문학상 수상 작가 토마스 만과 딸의 여정을 다뤘다.

심사위원상은 독일 감독 발레스카 그리스바흐의 '더 드림드 어드벤처'에, 각본상은 에마뉘엘 마레 감독의 '노트르 살뤼'에 각각 돌아갔다. '더 드림드 어드벤처'는 여성 고고학자가 불가리아 국경 도시에서 범죄의 연결고리를 찾는 과정을, '노트르 살뤼'는 1940년대 비시 프랑스 체제 아래 공무원의 삶을 그렸다.

남우주연상은 '카워드'의 에마뉘엘 마키아와 발렌틴 캉파뉴가, 여우주연상은 '올 오브 어 서든'의 비르지니 에피라와 오카모토 다오가 공동 수상했다. '카워드'는 제1차 세계대전 전선에서 만난 병사들의 연극 공연 이야기를, '올 오브 어 서든'은 하마구치 류스케 감독의 신작이다.

명예 황금종려상은 가수 겸 배우 바브라 스트라이샌드에게 수여됐다. 바브라 스트라이샌드는 영상 메시지를 통해 영화의 힘과 열정에 대해 언급했다.

올해 칸영화제는 박찬욱 감독이 심사위원장으로 경쟁부문 심사를 이끌었으며, 데미 무어, 스텔란 스카스가드, 클로이 자오, 라우라 완델 등 9명의 심사위원이 경쟁 부문 진출작 22편을 심사했다.

한편, 단편 황금종려상은 페데리코 루이스 감독의 '파라 로스 콘트린칸테스', 황금카메라상은 마리-클레망틴 뒤사베잠보 감독의 '벤이마나'에 각각 돌아갔다.

", "tags": [ "네이버뉴스", "생활/문화" @@ -3165,7 +3951,7 @@ "sourceLabel": "아시아경제", "url": "https://n.news.naver.com/mnews/article/277/0005767121", "lead": "미, 자회사 슈완스 팀 구성해 수폴스에 발령 공장장도 수폴스로 이동, 가동 준비 시작 헝가리도 교대근무자 채용 시작 CJ제일제당이 유럽과 미국에 짓고 있는 생산기지 인재 확보에 속도를 내고 있다. 올해 하반기와 내년 준공을 앞두고 두 생산기지 공사가 진척을 보이면서 본격적인 가동 준비에 나선 것으로 보인다. K푸드를 향한 글로벌 수요가 급증하는 가운데 CJ제일제당은 두 시장에 생산기지를 직접 만들어 글로벌 영토 확장에 박차를 가할 계획이다.", - "body": "미, 자회사 슈완스 팀 구성해 수폴스에 발령 공장장도 수폴스로 이동, 가동 준비 시작 헝가리도 교대근무자 채용 시작 CJ제일제당이 유럽과 미국에 짓고 있는 생산기지 인재 확보에 속도를 내고 있다. 올해 하반기와 내년 준공을 앞두고 두 생산기지 공사가 진척을 보이면서 본격적인 가동 준비에 나선 것으로 보인다. K푸드를 향한 글로벌 수요가 급증하는 가운데 CJ제일제당은 두 시장에 생산기지를 직접 만들어 글로벌 영토 확장에 박차를 가할 계획이다.\n\n24일 업계 따르면 CJ제일제당은 이달 초 슈완스 직원 중 미국 사우스다코타 수폴스 공장으로 21명 규모의 팀을 구성해 처음으로 발령냈다. 이들은 휴스턴 지역 시설에서 훈련받고 있으며 수폴스 공장 운영을 지원할 준비를 하고 있다. 동시에 수폴스 시내에 슈완스 사무 공간을 별도로 마련해 월마트, 타깃 등 현지 유통 채널과의 회의나 신제품 개발, 브랜드 확장 등을 논의하고 있다.\n\nCJ제일제당 헝가리 신공장. CJ제일제당 헝가리 법인 SNS\n\n올해 1분기 중에는 슈완스 뉴저지 공장에 있던 샘 더글라스 공장장을 수폴스 공장장으로 보내 공장 가동 준비에 나섰다. 더글라스 공장장은 지난 3월 수폴스개발재단과의 인터뷰에서 \"(2027년 공장 가동을 앞두고 준비를 위해) 올해 5월 뉴저지에서 수폴스로 이사를 할 것\"이라며 \"슈완스 다른 생산시설에 있는 일부 인력으로 이곳으로 이동시킬 생각\"이라고 밝혔다. 이어 \"전 분야에 다양한 구직 기회가 열려있다\"며 \"직원들이 만두나 에그롤 만드는 방법을 알고 입사할 것이라 기대하지 않는다. 체계적인 교육 프로그램을 운영할 것\"이라고 덧붙였다.\n\nCJ제일제당이 5월 초 미국 사우스다코타 수폴스 공장으로 21명 규모의 팀을 구성해 발령한 슈완스 직원들. 슈완스 SNS\n\n유럽 전진기지가 될 헝가리 공장도 지난 3월부터 교대근무자 모집을 시작했다. 지난해 말 외형 골조 공사를 끝내고 올해 내부 설치 작업을 한창 진행 중이다. CJ제일제당은 헝가리 현지 관리직 채용은 1분기 중 마무리했으며 중간관리직과 엔지니어, 생산 및 창고 교대 근무관리자 등 다양한 부문의 채용을 진행하고 있다.\n\nCJ제일제당은 최근 올해 1분기 실적 발표 중 헝가리 공장과 관련해 \"올해 12월 말 시운전을 할 생각이며 본질적인 사업 기여는 내년으로 넘어갈 것으로 예상된다\"며 \"독일 공장과 함께 유럽 전역을 커버할 예정이며 물량 추이에 따라 물류 전략을 재수립할 것\"이라고 설명했다.\n\nCJ제일제당은 두 공장을 바탕으로 식품 사업의 글로벌 확장에 집중하겠다는 의지를 내비치고 있다. 앞서 CJ제일제당은 2024년 11월 8000억원을 투입해 미국과 유럽에 신규 생산시설을 짓는다고 발표했다. CJ제일제당은 이번 생산시설 구축으로 헝가리 230명, 미국 650명 등 인력 약 900명을 현지 채용할 계획이다.\n\nCJ제일제당의 두 번째 유럽 생산공장이 될 헝가리 신공장은 올해 하반기 가동을 목표로 하고 있다. 이 공장은 축구장 16개 부지(11만5000㎡) 규모로 조성되며 최첨단 자동화 생산라인을 갖추고 비비고 만두와 치킨을 생산할 예정이다. 2018년 독일 냉동식품 기업 마인프로스트를 인수하며 유럽 내 첫 생산기지를 확보한 CJ제일제당은 프랑스, 헝가리에도 법인을 설립해 유럽 사업을 키워나가고 있다.\n\n미국은 2019년 인수한 뒤 올해 3월 지분 100%를 확보한 슈완스의 생산시설을 기반으로 사업을 확장하고 있다. CJ제일제당의 자회사인 슈완스가 사우스다코타주 수폴스에 2027년 완공을 목표로 북미 아시안 푸드 신공장 건설이 진행 중이다. 이 공장은 축구장 80개 규모(57만5000㎡)로 건설되며 초기 투자 금액은 약 7000억원 규모다.\n\n이 외에도 지난해 9월 완공한 일본 만두 공장을 통해 일본 사업을 키우고 있다. 1000억원 투자해 만든 이 공장은 국내 식품업계가 일본에 세운 첫 생산시설로 비비고 만두를 생산하고 있으며, 공장 가동 이후 출시한 '비비고 만두교자'는 현지 주요 유통 채널 6000여개에 납품하고 있다. 비비고 만두는 올해 3월 기준 일본 시장에서 처음으로 점유율 10%를 기록했다.\n\n이를 바탕으로 CJ제일제당의 식품 사업 해외 매출 비중은 확대해나가고 있다. CJ제일제당의 식품 사업 부문 전체 매출 규모는 지난해 11조5221억원, 해외 매출은 5조9247억원으로 해외 매출 비중이 51.4%를 기록했다. 연간 기준 해외 매출 비중이 절반을 넘어선 것은 이번이 처음이다. 올해 1분기에도 식품 사업 부문 전체 매출은 3조384억원, 해외 매출은 1조5555억원으로 비중이 51.2%로 집계됐다.", + "body": "미, 자회사 슈완스 팀 구성해 수폴스에 발령 공장장도 수폴스로 이동, 가동 준비 시작 헝가리도 교대근무자 채용 시작 CJ제일제당이 유럽과 미국에 짓고 있는 생산기지 인재 확보에 속도를 내고 있다. 올해 하반기와 내년 준공을 앞두고 두 생산기지 공사가 진척을 보이면서 본격적인 가동 준비에 나선 것으로 보인다. K푸드를 향한 글로벌 수요가 급증하는 가운데 CJ제일제당은 두 시장에 생산기지를 직접 만들어 글로벌 영토 확장에 박차를 가할 계획이다.\n\n24일 업계 따르면 CJ제일제당은 이달 초 슈완스 직원 중 미국 사우스다코타 수폴스 공장으로 21명 규모의 팀을 구성해 처음으로 발령냈다. 이들은 휴스턴 지역 시설에서 훈련받고 있으며 수폴스 공장 운영을 지원할 준비를 하고 있다. 동시에 수폴스 시내에 슈완스 사무 공간을 별도로 마련해 월마트, 타깃 등 현지 유통 채널과의 회의나 신제품 개발, 브랜드 확장 등을 논의하고 있다.\n\nCJ제일제당 헝가리 신공장. CJ제일제당 헝가리 법인 SNS\n\n올해 1분기 중에는 슈완스 뉴저지 공장에 있던 샘 더글라스 공장장을 수폴스 공장장으로 보내 공장 가동 준비에 나섰다. 더글라스 공장장은 지난 3월 수폴스개발재단과의 인터뷰에서 \"(2027년 공장 가동을 앞두고 준비를 위해) 올해 5월 뉴저지에서 수폴스로 이사를 할 것\"이라며 \"슈완스 다른 생산시설에 있는 일부 인력으로 이곳으로 이동시킬 생각\"이라고 밝혔다. 이어 \"전 분야에 다양한 구직 기회가 열려있다\"며 \"직원들이 만두나 에그롤 만드는 방법을 알고 입사할 것이라 기대하지 않는다. 체계적인 교육 프로그램을 운영할 것\"이라고 덧붙였다.\n\nCJ제일제당이 5월 초 미국 사우스다코타 수폴스 공장으로 21명 규모의 팀을 구성해 발령한 슈완스 직원들. 슈완스 SNS\n\n유럽 전진기지가 될 헝가리 공장도 지난 3월부터 교대근무자 모집을 시작했다. 지난해 말 외형 골조 공사를 끝내고 올해 내부 설치 작업을 한창 진행 중이다. CJ제일제당은 헝가리 현지 관리직 채용은 1분기 중 마무리했으며 중간관리직과 엔지니어, 생산 및 창고 교대 근무관리자 등 다양한 부문의 채용을 진행하고 있다.\n\nCJ제일제당은 최근 올해 1분기 실적 발표 중 헝가리 공장과 관련해 \"올해 12월 말 시운전을 할 �기지를 확보한 CJ제일제당은 프랑스, 헝가리에도 법인을 설립해 유럽 사업을 키워나가고 있다.\n\n미국은 2019년 인수한 뒤 올해 3월 지분 100%를 확보한 슈완스의 생산시설을 기반으로 사업을 확장하고 있다. CJ제일제당의 자회사인 슈완스가 사우스다코타주 수폴스에 2027년 완공을 목표로 북미 아시안 푸드 신공장 건설이 진행 중이다. 이 공장은 축구장 80개 규모(57만5000㎡)로 건설되며 초기 투자 금액은 약 7000억원 규모다.\n\n이 외에도 지난해 9월 완공한 일본 만두 공장을 통해 일본 사업을 키우고 있다. 1000억원 투자해 만든 이 공장은 국내 식품업계가 일본에 세운 첫 생산시설로 비비고 만두를 생산하고 있으며, 공장 가동 이후 출시한 '비비고 만두교자'는 현지 주요 유통 채널 6000여개에 납품하고 있다. 비비고 만두는 올해 3월 기준 일본 시장에서 처음으로 점유율 10%를 기록했다.\n\n이를 바탕으로 CJ제일제당의 식품 사업 해외 매출 비중은 확대해나가고 있다. CJ제일제당의 식품 사업 부문 전체 매출 규모는 지난해 11조5221억원, 해외 매출은 5조9247억원으로 해외 매출 비중이 51.4%를 기록했다. 연간 기준 해외 매출 비중이 절반을 넘어선 것은 이번이 처음이다. 올해 1분기에도 식품 사업 부문 전체 매출은 3조384억원, 해외 매출은 1조5555억원으로 비중이 51.2%로 집계됐다.", "htmlBody": "

미, 자회사 슈완스 팀 구성해 수폴스에 발령 공장장도 수폴스로 이동, 가동 준비 시작 헝가리도 교대근무자 채용 시작 CJ제일제당이 유럽과 미국에 짓고 있는 생산기지 인재 확보에 속도를 내고 있다. 올해 하반기와 내년 준공을 앞두고 두 생산기지 공사가 진척을 보이면서 본격적인 가동 준비에 나선 것으로 보인다. K푸드를 향한 글로벌 수요가 급증하는 가운데 CJ제일제당은 두 시장에 생산기지를 직접 만들어 글로벌 영토 확장에 박차를 가할 계획이다.

24일 업계 따르면 CJ제일제당은 이달 초 슈완스 직원 중 미국 사우스다코타 수폴스 공장으로 21명 규모의 팀을 구성해 처음으로 발령냈다. 이들은 휴스턴 지역 시설에서 훈련받고 있으며 수폴스 공장 운영을 지원할 준비를 하고 있다. 동시에 수폴스 시내에 슈완스 사무 공간을 별도로 마련해 월마트, 타깃 등 현지 유통 채널과의 회의나 신제품 개발, 브랜드 확장 등을 논의하고 있다.

\"CJ제일제당

CJ제일제당 헝가리 신공장. CJ제일제당 헝가리 법인 SNS

올해 1분기 중에는 슈완스 뉴저지 공장에 있던 샘 더글라스 공장장을 수폴스 공장장으로 보내 공장 가동 준비에 나섰다. 더글라스 공장장은 지난 3월 수폴스개발재단과의 인터뷰에서 "(2027년 공장 가동을 앞두고 준비를 위해) 올해 5월 뉴저지에서 수폴스로 이사를 할 것"이라며 "슈완스 다른 생산시설에 있는 일부 인력으로 이곳으로 이동시킬 생각"이라고 밝혔다. 이어 "전 분야에 다양한 구직 기회가 열려있다"며 "직원들이 만두나 에그롤 만드는 방법을 알고 입사할 것이라 기대하지 않는다. 체계적인 교육 프로그램을 운영할 것"이라고 덧붙였다.

\"CJ제일제당이

CJ제일제당이 5월 초 미국 사우스다코타 수폴스 공장으로 21명 규모의 팀을 구성해 발령한 슈완스 직원들. 슈완스 SNS

유럽 전진기지가 될 헝가리 공장도 지난 3월부터 교대근무자 모집을 시작했다. 지난해 말 외형 골조 공사를 끝내고 올해 내부 설치 작업을 한창 진행 중이다. CJ제일제당은 헝가리 현지 관리직 채용은 1분기 중 마무리했으며 중간관리직과 엔지니어, 생산 및 창고 교대 근무관리자 등 다양한 부문의 채용을 진행하고 있다.

CJ제일제당은 최근 올해 1분기 실적 발표 중 헝가리 공장과 관련해 "올해 12월 말 시운전을 할 생각이며 본질적인 사업 기여는 내년으로 넘어갈 것으로 예상된다"며 "독일 공장과 함께 유럽 전역을 커버할 예정이며 물량 추이에 따라 물류 전략을 재수립할 것"이라고 설명했다.

CJ제일제당은 두 공장을 바탕으로 식품 사업의 글로벌 확장에 집중하겠다는 의지를 내비치고 있다. 앞서 CJ제일제당은 2024년 11월 8000억원을 투입해 미국과 유럽에 신규 생산시설을 짓는다고 발표했다. CJ제일제당은 이번 생산시설 구축으로 헝가리 230명, 미국 650명 등 인력 약 900명을 현지 채용할 계획이다.

\"기사

CJ제일제당의 두 번째 유럽 생산공장이 될 헝가리 신공장은 올해 하반기 가동을 목표로 하고 있다. 이 공장은 축구장 16개 부지(11만5000㎡) 규모로 조성되며 최첨단 자동화 생산라인을 갖추고 비비고 만두와 치킨을 생산할 예정이다. 2018년 독일 냉동식품 기업 마인프로스트를 인수하며 유럽 내 첫 생산기지를 확보한 CJ제일제당은 프랑스, 헝가리에도 법인을 설립해 유럽 사업을 키워나가고 있다.

미국은 2019년 인수한 뒤 올해 3월 지분 100%를 확보한 슈완스의 생산시설을 기반으로 사업을 확장하고 있다. CJ제일제당의 자회사인 슈완스가 사우스다코타주 수폴스에 2027년 완공을 목표로 북미 아시안 푸드 신공장 건설이 진행 중이다. 이 공장은 축구장 80개 규모(57만5000㎡)로 건설되며 초기 투자 금액은 약 7000억원 규모다.

이 외에도 지난해 9월 완공한 일본 만두 공장을 통해 일본 사업을 키우고 있다. 1000억원 투자해 만든 이 공장은 국내 식품업계가 일본에 세운 첫 생산시설로 비비고 만두를 생산하고 있으며, 공장 가동 이후 출시한 '비비고 만두교자'는 현지 주요 유통 채널 6000여개에 납품하고 있다. 비비고 만두는 올해 3월 기준 일본 시장에서 처음으로 점유율 10%를 기록했다.

이를 바탕으로 CJ제일제당의 식품 사업 해외 매출 비중은 확대해나가고 있다. CJ제일제당의 식품 사업 부문 전체 매출 규모는 지난해 11조5221억원, 해외 매출은 5조9247억원으로 해외 매출 비중이 51.4%를 기록했다. 연간 기준 해외 매출 비중이 절반을 넘어선 것은 이번이 처음이다. 올해 1분기에도 식품 사업 부문 전체 매출은 3조384억원, 해외 매출은 1조5555억원으로 비중이 51.2%로 집계됐다.

", "tags": [ "네이버뉴스", @@ -4322,7 +5108,7 @@ "sourceLabel": "GeekNews", "url": "https://news.hada.io/topic?id=15268", "lead": "GeekNews 최신글 예전글 쓰레드 댓글 Ask Show GN⁺ Weekly | 글등록", - "body": "GeekNews 최신글 예전글 쓰레드 댓글 Ask Show GN⁺ Weekly | 글등록\n\n75 P by xguru 2024-06-10 | ★ favorite | 댓글 9개\n\n대규모 언어 모델(LLM)을 사용한 개발이 흥미로운 시기임\n\n지난 1년 동안 LLM이 실제 애플리케이션에 \"충분히 좋은\" 수준이 되었으며, 매년 더 좋아지고 저렴해지고 있음\n\n소셜 미디어의 데모와 함께, 2025년까지 AI에 약 2000억 달러가 투자될 것으로 추정됨\n\n업체들의 API로 인해 LLM이 더 접근하기 쉬워져, ML 엔지니어와 과학자뿐만 아니라 모두가 제품에 인텔리젠스를 구축할 수 있게 됨\n\nAI로 구축하는 진입 장벽은 낮아졌지만, 데모 이상으로 효과적인 제품과 시스템을 만드는 것은 여전히 어려움\n\n우리는 지난 1년 동안 구축해 왔으며, 그 과정에서 많은 어려움을 발견했음\n\n우리의 실수를 피하고 더 빠르게 반복할 수 있도록 우리가 배운 내용을 공유하고자 함\n\nTactical(전술적) : 프롬프팅, RAG, 워크플로우 엔지니어링, 평가 및 모니터링을 위한 몇 가지 실천 사항\n\nLLM으로 구축하는 실무자나 주말 프로젝트를 진행하는 사람들을 위해 작성됨\n\nOperational(운영적) : 제품 출시의 조직적, 일상적 관심사와 효과적인 팀 구축 방법\n\n지속 가능하고 안정적으로 배포하려는 제품/기술 리더를 위한 내용\n\nStrategic(전략적) : \"PMF 전에 GPU 없음\", \"모델이 아닌 시스템에 집중\" 등의 의견을 담은 장기적이고 큰 그림 관점과 반복하는 방법\n\n이 가이드는 LLM을 사용하여 성공적인 제품을 구축하기 위한 실용적인 안내서가 되는 것을 목표로 함\n\n우리 자신의 경험에서 비롯되었으며 업계 전반의 사례를 제시함\n\n이 섹션에서는 새로 등장하는 LLM 스택의 핵심 구성 요소에 대한 모범 사례를 공유함\n\n품질과 신뢰성을 높이기 위한 프롬프팅 팁, 출력을 평가하기 위한 전략, 그라운딩을 개선하기 위한 검색 증강 생성 아이디어 등이 포함됨\n\n또한 휴먼 인 더 루프 워크플로우를 설계하는 방법도 탐구할 예정임\n\n새로운 애플리케이션을 개발할 때는 프롬프팅으로 시작할 것을 권장함\n\n프롬프팅의 중요성을 과소평가하거나 과대평가하기 쉬움\n\n올바른 프롬프팅 기술을 제대로 사용하면 매우 멀리 갈 수 있기 때문에 과소평가되는 경향이 있음\n\n프롬프트 기반 애플리케이션도 제대로 작동하려면 프롬프트 주변에 상당한 엔지니어링이 필요하기 때문에 과대평가되는 경향이 있음\n\n기본 프롬프팅 기술을 최대한 활용하는 데 집중\n\n다양한 모델과 작업에서 성능 향상에 지속적으로 도움이 되는 몇 가지 프롬프팅 기술이 있음\n\nN-shot 프롬프트 + 문맥 내 학습, 사고의 연쇄(Chain-of-Thought), 관련 리소스 제공 등임\n\nN-shot 프롬프트를 통한 문맥 내 학습의 아이디어는 LLM에게 작업을 시연하고 출력을 기대에 맞추는 몇 가지 예시를 제공하는 것임\n\nN이 너무 낮으면 모델이 해당 특정 예시에 과도하게 고정되어 일반화 능력이 저하될 수 있음\n\n경험적으로 N ≥ 5를 목표로 하고, 수십 개까지 사용하는 것을 두려워하지 말 것\n\n전체 입출력 쌍을 제공할 필요는 없으며, 많은 경우 원하는 출력의 예시로 충분함\n\n도구 사용을 지원하는 LLM을 사용하는 경우, N-shot 예시에서도 에이전트가 사용하기를 원하는 도구를 사용해야 함\n\n사고의 연쇄(Chain-of-Thought, CoT) 프롬프팅\n\nCoT 프롬프팅에서는 LLM이 최종 답변을 반환하기 전에 사고 과정을 설명하도록 장려함\n\nLLM에게 메모리에서 모든 것을 수행할 필요가 없도록 스케치패드를 제공하는 것으로 생각할 수 있음\n\n원래 접근 방식은 단순히 \"단계별로 생각해 보자\"라는 문구를 지침의 일부로 추가하는 것이었지만, CoT를 더 구체적으로 만드는 것이 도움이 된다는 것을 발견함\n\n1~2문장으로 구체성을 추가하면 환각 발생률이 상당히 감소하는 경우가 많음\n\n최근에는 이 기술이 믿는 만큼 강력한지에 대해 의문이 제기되고 있음\n\n또한 CoT가 사용될 때 추론 중에 정확히 어떤 일이 일어나는지에 대해 상당한 논쟁이 있음\n\n그럼에도 불구하고 가능한 경우 실험해 볼 만한 기술임\n\n관련 리소스를 제공하는 것은 모델의 지식 기반을 확장하고, 환각을 줄이며, 사용자의 신뢰를 높이는 강력한 메커니즘임\n\n검색 증강 생성(Retrieval Augmented Generation, RAG)을 통해 수행되는 경우가 많음\n\n모델에 응답에 직접 활용할 수 있는 텍스트 스니펫을 제공하는 것은 필수적인 기술임\n\n관련 리소스를 제공할 때는 단순히 포함하는 것으로는 충분하지 않음\n\n모델에게 리소스 사용을 우선시하고, 직접 참조하며, 때로는 리소스가 충분하지 않을 때 언급하도록 지시하는 것을 잊지 말아야 함\n\n이러한 것들은 에이전트 응답을 리소스 코퍼스에 \"Ground\"하는 데 도움이 됨\n\n구조화된 입력과 출력은 모델이 입력을 더 잘 이해하고 다운스트림 시스템과 안정적으로 통합할 수 있는 출력을 반환하는 데 도움이 됨\n\n입력에 직렬화 형식을 추가하면 컨텍스트의 토큰 간 관계, 특정 토큰에 대한 추가 메타데이터(유형 등) 또는 요청을 모델 학습 데이터의 유사한 예시와 관련시키는 데 도움이 될 수 있음\n\n예를 들어, 인터넷에서 SQL 작성에 대한 많은 질문은 SQL 스키마를 지정하는 것으로 시작함\n\n따라서 Text-to-SQL에 대한 효과적인 프롬프팅에는 구조화된 스키마 정의가 포함되어야 함\n\n구조화된 출력은 유사한 목적을 수행하지만, 시스템의 다운스트림 구성 요소로의 통합을 단순화함\n\nInstructor와 Outlines는 구조화된 출력에 잘 작동함\n\n(LLM API SDK를 가져오는 경우 Instructor를 사용하고, 자체 호스팅 모델에 Huggingface를 가져오는 경우 Outlines를 사용)\n\n구조화된 입력은 작업을 명확하게 표현하고 학습 데이터의 형식과 유사하므로 더 나은 출력 가능성을 높임\n\n구조화된 입력을 사용할 때는 각 LLM 제품군마다 선호하는 방식이 있다는 점에 유의해야 함\n\nClaude는 을 선호하는 반면 GPT는 Markdown과 JSON을 선호함\n\nXML을 사용하면 태그를 제공하여 Claude의 응답을 미리 채울 수도 있음\n\n작고 한 가지 일을 잘하는 프롬프트를 만들 것\n\n소프트웨어에서 흔한 안티 패턴/코드 스멜은 모든 것을 수행하는 단일 클래스나 함수인 \"God Object\"임\n\n몇 문장의 지침, 몇 가지 예시로 시작할 수 있음\n\n그러나 성능을 개선하고 더 많은 edge case를 처리하려고 하면서 복잡성이 증가함\n\n더 많은 지침, 다단계 추론, 수십 개의 예시 등이 추가됨\n\n결국 처음에는 단순했던 프롬프트가 2,000 토큰의 프랑켄슈타인이 되어버림\n\n게다가 더 일반적이고 직관적인 입력에 대한 성능은 오히려 저하됨\n\nGoDaddy는 이 문제를 LLM 구축에서 얻은 교훈 중 1위로 꼽음\n\n시스템과 코드를 단순하게 유지하려고 노력하는 것처럼, 프롬프트도 마찬가지여야 함\n\n회의 녹취록 요약기에 대해 단일 만능 프롬프트를 사용하는 대신, 다음과 같은 단계로 나눌 수 있음\n\n주요 결정 사항, 조치 항목 및 담당자를 구조화된 형식으로 추출\n\n추출된 세부 정보를 원본 녹취록과 비교하여 일관성 확인\n\n결과적으로 단일 프롬프트를 여러 개의 단순하고 집중적이며 이해하기 쉬운 프롬프트로 분할함\n\n이렇게 분할하면 이제 각 프롬프트를 개별적으로 반복하고 평가할 수 있음\n\n에이전트에 실제로 전송해야 하는 컨텍스트의 양에 대한 가정을 재고하고 도전해야 함\n\n미켈란젤로처럼 컨텍스트 조각상을 만들어 가는 것이 아니라, 불필요한 재료를 깎아내어 조각상을 드러내야 함\n\nRAG는 잠재적으로 관련된 대리석 블록을 모두 모으는 데 널리 사용되는 방법이지만, 필요한 것을 추출하기 위해 무엇을 하고 있는가?\n\n모델에 전송되는 최종 프롬프트를 가져와 모든 컨텍스트 구성, 메타 프롬프팅, RAG 결과와 함께 빈 페이지에 배치하고 읽어보는 것이 컨텍스트를 재고하는 데 도움이 된다는 것을 발견함\n\n이 방법을 사용하여 중복, 자가 모순적인 언어 및 형식이 잘못된 부분을 발견할 수 있음\n\nBag-of-docs 표현은 인간에게 도움이 되지 않으므로 에이전트에게 좋다고 가정하지 말아야 함\n\n컨텍스트의 각 부분 간의 관계를 강조하고 추출을 가능한 한 단순하게 만들 수 있도록 컨텍스트를 구성하는 방법을 신중하게 고려해야 함\n\n프롬프팅 외에도 LLM을 조정하는 또 다른 효과적인 방법은 프롬프트의 일부로 지식을 제공하는 것임\n\n이는 제공된 컨텍스트에 LLM을 ground시키며, 이는 문맥 내 학습에 사용됨\n\n이를 검색 증강 생성(Retrieval-Augmented Generation, RAG)이라고 함\n\n실무자들은 RAG가 지식을 제공하고 출력을 개선하는 데 효과적이며, 미세 조정에 비해 훨씬 적은 노력과 비용이 든다는 것을 발견함\n\nRAG는 검색된 문서의 관련성, 밀도 및 세부 정보만큼만 좋음\n\nRAG 출력의 품질은 검색된 문서의 품질에 따라 달라지며, 몇 가지 요소를 고려할 수 있음\n\n이는 일반적으로 평균 역순위(Mean Reciprocal Rank, MRR) 또는 정규화된 할인 누적 이득(Normalized Discounted Cumulative Gain, NDCG)과 같은 순위 지표로 정량화됨\n\nMRR은 시스템이 순위 목록에서 첫 번째 관련 결과를 얼마나 잘 배치하는지 평가하는 반면, NDCG는 모든 결과의 관련성과 위치를 고려함\n\n이들은 관련 문서를 더 높이, 관련 없는 문서를 더 낮게 순위를 매기는 시스템의 성능을 측정함\n\n예를 들어, 영화 리뷰 요약을 생성하기 위해 사용자 요약을 검색하는 경우, 특정 영화에 대한 리뷰를 더 높이 순위를 매기고 다른 영화에 대한 리뷰는 제외하는 것이 좋음\n\n전통적인 추천 시스템과 마찬가지로 검색된 항목의 순위는 LLM이 다운스트림 작업을 수행하는 방식에 상당한 영향을 미침\n\n영향을 측정하려면 검색된 항목을 섞은 상태에서 RAG 기반 작업을 실행해 보고, RAG 출력이 어떻게 수행되는지 확인할 것\n\n두 문서가 동일하게 관련된 경우, 더 간결하고 관련 없는 세부 정보가 적은 문서를 선호해야 함\n\n영화 예로 돌아가면, 영화 대본과 모든 사용자 리뷰를 광범위한 의미에서 관련이 있다고 간주할 수 있음\n\n그럼에도 불구하고 높은 평가를 받은 리뷰와 편집 리뷰는 정보 밀도가 더 높을 가능성이 있음\n\n마지막으로, 문서에 제공된 \"세부 정보 수준\"을 고려할 것\n\n자연어에서 SQL 쿼리를 생성하기 위한 RAG 시스템을 구축한다고 상상해 보자\n\n열 이름이 있는 테이블 스키마를 컨텍스트로 제공하는 것만으로도 충분할 수 있음\n\n그러나 열 설명과 일부 대표 값을 포함하면 어떨까?\n\n추가 세부 정보는 LLM이 테이블의 의미를 더 잘 이해하고 더 정확한 SQL을 생성하는 데 도움이 될 수 있음\n\n키워드 검색을 잊지 말고, 기준선과 하이브리드 검색에 사용할 것\n\nEmbedding 기반 RAG 데모가 널리 퍼져 있기 때문에 정보 검색 분야의 수십 년 간의 연구와 솔루션을 잊거나 간과하기 쉬움\n\n그럼에도 불구하고 임베딩은 의심할 여지 없이 강력한 도구이지만, 만능은 아님\n\n첫째, 임베딩은 높은 수준의 의미론적 유사성을 포착하는 데 탁월하지만, 사용자가 이름(예: Ilya), 두문자어(예: RAG) 또는 ID(예: claude-3-sonnet)를 검색할 때와 같이 더 구체적이고 키워드 기반의 쿼리에는 어려움을 겪을 수 있음\n\nBM25와 같은 키워드 기반 검색은 이를 위해 명시적으로 설계됨\n\n사용자들은 키워드 기반 검색을 오랫동안 사용해 왔기 때문에 당연한 것으로 여기고 있을 것이며, 검색하고자 하는 문서가 반환되지 않으면 좌절감을 느낄 수 있음\n\n둘째, 키워드 검색으로 문서가 검색된 이유를 이해하는 것이 더 직관적임\n\n쿼리와 일치하는 키워드를 확인할 수 있기 때문\n\n반면에 임베딩 기반 검색은 해석 가능성이 낮음\n\n셋째, 수십 년 동안 최적화되고 실전에서 검증된 Lucene이나 OpenSearch와 같은 시스템 덕분에 키워드 검색이 일반적으로 더 계산적으로 효율적임\n\n대부분의 경우 하이브리드 접근 방식이 가장 효과적\n\n명백한 일치 항목에는 키워드 매칭을 사용하고, 동의어, 상위어, 철자 오류 및 멀티모달(예: 이미지와 텍스트)에는 임베딩을 사용\n\nShortwave는 쿼리 재작성, 키워드 + 임베딩 검색, 랭킹 등 자신들의 RAG 파이프라인을 어떻게 구축했는지 공유 한바 있음\n\n새로운 지식에 대해서는 파인튜닝보다 RAG를 선호\n\nRAG와 파인튜닝 모두 새로운 정보를 LLM에 통합하고 특정 작업에 대한 성능을 향상시키는 데 사용될 수 있음\n\n최근 연구에 따르면 RAG가 더 우수할 수 있음\n\n한 연구에서는 RAG와 비지도 미세 조정(지속적 사전 학습이라고도 함)을 MMLU와 시사 문제의 하위 집합에서 평가하여 비교함\n\nRAG가 학습 중에 접한 지식과 완전히 새로운 지식 모두에 대해 미세 조정보다 지속적으로 더 우수한 성능을 보였음\n\n다른 논문에서는 농업 데이터 세트에 대해 RAG와 지도 미세 조정을 비교함\n\n마찬가지로 RAG의 성능 향상이 미세 조정보다 컸으며, 특히 GPT-4에서 두드러짐(논문의 표 20 참조)\n\n성능 향상 외에도 RAG는 여러 실용적인 장점이 있음\n\n첫째, 지속적인 사전 학습이나 미세 조정에 비해 검색 인덱스를 최신 상태로 유지하는 것이 더 쉽고 저렴함\n\n둘째, 검색 인덱스에 유해하거나 편향된 내용이 포함된 문제가 있는 문서가 있는 경우 문제가 있는 문서를 쉽게 삭제하거나 수정할 수 있음\n\n또한 RAG의 R은 문서를 검색하는 방법에 대해 더 세분화된 제어를 제공함\n\n예를 들어 여러 조직을 위해 RAG 시스템을 호스팅하는 경우, 검색 인덱스를 분할하여 각 조직이 자체 인덱스의 문서만 검색할 수 있도록 할 수 있음\n\n이렇게 하면 한 조직의 정보를 실수로 다른 조직에 노출하는 일이 없도록 할 수 있음\n\n장문 컨텍스트 모델이 RAG를 쓸모없게 만들지는 않을 것임\n\nGemini 1.5가 최대 1,000만 토큰 크기의 컨텍스트 윈도우를 제공함에 따라 일부에서는 RAG의 미래에 의문을 제기하기 시작함\n\n1,000만 토큰의 컨텍스트 윈도우는 기존 RAG 프레임워크 대부분을 불필요하게 만듦\n\n데이터를 컨텍스트에 넣고 평소처럼 모델과 대화하기만 하면 됨\n\n이는 대부분의 엔지니어링 노력이 RAG에 투입되는 스타트업, 에이전트, langchain 프로젝트에 어떤 영향을 줄지 상상해 보라\n\n한 문장으로 요약하면 1,000만 컨텍스트가 RAG를 죽인다는 것\n\n장문 컨텍스트가 여러 문서 분석이나 PDF와의 채팅 등의 사용 사례에 게임 체인저가 될 것이라는 점은 사실이지만, RAG의 종말에 대한 소문은 크게 과장됨\n\n첫째, 1,000만 토큰의 컨텍스트 윈도우가 있더라도 모델에 입력할 정보를 선택하는 방법이 여전히 필요함\n\n둘째, 좁은 바늘구멍 평가를 넘어서 모델이 그렇게 큰 컨텍스트에 대해 효과적으로 추론할 수 있다는 설득력 있는 데이터는 아직 보지 못함\n\n따라서 좋은 검색(및 랭킹) 없이는 주의를 분산시키는 정보로 모델을 압도하거나 심지어 완전히 무관한 정보로 컨텍스트 윈도우를 채울 위험이 있음\n\nTransformer의 추론 비용은 컨텍스트 길이에 따라 제곱(또는 공간과 시간 모두에서 선형)으로 증가함\n\n조직의 전체 Google Drive 내용을 읽을 수 있는 모델이 존재한다고 해서 각 질문에 답하기 전에 그렇게 하는 것이 좋은 생각은 아님\n\nRAM 사용 방식에 대한 비유를 고려해 보자\n\n수십 테라바이트에 달하는 RAM이 있는 컴퓨팅 인스턴스가 존재하지만, 여전히 디스크에서 읽고 쓰고 있음\n\n따라서 아직 RAG를 쓰레기통에 버리지 말 것\n\n이 패턴은 컨텍스트 윈도우의 크기가 커질수록 여전히 유용할 것임\n\nLLM에 프롬프트를 주는 것은 시작에 불과함\n\nLLM을 최대한 활용하려면 단일 프롬프트를 넘어 워크플로우를 수용해야 함\n\n예를 들어, 복잡한 단일 작업을 여러 개의 더 간단한 작업으로 어떻게 분할할 수 있을까?\n\n미세 조정이나 캐싱이 성능 향상과 지연/비용 감소에 도움이 되는 시점은 언제일까?\n\n이 섹션에서는 검증된 전략과 실제 사례를 공유하여 LLM 워크플로우를 최적화하고 구축하는 데 도움을 줌\n\n단계별, 다중 턴 \"Flow\"는 큰 성능 향상을 제공할 수 있음\n\n하나의 큰 프롬프트를 여러 개의 작은 프롬프트로 분해함으로써 더 나은 결과를 얻을 수 있다는 것을 이미 알고 있음\n\n단일 프롬프트에서 다단계 워크플로우로 전환함으로써 CodeContests에서 GPT-4 정확도(pass@5)를 19%에서 44%로 높임\n\n명확한 목표를 가진 작은 작업은 최상의 에이전트 또는 흐름 프롬프트를 만듦\n\n모든 에이전트 프롬프트가 구조화된 출력을 요청할 필요는 없지만, 구조화된 출력은 에이전트의 환경과의 상호 작용을 조정하는 시스템과의 인터페이스에 큰 도움이 됨\n\n가능한 한 엄격하게 지정된 명시적 계획 단계\n\n미리 정의된 계획 중에서 선택하는 것을 고려할 것\n\n원래 사용자 프롬프트를 에이전트 프롬프트로 다시 작성\n\n이 과정에서 정보 손실이 발생하므로 주의할 것\n\n선형 체인, DAG 및 상태 머신으로서의 에이전트 동작\n\n다양한 종속성과 논리 관계는 서로 다른 규모에 더 적합하거나 덜 적합할 수 있음\n\n다양한 작업 아키텍처에서 성능 최적화를 이끌어낼 수 있을까?\n\n계획에는 최종 결과물이 잘 작동하도록 다른 에이전트의 응답을 평가하는 방법에 대한 지침을 포함할 수 있음\n\n에이전트 프롬프트가 이전에 발생할 수 있는 다양한 변형에 대해 평가되는지 확인할 것\n\n현재로서는 결정론적 워크플로우에 우선순위를 둘 것\n\nAI 에이전트는 사용자 요청과 환경에 동적으로 반응할 수 있지만, 이들의 비결정론적 특성은 배포에 어려움을 줌\n\n에이전트가 수행하는 각 단계는 실패할 가능성이 있으며, 오류에서 복구할 가능성은 낮음\n\n따라서 에이전트가 다단계 작업을 성공적으로 완료할 가능성은 단계 수가 증가함에 따라 기하급수적으로 감소함\n\n그 결과 에이전트를 구축하는 팀은 신뢰할 수 있는 에이전트를 배포하는 데 어려움을 겪음\n\n유망한 접근 방식은 결정론적 계획을 생성하고 이를 구조화되고 재현 가능한 방식으로 실행하는 에이전트 시스템을 갖는 것임\n\n첫 번째 단계에서는 상위 수준의 목표나 프롬프트가 주어지면 에이전트가 계획을 생성함\n\n이를 통해 각 단계를 보다 예측 가능하고 신뢰할 수 있게 만들 수 있음\n\n생성된 계획은 에이전트에 프롬프트를 제공하거나 미세 조정하기 위한 few-shot 샘플로 사용될 수 있음\n\n결정론적 실행은 시스템을 더 신뢰할 수 있게 만들어 테스트와 디버깅이 더 쉬워짐. 또한 실패는 계획의 특정 단계로 추적될 수 있음\n\n생성된 계획은 방향성 비순환 그래프(DAG)로 표현될 수 있으며, 정적 프롬프트에 비해 이해하고 새로운 상황에 적응하기 쉬움\n\n가장 성공적인 에이전트 구축자는 주니어 엔지니어를 관리하는 데 강력한 경험을 가진 사람일 수 있음\n\n계획 생성 과정은 주니어를 지시하고 관리하는 방식과 유사하기 때문\n\n주니어에게 모호하고 개방적인 방향 대신 명확한 목표와 구체적인 계획을 제공하는 것처럼, 에이전트에게도 동일하게 해야 함\n\n결국 신뢰할 수 있고 작동하는 에이전트의 핵심은\n\n보다 구조화되고 결정론적인 접근 방식을 채택하고,\n\n프롬프트를 개선하고 모델을 미세 조정하기 위한 데이터를 수집하는 데서 발견될 가능성이 높음\n\n이것 없이는 때때로 매우 잘 작동할 수 있지만 평균적으로 사용자를 실망시켜 유지력이 낮아지는 에이전트를 구축하게 될 것임\n\nLLM의 출력에 다양성이 필요한 작업이 있다고 가정해 보자\n\n사용자가 이전에 구매한 제품 목록을 고려하여 카탈로그에서 구매할 제품을 제안하는 LLM 파이프라인을 작성하고 있을 수 있음\n\n프롬프트를 여러 번 실행할 때 결과 추천이 너무 유사하다는 것을 알 수 있음\n\n따라서 LLM 요청의 Temperature(온도) 매개변수를 높일 수 있음\n\n온도 매개변수를 높이면 LLM 응답이 더 다양해짐\n\n샘플링 시 다음 토큰의 확률 분포가 더 평평해져 일반적으로 선택될 가능성이 낮은 토큰이 더 자주 선택됨\n\n그러나 온도를 높일 때 출력 다양성과 관련된 일부 실패 모드가 발생할 수 있음\n\n예를 들어 카탈로그의 일부 제품이 적합할 수 있지만 LLM에 의해 출력되지 않을 수 있음\n\nLLM이 학습 시 배운 내용을 기반으로 프롬프트를 따를 가능성이 높은 경우 동일한 소수의 제품이 출력에서 과대 대표될 수 있음\n\n온도가 너무 높으면 존재하지 않는 제품(또는 무의미한 내용)을 참조하는 출력이 생성될 수 있음\n\n온도를 높인다고 해서 LLM이 예상하는 확률 분포(예: 균일 무작위)에서 출력을 샘플링한다는 보장은 없음\n\n그럼에도 불구하고 출력 다양성을 높이기 위한 다른 트릭이 있음\n\n가장 간단한 방법은 프롬프트 내 요소를 조정하는 것\n\n예를 들어 프롬프트 템플릿에 과거 구매 내역과 같은 항목 목록이 포함된 경우, 이러한 항목을 프롬프트에 삽입할 때마다 순서를 섞으면 상당한 차이를 만들 수 있음\n\n또한 최근 출력의 짧은 목록을 유지하면 중복을 방지하는 데 도움이 됨\n\n추천 제품 예시에서 LLM에 이 최근 목록에서 항목 제안을 피하도록 지시하거나, 최근 제안과 유사한 출력을 거부하고 재샘플링함으로써 응답을 더욱 다양화할 수 있음\n\n또 다른 효과적인 전략은 프롬프트에 사용되는 표현을 다양화하는 것\n\n예를 들어 \"사용자가 정기적으로 사용하는 것을 좋아할 항목 선택\" 또는 \"사용자가 친구에게 추천할 가능성이 높은 제품 선택\"과 같은 문구를 통합하면 초점을 이동시켜 추천 제품의 다양성에 영향을 줄 수 있음\n\n캐싱은 동일한 입력에 대한 응답을 재계산할 필요성을 제거함으로써 비용을 절감하고 생성 지연 시간을 제거함\n\n또한 응답이 이전에 가드레일링되었다면, 이러한 검증된 응답을 제공하여 유해하거나 부적절한 콘텐츠를 제공할 위험을 줄일 수 있음\n\n캐싱에 대한 간단한 접근 방식은 새로운 기사나 제품 리뷰를 요약하는 경우와 같이 처리 중인 항목에 대해 고유한 ID를 사용하는 것임\n\n요청이 들어오면 캐시에 이미 요약이 존재하는지 확인할 수 있음\n\n그렇다면 즉시 반환할 수 있고, 그렇지 않다면 생성, 가드레일링 및 제공한 다음 향후 요청을 위해 캐시에 저장할 수 있음\n\n좀 더 개방형인 쿼리의 경우 개방형 입력에 대해서도 캐싱을 활용하는 검색 분야의 기술을 차용할 수 있음\n\n자동 완성 및 맞춤법 수정과 같은 기능은 사용자 입력을 정규화하여 캐시 적중률을 높이는 데 도움이 됨\n\n가장 영리하게 설계된 프롬프트조차도 부족한 일부 작업이 있을 수 있음\n\n예를 들어 상당한 프롬프트 엔지니어링 이후에도 시스템이 여전히 신뢰할 수 있고 고품질의 출력을 반환하는 데서 멀어질 수 있음\n\n이 경우 특정 작업을 위해 모델을 파인튜닝해야 할 수 있음\n\nHoneycomb의 Natural Language Query Assistant\n\n처음에는 \"프로그래밍 매뉴얼\"이 문맥 내 학습을 위한 n-shot 예제와 함께 프롬프트에 제공됨\n\n이것이 제대로 작동했지만, 모델을 파인 튜닝하면 도메인 특정 언어의 구문과 규칙에 대한 더 나은 출력을 얻을 수 있음\n\nLLM은 프론트엔드가 올바르게 렌더링하기 위해 구조화된 데이터와 비구조화된 데이터를 결합한 매우 특정한 형식으로 응답을 생성해야 함\n\n파인 튜닝은 일관되게 작동하도록 하는 데 필수적임\n\n파인튜닝이 효과적일 수 있지만 상당한 비용이 수반됨\n\n파인 튜닝 데이터에 주석을 달고, 모델을 파인 튜닝 및 평가한 다음, 결국 자체 호스팅해야 함\n\n따라서 더 높은 초기 비용이 그만한 가치가 있는지 고려해야 함\n\n프롬프팅으로 90%까지 도달할 수 있다면 파인 튜닝에 투자할 가치가 없을 수 있음\n\n그러나 파인 튜닝하기로 결정한다면 인간이 주석을 단 데이터 수집 비용을 줄이기 위해 합성 데이터에 대해 생성 및 파인 튜닝하거나 오픈 소스 데이터를 부트스트랩할 수 있음\n\nLLM의 입력과 출력은 임의의 텍스트이며, 설정하는 작업도 다양함\n\n그럼에도 불구하고 엄격하고 신중한 평가는 중요함\n\nOpenAI의 기술 리더들이 평가에 참여하고 개별 평가에 대해 피드백을 제공하는 것이 우연이 아님\n\nLLM 애플리케이션 평가에는 다양한 정의와 축소가 필요함\n\n단순히 단위 테스트이거나, 관찰 가능성과 더 유사하거나, 단순히 데이터 과학일 수 있음\n\n우리는 이러한 모든 관점이 유용하다는 것을 발견함\n\n이번 섹션에서는 평가 및 모니터링 파이프라인 구축에서 중요한 사항에 대해 배운 교훈을 제공함\n\n실제 입출력 샘플에서 몇 가지 assertion 기반 단위 테스트 생성\n\n프로덕션에서 입력과 출력의 샘플로 구성된 단위 테스트(즉, assertion)를 만들고, 최소 3가지 기준에 따라 출력에 대한 기대치를 설정\n\n3가지 기준이 임의적으로 보일 수 있지만, 시작하기에 실용적인 수임\n\n더 적으면 작업이 충분히 정의되지 않았거나 범용 챗봇과 같이 너무 개방적일 수 있음\n\n이러한 단위 테스트 또는 assertion은 프롬프트 편집, RAG를 통한 새 컨텍스트 추가 또는 기타 수정과 같은 파이프라인의 변경 사항에 의해 트리거되어야 함\n\n모든 응답에 포함하거나 제외할 구문이나 아이디어를 지정하는 assertion부터 시작하는 것을 고려\n\n또한 단어, 항목 또는 문장 수가 범위 내에 있는지 확인하는 검사를 고려\n\n다른 종류의 생성의 경우 assertion이 다르게 보일 수 있음\n\n예를 들어 코드 생성을 평가하기 위한 강력한 방법인 실행 평가에서는 생성된 코드를 실행하고 런타임 상태가 사용자 요청에 충분한지 확인\n\n예를 들어 사용자가 foo라는 새 함수를 요청하면 에이전트의 생성 코드를 실행한 후 foo를 호출할 수 있어야 함\n\n실행 평가의 한 가지 과제는 에이전트 코드가 종종 대상 코드와 약간 다른 형태로 런타임을 남긴다는 것\n\n어떤 타당한 답변이라도 만족시킬 수 있는 가장 약한 가정으로 assertion을 \"완화\"하는 것이 효과적일 수 있음\n\n고객을 위해 의도한 대로 제품을 사용하는 것(즉, \"도그푸딩\")은 실제 데이터에서의 장애 모드에 대한 통찰력을 제공할 수 있음\n\n이 접근 방식은 잠재적 약점을 식별하는 데 도움이 될 뿐만 아니라 평가로 변환할 수 있는 유용한 프로덕션 샘플 소스도 제공함\n\nLLM-as-Judge는 (어느 정도) 작동할 수 있지만 만능은 아님\n\nLLM-as-Judge는 강력한 LLM을 사용하여 다른 LLM의 출력을 평가하는 방식으로, 일부 사람들에게는 회의적으로 받아들여짐\n\n그럼에도 불구하고 잘 구현되면 LLM-as-Judge는 인간의 판단과 상당한 상관관계를 달성하고, 적어도 새로운 프롬프트나 기술이 어떻게 수행될 수 있는지에 대한 사전 정보를 구축하는 데 도움이 될 수 있음\n\n특히 쌍별 비교(예: 대조군 vs 처리군)를 할 때 LLM-as-Judge는 일반적으로 방향을 올바르게 잡지만 승/패의 크기는 노이즈가 있을 수 있음\n\nLLM-as-Judge를 최대한 활용하기 위한 제안\n\nLLM에게 단일 출력을 Likert 척도로 평가하도록 요청하는 대신 두 가지 옵션을 제시하고 더 나은 것을 선택하도록 요청\n\n이는 더 안정적인 결과로 이어지는 경향이 있음\n\n제시된 옵션의 순서가 LLM의 결정을 편향시킬 수 있음\n\n이를 완화하려면 각 쌍별 비교를 두 번 수행하고 각 시간에 쌍의 순서를 바꿈\n\n스와핑 후에는 올바른 옵션에 승리를 귀속시켜야 함\n\n경우에 따라 두 옵션이 똑같이 좋을 수 있음\n\n따라서 LLM이 임의로 승자를 선택할 필요가 없도록 동점을 선언하도록 허용\n\n최종 선호도를 제시하기 전에 LLM에게 그 결정을 설명하도록 요청하면 평가 신뢰성이 향상될 수 있음\n\n보너스로, 이를 통해 더 약하지만 빠른 LLM을 사용하면서도 유사한 결과를 얻을 수 있음\n\n파이프라인의 이 부분이 자주 배치 모드에 있기 때문에 CoT로 인한 추가 지연은 문제가 되지 않음\n\nLLM은 더 긴 응답으로 치우치는 경향이 있음\n\n이를 완화하려면 응답 쌍의 길이가 비슷한지 확인\n\nLLM-as-Judge의 특히 강력한 적용은 새로운 프롬프트 전략을 회귀에 대해 확인하는 것\n\n프로덕션 결과 모음을 추적한 경우 때로는 새로운 프롬프트 전략으로 해당 프로덕션 예제를 다시 실행하고 LLM-as-Judge를 사용하여 새 전략이 어디에서 어려움을 겪을 수 있는지 신속하게 평가할 수 있음\n\nLLM-as-Judge의 간단하지만 효과적인 접근 방식의 예시\n\n단순히 LLM 응답, 판사의 비평(즉, CoT) 및 최종 결과를 기록\n\n그런 다음 이해관계자와 검토하여 개선 영역을 식별\n\n3번의 반복을 통해 인간과 LLM의 일치도는 68%에서 94%로 향상됨\n\n그러나 LLM-as-Judge는 만능이 아님\n\n가장 강력한 모델조차도 신뢰할 수 있게 평가하지 못하는 미묘한 언어적 측면이 있음\n\n또한 기존의 분류기와 보상 모델이 LLM-as-Judge보다 더 높은 정확도를 달성할 수 있으며 비용과 지연 시간이 더 적다는 것을 발견함\n\n코드 생성의 경우 LLM-as-Judge는 실행 평가와 같은 보다 직접적인 평가 전략보다 약할 수 있음\n\n생성 결과를 평가할 때 다음과 같은 \"인턴 테스트\"를 사용하는 것이 좋음\n\n컨텍스트를 포함하여 언어 모델에 대한 정확한 입력을 가져와 관련 전공의 평균적인 대학생에게 과제로 제시한다면 그들이 성공할 수 있을까?\n\nLLM에 필요한 지식이 부족하기 때문이라면 컨텍스트를 풍부하게 만드는 방법을 고려\n\n컨텍스트를 개선해도 해결할 수 없다면 현대 LLM에는 너무 어려운 작업일 수 있음\n\n작업의 어떤 측면을 더 템플릿화할 수 있는가?\n\n모델에게 응답 전이나 후에 스스로 설명하도록 요청해보기\n\n특정 평가에 지나치게 중점을 두면 전반적인 성능이 저하될 수 있음\n\n\"측정 지표가 목표가 되면 더 이상 좋은 측정 지표가 아니게 된다.\" - Goodhart의 법칙\n\n이에 대한 예시로 Needle-in-a-Haystack(NIAH) 평가가 있음\n\n원래 평가는 컨텍스트 크기가 커짐에 따라 모델 리콜을 정량화하고 바늘 위치에 따라 리콜이 어떻게 영향을 받는지 확인하는 데 도움이 됨\n\n그러나 너무 지나치게 강조되어 Gemini 1.5 보고서의 Figure 1로 소개됨\n\n이 평가에는 폴 그레이엄의 에세이를 반복하는 긴 문서에 특정 구문(\"The special magic {city} number is: {number}\")을 삽입한 다음 모델에 매직 넘버를 상기시키는 작업이 포함됨\n\n일부 모델은 거의 완벽한 리콜을 달성하지만 NIAH가 실제 애플리케이션에 필요한 추론 및 리콜 능력을 진정으로 반영하는지는 의문\n\n1시간 분량의 회의 녹취록이 주어지면 LLM이 주요 결정과 다음 단계를 요약하고 각 항목을 관련 담당자에게 올바르게 귀속시킬 수 있는가?\n\n이 작업은 단순한 암기를 넘어 복잡한 토론을 파악하고 관련 정보를 식별하며 요약을 종합하는 능력도 고려하므로 더 현실적임\n\n의사-환자 화상 통화 녹취록을 사용하여 LLM에 환자의 약물에 대해 질문\n\n에스프레소에 담근 대추, 레몬, 염소 치즈 등 피자 토핑에 필요한 무작위 재료에 대한 구문을 삽입하는 등 보다 도전적인 NIAH도 포함\n\n약물 작업에서 리콜은 약 80%, 피자 작업에서는 30%였음\n\nNIAH 평가를 지나치게 강조하면 추출 및 요약 작업의 성능이 낮아질 수 있음\n\n이러한 LLM은 모든 문장에 주의를 기울이도록 미세 조정되어 있기 때문에 관련 없는 세부 정보와 주의 산만 요소를 중요한 것으로 취급하기 시작할 수 있음\n\n그러면 최종 출력에 포함될 수 있음(포함되지 말아야 할 때도!)\n\n이는 다른 평가 및 사용 사례에도 적용될 수 있음\n\n사실적 일관성을 강조하면 덜 구체적이고(따라서 사실과 일치하지 않을 가능성이 낮음) 관련성이 떨어질 수 있는 요약이 생성될 수 있음\n\n반대로 글쓰기 스타일과 웅변을 강조하면 사실적 불일치를 초래할 수 있는 더 화려한 마케팅 유형의 언어가 생성될 수 있음\n\n주석 달기를 이진 작업 또는 쌍대(pairwise) 비교로 단순화\n\n모델 출력에 대해 개방형 피드백을 제공하거나 Likert 척도로 평가하는 것은 인지적으로 까다로움\n\n그 결과 수집된 데이터는 인간 평가자 간의 변동성으로 인해 더 노이즈가 많아지고 따라서 덜 유용해짐\n\n보다 효과적인 접근 방식은 작업을 단순화하고 주석 작성자의 인지적 부담을 줄이는 것\n\n잘 작동하는 두 가지 작업은 이진 분류와 쌍대 비교\n\n이진 분류에서 주석 작성자는 모델의 출력에 대해 간단한 예/아니오 판단을 내리도록 요청받음\n\n생성된 요약이 소스 문서와 사실적으로 일치하는지, 제안된 응답이 관련이 있는지, 유해성이 포함되어 있는지 등을 물을 수 있음\n\nLikert 척도에 비해 이진 결정은 더 정확하고, 평가자 간 일관성이 더 높으며, 처리량이 더 높음\n\nDoordash가 일련의 예/아니오 질문을 통해 메뉴 항목에 태그를 붙이기 위해 레이블링 대기열을 설정한 방식\n\n쌍대 비교(Pairewise Comparison)에서 주석 작성자는 한 쌍의 모델 응답을 받고 어떤 것이 더 나은지 물음\n\n인간이 A 또는 B에 개별적으로 점수를 매기는 것보다 \"A가 B보다 낫다\"라고 말하는 것이 더 쉽기 때문에 이는 더 빠르고 신뢰할 수 있는 주석으로 이어짐(Likert 척도보다)\n\nLlama2 밋업에서 Llama2 논문의 저자 중 한 명인 Thomas Scialom은 쌍대 비교가 작성된 응답과 같은 지도 학습 미세 조정 데이터를 수집하는 것보다 더 빠르고 저렴하다는 것을 확인함\n\n전자의 비용은 단위당 $3.5이고 후자의 비용은 단위당 $25\n\n(참조가 필요 없는, Reference-free) 평가와 가드레일은 상호 교환적으로 사용될 수 있음\n\n가드레일은 부적절하거나 유해한 콘텐츠를 잡는 데 도움이 되는 반면, 평가는 모델 출력의 품질과 정확성을 측정하는 데 도움이 됨\n\n참조가 필요 없는 평가의 경우 동전의 양면으로 볼 수 있음\n\n참조가 필요 없는 평가는 인간이 작성한 답변과 같은 \"golden\" reference에 의존하지 않고 입력 프롬프트와 모델의 응답만으로 출력 품질을 평가할 수 있는 평가임\n\n요약의 사실적 일관성과 관련성을 평가하기 위해 입력 문서만 고려하면 됨\n\n요약이 이러한 지표에서 점수가 낮으면 사용자에게 표시하지 않도록 선택할 수 있어 평가를 가드레일로 효과적으로 사용할 수 있음\n\n인간이 번역한 참조 없이도 번역의 품질을 평가할 수 있어 다시 가드레일로 사용할 수 있음\n\nLLM 작업 시 주요 과제는 LLM이 그러면 안 될 때도 종종 출력을 생성한다는 것\n\n이는 무해하지만 무의미한 응답이나 유해성 또는 위험한 내용과 같은 더 심각한 결함으로 이어질 수 있음\n\n예를 들어 문서에서 특정 속성이나 메타데이터를 추출하라는 요청을 받으면 LLM은 해당 값이 실제로 존재하지 않을 때도 자신 있게 값을 반환할 수 있음\n\n또는 컨텍스트에 영어 이외의 문서를 제공했기 때문에 모델이 영어 이외의 언어로 응답할 수도 있음\n\nLLM에 \"해당 없음\" 또는 \"알 수 없음\" 응답을 반환하도록 프롬프트를 제공할 수 있지만 완벽하지는 않음\n\n로그 확률을 사용할 수 있는 경우에도 출력 품질의 좋지 않은 지표임\n\n로그 확률은 출력에 토큰이 나타날 가능성을 나타내지만 생성된 텍스트의 정확성을 반영하지는 않음\n\n오히려 쿼리에 응답하고 일관된 응답을 생성하도록 훈련된 명령어 튜닝 모델의 경우 로그 확률이 잘 보정되지 않을 수 있음\n\n따라서 높은 로그 확률은 출력이 유창하고 일관성이 있음을 나타낼 수 있지만 정확하거나 관련이 있다는 의미는 아님\n\n주의 깊은 프롬프트 엔지니어링은 어느 정도 도움이 될 수 있지만, 원치 않는 출력을 감지하고 필터링/재생성하는 강력한 가드레일로 보완해야 함\n\n예를 들어 OpenAI는 혐오 발언, 자해 또는 성적 출력과 같은 안전하지 않은 응답을 식별할 수 있는 콘텐츠 조정 API를 제공함\n\n마찬가지로 개인 식별 정보(PII)를 감지하기 위한 수많은 패키지가 있음\n\n가드레일의 한 가지 이점은 사용 사례에 대해 크게 무관하며 따라서 특정 언어로 된 모든 출력에 광범위하게 적용될 수 있다는 것\n\n또한 정밀한 검색을 통해 관련 문서가 없으면 시스템이 결정적으로 \"모르겠습니다\"라고 응답할 수 있음\n\nLLM은 출력이 예상될 때 출력을 생성하지 못할 수 있음\n\nAPI 제공업체의 긴 지연 시간과 같은 간단한 문제부터 콘텐츠 조정 필터에 의해 출력이 차단되는 것과 같은 더 복잡한 문제에 이르기까지 다양한 이유로 발생할 수 있음\n\n따라서 디버깅 및 모니터링을 위해 입력과 (잠재적으로 출력 부족을) 일관되게 기록하는 것이 중요함\n\n환각(Hallucination)은 끈질긴 문제임\n\n콘텐츠 안전성이나 PII 결함은 많은 주의를 받아 거의 발생하지 않는 반면, 사실적 불일치는 끈질기게 지속되며 감지하기가 더 어려움\n\n더 흔하게 발생하며 기준 발생률은 5~10%이고, LLM 제공업체로부터 배운 바에 따르면 요약과 같은 간단한 작업에서도 2% 미만으로 낮추는 것이 어려울 수 있음\n\n이를 해결하기 위해 프롬프트 엔지니어링(생성 업스트림)과 사실적 불일치 가드레일(생성 다운스트림)을 결합할 수 있음\n\n프롬프트 엔지니어링의 경우 CoT와 같은 기술은 LLM이 최종 출력을 반환하기 전에 추론을 설명하도록 함으로써 환각을 줄이는 데 도움이 됨\n\n그런 다음 사실적 불일치 가드레일을 적용하여 요약의 사실성을 평가하고 환각을 필터링하거나 재생성할 수 있음\n\n경우에 따라 환각은 결정론적으로 감지될 수 있음\n\nRAG 검색의 리소스를 사용할 때 출력이 구조화되어 있고 리소스가 무엇인지 식별한다면 입력 컨텍스트에서 소싱되었는지 수동으로 확인할 수 있어야 함\n\n[운영: 일상(Day-to-Day) 및 조직 문제 ]\n\n재료의 품질이 요리의 맛을 결정하듯이 입력 데이터의 품질은 기계 학습 시스템의 성능을 제약함\n\n또한 출력 데이터는 제품이 작동하는지 여부를 알 수 있는 유일한 방법임\n\n모든 저자는 데이터 분포(모드, 엣지 케이스, 모델의 한계)를 더 잘 이해하기 위해 매주 몇 시간 동안 입력과 출력을 면밀히 살펴봄\n\n전통적인 기계 학습 파이프라인에서 오류의 일반적인 원인은 훈련-서비스 편향 임\n\n이는 훈련에 사용되는 데이터가 모델이 프로덕션에서 접하는 데이터와 다를 때 발생함\n\n훈련이나 미세 조정 없이 LLM을 사용할 수 있으므로 훈련 세트는 없지만 개발-프로덕션 데이터 편향이라는 유사한 문제가 발생함\n\n기본적으로 개발 중에 시스템을 테스트하는 데이터는 시스템이 프로덕션에서 직면할 데이터를 반영해야 함\n\n그렇지 않으면 프로덕션 정확도가 저하될 수 있음\n\nLLM 개발-프로덕션 편향은 구조적 편향과 내용 기반 편향의 두 가지 유형으로 분류될 수 있음\n\n구조적 편향에는 목록형 값을 가진 JSON 딕셔너리와 JSON 목록 간의 차이, 일관되지 않은 케이싱, 오타나 문장 조각과 같은 오류 등 형식 불일치와 같은 문제가 포함됨\n\n이러한 오류는 다양한 LLM이 특정 데이터 형식으로 훈련되고 프롬프트가 사소한 변경에 매우 민감할 수 있기 때문에 예측할 수 없는 모델 성능으로 이어질 수 있음\n\n내용 기반 또는 \"의미론적\" 편향은 데이터의 의미나 맥락의 차이를 나타냄\n\n전통적인 ML과 마찬가지로 LLM 입출력 쌍 간의 편향을 주기적으로 측정하는 것이 유용함\n\n입력 및 출력 길이 또는 특정 형식 요구 사항(예: JSON 또는 XML)과 같은 단순 메트릭은 변경 사항을 추적하는 간단한 방법임\n\n보다 \"고급\" 편향 감지를 위해 입출력 쌍의 임베딩을 클러스터링하여 사용자가 이전에 모델에 노출되지 않은 영역을 탐색하고 있음을 나타낼 수 있는 사용자가 논의하는 주제의 변화와 같은 의미론적 편향을 감지하는 것을 고려할 것\n\n프롬프트 엔지니어링과 같은 변경 사항을 테스트할 때는 홀드아웃 데이터 세트가 최신 상태이고 가장 최근 유형의 사용자 상호 작용을 반영하는지 확인\n\n예를 들어 프로덕션 입력에서 오타가 흔하다면 홀드아웃 데이터에도 있어야 함\n\n단순히 숫자로 편향을 측정하는 것 이상으로 출력에 대해 정성적 평가를 수행하는 것이 유익함\n\n모델의 출력을 정기적으로 검토하는 것(속어로 \"바이브 체크\"라고 알려진 관행)은 결과가 기대에 부합하고 사용자 요구에 계속 관련성이 있는지 확인해줌\n\n편향 확인에 비결정론을 통합하는 것도 유용함\n\n테스트 데이터 세트의 각 입력에 대해 파이프라인을 여러 번 실행하고 모든 출력을 분석함으로써 가끔만 발생할 수 있는 이상 현상을 포착할 가능성이 높아짐\n\n인상적인 제로샷 능력과 종종 기분 좋은 출력에도 불구하고 LLM의 실패 모드는 매우 예측할 수 없음\n\n맞춤 작업의 경우 LLM의 성능에 대한 직관적인 이해를 개발하기 위해 데이터 샘플을 정기적으로 검토하는 것이 필수적임\n\n프로덕션의 입출력 쌍은 LLM 애플리케이션의 \"실제 사물, 실제 장소\"( genchi genbutsu )이며 대체할 수 없음\n\n최근 연구에 따르면 개발자가 더 많은 데이터와 상호 작용할수록 \"좋은\" 출력과 \"나쁜\" 출력에 대한 인식이 변한다고 강조함(즉, 기준 편향 )\n\n개발자는 LLM 출력을 평가하기 위한 일부 기준을 사전에 제시할 수 있지만, 이러한 사전 정의된 기준은 종종 불완전함\n\n예를 들어 개발 과정에서 좋은 응답 확률을 높이고 나쁜 응답 확률을 낮추기 위해 프롬프트를 업데이트할 수 있음\n\n이러한 평가, 재평가 및 기준 업데이트의 반복 프로세스는 출력을 직접 관찰하지 않고는 LLM 동작이나 인간의 선호도를 예측하기 어렵기 때문에 필요함\n\n이를 효과적으로 관리하기 위해 LLM 입력과 출력을 기록해야 함\n\n매일 이러한 로그 샘플을 검사하면 새로운 패턴이나 실패 모드를 신속하게 식별하고 적응할 수 있음\n\n새로운 문제를 발견하면 즉시 그것에 대한 assertion 또는 eval을 작성할 수 있음\n\n마찬가지로 실패 모드 정의에 대한 모든 업데이트는 평가 기준에 반영되어야 함\n\n이러한 \"바이브 체크\"는 잘못된 출력의 신호이며, 코드와 assertion은 이를 운영함\n\n마지막으로 이러한 태도는 Socialized되어야 함\n\n예를 들어 온콜 로테이션에 입력 및 출력 검토 또는 주석 달기를 추가하는 것\n\nLLM API를 사용하면 소수의 제공업체의 지능에 의존할 수 있음\n\n이는 좋은 점이지만 이러한 종속성은 성능, 지연 시간, 처리량 및 비용 측면에서 절충점을 수반함\n\n또한 지난 1년 동안 거의 매월 더 새롭고 더 나은 모델이 출시됨에 따라 오래된 모델을 폐기하고 새로운 모델로 마이그레이션할 때 제품을 업데이트할 준비가 되어 있어야 함\n\n이 섹션에서는 완전히 제어할 수 없는 기술, 즉 모델을 자체 호스팅하고 관리할 수 없는 기술을 사용할 때 얻은 교훈을 공유함\n\n실제 사용 사례 대부분의 경우 LLM의 출력은 일종의 기계 판독 가능 형식을 통해 다운스트림 애플리케이션에서 소비될 것임\n\n예를 들어 부동산 CRM인 ReChat은 프론트엔드에서 위젯을 렌더링하기 위해 구조화된 응답이 필요함\n\n유사하게 제품 전략 아이디어 생성 도구인 Boba는 제목, 요약, 타당성 점수 및 시간 범위 필드가 있는 구조화된 출력이 필요함\n\n마지막으로 LinkedIn은 LLM을 제한하여 YAML을 생성하는 방법을 공유했는데, 이는 사용할 기술을 결정하고 기술을 호출하는 매개변수를 제공하는 데 사용됨\n\n이 애플리케이션 패턴은 Postel의 법칙의 극단적인 버전임\n\n수락하는 것(임의의 자연어)에 자유롭고 보내는 것(유형화된 기계 판독 가능 개체)에 보수적이어야 함\n\n따라서 이것이 매우 내구성이 있을 것으로 기대함\n\n현재 Instructor와 Outlines는 LLM에서 구조화된 출력을 이끌어내기 위한 사실상의 표준임\n\nLLM API(예: Anthropic, OpenAI)를 사용하는 경우 Instructor를 사용하고, 자체 호스팅 모델(예: Huggingface)을 사용하는 경우 Outlines를 사용할 것\n\n모델 간 프롬프트 마이그레이션은 고통스러운 일임\n\n때로는 주의 깊게 만든 프롬프트가 한 모델에서는 훌륭하게 작동하지만 다른 모델에서는 제대로 작동하지 않을 수 있음\n\n이는 다양한 모델 제공업체 간에 전환할 때뿐만 아니라 동일한 모델의 버전 간에 업그레이드할 때도 발생할 수 있음\n\n예를 들어 Voiceflow는 gpt-3.5-turbo-0301에서 gpt-3.5-turbo-1106으로 마이그레이션하면 의도 분류 작업에서 10%의 성능 저하가 발생한다는 것을 발견함\n\n유사하게 GoDaddy는 1106 버전으로 업그레이드하면 gpt-3.5-turbo와 gpt-4 사이의 성능 격차가 좁혀지는 긍정적인 방향의 추세를 관찰함\n\n(또는 당신이 반쯤 찬 유리잔을 보는 사람이라면 새로운 업그레이드로 gpt-4의 리드가 줄어든 것에 실망할 수도 있음)\n\n따라서 모델 간에 프롬프트를 마이그레이션해야 하는 경우 단순히 API 엔드포인트를 교체하는 것보다 더 많은 시간이 걸릴 것으로 예상해야 함\n\n동일한 프롬프트를 연결하면 유사하거나 더 나은 결과로 이어질 것이라고 가정하지 말 것\n\n또한 신뢰할 수 있고 자동화된 평가는 마이그레이션 전후의 작업 성능을 측정하는 데 도움이 되며 수동 검증에 필요한 노력을 줄여줌\n\n모든 기계 학습 파이프라인에서 \"무엇이든 변경하면 모든 것이 변경됨\"\n\n이는 우리 자신이 훈련하지 않고 우리 모르게 변경될 수 있는 대규모 언어 모델(LLM)과 같은 구성 요소에 의존할 때 특히 관련이 있음\n\n다행히도 많은 모델 제공업체는 특정 모델 버전(예: gpt-4-turbo-1106)을 \"고정\"할 수 있는 옵션을 제공함\n\n이를 통해 모델 가중치의 특정 버전을 사용하여 변경되지 않도록 할 수 있음\n\n프로덕션에서 모델 버전을 고정하면 모델 동작의 예기치 않은 변경을 방지할 수 있음\n\n이는 모델이 교체될 때 발생할 수 있는 지나치게 장황한 출력이나 기타 예상치 못한 실패 모드와 같은 문제에 대한 고객 불만을 피하는 데 도움이 될 수 있음\n\n또한 프로덕션 설정을 미러링하지만 최신 모델 버전을 사용하는 \"섀도우 파이프라인\"을 유지하는 것을 고려해 볼 것\n\n이를 통해 새로운 릴리스로 안전한 실험과 테스트를 수행할 수 있음\n\n이러한 새로운 모델에서 출력의 안정성과 품질을 검증한 후에는 프로덕션 환경에서 모델 버전을 자신 있게 업데이트할 수 있음\n\n작업을 완료할 수 있는 가장 작은 모델 선택하기\n\n새로운 애플리케이션에서 작업할 때 사용 가능한 가장 크고 강력한 모델을 사용하고 싶은 유혹이 있음\n\n그러나 일단 작업이 기술적으로 가능하다는 것이 확인되면 더 작은 모델로 유사한 결과를 얻을 수 있는지 실험해 볼 가치가 있음\n\n작은 모델의 장점은 지연 시간과 비용이 낮다는 것\n\n더 약할 수 있지만 Chain-of-Thought, n-shot 프롬프트, 문맥 내 학습과 같은 기술은 작은 모델이 자신의 역량 이상으로 성장하는 데 도움이 될 수 있음\n\nLLM API 이상으로 특정 작업에 대한 미세 조정도 성능 향상에 도움이 될 수 있음\n\n종합하면 작은 모델을 사용하여 신중하게 설계된 워크플로우는 더 빠르고 저렴하면서도 단일 대형 모델의 출력 품질과 일치하거나 심지어 능가할 수 있음\n\n예를 들어 이 트윗 은 Haiku + 10-shot 프롬프트가 제로샷 Opus와 GPT-4를 능가하는 방법에 대한 일화를 공유함\n\n장기적으로 출력 품질, 지연 시간 및 비용의 최적 균형으로 작은 모델을 사용한 흐름 엔지니어링의 더 많은 사례가 나타날 것으로 예상됨\n\n또 다른 예로 겸손한 분류 작업을 들 수 있음\n\nDistilBERT(6,700만 개 매개변수)와 같은 경량 모델은 놀라울 정도로 강력한 기준선임\n\n4억 개 매개변수의 DistilBART는 또 다른 훌륭한 옵션\n\n오픈 소스 데이터에서 미세 조정되면 지연 시간과 비용의 5% 미만으로 대부분의 LLM을 능가하는 0.84의 ROC-AUC로 환각을 식별할 수 있음\n\n요점은 작은 모델을 간과하지 말아야 한다는 것\n\n모든 문제에 거대한 모델을 적용하기는 쉽지만 약간의 창의성과 실험으로 우리는 종종 더 효율적인 솔루션을 찾을 수 있음\n\n새로운 기술은 새로운 가능성을 제공하지만 훌륭한 제품을 만드는 원칙은 영원함\n\n따라서 처음으로 새로운 문제를 해결하더라도 제품 설계에 대해 바퀴를 다시 발명할 필요는 없음\n\n견고한 제품 기본에 LLM 애플리케이션 개발을 기반으로 함으로써 얻을 수 있는 것이 많음\n\n이를 통해 우리가 서비스하는 사람들에게 실제 가치를 제공할 수 있음\n\n디자이너를 두면 제품을 어떻게 구축하고 사용자에게 제시할 수 있는지 이해하고 깊이 생각하게 됨\n\n때로는 디자이너를 사물을 예쁘게 만드는 사람으로 고정 관념을 가지기도 함\n\n그러나 사용자 인터페이스뿐만 아니라 기존 규칙과 패러다임을 깨더라도 사용자 경험을 어떻게 개선할 수 있는지 재고함\n\n디자이너는 사용자의 요구 사항을 다양한 형태로 재구성하는 데 특히 재능이 있음\n\n이러한 형태 중 일부는 다른 형태보다 해결하기가 더 쉬우므로 AI 솔루션에 더 많거나 적은 기회를 제공할 수 있음\n\n다른 많은 제품과 마찬가지로 AI 제품 구축은 제품을 구동하는 기술이 아니라 수행할 작업을 중심으로 이루어져야 함\n\n다음과 같은 질문을 스스로에게 묻는 데 초점을 맞출 것\n\n\"사용자가 이 제품에 요청하는 작업은 무엇인가? 그 작업이 챗봇이 잘할 만한 일인가? 자동 완성은 어떤가? 어쩌면 다른 것일 수도 있다!\"\n\n기존 설계 패턴과 그것이 수행할 작업과 어떤 관련이 있는지 고려할 것\n\n이것들은 디자이너가 팀의 역량에 더하는 귀중한 자산임\n\n품질 좋은 주석을 얻는 한 가지 방법은 사용자 경험(UX)에 Human-in-the-Loop(HITL)를 통합하는 것\n\n사용자가 쉽게 피드백과 수정 사항을 제공할 수 있도록 하면 즉각적인 출력을 개선하고 모델 개선에 유용한 데이터를 수집할 수 있음\n\n사용자가 제품을 업로드하고 분류하는 전자상거래 플랫폼을 상상해 보자\n\n사용자가 수동으로 올바른 제품 범주를 선택하고, LLM이 주기적으로 새 제품을 확인하고 백엔드에서 잘못된 분류를 수정\n\n사용자는 범주를 전혀 선택하지 않고, LLM이 주기적으로 백엔드에서 제품을 분류(잠재적 오류 포함)\n\nLLM이 실시간으로 제품 범주를 제안하고, 사용자가 필요에 따라 검증 및 업데이트 가능\n\n세 가지 접근 방식 모두 LLM을 포함하지만 매우 다른 UX를 제공함\n\n첫 번째 접근 방식은 초기 부담을 사용자에게 지우고 LLM이 사후 처리 검사 역할을 함\n\n두 번째 접근 방식은 사용자의 노력이 전혀 필요하지 않지만 투명성이나 제어권을 제공하지 않음\n\nLLM이 사전에 범주를 제안함으로써 사용자의 인지 부하를 줄이고 제품을 분류하기 위해 우리의 분류법을 배울 필요가 없음\n\n동시에 사용자가 제안을 검토하고 편집할 수 있도록 함으로써 제품 분류 방식에 대한 최종 결정권을 사용자의 손에 단단히 쥐어줌\n\n보너스로 세 번째 접근 방식은 모델 개선을 위한 자연스러운 피드백 루프를 만듦\n\n좋은 제안은 수락되고(긍정 레이블) 나쁜 제안은 업데이트됨(부정 후 긍정 레이블)\n\n제안, 사용자 검증 및 데이터 수집의 이 패턴은 여러 애플리케이션에서 일반적으로 볼 수 있음\n\n코딩 어시스턴트: 사용자가 제안을 수락(강한 긍정), 수락 및 조정(긍정) 또는 무시(부정)할 수 있음\n\nMidjourney: 사용자가 이미지를 업스케일하고 다운로드(강한 긍정)하거나, 이미지를 변경(긍정)하거나, 새로운 이미지 세트를 생성(부정)할 수 있음\n\n챗봇: 사용자가 응답에 대해 좋아요(긍정) 또는 싫어요(부정)를 제공하거나, 응답이 정말 나쁜 경우 응답을 다시 생성(강한 부정)하도록 선택할 수 있음\n\n명시적 피드백은 사용자가 제품의 요청에 응답하여 제공하는 정보\n\n암시적 피드백은 사용자가 의도적으로 피드백을 제공할 필요 없이 사용자 상호 작용에서 배우는 정보\n\n코딩 어시스턴트와 Midjourney는 암시적 피드백의 예이고 좋아요와 싫어요는 명시적 피드백\n\n코딩 어시스턴트와 Midjourney처럼 UX를 잘 설계하면 제품과 모델을 개선하기 위한 많은 암시적 피드백을 수집할 수 있음\n\n무자비하게 요구사항 계층(Hierarchy)의 우선순위 지정하기\n\n데모를 프로덕션에 배치하는 것에 대해 생각할 때, 다음에 대한 요구 사항을 고려해야 함\n\n신뢰성: 99.9% 가동 시간, 구조화된 출력 준수\n\n무해성: 공격적이거나 NSFW 또는 기타 유해한 콘텐츠를 생성하지 않음\n\n사실적 일관성: 제공된 맥락에 충실하고 사실을 왜곡하지 않음\n\n확장성: 지연 시간 SLA, 지원되는 처리량\n\n기타: 보안, 개인 정보 보호, 공정성, GDPR, DMA 등\n\n이러한 모든 요구 사항을 한 번에 해결하려고 하면 아무것도 출시할 수 없음\n\n이는 제품이 작동하지 않거나 실행 가능하지 않을 수 있는 타협할 수 없는 사항(예: 신뢰성, 무해성)이 무엇인지 명확히 하는 것을 의미함\n\nMVP(Minimum Lovable Product) 제품을 식별하는 것이 중요함\n\n첫 번째 버전이 완벽하지 않을 것이라는 점을 받아들이고 출시하고 반복해야 함\n\n언어 모델과 애플리케이션의 검토 수준을 결정할 때는 사용 사례와 대상을 고려해야 함\n\n의료 또는 금융 조언을 제공하는 고객 대면 챗봇의 경우 안전성과 정확성에 대해 매우 높은 기준이 필요함\n\n실수나 잘못된 출력은 실제 피해를 일으키고 신뢰를 잃을 수 있음\n\n그러나 추천 시스템과 같은 덜 중요한 애플리케이션이나 콘텐츠 분류 또는 요약과 같은 내부 대면 애플리케이션의 경우 지나치게 엄격한 요구 사항은 많은 가치를 추가하지 않고 진전을 늦출 뿐임\n\n이는 많은 회사가 외부 애플리케이션에 비해 내부 LLM 애플리케이션으로 더 빠르게 움직이고 있다는 최근 a16z 보고서와 일치함\n\n내부 생산성을 위해 AI를 실험함으로써 조직은 더 통제된 환경에서 위험을 관리하는 방법을 배우면서 가치를 포착하기 시작할 수 있음\n\n그런 다음 자신감이 생기면 고객 대면 사용 사례로 확장할 수 있음\n\n어떤 직무도 정의하기 쉽지 않지만, 이 새로운 영역에서 업무에 대한 직무 기술서를 작성하는 것은 다른 것보다 더 어려움\n\n교차하는 직책에 대한 벤 다이어그램이나 직무 기술에 대한 제안은 생략하겠음\n\n그러나 새로운 역할인 AI 엔지니어의 존재를 인정하고 그 역할에 대해 논의할 것임\n\n중요한 것은 나머지 팀과 책임이 어떻게 할당되어야 하는지에 대해 논의할 것임\n\nLLM과 같은 새로운 패러다임에 직면했을 때 소프트웨어 엔지니어는 도구를 선호하는 경향이 있음\n\n그 결과 도구가 해결하려고 했던 문제와 프로세스를 간과하게 됨\n\n이렇게 하면서 많은 엔지니어는 우발적 복잡성을 가정하게 되는데, 이는 팀의 장기적인 생산성에 부정적인 결과를 초래함\n\n예를 들어 이 글 은 특정 도구가 대규모 언어 모델에 대한 프롬프트를 자동으로 생성할 수 있는 방법에 대해 설명함\n\n문제 해결 방법론이나 프로세스를 먼저 이해하지 않고 이러한 도구를 사용하는 엔지니어는 결국 불필요한 기술 부채를 떠안게 된다고 주장함(IMHO 정당하게)\n\n우발적 복잡성 외에도 도구는 종종 불충분하게 지정됨\n\n예를 들어 유해성, 간결성, 어조 등에 대한 일반 평가기와 함께 \"LLM 평가 도구 상자\"를 제공하는 LLM 평가 도구 산업이 성장하고 있음\n\n많은 팀이 자신의 도메인의 특정 실패 모드에 대해 비판적으로 생각하지 않고 이러한 도구를 채택하는 것을 봄\n\n이와 대조적으로 EvalGen은 사용자를 기준 지정, 데이터 레이블링, 평가 확인 등 각 단계에 깊이 참여시켜 도메인별 평가를 생성하는 프로세스를 사용자에게 가르치는 데 중점을 둠\n\n소프트웨어는 사용자를 다음과 같은 워크플로우로 안내함\n\nEvalGen이 안내하는 LLM 평가 제작의 모범 사례\n\n도메인별 테스트 정의(프롬프트에서 자동으로 부트스트랩됨)\n\n코드 또는 LLM-as-a-Judge로 어설션으로 정의됨\n\n테스트가 지정된 기준을 포착하는지 사용자가 확인할 수 있도록 테스트를 인간의 판단과 일치시키는 것의 중요성\n\n시스템(프롬프트 등)이 변경됨에 따라 테스트 반복\n\nEvalGen은 개발자에게 평가 구축 프로세스에 대한 멘탈 모델을 제공하지만 특정 도구에 고정하지는 않음\n\nAI 엔지니어에게 이러한 맥락을 제공한 후에는 종종 더 간단한 도구를 선택하거나 자체 도구를 구축하기로 결정한다는 것을 발견함\n\n프롬프트 작성 및 평가 이외에 LLM의 구성 요소가 너무 많아 여기에 모두 나열할 수 없음\n\n그러나 AI 엔지니어가 도구를 채택하기 전에 프로세스를 이해하려고 노력하는 것이 중요함\n\nA/B 테스트, 무작위 대조 시험뿐만 아니라 시스템의 가능한 가장 작은 구성 요소를 수정하고 오프라인 평가를 수행하는 빈번한 시도를 의미함\n\n모두가 평가에 열광하는 이유는 실제로 신뢰성과 자신감에 관한 것이 아니라 실험을 가능하게 하는 것임!\n\n평가가 더 좋을수록 실험을 더 빨리 반복할 수 있고, 따라서 시스템의 최상의 버전으로 더 빨리 수렴할 수 있음\n\n실험이 매우 저렴해졌기 때문에 동일한 문제를 해결하기 위해 다양한 접근 방식을 시도하는 것이 일반적임\n\n데이터 수집 및 모델 훈련의 높은 비용은 최소화됨\n\n프롬프트 엔지니어링 비용은 인간의 시간보다 조금 더 들음\n\n모든 사람이 프롬프트 엔지니어링의 기본을 배울 수 있도록 팀을 배치할 것\n\n이는 모든 사람이 실험하도록 장려하고 조직 전체에서 다양한 아이디어로 이어짐\n\n탐색을 위해서만 실험하지 말고 활용을 위해서도 실험을 사용할 것!\n\n팀의 다른 사람이 다른 방식으로 접근하는 것을 고려해 볼 것\n\nChain-of-Thought나 Few-Shot과 같은 프롬프트 기술을 조사하여 품질을 높일 것\n\n그렇다면 재구축하거나 개선할 수 있는 것을 구매할 것\n\n제품/프로젝트 기획 중에는 평가 구축 및 여러 실험 수행을 위한 시간을 따로 할애할 것\n\n엔지니어링 제품에 대한 제품 사양을 생각해 보고, 여기에 평가에 대한 명확한 기준을 추가할 것\n\n로드맵 작성 시 실험에 필요한 시간을 과소평가하지 말 것\n\n프로덕션 승인을 받기 전에 여러 번의 개발 및 평가 반복을 예상할 것\n\n모든 사람이 새로운 AI 기술을 사용할 수 있도록 권한 부여\n\n생성형 AI의 채택이 증가함에 따라 전문가뿐만 아니라 전체 팀이 이 새로운 기술을 이해하고 사용할 수 있다고 느끼기를 원함\n\nLLM이 어떻게 작동하는지(예: 지연 시간, 실패 모드, UX)에 대한 직관을 개발하는 더 좋은 방법은 없음\n\n파이프라인의 성능을 향상시키기 위해 코딩 방법을 알 필요가 없으며, 모든 사람이 프롬프트 엔지니어링 및 평가를 통해 기여할 수 있음\n\nn-shot 프롬프팅 및 CoT와 같은 기술이 모델을 원하는 출력 방향으로 조건화하는 데 도움이 되는 프롬프트 엔지니어링의 기초부터 시작할 수 있음\n\n지식을 가진 사람들은 LLM이 본질적으로 자기회귀적이라는 점과 같은 보다 기술적인 측면에 대해서도 교육할 수 있음\n\n즉, 입력 토큰은 병렬로 처리되지만 출력 토큰은 순차적으로 생성됨\n\n따라서 지연 시간은 입력 길이보다 출력 길이의 함수임\n\n이는 UX를 설계하고 성능 기대치를 설정할 때 주요 고려 사항임\n\n실험과 탐색을 위한 실습 기회를 제공할 수도 있음\n\n전체 팀이 며칠 동안 추측성 프로젝트를 해킹하는 데 시간을 보내는 것이 비싸 보일 수 있지만, 그 결과는 당신을 놀라게 할 수 있음\n\n해커톤을 통해 3년 로드맵을 1년 안에 거의 완료한 팀이 있음\n\n또 다른 팀은 해커톤을 통해 LLM 덕분에 이제 가능해진 패러다임을 전환하는 UX로 이어졌으며, 이제 올해와 그 이후의 우선 순위가 되었음\n\n\"AI 엔지니어링이 모든 것\"이라는 함정에 빠지지 말 것\n\n새로운 직책이 생겨날 때 이러한 역할과 관련된 능력을 과대평가하는 경향이 초기에 있음\n\n이는 종종 이러한 직업의 실제 범위가 명확해짐에 따라 고통스러운 수정으로 이어짐\n\n이 분야의 신참자와 채용 관리자는 과장된 주장을 하거나 과도한 기대를 할 수 있음\n\n지난 10년 동안의 주목할 만한 예는 다음과 같음\n\n데이터 과학자: \"모든 소프트웨어 엔지니어보다 통계학을 더 잘하고 모든 통계학자보다 소프트웨어 엔지니어링을 더 잘하는 사람\"\n\n머신러닝 엔지니어(MLE): 머신러닝에 대한 소프트웨어 엔지니어링 중심의 관점\n\n처음에는 많은 사람들이 데이터 기반 프로젝트에는 데이터 과학자만으로 충분하다고 가정함\n\n그러나 데이터 과학자는 데이터 제품을 효과적으로 개발하고 배포하기 위해 소프트웨어 및 데이터 엔지니어와 협력해야 한다는 것이 분명해짐\n\n이 오해는 AI 엔지니어라는 새로운 역할에서도 다시 나타났으며, 일부 팀은 AI 엔지니어가 필요한 전부라고 믿음\n\n실제로 머신러닝 또는 AI 제품을 구축하려면 광범위한 전문 역할이 필요함\n\n우리는 12개 이상의 회사와 AI 제품에 대해 상담했으며, 그들이 \"AI 엔지니어링이 필요한 전부\"라는 믿음의 함정에 빠지는 것을 일관되게 관찰함\n\n그 결과 제품 구축에 필요한 중요한 측면을 간과하면서 제품이 데모 이상으로 확장하는 데 어려움을 겪는 경우가 많음\n\n예를 들어 평가 및 측정은 Vibe 체크 이상으로 제품을 확장하는 데 중요함\n\n효과적인 평가를 위한 기술은 전통적으로 머신러닝 엔지니어에게서 볼 수 있는 강점 중 일부와 일치함\n\nAI 엔지니어로만 구성된 팀은 이러한 기술이 부족할 가능성이 높음\n\n공동 저자인 Hamel Husain은 데이터 편향 감지 및 도메인 특정 평가 설계와 관련된 최근 작업에서 이러한 기술의 중요성을 설명함\n\nAI 제품 구축 여정에서 필요한 역할 유형 및 시기\n\nAI 엔지니어가 포함될 수 있지만 반드시 필요한 것은 아님\n\nAI 엔지니어는 제품(UX, 배관 등)을 프로토타이핑하고 신속하게 반복하는 데 유용함\n\n다음으로 시스템을 계측하고 데이터를 수집하여 올바른 기반을 만들 것\n\n데이터 유형과 규모에 따라 플랫폼 및/또는 데이터 엔지니어가 필요할 수 있음\n\n또한 문제를 디버깅하기 위해 이 데이터를 쿼리하고 분석하는 시스템이 있어야 함\n\n기본 사항에는 지표 설계, 평가 시스템 구축, 실험 실행, RAG 검색 최적화, 확률적 시스템 디버깅 등의 단계가 포함됨\n\nMLE는 이 분야에 매우 능숙함(물론 AI 엔지니어도 습득할 수 있음)\n\n선행 단계를 완료하지 않은 경우 MLE를 고용하는 것은 보통 타당하지 않음\n\n작은 회사에서는 이상적으로 창업팀이 이 역할을 해야 하며, 큰 회사에서는 제품 관리자가 이 역할을 할 수 있음\n\n역할의 진행 및 타이밍을 인식하는 것이 중요함\n\n잘못된 시기에 사람들을 고용하거나(예: MLE를 너무 일찍 고용) 잘못된 순서로 구축하는 것은 시간과 비용 낭비이며 이직을 야기함\n\n또한 1-2단계에서 MLE와 정기적으로 체크인(그러나 정규직으로 고용하지는 않음)하면 회사가 올바른 기반을 구축하는 데 도움이 됨\n\n[전략: LLM을 활용한 구축에서 뒤처지지 않는 방법]\n\n성공적인 제품 개발을 위해서는 무작정 프로토타입을 만들거나 최신 모델이나 트렌드를 따라가기보다는 신중한 기획과 우선순위 설정이 필요함\n\nAI 제품 개발 시 직접 개발할 것인지 구매할 것인지 등의 주요 트레이드오프를 검토해야 함\n\n초기 LLM 애플리케이션 개발을 위한 \"플레이북\"을 제시함\n\n훌륭한 제품이 되려면 단순히 다른 사람의 API를 얇게 포장하는 것 이상이 되어야 함\n\n하지만 반대 방향의 실수는 더 큰 비용을 초래할 수 있음\n\n지난 해에는 명확한 제품 비전이나 목표 시장 없이 모델 학습과 커스터마이징에 막대한 벤처 자본이 쓰여졌음\n\n한 회사는 무려 60억 달러의 시리즈 A 투자를 받기도 함\n\n이 섹션에서는 즉시 자체 모델 학습을 시작하는 것이 왜 실수인지 설명하고, 자체 호스팅의 역할을 고려해 볼 것임\n\n처음부터 (거의) 다시 트레이닝 하는 것은 의미 없음\n\n대부분의 조직에게 처음부터 LLM을 프리트레이닝하는 것은 제품 개발에서 벗어난 비현실적인 일임\n\n머신러닝 인프라의 개발과 유지에는 많은 자원이 소요됨\n\n데이터 수집, 모델 학습과 평가, 배포 등이 포함됨\n\n제품-시장 적합성을 검증하는 단계라면 이러한 노력은 핵심 제품 개발에서 자원을 분산시킴\n\n컴퓨팅 자원, 데이터, 기술적 역량이 있다 해도 프리트레인된 LLM은 몇 달 안에 구식이 될 수 있음\n\n금융 업무에 특화된 LLM인 BloombergGPT는 363B 토큰으로 프리트레이닝되었음\n\nAI 엔지니어링 4명, ML 제품 및 연구 5명 등 9명의 전임 직원들의 엄청난 노력이 투입됨\n\n그럼에도 1년 내에 해당 업무에서 gpt-3.5-turbo와 gpt-4에 뒤쳐졌음\n\n이런 사례들은 대부분의 실제 애플리케이션에서 LLM을 처음부터 프리트레이닝하는 것이 자원의 최선의 활용법이 아님을 시사함\n\n대신 팀은 특정 요구사항에 맞춰 사용 가능한 가장 강력한 오픈소스 모델을 파인튜닝하는 것이 더 나음\n\nReplit의 코드 모델은 코드 생성과 이해에 특화되어 프리트레이닝된 훌륭한 사례임\n\n프리트레이닝으로 CodeLlama7b 등 더 큰 모델보다 우수한 성능을 보였음\n\n그러나 더 강력한 모델들이 출시됨에 따라 효용성 유지를 위해서는 지속적인 투자가 필요했음\n\n대부분의 조직에서 파인튜닝은 전략적 사고보다는 FOMO(Fear Of Missing Out, 놓칠 것에 대한 두려움)에 의해 주도됨\n\n조직은 \"단순한 래퍼\"라는 비난을 피하기 위해 너무 일찍 파인튜닝에 투자함\n\n실제로 파인튜닝은 다른 접근 방식으로는 충분하지 않다는 것을 확신시켜주는 많은 사례를 수집한 후에야 배포해야 할 중장비와 같음\n\n1년 전 많은 팀이 파인튜닝에 대해 기대감을 표했지만, 몇 안 되는 팀만이 제품-시장 적합성을 발견했고 대부분은 결정을 후회함\n\n파인튜닝을 할 거라면 기본 모델이 개선됨에 따라 반복해서 수행할 준비가 되어 있어야 함\n\n아래의 \"모델은 제품이 아님\"과 \"LLMOps 구축\"을 참조\n\n파인튜닝이 실제로 올바른 선택일 수 있는 경우\n\n기존 모델 학습에 사용된 대부분의 개방형 웹 규모 데이터셋에서 사용할 수 없는 데이터가 필요한 경우\n\n기존 모델로는 충분하지 않다는 것을 보여주는 MVP를 이미 구축한 경우\n\n그러나 주의해야 함: 훌륭한 학습 데이터를 모델 구축자가 쉽게 얻을 수 없다면 당신은 어디서 얻을 것인가?\n\nLLM 기반 애플리케이션은 과학 박람회 프로젝트가 아님\n\n전략적 목표와 경쟁 차별화에 대한 기여도에 상응하는 투자가 이루어져야 함\n\n추론 API로 시작하되, 셀프호스팅을 두려워하지 말 것\n\nLLM API를 사용하면 스타트업이 처음부터 자체 모델을 학습시키지 않고도 언어 모델링 기능을 쉽게 채택하고 통합할 수 있음\n\nAnthropic, OpenAI 등의 제공업체는 몇 줄의 코드만으로 제품에 인텔리전스를 부여할 수 있는 일반 API를 제공함\n\n이러한 서비스를 사용하면 노력을 줄이고 고객을 위한 가치 창출에 집중할 수 있어 아이디어를 검증하고 제품-시장 적합성을 더 빨리 반복할 수 있음\n\n그러나 데이터베이스와 마찬가지로 관리형 서비스는 규모와 요구사항이 증가함에 따라 모든 사용 사례에 적합하지 않음\n\n실제로 자체 호스팅은 의료 및 금융과 같은 규제 산업 또는 계약상 의무나 기밀 유지 요건에 의해 요구되는 대로 기밀/개인 데이터를 네트워크 외부로 보내지 않고 모델을 사용하는 유일한 방법일 수 있음\n\n또한 자체 호스팅은 추론 제공업체가 부과하는 속도 제한, 모델 사용 중단, 사용 제한 등의 제약을 우회함\n\n자체 호스팅은 모델에 대한 완전한 제어 권한을 제공하여 차별화되고 고품질의 시스템을 더 쉽게 구축할 수 있게 함\n\n마지막으로 자체 호스팅, 특히 파인튜닝은 대규모로 비용을 절감할 수 있음\n\n예를 들어 Buzzfeed는 오픈소스 LLM을 파인튜닝하여 비용을 80% 절감한 사례를 공유했음\n\n장기적으로 경쟁 우위를 유지하려면 모델을 넘어서 제품을 차별화할 수 있는 요소를 고려해야 함\n\n실행 속도가 중요하지만 그것이 유일한 장점이 되어서는 안 됨\n\n모델은 제품이 아님, 그 모델을 둘러싼 시스템이 제품임\n\n모델을 구축하지 않는 팀에게 혁신의 빠른 속도는 축복임\n\n컨텍스트 크기, 추론 능력, 가격 대비 가치 등의 향상을 추구하며 최신 모델로 마이그레이션하여 더 나은 제품을 만들 수 있기 때문\n\n종합하면 모델은 시스템에서 가장 지속성이 낮은 구성 요소일 가능성이 높음\n\n대신 지속적인 가치를 제공할 수 있는 부분에 노력을 집중해야 함\n\nEvals: 모델 전반에 걸쳐 작업 성능을 안정적으로 측정하기 위함\n\nGuardrails: 모델에 상관없이 원치 않는 출력을 방지하기 위함\n\nCaching: 모델을 완전히 피함으로써 지연 시간과 비용을 줄이기 위함\n\nData flywheel: 위의 모든 것의 반복적 개선을 추진하기 위함\n\n이러한 구성 요소는 원시 모델 기능보다 더 두꺼운 제품 품질의 해자를 만듦\n\n그러나 애플리케이션 계층에서 구축하는 것이 위험이 없다는 의미는 아님\n\nOpenAI나 다른 모델 제공업체가 실행 가능한 엔터프라이즈 소프트웨어를 제공하려면 가위로 잘라내야 할 부분에 가위질하지 말 것\n\n예를 들어 일부 팀은 독점 모델에서 구조화된 출력을 검증하기 위한 맞춤형 도구를 구축하는 데 투자했음\n\n여기에 최소한의 투자는 중요하지만 깊이 투자하는 것은 시간을 잘 활용하는 것이 아님\n\nOpenAI는 함수 호출을 요청할 때 유효한 함수 호출을 받을 수 있도록 해야 함. 모든 고객이 원하기 때문\n\n여기에 \"전략적 미루기\"를 적용하고, 절대적으로 필요한 것을 구축하고, 제공업체의 기능 확장을 기다릴 것\n\n모든 사람을 위한 모든 것이 되려고 하는 제품을 만드는 것은 평범함의 레시피임\n\n설득력 있는 제품을 만들기 위해 기업은 사용자가 계속 돌아오게 하는 끈적거리는 경험을 구축하는 데 전문화해야 함\n\n사용자가 묻는 모든 질문에 답하는 것을 목표로 하는 일반적인 RAG 시스템을 고려해 보자\n\n전문화가 부족하다는 것은 시스템이 최신 정보에 우선순위를 두거나, 도메인 특화 형식을 구문 분석하거나, 특정 작업의 뉘앙스를 이해할 수 없다는 것을 의미함\n\n그 결과 사용자는 얕고 신뢰할 수 없는 경험을 하게 되어 요구사항을 충족시키지 못하고 이탈하게 됨\n\n이를 해결하기 위해 특정 도메인과 사용 사례에 집중해야 함\n\n이렇게 하면 사용자에게 공감을 주는 도메인 특화 도구를 만들 수 있음\n\n전문화를 통해 시스템의 기능과 한계를 솔직하게 알릴 수 있음\n\n시스템이 할 수 있는 것과 할 수 없는 것에 대해 투명하게 공개하는 것은 자기 인식을 보여주고, 사용자가 어디에 가장 많은 가치를 더할 수 있는지 이해하는 데 도움이 되며, 결과적으로 출력에 대한 신뢰와 확신을 구축함\n\nLLMOps를 만들되, 적절한 이유를 가질 것 : 빠른 반복\n\nDevOps는 근본적으로 재현 가능한 워크플로우나 왼쪽 이동 또는 두 개의 피자 팀에 권한을 부여하는 것이 아님. YAML 파일을 작성하는 것은 더더욱 아님\n\nDevOps는 작업과 그 결과 사이의 피드백 주기를 단축하여 오류 대신 개선 사항이 축적되도록 하는 것임\n\n그 뿌리는 린 스타트업 운동을 통해 린 제조와 토요타 생산 시스템으로 거슬러 올라가며, 싱글 미닛 다이 교환과 카이젠을 강조함\n\nMLOps는 DevOps의 형태를 ML에 적용했음\n\n재현 가능한 실험과 모델 구축자가 배포할 수 있도록 권한을 부여하는 올인원 도구 제품군이 있음. YAML 파일도 많음\n\n그러나 업계로서 MLOps는 DevOps의 기능을 채택하지 않았음. 모델과 프로덕션에서의 추론 및 상호 작용 사이의 피드백 갭을 줄이지 않았음\n\n다행히도 LLMOps 분야는 프롬프트 관리와 같은 사소한 문제에서 벗어나 반복을 방해하는 어려운 문제인 프로덕션 모니터링과 평가로 연결되는 지속적인 개선으로 방향을 전환했음\n\n이미 채팅 및 코딩 모델에 대한 중립적이고 크라우드소싱된 평가를 위한 대화형 아레나가 있음. 집단적이고 반복적인 개선의 외부 루프임\n\nLangSmith, Log10, LangFuse, W&B Weave, HoneyHive 등의 도구는 프로덕션에서 시스템 결과에 대한 데이터를 수집하고 정리할 뿐만 아니라 개발과 깊이 통합하여 해당 시스템을 개선하는 데 활용할 것을 약속함. 이러한 도구를 수용하거나 자체적으로 구축하라\n\n구매할 수 있는 LLM 기능을 만들지 말 것\n\n대부분의 성공적인 비즈니스는 LLM 비즈니스가 아님. 동시에 대부분의 비즈니스에는 LLM으로 개선할 기회가 있음\n\n이 두 가지 관찰은 종종 리더를 오도하여 비용은 늘리고 품질은 떨어뜨리면서 LLM으로 시스템을 성급하게 개조하고 인조의 허영심 강한 \"AI\" 기능으로 출시하게 만듦. 지금은 두려워하는 반짝이 아이콘이 완성됨\n\n더 나은 방법이 있음: 제품 목표에 진정으로 부합하고 핵심 운영을 강화하는 LLM 애플리케이션에 집중할 것\n\n팀의 시간을 낭비하는 몇 가지 잘못된 시도를 고려해 보자\n\n비즈니스를 위한 맞춤형 text-to-SQL 기능 구축\n\n위의 사항들이 LLM 애플리케이션의 헬로 월드이지만 제품 회사가 직접 구축하는 것은 이치에 맞지 않음\n\n이는 많은 비즈니스에 공통된 일반적인 문제로 유망한 데모와 신뢰할 수 있는 구성 요소 사이의 격차가 크며 소프트웨어 회사의 관례적 영역임\n\n현재 Y Combinator 배치에서 대규모로 해결하고 있는 일반적인 문제에 귀중한 R&D 자원을 투자하는 것은 낭비임\n\n이것이 진부한 비즈니스 조언처럼 들린다면 현재 과대 광고 물결의 들뜬 흥분 속에서 \"LLM\"이라는 것을 최첨단의 차별화된 것으로 오해하기 쉽고 이미 낡아빠진 애플리케이션을 놓치기 쉽기 때문임\n\nAI를 루프안에 넣고, 사람을 중심에 둘 것\n\n현재 LLM 기반 애플리케이션은 취약함. 엄청난 양의 안전 조치와 방어적 엔지니어링이 필요하지만 여전히 예측하기 어려움. 게다가 엄격하게 범위가 지정되면 이러한 애플리케이션은 엄청나게 유용할 수 있음. 이는 LLM이 사용자 워크플로를 가속화하는 훌륭한 도구가 된다는 것을 의미함\n\nLLM 기반 애플리케이션이 워크플로를 완전히 대체하거나 직무 기능을 대신하는 것을 상상하고 싶을 수 있지만, 오늘날 가장 효과적인 패러다임은 인간-컴퓨터 켄타우로스(Centaur chess)임\n\n유능한 인간이 자신의 빠른 활용을 위해 조정된 LLM 기능과 결합하면 작업을 수행하는 생산성과 행복감이 크게 향상될 수 있음\n\nLLM의 대표적인 애플리케이션 중 하나인 GitHub CoPilot은 이러한 워크플로의 힘을 입증했음\n\n\"전반적으로 개발자들은 GitHub Copilot과 GitHub Copilot Chat을 사용할 때 코딩이 더 쉽고, 오류가 적으며, 가독성이 높고, 재사용성이 높으며, 간결하고, 유지 관리가 용이하며, 탄력적이라고 느꼈다고 말했습니다.\" - Mario Rodriguez, GitHub\n\n오랫동안 ML 작업을 해온 사람들은 \"human-in-the-loop\"라는 아이디어에 빠르게 도달할 수 있지만 그렇게 서두르지 말 것\n\nHITL 머신러닝은 ML 모델이 예측대로 동작하도록 보장하는 인간 전문가에 기반한 패러다임임\n\n여기서 제안하는 것은 관련되기는 하지만 더 미묘한 것임. LLM 기반 시스템은 오늘날 대부분의 워크플로의 주요 동력이 되어서는 안 되며, 단순히 자원이 되어야 함\n\n인간을 중심에 두고 LLM이 어떻게 워크플로를 지원할 수 있는지 묻는 것은 제품 및 설계 결정에 상당히 다른 영향을 미침\n\n궁극적으로 LLM에 모든 책임을 신속하게 아웃소싱하려는 경쟁업체와는 다른 제품, 즉 더 나은 제품, 더 유용하고 덜 위험한 제품을 만들게 될 것임\n\n전략 3. 프롬프팅, Eval, 데이터 수집으로 시작하기\n\n이전 섹션에서는 기술과 조언의 화력을 쏟아부었음. 받아들이기에 많은 양임. 유용한 조언의 최소 집합을 고려해 보자.\n\n팀이 LLM 제품을 만들고 싶다면 어디서부터 시작해야 할까?\n\n지난 1년 동안 성공적인 LLM 애플리케이션은 일관된 궤적을 따른다는 것을 확신할 만큼 충분히 봐왔음. 이 섹션에서는 이 기본적인 \"시작하기\" 플레이북을 살펴볼 것임\n\n핵심 아이디어는 간단하게 시작하고 필요에 따라 복잡성을 추가하는 것임\n\nRule of Thumb : 각 수준의 정교함은 일반적으로 이전 단계보다 최소한 한 자릿수 이상의 노력이 필요하다는 것임. 이를 염두에 두고...\n\n이전에 전술 섹션에서 논의한 모든 기술을 사용할 것\n\nChain-of-thought, n-shot 예제, 구조화된 입출력은 거의 항상 좋은 아이디어임\n\n약한 모델에서 성능을 짜내기 전에 가장 성능이 높은 모델로 프로토타입을 만들 것\n\n프롬프트 엔지니어링으로 원하는 성능 수준을 달성할 수 없는 경우에만 파인튜닝을 고려해야 함\n\n독점 모델 사용을 차단하고 자체 호스팅을 요구하는 비기능적 요구사항(예: 데이터 프라이버시, 완전한 제어, 비용)이 있는 경우 더 자주 발생할 것임\n\n동일한 프라이버시 요구사항이 파인튜닝을 위해 사용자 데이터 사용을 차단하지 않도록 주의할 것\n\n막 시작하는 팀도 평가(evals)가 필요함. 그렇지 않으면 프롬프트 엔지니어링이 충분한지 또는 파인튜닝된 모델이 기본 모델을 대체할 준비가 되었는지 알 수 없음\n\n효과적인 평가는 작업에 특화되어 있으며 의도한 사용 사례를 반영함\n\n권장하는 첫 번째 수준의 평가는 단위 테스트임\n\n이러한 간단한 어설션은 알려졌거나 가설로 설정된 실패 모드를 감지하고 초기 설계 결정을 내리는 데 도움이 됨\n\n분류, 요약 등을 위한 다른 작업별 평가도 참조할 것\n\n단위 테스트와 모델 기반 평가는 유용하지만 인간 평가의 필요성을 대체하지는 않음\n\n사람들이 모델/제품을 사용하고 피드백을 제공하도록 할 것\n\n이는 실제 성능과 결함률을 측정하는 동시에 향후 모델을 파인튜닝하는 데 사용할 수 있는 고품질의 주석 데이터를 수집한다는 이중 목적을 수행함\n\n이는 시간이 지남에 따라 복리로 작용하는 긍정적인 피드백 루프 또는 데이터 플라이휠을 만듦\n\n모델 성능을 평가하거나 결함을 찾기 위한 인간 평가\n\n주석 데이터를 사용하여 모델을 파인튜닝하거나 프롬프트를 업데이트\n\n예를 들어 LLM 생성 요약의 결함을 감사할 때 각 문장에 사실적 불일치, 무관함 또는 스타일 불량을 식별하는 세분화된 피드백 레이블을 지정할 수 있음\n\n그런 다음 이러한 사실적 불일치 주석을 사용하여 환각 분류기를 학습시키거나 관련성 주석을 사용하여 관련성 보상 모델을 학습시킬 수 있음\n\nLinkedIn은 환각, 책임감 있는 AI 위반, 일관성 등을 추정하기 위해 모델 기반 평가자를 사용한 성공 사례를 공유했음\n\n시간이 지남에 따라 가치가 증대되는 자산을 창출함으로써, 평가(evals) 구축을 단순한 운영 비용에서 전략적 투자로 전환하고, 그 과정에서 데이터 플라이휠을 구축\n\n전략 4. 저비용 인지의 고차원적 추세 (The high-level trend of low-cost cognition)\n\n1971년 Xerox PARC의 연구원들은 우리가 현재 살고 있는 네트워크로 연결된 개인용 컴퓨터의 세계를 예측했음\n\n그들은 이를 가능하게 한 기술(이더넷, 그래픽 렌더링, 마우스, 윈도우 등)의 발명에 중추적인 역할을 함으로써 그 미래를 탄생시키는 데 기여했음\n\n매우 유용하지만(예: 비디오 디스플레이) 아직 경제적이지 않은(비디오 디스플레이를 구동하기에 충분한 RAM이 수천 달러) 애플리케이션을 살펴봄\n\n그런 다음 해당 기술의 역사적 가격 추세(무어의 법칙과 유사)를 살펴보고 그 기술이 언제 경제적이 될지 예측함\n\nLLM 기술에 대해서도 같은 작업을 할 수 있음. 비록 달러당 트랜지스터 수만큼 깔끔한 것은 아니지만\n\n오랫동안 사용된 인기 있는 벤치마크(예: Massively-Multitask Language Understanding 데이터셋)와 일관된 입력 접근 방식(5-shot 프롬프팅)을 선택\n\n그런 다음 시간이 지남에 따라 이 벤치마크에서 다양한 성능 수준을 가진 언어 모델을 실행하는 비용을 비교\n\n고정 비용에 대해 능력이 빠르게 증가하고 있음. 고정된 능력 수준에 대해 비용이 빠르게 감소하고 있음\n\nOpenAI의 davinci 모델이 API로 출시된 이후 4년 동안, 100만 토큰(이 문서의 약 100개 사본) 규모에서 그 작업에 상응하는 성능을 가진 모델을 실행하는 비용은 $20에서 10센트 미만으로 떨어졌음. 반감기는 불과 6개월임\n\n유사하게 2024년 5월 기준 API 제공업체를 통하거나 자체적으로 Meta의 LLaMA 3 8B를 실행하는 비용은 토큰 100만 개당 20센트에 불과하며 ChatGPT를 가능하게 한 모델인 OpenAI의 text-davinci-003과 유사한 성능을 보임\n\n해당 모델은 2023년 11월 말 출시 당시에도 토큰 100만 개당 약 $20의 비용이 들었음. 불과 18개월 만에 두 자릿수 차이가 남. 무어의 법칙이 예측하는 단순한 두 배 증가와 동일한 기간임\n\n이제 매우 유용하지만(Park et al과 같은 생성적 비디오 게임 캐릭터 구동) 아직 경제적이지 않은(시간당 비용이 $625로 추정됨) LLM 애플리케이션을 고려해 보자\n\n해당 논문이 2023년 8월에 발표된 이후 비용은 시간당 약 $62.50로 한 자릿수 정도 떨어졌음\n\n9개월 후에는 시간당 $6.25로 떨어질 것으로 예상할 수 있음\n\n한편 팩맨이 1980년에 출시되었을 때 오늘날의 $1로 몇 분 또는 몇 십 분 동안 플레이할 수 있는 크레딧을 살 수 있었음. 시간당 6게임 또는 시간당 $6라고 부름\n\n이 냅킨 계산은 매력적인 LLM 강화 게임 경험이 2025년 경에는 경제적이 될 것임을 시사함\n\n이러한 추세는 새로운 것이며 불과 몇 년 되지 않았음. 그러나 앞으로 몇 년 동안 이 과정이 느려질 것이라고 기대할 만한 이유는 거의 없음\n\n매개변수당 ~20 토큰의 \"Chinchilla 비율\"을 넘어 스케일링하는 것과 같은 알고리즘과 데이터셋의 낮게 매달린 과일을 사용하더라도, 데이터 센터 내부와 실리콘 계층에서의 더 깊은 혁신과 투자는 그 격차를 메울 것임\n\n그리고 이것이 아마도 가장 중요한 전략적 사실일 것임\n\n오늘날 완전히 실현 불가능한 플로어 데모나 연구 논문이 몇 년 후에는 프리미엄 기능이 되고 그 직후에는 상품이 될 것임\n\n이를 염두에 두고 시스템과 조직을 구축해야 함\n\n[0에서 1로 가는 데모는 이제 충분함. 이제는 1에서 N으로 가는 제품을 만들 때]\n\nLLM 데모를 만드는 것은 정말 재미있음. 몇 줄의 코드, 벡터 데이터베이스, 신중하게 작성된 프롬프트로 \"마법\" 을 만들어냄\n\n지난 1년 동안 이 마법은 인터넷, 스마트폰, 심지어 인쇄술과 비교되었음\n\n안타깝게도 실제 소프트웨어 출시 작업을 해본 사람이라면 누구나 알고 있듯이, 통제된 환경에서 작동하는 데모와 대규모로 안정적으로 작동하는 제품 사이에는 엄청난 차이가 있음\n\n상상하고 데모를 만드는 것은 쉽지만 제품으로 만드는 것은 매우 어려운 문제들이 많음\n\n예를 들어 자율 주행: 자동차가 한 블록을 자율 주행하는 것은 쉽게 시연할 수 있지만, 이를 제품으로 만드는 데는 10년이 걸림 - Andrej Karpathy\n\n1988년 신경망으로 운전되는 첫 번째 자동차가 등장했음\n\n25년 후 Andrej Karpathy는 Waymo에서 첫 번째 데모 라이드를 했음\n\n그로부터 10년 후 회사는 무인 운전 허가를 받았음\n\n프로토타입에서 상용 제품으로 가기까지 35년 동안 엄격한 엔지니어링, 테스트, 개선, 규제 탐색이 이루어졌음\n\n산업계와 학계 전반에 걸쳐 지난 1년 동안의 기복을 관찰했음 : LLM 애플리케이션의 1년차 (Year 1 of N for LLM applications)\n\n평가, 프롬프트 엔지니어링, 가드레일과 같은 전술부터 운영 기술, 팀 구축, 내부적으로 구축할 기능 선택과 같은 전략적 관점에 이르기까지 우리가 배운 교훈이 2년차 이후에 도움이 되기를 바람\n\n이 흥미로운 새로운 기술을 함께 발전시켜 나가길 기대함\n\nAI 시대, 0→1 서비스에서 오픈보다 운영이 더 중요한 이유\n\nAI-Powered 기능을 구축하며 배운 것들\n\nLean Analytics, AI와 에이전트 시대에 맞춰 돌아보기\n\n인증 이메일 클릭후 다시 체크박스를 눌러주세요\n\ninthelife 2024-06-17 [-]\n\n내용이 좋아서, 두고두고 보려고 Mindmap으로 만들어 보았습니다 ^^;\n\nhttps://drive.google.com/file/d/…\n\n너무 좋은 글입니다!! 처음부터 끝까지 유용하게 곱씹어볼말들이 많습니다. 이렇게 주옥 같은 글을 번역해서 올려주셔서 감사합니다!!\n\n이제 스카이넷은 끝났어, 메가스터디가 온다.\n\nmy0075425 2024-06-11 [-]\n\n심리학 전공자가 Lazada의 데이터 사이언스VP가 된 방법\n\n와.. 엄청나게 자극이 되네요.. 소개 감사합니다\n\nhumblebee 2024-06-10 [-]\n\n통찰력과 경험이 생생하게 느끼져는 멋진 글이에요! 저와 팀에게 있어 큰 도움이 될것같습니다. 너무 잘 읽었습니다. 감사합니다 ☺️\n\n처음 오셨나요 사이트 이용법 FAQ About 긱배지 이용약관 개인정보 처리방침\n\n| Blog Lists RSS | Bookmarklet\n\nX (Twitter) Facebook | 긱뉴스봇 : Slack 잔디 Discord Teams Dooray! Google Chat Swit\n\n시작하기 이용법 FAQ About 긱배지 약관 개인정보", + "body": "GeekNews 최신글 예전글 쓰레드 댓글 Ask Show GN⁺ Weekly | 글등록\n\n75 P by xguru 2024-06-10 | ★ favorite | 댓글 9개\n\n대규모 언어 모델(LLM)을 사용한 개발이 흥미로운 시기임\n\n지난 1년 동안 LLM이 실제 애플리케이션에 \"충분히 좋은\" 수준이 되었으며, 매년 더 좋아지고 저렴해지고 있음\n\n소셜 미디어의 데모와 함께, 2025년까지 AI에 약 2000억 달러가 투자될 것으로 추정됨\n\n업체들의 API로 인해 LLM이 더 접근하기 쉬워져, ML 엔지니어와 과학자뿐만 아니라 모두가 제품에 인텔리젠스를 구축할 수 있게 됨\n\nAI로 구축하는 진입 장벽은 낮아졌지만, 데모 이상으로 효과적인 제품과 시스템을 만드는 것은 여전히 어려움\n\n우리는 지난 1년 동안 구축해 왔으며, 그 과정에서 많은 어려움을 발견했음\n\n우리의 실수를 피하고 더 빠르게 반복할 수 있도록 우리가 배운 내용을 공유하고자 함\n\nTactical(전술적) : 프롬프팅, RAG, 워크플로우 엔지니어링, 평가 및 모니터링을 위한 몇 가지 실천 사항\n\nLLM으로 구축하는 실무자나 주말 프로젝트를 진행하는 사람들을 위해 작성됨\n\nOperational(운영적) : 제품 출시의 조직적, 일상적 관심사와 효과적인 팀 구축 방법\n\n지속 가능하고 안정적으로 배포하려는 제품/기술 리더를 위한 내용\n\nStrategic(전략적) : \"PMF 전에 GPU 없음\", \"모델이 아닌 시스템에 집중\" 등의 의견을 담은 장기적이고 큰 그림 관점과 반복하는 방법\n\n이 가이드는 LLM을 사용하여 성공적인 제품을 구축하기 위한 실용적인 안내서가 되는 것을 목표로 함\n\n우리 자신의 경험에서 비롯되었으며 업계 전반의 사례를 제시함\n\n이 섹션에서는 새로 등장하는 LLM 스택의 핵심 구성 요소에 대한 모범 사례를 공유함\n\n품질과 신뢰성을 높이기 위한 프롬프팅 팁, 출력을 평가하기 위한 전략, 그라운딩을 개선하기 위한 검색 증강 생성 아이디어 등이 포함됨\n\n또한 휴먼 인 더 루프 워크플로우를 설계하는 방법도 탐구할 예정임\n\n새로운 애플리케이션을 개발할 때는 프롬프팅으로 시작할 것을 권장함\n\n프롬프팅의 중요성을 과소평가하거나 과대평가하기 쉬움\n\n올바른 프롬프팅 기술을 제대로 사용하면 매우 멀리 갈 수 있기 때문에 과소평가되는 경향이 있음\n\n프롬프트 기반 애플리케이션도 제대로 작동하려면 프롬프트 주변에 상당한 엔지니어링이 필요하기 때문에 과대평가되는 경향이 있음\n\n기본 프롬프팅 기술을 최대한 활용하는 데 집중\n\n다양한 모델과 작업에서 성능 향상에 지속적으로 도움이 되는 몇 가지 프롬프팅 기술이 있음\n\nN-shot 프롬프트 + 문맥 내 학습, 사고의 연쇄(Chain-of-Thought), 관련 리소스 제공 등임\n\nN-shot 프롬프트를 통한 문맥 내 학습의 아이디어는 LLM에게 작업을 시연하고 출력을 기대에 맞추는 몇 가지 예시를 제공하는 것임\n\nN이 너무 낮으면 모델이 해당 특정 예시에 과도하게 고정되어 일반화 능력이 저하될 수 있음\n\n경험적으로 N ≥ 5를 목표로 하고, 수십 개까지 사용하는 것을 두려워하지 말 것\n\n전체 입출력 쌍을 제공할 필요는 없으며, 많은 경우 원하는 출력의 예시로 충분함\n\n도구 사용을 지원하는 LLM을 사용하는 경우, N-shot 예시에서도 에이전트가 사용하기를 원하는 도구를 사용해야 함\n\n사고의 연쇄(Chain-of-Thought, CoT) 프롬프팅\n\nCoT 프롬프팅에서는 LLM이 최종 답변을 반환하기 전에 사고 과정을 설명하도록 장려함\n\nLLM에게 메모리에서 모든 것을 수행할 필요가 없도록 스케치패드를 제공하는 것으로 생각할 수 있음\n\n원래 접근 방식은 단순히 \"단계별로 생각해 보자\"라는 문구를 지침의 일부로 추가하는 것이었지만, CoT를 더 구체적으로 만드는 것이 도움이 된다는 것을 발견함\n\n1~2문장으로 구체성을 추가하면 환각 발생률이 상당히 감소하는 경우가 많음\n\n최근에는 이 기술이 믿는 만큼 강력한지에 대해 의문이 제기되고 있음\n\n또한 CoT가 사용될 때 추론 중에 정확히 어떤 일이 일어나는지에 대해 상당한 논쟁이 있음\n\n그럼에도 불구하고 가능한 경우 실험해 볼 만한 기술임\n\n관련 리소스를 제공하는 것은 모델의 지식 기반을 확장하고, 환각을 줄이며, 사용자의 신뢰를 높이는 강력한 메커니즘임\n\n검색 증강 생성(Retrieval Augmented Generation, RAG)을 통해 수행되는 경우가 많음\n\n모델에 응답에 직접 활용할 수 있는 텍스트 스니펫을 제공하는 것은 필수적인 기술임\n\n관련 리소스를 제공할 때는 단순히 포함하는 것으로는 충분하지 않음\n\n모델에게 리소스 사용을 우선시하고, 직접 참조하며, 때로는 리소스가 충분하지 않을 때 언급하도록 지시하는 것을 잊지 말아야 함\n\n이러한 것들은 에이전트 응답을 리소스 코퍼스에 \"Ground\"하는 데 도움이 됨\n\n구조화된 입력과 출력은 모델이 입력을 더 잘 이해하고 다운스트림 시스템과 안정적으로 통합할 수 있는 출력을 반환하는 데 도움이 됨\n\n입력에 직렬화 형식을 추가하면 컨텍스트의 토큰 간 관계, 특정 토큰에 대한 추가 메타데이터(유형 등) 또는 요청을 모델 학습 데이터의 유사한 예시와 관련시키는 데 도움이 될 수 있음\n\n예를 들어, 인터넷에서 SQL 작성에 대한 많은 질문은 SQL 스키마를 지정하는 것으로 시작함\n\n따라서 Text-to-SQL에 대한 효과적인 프롬프팅에는 구조화된 스키마 정의가 포함되어야 함\n\n구조화된 출력은 유사한 목적을 수행하지만, 시스템의 다운스트림 구성 요소로의 통합을 단순화함\n\nInstructor와 Outlines는 구조화된 출력에 잘 작동함\n\n(LLM API SDK를 가져오는 경우 Instructor를 사용하고, 자체 호스팅 모델에 Huggingface를 가져오는 경우 Outlines를 사용)\n\n구조화된 입력은 작업을 명확하게 표현하고 학습 데이터의 형식과 유사하므로 더 나은 출력 가능성을 높임\n\n구조화된 입력을 사용할 때는 각 LLM 제품군마다 선호하는 방식이 있다는 점에 유의해야 함\n\nClaude는 을 선호하는 반면 GPT는 Markdown과 JSON을 선호함\n\nXML을 사용하면 태그를 제공하여 Claude의 응답을 미리 채울 수도 있음\n\n작고 한 가지 일을 잘하는 프롬프트를 만들 것\n\n소프트웨어에서 흔한 안티 패턴/코드 스멜은 모든 것을 수행하는 단일 클래스나 함수인 \"God Object\"임\n\n몇 문장의 지침, 몇 가지 예시로 시작할 수 있음\n\n그러나 성능을 개선하고 더 많은 edge case를 처리하려고 하면서 복잡성이 증가함\n\n더 많은 지침, 다단계 추론, 수십 개의 예시 등이 추가됨\n\n결국 처음에는 단순했던 프롬프트가 2,000 토큰의 프랑켄슈타인이 되어버림\n\n게다가 더 일반적이고 직관적인 입력에 대한 성능은 오히려 저하됨\n\nGoDaddy는 이 문제를 LLM 구축에서 얻은 교훈 중 1위로 꼽음\n\n시스템과 코드를 단순하게 유지하려고 노력하는 것처럼, 프롬프트도 마찬가지여야 함\n\n회의 녹취록 요약기에 대해 단일 만능 프롬프트를 사용하는 대신, 다음과 같은 단계로 나눌 수 있음\n\n주요 결정 사항, 조치 항목 및 담당자를 구조화된 형식으로 추출\n\n추출된 세부 정보를 원본 녹취록과 비교하여 일관성 확인\n\n결과적으로 단일 프롬프트를 여러 개의 단순하고 집중적이며 이해하기 쉬운 프롬프트로 분할함\n\n이렇게 분할하면 이제 각 프롬프트를 개별적으로 반복하고 평가할 수 있음\n\n에이전트에 실제로 전송해야 하는 컨텍스트의 양에 대한 가정을 재고하고 도전해야 함\n\n미켈란젤로처럼 컨텍스트 조각상을 만들어 가는 것이 아니라, 불필요한 재료를 깎아내어 조각상을 드러내야 함\n\nRAG는 잠재적으로 관련된 대리석 블록을 모두 모으는 데 널리 사용되는 방법이지만, 필요한 것을 추출하기 위해 무엇을 하고 있는가?\n\n모델에 전송되는 최종 프롬프트를 가져와 모든 컨텍스트 구성, 메타 프롬프팅, RAG 결과와 함께 빈 페이지에 배치하고 읽어보는 것이 컨텍스트를 재고하는 데 도움이 된다는 것을 발견함\n\n이 방법을 사용하여 중복, 자가 모순적인 언어 및 형식이 잘못된 부분을 발견할 수 있음\n\nBag-of-docs 표현은 인간에게 도움이 되지 않으므로 에이전트에게 좋다고 가정하지 말아야 함\n\n컨텍스트의 각 부분 간의 관계를 강조하고 추출을 가능한 한 단순하게 만들 수 있도록 컨텍스트를 구성하는 방법을 신중하게 고려해야 함\n\n프롬프팅 외에도 LLM을 조정하는 또 다른 효과적인 방법은 프롬프트의 일부로 지식을 제공하는 것임\n\n이는 제공된 컨텍스트에 LLM을 ground시키며, 이는 문맥 내 학습에 사용됨\n\n이를 검색 증강 생성(Retrieval-Augmented Generation, RAG)이라고 함\n\n실무자들은 RAG가 지식을 제공하고 출력을 개선하는 데 효과적이며, 미세 조정에 비해 훨씬 적은 노력과 비용이 든다는 것을 발견함\n\nRAG는 검색된 문서의 관련성, 밀도 및 세부 정보만큼만 좋음\n\nRAG 출력의 품질은 검색된 문서의 품질에 따라 달라지며, 몇 가지 요소를 고려할 수 있음\n\n이는 일반적으로 평균 역순위(Mean Reciprocal Rank, MRR) 또는 정규화된 할인 누적 이득(Normalized Discounted Cumulative Gain, NDCG)과 같은 순위 지표로 정량화됨\n\nMRR은 시스템이 순위 목록에서 첫 번째 관련 결과를 얼마나 잘 배치하는지 평가하는 반면, NDCG는 모든 결과의 관련성과 위치를 고려함\n\n이들은 관련 문서를 더 높이, 관련 없는 문서를 더 낮게 순위를 매기는 시스템의 성능을 측정함\n\n예를 들어, 영화 리뷰 요약을 생성하기 위해 사용자 요약을 검색하는 경우, 특정 영화에 대한 리뷰를 더 높이 순위를 매기고 다른 영화에 대한 리뷰는 제외하는 것이 좋음\n\n전통적인 추천 시스템과 마찬가지로 검색된 항목의 순위는 LLM이 다운스트림 작업을 수행하는 방식에 상당한 영향을 미침\n\n영향을 측정하려면 검색된 항목을 섞은 상태에서 RAG 기반 작업을 실행해 보고, RAG 출력이 어떻게 수행되는지 확인할 것\n\n두 문서가 동일하게 관련된 경우, 더 간결하고 관련 없는 세부 정보가 적은 문서를 선호해야 함\n\n영화 예로 돌아가면, 영화 대본과 모든 사용자 리뷰를 광범위한 의미에서 관련이 있다고 간주할 수 있음\n\n그럼에도 불구하고 높은 평가를 받은 리뷰와 편집 리뷰는 정보 밀도가 더 높을 가능성이 있음\n\n마지막으로, 문서에 제공된 \"세부 정보 수준\"을 고려할 것\n\n자연어에서 SQL 쿼리를 생성하기 위한 RAG 시스템을 구축한다고 상상해 보자\n\n열 이름이 있는 테이블 스키마를 컨텍스트로 제공하는 것만으로도 충분할 수 있음\n\n그러나 열 설명과 일부 대표 값을 포함하면 어떨까?\n\n추가 세부 정보는 LLM이 테이블의 의미를 더 잘 이해하고 더 정확한 SQL을 생성하는 데 도움이 될 수 있음\n\n키워드 검색을 잊지 말고, 기준선과 하이브리드 검색에 사용할 것\n\nEmbedding 기반 RAG 데모가 널리 퍼져 있기 때문에 정보 검색 분야의 수십 년 간의 연구와 솔루션을 잊거나 간과하기 쉬움\n\n그럼에도 불구하고 임베딩은 의심할 여지 없이 강력한 도구이지만, 만능은 아님\n\n첫째, 임베딩은 높은 수준의 의미론적 유사성을 포착하는 데 탁월하지만, 사용자가 이름(예: Ilya), 두문자어(예: RAG) 또는 ID(예: claude-3-sonnet)를 검색할 때와 같이 더 구체적이고 키워드 기반의 쿼리에는 어려움을 겪을 수 있음\n\nBM25와 같은 키워드 기반 검색은 이를 위해 명시적으로 설계됨\n\n사용자들은 키워드 기반 검색을 오랫동안 사용해 왔기 때문에 당연한 것으로 여기고 있을 것이며, 검색하고자 하는 문서가 반환되지 않으면 좌절감을 느낄 수 있음\n\n둘째, 키워드 검색으로 문서가 검색된 이유를 이해하는 것이 더 직관적임\n\n쿼리와 일치하는 키워드를 확인할 수 있기 때문\n\n반면에 임베딩 기반 검색은 해석 가능성이 낮음\n\n셋째, 수십 년 동안 최적화되고 실전에서 검증된 Lucene이나 OpenSearch와 같은 시스템 덕분에 키워드 검색이 일반적으로 더 계산적으로 효율적임\n\n대부분의 경우 하이브리드 접근 방식이 가장 효과적\n\n명백한 일치 항목에는 키워드 매칭을 사용하고, 동의어, 상위어, 철자 오류 및 멀티모달(예: 이미지와 텍스트)에는 임베딩을 사용\n\nShortwave는 쿼리 재작성, 키워드 + 임베딩 검색, 랭킹 등 자신들의 RAG 파이프라인을 어떻게 구축했는지 공유 한바 있음\n\n새로운 지식에 대해서는 파인튜닝보다 RAG를 선호\n\nRAG와 파인튜닝 모두 새로운 정보를 LLM에 통합하고 특정 작업에 대한 성능을 향상시키는 데 사용될 수 있음\n\n최근 연구에 따르면 RAG가 더 우수할 수 있음\n\n한 연구에서는 RAG와 비지도 미세 조정(지속적 사전 학습이라고도 함)을 MMLU와 시사 문제의 하위 집합에서 평가하여 비교함\n\nRAG가 학습 중에 접한 지식과 완전히 새로운 지식 모두에 대해 미세 조정보다 지속적으로 더 우수한 성능을 보였음\n\n다른 논문에서는 농업 데이터 세트에 대해 RAG와 지도 미세 조정을 비교함\n\n마찬가지로 RAG의 성능 향상이 미세 조정보다 컸으며, 특히 GPT-4에서 두드러짐(논문의 표 20 참조)\n\n성능 향상 외에도 RAG는 여러 실용적인 장점이 있음\n\n첫째, 지속적인 사전 학습이나 미세 조정에 비해 검색 인덱스를 최신 상태로 유지하는 것이 더 쉽고 저렴함\n\n둘째, 검색 인덱스에 유해하거나 편향된 내용이 포함된 문제가 있는 문서가 있는 경우 문제가 있는 문서를 쉽게 삭제하거나 수정할 수 있음\n\n또한 RAG의 R은 문서를 검색하는 방법에 대해 더 세분화된 제어를 제공함\n\n예를 들어 여러 조직을 위해 RAG 시스템을 호스팅하는 경우, 검색 인덱스를 분할하여 각 조직이 자체 인덱스의 문서만 검색할 수 있도록 할 수 있음\n\n이렇게 하면 한 조직의 정보를 실수로 다른 조직에 노출하는 일이 없도록 할 수 있음\n\n장문 컨텍스트 모델이 RAG를 쓸모없게 만들지는 않을 것임\n\nGemini 1.5가 최대 1,000만 토큰 크기의 컨텍스트 윈도우를 제공함에 따라 일부에서는 RAG의 미래에 의문을 제기하기 시작함\n\n1,000만 토큰의 컨텍스트 윈도우는 기존 RAG 프레임워크 대부분을 불필요하게 만듦\n\n데이터를 컨텍스트에 넣고 평소처럼 모델과 대화하기만 하면 됨\n\n이는 대부분의 엔지니어링 노력이 RAG에 투입되는 스타트업, 에이전트, langchain 프로젝트에 어떤 영향을 줄지 상상해 보라\n\n한 문장으로 요약하면 1,000만 컨텍스트가 RAG를 죽인다는 것\n\n장문 컨텍스트가 여러 문서 분석이나 PDF와의 채팅 등의 사용 사례에 게임 체인저가 될 것이라는 점은 사실이지만, RAG의 종말에 대한 소문은 크게 과장됨\n\n첫째, 1,000만 토큰의 컨텍스트 윈도우가 있더라도 모델에 입력할 정보를 선택하는 방법이 여전히 필요함\n\n둘째, 좁은 바늘구멍 평가를 넘어서 모델이 그렇게 큰 컨텍스트에 대해 효과적으로 추론할 수 있다는 설득력 있는 데이터는 아직 보지 못함\n\n따라서 좋은 검색(및 랭킹) 없이는 주의를 분산시키는 정보로 모델을 압도하거나 심지어 완전히 무관한 정보로 컨텍스트 윈도우를 채울 위험이 있음\n\nTransformer의 추론 비용은 컨텍스트 길이에 따라 제곱(또는 공간과 시간 모두에서 선형)으로 증가함\n\n조직의 전체 Google Drive 내용을 읽을 수 있는 모델이 존재한다고 해서 각 질문에 답하기 전에 그렇게 하는 것이 좋은 생각은 아님\n\nRAM 사용 방식에 대한 비유를 고려해 보자\n\n수십 테라바이트에 달하는 RAM이 있는 컴퓨팅 인스턴스가 존재하지만, 여전히 디스크에서 읽고 쓰고 있음\n\n따라서 아직 RAG를 쓰레기통에 버리지 말 것\n\n이 패턴은 컨텍스트 윈도우의 크기가 커질수록 여전히 유용할 것임\n\nLLM에 프롬프트를 주는 것은 시작에 불과함\n\nLLM을 최대한 활용하려면 단일 프롬프트를 넘어 워크플로우를 수용해야 함\n\n예를 들어, 복잡한 단일 작업을 여러 개의 더 간단한 작업으로 어떻게 분할할 수 있을까?\n\n미세 조정이나 캐싱이 성능 향상과 지연/비용 감소에 도움이 되는 시점은 언제일까?\n\n이 섹션에서는 검증된 전략과 실제 사례를 공유하여 LLM 워크플로우를 최적화하고 구축하는 데 도움을 줌\n\n단계별, 다중 턴 \"Flow\"는 큰 성능 향상을 제공할 수 있음\n\n하나의 큰 프롬프트를 여러 개의 작은 프롬프트로 분해함으로써 더 나은 결과를 얻을 수 있다는 것을 이미 알고 있음\n\n단일 프롬프트에서 다단계 워크플로우로 전환함으로써 CodeContests에서 GPT-4 정확도(pass@5)를 19%에서 44%로 높임\n\n명확한 목표를 가진 작은 작업은 최상의 에이전트 또는 흐름 프롬프트를 만듦\n\n모든 에이전트 프롬프트가 구조화된 출력을 요청할 필요는 없지만, 구조화된 출력은 에이전트의 환경과의 상호 작용을 조정하는 시스템과의 인터페이스에 큰 도움이 됨\n\n가능한 한 엄격하게 지정된 명시적 계획 단계\n\n미리 정의된 계획 중에서 선택하는 것을 고려할 것\n\n원래 사용자 프롬프트를 에이전트 프롬프트로 다시 작성\n\n이 과정에서 정보 손실이 발생하므로 주의할 것\n\n선형 체인, DAG 및 상태 머신으로서의 에이전트 동작\n\n다양한 종속성과 논리 관계는 서로 다른 규모에 더 적합하거나 덜 적합할 수 있음\n\n다양한 작업 아키텍처에서 성능 최적화를 이끌어낼 수 있을까?\n\n계획에는 최종 결과물이 잘 작동하도록 다른 에이전트의 응답을 평가하는 방법에 대한 지침을 포함할 수 있음\n\n에이전트 프롬프트가 이전에 발생할 수 있는 다양한 변형에 대해 평가되는지 확인할 것\n\n현재로서는 결정론적 워크플로우에 우선순위를 둘 것\n\nAI 에이전트는 사용자 요청과 환경에 동적으로 반응할 수 있지만, 이들의 비결정론적 특성은 배포에 어려움을 줌\n\n에이전트가 수행하는 각 단계는 실패할 가능성이 있으며, 오류에서 복구할 가능성은 낮음\n\n따라서 에이전트가 다단계 작업을 성공적으로 완료할 가능성은 단계 수가 증가함에 따라 기하급수적으로 감소함\n\n그 결과 에이전트를 구축하는 팀은 신뢰할 수 있는 에이전트를 배포하는 데 어려움을 겪음\n\n유망한 접근 방식은 결정론적 계획을 생성하고 이를 구조화되고 재현 가능한 방식으로 실행하는 에이전트 시스템을 갖는 것임\n\n첫 번째 단계에서는 상위 수준의 목표나 프롬프트가 주어지면 에이전트가 계획을 생성함\n\n이를 통해 각 단계를 보다 예측 가능하고 신뢰할 수 있게 만들 수 있음\n\n생성된 계획은 에이전트에 프롬프트를 제공하거나 미세 조정하기 위한 few-shot 샘플로 사용될 수 있음\n\n결정론적 실행은 시스템을 더 신뢰할 수 있게 만들어 테스트와 디버깅이 더 쉬워짐. 또한 실패는 계획의 특정 단계로 추적될 수 있음\n\n생성된 계획은 방향성 비순환 그래프(DAG)로 표현될 수 있으며, 정적 프롬프트에 비해 이해하고 새로운 상황에 적응하기 쉬움\n\n가장 성공적인 에이전트 구축자는 주니어 엔지니어를 관리하는 데 강력한 경험을 가진 사람일 수 있음\n\n계획 생성 과정은 주니어를 지시하고 관리하는 방식과 유사하기 때문\n\n주니어에게 모호하고 개방적인 방향 대신 명확한 목표와 구체적인 계획을 제공하는 것처럼, 에이전트에게도 동일하게 해야 함\n\n결국 신뢰할 수 있고 작동하는 에이전트의 핵심은\n\n보다 구조화되고 결정론적인 접근 방식을 채택하고,\n\n프롬프트를 개선하고 모델을 미세 조정하기 위한 데이터를 수집하는 데서 발견될 가능성이 높음\n\n이것 없이는 때때로 매우 잘 작동할 수 있지만 평균적으로 사용자를 실망시켜 유지력이 낮아지는 에이전트를 구축하게 될 것임\n\nLLM의 출력에 다양성이 필요한 작업이 있다고 가정해 보자\n\n사용자가 이전에 구매한 제품 목록을 고려하여 카탈로그에서 구매할 제품을 제안하는 LLM 파이프라인을 작성하고 있을 수 있음\n\n프롬프트를 여러 번 실행할 때 결과 추천이 너무 유사하다는 것을 알 수 있음\n\n따라서 LLM 요청의 Temperature(온도) 매개변수를 높일 수 있음\n\n온도 매개변수를 높이면 LLM 응답이 더 다양해짐\n\n샘플링 시 다음 토큰의 확률 분포가 더 평평해져 일반적으로 선택될 가능성이 낮은 토큰이 더 자주 선택됨\n\n그러나 온도를 높일 때 출력 다양성과 관련된 일부 실패 모드가 발생할 수 있음\n\n예를 들어 카탈로그의 일부 제품이 적합할 수 있지만 LLM에 의해 출력되지 않을 수 있음\n\nLLM이 학습 시 배운 내용을 기반으로 프롬프트를 따를 가능성이 높은 경우 동일한 소수의 제품이 출력에서 과대 대표될 수 있음\n\n온도가 너무 높으면 존재하지 않는 제품(또는 무의미한 내용)을 참조하는 출력이 생성될 수 있음\n\n온도를 높인다고 해서 LLM이 예상하는 확률 분포(예: 균일 무작위)에서 출력을 샘플링한다는 보장은 없음\n\n그럼에도 불구하고 출력 다양성을 높이기 위한 다른 트릭이 있음\n\n가장 간단한 방법은 프롬프트 내 요소를 조정하는 것\n\n예를 들어 프롬프트 템플릿에 과거 구매 내역과 같은 항목 목록이 포함된 경우, 이러한 항목을 프롬프트에 삽입할 때마다 순서를 섞으면 상당한 차이를 만들 수 있음\n\n또한 최근 출력의 짧은 목록을 유지하면 중복을 방지하는 데 도움이 됨\n\n추천 제품 예시에서 LLM에 이 최근 목록에서 항목 제안을 피하도록 지시하거나, 최근 제안과 유사한 출력을 거부하고 재샘플링함으로써 응답을 더욱 다양화할 수 있음\n\n또 다른 효과적인 전략은 프롬프트에 사용되는 표현을 다양화하는 것\n\n예를 들어 \"사용자가 정기적으로 사용하는 것을 좋아할 항목 선택\" 또는 \"사용자가 친구에게 추천할 가능성이 높은 제품 선택\"과 같은 문구를 통합하면 초점을 이동시켜 추천 제품의 다양성에 영향을 줄 수 있음\n\n캐싱은 동일한 입력에 대한 응답을 재계산할 필요성을 제거함으로써 비용을 절감하고 생성 지연 시간을 제거함\n\n또한 응답이 이전에 가드레일링되었다면, 이러한 검증된 응답을 제공하여 유해하거나 부적절한 콘텐츠를 제공할 위험을 줄일 수 있음\n\n캐싱에 대한 간단한 접근 방식은 새로운 기사나 제품 리뷰를 요약하는 경우와 같이 처리 중인 항목에 대해 고유한 ID를 사용하는 것임\n\n요청이 들어오면 캐시에 이미 요약이 존재하는지 확인할 수 있음\n\n그렇다면 즉시 반환할 수 있고, 그렇지 않다면 생성, 가드레일링 및 제공한 다음 향후 요청을 위해 캐시에 저장할 수 있음\n\n좀 더 개방형인 쿼리의 경우 개방형 입력에 대해서도 캐싱을 활용하는 검색 분야의 기술을 차용할 수 있음\n\n자동 완성 및 맞춤법 수정과 같은 기능은 사용자 입력을 정규화하여 캐시 적중률을 높이는 데 도움이 됨\n\n가장 영리하게 설계된 프롬프트조차도 부족한 일부 작업이 있을 수 있음\n\n예를 들어 상당한 프롬프트 엔지니어링 이후에도 시스템이 여전히 신뢰할 수 있고 고품질의 출력을 반환하는 데서 멀어질 수 있음\n\n이 경우 특정 작업을 위해 모델을 파인튜닝해야 할 수 있음\n\nHoneycomb의 Natural Language Query Assistant\n\n처음에는 \"프로그래밍 매뉴얼\"이 문맥 내 학습을 위한 n-shot 예제와 함께 프롬프트에 제공됨\n\n이것이 제대로 작동했지만, 모델을 파인 튜닝하면 도메인 특정 언어의 구문과 규칙에 대한 더 나은 출력을 얻을 수 있음\n\nLLM은 프론트엔드가 올바르게 렌더링하기 위해 구조화된 데이터와 비구조화된 데이터를 결합한 매우 특정한 형식으로 응답을 생성해야 함\n\n파인 튜닝은 일관되게 작동하도록 하는 데 필수적임\n\n파인튜닝이 효과적일 수 있지만 상당한 비용이 수반됨\n\n파인 튜닝 데이터에 주석을 달고, 모델을 파인 튜닝 및 평가한 다음, 결국 자체 호스팅해야 함\n\n따라서 더 높은 초기 비용이 그만한 가치가 있는지 고려해야 함\n\n프롬프팅으로 90%까지 도달할 수 있다면 파인 튜닝에 투자할 가치가 없을 수 있음\n\n그러나 파인 튜닝하기로 결정한다면 인간이 주석을 단 데이터 수집 비용을 줄이기 위해 합성 데이터에 대해 생성 및 파인 튜닝하거나 오픈 소스 데이터를 부트스트랩할 수 있음\n\nLLM의 입력과 출력은 임의의 텍스트이며, 설정하는 작업도 다양함\n\n그럼에도 불구하고 엄격하고 신중한 평가는 중요함\n\nOpenAI의 기술 리더들이 평가에 참여하고 개별 평가에 대해 피드백을 제공하는 것이 우연이 아님\n\nLLM 애플리케이션 평가에는 다양한 정의와 축소가 필요함\n\n단순히 단위 테스트이거나, 관찰 가능성과 더 유사하거나, 단순히 데이터 과학일 수 있음\n\n우리는 이러한 모든 관점이 유용하다는 것을 발견함\n\n이번 섹션에서는 평가 및 모니터링 파이프라인 구축에서 중요한 사항에 대해 배운 교훈을 제공함\n\n실제 입출력 샘플에서 몇 가지 assertion 기반 단위 테스트 생성\n\n프로덕션에서 입력과 출력의 샘플로 구성된 단위 테스트(즉, assertion)를 만들고, 최소 3가지 기준에 따라 출력에 대한 기대치를 설정\n\n3가지 기준이 임의적으로 보일 수 있지만, 시작하기에 실용적인 수임\n\n더 적으면 작업이 충분히 정의되지 않았거나 범용 챗봇과 같이 너무 개방적일 수 있음\n\n이러한 단위 테스트 또는 assertion은 프롬프트 편집, RAG를 통한 새 컨텍스트 추가 또는 기타 수정과 같은 파이프라인의 변경 사항에 의해 트리거되어야 함\n\n모든 응답에 포함하거나 제외할 구문이나 아이디어를 지정하는 assertion부터 시작하는 것을 고려\n\n또한 단어, 항목 또는 문장 수가 범위 내에 있는지 확인하는 검사를 고려\n\n다른 종류의 생성의 경우 assertion이 다르게 보일 수 있음\n\n예를 들어 코드 생성을 평가하기 위한 강력한 방법인 실행 평가에서는 생성된 코드를 실행하고 런타임 상태가 사용자 요청에 충분한지 확인\n\n예를 들어 사용자가 foo라는 새 함수를 요청하면 에이전트의 생성 코드를 실행한 후 foo를 호출할 수 있어야 함\n\n실행 평가의 한 가지 과제는 에이전트 코드가 종종 대상 코드와 약간 다른 형태로 런타임을 남긴다는 것\n\n어떤 타당한 답변이라도 만족시킬 수 있는 가장 약한 가정으로 assertion을 \"완화\"하는 것이 효과적일 수 있음\n\n고객을 위해 의도한 대로 제품을 사용하는 것(즉, \"도그푸딩\")은 실제 데이터에서의 장애 모드에 대한 통찰력을 제공할 수 있음\n\n이 접근 방식은 잠재적 약점을 식별하는 데 도움이 될 뿐만 아니라 평가로 변환할 수 있는 유용한 프로덕션 샘플 소스도 제공함\n\nLLM-as-Judge는 (어느 정도) 작동할 수 있지만 만능은 아님\n\nLLM-as-Judge는 강력한 LLM을 사용하여 다른 LLM의 출력을 평가하는 방식으로, 일부 사람들에게는 회의적으로 받아들여짐\n\n그럼에도 불구하고 잘 구현되면 LLM-as-Judge는 인간의 판단과 상당한 상관관계를 달성하고, 적어도 새로운 프롬프트나 기술이 어떻게 수행될 수 있는지에 대한 사전 정보를 구축하는 데 도움이 될 수 있음\n\n특히 쌍별 비교(예: 대조군 vs 처리군)를 할 때 LLM-as-Judge는 일반적으로 방향을 올바르게 잡지만 승/패의 크기는 노이즈가 있을 수 있음\n\nLLM-as-Judge를 최대한 활용하기 위한 제안\n\nLLM에게 단일 출력을 Likert 척도로 평가하도록 요청하는 대신 두 가지 옵션을 제시하고 더 나은 것을 선택하도록 요청\n\n이는 더 안정적인 결과로 이어지는 경향이 있음\n\n제시된 옵션의 순서가 LLM의 결정을 편향시킬 수 있음\n\n이를 완화하려면 각 쌍별 비교를 두 번 수행하고 각 시간에 쌍의 순서를 바꿈\n\n스와핑 후에는 올바른 옵션에 승리를 귀속시켜야 함\n\n경우에 따라 두 옵션이 똑같이 좋을 수 있음\n\n따라서 LLM이 임의로 승자를 선택할 필요가 없도록 동점을 선언하도록 허용\n\n최종 선호도를 제시하기 전에 LLM에게 그 결정을 설명하도록 요청하면 평가 신뢰성이 향상될 수 있음\n\n보너스로, 이를 통해 더 약하지만 빠른 LLM을 사용하면서도 유사한 결과를 얻을 수 있음\n\n파이프라인의 이 부분이 자주 배치 모드에 있기 때문에 CoT로 인한 추가 지연은 문제가 되지 않음\n\nLLM은 더 긴 응답으로 치우치는 경향이 있음\n\n이를 완화하려면 응답 쌍의 길이가 비슷한지 확인\n\nLLM-as-Judge의 특히 강력한 적용은 새로운 프롬프트 전략을 회귀에 대해 확인하는 것\n\n프로덕션 결과 모음을 추적한 경우 때로는 새로운 프롬프트 전략으로 해당 프로덕션 예제를 다시 실행하고 LLM-as-Judge를 사용하여 새 전략이 어디에서 어려움을 겪을 수 있는지 신속하게 평가할 수 있음\n\nLLM-as-Judge의 간단하지만 효과적인 접근 방식의 예시\n\n단순히 LLM 응답, 판사의 비평(즉, CoT) 및 최종 결과를 기록\n\n그런 다음 이해관계자와 검토하여 개선 영역을 식별\n\n3번의 반복을 통해 인간과 LLM의 일치도는 68%에서 94%로 향상됨\n\n그러나 LLM-as-Judge는 만능이 아님\n\n가장 강력한 모델조차도 신뢰할 수 있게 평가하지 못하는 미묘한 언어적 측면이 있음\n\n또한 기존의 분류기와 보상 모델이 LLM-as-Judge보다 더 높은 정확도를 달성할 수 있으며 비용과 지연 시간이 더 적다는 것을 발견함\n\n코드 생성의 경우 LLM-as-Judge는 실행 평가와 같은 보다 직접적인 평가 전략보다 약할 수 있음\n\n생성 결과를 평가할 때 다음과 같은 \"인턴 테스트\"를 사용하는 것이 좋음\n\n컨텍스트를 포함하여 언어 모델에 대한 정확한 입력을 가져와 관련 전공의 평균적인 대학생에게 과제로 제시한다면 그들이 성공할 수 있을까?\n\nLLM에 필요한 지식이 부족하기 때문이라면 컨텍스트를 풍부하게 만드는 방법을 고려\n\n컨텍스트를 개선해도 해결할 수 없다면 현대 LLM에는 너무 어려운 작업일 수 있음\n\n작업의 어떤 측면을 더 템플릿화할 수 있는가?\n\n모델에게 응답 전이나 후에 스스로 설명하도록 요청해보기\n\n특정 평가에 지나치게 중점을 두면 전반적인 성능이 저하될 수 있음\n\n\"측정 지표가 목표가 되면 더 이상 좋은 측정 지표가 아니게 된다.\" - Goodhart의 법칙\n\n이에 대한 예시로 Needle-in-a-Haystack(NIAH) 평가가 있음\n\n원래 평가는 컨텍스트 크기가 커짐에 따라 모델 리콜을 정량화하고 바늘 위치에 따라 리콜이 어떻게 영향을 받는지 확인하는 데 도움이 됨\n\n그러나 너무 지나치게 강조되어 Gemini 1.5 보고서의 Figure 1로 소개됨\n\n이 평가에는 폴 그레이엄의 에세이를 반복하는 긴 문서에 특정 구문(\"The special magic {city} number is: {number}\")을 삽입한 다음 모델에 매직 넘버를 상기시키는 작업이 포함됨\n\n일부 모델은 거의 완벽한 리콜을 달성하지만 NIAH가 실제 애플리케이션에 필요한 추론 및 리콜 능력을 진정으로 반영하는지는 의문\n\n1시간 분량의 회의 녹취록이 주어지면 LLM이 주요 결정과 다음 단계를 요약하고 각 항목을 관련 담당자에게 올바르게 귀속시킬 수 있는가?\n\n이 작업은 단순한 암기를 넘어 복잡한 토론을 파악하고 관련 정보를 식별하며 요약을 종합하는 능력도 고려하므로 더 현실적임\n\n의사-환자 화상 통화 녹취록을 사용하여 LLM에 환자의 약물에 대해 질문\n\n에스프레소에 담근 대추, 레몬, 염소 치즈 등 피자 토핑에 필요한 무작위 재료에 대한 구문을 삽입하는 등 보다 도전적인 NIAH도 포함\n\n약물 작업에서 리콜은 약 80%, 피자 작업에서는 30%였음\n\nNIAH 평가를 지나치게 강조하면 추출 및 요약 작업의 성능이 낮아질 수 있음\n\n이러한 LLM은 모든 문장에 주의를 기울이도록 미세 조정되어 있기 때문에 관련 없는 세부 정보와 주의 산만 요소를 중요한 것으로 취급하기 시작할 수 있음\n\n그러면 최종 출력에 포함될 수 있음(포함되지 말아야 할 때도!)\n\n이는 다른 평가 및 사용 사례에도 적용될 수 있음\n\n사실적 일관성을 강조하면 덜 구체적이고(따라서 사실과 일치하지 않을 가능성이 낮음) 관련성이 떨어질 수 있는 요약이 생성될 수 있음\n\n반대로 글쓰기 스타일과 웅변을 강조하면 사실적 불일치를 초래할 수 있는 더 화려한 마케팅 유형의 언어가 생성될 수 있음\n\n주석 달기를 이진 작업 또는 쌍대(pairwise) 비교로 단순화\n\n모델 출력에 대해 개방형 피드백을 제공하거나 Likert 척도로 평가하는 것은 인지적으로 까다로움\n\n그 결과 수집된 데이터는 인간 평가자 간의 변동성으로 인해 더 노이즈가 많아지고 따라서 덜 유용해짐\n\n보다 효과적인 접근 방식은 작업을 단순화하고 주석 작성자의 인지적 부담을 줄이는 것\n\n잘 작동하는 두 가지 작업은 이진 분류와 쌍대 비교\n\n이진 분류에서 주석 작성자는 모델의 출력에 대해 간단한 예/아니오 판단을 내리도록 요청받음\n\n생성된 요약이 소스 문서와 사실적으로 일치하는지, 제안된 응답이 관련이 있는지, 유해성이 포함되어 있는지 등을 물을 수 있음\n\nLikert 척도에 비해 이진 결정은 더 정확하고, 평가자 간 일관성이 더 높으며, 처리량이 더 높음\n\nDoordash가 일련의 예/아니오 질문을 통해 메뉴 항목에 태그를 붙이기 위해 레이블링 대기열을 설정한 방식\n\n쌍대 비교(Pairewise Comparison)에서 주석 작성자는 한 쌍의 모델 응답을 받고 어떤 것이 더 나은지 물음\n\n인간이 A 또는 B에 개별적으로 점수를 매기는 것보다 \"A가 B보다 낫다\"라고 말하는 것이 더 쉽기 때문에 이는 더 빠르고 신뢰할 수 있는 주석으로 이어짐(Likert 척도보다)\n\nLlama2 밋업에서 Llama2 논문의 저자 중 한 명인 Thomas Scialom은 쌍대 비교가 작성된 응답과 같은 지도 학습 미세 조정 데이터를 수집하는 것보다 더 빠르고 저렴하다는 것을 확인함\n\n전자의 비용은 단위당 $3.5이고 후자의 비용은 단위당 $25\n\n(참조가 필요 없는, Reference-free) 평가와 가드레일은 상호 교환적으로 사용될 수 있음\n\n가드레일은 부적절하거나 유해한 콘텐츠를 잡는 데 도움이 되는 반면, 평가는 모델 출력의 품질과 정확성을 측정하는 데 도움이 됨\n\n참조가 필요 없는 평가의 경우 동전의 양면으로 볼 수 있음\n\n참조가 필요 없는 평가는 인간이 작성한 답변과 같은 \"golden\" reference에 의존하지 않고 입력 프롬프트와 모델의 응답만으로 출력 품질을 평가할 수 있는 평가임\n\n요약의 사실적 일관성과 관련성을 평가하기 위해 입력 문서만 고려하면 됨\n\n요약이 이러한 지표에서 점수가 낮으면 사용자에게 표시하지 않도록 선택할 수 있어 평가를 가드레일로 효과적으로 사용할 수 있음\n\n인간이 번역한 참조 없이도 번역의 품질을 평가할 수 있어 다시 가드레일로 사용할 수 있음\n\nLLM 작업 시 주요 과제는 LLM이 그러면 안 될 때도 종종 출력을 생성한다는 것\n\n이는 무해하지만 무의미한 응답이나 유해성 또는 위험한 내용과 같은 더 심각한 결함으로 이어질 수 있음\n\n예를 들어 문서에서 특정 속성이나 메타데이터를 추출하라는 요청을 받으면 LLM은 해당 값이 실제로 존재하지 않을 때도 자신 있게 값을 반환할 수 있음\n\n또는 컨텍스트에 영어 이외의 문서를 제공했기 때문에 모델이 영어 이외의 언어로 응답할 수도 있음\n\nLLM에 \"해당 없음\" 또는 \"알 수 없음\" 응답을 반환하도록 프롬프트를 제공할 수 있지만 완벽하지는 않음\n\n로그 확률을 사용할 수 있는 경우에도 출력 품질의 좋지 않은 지표임\n\n로그 확률은 출력에 토큰이 나타날 가능성을 나타내지만 생성된 텍스트의 정확성을 반영하지는 않음\n\n오히려 쿼리에 응답하고 일관된 응답을 생성하도록 훈련된 명령어 튜닝 모델의 경우 로그 확률이 잘 보정되지 않을 수 있음\n\n따라서 높은 로그 확률은 출력이 유창하고 일관성이 있음을 나타낼 수 있지만 정확하거나 관련이 있다는 의미는 아님\n\n주의 깊은 프롬프트 엔지니어링은 어느 정도 도움이 될 수 있지만, 원치 않는 출력을 감지하고 필터링/재생성하는 강력한 가드레일로 보완해야 함\n\n예를 들어 OpenAI는 혐오 발언, 자해 또는 성적 출력과 같은 안전하지 않은 응답을 식별할 수 있는 콘텐츠 조정 API를 제공함\n\n마찬가지로 개인 식별 정보(PII)를 감지하기 위한 수많은 패키지가 있음\n\n가드레일의 한 가지 이점은 사용 사례에 대해 크게 무관하며 따라서 특정 언어로 된 모든 출력에 광범위하게 적용될 수 있다는 것\n\n또한 정밀한 검색을 통해 관련 문서가 없으면 시스템이 결정적으로 \"모르겠습니다\"라고 응답할 수 있음\n\nLLM은 출력이 예상될 때 출력을 생성하지 못할 수 있음\n\nAPI 제공업체의 긴 지연 시간과 같은 간단한 문제부터 콘텐츠 조정 필터에 의해 출력이 차단되는 것과 같은 더 복잡한 문제에 이르기까지 다양한 이유로 발생할 수 있음\n\n따라서 디버깅 및 모니터링을 위해 입력과 (잠재적으로 출력 부족을) 일관되게 기록하는 것이 중요함\n\n환각(Hallucination)은 끈질긴 문제임\n\n콘텐츠 안전성이나 PII 결함은 많은 주의를 받아 거의 발생하지 않는 반면, 사실적 불일치는 끈질기게 지속되며 감지하기가 더 어려움\n\n더 흔하게 발생하며 기준 발생률은 5~10%이고, LLM 제공업체로부터 배운 바에 따르면 요약과 같은 간단한 작업에서도 2% 미만으로 낮추는 것이 어려울 수 있음\n\n이를 해결하기 위해 프롬프트 엔지니어링(생성 업스트림)과 사실적 불일치 가드레일(생성 다운스트림)을 결합할 수 있음\n\n프롬프트 엔지니어링의 경우 CoT와 같은 기술은 LLM이 최종 출력을 반환하기 전에 추론을 설명하도록 함으로써 환각을 줄이는 데 도움이 됨\n\n그런 다음 사실적 불일치 가드레일을 적용하여 요약의 사실성을 평가하고 환각을 필터링하거나 재생성할 수 있음\n\n경우에 따라 환각은 결정론적으로 감지될 수 있음\n\nRAG 검색의 리소스를 사용할 때 출력이 구조화되어 있고 리소스가 무엇인지 식별한다면 입력 컨텍스트에서 소싱되었는지 수동으로 확인할 수 있어야 함\n\n[운영: 일상(Day-to-Day) 및 조직 문제 ]\n\n재료의 품질이 요리의 맛을 결정하듯이 입력 데이터의 품질은 기계 학습 시스템의 성능을 제약함\n\n또한 출력 데이터는 제품이 작동하는지 여부를 알 수 있는 유일한 방법임\n\n모든 저자는 데이터 분포(모드, 엣지 케이스, 모델의 한계)를 더 잘 이해하기 위해 매주 몇 시간 동안 입력과 출력을 면밀히 살펴봄\n\n전통적인 기계 학습 파이프라인에서 오류의 일반적인 원인은 훈련-서비스 편향 임\n\n이는 훈련에 사용되는 데이터가 모델이 프로덕션에서 접하는 데이터와 다를 때 발생함\n\n훈련이나 미세 조정 없이 LLM을 사용할 수 있으므로 훈련 세트는 없지만 개발-프로덕션 데이터 편향이라는 유사한 문제가 발생함\n\n기본적으로 개발 중에 시스템을 테스트하는 데이터는 시스템이 프로덕션에서 직면할 데이터를 반영해야 함\n\n그렇지 않으면 프로덕션 정확도가 저하될 수 있음\n\nLLM 개발-프로덕션 편향은 구조적 편향과 내용 기반 편향의 두 가지 유형으로 분류될 수 있음\n\n구조적 편향에는 목록형 값을 가진 JSON 딕셔너리와 JSON 목록 간의 차이, 일관되지 않은 케이싱, 오타나 문장 조각과 같은 오류 등 형식 불일치와 같은 문제가 포함됨\n\n이러한 오류는 다양한 LLM이 특정 데이터 형식으로 훈련되고 프롬프트가 사소한 변경에 매우 민감할 수 있기 때문에 예측할 수 없는 모델 성능으로 이어질 수 있음\n\n내용 기반 또는 \"의미론적\" 편향은 데이터의 의미나 맥락의 차이를 나타냄\n\n전통적인 ML과 마찬가지로 LLM 입출력 쌍 간의 편향을 주기적으로 측정하는 것이 유용함\n\n입력 및 출력 길이 또는 특정 형식 요구 사항(예: JSON 또는 XML)과 같은 단순 메트릭은 변경 사항을 추적하는 간단한 방법임\n\n보다 \"고급\" 편향 감지를 위해 입출력 쌍의 임베딩을 클러스터링하여 사용자가 이전에 모델에 노출되지 않은 영역을 탐색하고 있음을 나타낼 수 있는 사용자가 논의하는 주제의 변화와 같은 의미론적 편향을 감지하는 것을 고려할 것\n\n프롬프트 엔지니어링과 같은 변경 사항을 테스트할 때는 홀드아웃 데이터 세트가 최신 상태이고 가장 최근 유형의 사용자 상호 작용을 반영하는지 확인\n\n예를 들어 프로덕션 입력에서 오타가 흔하다면 홀드아웃 데이터에도 있어야 함\n\n단순히 숫자로 편향을 측정하는 것 이상으로 출력에 대해 정성적 평가를 수행하는 것이 유익함\n\n모델의 출력을 정기적으로 검토하는 것(속어로 \"바이브 체크\"라고 알려진 관행)은 결과가 기대에 부합하고 사용자 요구에 계속 관련성이 있는지 확인해줌\n\n편향 확인에 비결정론을 통합하는 것도 유용함\n\n테스트 데이터 세트의 각 입력에 대해 파이프라인을 여러 번 실행하고 모든 출력을 분석함으로써 가끔만 발생할 수 있는 이상 현상을 포착할 가능성이 높아짐\n\n인상적인 제로샷 능력과 종종 기분 좋은 출력에도 불구하고 LLM의 실패 모드는 매우 예측할 수 없음\n\n맞춤 작업의 경우 LLM의 성능에 대한 직관적인 이해를 개발하기 위해 데이터 샘플을 정기적으로 검토하는 것이 필수적임\n\n프로덕션의 입출력 쌍은 LLM 애플리케이션의 \"실제 사물, 실제 장소\"( genchi genbutsu )이며 대체할 수 없음\n\n최근 연구에 따르면 개발자가 더 많은 데이터와 상호 작용할수록 \"좋은\" 출력과 \"나쁜\" 출력에 대한 인식이 변한다고 강조함(즉, 기준 편향 )\n\n개발자는 LLM 출력을 평가하기 위한 일부 기준을 사전에 제시할 수 있지만, 이러한 사전 정의된 기준은 종종 불완전함\n\n예를 들어 개발 과정에서 좋은 응답 확률을 높이고 나쁜 응답 확률을 낮추기 위해 프롬프트를 업데이트할 수 있음\n\n이러한 평가, 재평가 및 기준 업데이트의 반복 프로세스는 출력을 직접 관찰하지 않고는 LLM 동작이나 인간의 선호도를 예측하기 어렵기 때문에 필요함\n\n이를 효과적으로 관리하기 위해 LLM 입력과 출력을 기록해야 함\n\n매일 이러한 로그 샘플을 검사하면 새로운 패턴이나 실패 모드를 신속하게 식별하고 적응할 수 있음\n\n새로운 문제를 발견하면 즉시 그것에 대한 assertion 또는 eval을 작성할 수 있음\n\n마찬가지로 실패 모드 정의에 대한 모든 업데이트는 평가 기준에 반영되어야 함\n\n이러한 \"바이브 체크\"는 잘못된 출력의 신호이며, 코드와 assertion은 이를 운영함\n\n마지막으로 이러한 태도는 Socialized되어야 함\n\n예를 들어 온콜 로테이션에 입력 및 출력 검토 또는 주석 달기를 추가하는 것\n\nLLM API를 사용하면 소수의 제공업체의 지능에 의존할 수 있음\n\n이는 좋은 점이지만 이러한 종속성은 성능, 지연 시간, 처리량 및 비용 측면에서 절충점을 수반함\n\n또한 지난 1년 동안 거의 매월 더 새롭고 더 나은 모델이 출시됨에 따라 오래된 모델을 폐기하고 새로운 모델로 마이그레이션할 때 제품을 업데이트할 준비가 되어 있어야 함\n\n이 섹션에서는 완전히 제어할 수 없는 기술, 즉 모델을 자체 호스팅하고 관리할 수 없는 기술을 사용할 때 얻은 교훈을 공유함\n\n실제 사용 사례 대부분의 경우 LLM의 출력은 일종의 기계 판독 가능 형식을 통해 다운스트림 애플리케이션에서 소비될 것임\n\n예를 들어 부동산 CRM인 ReChat은 프론트엔드에서 위젯을 렌더링하기 위해 구조화된 응답이 필요함\n\n유사하게 제품 전략 아이디어 생성 도구인 Boba는 제목, 요약, 타당성 점수 및 시간 범위 필드가 있는 구조화된 출력이 필요함\n\n마지막으로 LinkedIn은 LLM을 제한하여 YAML을 생성하는 방법을 공유했는데, 이는 사용할 기술을 결정하고 기술을 호출하는 매개변수를 제공하는 데 사용됨\n\n이 애플리케이션 패턴은 Postel의 법칙의 극단적인 버전임\n\n수락하는 것(임의의 자연어)에 자유롭고 보내는 것(유형화된 기계 판독 가능 개체)에 보수적이어야 함\n\n따라서 이것이 매우 내구성이 있을 것으로 기대함\n\n현재 Instructor와 Outlines는 LLM에서 구조화된 출력을 이끌어내기 위한 사실상의 표준임\n\nLLM API(예: Anthropic, OpenAI)를 사용하는 경우 Instructor를 사용하고, 자체 호스팅 모델(예: Huggingface)을 사용하는 경우 Outlines를 사용할 것\n\n모델 간 프롬프트 마이그레이션은 고통스러운 일임\n\n때로는 주의 깊게 만든 프롬프트가 한 모델에서는 훌륭하게 작동하지만 다른 모델에서는 제대로 작동하지 않을 수 있음\n\n이는 다양한 모델 제공업체 간에 전환할 때뿐만 아니라 동일한 모델의 버전 간에 업그레이드할 때도 발생할 수 있음\n\n예를 들어 Voiceflow는 gpt-3.5-turbo-0301에서 gpt-3.5-turbo-1106으로 마이그레이션하면 의도 분류 작업에서 10%의 성능 저하가 발생한다는 것을 발견함\n\n유사하게 GoDaddy는 1106 버전으로 업그레이드하면 gpt-3.5-turbo와 gpt-4 사이의 성능 격차가 좁혀지는 긍정적인 방향의 추세를 관찰함\n\n(또는 당신이 반쯤 찬 유리잔을 보는 사람이라면 새로운 업그레이드로 gpt-4의 리드가 줄어든 것에 실망할 수도 있음)\n\n따라서 모델 간에 프롬프트를 마이그레이션해야 하는 경우 단순히 API 엔드포인트를 교체하는 것보다 더 많은 시간이 걸릴 것으로 예상해야 함\n\n동일한 프롬프트를 연결하면 유사하거나 더 나은 결과로 이어질 것이라고 가정하지 말 것\n\n또한 신뢰할 수 있고 자동화된 평가는 마이그레이션 전후의 작업 성능을 측정하는 데 도움이 되며 수동 검증에 필요한 노력을 줄여줌\n\n모든 기계 학습 파이프라인에서 \"무엇이든 변경하면 모든 것이 변경됨\"\n\n이는 우리 자신이 훈련하지 않고 우리 모르게 변경될 수 있는 대규모 언어 모델(LLM)과 같은 구성 요소에 의존할 때 특히 관련이 있음\n\n다행히도 많은 모델 제공업체는 특정 모델 버전(예: gpt-4-turbo-1106)을 \"고정\"할 수 있는 옵션을 제공함\n\n이를 통해 모델 가중치의 특정 버전을 사용하여 변경되지 않도록 할 수 있음\n\n프로덕션에서 모델 버전을 고정하면 모델 동작의 예기치 않은 변경을 방지할 수 있음\n\n이는 모델이 교체될 때 발생할 수 있는 지나치게 장황한 출력이나 기타 예상치 못한 실패 모드와 같은 문제에 대한 고객 불만을 피하는 데 도움이 될 수 있음\n\n또한 프로덕션 설정을 미러링하지만 최신 모델 버전을 사용하는 \"섀도우 파이프라인\"을 유지하는 것을 고려해 볼 것\n\n이를 통해 새로운 릴리스로 안전한 실험과 테스트를 수행할 수 있음\n\n이러한 새로운 모델에서 출력의 안정성과 품질을 검증한 후에는 프로덕션 환경에서 모델 버전을 자신 있게 업데이트할 수 있음\n\n작업을 완료할 수 있는 가장 작은 모델 선택하기\n\n새로운 애플리케이션에서 작업할 때 사용 가능한 가장 크고 강력한 모델을 사용하고 싶은 유혹이 있음\n\n그러나 일단 작업이 기술적으로 가능하다는 것이 확인되면 더 작은 모델로 유사한 결과를 얻을 수 있는지 실험해 볼 가치가 있음\n\n작은 모델의 장점은 지연 시간과 비용이 낮다는 것\n\n더 약할 수 있지만 Chain-of-Thought, n-shot 프롬프트, 문맥 내 학습과 같은 기술은 작은 모델이 자신의 역량 이상으로 성장하는 데 도움이 될 수 있음\n\nLLM API 이상으로 특정 작업에 대한 미세 조정도 성능 향상에 도움이 될 수 있음\n\n종합하면 작은 모델을 사용하여 신중하게 설계된 워크플로우는 더 빠르고 저렴하면서도 단일 대형 모델의 출력 품질과 일치하거나 심지어 능가할 수 있음\n\n예를 들어 이 트윗 은 Haiku + 10-shot 프롬프트가 제로샷 Opus와 GPT-4를 능가하는 방법에 대한 일화를 공유함\n\n장기적으로 출력 품질, 지연 시간 및 비용의 최적 균형으로 작은 모델을 사용한 흐름 엔지니어링의 더 많은 사례가 나타날 것으로 예상됨\n\n또 다른 예로 겸손한 분류 작업을 들 수 있음\n\nDistilBERT(6,700만 개 매개변수)와 같은 경량 모델은 놀라울 정도로 강력한 기준선임\n\n4억 개 매개변수의 DistilBART는 또 다른 훌륭한 옵션\n\n오픈 소스 데이터에서 미세 조정되면 지연 시간과 비용의 5% 미만으로 대부분의 LLM을 능가하는 0.84의 ROC-AUC로 환각을 식별할 수 있음\n\n요점은 작은 모델을 간과하지 말아야 한다는 것\n\n모든 문제에 거대한 모델을 적용하기는 쉽지만 약간의 창의성과 실험으로 우리는 종종 더 효율적인 솔루션을 찾을 수 있음\n\n새로운 기술은 새로운 가능성을 제공하지만 훌륭한 제품을 만드는 원칙은 영원함\n\n따라서 처음으로 새로운 문제를 해결하더라도 제품 설계에 대해 바퀴를 다시 발명할 필요는 없음\n\n견고한 제품 기본에 LLM 애플리케이션 개발을 기반으로 함으로써 얻을 수 있는 것이 많음\n\n이를 통해 우리가 서비스하는 사람들에게 실제 가치를 제공할 수 있음\n\n디자이너를 두면 제품을 어떻게 구축하고 사용자에게 제시할 수 있는지 이해하고 깊이 생각하게 됨\n\n때로는 디자이너를 사물을 예쁘게 만드는 사람으로 고정 관념을 가지기도 함\n\n그러나 사용자 인터페이스뿐만 아니라 기존 규칙과 패러다임을 깨더라도 사용자 경험을 어떻게 개선할 수 있는지 재고함\n\n디자이너는 사용자의 요구 사항을 다양한 형태로 재구성하는 데 특히 재능이 있음\n\n이러한 형태 중 일부는 다른 형태보다 해결하기가 더 쉬우므로 AI 솔루션에 더 많거나 적은 기회를 제공할 수 있음\n\n다른 많은 제품과 마찬가지로 AI 제품 구축은 제품을 구동하는 기술이 아니라 수행할 작업을 중심으로 이루어져야 함\n\n다음과 같은 질문을 스스로에게 묻는 데 초점을 맞출 것\n\n\"사용자가 이 제품에 요청하는 작업은 무엇인가? 그 작업이 챗봇이 잘할 만한 일인가? 자동 완성은 어떤가? 어쩌면 다른 것일 수도 있다!\"\n\n기존 설계 패턴과 그것이 수행할 작업과 어떤 관련이 있는지 고려할 것\n\n이것들은 디자이너가 팀의 역량에 더하는 귀중한 자산임\n\n품질 좋은 주석을 얻는 한 가지 방법은 사용자 경험(UX)에 Human-in-the-Loop(HITL)를 통합하는 것\n\n사용자가 쉽게 피드백과 수정 사항을 제공할 수 있도록 하면 즉각적인 출력을 개선하고 모델 개선에 유용한 데이터를 수집할 수 있음\n\n사용자가 제품을 업로드하고 분류하는 전자상거래 플랫폼을 상상해 보자\n\n사용자가 수동으로 올바른 제품 범주를 선택하고, LLM이 주기적으로 새 제품을 확인하고 백엔드에서 잘못된 분류를 수정\n\n사용자는 범주를 전혀 선택하지 않고, LLM이 주기적으로 백엔드에서 제품을 분류(잠재적 오류 포함)\n\nLLM이 실시간으로 제품 범주를 제안하고, 사용자가 필요에 따라 검증 및 업데이트 가능\n\n세 가지 접근 방식 모두 LLM을 포함하지만 매우 다른 UX를 제공함\n\n첫 번째 접근 방식은 초기 부담을 사용자에게 지우고 LLM이 사후 처리 검사 역할을 함\n\n두 번째 접근 방식은 사용자의 노력이 전혀 필요하지 않지만 투명성이나 제어권을 제공하지 않음\n\nLLM이 사전에 범주를 제안함으로써 사용자의 인지 부하를 줄이고 제품을 분류하기 위해 우리의 분류법을 배울 필요가 없음\n\n동시에 사용자가 제안을 검토하고 편집할 수 있도록 함으로써 제품 분류 방식에 대한 최종 결정권을 사용자의 손에 단단히 쥐어줌\n\n보너스로 세 번째 접근 방식은 모델 개선을 위한 자연스러운 피드백 루프를 만듦\n\n좋은 제안은 수락되고(긍정 레이블) 나쁜 제안은 업데이트됨(부정 후 긍정 레이블)\n\n제안, 사용자 검증 및 데이터 수집의 이 패턴은 여러 애플리케이션에서 일반적으로 볼 수 있음\n\n코딩 어시스턴트: 사용자가 제안을 수락(강한 긍정), 수락 및 조정(긍정) 또는 무시(부정)할 수 있음\n\nMidjourney: 사용자가 이미지를 업스케일하고 다운로드(강한 긍정)하거나, 이미지를 변경(긍정)하거나, 새로운 이미지 세트를 생성(부정)할 수 있음\n\n챗봇: 사용자가 응답에 대해 좋아요(긍정) 또는 싫어요(부정)를 제공하거나, 응답이 정말 나쁜 경우 응답을 다시 생성(강한 부정)하도록 선택할 수 있음\n\n명시적 피드백은 사용자가 제품의 요청에 응답하여 제공하는 정보\n\n암시적 피드백은 사용자가 의도적으로 피드백을 제공할 필요 없이 사용자 상호 작용에서 배우는 정보\n\n코딩 어시스턴트와 Midjourney는 암시적 피드백의 예이고 좋아요와 싫어요는 명시적 피드백\n\n코딩 어시스턴트와 Midjourney처럼 UX를 잘 설계하면 제품과 모델을 개선하기 위한 많은 암시적 피드백을 수집할 수 있음\n\n무자비하게 요구사항 계층(Hierarchy)의 우선순위 지정하기\n\n데모를 프로덕션에 배치하는 것에 대해 생각할 때, 다음에 대한 요구 사항을 고려해야 함\n\n신뢰성: 99.9% 가동 시간, 구조화된 출력 준수\n\n무해성: 공격적이거나 NSFW 또는 기타 유해한 콘텐츠를 생성하지 않음\n\n사실적 일관성: 제공된 맥락에 충실하고 사실을 왜곡하지 않음\n\n확장성: 지연 시간 SLA, 지원되는 처리량\n\n기타: 보안, 개인 정보 보호, 공정성, GDPR, DMA 등\n\n이러한 모든 요구 사항을 한 번에 해결하려고 하면 아무것도 출시할 수 없음\n\n이는 제품이 작동하지 않거나 실행 가능하지 않을 수 있는 타협할 수 없는 사항(예: 신뢰성, 무해성)이 무엇인지 명확히 하는 것을 의미함\n\nMVP(Minimum Lovable Product) 제품을 식별하는 것이 중요함\n\n첫 번째 버전이 완벽하지 않을 것이라는 점을 받아들이고 출시하고 반복해야 함\n\n언어 모델과 애플리케이션의 검토 수준을 결정할 때는 사용 사례와 대상을 고려해야 함\n\n의료 또는 금융 조언을 제공하는 고객 대면 챗봇의 경우 안전성과 정확성에 대해 매우 높은 기준이 필요함\n\n실수나 잘못된 출력은 실제 피해를 일으키고 신뢰를 잃을 수 있음\n\n그러나 추천 시스템과 같은 덜 중요한 애플리케이션이나 콘텐츠 분류 또는 요약과 같은 내부 대면 애플리케이션의 경우 지나치게 엄격한 요구 사항은 많은 가치를 추가하지 않고 진전을 늦출 뿐임\n\n이는 많은 회사가 외부 애플리케이션에 비해 내부 LLM 애플리케이션으로 더 빠르게 움직이고 있다는 최근 a16z 보고서와 일치함\n\n내부 생산성을 위해 AI를 실험함으로써 조직은 더 통제된 환경에서 위험을 관리하는 방법을 배우면서 가치를 포착하기 시작할 수 있음\n\n그런 다음 자신감이 생기면 고객 대면 사용 사례로 확장할 수 있음\n\n어떤 직무도 정의하기 쉽지 않지만, 이 새로운 영역에서 업무에 대한 직무 기술서를 작성하는 것은 다른 것보다 더 어려움\n\n교차하는 직책에 대한 벤 다이어그램이나 직무 기술에 대한 제안은 생략하겠음\n\n그러나 새로운 역할인 AI 엔지니어의 존재를 인정하고 그 역할에 대해 논의할 것임\n\n중요한 것은 나머지 팀과 책임이 어떻게 할당되어야 하는지에 대해 논의할 것임\n\nLLM과 같은 새로운 패러다임에 직면했을 때 소프트웨어 엔지니어는 도구를 선호하는 경향이 있음\n\n그 결과 도구가 해결하려고 했던 문제와 프로세스를 간과하게 됨\n\n이렇게 하면서 많은 엔지니어는 우발적 복잡성을 가정하게 되는데, 이는 팀의 장기적인 생산성에 부정적인 결과를 초래함\n\n예를 들어 이 글 은 특정 도구가 대규모 언어 모델에 대한 프롬프트를 자동으로 생성할 수 있는 방법에 대해 설명함\n\n문제 해결 방법론이나 프로세스를 먼저 이해하지 않고 이러한 도구를 사용하는 엔지니어는 결국 불필요한 기술 부채를 떠안게 된다고 주장함(IMHO 정당하게)\n\n우발적 복잡성 외에도 도구는 종종 불충분하게 지정됨\n\n예를 들어 유해성, 간결성, 어조 등에 대한 일반 평가기와 함께 \"LLM 평가 도구 상자\"를 제공하는 LLM 평가 도구 산업이 성장하고 있음\n\n많은 팀이 자신의 도메인의 특정 실패 모드에 대해 비판적으로 생각하지 않고 이러한 도구를 채택하는 것을 봄\n\n이와 대조적으로 EvalGen은 사용자를 기준 지정, 데이터 레이블링, 평가 확인 등 각 단계에 깊이 참여시켜 도메인별 평가를 생성하는 프로세스를 사용자에게 가르치는 데 중점을 둠\n\n소프트웨어는 사용자를 다음과 같은 워크플로우로 안내함\n\nEvalGen이 안내하는 LLM 평가 제작의 모범 사례\n\n도메인별 테스트 정의(프롬프트에서 자동으로 부트스트랩됨)\n\n코드 또는 LLM-as-a-Judge로 어설션으로 정의됨\n\n테스트가 지정된 기준을 포착하는지 사용자가 확인할 수 있도록 테스트를 인간의 판단과 일치시키는 것의 중요성\n\n시스템(프롬프트 등)이 변경됨에 따라 테스트 반복\n\nEvalGen은 개발자에게 평가 구축 프로세스에 대한 멘탈 모델을 제공하지만 특정 도구에 고정하지는 않음\n\nAI 엔지니어에게 이러한 맥락을 제공한 후에는 종종 더 간단한 도구를 선택하거나 자체 도구를 구축하기로 결정한다는 것을 발견함\n\n프롬프트 작성 및 평가 이외에 LLM의 구성 요소가 너무 많아 여기에 모두 나열할 수 없음\n\n그러나 AI 엔지니어가 도구를 채택하기 전에 프로세스를 이해하려고 노력하는 것이 중요함\n\nA/B 테스트, 무작위 대조 시험뿐만 아니라 시스템의 가능한 가장 작은 구성 요소를 수정하고 오프라인 평가를 수행하는 빈번한 시도를 의미함\n\n모두가 평가에 열광하는 이유는 실제로 신뢰성과 자신감에 관한 것이 아니라 실험을 가능하게 하는 것임!\n\n평가가 더 좋을수록 실험을 더 빨리 반복할 수 있고, 따라서 시스템의 최상의 버전으로 더 빨리 수렴할 수 있음\n\n실험이 매우 저렴해졌기 때문에 동일한 문제를 해결하기 위해 다양한 접근 방식을 시도하는 것이 일반적임\n\n데이터 수집 및 모델 훈련의 높은 비용은 최소화됨\n\n프롬프트 엔지니어링 비용은 인간의 시간보다 조금 더 들음\n\n모든 사람이 프롬프트 엔지니어링의 기본을 배울 수 있도록 팀을 배치할 것\n\n이는 모든 사람이 실험하도록 장려하고 조직 전체에서 다양한 아이디어로 이어짐\n\n탐색을 위해서만 실험하지 말고 활용을 위해서도 실험을 사용할 것!\n\n팀의 다른 사람이 다른 방식으로 접근하는 것을 고려해 볼 것\n\nChain-of-Thought나 Few-Shot과 같은 프롬프트 기술을 조사하여 품질을 높일 것\n\n그렇다면 재구축하거나 개선할 수 있는 것을 구매할 것\n\n제품/프로젝트 기획 중에는 평가 구축 및 여러 실험 수행을 위한 시간을 따로 할애할 것\n\n엔지니어링 제품에 대한 제품 사양을 생각해 보고, 여기에 평가에 대한 명확한 기준을 추가할 것\n\n로드맵 작성 시 실험에 필요한 시간을 과소평가하지 말 것\n\n프로덕션 승인을 받기 전에 여러 번의 개발 및 평가 반복을 예상할 것\n\n모든 사람이 새로운 AI 기술을 사용할 수 있도록 권한 부여\n\n생성형 AI의 채택이 증가함에 따라 전문가뿐만 아니라 전체 팀이 이 새로운 기술을 이해하고 사용할 수 있다고 느끼기를 원함\n\nLLM이 어떻게 작동하는지(예: 지연 시간, 실패 모드, UX)에 대한 직관을 개발하는 더 좋은 방법은 없음\n\n파이프라인의 성능을 향상시키기 위해 코딩 방법을 알 필요가 없으며, 모든 사람이 프롬프트 엔지니어링 및 평가를 통해 기여할 수 있음\n\nn-shot 프롬프팅 및 CoT와 같은 기술이 모델을 원하는 출력 방향으로 조건화하는 데 도움이 되는 프롬프트 엔지니어링의 기초부터 시작할 수 있음\n\n지식을 가진 사람들은 LLM이 본질적으로 자기회귀적이라는 점과 같은 보다 기술적인 측면에 대해서도 교육할 수 있음\n\n즉, 입력 토큰은 병렬로 처리되지만 출력 토큰은 순차적으로 생성됨\n\n따라서 지연 시간은 입력 길이보다 출력 길이의 함수임\n\n이는 UX를 설계하고 성능 기대치를 설정할 때 주요 고려 사항임\n\n실험과 탐색을 위한 실습 기회를 제공할 수도 있음\n\n전체 팀이 며칠 동안 추측성 프로젝트를 해킹하는 데 시간을 보내는 것이 비싸 보일 수 있지만, 그 결과는 당신을 놀라게 할 수 있음\n\n해커톤을 통해 3년 로드맵을 1년 안에 거의 완료한 팀이 있음\n\n또 다른 팀은 해커톤을 통해 LLM 덕분에 이제 가능해진 패러다임을 전환하는 UX로 이어졌으며, 이제 올해와 그 이후의 우선 순위가 되었음\n\n\"AI 엔지니어링이 모든 것\"이라는 함정에 빠지지 말 것\n\n새로운 직책이 생겨날 때 이러한 역할과 관련된 능력을 과대평가하는 경향이 초기에 있음\n\n이는 종종 이러한 직업의 실제 범위가 명확해짐에 따라 고통스러운 수정으로 이어짐\n\n이 분야의 신참자와 채용 관리자는 과장된 주장을 하거나 과도한 기대를 할 수 있음\n\n지난 10년 동안의 주목할 만한 예는 다음과 같음\n\n데이터 과학자: \"모든 소프트웨어 엔지니어보다 통계학을 더 잘하고 모든 통계학자보다 소프트웨어 엔지니어링을 더 잘하는 사람\"\n\n머신러닝 엔지니어(MLE): 머신러닝에 대한 소프트웨어 엔지니어링 중심의 관점\n\n처음에는 많은 사람들이 데이터 기반 프로젝트에는 데이터 과학자만으로 충분하다고 가정함\n\n그러나 데이터 과학자는 데이터 제품을 효과적으로 개발하고 배포하기 위해 소프트웨어 및 데이터 엔지니어와 협력해야 한다는 것이 분명해짐\n\n이 오해는 AI 엔지니어라는 새로운 역할에서도 다시 나타났으며, 일부 팀은 AI 엔지니어가 필요한 전부라고 믿음\n\n실제로 머신러닝 또는 AI 제품을 구축하려면 광범위한 전문 역할이 필요함\n\n우리는 12개 이상의 회사와 AI 제품에 대해 상담했으며, 그들이 \"AI 엔지니어링이 필요한 전부\"라는 믿음의 함정에 빠지는 것을 일관되게 관찰함\n\n그 결과 제품 구축에 필요한 중요한 측면을 간과하면서 제품이 데모 이상으로 확장하는 데 어려움을 겪는 경우가 많음\n\n예를 들어 평가 및 측정은 Vibe 체크 이상으로 제품을 확장하는 데 중요함\n\n효과적인 평가를 위한 기술은 전통적으로 머신러닝 엔지니어에게서 볼 수 있는 강점 중 일부와 일치함\n\nAI 엔지니어로만 구성된 팀은 이러한 기술이 부족할 가능성이 높음\n\n공동 저자인 Hamel Husain은 데이터 편향 감지 및 도메인 특정 평가 설계와 관련된 최근 작업에서 이러한 기술의 중요성을 설명함\n\nAI 제품 구축 여정에서 필요한 역할 유형 및 시기\n\nAI 엔지니어가 포함될 수 있지만 반드시 필요한 것은 아님\n\nAI 엔지니어는 제품(UX, 배관 등)을 프로토타이핑하고 신속하게 반복하는 데 유용함\n\n다음으로 시스템을 계측하고 데이터를 수집하여 올바른 기반을 만들 것\n\n데이터 유형과 규모에 따라 플랫폼 및/또는 데이터 엔지니어가 필요할 수 있음\n\n또한 문제를 디버깅하기 위해 이 데이터를 쿼리하고 분석하는 시스템이 있어야 함\n\n기본 사항에는 지표 설계, 평가 시스템 구축, 실험 실행, RAG 검색 최적화, 확률적 시스템 디버깅 등의 단계가 포함됨\n\nMLE는 이 분야에 매우 능숙함(물론 AI 엔지니어도 습득할 수 있음)\n\n선행 단계를 완료하지 않은 경우 MLE를 고용하는 것은 보통 타당하지 않음\n\n작은 회사에서는 이상적으로 창업팀이 이 역할을 해야 하며, 큰 회사에서는 제품 관리자가 이 역할을 할 수 있음\n\n역할의 진행 및 타이밍을 인식하는 것이 중요함\n\n잘못된 시기에 사람들을 고용하거나(예: MLE를 너무 일찍 고용) 잘못된 순서로 구축하는 것은 시간과 비용 낭비이며 이직을 야기함\n\n또한 1-2단계에서 MLE와 정기적으로 체크인(그러나 정규직으로 고용하지는 않음)하면 회사가 올바른 기반을 구축하는 데 도움이 됨\n\n[전략: LLM을 활용한 구축에서 뒤처지지 않는 방법]\n\n성공적인 제품 개발을 위해서는 무작정 프로토타입을 만들거나 최신 모델이나 트렌드를 따라가기보다는 신중한 기획과 우선순위 설정이 필요함\n\nAI 제품 개발 시 직접 개발할 것인지 구매할 것인지 등의 주요 트레이드오프를 검토해야 함\n\n초기 LLM 애플리케이션 개발을 위한 \"플레이북\"을 제시함\n\n훌륭한 제품이 되려면 단순히 다른 사람의 API를 얇게 포장하는 것 이상이 되어야 함\n\n하지만 반대 방향의 실수는 더 큰 비용을 초래할 수 있음\n\n지난 해에는 명확한 제품 비전이나 목표 시장 없이 모델 학습과 커스터마이징에 막대한 벤처 자본이 쓰여졌음\n\n한 회사는 무려 60억 달러의 시리즈 A 투자를 받기도 함\n\n이 섹션에서는 즉시 자체 모델 학습을 시작하는 것이 왜 실수인지 설명하고, 자체 호스팅의 역할을 고려해 볼 것임\n\n처음부터 (거의) 다시 트레이닝 하는 것은 의미 없음\n\n대부분의 조직에게 처음부터 LLM을 프리트레이닝하는 것은 제품 개발에서 벗어난 비현실적인 일임\n\n머신러닝 인프라의 개발과 유지에는 많은 자원이 소요됨\n\n데이터 수집, 모델 학습과 평가, 배포 등이 포함됨\n\n제품-시장 적합성을 검증하는 단계라면 이러한 노력은 핵심 제품 개발에서 자원을 분산시킴\n\n컴퓨팅 자원, 데이터, 기술적 역량이 있다 해도 프리트레인된 LLM은 몇 달 안에 구식이 될 수 있음\n\n금융 업무에 특화된 LLM인 BloombergGPT는 363B 토큰으로 프리트레이닝되었음\n\nAI 엔지니어링 4명, ML 제품 및 연구 5명 등 9명의 전임 직원들의 엄청난 노력이 투입됨\n\n그럼에도 1년 내에 해당 업무에서 gpt-3.5-turbo와 gpt-4에 뒤쳐졌음\n\n이런 사례들은 대부분의 실제 애플리케이션에서 LLM을 처음부터 프리트레이닝하는 것이 자원의 최선의 활용법이 아님을 시사함\n\n대신 팀은 특정 요구사항에 맞춰 사용 가능한 가장 강력한 오픈소스 모델을 파인튜닝하는 것이 더 나음\n\nReplit의 코드 모델은 코드 생성과 이해에 특화되어 프리트레이닝된 훌륭한 사례임\n\n프리트레이닝으로 CodeLlama7b 등 더 큰 모델보다 우수한 성능을 보였음\n\n그러나 더 강력한 모델들이 출시됨에 따라 효용성 유지를 위해서는 지속적인 투자가 필요했음\n\n대부분의 조직에서 파인튜닝은 전략적 사고보다는 FOMO(Fear Of Missing Out, 놓칠 것에 대한 두려움)에 의해 주도됨\n\n조직은 \"단순한 래퍼\"라는 비난을 피하기 위해 너무 일찍 파인튜닝에 투자함\n\n실제로 파인튜닝은 다른 접근 방식으로는 충분하지 않다는 것을 확신시켜주는 많은 사례를 수집한 후에야 배포해야 할 중장비와 같음\n\n1년 전 많은 팀이 파인튜닝에 대해 기대감을 표했지만, 몇 안 되는 팀만이 제품-시장 적합성을 발견했고 대부분은 결정을 후회함\n\n파인튜닝을 할 거라면 기본 모델이 개선됨에 따라 반복해서 수행할 준비가 되어 있어야 함\n\n아래의 \"모델은 제품이 아님\"과 \"LLMOps 구축\"을 참조\n\n파인튜닝이 실제로 올바른 선택일 수 있는 경우\n\n기존 모델 학습에 사용된 대부분의 개방형 웹 규모 데이터셋에서 사용할 수 없는 데이터가 필요한 경우\n\n기존 모델로는 충분하지 않다는 것을 보여주는 MVP를 이미 구축한 경우\n\n그러나 주의해야 함: 훌륭한 학습 데이터를 모델 구축자가 쉽게 얻을 수 없다면 당신은 어디서 얻을 것인가?\n\nLLM 기반 애플리케이션은 과학 박람회 프로젝트가 아님\n\n전략적 목표와 경쟁 차별화에 대한 기여도에 상응하는 투자가 이루어져야 함\n\n추론 API로 시작하되, 셀프호스팅을 두려워하지 말 것\n\nLLM API를 사용하면 스타트업이 처음부터 자체 모델을 학습시키지 않고도 언어 모델링 기능을 쉽게 채택하고 통합할 수 있음\n\nAnthropic, OpenAI 등의 제공업체는 몇 줄의 코드만으로 제품에 인텔리전스를 부여할 수 있는 일반 API를 제공함\n\n이러한 서비스를 사용하면 노력을 줄이고 고객을 위한 가치 창출에 집중할 수 있어 아이디어를 검증하고 제품-시장 적합성을 더 빨리 반복할 수 있음\n\n그러나 데이터베이스와 마찬가지로 관리형 서비스는 규모와 요구사항이 증가함에 따라 모든 사용 사례에 적합하지 않음\n\n실제로 자체 호스팅은 의료 및 금융과 같은 규제 산업 또는 계약상 의무나 기밀 유지 요건에 의해 요구되는 대로 기밀/개인 데이터를 네트워크 외부로 보내지 않고 모델을 사용하는 유일한 방법일 수 있음\n\n또한 자체 호스팅은 추론 제공업체가 부과하는 속도 제한, 모델 사용 중단, 사용 제한 등의 제약을 우회함\n\n자체 호스팅은 모델에 대한 완전한 제어 권한을 제공하여 차별화되고 고품질의 시스템을 더 쉽게 구축할 수 있게 함\n\n마지막으로 자체 호스팅, 특히 파인튜닝은 대규모로 비용을 절감할 수 있음\n\n예를 들어 Buzzfeed는 오픈소스 LLM을 파인튜닝하여 비용을 80% 절감한 사례를 공유했음\n\n장기적으로 경쟁 우위를 유지하려면 모델을 넘어서 제품을 차별화할 수 있는 요소를 고려해야 함\n\n실행 속도가 중요하지만 그것이 유일한 장점이 되어서는 안 됨\n\n모델은 제품이 아님, 그 모델을 둘러싼 시스템이 제품임\n\n모델을 구축하지 않는 팀에게 혁신의 빠른 속도는 축복임\n\n컨텍스트 크기, 추론 능력, 가격 대비 가치 등의 향상을 추구하며 최신 모델로 마이그레이션하여 더 나은 제품을 만들 수 있기 때문\n\n종합하면 모델은 시스템에서 가장 지속성이 낮은 구성 요소일 가능성이 높음\n\n대신 지속적인 가치를 제공할 수 있는 부분에 노력을 집중해야 함\n\nEvals: 모델 전반에 걸쳐 작업 성능을 안정적으로 측정하기 위함\n\nGuardrails: 모델에 상관없이 원치 않는 출력을 방지하기 위함\n\nCaching: 모델을 완전히 피함으로써 지연 시간과 비용을 줄이기 위함\n\nData flywheel: 위의 모든 것의 반복적 개선을 추진하기 위함\n\n이러한 구성 요소는 원시 모델 기능보다 더 두꺼운 제품 품질의 해자를 만듦\n\n그러나 애플리케이션 계층에서 구축하는 것이 위험이 없다는 의미는 아님\n\nOpenAI나 다른 모델 제공업체가 실행 가능한 엔터프라이즈 소프트웨어를 제공하려면 가위로 잘라내야 할 부분에 가위질하지 말 것\n\n예를 들어 일부 팀은 독점 모델에서 구조화된 출력을 검증하기 위한 맞춤형 도구를 구축하는 데 투자했음\n\n여기에 최소한의 투자는 중요하지만 깊이 투자하는 것은 시간을 잘 활용하는 것이 아님\n\nOpenAI는 함수 호출을 요청할 때 유효한 함수 호출을 받을 수 있도록 해야 함. 모든 고객이 원하기 때문\n\n여기에 \"전략적 미루기\"를 적용하고, 절대적으로 필요한 것을 구축하고, 제공업체의 기능 확장을 기다릴 것\n\n모든 사람을 위한 모든 것이 되려고 하는 제품을 만드는 것은 평범함의 레시피임\n\n설득력 있는 제품을 만들기 위해 기업은 사용자가 계속 돌아오게 하는 끈적거리는 경험을 구축하는 데 전문화해야 함\n\n사용자가 묻는 모든 질문에 답하는 것을 목표로 하는 일반적인 RAG 시스템을 고려해 보자\n\n전문화가 부족하다는 것은 시스템이 최신 정보에 우선순위를 두거나, 도메인 특화 형식을 구문 분석하거나, 특정 작업의 뉘앙스를 이해할 수 없다는 것을 의미함\n\n그 결과 사용자는 얕고 신뢰할 수 없는 경험을 하게 되어 요구사항을 충족시키지 못하고 이탈하게 됨\n\n이를 해결하기 위해 특정 도메인과 사용 사례에 집중해야 함\n\n이렇게 하면 사용자에게 공감을 주는 도메인 특화 도구를 만들 수 있음\n\n전문화를 통해 시스템의 기능과 한계를 솔직하게 알릴 수 있음\n\n시스템이 할 수 있는 것과 할 수 없는 것에 대해 투명하게 공개하는 것은 자기 인식을 보여주고, 사용자가 어디에 가장 많은 가치를 더할 수 있는지 이해하는 데 도움이 되며, 결과적으로 출력에 대한 신뢰와 확신을 구축함\n\nLLMOps를 만들되, 적절한 이유를 가질 것 : 빠른 반복\n\nDevOps는 근본적으로 재현 가능한 워크플로우나 왼쪽 이동 또는 두 개의 피자 팀에 권한을 부여하는 것이 아님. YAML 파일을 작성하는 것은 더더욱 아님\n\nDevOps는 작업과 그 결과 사이의 피드백 주기를 단축하여 오류 대신 개선 사항이 축적되도록 하는 것임\n\n그 뿌리는 린 스타트업 운동을 통해 린 제조와 토요타 생산 시스템으로 거슬러 올라가며, 싱글 미닛 다이 교환과 카이젠을 강조함\n\nMLOps는 DevOps의 형태를 ML에 적용했음\n\n재현 가능한 실험과 모델 구축자가 배포할 수 있도록 권한을 부여하는 올인원 도구 제품군이 있음. YAML 파일도 많음\n\n그러나 업계로서 MLOps는 DevOps의 기능을 채택하지 않았음. 모델과 프로덕션에서의 추론 및 상호 작용 사이의 피드백 갭을 줄이지 않았음\n\n다행히도 LLMOps 분야는 프롬프트 관리와 같은 사소한 문제에서 벗어나 반복을 방해하는 어려운 문제인 프로덕션 모니터링과 평가로 연결되는 지속적인 개선으로 방향을 전환했음\n\n이미 채팅 및 코딩 모델에 대한 중립적이고 크라우드소싱된 평가를 위한 대화형 아레나가 있음. 집단적이고 반복적인 개선의 외부 루프임\n\nLangSmith, Log10, LangFuse, W&B Weave, HoneyHive 등의 도구는 프로덕션에서 시스템 결과에 대한 데이터를 수집하고 정리할 뿐만 아니라 개발과 깊이 통합하여 해당 시스템을 개선하는 데 활용할 것을 약속함. 이러한 도구를 수용하거나 자체적으로 구축하라\n\n구매할 수 있는 LLM 기능을 만들지 말 것\n\n대부분의 성공적인 비즈니스는 LLM 비즈니스가 아님. 동시에 대부분의 비즈니스에는 LLM으로 개선할 기회가 있음\n\n이 두 가지 관찰은 종종 리더를 오도하여 비용은 늘리고 품질은 떨어뜨리면서 LLM으로 시스템을 성급하게 개조하고 인조의 허영심 강한 \"AI\" 기능으로 출시하게 만듦. 지금은 두려워하는 반짝이 아이콘이 완성됨\n\n더 나은 방법이 있음: 제품 목표에 진정으로 부합하고 핵심 운영을 강화하는 LLM 애플리케이션에 집중할 것\n\n팀의 시간을 낭비하는 몇 가지 잘못된 시도를 고려해 보자\n\n비즈니스를 위한 맞춤형 text-to-SQL 기능 구축\n\n위의 사항들이 LLM 애플리케이션의 헬로 월드이지만 제품 회사가 직접 구축하는 것은 이치에 맞지 않음\n\n이는 많은 비즈니스에 공통된 일반적인 문제로 유망한 데모와 신뢰할 수 있는 구성 요소 사이의 격차가 크며 소프트웨어 회사의 관례적 영역임\n\n현재 Y Combinator 배치에서 대규모로 해결하고 있는 일반적인 문제에 귀중한 R&D 자원을 투자하는 것은 낭비임\n\n이것이 진부한 비즈니스 조언처럼 들린다면 현재 과대 광고 물결의 들뜬 흥분 속에서 \"LLM\"이라는 것을 최첨단의 차별화된 것으로 오해하기 쉽고 이미 낡아빠진 애플리케이션을 놓치기 쉽기 때문임\n\nAI를 루프안에 넣고, 사람을 중심에 둘 것\n\n현재 LLM 기반 애플리케이션은 취약함. 엄청난 양의 안전 조치와 방어적 엔지니어링이 필요하지만 여전히 예측하기 어려움. 게다가 엄격하게 범위가 지정되면 이러한 애플리케이션은 엄청나게 유용할 수 있음. 이는 LLM이 사용자 워크플로를 가속화하는 훌륭한 도구가 된다는 것을 의미함\n\nLLM 기반 애플리케이션이 워크플로를 완전히 대체하거나 직무 기능을 대신하는 것을 상상하고 싶을 수 있지만, 오늘날 가장 효과적인 패러다임은 인간-컴퓨터 켄타우로스(Centaur chess)임\n\n유능한 인간이 자신의 빠른 활용을 위해 조정된 LLM 기능과 결합하면 작업을 수행하는 생산성과 행복감이 크게 향상될 수 있음\n\nLLM의 대표적인 애플리케이션 중 하나인 GitHub CoPilot은 이러한 워크플로의 힘을 입증했음\n\n\"전반적으로 개발자들은 GitHub Copilot과 GitHub Copilot Chat을 사용할 때 코딩이 더 쉽고, 오류가 적으며, 가독성이 높고, 재사용성이 높으며, 간결하고, 유지 관리가 용이하며, 탄력적이라고 느꼈다고 말했습니다.\" - Mario Rodriguez, GitHub\n\n오랫동안 ML 작업을 해온 사람들은 \"human-in-the-loop\"라는 아이디어에 빠르게 도달할 수 있지만 그렇게 서두르지 말 것\n\nHITL 머신러닝은 ML 모델이 예측대로 동작하도록 보장하는 인간 전문가에 기반한 패러다임임\n\n여기서 제안하는 것은 관련되기는 하지만 더 미묘한 것임. LLM 기반 시스템은 오늘날 대부분의 워크플로의 주요 동력이 되어서는 안 되며, 단순히 자원이 되어야 함\n\n인간을 중심에 두고 LLM이 어떻게 워크플로를 지원할 수 있는지 묻는 것은 제품 및 설계 결정에 상당히 다른 영향을 미침\n\n궁극적으로 LLM에 모든 책임을 신속하게 아웃소싱하려는 경쟁업체와는 다른 제품, 즉 더 나은 제품, 더 유용하고 덜 위험한 제품을 만들게 될 것임\n\n전략 3. 프롬프팅, Eval, 데이터 수집으로 시작하기\n\n이전 섹션에서는 기술과 조언의 화력을 쏟아부었음. 받아들이기에 많은 양임. 유용한 조언의 최소 집합을 고려해 보자.\n\n팀이 LLM 제품을 만들고 싶다면 어디서부터 시작해야 할까?\n\n지난 1년 동안 성공적인 LLM 애플리케이션은 일관된 궤적을 따른다는 것을 확신할 만큼 충분히 봐왔음. 이 섹션에서는 이 기본적인 \"시작하기\" 플레이북을 살펴볼 것임\n\n핵심 아이디어는 간단하게 시작하고 필요에 따라 복잡성을 추가하는 것임\n\nRule of Thumb : 각 수준의 정교함은 일반적으로 이전 단계보다 최소한 한 자릿수 이상의 노력이 필요하다는 것임. 이를 염두에 두고...\n\n이전에 전술 섹션에서 논의한 모든 기술을 사용할 것\n\nChain-of-thought, n-shot 예제, 구조화된 입출력은 거의 항상 좋은 아이디어임\n\n약한 모델에서 성능을 짜내기 전에 가장 성능이 높은 모델로 프로토타입을 만들 것\n\n프롬프트 엔지니어링으로 원하는 성능 수준을 달성할 수 없는 경우에만 파인튜닝을 고려해야 함\n\n독점 모델 사용을 차단하고 자체 호스팅을 요구하는 비기능적 요구사항(예: 데이터 프라이버시, 완전한 제어, 비용)이 있는 경우 더 자주 발생할 것임\n\n동일한 프라이버시 요구사항이 파인튜닝을 위해 사용자 데이터 사용을 차단하지 않도록 주의할 것\n\n막 시작하는 팀도 평가(evals)가 필요함. 그렇지 않으면 프롬프트 엔지니어링이 충분한지 또는 파인튜닝된 모델이 기본 모델을 대�여 비용은 늘리고 품질은 떨어뜨리면서 LLM으로 시스템을 성급하게 개조하고 인조의 허영심 강한 \"AI\" 기능으로 출시하게 만듦. 지금은 두려워하는 반짝이 아이콘이 완성됨\n\n더 나은 방법이 있음: 제품 목표에 진정으로 부합하고 핵심 운영을 강화하는 LLM 애플리케이션에 집중할 것\n\n팀의 시간을 낭비하는 몇 가지 잘못된 시도를 고려해 보자\n\n비즈니스를 위한 맞춤형 text-to-SQL 기능 구축\n\n위의 사항들이 LLM 애플리케이션의 헬로 월드이지만 제품 회사가 직접 구축하는 것은 이치에 맞지 않음\n\n이는 많은 비즈니스에 공통된 일반적인 문제로 유망한 데모와 신뢰할 수 있는 구성 요소 사이의 격차가 크며 소프트웨어 회사의 관례적 영역임\n\n현재 Y Combinator 배치에서 대규모로 해결하고 있는 일반적인 문제에 귀중한 R&D 자원을 투자하는 것은 낭비임\n\n이것이 진부한 비즈니스 조언처럼 들린다면 현재 과대 광고 물결의 들뜬 흥분 속에서 \"LLM\"이라는 것을 최첨단의 차별화된 것으로 오해하기 쉽고 이미 낡아빠진 애플리케이션을 놓치기 쉽기 때문임\n\nAI를 루프안에 넣고, 사람을 중심에 둘 것\n\n현재 LLM 기반 애플리케이션은 취약함. 엄청난 양의 안전 조치와 방어적 엔지니어링이 필요하지만 여전히 예측하기 어려움. 게다가 엄격하게 범위가 지정되면 이러한 애플리케이션은 엄청나게 유용할 수 있음. 이는 LLM이 사용자 워크플로를 가속화하는 훌륭한 도구가 된다는 것을 의미함\n\nLLM 기반 애플리케이션이 워크플로를 완전히 대체하거나 직무 기능을 대신하는 것을 상상하고 싶을 수 있지만, 오늘날 가장 효과적인 패러다임은 인간-컴퓨터 켄타우로스(Centaur chess)임\n\n유능한 인간이 자신의 빠른 활용을 위해 조정된 LLM 기능과 결합하면 작업을 수행하는 생산성과 행복감이 크게 향상될 수 있음\n\nLLM의 대표적인 애플리케이션 중 하나인 GitHub CoPilot은 이러한 워크플로의 힘을 입증했음\n\n\"전반적으로 개발자들은 GitHub Copilot과 GitHub Copilot Chat을 사용할 때 코딩이 더 쉽고, 오류가 적으며, 가독성이 높고, 재사용성이 높으며, 간결하고, 유지 관리가 용이하며, 탄력적이라고 느꼈다고 말했습니다.\" - Mario Rodriguez, GitHub\n\n오랫동안 ML 작업을 해온 사람들은 \"human-in-the-loop\"라는 아이디어에 빠르게 도달할 수 있지만 그렇게 서두르지 말 것\n\nHITL 머신러닝은 ML 모델이 예측대로 동작하도록 보장하는 인간 전문가에 기반한 패러다임임\n\n여기서 제안하는 것은 관련되기는 하지만 더 미묘한 것임. LLM 기반 시스템은 오늘날 대부분의 워크플로의 주요 동력이 되어서는 안 되며, 단순히 자원이 되어야 함\n\n인간을 중심에 두고 LLM이 어떻게 워크플로를 지원할 수 있는지 묻는 것은 제품 및 설계 결정에 상당히 다른 영향을 미침\n\n궁극적으로 LLM에 모든 책임을 신속하게 아웃소싱하려는 경쟁업체와는 다른 제품, 즉 더 나은 제품, 더 유용하고 덜 위험한 제품을 만들게 될 것임\n\n전략 3. 프롬프팅, Eval, 데이터 수집으로 시작하기\n\n이전 섹션에서는 기술과 조언의 화력을 쏟아부었음. 받아들이기에 많은 양임. 유용한 조언의 최소 집합을 고려해 보자.\n\n팀이 LLM 제품을 만들고 싶다면 어디서부터 시작해야 할까?\n\n지난 1년 동안 성공적인 LLM 애플리케이션은 일관된 궤적을 따른다는 것을 확신할 만큼 충분히 봐왔음. 이 섹션에서는 이 기본적인 \"시작하기\" 플레이북을 살펴볼 것임\n\n핵심 아이디어는 간단하게 시작하고 필요에 따라 복잡성을 추가하는 것임\n\nRule of Thumb : 각 수준의 정교함은 일반적으로 이전 단계보다 최소한 한 자릿수 이상의 노력이 필요하다는 것임. 이를 염두에 두고...\n\n이전에 전술 섹션에서 논의한 모든 기술을 사용할 것\n\nChain-of-thought, n-shot 예제, 구조화된 입출력은 거의 항상 좋은 아이디어임\n\n약한 모델에서 성능을 짜내기 전에 가장 성능이 높은 모델로 프로토타입을 만들 것\n\n프롬프트 엔지니어링으로 원하는 성능 수준을 달성할 수 없는 경우에만 파인튜닝을 고려해야 함\n\n독점 모델 사용을 차단하고 자체 호스팅을 요구하는 비기능적 요구사항(예: 데이터 프라이버시, 완전한 제어, 비용)이 있는 경우 더 자주 발생할 것임\n\n동일한 프라이버시 요구사항이 파인튜닝을 위해 사용자 데이터 사용을 차단하지 않도록 주의할 것\n\n막 시작하는 팀도 평가(evals)가 필요함. 그렇지 않으면 프롬프트 엔지니어링이 충분한지 또는 파인튜닝된 모델이 기본 모델을 대체할 준비가 되었는지 알 수 없음\n\n효과적인 평가는 작업에 특화되어 있으며 의도한 사용 사례를 반영함\n\n권장하는 첫 번째 수준의 평가는 단위 테스트임\n\n이러한 간단한 어설션은 알려졌거나 가설로 설정된 실패 모드를 감지하고 초기 설계 결정을 내리는 데 도움이 됨\n\n분류, 요약 등을 위한 다른 작업별 평가도 참조할 것\n\n단위 테스트와 모델 기반 평가는 유용하지만 인간 평가의 필요성을 대체하지는 않음\n\n사람들이 모델/제품을 사용하고 피드백을 제공하도록 할 것\n\n이는 실제 성능과 결함률을 측정하는 동시에 향후 모델을 파인튜닝하는 데 사용할 수 있는 고품질의 주석 데이터를 수집한다는 이중 목적을 수행함\n\n이는 시간이 지남에 따라 복리로 작용하는 긍정적인 피드백 루프 또는 데이터 플라이휠을 만듦\n\n모델 성능을 평가하거나 결함을 찾기 위한 인간 평가\n\n주석 데이터를 사용하여 모델을 파인튜닝하거나 프롬프트를 업데이트\n\n예를 들어 LLM 생성 요약의 결함을 감사할 때 각 문장에 사실적 불일치, 무관함 또는 스타일 불량을 식별하는 세분화된 피드백 레이블을 지정할 수 있음\n\n그런 다음 이러한 사실적 불일치 주석을 사용하여 환각 분류기를 학습시키거나 관련성 주석을 사용하여 관련성 보상 모델을 학습시킬 수 있음\n\nLinkedIn은 환각, 책임감 있는 AI 위반, 일관성 등을 추정하기 위해 모델 기반 평가자를 사용한 성공 사례를 공유했음\n\n시간이 지남에 따라 가치가 증대되는 자산을 창출함으로써, 평가(evals) 구축을 단순한 운영 비용에서 전략적 투자로 전환하고, 그 과정에서 데이터 플라이휠을 구축\n\n전략 4. 저비용 인지의 고차원적 추세 (The high-level trend of low-cost cognition)\n\n1971년 Xerox PARC의 연구원들은 우리가 현재 살고 있는 네트워크로 연결된 개인용 컴퓨터의 세계를 예측했음\n\n그들은 이를 가능하게 한 기술(이더넷, 그래픽 렌더링, 마우스, 윈도우 등)의 발명에 중추적인 역할을 함으로써 그 미래를 탄생시키는 데 기여했음\n\n매우 유용하지만(예: 비디오 디스플레이) 아직 경제적이지 않은(비디오 디스플레이를 구동하기에 충분한 RAM이 수천 달러) 애플리케이션을 살펴봄\n\n그런 다음 해당 기술의 역사적 가격 추세(무어의 법칙과 유사)를 살펴보고 그 기술이 언제 경제적이 될지 예측함\n\nLLM 기술에 대해서도 같은 작업을 할 수 있음. 비록 달러당 트랜지스터 수만큼 깔끔한 것은 아니지만\n\n오랫동안 사용된 인기 있는 벤치마크(예: Massively-Multitask Language Understanding 데이터셋)와 일관된 입력 접근 방식(5-shot 프롬프팅)을 선택\n\n그런 다음 시간이 지남에 따라 이 벤치마크에서 다양한 성능 수준을 가진 언어 모델을 실행하는 비용을 비교\n\n고정 비용에 대해 능력이 빠르게 증가하고 있음. 고정된 능력 수준에 대해 비용이 빠르게 감소하고 있음\n\nOpenAI의 davinci 모델이 API로 출시된 이후 4년 동안, 100만 토큰(이 문서의 약 100개 사본) 규모에서 그 작업에 상응하는 성능을 가진 모델을 실행하는 비용은 $20에서 10센트 미만으로 떨어졌음. 반감기는 불과 6개월임\n\n유사하게 2024년 5월 기준 API 제공업체를 통하거나 자체적으로 Meta의 LLaMA 3 8B를 실행하는 비용은 토큰 100만 개당 20센트에 불과하며 ChatGPT를 가능하게 한 모델인 OpenAI의 text-davinci-003과 유사한 성능을 보임\n\n해당 모델은 2023년 11월 말 출시 당시에도 토큰 100만 개당 약 $20의 비용이 들었음. 불과 18개월 만에 두 자릿수 차이가 남. 무어의 법칙이 예측하는 단순한 두 배 증가와 동일한 기간임\n\n이제 매우 유용하지만(Park et al과 같은 생성적 비디오 게임 캐릭터 구동) 아직 경제적이지 않은(시간당 비용이 $625로 추정됨) LLM 애플리케이션을 고려해 보자\n\n해당 논문이 2023년 8월에 발표된 이후 비용은 시간당 약 $62.50로 한 자릿수 정도 떨어졌음\n\n9개월 후에는 시간당 $6.25로 떨어질 것으로 예상할 수 있음\n\n한편 팩맨이 1980년에 출시되었을 때 오늘날의 $1로 몇 분 또는 몇 십 분 동안 플레이할 수 있는 크레딧을 살 수 있었음. 시간당 6게임 또는 시간당 $6라고 부름\n\n이 냅킨 계산은 매력적인 LLM 강화 게임 경험이 2025년 경에는 경제적이 될 것임을 시사함\n\n이러한 추세는 새로운 것이며 불과 몇 년 되지 않았음. 그러나 앞으로 몇 년 동안 이 과정이 느려질 것이라고 기대할 만한 이유는 거의 없음\n\n매개변수당 ~20 토큰의 \"Chinchilla 비율\"을 넘어 스케일링하는 것과 같은 알고리즘과 데이터셋의 낮게 매달린 과일을 사용하더라도, 데이터 센터 내부와 실리콘 계층에서의 더 깊은 혁신과 투자는 그 격차를 메울 것임\n\n그리고 이것이 아마도 가장 중요한 전략적 사실일 것임\n\n오늘날 완전히 실현 불가능한 플로어 데모나 연구 논문이 몇 년 후에는 프리미엄 기능이 되고 그 직후에는 상품이 될 것임\n\n이를 염두에 두고 시스템과 조직을 구축해야 함\n\n[0에서 1로 가는 데모는 이제 충분함. 이제는 1에서 N으로 가는 제품을 만들 때]\n\nLLM 데모를 만드는 것은 정말 재미있음. 몇 줄의 코드, 벡터 데이터베이스, 신중하게 작성된 프롬프트로 \"마법\" 을 만들어냄\n\n지난 1년 동안 이 마법은 인터넷, 스마트폰, 심지어 인쇄술과 비교되었음\n\n안타깝게도 실제 소프트웨어 출시 작업을 해본 사람이라면 누구나 알고 있듯이, 통제된 환경에서 작동하는 데모와 대규모로 안정적으로 작동하는 제품 사이에는 엄청난 차이가 있음\n\n상상하고 데모를 만드는 것은 쉽지만 제품으로 만드는 것은 매우 어려운 문제들이 많음\n\n예를 들어 자율 주행: 자동차가 한 블록을 자율 주행하는 것은 쉽게 시연할 수 있지만, 이를 제품으로 만드는 데는 10년이 걸림 - Andrej Karpathy\n\n1988년 신경망으로 운전되는 첫 번째 자동차가 등장했음\n\n25년 후 Andrej Karpathy는 Waymo에서 첫 번째 데모 라이드를 했음\n\n그로부터 10년 후 회사는 무인 운전 허가를 받았음\n\n프로토타입에서 상용 제품으로 가기까지 35년 동안 엄격한 엔지니어링, 테스트, 개선, 규제 탐색이 이루어졌음\n\n산업계와 학계 전반에 걸쳐 지난 1년 동안의 기복을 관찰했음 : LLM 애플리케이션의 1년차 (Year 1 of N for LLM applications)\n\n평가, 프롬프트 엔지니어링, 가드레일과 같은 전술부터 운영 기술, 팀 구축, 내부적으로 구축할 기능 선택과 같은 전략적 관점에 이르기까지 우리가 배운 교훈이 2년차 이후에 도움이 되기를 바람\n\n이 흥미로운 새로운 기술을 함께 발전시켜 나가길 기대함\n\nAI 시대, 0→1 서비스에서 오픈보다 운영이 더 중요한 이유\n\nAI-Powered 기능을 구축하며 배운 것들\n\nLean Analytics, AI와 에이전트 시대에 맞춰 돌아보기\n\n인증 이메일 클릭후 다시 체크박스를 눌러주세요\n\ninthelife 2024-06-17 [-]\n\n내용이 좋아서, 두고두고 보려고 Mindmap으로 만들어 보았습니다 ^^;\n\nhttps://drive.google.com/file/d/…\n\n너무 좋은 글입니다!! 처음부터 끝까지 유용하게 곱씹어볼말들이 많습니다. 이렇게 주옥 같은 글을 번역해서 올려주셔서 감사합니다!!\n\n이제 스카이넷은 끝났어, 메가스터디가 온다.\n\nmy0075425 2024-06-11 [-]\n\n심리학 전공자가 Lazada의 데이터 사이언스VP가 된 방법\n\n와.. 엄청나게 자극이 되네요.. 소개 감사합니다\n\nhumblebee 2024-06-10 [-]\n\n통찰력과 경험이 생생하게 느끼져는 멋진 글이에요! 저와 팀에게 있어 큰 도움이 될것같습니다. 너무 잘 읽었습니다. 감사합니다 ☺️\n\n처음 오셨나요 사이트 이용법 FAQ About 긱배지 이용약관 개인정보 처리방침\n\n| Blog Lists RSS | Bookmarklet\n\nX (Twitter) Facebook | 긱뉴스봇 : Slack 잔디 Discord Teams Dooray! Google Chat Swit\n\n시작하기 이용법 FAQ About 긱배지 약관 개인정보", "htmlBody": "

GeekNews 최신글 예전글 쓰레드 댓글 Ask Show GN⁺ Weekly | 글등록

(function () { if (typeof window.getCachedNavState !== 'function' || typeof window.applyNavState !== 'function') { return; } var payload = window.getCachedNavState(); if (payload) { window.applyNavState(payload); } })();

1년 동안 LLM과 함께 구축하며 배운 점

75 P by xguru 2024-06-10 | ★ favorite | 댓글 9개

  • 대규모 언어 모델(LLM)을 사용한 개발이 흥미로운 시기임\n\n지난 1년 동안 LLM이 실제 애플리케이션에 "충분히 좋은" 수준이 되었으며, 매년 더 좋아지고 저렴해지고 있음
  • 소셜 미디어의 데모와 함께, 2025년까지 AI에 약 2000억 달러가 투자될 것으로 추정됨
  • 업체들의 API로 인해 LLM이 더 접근하기 쉬워져, ML 엔지니어와 과학자뿐만 아니라 모두가 제품에 인텔리젠스를 구축할 수 있게 됨

AI로 구축하는 진입 장벽은 낮아졌지만, 데모 이상으로 효과적인 제품과 시스템을 만드는 것은 여전히 어려움 우리는 지난 1년 동안 구축해 왔으며, 그 과정에서 많은 어려움을 발견했음

  • 우리의 실수를 피하고 더 빠르게 반복할 수 있도록 우리가 배운 내용을 공유하고자 함
  • Tactical(전술적) : 프롬프팅, RAG, 워크플로우 엔지니어링, 평가 및 모니터링을 위한 몇 가지 실천 사항\n\nLLM으로 구축하는 실무자나 주말 프로젝트를 진행하는 사람들을 위해 작성됨

Operational(운영적) : 제품 출시의 조직적, 일상적 관심사와 효과적인 팀 구축 방법

  • 지속 가능하고 안정적으로 배포하려는 제품/기술 리더를 위한 내용

Strategic(전략적) : "PMF 전에 GPU 없음", "모델이 아닌 시스템에 집중" 등의 의견을 담은 장기적이고 큰 그림 관점과 반복하는 방법

  • 창업자와 경영진을 염두에 두고 작성됨

이 가이드는 LLM을 사용하여 성공적인 제품을 구축하기 위한 실용적인 안내서가 되는 것을 목표로 함

  • 우리 자신의 경험에서 비롯되었으며 업계 전반의 사례를 제시함
  • 이 섹션에서는 새로 등장하는 LLM 스택의 핵심 구성 요소에 대한 모범 사례를 공유함\n\n품질과 신뢰성을 높이기 위한 프롬프팅 팁, 출력을 평가하기 위한 전략, 그라운딩을 개선하기 위한 검색 증강 생성 아이디어 등이 포함됨
  • 또한 휴먼 인 더 루프 워크플로우를 설계하는 방법도 탐구할 예정임
  • 새로운 애플리케이션을 개발할 때는 프롬프팅으로 시작할 것을 권장함\n\n프롬프팅의 중요성을 과소평가하거나 과대평가하기 쉬움
  • 올바른 프롬프팅 기술을 제대로 사용하면 매우 멀리 갈 수 있기 때문에 과소평가되는 경향이 있음
  • 프롬프트 기반 애플리케이션도 제대로 작동하려면 프롬프트 주변에 상당한 엔지니어링이 필요하기 때문에 과대평가되는 경향이 있음

기본 프롬프팅 기술을 최대한 활용하는 데 집중

  • 다양한 모델과 작업에서 성능 향상에 지속적으로 도움이 되는 몇 가지 프롬프팅 기술이 있음\n\nN-shot 프롬프트 + 문맥 내 학습, 사고의 연쇄(Chain-of-Thought), 관련 리소스 제공 등임

N-shot 프롬프트와 문맥 내 학습

  • N-shot 프롬프트를 통한 문맥 내 학습의 아이디어는 LLM에게 작업을 시연하고 출력을 기대에 맞추는 몇 가지 예시를 제공하는 것임
  • N이 너무 낮으면 모델이 해당 특정 예시에 과도하게 고정되어 일반화 능력이 저하될 수 있음
  • 경험적으로 N ≥ 5를 목표로 하고, 수십 개까지 사용하는 것을 두려워하지 말 것
  • 예시는 예상되는 입력 분포를 대표해야 함
  • 전체 입출력 쌍을 제공할 필요는 없으며, 많은 경우 원하는 출력의 예시로 충분함
  • 도구 사용을 지원하는 LLM을 사용하는 경우, N-shot 예시에서도 에이전트가 사용하기를 원하는 도구를 사용해야 함

사고의 연쇄(Chain-of-Thought, CoT) 프롬프팅

  • CoT 프롬프팅에서는 LLM이 최종 답변을 반환하기 전에 사고 과정을 설명하도록 장려함
  • LLM에게 메모리에서 모든 것을 수행할 필요가 없도록 스케치패드를 제공하는 것으로 생각할 수 있음
  • 원래 접근 방식은 단순히 "단계별로 생각해 보자"라는 문구를 지침의 일부로 추가하는 것이었지만, CoT를 더 구체적으로 만드는 것이 도움이 된다는 것을 발견함
  • 1~2문장으로 구체성을 추가하면 환각 발생률이 상당히 감소하는 경우가 많음
  • 최근에는 이 기술이 믿는 만큼 강력한지에 대해 의문이 제기되고 있음
  • 또한 CoT가 사용될 때 추론 중에 정확히 어떤 일이 일어나는지에 대해 상당한 논쟁이 있음
  • 그럼에도 불구하고 가능한 경우 실험해 볼 만한 기술임
  • 관련 리소스를 제공하는 것은 모델의 지식 기반을 확장하고, 환각을 줄이며, 사용자의 신뢰를 높이는 강력한 메커니즘임
  • 검색 증강 생성(Retrieval Augmented Generation, RAG)을 통해 수행되는 경우가 많음
  • 모델에 응답에 직접 활용할 수 있는 텍스트 스니펫을 제공하는 것은 필수적인 기술임
  • 관련 리소스를 제공할 때는 단순히 포함하는 것으로는 충분하지 않음\n\n모델에게 리소스 사용을 우선시하고, 직접 참조하며, 때로는 리소스가 충분하지 않을 때 언급하도록 지시하는 것을 잊지 말아야 함

이러한 것들은 에이전트 응답을 리소스 코퍼스에 "Ground"하는 데 도움이 됨

  • 구조화된 입력과 출력은 모델이 입력을 더 잘 이해하고 다운스트림 시스템과 안정적으로 통합할 수 있는 출력을 반환하는 데 도움이 됨\n\n입력에 직렬화 형식을 추가하면 컨텍스트의 토큰 간 관계, 특정 토큰에 대한 추가 메타데이터(유형 등) 또는 요청을 모델 학습 데이터의 유사한 예시와 관련시키는 데 도움이 될 수 있음
  • 예를 들어, 인터넷에서 SQL 작성에 대한 많은 질문은 SQL 스키마를 지정하는 것으로 시작함\n\n따라서 Text-to-SQL에 대한 효과적인 프롬프팅에는 구조화된 스키마 정의가 포함되어야 함

구조화된 출력은 유사한 목적을 수행하지만, 시스템의 다운스트림 구성 요소로의 통합을 단순화함

  • Instructor와 Outlines는 구조화된 출력에 잘 작동함\n\n(LLM API SDK를 가져오는 경우 Instructor를 사용하고, 자체 호스팅 모델에 Huggingface를 가져오는 경우 Outlines를 사용)

구조화된 입력은 작업을 명확하게 표현하고 학습 데이터의 형식과 유사하므로 더 나은 출력 가능성을 높임

구조화된 입력을 사용할 때는 각 LLM 제품군마다 선호하는 방식이 있다는 점에 유의해야 함

  • Claude는 <xml> 을 선호하는 반면 GPT는 Markdown과 JSON을 선호함
  • XML을 사용하면 <response> 태그를 제공하여 Claude의 응답을 미리 채울 수도 있음

작고 한 가지 일을 잘하는 프롬프트를 만들 것

  • 소프트웨어에서 흔한 안티 패턴/코드 스멜은 모든 것을 수행하는 단일 클래스나 함수인 "God Object"임\n\n이는 프롬프트에도 동일하게 적용됨

프롬프트는 일반적으로 간단하게 시작함

  • 몇 문장의 지침, 몇 가지 예시로 시작할 수 있음
  • 그러나 성능을 개선하고 더 많은 edge case를 처리하려고 하면서 복잡성이 증가함\n\n더 많은 지침, 다단계 추론, 수십 개의 예시 등이 추가됨

결국 처음에는 단순했던 프롬프트가 2,000 토큰의 프랑켄슈타인이 되어버림

  • 게다가 더 일반적이고 직관적인 입력에 대한 성능은 오히려 저하됨
  • GoDaddy는 이 문제를 LLM 구축에서 얻은 교훈 중 1위로 꼽음

시스템과 코드를 단순하게 유지하려고 노력하는 것처럼, 프롬프트도 마찬가지여야 함

  • 회의 녹취록 요약기에 대해 단일 만능 프롬프트를 사용하는 대신, 다음과 같은 단계로 나눌 수 있음\n\n주요 결정 사항, 조치 항목 및 담당자를 구조화된 형식으로 추출
  • 추출된 세부 정보를 원본 녹취록과 비교하여 일관성 확인
  • 구조화된 세부 정보에서 간결한 요약 생성

결과적으로 단일 프롬프트를 여러 개의 단순하고 집중적이며 이해하기 쉬운 프롬프트로 분할함

  • 이렇게 분할하면 이제 각 프롬프트를 개별적으로 반복하고 평가할 수 있음
  • 에이전트에 실제로 전송해야 하는 컨텍스트의 양에 대한 가정을 재고하고 도전해야 함\n\n미켈란젤로처럼 컨텍스트 조각상을 만들어 가는 것이 아니라, 불필요한 재료를 깎아내어 조각상을 드러내야 함
  • RAG는 잠재적으로 관련된 대리석 블록을 모두 모으는 데 널리 사용되는 방법이지만, 필요한 것을 추출하기 위해 무엇을 하고 있는가?

모델에 전송되는 최종 프롬프트를 가져와 모든 컨텍스트 구성, 메타 프롬프팅, RAG 결과와 함께 빈 페이지에 배치하고 읽어보는 것이 컨텍스트를 재고하는 데 도움이 된다는 것을 발견함

  • 이 방법을 사용하여 중복, 자가 모순적인 언어 및 형식이 잘못된 부분을 발견할 수 있음

다른 핵심 최적화는 컨텍스트의 구조임

  • Bag-of-docs 표현은 인간에게 도움이 되지 않으므로 에이전트에게 좋다고 가정하지 말아야 함
  • 컨텍스트의 각 부분 간의 관계를 강조하고 추출을 가능한 한 단순하게 만들 수 있도록 컨텍스트를 구성하는 방법을 신중하게 고려해야 함
  • 프롬프팅 외에도 LLM을 조정하는 또 다른 효과적인 방법은 프롬프트의 일부로 지식을 제공하는 것임\n\n이는 제공된 컨텍스트에 LLM을 ground시키며, 이는 문맥 내 학습에 사용됨
  • 이를 검색 증강 생성(Retrieval-Augmented Generation, RAG)이라고 함
  • 실무자들은 RAG가 지식을 제공하고 출력을 개선하는 데 효과적이며, 미세 조정에 비해 훨씬 적은 노력과 비용이 든다는 것을 발견함
  • RAG는 검색된 문서의 관련성, 밀도 및 세부 정보만큼만 좋음

RAG 출력의 품질은 검색된 문서의 품질에 따라 달라지며, 몇 가지 요소를 고려할 수 있음

  • 첫 번째이자 가장 명백한 척도는 "관련성"\n\n이는 일반적으로 평균 역순위(Mean Reciprocal Rank, MRR) 또는 정규화된 할인 누적 이득(Normalized Discounted Cumulative Gain, NDCG)과 같은 순위 지표로 정량화됨
  • MRR은 시스템이 순위 목록에서 첫 번째 관련 결과를 얼마나 잘 배치하는지 평가하는 반면, NDCG는 모든 결과의 관련성과 위치를 고려함
  • 이들은 관련 문서를 더 높이, 관련 없는 문서를 더 낮게 순위를 매기는 시스템의 성능을 측정함
  • 예를 들어, 영화 리뷰 요약을 생성하기 위해 사용자 요약을 검색하는 경우, 특정 영화에 대한 리뷰를 더 높이 순위를 매기고 다른 영화에 대한 리뷰는 제외하는 것이 좋음
  • 전통적인 추천 시스템과 마찬가지로 검색된 항목의 순위는 LLM이 다운스트림 작업을 수행하는 방식에 상당한 영향을 미침
  • 영향을 측정하려면 검색된 항목을 섞은 상태에서 RAG 기반 작업을 실행해 보고, RAG 출력이 어떻게 수행되는지 확인할 것
  • 두 문서가 동일하게 관련된 경우, 더 간결하고 관련 없는 세부 정보가 적은 문서를 선호해야 함
  • 영화 예로 돌아가면, 영화 대본과 모든 사용자 리뷰를 광범위한 의미에서 관련이 있다고 간주할 수 있음
  • 그럼에도 불구하고 높은 평가를 받은 리뷰와 편집 리뷰는 정보 밀도가 더 높을 가능성이 있음

마지막으로, 문서에 제공된 "세부 정보 수준"을 고려할 것

  • 자연어에서 SQL 쿼리를 생성하기 위한 RAG 시스템을 구축한다고 상상해 보자\n\n열 이름이 있는 테이블 스키마를 컨텍스트로 제공하는 것만으로도 충분할 수 있음
  • 그러나 열 설명과 일부 대표 값을 포함하면 어떨까?

추가 세부 정보는 LLM이 테이블의 의미를 더 잘 이해하고 더 정확한 SQL을 생성하는 데 도움이 될 수 있음

키워드 검색을 잊지 말고, 기준선과 하이브리드 검색에 사용할 것

  • Embedding 기반 RAG 데모가 널리 퍼져 있기 때문에 정보 검색 분야의 수십 년 간의 연구와 솔루션을 잊거나 간과하기 쉬움\n\n그럼에도 불구하고 임베딩은 의심할 여지 없이 강력한 도구이지만, 만능은 아님
  • 첫째, 임베딩은 높은 수준의 의미론적 유사성을 포착하는 데 탁월하지만, 사용자가 이름(예: Ilya), 두문자어(예: RAG) 또는 ID(예: claude-3-sonnet)를 검색할 때와 같이 더 구체적이고 키워드 기반의 쿼리에는 어려움을 겪을 수 있음\n\nBM25와 같은 키워드 기반 검색은 이를 위해 명시적으로 설계됨
  • 사용자들은 키워드 기반 검색을 오랫동안 사용해 왔기 때문에 당연한 것으로 여기고 있을 것이며, 검색하고자 하는 문서가 반환되지 않으면 좌절감을 느낄 수 있음

둘째, 키워드 검색으로 문서가 검색된 이유를 이해하는 것이 더 직관적임

  • 쿼리와 일치하는 키워드를 확인할 수 있기 때문
  • 반면에 임베딩 기반 검색은 해석 가능성이 낮음

셋째, 수십 년 동안 최적화되고 실전에서 검증된 Lucene이나 OpenSearch와 같은 시스템 덕분에 키워드 검색이 일반적으로 더 계산적으로 효율적임

대부분의 경우 하이브리드 접근 방식이 가장 효과적

  • 명백한 일치 항목에는 키워드 매칭을 사용하고, 동의어, 상위어, 철자 오류 및 멀티모달(예: 이미지와 텍스트)에는 임베딩을 사용
  • Shortwave는 쿼리 재작성, 키워드 + 임베딩 검색, 랭킹 등 자신들의 RAG 파이프라인을 어떻게 구축했는지 공유 한바 있음

새로운 지식에 대해서는 파인튜닝보다 RAG를 선호

  • RAG와 파인튜닝 모두 새로운 정보를 LLM에 통합하고 특정 작업에 대한 성능을 향상시키는 데 사용될 수 있음\n\n그렇다면 어떤 것을 먼저 시도해야 할까?
  • 최근 연구에 따르면 RAG가 더 우수할 수 있음
  • 한 연구에서는 RAG와 비지도 미세 조정(지속적 사전 학습이라고도 함)을 MMLU와 시사 문제의 하위 집합에서 평가하여 비교함\n\nRAG가 학습 중에 접한 지식과 완전히 새로운 지식 모두에 대해 미세 조정보다 지속적으로 더 우수한 성능을 보였음

다른 논문에서는 농업 데이터 세트에 대해 RAG와 지도 미세 조정을 비교함

  • 마찬가지로 RAG의 성능 향상이 미세 조정보다 컸으며, 특히 GPT-4에서 두드러짐(논문의 표 20 참조)

성능 향상 외에도 RAG는 여러 실용적인 장점이 있음

  • 첫째, 지속적인 사전 학습이나 미세 조정에 비해 검색 인덱스를 최신 상태로 유지하는 것이 더 쉽고 저렴함
  • 둘째, 검색 인덱스에 유해하거나 편향된 내용이 포함된 문제가 있는 문서가 있는 경우 문제가 있는 문서를 쉽게 삭제하거나 수정할 수 있음

또한 RAG의 R은 문서를 검색하는 방법에 대해 더 세분화된 제어를 제공함

  • 예를 들어 여러 조직을 위해 RAG 시스템을 호스팅하는 경우, 검색 인덱스를 분할하여 각 조직이 자체 인덱스의 문서만 검색할 수 있도록 할 수 있음
  • 이렇게 하면 한 조직의 정보를 실수로 다른 조직에 노출하는 일이 없도록 할 수 있음

장문 컨텍스트 모델이 RAG를 쓸모없게 만들지는 않을 것임

  • Gemini 1.5가 최대 1,000만 토큰 크기의 컨텍스트 윈도우를 제공함에 따라 일부에서는 RAG의 미래에 의문을 제기하기 시작함\n\n1,000만 토큰의 컨텍스트 윈도우는 기존 RAG 프레임워크 대부분을 불필요하게 만듦\n\n데이터를 컨텍스트에 넣고 평소처럼 모델과 대화하기만 하면 됨

이는 대부분의 엔지니어링 노력이 RAG에 투입되는 스타트업, 에이전트, langchain 프로젝트에 어떤 영향을 줄지 상상해 보라

  • 한 문장으로 요약하면 1,000만 컨텍스트가 RAG를 죽인다는 것
  • 장문 컨텍스트가 여러 문서 분석이나 PDF와의 채팅 등의 사용 사례에 게임 체인저가 될 것이라는 점은 사실이지만, RAG의 종말에 대한 소문은 크게 과장됨\n\n첫째, 1,000만 토큰의 컨텍스트 윈도우가 있더라도 모델에 입력할 정보를 선택하는 방법이 여전히 필요함
  • 둘째, 좁은 바늘구멍 평가를 넘어서 모델이 그렇게 큰 컨텍스트에 대해 효과적으로 추론할 수 있다는 설득력 있는 데이터는 아직 보지 못함
  • 따라서 좋은 검색(및 랭킹) 없이는 주의를 분산시키는 정보로 모델을 압도하거나 심지어 완전히 무관한 정보로 컨텍스트 윈도우를 채울 위험이 있음
  • Transformer의 추론 비용은 컨텍스트 길이에 따라 제곱(또는 공간과 시간 모두에서 선형)으로 증가함
  • 조직의 전체 Google Drive 내용을 읽을 수 있는 모델이 존재한다고 해서 각 질문에 답하기 전에 그렇게 하는 것이 좋은 생각은 아님
  • RAM 사용 방식에 대한 비유를 고려해 보자\n\n수십 테라바이트에 달하는 RAM이 있는 컴퓨팅 인스턴스가 존재하지만, 여전히 디스크에서 읽고 쓰고 있음

따라서 아직 RAG를 쓰레기통에 버리지 말 것

  • 이 패턴은 컨텍스트 윈도우의 크기가 커질수록 여전히 유용할 것임

전술 3. 워크플로우 튜닝 및 최적화

  • LLM에 프롬프트를 주는 것은 시작에 불과함\n\nLLM을 최대한 활용하려면 단일 프롬프트를 넘어 워크플로우를 수용해야 함
  • 예를 들어, 복잡한 단일 작업을 여러 개의 더 간단한 작업으로 어떻게 분할할 수 있을까?
  • 미세 조정이나 캐싱이 성능 향상과 지연/비용 감소에 도움이 되는 시점은 언제일까?

이 섹션에서는 검증된 전략과 실제 사례를 공유하여 LLM 워크플로우를 최적화하고 구축하는 데 도움을 줌

단계별, 다중 턴 "Flow"는 큰 성능 향상을 제공할 수 있음

  • 하나의 큰 프롬프트를 여러 개의 작은 프롬프트로 분해함으로써 더 나은 결과를 얻을 수 있다는 것을 이미 알고 있음\n\nAlphaCodium이 그 예시임\n\n단일 프롬프트에서 다단계 워크플로우로 전환함으로써 CodeContests에서 GPT-4 정확도(pass@5)를 19%에서 44%로 높임
  • 공개 테스트에 대한 추론
  • 가능한 솔루션 순위 매기기
  • 공개 및 합성 테스트에 대한 솔루션 반복

명확한 목표를 가진 작은 작업은 최상의 에이전트 또는 흐름 프롬프트를 만듦

  • 모든 에이전트 프롬프트가 구조화된 출력을 요청할 필요는 없지만, 구조화된 출력은 에이전트의 환경과의 상호 작용을 조정하는 시스템과의 인터페이스에 큰 도움이 됨
  • 가능한 한 엄격하게 지정된 명시적 계획 단계\n\n미리 정의된 계획 중에서 선택하는 것을 고려할 것

원래 사용자 프롬프트를 에이전트 프롬프트로 다시 작성

  • 이 과정에서 정보 손실이 발생하므로 주의할 것

선형 체인, DAG 및 상태 머신으로서의 에이전트 동작

  • 다양한 종속성과 논리 관계는 서로 다른 규모에 더 적합하거나 덜 적합할 수 있음
  • 다양한 작업 아키텍처에서 성능 최적화를 이끌어낼 수 있을까?
  • 계획에는 최종 결과물이 잘 작동하도록 다른 에이전트의 응답을 평가하는 방법에 대한 지침을 포함할 수 있음

고정된 업스트림 상태로 프롬프트 엔지니어링

  • 에이전트 프롬프트가 이전에 발생할 수 있는 다양한 변형에 대해 평가되는지 확인할 것

현재로서는 결정론적 워크플로우에 우선순위를 둘 것

  • AI 에이전트는 사용자 요청과 환경에 동적으로 반응할 수 있지만, 이들의 비결정론적 특성은 배포에 어려움을 줌\n\n에이전트가 수행하는 각 단계는 실패할 가능성이 있으며, 오류에서 복구할 가능성은 낮음
  • 따라서 에이전트가 다단계 작업을 성공적으로 완료할 가능성은 단계 수가 증가함에 따라 기하급수적으로 감소함
  • 그 결과 에이전트를 구축하는 팀은 신뢰할 수 있는 에이전트를 배포하는 데 어려움을 겪음

유망한 접근 방식은 결정론적 계획을 생성하고 이를 구조화되고 재현 가능한 방식으로 실행하는 에이전트 시스템을 갖는 것임

  • 첫 번째 단계에서는 상위 수준의 목표나 프롬프트가 주어지면 에이전트가 계획을 생성함
  • 그런 다음 계획이 결정론적으로 실행됨
  • 이를 통해 각 단계를 보다 예측 가능하고 신뢰할 수 있게 만들 수 있음
  • 장점\n\n생성된 계획은 에이전트에 프롬프트를 제공하거나 미세 조정하기 위한 few-shot 샘플로 사용될 수 있음
  • 결정론적 실행은 시스템을 더 신뢰할 수 있게 만들어 테스트와 디버깅이 더 쉬워짐. 또한 실패는 계획의 특정 단계로 추적될 수 있음
  • 생성된 계획은 방향성 비순환 그래프(DAG)로 표현될 수 있으며, 정적 프롬프트에 비해 이해하고 새로운 상황에 적응하기 쉬움

가장 성공적인 에이전트 구축자는 주니어 엔지니어를 관리하는 데 강력한 경험을 가진 사람일 수 있음

  • 계획 생성 과정은 주니어를 지시하고 관리하는 방식과 유사하기 때문
  • 주니어에게 모호하고 개방적인 방향 대신 명확한 목표와 구체적인 계획을 제공하는 것처럼, 에이전트에게도 동일하게 해야 함

결국 신뢰할 수 있고 작동하는 에이전트의 핵심은

  • 보다 구조화되고 결정론적인 접근 방식을 채택하고,
  • 프롬프트를 개선하고 모델을 미세 조정하기 위한 데이터를 수집하는 데서 발견될 가능성이 높음

이것 없이는 때때로 매우 잘 작동할 수 있지만 평균적으로 사용자를 실망시켜 유지력이 낮아지는 에이전트를 구축하게 될 것임

온도 매개변수 이상의 다양한 출력 얻기

  • LLM의 출력에 다양성이 필요한 작업이 있다고 가정해 보자\n\n사용자가 이전에 구매한 제품 목록을 고려하여 카탈로그에서 구매할 제품을 제안하는 LLM 파이프라인을 작성하고 있을 수 있음
  • 프롬프트를 여러 번 실행할 때 결과 추천이 너무 유사하다는 것을 알 수 있음
  • 따라서 LLM 요청의 Temperature(온도) 매개변수를 높일 수 있음

온도 매개변수를 높이면 LLM 응답이 더 다양해짐

  • 샘플링 시 다음 토큰의 확률 분포가 더 평평해져 일반적으로 선택될 가능성이 낮은 토큰이 더 자주 선택됨

그러나 온도를 높일 때 출력 다양성과 관련된 일부 실패 모드가 발생할 수 있음

  • 예를 들어 카탈로그의 일부 제품이 적합할 수 있지만 LLM에 의해 출력되지 않을 수 있음
  • LLM이 학습 시 배운 내용을 기반으로 프롬프트를 따를 가능성이 높은 경우 동일한 소수의 제품이 출력에서 과대 대표될 수 있음
  • 온도가 너무 높으면 존재하지 않는 제품(또는 무의미한 내용)을 참조하는 출력이 생성될 수 있음

온도를 높인다고 해서 LLM이 예상하는 확률 분포(예: 균일 무작위)에서 출력을 샘플링한다는 보장은 없음 그럼에도 불구하고 출력 다양성을 높이기 위한 다른 트릭이 있음

  • 가장 간단한 방법은 프롬프트 내 요소를 조정하는 것\n\n예를 들어 프롬프트 템플릿에 과거 구매 내역과 같은 항목 목록이 포함된 경우, 이러한 항목을 프롬프트에 삽입할 때마다 순서를 섞으면 상당한 차이를 만들 수 있음

또한 최근 출력의 짧은 목록을 유지하면 중복을 방지하는 데 도움이 됨

  • 추천 제품 예시에서 LLM에 이 최근 목록에서 항목 제안을 피하도록 지시하거나, 최근 제안과 유사한 출력을 거부하고 재샘플링함으로써 응답을 더욱 다양화할 수 있음

또 다른 효과적인 전략은 프롬프트에 사용되는 표현을 다양화하는 것

  • 예를 들어 "사용자가 정기적으로 사용하는 것을 좋아할 항목 선택" 또는 "사용자가 친구에게 추천할 가능성이 높은 제품 선택"과 같은 문구를 통합하면 초점을 이동시켜 추천 제품의 다양성에 영향을 줄 수 있음
  • 캐싱은 동일한 입력에 대한 응답을 재계산할 필요성을 제거함으로써 비용을 절감하고 생성 지연 시간을 제거함\n\n또한 응답이 이전에 가드레일링되었다면, 이러한 검증된 응답을 제공하여 유해하거나 부적절한 콘텐츠를 제공할 위험을 줄일 수 있음

캐싱에 대한 간단한 접근 방식은 새로운 기사나 제품 리뷰를 요약하는 경우와 같이 처리 중인 항목에 대해 고유한 ID를 사용하는 것임

  • 요청이 들어오면 캐시에 이미 요약이 존재하는지 확인할 수 있음\n\n그렇다면 즉시 반환할 수 있고, 그렇지 않다면 생성, 가드레일링 및 제공한 다음 향후 요청을 위해 캐시에 저장할 수 있음

좀 더 개방형인 쿼리의 경우 개방형 입력에 대해서도 캐싱을 활용하는 검색 분야의 기술을 차용할 수 있음

  • 자동 완성 및 맞춤법 수정과 같은 기능은 사용자 입력을 정규화하여 캐시 적중률을 높이는 데 도움이 됨

finetune(파인 튜닝)이 필요한 시점

  • 가장 영리하게 설계된 프롬프트조차도 부족한 일부 작업이 있을 수 있음\n\n예를 들어 상당한 프롬프트 엔지니어링 이후에도 시스템이 여전히 신뢰할 수 있고 고품질의 출력을 반환하는 데서 멀어질 수 있음
  • 이 경우 특정 작업을 위해 모델을 파인튜닝해야 할 수 있음
  • Honeycomb의 Natural Language Query Assistant\n\n처음에는 "프로그래밍 매뉴얼"이 문맥 내 학습을 위한 n-shot 예제와 함께 프롬프트에 제공됨
  • 이것이 제대로 작동했지만, 모델을 파인 튜닝하면 도메인 특정 언어의 구문과 규칙에 대한 더 나은 출력을 얻을 수 있음
  • LLM은 프론트엔드가 올바르게 렌더링하기 위해 구조화된 데이터와 비구조화된 데이터를 결합한 매우 특정한 형식으로 응답을 생성해야 함
  • 파인 튜닝은 일관되게 작동하도록 하는 데 필수적임

파인튜닝이 효과적일 수 있지만 상당한 비용이 수반됨

  • 파인 튜닝 데이터에 주석을 달고, 모델을 파인 튜닝 및 평가한 다음, 결국 자체 호스팅해야 함
  • 따라서 더 높은 초기 비용이 그만한 가치가 있는지 고려해야 함

프롬프팅으로 90%까지 도달할 수 있다면 파인 튜닝에 투자할 가치가 없을 수 있음

  • 그러나 파인 튜닝하기로 결정한다면 인간이 주석을 단 데이터 수집 비용을 줄이기 위해 합성 데이터에 대해 생성 및 파인 튜닝하거나 오픈 소스 데이터를 부트스트랩할 수 있음
  • LLM 평가는 지뢰밭이 될 수 있음\n\nLLM의 입력과 출력은 임의의 텍스트이며, 설정하는 작업도 다양함
  • 그럼에도 불구하고 엄격하고 신중한 평가는 중요함\n\nOpenAI의 기술 리더들이 평가에 참여하고 개별 평가에 대해 피드백을 제공하는 것이 우연이 아님

LLM 애플리케이션 평가에는 다양한 정의와 축소가 필요함

  • 단순히 단위 테스트이거나, 관찰 가능성과 더 유사하거나, 단순히 데이터 과학일 수 있음
  • 우리는 이러한 모든 관점이 유용하다는 것을 발견함

이번 섹션에서는 평가 및 모니터링 파이프라인 구축에서 중요한 사항에 대해 배운 교훈을 제공함

실제 입출력 샘플에서 몇 가지 assertion 기반 단위 테스트 생성

  • 프로덕션에서 입력과 출력의 샘플로 구성된 단위 테스트(즉, assertion)를 만들고, 최소 3가지 기준에 따라 출력에 대한 기대치를 설정\n\n3가지 기준이 임의적으로 보일 수 있지만, 시작하기에 실용적인 수임\n\n더 적으면 작업이 충분히 정의되지 않았거나 범용 챗봇과 같이 너무 개방적일 수 있음

이러한 단위 테스트 또는 assertion은 프롬프트 편집, RAG를 통한 새 컨텍스트 추가 또는 기타 수정과 같은 파이프라인의 변경 사항에 의해 트리거되어야 함

모든 응답에 포함하거나 제외할 구문이나 아이디어를 지정하는 assertion부터 시작하는 것을 고려

  • 또한 단어, 항목 또는 문장 수가 범위 내에 있는지 확인하는 검사를 고려
  • 다른 종류의 생성의 경우 assertion이 다르게 보일 수 있음\n\n예를 들어 코드 생성을 평가하기 위한 강력한 방법인 실행 평가에서는 생성된 코드를 실행하고 런타임 상태가 사용자 요청에 충분한지 확인

예를 들어 사용자가 foo라는 새 함수를 요청하면 에이전트의 생성 코드를 실행한 후 foo를 호출할 수 있어야 함 실행 평가의 한 가지 과제는 에이전트 코드가 종종 대상 코드와 약간 다른 형태로 런타임을 남긴다는 것

  • 어떤 타당한 답변이라도 만족시킬 수 있는 가장 약한 가정으로 assertion을 "완화"하는 것이 효과적일 수 있음

고객을 위해 의도한 대로 제품을 사용하는 것(즉, "도그푸딩")은 실제 데이터에서의 장애 모드에 대한 통찰력을 제공할 수 있음

  • 이 접근 방식은 잠재적 약점을 식별하는 데 도움이 될 뿐만 아니라 평가로 변환할 수 있는 유용한 프로덕션 샘플 소스도 제공함

LLM-as-Judge는 (어느 정도) 작동할 수 있지만 만능은 아님

  • LLM-as-Judge는 강력한 LLM을 사용하여 다른 LLM의 출력을 평가하는 방식으로, 일부 사람들에게는 회의적으로 받아들여짐
  • 그럼에도 불구하고 잘 구현되면 LLM-as-Judge는 인간의 판단과 상당한 상관관계를 달성하고, 적어도 새로운 프롬프트나 기술이 어떻게 수행될 수 있는지에 대한 사전 정보를 구축하는 데 도움이 될 수 있음\n\n특히 쌍별 비교(예: 대조군 vs 처리군)를 할 때 LLM-as-Judge는 일반적으로 방향을 올바르게 잡지만 승/패의 크기는 노이즈가 있을 수 있음

LLM-as-Judge를 최대한 활용하기 위한 제안

  • 쌍별 비교 사용\n\nLLM에게 단일 출력을 Likert 척도로 평가하도록 요청하는 대신 두 가지 옵션을 제시하고 더 나은 것을 선택하도록 요청
  • 이는 더 안정적인 결과로 이어지는 경향이 있음
  • 제시된 옵션의 순서가 LLM의 결정을 편향시킬 수 있음
  • 이를 완화하려면 각 쌍별 비교를 두 번 수행하고 각 시간에 쌍의 순서를 바꿈
  • 스와핑 후에는 올바른 옵션에 승리를 귀속시켜야 함
  • 경우에 따라 두 옵션이 똑같이 좋을 수 있음
  • 따라서 LLM이 임의로 승자를 선택할 필요가 없도록 동점을 선언하도록 허용

Chain-of-Thought 사용

  • 최종 선호도를 제시하기 전에 LLM에게 그 결정을 설명하도록 요청하면 평가 신뢰성이 향상될 수 있음
  • 보너스로, 이를 통해 더 약하지만 빠른 LLM을 사용하면서도 유사한 결과를 얻을 수 있음
  • 파이프라인의 이 부분이 자주 배치 모드에 있기 때문에 CoT로 인한 추가 지연은 문제가 되지 않음
  • LLM은 더 긴 응답으로 치우치는 경향이 있음
  • 이를 완화하려면 응답 쌍의 길이가 비슷한지 확인

LLM-as-Judge의 특히 강력한 적용은 새로운 프롬프트 전략을 회귀에 대해 확인하는 것

  • 프로덕션 결과 모음을 추적한 경우 때로는 새로운 프롬프트 전략으로 해당 프로덕션 예제를 다시 실행하고 LLM-as-Judge를 사용하여 새 전략이 어디에서 어려움을 겪을 수 있는지 신속하게 평가할 수 있음

LLM-as-Judge의 간단하지만 효과적인 접근 방식의 예시

  • 단순히 LLM 응답, 판사의 비평(즉, CoT) 및 최종 결과를 기록
  • 그런 다음 이해관계자와 검토하여 개선 영역을 식별
  • 3번의 반복을 통해 인간과 LLM의 일치도는 68%에서 94%로 향상됨

그러나 LLM-as-Judge는 만능이 아님

  • 가장 강력한 모델조차도 신뢰할 수 있게 평가하지 못하는 미묘한 언어적 측면이 있음

또한 기존의 분류기와 보상 모델이 LLM-as-Judge보다 더 높은 정확도를 달성할 수 있으며 비용과 지연 시간이 더 적다는 것을 발견함

  • 코드 생성의 경우 LLM-as-Judge는 실행 평가와 같은 보다 직접적인 평가 전략보다 약할 수 있음

생성 결과 평가를 위한 "인턴 테스트"

  • 생성 결과를 평가할 때 다음과 같은 "인턴 테스트"를 사용하는 것이 좋음\n\n컨텍스트를 포함하여 언어 모델에 대한 정확한 입력을 가져와 관련 전공의 평균적인 대학생에게 과제로 제시한다면 그들이 성공할 수 있을까?
  • LLM에 필요한 지식이 부족하기 때문이라면 컨텍스트를 풍부하게 만드는 방법을 고려
  • 컨텍스트를 개선해도 해결할 수 없다면 현대 LLM에는 너무 어려운 작업일 수 있음

답변이 예이지만 시간이 걸리는 경우

  • 작업의 복잡성을 줄이려고 시도해볼 수 있음\n\n분해 가능한가?
  • 작업의 어떤 측면을 더 템플릿화할 수 있는가?

답변이 예이고 빠르게 할 수 있는 경우

  • 데이터를 파고들 때\n\n모델이 잘못하고 있는 것은 무엇인가?
  • 실패의 패턴을 찾을 수 있는가?

모델에게 응답 전이나 후에 스스로 설명하도록 요청해보기

특정 평가에 지나치게 중점을 두면 전반적인 성능이 저하될 수 있음

"측정 지표가 목표가 되면 더 이상 좋은 측정 지표가 아니게 된다." - Goodhart의 법칙
  • 이에 대한 예시로 Needle-in-a-Haystack(NIAH) 평가가 있음\n\n원래 평가는 컨텍스트 크기가 커짐에 따라 모델 리콜을 정량화하고 바늘 위치에 따라 리콜이 어떻게 영향을 받는지 확인하는 데 도움이 됨
  • 그러나 너무 지나치게 강조되어 Gemini 1.5 보고서의 Figure 1로 소개됨
  • 이 평가에는 폴 그레이엄의 에세이를 반복하는 긴 문서에 특정 구문("The special magic {city} number is: {number}")을 삽입한 다음 모델에 매직 넘버를 상기시키는 작업이 포함됨

일부 모델은 거의 완벽한 리콜을 달성하지만 NIAH가 실제 애플리케이션에 필요한 추론 및 리콜 능력을 진정으로 반영하는지는 의문 보다 실용적인 시나리오 고려

  • 1시간 분량의 회의 녹취록이 주어지면 LLM이 주요 결정과 다음 단계를 요약하고 각 항목을 관련 담당자에게 올바르게 귀속시킬 수 있는가?
  • 이 작업은 단순한 암기를 넘어 복잡한 토론을 파악하고 관련 정보를 식별하며 요약을 종합하는 능력도 고려하므로 더 현실적임
  • 의사-환자 화상 통화 녹취록을 사용하여 LLM에 환자의 약물에 대해 질문
  • 에스프레소에 담근 대추, 레몬, 염소 치즈 등 피자 토핑에 필요한 무작위 재료에 대한 구문을 삽입하는 등 보다 도전적인 NIAH도 포함
  • 약물 작업에서 리콜은 약 80%, 피자 작업에서는 30%였음

NIAH 평가를 지나치게 강조하면 추출 및 요약 작업의 성능이 낮아질 수 있음

  • 이러한 LLM은 모든 문장에 주의를 기울이도록 미세 조정되어 있기 때문에 관련 없는 세부 정보와 주의 산만 요소를 중요한 것으로 취급하기 시작할 수 있음
  • 그러면 최종 출력에 포함될 수 있음(포함되지 말아야 할 때도!)

이는 다른 평가 및 사용 사례에도 적용될 수 있음

  • 예를 들어 요약\n\n사실적 일관성을 강조하면 덜 구체적이고(따라서 사실과 일치하지 않을 가능성이 낮음) 관련성이 떨어질 수 있는 요약이 생성될 수 있음
  • 반대로 글쓰기 스타일과 웅변을 강조하면 사실적 불일치를 초래할 수 있는 더 화려한 마케팅 유형의 언어가 생성될 수 있음

주석 달기를 이진 작업 또는 쌍대(pairwise) 비교로 단순화

  • 모델 출력에 대해 개방형 피드백을 제공하거나 Likert 척도로 평가하는 것은 인지적으로 까다로움\n\n그 결과 수집된 데이터는 인간 평가자 간의 변동성으로 인해 더 노이즈가 많아지고 따라서 덜 유용해짐

보다 효과적인 접근 방식은 작업을 단순화하고 주석 작성자의 인지적 부담을 줄이는 것

  • 잘 작동하는 두 가지 작업은 이진 분류와 쌍대 비교

이진 분류에서 주석 작성자는 모델의 출력에 대해 간단한 예/아니오 판단을 내리도록 요청받음

  • 생성된 요약이 소스 문서와 사실적으로 일치하는지, 제안된 응답이 관련이 있는지, 유해성이 포함되어 있는지 등을 물을 수 있음
  • Likert 척도에 비해 이진 결정은 더 정확하고, 평가자 간 일관성이 더 높으며, 처리량이 더 높음
  • Doordash가 일련의 예/아니오 질문을 통해 메뉴 항목에 태그를 붙이기 위해 레이블링 대기열을 설정한 방식

쌍대 비교(Pairewise Comparison)에서 주석 작성자는 한 쌍의 모델 응답을 받고 어떤 것이 더 나은지 물음

  • 인간이 A 또는 B에 개별적으로 점수를 매기는 것보다 "A가 B보다 낫다"라고 말하는 것이 더 쉽기 때문에 이는 더 빠르고 신뢰할 수 있는 주석으로 이어짐(Likert 척도보다)

Llama2 밋업에서 Llama2 논문의 저자 중 한 명인 Thomas Scialom은 쌍대 비교가 작성된 응답과 같은 지도 학습 미세 조정 데이터를 수집하는 것보다 더 빠르고 저렴하다는 것을 확인함

  • 전자의 비용은 단위당 $3.5이고 후자의 비용은 단위당 $25

(참조가 필요 없는, Reference-free) 평가와 가드레일은 상호 교환적으로 사용될 수 있음

  • 가드레일은 부적절하거나 유해한 콘텐츠를 잡는 데 도움이 되는 반면, 평가는 모델 출력의 품질과 정확성을 측정하는 데 도움이 됨\n\n참조가 필요 없는 평가의 경우 동전의 양면으로 볼 수 있음\n\n참조가 필요 없는 평가는 인간이 작성한 답변과 같은 "golden" reference에 의존하지 않고 입력 프롬프트와 모델의 응답만으로 출력 품질을 평가할 수 있는 평가임

참조가 필요 없는 평가 예시 : 요약 평가

  • 요약의 사실적 일관성과 관련성을 평가하기 위해 입력 문서만 고려하면 됨
  • 요약이 이러한 지표에서 점수가 낮으면 사용자에게 표시하지 않도록 선택할 수 있어 평가를 가드레일로 효과적으로 사용할 수 있음

참조가 필요 없는 "번역" 평가 :

  • 인간이 번역한 참조 없이도 번역의 품질을 평가할 수 있어 다시 가드레일로 사용할 수 있음

LLM은 그러면 안 될 때도 출력을 반환함

  • LLM 작업 시 주요 과제는 LLM이 그러면 안 될 때도 종종 출력을 생성한다는 것\n\n이는 무해하지만 무의미한 응답이나 유해성 또는 위험한 내용과 같은 더 심각한 결함으로 이어질 수 있음
  • 예를 들어 문서에서 특정 속성이나 메타데이터를 추출하라는 요청을 받으면 LLM은 해당 값이 실제로 존재하지 않을 때도 자신 있게 값을 반환할 수 있음
  • 또는 컨텍스트에 영어 이외의 문서를 제공했기 때문에 모델이 영어 이외의 언어로 응답할 수도 있음

LLM에 "해당 없음" 또는 "알 수 없음" 응답을 반환하도록 프롬프트를 제공할 수 있지만 완벽하지는 않음

  • 로그 확률을 사용할 수 있는 경우에도 출력 품질의 좋지 않은 지표임\n\n로그 확률은 출력에 토큰이 나타날 가능성을 나타내지만 생성된 텍스트의 정확성을 반영하지는 않음

오히려 쿼리에 응답하고 일관된 응답을 생성하도록 훈련된 명령어 튜닝 모델의 경우 로그 확률이 잘 보정되지 않을 수 있음

  • 따라서 높은 로그 확률은 출력이 유창하고 일관성이 있음을 나타낼 수 있지만 정확하거나 관련이 있다는 의미는 아님

주의 깊은 프롬프트 엔지니어링은 어느 정도 도움이 될 수 있지만, 원치 않는 출력을 감지하고 필터링/재생성하는 강력한 가드레일로 보완해야 함

  • 예를 들어 OpenAI는 혐오 발언, 자해 또는 성적 출력과 같은 안전하지 않은 응답을 식별할 수 있는 콘텐츠 조정 API를 제공함
  • 마찬가지로 개인 식별 정보(PII)를 감지하기 위한 수많은 패키지가 있음

가드레일의 한 가지 이점은 사용 사례에 대해 크게 무관하며 따라서 특정 언어로 된 모든 출력에 광범위하게 적용될 수 있다는 것

  • 또한 정밀한 검색을 통해 관련 문서가 없으면 시스템이 결정적으로 "모르겠습니다"라고 응답할 수 있음

LLM은 출력이 예상될 때 출력을 생성하지 못할 수 있음

  • API 제공업체의 긴 지연 시간과 같은 간단한 문제부터 콘텐츠 조정 필터에 의해 출력이 차단되는 것과 같은 더 복잡한 문제에 이르기까지 다양한 이유로 발생할 수 있음

따라서 디버깅 및 모니터링을 위해 입력과 (잠재적으로 출력 부족을) 일관되게 기록하는 것이 중요함

환각(Hallucination)은 끈질긴 문제임

  • 콘텐츠 안전성이나 PII 결함은 많은 주의를 받아 거의 발생하지 않는 반면, 사실적 불일치는 끈질기게 지속되며 감지하기가 더 어려움\n\n더 흔하게 발생하며 기준 발생률은 5~10%이고, LLM 제공업체로부터 배운 바에 따르면 요약과 같은 간단한 작업에서도 2% 미만으로 낮추는 것이 어려울 수 있음

이를 해결하기 위해 프롬프트 엔지니어링(생성 업스트림)과 사실적 불일치 가드레일(생성 다운스트림)을 결합할 수 있음

  • 프롬프트 엔지니어링의 경우 CoT와 같은 기술은 LLM이 최종 출력을 반환하기 전에 추론을 설명하도록 함으로써 환각을 줄이는 데 도움이 됨
  • 그런 다음 사실적 불일치 가드레일을 적용하여 요약의 사실성을 평가하고 환각을 필터링하거나 재생성할 수 있음

경우에 따라 환각은 결정론적으로 감지될 수 있음

  • RAG 검색의 리소스를 사용할 때 출력이 구조화되어 있고 리소스가 무엇인지 식별한다면 입력 컨텍스트에서 소싱되었는지 수동으로 확인할 수 있어야 함

[운영: 일상(Day-to-Day) 및 조직 문제 ]

  • 재료의 품질이 요리의 맛을 결정하듯이 입력 데이터의 품질은 기계 학습 시스템의 성능을 제약함
  • 또한 출력 데이터는 제품이 작동하는지 여부를 알 수 있는 유일한 방법임
  • 모든 저자는 데이터 분포(모드, 엣지 케이스, 모델의 한계)를 더 잘 이해하기 위해 매주 몇 시간 동안 입력과 출력을 면밀히 살펴봄
  • 전통적인 기계 학습 파이프라인에서 오류의 일반적인 원인은 훈련-서비스 편향 임\n\n이는 훈련에 사용되는 데이터가 모델이 프로덕션에서 접하는 데이터와 다를 때 발생함

훈련이나 미세 조정 없이 LLM을 사용할 수 있으므로 훈련 세트는 없지만 개발-프로덕션 데이터 편향이라는 유사한 문제가 발생함

  • 기본적으로 개발 중에 시스템을 테스트하는 데이터는 시스템이 프로덕션에서 직면할 데이터를 반영해야 함\n\n그렇지 않으면 프로덕션 정확도가 저하될 수 있음

LLM 개발-프로덕션 편향은 구조적 편향과 내용 기반 편향의 두 가지 유형으로 분류될 수 있음

  • 구조적 편향에는 목록형 값을 가진 JSON 딕셔너리와 JSON 목록 간의 차이, 일관되지 않은 케이싱, 오타나 문장 조각과 같은 오류 등 형식 불일치와 같은 문제가 포함됨\n\n이러한 오류는 다양한 LLM이 특정 데이터 형식으로 훈련되고 프롬프트가 사소한 변경에 매우 민감할 수 있기 때문에 예측할 수 없는 모델 성능으로 이어질 수 있음

내용 기반 또는 "의미론적" 편향은 데이터의 의미나 맥락의 차이를 나타냄

전통적인 ML과 마찬가지로 LLM 입출력 쌍 간의 편향을 주기적으로 측정하는 것이 유용함

  • 입력 및 출력 길이 또는 특정 형식 요구 사항(예: JSON 또는 XML)과 같은 단순 메트릭은 변경 사항을 추적하는 간단한 방법임

보다 "고급" 편향 감지를 위해 입출력 쌍의 임베딩을 클러스터링하여 사용자가 이전에 모델에 노출되지 않은 영역을 탐색하고 있음을 나타낼 수 있는 사용자가 논의하는 주제의 변화와 같은 의미론적 편향을 감지하는 것을 고려할 것 프롬프트 엔지니어링과 같은 변경 사항을 테스트할 때는 홀드아웃 데이터 세트가 최신 상태이고 가장 최근 유형의 사용자 상호 작용을 반영하는지 확인

  • 예를 들어 프로덕션 입력에서 오타가 흔하다면 홀드아웃 데이터에도 있어야 함

단순히 숫자로 편향을 측정하는 것 이상으로 출력에 대해 정성적 평가를 수행하는 것이 유익함

  • 모델의 출력을 정기적으로 검토하는 것(속어로 "바이브 체크"라고 알려진 관행)은 결과가 기대에 부합하고 사용자 요구에 계속 관련성이 있는지 확인해줌

편향 확인에 비결정론을 통합하는 것도 유용함

  • 테스트 데이터 세트의 각 입력에 대해 파이프라인을 여러 번 실행하고 모든 출력을 분석함으로써 가끔만 발생할 수 있는 이상 현상을 포착할 가능성이 높아짐

매일 LLM 입출력 샘플 확인하기

  • LLM은 역동적이고 끊임없이 진화하고 있음\n\n인상적인 제로샷 능력과 종종 기분 좋은 출력에도 불구하고 LLM의 실패 모드는 매우 예측할 수 없음

맞춤 작업의 경우 LLM의 성능에 대한 직관적인 이해를 개발하기 위해 데이터 샘플을 정기적으로 검토하는 것이 필수적임

  • 프로덕션의 입출력 쌍은 LLM 애플리케이션의 "실제 사물, 실제 장소"( genchi genbutsu )이며 대체할 수 없음

최근 연구에 따르면 개발자가 더 많은 데이터와 상호 작용할수록 "좋은" 출력과 "나쁜" 출력에 대한 인식이 변한다고 강조함(즉, 기준 편향 )

  • 개발자는 LLM 출력을 평가하기 위한 일부 기준을 사전에 제시할 수 있지만, 이러한 사전 정의된 기준은 종종 불완전함

예를 들어 개발 과정에서 좋은 응답 확률을 높이고 나쁜 응답 확률을 낮추기 위해 프롬프트를 업데이트할 수 있음

  • 이러한 평가, 재평가 및 기준 업데이트의 반복 프로세스는 출력을 직접 관찰하지 않고는 LLM 동작이나 인간의 선호도를 예측하기 어렵기 때문에 필요함

이를 효과적으로 관리하기 위해 LLM 입력과 출력을 기록해야 함

  • 매일 이러한 로그 샘플을 검사하면 새로운 패턴이나 실패 모드를 신속하게 식별하고 적응할 수 있음
  • 새로운 문제를 발견하면 즉시 그것에 대한 assertion 또는 eval을 작성할 수 있음

마찬가지로 실패 모드 정의에 대한 모든 업데이트는 평가 기준에 반영되어야 함

  • 이러한 "바이브 체크"는 잘못된 출력의 신호이며, 코드와 assertion은 이를 운영함

마지막으로 이러한 태도는 Socialized되어야 함

  • 예를 들어 온콜 로테이션에 입력 및 출력 검토 또는 주석 달기를 추가하는 것
  • LLM API를 사용하면 소수의 제공업체의 지능에 의존할 수 있음\n\n이는 좋은 점이지만 이러한 종속성은 성능, 지연 시간, 처리량 및 비용 측면에서 절충점을 수반함

또한 지난 1년 동안 거의 매월 더 새롭고 더 나은 모델이 출시됨에 따라 오래된 모델을 폐기하고 새로운 모델로 마이그레이션할 때 제품을 업데이트할 준비가 되어 있어야 함

  • 이 섹션에서는 완전히 제어할 수 없는 기술, 즉 모델을 자체 호스팅하고 관리할 수 없는 기술을 사용할 때 얻은 교훈을 공유함

실제 사용 사례 대부분의 경우 LLM의 출력은 일종의 기계 판독 가능 형식을 통해 다운스트림 애플리케이션에서 소비될 것임

  • 예를 들어 부동산 CRM인 ReChat은 프론트엔드에서 위젯을 렌더링하기 위해 구조화된 응답이 필요함
  • 유사하게 제품 전략 아이디어 생성 도구인 Boba는 제목, 요약, 타당성 점수 및 시간 범위 필드가 있는 구조화된 출력이 필요함
  • 마지막으로 LinkedIn은 LLM을 제한하여 YAML을 생성하는 방법을 공유했는데, 이는 사용할 기술을 결정하고 기술을 호출하는 매개변수를 제공하는 데 사용됨

이 애플리케이션 패턴은 Postel의 법칙의 극단적인 버전임

  • 수락하는 것(임의의 자연어)에 자유롭고 보내는 것(유형화된 기계 판독 가능 개체)에 보수적이어야 함
  • 따라서 이것이 매우 내구성이 있을 것으로 기대함

현재 Instructor와 Outlines는 LLM에서 구조화된 출력을 이끌어내기 위한 사실상의 표준임

  • LLM API(예: Anthropic, OpenAI)를 사용하는 경우 Instructor를 사용하고, 자체 호스팅 모델(예: Huggingface)을 사용하는 경우 Outlines를 사용할 것

모델 간 프롬프트 마이그레이션은 고통스러운 일임

  • 때로는 주의 깊게 만든 프롬프트가 한 모델에서는 훌륭하게 작동하지만 다른 모델에서는 제대로 작동하지 않을 수 있음\n\n이는 다양한 모델 제공업체 간에 전환할 때뿐만 아니라 동일한 모델의 버전 간에 업그레이드할 때도 발생할 수 있음

예를 들어 Voiceflow는 gpt-3.5-turbo-0301에서 gpt-3.5-turbo-1106으로 마이그레이션하면 의도 분류 작업에서 10%의 성능 저하가 발생한다는 것을 발견함

  • (다행히도 그들은 평가를 가지고 있었음!)

유사하게 GoDaddy는 1106 버전으로 업그레이드하면 gpt-3.5-turbo와 gpt-4 사이의 성능 격차가 좁혀지는 긍정적인 방향의 추세를 관찰함

  • (또는 당신이 반쯤 찬 유리잔을 보는 사람이라면 새로운 업그레이드로 gpt-4의 리드가 줄어든 것에 실망할 수도 있음)

따라서 모델 간에 프롬프트를 마이그레이션해야 하는 경우 단순히 API 엔드포인트를 교체하는 것보다 더 많은 시간이 걸릴 것으로 예상해야 함

  • 동일한 프롬프트를 연결하면 유사하거나 더 나은 결과로 이어질 것이라고 가정하지 말 것

또한 신뢰할 수 있고 자동화된 평가는 마이그레이션 전후의 작업 성능을 측정하는 데 도움이 되며 수동 검증에 필요한 노력을 줄여줌

  • 모든 기계 학습 파이프라인에서 "무엇이든 변경하면 모든 것이 변경됨"\n\n이는 우리 자신이 훈련하지 않고 우리 모르게 변경될 수 있는 대규모 언어 모델(LLM)과 같은 구성 요소에 의존할 때 특히 관련이 있음

다행히도 많은 모델 제공업체는 특정 모델 버전(예: gpt-4-turbo-1106)을 "고정"할 수 있는 옵션을 제공함

  • 이를 통해 모델 가중치의 특정 버전을 사용하여 변경되지 않도록 할 수 있음

프로덕션에서 모델 버전을 고정하면 모델 동작의 예기치 않은 변경을 방지할 수 있음

  • 이는 모델이 교체될 때 발생할 수 있는 지나치게 장황한 출력이나 기타 예상치 못한 실패 모드와 같은 문제에 대한 고객 불만을 피하는 데 도움이 될 수 있음

또한 프로덕션 설정을 미러링하지만 최신 모델 버전을 사용하는 "섀도우 파이프라인"을 유지하는 것을 고려해 볼 것

  • 이를 통해 새로운 릴리스로 안전한 실험과 테스트를 수행할 수 있음

이러한 새로운 모델에서 출력의 안정성과 품질을 검증한 후에는 프로덕션 환경에서 모델 버전을 자신 있게 업데이트할 수 있음

작업을 완료할 수 있는 가장 작은 모델 선택하기

  • 새로운 애플리케이션에서 작업할 때 사용 가능한 가장 크고 강력한 모델을 사용하고 싶은 유혹이 있음\n\n그러나 일단 작업이 기술적으로 가능하다는 것이 확인되면 더 작은 모델로 유사한 결과를 얻을 수 있는지 실험해 볼 가치가 있음

작은 모델의 장점은 지연 시간과 비용이 낮다는 것

  • 더 약할 수 있지만 Chain-of-Thought, n-shot 프롬프트, 문맥 내 학습과 같은 기술은 작은 모델이 자신의 역량 이상으로 성장하는 데 도움이 될 수 있음

LLM API 이상으로 특정 작업에 대한 미세 조정도 성능 향상에 도움이 될 수 있음 종합하면 작은 모델을 사용하여 신중하게 설계된 워크플로우는 더 빠르고 저렴하면서도 단일 대형 모델의 출력 품질과 일치하거나 심지어 능가할 수 있음

  • 예를 들어 이 트윗 은 Haiku + 10-shot 프롬프트가 제로샷 Opus와 GPT-4를 능가하는 방법에 대한 일화를 공유함

장기적으로 출력 품질, 지연 시간 및 비용의 최적 균형으로 작은 모델을 사용한 흐름 엔지니어링의 더 많은 사례가 나타날 것으로 예상됨 또 다른 예로 겸손한 분류 작업을 들 수 있음

  • DistilBERT(6,700만 개 매개변수)와 같은 경량 모델은 놀라울 정도로 강력한 기준선임
  • 4억 개 매개변수의 DistilBART는 또 다른 훌륭한 옵션\n\n오픈 소스 데이터에서 미세 조정되면 지연 시간과 비용의 5% 미만으로 대부분의 LLM을 능가하는 0.84의 ROC-AUC로 환각을 식별할 수 있음

요점은 작은 모델을 간과하지 말아야 한다는 것

  • 모든 문제에 거대한 모델을 적용하기는 쉽지만 약간의 창의성과 실험으로 우리는 종종 더 효율적인 솔루션을 찾을 수 있음
  • 새로운 기술은 새로운 가능성을 제공하지만 훌륭한 제품을 만드는 원칙은 영원함\n\n따라서 처음으로 새로운 문제를 해결하더라도 제품 설계에 대해 바퀴를 다시 발명할 필요는 없음

견고한 제품 기본에 LLM 애플리케이션 개발을 기반으로 함으로써 얻을 수 있는 것이 많음

  • 이를 통해 우리가 서비스하는 사람들에게 실제 가치를 제공할 수 있음

초기부터 디자인을 involve하기

  • 디자이너를 두면 제품을 어떻게 구축하고 사용자에게 제시할 수 있는지 이해하고 깊이 생각하게 됨\n\n때로는 디자이너를 사물을 예쁘게 만드는 사람으로 고정 관념을 가지기도 함
  • 그러나 사용자 인터페이스뿐만 아니라 기존 규칙과 패러다임을 깨더라도 사용자 경험을 어떻게 개선할 수 있는지 재고함

디자이너는 사용자의 요구 사항을 다양한 형태로 재구성하는 데 특히 재능이 있음

  • 이러한 형태 중 일부는 다른 형태보다 해결하기가 더 쉬우므로 AI 솔루션에 더 많거나 적은 기회를 제공할 수 있음

다른 많은 제품과 마찬가지로 AI 제품 구축은 제품을 구동하는 기술이 아니라 수행할 작업을 중심으로 이루어져야 함 다음과 같은 질문을 스스로에게 묻는 데 초점을 맞출 것

  • "사용자가 이 제품에 요청하는 작업은 무엇인가? 그 작업이 챗봇이 잘할 만한 일인가? 자동 완성은 어떤가? 어쩌면 다른 것일 수도 있다!"

기존 설계 패턴과 그것이 수행할 작업과 어떤 관련이 있는지 고려할 것

  • 이것들은 디자이너가 팀의 역량에 더하는 귀중한 자산임

휴먼 인 더 루프를 위한 UX 설계

  • 품질 좋은 주석을 얻는 한 가지 방법은 사용자 경험(UX)에 Human-in-the-Loop(HITL)를 통합하는 것\n\n사용자가 쉽게 피드백과 수정 사항을 제공할 수 있도록 하면 즉각적인 출력을 개선하고 모델 개선에 유용한 데이터를 수집할 수 있음

사용자가 제품을 업로드하고 분류하는 전자상거래 플랫폼을 상상해 보자

  • UX를 설계하는 방법에는 여러 가지가 있음\n\n사용자가 수동으로 올바른 제품 범주를 선택하고, LLM이 주기적으로 새 제품을 확인하고 백엔드에서 잘못된 분류를 수정
  • 사용자는 범주를 전혀 선택하지 않고, LLM이 주기적으로 백엔드에서 제품을 분류(잠재적 오류 포함)
  • LLM이 실시간으로 제품 범주를 제안하고, 사용자가 필요에 따라 검증 및 업데이트 가능

세 가지 접근 방식 모두 LLM을 포함하지만 매우 다른 UX를 제공함

  • 첫 번째 접근 방식은 초기 부담을 사용자에게 지우고 LLM이 사후 처리 검사 역할을 함
  • 두 번째 접근 방식은 사용자의 노력이 전혀 필요하지 않지만 투명성이나 제어권을 제공하지 않음
  • 세 번째 접근 방식이 적절한 균형을 유지함\n\nLLM이 사전에 범주를 제안함으로써 사용자의 인지 부하를 줄이고 제품을 분류하기 위해 우리의 분류법을 배울 필요가 없음
  • 동시에 사용자가 제안을 검토하고 편집할 수 있도록 함으로써 제품 분류 방식에 대한 최종 결정권을 사용자의 손에 단단히 쥐어줌

보너스로 세 번째 접근 방식은 모델 개선을 위한 자연스러운 피드백 루프를 만듦

  • 좋은 제안은 수락되고(긍정 레이블) 나쁜 제안은 업데이트됨(부정 후 긍정 레이블)

제안, 사용자 검증 및 데이터 수집의 이 패턴은 여러 애플리케이션에서 일반적으로 볼 수 있음

  • 코딩 어시스턴트: 사용자가 제안을 수락(강한 긍정), 수락 및 조정(긍정) 또는 무시(부정)할 수 있음
  • Midjourney: 사용자가 이미지를 업스케일하고 다운로드(강한 긍정)하거나, 이미지를 변경(긍정)하거나, 새로운 이미지 세트를 생성(부정)할 수 있음
  • 챗봇: 사용자가 응답에 대해 좋아요(긍정) 또는 싫어요(부정)를 제공하거나, 응답이 정말 나쁜 경우 응답을 다시 생성(강한 부정)하도록 선택할 수 있음

피드백은 명시적이거나 암시적일 수 있음

  • 명시적 피드백은 사용자가 제품의 요청에 응답하여 제공하는 정보
  • 암시적 피드백은 사용자가 의도적으로 피드백을 제공할 필요 없이 사용자 상호 작용에서 배우는 정보

코딩 어시스턴트와 Midjourney는 암시적 피드백의 예이고 좋아요와 싫어요는 명시적 피드백

  • 코딩 어시스턴트와 Midjourney처럼 UX를 잘 설계하면 제품과 모델을 개선하기 위한 많은 암시적 피드백을 수집할 수 있음

무자비하게 요구사항 계층(Hierarchy)의 우선순위 지정하기

  • 데모를 프로덕션에 배치하는 것에 대해 생각할 때, 다음에 대한 요구 사항을 고려해야 함\n\n신뢰성: 99.9% 가동 시간, 구조화된 출력 준수
  • 무해성: 공격적이거나 NSFW 또는 기타 유해한 콘텐츠를 생성하지 않음
  • 사실적 일관성: 제공된 맥락에 충실하고 사실을 왜곡하지 않음
  • 유용성: 사용자의 요구와 요청에 관련됨
  • 확장성: 지연 시간 SLA, 지원되는 처리량
  • 비용: 예산이 무제한이 아니기 때문
  • 기타: 보안, 개인 정보 보호, 공정성, GDPR, DMA 등

이러한 모든 요구 사항을 한 번에 해결하려고 하면 아무것도 출시할 수 없음

  • 따라서 우선순위를 정해야 함. 무자비하게.

이는 제품이 작동하지 않거나 실행 가능하지 않을 수 있는 타협할 수 없는 사항(예: 신뢰성, 무해성)이 무엇인지 명확히 하는 것을 의미함

  • MVP(Minimum Lovable Product) 제품을 식별하는 것이 중요함

첫 번째 버전이 완벽하지 않을 것이라는 점을 받아들이고 출시하고 반복해야 함

사용 사례에 따른 위험 감수 수준 조정

  • 언어 모델과 애플리케이션의 검토 수준을 결정할 때는 사용 사례와 대상을 고려해야 함\n\n의료 또는 금융 조언을 제공하는 고객 대면 챗봇의 경우 안전성과 정확성에 대해 매우 높은 기준이 필요함\n\n실수나 잘못된 출력은 실제 피해를 일으키고 신뢰를 잃을 수 있음

그러나 추천 시스템과 같은 덜 중요한 애플리케이션이나 콘텐츠 분류 또는 요약과 같은 내부 대면 애플리케이션의 경우 지나치게 엄격한 요구 사항은 많은 가치를 추가하지 않고 진전을 늦출 뿐임

이는 많은 회사가 외부 애플리케이션에 비해 내부 LLM 애플리케이션으로 더 빠르게 움직이고 있다는 최근 a16z 보고서와 일치함

  • 내부 생산성을 위해 AI를 실험함으로써 조직은 더 통제된 환경에서 위험을 관리하는 방법을 배우면서 가치를 포착하기 시작할 수 있음
  • 그런 다음 자신감이 생기면 고객 대면 사용 사례로 확장할 수 있음

운영 4. 팀과 역할(Roles)

  • 어떤 직무도 정의하기 쉽지 않지만, 이 새로운 영역에서 업무에 대한 직무 기술서를 작성하는 것은 다른 것보다 더 어려움\n\n교차하는 직책에 대한 벤 다이어그램이나 직무 기술에 대한 제안은 생략하겠음
  • 그러나 새로운 역할인 AI 엔지니어의 존재를 인정하고 그 역할에 대해 논의할 것임

중요한 것은 나머지 팀과 책임이 어떻게 할당되어야 하는지에 대해 논의할 것임

  • LLM과 같은 새로운 패러다임에 직면했을 때 소프트웨어 엔지니어는 도구를 선호하는 경향이 있음\n\n그 결과 도구가 해결하려고 했던 문제와 프로세스를 간과하게 됨
  • 이렇게 하면서 많은 엔지니어는 우발적 복잡성을 가정하게 되는데, 이는 팀의 장기적인 생산성에 부정적인 결과를 초래함

예를 들어 이 글 은 특정 도구가 대규모 언어 모델에 대한 프롬프트를 자동으로 생성할 수 있는 방법에 대해 설명함

  • 문제 해결 방법론이나 프로세스를 먼저 이해하지 않고 이러한 도구를 사용하는 엔지니어는 결국 불필요한 기술 부채를 떠안게 된다고 주장함(IMHO 정당하게)

우발적 복잡성 외에도 도구는 종종 불충분하게 지정됨

  • 예를 들어 유해성, 간결성, 어조 등에 대한 일반 평가기와 함께 "LLM 평가 도구 상자"를 제공하는 LLM 평가 도구 산업이 성장하고 있음
  • 많은 팀이 자신의 도메인의 특정 실패 모드에 대해 비판적으로 생각하지 않고 이러한 도구를 채택하는 것을 봄

이와 대조적으로 EvalGen은 사용자를 기준 지정, 데이터 레이블링, 평가 확인 등 각 단계에 깊이 참여시켜 도메인별 평가를 생성하는 프로세스를 사용자에게 가르치는 데 중점을 둠

  • 소프트웨어는 사용자를 다음과 같은 워크플로우로 안내함

EvalGen이 안내하는 LLM 평가 제작의 모범 사례

  • 도메인별 테스트 정의(프롬프트에서 자동으로 부트스트랩됨)\n\n코드 또는 LLM-as-a-Judge로 어설션으로 정의됨

테스트가 지정된 기준을 포착하는지 사용자가 확인할 수 있도록 테스트를 인간의 판단과 일치시키는 것의 중요성 시스템(프롬프트 등)이 변경됨에 따라 테스트 반복

EvalGen은 개발자에게 평가 구축 프로세스에 대한 멘탈 모델을 제공하지만 특정 도구에 고정하지는 않음

  • AI 엔지니어에게 이러한 맥락을 제공한 후에는 종종 더 간단한 도구를 선택하거나 자체 도구를 구축하기로 결정한다는 것을 발견함

프롬프트 작성 및 평가 이외에 LLM의 구성 요소가 너무 많아 여기에 모두 나열할 수 없음

  • 그러나 AI 엔지니어가 도구를 채택하기 전에 프로세스를 이해하려고 노력하는 것이 중요함
  • ML 제품은 실험과 깊이 연관되어 있음\n\nA/B 테스트, 무작위 대조 시험뿐만 아니라 시스템의 가능한 가장 작은 구성 요소를 수정하고 오프라인 평가를 수행하는 빈번한 시도를 의미함
  • 모두가 평가에 열광하는 이유는 실제로 신뢰성과 자신감에 관한 것이 아니라 실험을 가능하게 하는 것임!\n\n평가가 더 좋을수록 실험을 더 빨리 반복할 수 있고, 따라서 시스템의 최상의 버전으로 더 빨리 수렴할 수 있음

실험이 매우 저렴해졌기 때문에 동일한 문제를 해결하기 위해 다양한 접근 방식을 시도하는 것이 일반적임

  • 데이터 수집 및 모델 훈련의 높은 비용은 최소화됨\n\n프롬프트 엔지니어링 비용은 인간의 시간보다 조금 더 들음

모든 사람이 프롬프트 엔지니어링의 기본을 배울 수 있도록 팀을 배치할 것

  • 이는 모든 사람이 실험하도록 장려하고 조직 전체에서 다양한 아이디어로 이어짐

탐색을 위해서만 실험하지 말고 활용을 위해서도 실험을 사용할 것!

  • 새로운 작업의 작동 버전이 있는가?\n\n팀의 다른 사람이 다른 방식으로 접근하는 것을 고려해 볼 것

더 빠를 수 있는 다른 방법으로 해보기 Chain-of-Thought나 Few-Shot과 같은 프롬프트 기술을 조사하여 품질을 높일 것 도구가 실험을 방해하지 않도록 할 것

  • 그렇다면 재구축하거나 개선할 수 있는 것을 구매할 것

제품/프로젝트 기획 중에는 평가 구축 및 여러 실험 수행을 위한 시간을 따로 할애할 것

  • 엔지니어링 제품에 대한 제품 사양을 생각해 보고, 여기에 평가에 대한 명확한 기준을 추가할 것

로드맵 작성 시 실험에 필요한 시간을 과소평가하지 말 것

  • 프로덕션 승인을 받기 전에 여러 번의 개발 및 평가 반복을 예상할 것

모든 사람이 새로운 AI 기술을 사용할 수 있도록 권한 부여

  • 생성형 AI의 채택이 증가함에 따라 전문가뿐만 아니라 전체 팀이 이 새로운 기술을 이해하고 사용할 수 있다고 느끼기를 원함\n\nLLM이 어떻게 작동하는지(예: 지연 시간, 실패 모드, UX)에 대한 직관을 개발하는 더 좋은 방법은 없음
  • LLM은 비교적 접근하기 쉬움\n\n파이프라인의 성능을 향상시키기 위해 코딩 방법을 알 필요가 없으며, 모든 사람이 프롬프트 엔지니어링 및 평가를 통해 기여할 수 있음
  • n-shot 프롬프팅 및 CoT와 같은 기술이 모델을 원하는 출력 방향으로 조건화하는 데 도움이 되는 프롬프트 엔지니어링의 기초부터 시작할 수 있음

지식을 가진 사람들은 LLM이 본질적으로 자기회귀적이라는 점과 같은 보다 기술적인 측면에 대해서도 교육할 수 있음

  • 즉, 입력 토큰은 병렬로 처리되지만 출력 토큰은 순차적으로 생성됨
  • 따라서 지연 시간은 입력 길이보다 출력 길이의 함수임\n\n이는 UX를 설계하고 성능 기대치를 설정할 때 주요 고려 사항임

실험과 탐색을 위한 실습 기회를 제공할 수도 있음

  • 해커톤은 어떨까?\n\n전체 팀이 며칠 동안 추측성 프로젝트를 해킹하는 데 시간을 보내는 것이 비싸 보일 수 있지만, 그 결과는 당신을 놀라게 할 수 있음

해커톤을 통해 3년 로드맵을 1년 안에 거의 완료한 팀이 있음

  • 또 다른 팀은 해커톤을 통해 LLM 덕분에 이제 가능해진 패러다임을 전환하는 UX로 이어졌으며, 이제 올해와 그 이후의 우선 순위가 되었음

"AI 엔지니어링이 모든 것"이라는 함정에 빠지지 말 것

  • 새로운 직책이 생겨날 때 이러한 역할과 관련된 능력을 과대평가하는 경향이 초기에 있음\n\n이는 종종 이러한 직업의 실제 범위가 명확해짐에 따라 고통스러운 수정으로 이어짐
  • 이 분야의 신참자와 채용 관리자는 과장된 주장을 하거나 과도한 기대를 할 수 있음

지난 10년 동안의 주목할 만한 예는 다음과 같음

  • 데이터 과학자: "모든 소프트웨어 엔지니어보다 통계학을 더 잘하고 모든 통계학자보다 소프트웨어 엔지니어링을 더 잘하는 사람"
  • 머신러닝 엔지니어(MLE): 머신러닝에 대한 소프트웨어 엔지니어링 중심의 관점

처음에는 많은 사람들이 데이터 기반 프로젝트에는 데이터 과학자만으로 충분하다고 가정함

  • 그러나 데이터 과학자는 데이터 제품을 효과적으로 개발하고 배포하기 위해 소프트웨어 및 데이터 엔지니어와 협력해야 한다는 것이 분명해짐

이 오해는 AI 엔지니어라는 새로운 역할에서도 다시 나타났으며, 일부 팀은 AI 엔지니어가 필요한 전부라고 믿음

  • 실제로 머신러닝 또는 AI 제품을 구축하려면 광범위한 전문 역할이 필요함

우리는 12개 이상의 회사와 AI 제품에 대해 상담했으며, 그들이 "AI 엔지니어링이 필요한 전부"라는 믿음의 함정에 빠지는 것을 일관되게 관찰함

  • 그 결과 제품 구축에 필요한 중요한 측면을 간과하면서 제품이 데모 이상으로 확장하는 데 어려움을 겪는 경우가 많음

예를 들어 평가 및 측정은 Vibe 체크 이상으로 제품을 확장하는 데 중요함

  • 효과적인 평가를 위한 기술은 전통적으로 머신러닝 엔지니어에게서 볼 수 있는 강점 중 일부와 일치함\n\nAI 엔지니어로만 구성된 팀은 이러한 기술이 부족할 가능성이 높음

공동 저자인 Hamel Husain은 데이터 편향 감지 및 도메인 특정 평가 설계와 관련된 최근 작업에서 이러한 기술의 중요성을 설명함 AI 제품 구축 여정에서 필요한 역할 유형 및 시기

  • 먼저 제품 구축에 집중할 것
  • AI 엔지니어가 포함될 수 있지만 반드시 필요한 것은 아님
  • AI 엔지니어는 제품(UX, 배관 등)을 프로토타이핑하고 신속하게 반복하는 데 유용함
  • 다음으로 시스템을 계측하고 데이터를 수집하여 올바른 기반을 만들 것
  • 데이터 유형과 규모에 따라 플랫폼 및/또는 데이터 엔지니어가 필요할 수 있음
  • 또한 문제를 디버깅하기 위해 이 데이터를 쿼리하고 분석하는 시스템이 있어야 함
  • 다음으로 AI 시스템을 최적화할 것
  • 이는 반드시 모델 훈련을 포함하지는 않음
  • 기본 사항에는 지표 설계, 평가 시스템 구축, 실험 실행, RAG 검색 최적화, 확률적 시스템 디버깅 등의 단계가 포함됨
  • MLE는 이 분야에 매우 능숙함(물론 AI 엔지니어도 습득할 수 있음)
  • 선행 단계를 완료하지 않은 경우 MLE를 고용하는 것은 보통 타당하지 않음

이 외에도 항상 도메인 전문가가 필요함

  • 작은 회사에서는 이상적으로 창업팀이 이 역할을 해야 하며, 큰 회사에서는 제품 관리자가 이 역할을 할 수 있음

역할의 진행 및 타이밍을 인식하는 것이 중요함

  • 잘못된 시기에 사람들을 고용하거나(예: MLE를 너무 일찍 고용) 잘못된 순서로 구축하는 것은 시간과 비용 낭비이며 이직을 야기함

또한 1-2단계에서 MLE와 정기적으로 체크인(그러나 정규직으로 고용하지는 않음)하면 회사가 올바른 기반을 구축하는 데 도움이 됨

[전략: LLM을 활용한 구축에서 뒤처지지 않는 방법]

  • 성공적인 제품 개발을 위해서는 무작정 프로토타입을 만들거나 최신 모델이나 트렌드를 따라가기보다는 신중한 기획과 우선순위 설정이 필요함
  • AI 제품 개발 시 직접 개발할 것인지 구매할 것인지 등의 주요 트레이드오프를 검토해야 함
  • 초기 LLM 애플리케이션 개발을 위한 "플레이북"을 제시함

전략 1: PMF 전에는 GPU 없음

  • 훌륭한 제품이 되려면 단순히 다른 사람의 API를 얇게 포장하는 것 이상이 되어야 함
  • 하지만 반대 방향의 실수는 더 큰 비용을 초래할 수 있음\n\n지난 해에는 명확한 제품 비전이나 목표 시장 없이 모델 학습과 커스터마이징에 막대한 벤처 자본이 쓰여졌음
  • 한 회사는 무려 60억 달러의 시리즈 A 투자를 받기도 함

이 섹션에서는 즉시 자체 모델 학습을 시작하는 것이 왜 실수인지 설명하고, 자체 호스팅의 역할을 고려해 볼 것임

처음부터 (거의) 다시 트레이닝 하는 것은 의미 없음

  • 대부분의 조직에게 처음부터 LLM을 프리트레이닝하는 것은 제품 개발에서 벗어난 비현실적인 일임\n\n머신러닝 인프라의 개발과 유지에는 많은 자원이 소요됨\n\n데이터 수집, 모델 학습과 평가, 배포 등이 포함됨

제품-시장 적합성을 검증하는 단계라면 이러한 노력은 핵심 제품 개발에서 자원을 분산시킴 컴퓨팅 자원, 데이터, 기술적 역량이 있다 해도 프리트레인된 LLM은 몇 달 안에 구식이 될 수 있음

  • 금융 업무에 특화된 LLM인 BloombergGPT는 363B 토큰으로 프리트레이닝되었음
  • AI 엔지니어링 4명, ML 제품 및 연구 5명 등 9명의 전임 직원들의 엄청난 노력이 투입됨
  • 그럼에도 1년 내에 해당 업무에서 gpt-3.5-turbo와 gpt-4에 뒤쳐졌음

이런 사례들은 대부분의 실제 애플리케이션에서 LLM을 처음부터 프리트레이닝하는 것이 자원의 최선의 활용법이 아님을 시사함

  • 대신 팀은 특정 요구사항에 맞춰 사용 가능한 가장 강력한 오픈소스 모델을 파인튜닝하는 것이 더 나음
  • Replit의 코드 모델은 코드 생성과 이해에 특화되어 프리트레이닝된 훌륭한 사례임
  • 프리트레이닝으로 CodeLlama7b 등 더 큰 모델보다 우수한 성능을 보였음
  • 그러나 더 강력한 모델들이 출시됨에 따라 효용성 유지를 위해서는 지속적인 투자가 필요했음

필요하다고 확인되기 전까지는 파인튜닝 금지

  • 대부분의 조직에서 파인튜닝은 전략적 사고보다는 FOMO(Fear Of Missing Out, 놓칠 것에 대한 두려움)에 의해 주도됨\n\n조직은 "단순한 래퍼"라는 비난을 피하기 위해 너무 일찍 파인튜닝에 투자함
  • 실제로 파인튜닝은 다른 접근 방식으로는 충분하지 않다는 것을 확신시켜주는 많은 사례를 수집한 후에야 배포해야 할 중장비와 같음

1년 전 많은 팀이 파인튜닝에 대해 기대감을 표했지만, 몇 안 되는 팀만이 제품-시장 적합성을 발견했고 대부분은 결정을 후회함

  • 파인튜닝을 할 거라면 기본 모델이 개선됨에 따라 반복해서 수행할 준비가 되어 있어야 함\n\n아래의 "모델은 제품이 아님"과 "LLMOps 구축"을 참조

파인튜닝이 실제로 올바른 선택일 수 있는 경우

  • 기존 모델 학습에 사용된 대부분의 개방형 웹 규모 데이터셋에서 사용할 수 없는 데이터가 필요한 경우
  • 기존 모델로는 충분하지 않다는 것을 보여주는 MVP를 이미 구축한 경우
  • 그러나 주의해야 함: 훌륭한 학습 데이터를 모델 구축자가 쉽게 얻을 수 없다면 당신은 어디서 얻을 것인가?

LLM 기반 애플리케이션은 과학 박람회 프로젝트가 아님

  • 전략적 목표와 경쟁 차별화에 대한 기여도에 상응하는 투자가 이루어져야 함

추론 API로 시작하되, 셀프호스팅을 두려워하지 말 것

  • LLM API를 사용하면 스타트업이 처음부터 자체 모델을 학습시키지 않고도 언어 모델링 기능을 쉽게 채택하고 통합할 수 있음\n\nAnthropic, OpenAI 등의 제공업체는 몇 줄의 코드만으로 제품에 인텔리전스를 부여할 수 있는 일반 API를 제공함
  • 이러한 서비스를 사용하면 노력을 줄이고 고객을 위한 가치 창출에 집중할 수 있어 아이디어를 검증하고 제품-시장 적합성을 더 빨리 반복할 수 있음

그러나 데이터베이스와 마찬가지로 관리형 서비스는 규모와 요구사항이 증가함에 따라 모든 사용 사례에 적합하지 않음

  • 실제로 자체 호스팅은 의료 및 금융과 같은 규제 산업 또는 계약상 의무나 기밀 유지 요건에 의해 요구되는 대로 기밀/개인 데이터를 네트워크 외부로 보내지 않고 모델을 사용하는 유일한 방법일 수 있음

또한 자체 호스팅은 추론 제공업체가 부과하는 속도 제한, 모델 사용 중단, 사용 제한 등의 제약을 우회함

  • 자체 호스팅은 모델에 대한 완전한 제어 권한을 제공하여 차별화되고 고품질의 시스템을 더 쉽게 구축할 수 있게 함

마지막으로 자체 호스팅, 특히 파인튜닝은 대규모로 비용을 절감할 수 있음

  • 예를 들어 Buzzfeed는 오픈소스 LLM을 파인튜닝하여 비용을 80% 절감한 사례를 공유했음

전략 2: 더 나은 것을 향해 반복하기

  • 장기적으로 경쟁 우위를 유지하려면 모델을 넘어서 제품을 차별화할 수 있는 요소를 고려해야 함
  • 실행 속도가 중요하지만 그것이 유일한 장점이 되어서는 안 됨

모델은 제품이 아님, 그 모델을 둘러싼 시스템이 제품임

  • 모델을 구축하지 않는 팀에게 혁신의 빠른 속도는 축복임\n\n컨텍스트 크기, 추론 능력, 가격 대비 가치 등의 향상을 추구하며 최신 모델로 마이그레이션하여 더 나은 제품을 만들 수 있기 때문
  • 이러한 진보는 예측 가능할 만큼 흥미로움
  • 종합하면 모델은 시스템에서 가장 지속성이 낮은 구성 요소일 가능성이 높음

대신 지속적인 가치를 제공할 수 있는 부분에 노력을 집중해야 함

  • Evals: 모델 전반에 걸쳐 작업 성능을 안정적으로 측정하기 위함
  • Guardrails: 모델에 상관없이 원치 않는 출력을 방지하기 위함
  • Caching: 모델을 완전히 피함으로써 지연 시간과 비용을 줄이기 위함
  • Data flywheel: 위의 모든 것의 반복적 개선을 추진하기 위함
  • 이러한 구성 요소는 원시 모델 기능보다 더 두꺼운 제품 품질의 해자를 만듦

그러나 애플리케이션 계층에서 구축하는 것이 위험이 없다는 의미는 아님

  • OpenAI나 다른 모델 제공업체가 실행 가능한 엔터프라이즈 소프트웨어를 제공하려면 가위로 잘라내야 할 부분에 가위질하지 말 것

예를 들어 일부 팀은 독점 모델에서 구조화된 출력을 검증하기 위한 맞춤형 도구를 구축하는 데 투자했음

  • 여기에 최소한의 투자는 중요하지만 깊이 투자하는 것은 시간을 잘 활용하는 것이 아님
  • OpenAI는 함수 호출을 요청할 때 유효한 함수 호출을 받을 수 있도록 해야 함. 모든 고객이 원하기 때문
  • 여기에 "전략적 미루기"를 적용하고, 절대적으로 필요한 것을 구축하고, 제공업체의 기능 확장을 기다릴 것
  • 모든 사람을 위한 모든 것이 되려고 하는 제품을 만드는 것은 평범함의 레시피임
  • 설득력 있는 제품을 만들기 위해 기업은 사용자가 계속 돌아오게 하는 끈적거리는 경험을 구축하는 데 전문화해야 함
  • 사용자가 묻는 모든 질문에 답하는 것을 목표로 하는 일반적인 RAG 시스템을 고려해 보자\n\n전문화가 부족하다는 것은 시스템이 최신 정보에 우선순위를 두거나, 도메인 특화 형식을 구문 분석하거나, 특정 작업의 뉘앙스를 이해할 수 없다는 것을 의미함
  • 그 결과 사용자는 얕고 신뢰할 수 없는 경험을 하게 되어 요구사항을 충족시키지 못하고 이탈하게 됨

이를 해결하기 위해 특정 도메인과 사용 사례에 집중해야 함

  • 넓이보다는 깊이를 더해 범위를 좁혀야 함
  • 이렇게 하면 사용자에게 공감을 주는 도메인 특화 도구를 만들 수 있음

전문화를 통해 시스템의 기능과 한계를 솔직하게 알릴 수 있음

  • 시스템이 할 수 있는 것과 할 수 없는 것에 대해 투명하게 공개하는 것은 자기 인식을 보여주고, 사용자가 어디에 가장 많은 가치를 더할 수 있는지 이해하는 데 도움이 되며, 결과적으로 출력에 대한 신뢰와 확신을 구축함

LLMOps를 만들되, 적절한 이유를 가질 것 : 빠른 반복

  • DevOps는 근본적으로 재현 가능한 워크플로우나 왼쪽 이동 또는 두 개의 피자 팀에 권한을 부여하는 것이 아님. YAML 파일을 작성하는 것은 더더욱 아님
  • DevOps는 작업과 그 결과 사이의 피드백 주기를 단축하여 오류 대신 개선 사항이 축적되도록 하는 것임\n\n그 뿌리는 린 스타트업 운동을 통해 린 제조와 토요타 생산 시스템으로 거슬러 올라가며, 싱글 미닛 다이 교환과 카이젠을 강조함

MLOps는 DevOps의 형태를 ML에 적용했음

  • 재현 가능한 실험과 모델 구축자가 배포할 수 있도록 권한을 부여하는 올인원 도구 제품군이 있음. YAML 파일도 많음

그러나 업계로서 MLOps는 DevOps의 기능을 채택하지 않았음. 모델과 프로덕션에서의 추론 및 상호 작용 사이의 피드백 갭을 줄이지 않았음 다행히도 LLMOps 분야는 프롬프트 관리와 같은 사소한 문제에서 벗어나 반복을 방해하는 어려운 문제인 프로덕션 모니터링과 평가로 연결되는 지속적인 개선으로 방향을 전환했음 이미 채팅 및 코딩 모델에 대한 중립적이고 크라우드소싱된 평가를 위한 대화형 아레나가 있음. 집단적이고 반복적인 개선의 외부 루프임

  • LangSmith, Log10, LangFuse, W&B Weave, HoneyHive 등의 도구는 프로덕션에서 시스템 결과에 대한 데이터를 수집하고 정리할 뿐만 아니라 개발과 깊이 통합하여 해당 시스템을 개선하는 데 활용할 것을 약속함. 이러한 도구를 수용하거나 자체적으로 구축하라

구매할 수 있는 LLM 기능을 만들지 말 것

  • 대부분의 성공적인 비즈니스는 LLM 비즈니스가 아님. 동시에 대부분의 비즈니스에는 LLM으로 개선할 기회가 있음
  • 이 두 가지 관찰은 종종 리더를 오도하여 비용은 늘리고 품질은 떨어뜨리면서 LLM으로 시스템을 성급하게 개조하고 인조의 허영심 강한 "AI" 기능으로 출시하게 만듦. 지금은 두려워하는 반짝이 아이콘이 완성됨
  • 더 나은 방법이 있음: 제품 목표에 진정으로 부합하고 핵심 운영을 강화하는 LLM 애플리케이션에 집중할 것
  • 팀의 시간을 낭비하는 몇 가지 잘못된 시도를 고려해 보자\n\n비즈니스를 위한 맞춤형 text-to-SQL 기능 구축
  • 문서와 대화할 수 있는 챗봇 구축
  • 회사 지식 베이스를 고객 지원 챗봇과 통합

위의 사항들이 LLM 애플리케이션의 헬로 월드이지만 제품 회사가 직접 구축하는 것은 이치에 맞지 않음

  • 이는 많은 비즈니스에 공통된 일반적인 문제로 유망한 데모와 신뢰할 수 있는 구성 요소 사이의 격차가 크며 소프트웨어 회사의 관례적 영역임
  • 현재 Y Combinator 배치에서 대규모로 해결하고 있는 일반적인 문제에 귀중한 R&D 자원을 투자하는 것은 낭비임

이것이 진부한 비즈니스 조언처럼 들린다면 현재 과대 광고 물결의 들뜬 흥분 속에서 "LLM"이라는 것을 최첨단의 차별화된 것으로 오해하기 쉽고 이미 낡아빠진 애플리케이션을 놓치기 쉽기 때문임

AI를 루프안에 넣고, 사람을 중심에 둘 것

  • 현재 LLM 기반 애플리케이션은 취약함. 엄청난 양의 안전 조치와 방어적 엔지니어링이 필요하지만 여전히 예측하기 어려움. 게다가 엄격하게 범위가 지정되면 이러한 애플리케이션은 엄청나게 유용할 수 있음. 이는 LLM이 사용자 워크플로를 가속화하는 훌륭한 도구가 된다는 것을 의미함
  • LLM 기반 애플리케이션이 워크플로를 완전히 대체하거나 직무 기능을 대신하는 것을 상상하고 싶을 수 있지만, 오늘날 가장 효과적인 패러다임은 인간-컴퓨터 켄타우로스(Centaur chess)임\n\n유능한 인간이 자신의 빠른 활용을 위해 조정된 LLM 기능과 결합하면 작업을 수행하는 생산성과 행복감이 크게 향상될 수 있음
  • LLM의 대표적인 애플리케이션 중 하나인 GitHub CoPilot은 이러한 워크플로의 힘을 입증했음\n\n"전반적으로 개발자들은 GitHub Copilot과 GitHub Copilot Chat을 사용할 때 코딩이 더 쉽고, 오류가 적으며, 가독성이 높고, 재사용성이 높으며, 간결하고, 유지 관리가 용이하며, 탄력적이라고 느꼈다고 말했습니다." - Mario Rodriguez, GitHub

오랫동안 ML 작업을 해온 사람들은 "human-in-the-loop"라는 아이디어에 빠르게 도달할 수 있지만 그렇게 서두르지 말 것

  • HITL 머신러닝은 ML 모델이 예측대로 동작하도록 보장하는 인간 전문가에 기반한 패러다임임
  • 여기서 제안하는 것은 관련되기는 하지만 더 미묘한 것임. LLM 기반 시스템은 오늘날 대부분의 워크플로의 주요 동력이 되어서는 안 되며, 단순히 자원이 되어야 함

인간을 중심에 두고 LLM이 어떻게 워크플로를 지원할 수 있는지 묻는 것은 제품 및 설계 결정에 상당히 다른 영향을 미침

  • 궁극적으로 LLM에 모든 책임을 신속하게 아웃소싱하려는 경쟁업체와는 다른 제품, 즉 더 나은 제품, 더 유용하고 덜 위험한 제품을 만들게 될 것임

전략 3. 프롬프팅, Eval, 데이터 수집으로 시작하기

  • 이전 섹션에서는 기술과 조언의 화력을 쏟아부었음. 받아들이기에 많은 양임. 유용한 조언의 최소 집합을 고려해 보자.\n\n팀이 LLM 제품을 만들고 싶다면 어디서부터 시작해야 할까?

지난 1년 동안 성공적인 LLM 애플리케이션은 일관된 궤적을 따른다는 것을 확신할 만큼 충분히 봐왔음. 이 섹션에서는 이 기본적인 "시작하기" 플레이북을 살펴볼 것임 핵심 아이디어는 간단하게 시작하고 필요에 따라 복잡성을 추가하는 것임

  • Rule of Thumb : 각 수준의 정교함은 일반적으로 이전 단계보다 최소한 한 자릿수 이상의 노력이 필요하다는 것임. 이를 염두에 두고...
  • 프롬프트 엔지니어링부터 시작할 것\n\n이전에 전술 섹션에서 논의한 모든 기술을 사용할 것
  • Chain-of-thought, n-shot 예제, 구조화된 입출력은 거의 항상 좋은 아이디어임
  • 약한 모델에서 성능을 짜내기 전에 가장 성능이 높은 모델로 프로토타입을 만들 것

프롬프트 엔지니어링으로 원하는 성능 수준을 달성할 수 없는 경우에만 파인튜닝을 고려해야 함

  • 독점 모델 사용을 차단하고 자체 호스팅을 요구하는 비기능적 요구사항(예: 데이터 프라이버시, 완전한 제어, 비용)이 있는 경우 더 자주 발생할 것임
  • 동일한 프라이버시 요구사항이 파인튜닝을 위해 사용자 데이터 사용을 차단하지 않도록 주의할 것

평가를 만들고 데이터 플라이휠 시작하기

  • 막 시작하는 팀도 평가(evals)가 필요함. 그렇지 않으면 프롬프트 엔지니어링이 충분한지 또는 파인튜닝된 모델이 기본 모델을 대체할 준비가 되었는지 알 수 없음
  • 효과적인 평가는 작업에 특화되어 있으며 의도한 사용 사례를 반영함\n\n권장하는 첫 번째 수준의 평가는 단위 테스트임
  • 이러한 간단한 어설션은 알려졌거나 가설로 설정된 실패 모드를 감지하고 초기 설계 결정을 내리는 데 도움이 됨
  • 분류, 요약 등을 위한 다른 작업별 평가도 참조할 것

단위 테스트와 모델 기반 평가는 유용하지만 인간 평가의 필요성을 대체하지는 않음

  • 사람들이 모델/제품을 사용하고 피드백을 제공하도록 할 것
  • 이는 실제 성능과 결함률을 측정하는 동시에 향후 모델을 파인튜닝하는 데 사용할 수 있는 고품질의 주석 데이터를 수집한다는 이중 목적을 수행함
  • 이는 시간이 지남에 따라 복리로 작용하는 긍정적인 피드백 루프 또는 데이터 플라이휠을 만듦\n\n모델 성능을 평가하거나 결함을 찾기 위한 인간 평가
  • 주석 데이터를 사용하여 모델을 파인튜닝하거나 프롬프트를 업데이트

예를 들어 LLM 생성 요약의 결함을 감사할 때 각 문장에 사실적 불일치, 무관함 또는 스타일 불량을 식별하는 세분화된 피드백 레이블을 지정할 수 있음

  • 그런 다음 이러한 사실적 불일치 주석을 사용하여 환각 분류기를 학습시키거나 관련성 주석을 사용하여 관련성 보상 모델을 학습시킬 수 있음

LinkedIn은 환각, 책임감 있는 AI 위반, 일관성 등을 추정하기 위해 모델 기반 평가자를 사용한 성공 사례를 공유했음 시간이 지남에 따라 가치가 증대되는 자산을 창출함으로써, 평가(evals) 구축을 단순한 운영 비용에서 전략적 투자로 전환하고, 그 과정에서 데이터 플라이휠을 구축

전략 4. 저비용 인지의 고차원적 추세 (The high-level trend of low-cost cognition)

  • 1971년 Xerox PARC의 연구원들은 우리가 현재 살고 있는 네트워크로 연결된 개인용 컴퓨터의 세계를 예측했음\n\n그들은 이를 가능하게 한 기술(이더넷, 그래픽 렌더링, 마우스, 윈도우 등)의 발명에 중추적인 역할을 함으로써 그 미래를 탄생시키는 데 기여했음
  • 매우 유용하지만(예: 비디오 디스플레이) 아직 경제적이지 않은(비디오 디스플레이를 구동하기에 충분한 RAM이 수천 달러) 애플리케이션을 살펴봄
  • 그런 다음 해당 기술의 역사적 가격 추세(무어의 법칙과 유사)를 살펴보고 그 기술이 언제 경제적이 될지 예측함

LLM 기술에 대해서도 같은 작업을 할 수 있음. 비록 달러당 트랜지스터 수만큼 깔끔한 것은 아니지만

  • 오랫동안 사용된 인기 있는 벤치마크(예: Massively-Multitask Language Understanding 데이터셋)와 일관된 입력 접근 방식(5-shot 프롬프팅)을 선택
  • 그런 다음 시간이 지남에 따라 이 벤치마크에서 다양한 성능 수준을 가진 언어 모델을 실행하는 비용을 비교

고정 비용에 대해 능력이 빠르게 증가하고 있음. 고정된 능력 수준에 대해 비용이 빠르게 감소하고 있음

  • OpenAI의 davinci 모델이 API로 출시된 이후 4년 동안, 100만 토큰(이 문서의 약 100개 사본) 규모에서 그 작업에 상응하는 성능을 가진 모델을 실행하는 비용은 $20에서 10센트 미만으로 떨어졌음. 반감기는 불과 6개월임
  • 유사하게 2024년 5월 기준 API 제공업체를 통하거나 자체적으로 Meta의 LLaMA 3 8B를 실행하는 비용은 토큰 100만 개당 20센트에 불과하며 ChatGPT를 가능하게 한 모델인 OpenAI의 text-davinci-003과 유사한 성능을 보임
  • 해당 모델은 2023년 11월 말 출시 당시에도 토큰 100만 개당 약 $20의 비용이 들었음. 불과 18개월 만에 두 자릿수 차이가 남. 무어의 법칙이 예측하는 단순한 두 배 증가와 동일한 기간임

이제 매우 유용하지만(Park et al과 같은 생성적 비디오 게임 캐릭터 구동) 아직 경제적이지 않은(시간당 비용이 $625로 추정됨) LLM 애플리케이션을 고려해 보자

  • 해당 논문이 2023년 8월에 발표된 이후 비용은 시간당 약 $62.50로 한 자릿수 정도 떨어졌음
  • 9개월 후에는 시간당 $6.25로 떨어질 것으로 예상할 수 있음

한편 팩맨이 1980년에 출시되었을 때 오늘날의 $1로 몇 분 또는 몇 십 분 동안 플레이할 수 있는 크레딧을 살 수 있었음. 시간당 6게임 또는 시간당 $6라고 부름

  • 이 냅킨 계산은 매력적인 LLM 강화 게임 경험이 2025년 경에는 경제적이 될 것임을 시사함

이러한 추세는 새로운 것이며 불과 몇 년 되지 않았음. 그러나 앞으로 몇 년 동안 이 과정이 느려질 것이라고 기대할 만한 이유는 거의 없음

  • 매개변수당 ~20 토큰의 "Chinchilla 비율"을 넘어 스케일링하는 것과 같은 알고리즘과 데이터셋의 낮게 매달린 과일을 사용하더라도, 데이터 센터 내부와 실리콘 계층에서의 더 깊은 혁신과 투자는 그 격차를 메울 것임

그리고 이것이 아마도 가장 중요한 전략적 사실일 것임

  • 오늘날 완전히 실현 불가능한 플로어 데모나 연구 논문이 몇 년 후에는 프리미엄 기능이 되고 그 직후에는 상품이 될 것임
  • 이를 염두에 두고 시스템과 조직을 구축해야 함

[0에서 1로 가는 데모는 이제 충분함. 이제는 1에서 N으로 가는 제품을 만들 때]

  • LLM 데모를 만드는 것은 정말 재미있음. 몇 줄의 코드, 벡터 데이터베이스, 신중하게 작성된 프롬프트로 "마법" 을 만들어냄
  • 지난 1년 동안 이 마법은 인터넷, 스마트폰, 심지어 인쇄술과 비교되었음
  • 안타깝게도 실제 소프트웨어 출시 작업을 해본 사람이라면 누구나 알고 있듯이, 통제된 환경에서 작동하는 데모와 대규모로 안정적으로 작동하는 제품 사이에는 엄청난 차이가 있음
  • 상상하고 데모를 만드는 것은 쉽지만 제품으로 만드는 것은 매우 어려운 문제들이 많음\n\n예를 들어 자율 주행: 자동차가 한 블록을 자율 주행하는 것은 쉽게 시연할 수 있지만, 이를 제품으로 만드는 데는 10년이 걸림 - Andrej Karpathy
  • 1988년 신경망으로 운전되는 첫 번째 자동차가 등장했음
  • 25년 후 Andrej Karpathy는 Waymo에서 첫 번째 데모 라이드를 했음
  • 그로부터 10년 후 회사는 무인 운전 허가를 받았음
  • 프로토타입에서 상용 제품으로 가기까지 35년 동안 엄격한 엔지니어링, 테스트, 개선, 규제 탐색이 이루어졌음

산업계와 학계 전반에 걸쳐 지난 1년 동안의 기복을 관찰했음 : LLM 애플리케이션의 1년차 (Year 1 of N for LLM applications)

  • 평가, 프롬프트 엔지니어링, 가드레일과 같은 전술부터 운영 기술, 팀 구축, 내부적으로 구축할 기능 선택과 같은 전략적 관점에 이르기까지 우리가 배운 교훈이 2년차 이후에 도움이 되기를 바람
  • 이 흥미로운 새로운 기술을 함께 발전시켜 나가길 기대함
  • LLM 기반의 시스템 & 제품 구축 패턴들
  • 프로덕션용 LLM 어플리케이션 구축하기
  • AI 시대, 0→1 서비스에서 오픈보다 운영이 더 중요한 이유
  • AI-Powered 기능을 구축하며 배운 것들
  • Lean Analytics, AI와 에이전트 시대에 맞춰 돌아보기

인증 이메일 클릭후 다시 체크박스를 눌러주세요

inthelife 2024-06-17 [-]

내용이 좋아서, 두고두고 보려고 Mindmap으로 만들어 보았습니다 ^^;

https://drive.google.com/file/d/&hellip;

hheungsu 2024-06-15 [-]

너무 좋은 글입니다!! 처음부터 끝까지 유용하게 곱씹어볼말들이 많습니다. 이렇게 주옥 같은 글을 번역해서 올려주셔서 감사합니다!!

nutella 2024-06-12 [-]

지금 시점에서 정말 도움이 많이 되네요

komanabi 2024-06-11 [-]

메가스터디는 끝났어, 오메가쓰리가온다!!!

ssifood 2024-06-11 [-]

이제 스카이넷은 끝났어, 메가스터디가 온다.

my0075425 2024-06-11 [-]

이제 인류는 끝났어 스카이넷이 온다!!

zihado 2024-06-10 [-]

원글 작성자의 커리어도 흥미로웠습니다\n\n심리학 전공자가 Lazada의 데이터 사이언스VP가 된 방법

eungook 2024-06-11 [-]

와.. 엄청나게 자극이 되네요.. 소개 감사합니다

humblebee 2024-06-10 [-]

통찰력과 경험이 생생하게 느끼져는 멋진 글이에요! 저와 팀에게 있어 큰 도움이 될것같습니다. 너무 잘 읽었습니다. 감사합니다 ☺️

{"topicId":15268,"topicType":"url","contentLengthBucket":"long","spamContentDetectedCode":217}

처음 오셨나요 사이트 이용법 FAQ About 긱배지 이용약관 개인정보 처리방침 Releases --> | Blog Lists RSS | Bookmarklet

X (Twitter) Facebook | 긱뉴스봇 : Slack 잔디 Discord Teams Dooray! Google Chat Swit

시작하기 이용법 FAQ About 긱배지 약관 개인정보

Lists Blog RSS X 긱뉴스봇

{"topicId":15268,"topicType":"url","contentLengthBucket":"long","spamContentDetectedCode":217}

처음 오셨나요 사이트 이용법 FAQ About 긱배지 이용약관 개인정보 처리방침 Releases --> | Blog Lists RSS | Bookmarklet

X (Twitter) Facebook | 긱뉴스봇 : Slack 잔디 Discord Teams Dooray! Google Chat Swit

시작하기 이용법 FAQ About 긱배지 약관 개인정보

Lists Blog RSS X 긱뉴스봇

", "tags": [ "AI", @@ -4541,3 +5327,31 @@ } ] } +atedAt": "2026-05-20T23:30:53.755Z", + "updatedAt": "2026-05-20T23:30:53.755Z" + }, + { + "ownerKey": "token:55da11db2e67390bf2bacb861407e697b7e4df64bfb6902d365b4f5856ed084e", + "article": { + "id": "naver-news-9fedaecce4d892be", + "title": "선거운동원에게 인사 건네는 하정우 [포토]", + "sourceLabel": "더팩트", + "url": "https://n.news.naver.com/mnews/article/629/0000501066", + "lead": "[더팩트 | 부산=박상민 기자] 하정우 더불어민주당 부산 북구갑 국회의원 후보가 21일 오전 부산 북구 구포대교 사거리에서 출정식을 연 가운데 한 선거운동원에게 인사를 건네고 있다", + "body": "[더팩트 | 부산=박상민 기자] 하정우 더불어민주당 부산 북구갑 국회의원 후보가 21일 오전 부산 북구 구포대교 사거리에서 출정식을 연 가운데 한 선거운동원에게 인사를 건네고 있다\n\n발로 뛰는 더팩트는 24시간 여러분의 제보를 기다립니다. ▶카카오톡: '더팩트제보' 검색 ▶이메일: jebo@tf.co.kr ▶뉴스 홈페이지: http://talk.tf.co.kr/bbs/report/write", + "htmlBody": "
\"기사

[더팩트 | 부산=박상민 기자] 하정우 더불어민주당 부산 북구갑 국회의원 후보가 21일 오전 부산 북구 구포대교 사거리에서 출정식을 연 가운데 한 선거운동원에게 인사를 건네고 있다

발로 뛰는 더팩트는 24시간 여러분의 제보를 기다립니다. ▶카카오톡: '더팩트제보' 검색 ▶이메일: jebo@tf.co.kr ▶뉴스 홈페이지: http://talk.tf.co.kr/bbs/report/write

", + "tags": [ + "네이버뉴스", + "정치" + ], + "signals": [ + "상단노출" + ], + "listedDate": "2026-05-21", + "publishedAt": "2026-05-21T08:30:10+09:00" + }, + "createdAt": "2026-05-20T23:30:53.755Z", + "updatedAt": "2026-05-20T23:30:53.755Z" + } + ] +} diff --git a/etc/servers/work-server/src/routes/chat.test.ts b/etc/servers/work-server/src/routes/chat.test.ts index 95c3c5b..3205355 100644 --- a/etc/servers/work-server/src/routes/chat.test.ts +++ b/etc/servers/work-server/src/routes/chat.test.ts @@ -1,6 +1,18 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import test from 'node:test'; -import { resolvePromptFollowupMode, resolveStaticContentType } from './chat.js'; +import Fastify from 'fastify'; +import { registerChatRoutes, resolvePromptFollowupMode, resolveStaticContentType } from './chat.js'; + +const repoRoot = path.resolve(process.cwd(), '../../..'); + +async function removeSessionUploads(sessionId: string) { + await fs.rm(path.join(repoRoot, 'public', '.codex_chat', sessionId), { + recursive: true, + force: true, + }); +} test('resolveStaticContentType returns html content type for chat resource html files', () => { assert.equal(resolveStaticContentType('/tmp/sample.html'), 'text/html; charset=utf-8'); @@ -18,3 +30,66 @@ test('resolvePromptFollowupMode defaults to queue and preserves direct mode', () assert.equal(resolvePromptFollowupMode('queue'), 'queue'); assert.equal(resolvePromptFollowupMode('direct'), 'direct'); }); + +test('chat attachments accept binary octet-stream uploads without base64 expansion', async () => { + const app = Fastify(); + await registerChatRoutes(app); + const sessionId = `binary-upload-${Date.now()}`; + const payload = Buffer.alloc(829_627, 1); + + try { + const response = await app.inject({ + method: 'POST', + url: '/api/chat/attachments', + headers: { + 'content-type': 'application/octet-stream', + 'x-chat-attachment-session-id': sessionId, + 'x-chat-attachment-file-name': encodeURIComponent('image.png'), + 'x-chat-attachment-mime-type': encodeURIComponent('image/png'), + }, + payload, + }); + + assert.equal(response.statusCode, 200); + const body = response.json() as { ok: boolean; item: { path: string; size: number; mimeType: string } }; + assert.equal(body.ok, true); + assert.equal(body.item.size, payload.byteLength); + assert.equal(body.item.mimeType, 'image/png'); + assert.match(body.item.path, new RegExp(`^public/\\.codex_chat/${sessionId}/resource/uploads/.+image\\.png$`)); + } finally { + await removeSessionUploads(sessionId); + await app.close(); + } +}); + +test('chat attachments keep legacy JSON base64 uploads working', async () => { + const app = Fastify(); + await registerChatRoutes(app); + const sessionId = `json-upload-${Date.now()}`; + + try { + const response = await app.inject({ + method: 'POST', + url: '/api/chat/attachments', + headers: { + 'content-type': 'application/json', + }, + payload: JSON.stringify({ + sessionId, + fileName: 'note.txt', + mimeType: 'text/plain', + contentBase64: Buffer.from('hello', 'utf8').toString('base64'), + }), + }); + + assert.equal(response.statusCode, 200); + const body = response.json() as { ok: boolean; item: { size: number; mimeType: string; name: string } }; + assert.equal(body.ok, true); + assert.equal(body.item.size, 5); + assert.equal(body.item.mimeType, 'text/plain'); + assert.equal(body.item.name, 'note.txt'); + } finally { + await removeSessionUploads(sessionId); + await app.close(); + } +}); diff --git a/etc/servers/work-server/src/routes/chat.ts b/etc/servers/work-server/src/routes/chat.ts index f3a31cb..34c517f 100644 --- a/etc/servers/work-server/src/routes/chat.ts +++ b/etc/servers/work-server/src/routes/chat.ts @@ -55,6 +55,7 @@ import { getChatShareTokenRoomMap, resolveChatShareTokenRoomSessionIds, upsertChatShareTokenRoomMap, + type ChatShareRoomLinkContext, type ChatShareTokenRoomMapItem, } from '../services/chat-share-room-map-service.js'; import { chatRuntimeService } from '../services/chat-runtime-service.js'; @@ -62,8 +63,8 @@ import { resolveMainProjectRoot } from '../services/main-project-root-service.js import { openResourceManagerPreviewStream } from '../services/resource-manager-service.js'; import { getTokenSettingById, type TokenSettingRecord } from '../services/token-setting-config-service.js'; -const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 20 * 1024 * 1024; -const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 10 * 1024 * 1024; +const CHAT_ATTACHMENT_FILE_SIZE_LIMIT = 300 * 1024 * 1024; +const CHAT_ATTACHMENT_ROUTE_BODY_LIMIT = 450 * 1024 * 1024; const CHAT_PUBLIC_ROUTE_PREFIX = '/.codex_chat/'; const CHAT_API_RESOURCE_ROUTE_PREFIX = '/api/chat/resources'; const CHAT_SHARE_ROUTE_PREFIX = '/api/chat/shares'; @@ -248,6 +249,7 @@ type ChatShareResolvedRoom = { contextLabel: string | null; contextDescription: string | null; notifyOffline: boolean; + linkContext: ChatShareRoomLinkContext | null; createdAt: string | null; updatedAt: string | null; }; @@ -265,6 +267,7 @@ function mapResolvedShareRoomItem(room: ChatShareTokenRoomMapItem): ChatShareRes contextLabel: room.contextLabel, contextDescription: room.contextDescription, notifyOffline: room.notifyOffline, + linkContext: room.linkContext, createdAt: room.createdAt, updatedAt: room.conversationUpdatedAt ?? room.updatedAt, }; @@ -292,6 +295,7 @@ async function resolveManagedShareRooms(args: { contextLabel: null, contextDescription: null, notifyOffline: false, + linkContext: null, createdAt: null, updatedAt: null, } satisfies ChatShareResolvedRoom, @@ -319,6 +323,7 @@ async function resolveManagedShareRooms(args: { contextLabel: null, contextDescription: null, notifyOffline: false, + linkContext: null, createdAt: null, updatedAt: null, } satisfies ChatShareResolvedRoom, @@ -370,6 +375,31 @@ function createManagedChatShareMessageIds() { }; } +function normalizeShareRoomLinkContext(input: { + linkedSessionId?: string | null; + linkedRequestId?: string | null; + linkedTitle?: string | null; + linkedRequestPreview?: string | null; + linkedChatTypeLabel?: string | null; +}): ChatShareRoomLinkContext | null { + const sourceSessionId = input.linkedSessionId?.trim() || ''; + const sourceRequestId = input.linkedRequestId?.trim() || ''; + + if (!sourceSessionId || !sourceRequestId) { + return null; + } + + return { + kind: 'linked-session', + sourceSessionId, + sourceRequestId, + sourceTitle: input.linkedTitle?.trim() || null, + sourceRequestPreview: input.linkedRequestPreview?.trim() || null, + sourceChatTypeLabel: input.linkedChatTypeLabel?.trim() || null, + linkedAt: new Date().toISOString(), + }; +} + function sortShareMessages(messages: ListedChatConversationMessage[]) { return [...messages].sort((left, right) => { if (left.id !== right.id) { @@ -700,6 +730,21 @@ async function saveChatAttachmentFile(args: { contentBase64: string; }) { const buffer = Buffer.from(args.contentBase64, 'base64'); + return saveChatAttachmentBuffer({ + sessionId: args.sessionId, + fileName: args.fileName, + mimeType: args.mimeType, + buffer, + }); +} + +async function saveChatAttachmentBuffer(args: { + sessionId: string; + fileName?: string; + mimeType?: string; + buffer: Buffer; +}) { + const buffer = args.buffer; if (buffer.byteLength === 0) { return { @@ -713,7 +758,7 @@ async function saveChatAttachmentFile(args: { return { ok: false as const, statusCode: 413, - message: '첨부 파일은 10MB 이하만 업로드할 수 있습니다.', + message: '첨부 파일은 300MB 이하만 업로드할 수 있습니다.', }; } @@ -746,6 +791,62 @@ async function saveChatAttachmentFile(args: { }; } +const chatAttachmentJsonBodySchema = z.object({ + sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/), + fileName: z.string().trim().max(255).optional(), + mimeType: z.string().trim().max(200).optional(), + contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2), +}); + +const chatAttachmentShareJsonBodySchema = z.object({ + sessionId: z.string().trim().min(1).max(120).optional().nullable(), + fileName: z.string().trim().max(255).optional(), + mimeType: z.string().trim().max(200).optional(), + contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2), +}); + +function decodeChatAttachmentHeaderValue(value: unknown) { + const normalized = String(Array.isArray(value) ? value[0] ?? '' : value ?? '').trim(); + + if (!normalized) { + return ''; + } + + try { + return decodeURIComponent(normalized); + } catch { + return normalized; + } +} + +function parseChatAttachmentBinaryHeaders(headers: Record, options?: { allowOptionalSessionId?: boolean }) { + const sessionId = decodeChatAttachmentHeaderValue(headers['x-chat-attachment-session-id']); + const fileName = decodeChatAttachmentHeaderValue(headers['x-chat-attachment-file-name']); + const mimeType = decodeChatAttachmentHeaderValue(headers['x-chat-attachment-mime-type']); + const schema = options?.allowOptionalSessionId + ? z.object({ + sessionId: z.string().trim().max(120).regex(/^[A-Za-z0-9._:-]+$/).optional().nullable(), + fileName: z.string().trim().max(255).optional(), + mimeType: z.string().trim().max(200).optional(), + }) + : z.object({ + sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/), + fileName: z.string().trim().max(255).optional(), + mimeType: z.string().trim().max(200).optional(), + }); + + return schema.parse({ + sessionId: sessionId || (options?.allowOptionalSessionId ? null : ''), + fileName: fileName || undefined, + mimeType: mimeType || undefined, + }); +} + +function isOctetStreamRequest(contentTypeHeader: unknown) { + const contentType = String(Array.isArray(contentTypeHeader) ? contentTypeHeader[0] ?? '' : contentTypeHeader ?? '').toLowerCase(); + return contentType.startsWith('application/octet-stream'); +} + function getClientIdHeader(request: { headers: Record }) { const raw = request.headers['x-client-id']; return Array.isArray(raw) ? String(raw[0] ?? '').trim() : String(raw ?? '').trim(); @@ -1119,22 +1220,32 @@ async function buildChatShareSnapshot( options?: { sessionId?: string | null; requestId?: string | null; + detailLevel?: 'full' | 'initial'; }, ) { const normalizedSessionId = options?.sessionId?.trim() || tokenPayload.sessionId.trim(); + const detailLevel = options?.detailLevel === 'initial' ? 'initial' : 'full'; const conversation = await getChatConversation(normalizedSessionId, null); if (!conversation) { return null; } - const [requests, messages] = await Promise.all([ - listChatConversationRequests(normalizedSessionId, 1000), - listChatConversationMessages(normalizedSessionId, { limit: 1000 }), - ]); + const isManagedShareRoomSession = + tokenPayload.kind === 'request-bundle' && normalizedSessionId.startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX); + const useInitialManagedShareRoomView = isManagedShareRoomSession && detailLevel === 'initial'; + const detailPage = useInitialManagedShareRoomView + ? await listChatConversationDetailPage(normalizedSessionId, { limit: 12 }) + : null; + const [requests, messages] = detailPage + ? [detailPage.requests, detailPage.messages] + : await Promise.all([ + listChatConversationRequests(normalizedSessionId, 1000), + listChatConversationMessages(normalizedSessionId, { limit: 1000 }), + ]); const requestMap = new Map(requests.map((request) => [request.requestId.trim(), request] as const)); const targetRequestId = options?.requestId?.trim() || tokenPayload.requestId.trim(); - const targetRequestFromStore = requestMap.get(targetRequestId) ?? null; + const targetRequestFromStore = requestMap.get(targetRequestId) ?? await getChatConversationRequest(normalizedSessionId, targetRequestId); const placeholderTargetRequest = targetRequestFromStore ? null : buildManagedSharePlaceholderRequest(tokenPayload, conversation); const targetRequest = targetRequestFromStore ?? placeholderTargetRequest; @@ -1143,13 +1254,13 @@ async function buildChatShareSnapshot( } const childRequestIdsByParentRequestId = buildChildRequestIdsByParentRequestId(requests); - const isManagedShareRoomSession = - tokenPayload.kind === 'request-bundle' && normalizedSessionId.startsWith(MANAGED_CHAT_SHARE_SESSION_PREFIX); const isManagedShareRoomPlaceholder = isManagedShareRoomSession && !targetRequestFromStore; const rootRequestId = isManagedShareRoomPlaceholder ? targetRequestId : resolveShareRootRequestId(targetRequestId, requestMap, tokenPayload.kind); - const scopeRequestIds = isManagedShareRoomSession + const scopeRequestIds = useInitialManagedShareRoomView + ? requests.map((request) => request.requestId.trim()).filter(Boolean) + : isManagedShareRoomSession ? requests.map((request) => request.requestId.trim()).filter(Boolean) : collectShareScopeRequestIds(rootRequestId, childRequestIdsByParentRequestId); const scopeRequestIdSet = new Set(scopeRequestIds); @@ -1158,9 +1269,11 @@ async function buildChatShareSnapshot( const linkedRequestId = message.clientRequestId?.trim() || ''; return linkedRequestId ? scopeRequestIdSet.has(linkedRequestId) : false; }); - const activityLogs = await listChatConversationActivityLogsByRequestIds(normalizedSessionId, scopeRequestIds); + const activityLogs = useInitialManagedShareRoomView + ? [] + : await listChatConversationActivityLogsByRequestIds(normalizedSessionId, scopeRequestIds); const promptTarget = tokenPayload.kind === 'prompt' ? resolvePromptTarget(scopedMessages, tokenPayload) : null; - const roomRequestCounts = buildRoomRequestCounts(requests, messages); + const roomRequestCounts = useInitialManagedShareRoomView ? undefined : buildRoomRequestCounts(requests, messages); if (tokenPayload.kind === 'prompt' && !promptTarget) { return null; @@ -1176,6 +1289,7 @@ async function buildChatShareSnapshot( activityLogs, roomRequestCounts, promptTarget, + detailLevel, } satisfies { conversation: Awaited>; rootRequestId: string; @@ -1187,7 +1301,7 @@ async function buildChatShareSnapshot( roomRequestCounts: { processingCount: number; unansweredCount: number; - }; + } | undefined; promptTarget: | { sourceMessageId: number; @@ -1195,6 +1309,7 @@ async function buildChatShareSnapshot( prompt: Extract; } | null; + detailLevel: 'full' | 'initial'; }; } @@ -1509,6 +1624,10 @@ function hasManagedShareAllowedApp( } export async function registerChatRoutes(app: FastifyInstance) { + app.addContentTypeParser('application/octet-stream', { parseAs: 'buffer' }, (request, body, done) => { + done(null, body); + }); + app.addHook('onSend', async (request, reply, payload) => { if (request.method.toUpperCase() === 'GET' && request.url.startsWith('/api/chat')) { applyChatApiNoStoreHeaders(reply); @@ -1756,6 +1875,11 @@ export async function registerChatRoutes(app: FastifyInstance) { title: z.string().trim().min(1).max(200), requestBadgeLabel: z.string().trim().max(120).optional().nullable(), seedMessage: z.string().trim().min(1).max(20000), + linkedSessionId: z.string().trim().min(1).max(120).optional().nullable(), + linkedRequestId: z.string().trim().min(1).max(120).optional().nullable(), + linkedTitle: z.string().trim().max(200).optional().nullable(), + linkedRequestPreview: z.string().trim().max(1000).optional().nullable(), + linkedChatTypeLabel: z.string().trim().max(200).optional().nullable(), }).parse(request.body ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); @@ -1852,6 +1976,7 @@ export async function registerChatRoutes(app: FastifyInstance) { rootRequestId: requestId, isDefault: false, createdByClientId: clientId || null, + linkContext: normalizeShareRoomLinkContext(payload), }); const room = await getChatShareTokenRoomMap(currentToken.id, sessionId); @@ -1872,6 +1997,7 @@ export async function registerChatRoutes(app: FastifyInstance) { contextLabel: payload.chatTypeLabel, contextDescription: null, notifyOffline: true, + linkContext: normalizeShareRoomLinkContext(payload), createdAt, updatedAt: createdAt, }, @@ -2322,6 +2448,7 @@ export async function registerChatRoutes(app: FastifyInstance) { }).parse(request.params ?? {}); const query = z.object({ sessionId: z.string().trim().min(1).max(120).optional(), + view: z.enum(['full', 'initial']).optional(), }).parse(request.query ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); @@ -2361,6 +2488,7 @@ export async function registerChatRoutes(app: FastifyInstance) { const shareSnapshot = await buildChatShareSnapshot(tokenPayload, { sessionId: activeRoom.sessionId, requestId: activeRoom.requestId, + detailLevel: query.view === 'initial' ? 'initial' : 'full', }); if (!shareSnapshot) { @@ -2433,6 +2561,7 @@ export async function registerChatRoutes(app: FastifyInstance) { rooms: resolvedRoomContext.rooms, activeSessionId: activeRoom.sessionId, promptTarget: shareSnapshot.promptTarget, + detailLevel: shareSnapshot.detailLevel, refreshedAt: new Date().toISOString(), }; }); @@ -2750,15 +2879,16 @@ export async function registerChatRoutes(app: FastifyInstance) { }; }); - app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/attachments`, { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => { + app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/origin-reply`, async (request, reply) => { const params = z.object({ token: z.string().trim().min(1).max(16000), }).parse(request.params ?? {}); const payload = z.object({ sessionId: z.string().trim().min(1).max(120).optional().nullable(), - fileName: z.string().trim().max(255).optional(), - mimeType: z.string().trim().max(200).optional(), - contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2), + sourceSessionId: z.string().trim().min(1).max(120), + sourceRequestId: z.string().trim().min(1).max(120), + text: z.string().trim().min(1).max(20000), + mode: z.enum(['queue', 'direct']).optional(), }).parse(request.body ?? {}); const managedContext = await resolveManagedChatShareContext(params.token); const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); @@ -2781,6 +2911,109 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } + const unavailableMessage = resolveManagedShareUnavailableMessage(managedContext.managedResource); + + if (unavailableMessage) { + return reply.code(403).send({ + message: unavailableMessage, + }); + } + + if (!(await ensureManagedShareAccessPin(request, reply, managedContext.sharePath))) { + return; + } + + if (managedContext.managedResource?.token.permissions && !managedContext.managedResource.token.permissions.includes('comment')) { + return reply.code(403).send({ + message: '이 공유 링크에는 원 세션 답변 전송 권한이 없습니다.', + }); + } + + const normalizedSourceSessionId = payload.sourceSessionId.trim(); + const normalizedSourceRequestId = payload.sourceRequestId.trim(); + const allowedLinkTargets = resolvedRoomContext.rooms + .map((room) => room.linkContext) + .filter((item): item is ChatShareRoomLinkContext => item?.kind === 'linked-session'); + const matchedLinkTarget = allowedLinkTargets.find((item) => + item.sourceSessionId === normalizedSourceSessionId && item.sourceRequestId === normalizedSourceRequestId, + ); + + if (!matchedLinkTarget) { + return reply.code(403).send({ + message: '현재 공유채팅에 연결된 원 세션만 답변 전송할 수 있습니다.', + }); + } + + const targetRequest = await getChatConversationRequest(normalizedSourceSessionId, normalizedSourceRequestId); + + if (!targetRequest) { + return reply.code(404).send({ + message: '원 세션 요청을 찾지 못했습니다.', + }); + } + + const targetConversation = await getChatConversation(normalizedSourceSessionId, null); + const queuedRequestId = await getActiveChatService()?.submitExternalMessage( + normalizedSourceSessionId, + payload.text, + { + mode: payload.mode === 'direct' ? 'direct' : 'queue', + requestOrigin: 'composer', + sharedResourceTokenId: managedContext.managedResource?.token.id ?? tokenPayload.managedResourceTokenId ?? null, + parentRequestId: normalizedSourceRequestId, + clientId: targetRequest.requesterClientId ?? targetConversation?.clientId ?? null, + }, + ); + + if (!queuedRequestId) { + return reply.code(503).send({ + message: '원 세션 답변 전송을 시작하지 못했습니다.', + }); + } + + if (managedContext.managedResource) { + await recordSharedResourceTokenUsage(managedContext.managedResource.token.id, { + actorLabel: 'share-viewer', + summary: '공유채팅에서 원 세션으로 답변을 전송했습니다.', + detail: `${normalizedSourceSessionId}:${normalizedSourceRequestId}`, + }); + } + + return { + ok: true, + queuedRequestId, + }; + }); + + app.post(`${CHAT_SHARE_ROUTE_PREFIX}/:token/attachments`, { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => { + const params = z.object({ + token: z.string().trim().min(1).max(16000), + }).parse(request.params ?? {}); + const isBinaryRequest = isOctetStreamRequest(request.headers['content-type']); + const payload = isBinaryRequest + ? parseChatAttachmentBinaryHeaders(request.headers, { allowOptionalSessionId: true }) + : chatAttachmentShareJsonBodySchema.parse(request.body ?? {}); + const managedContext = await resolveManagedChatShareContext(params.token); + const tokenPayload = resolveChatSharePayloadFromManagedResource(managedContext.managedResource) ?? parseChatShareToken(params.token); + + if (!tokenPayload) { + return reply.code(404).send({ + message: '공유 링크가 유효하지 않습니다.', + }); + } + + const resolvedRoomContext = await resolveActiveManagedShareRoom({ + managedResource: managedContext.managedResource, + tokenPayload, + requestedSessionId: payload.sessionId ?? null, + }); + + if (!resolvedRoomContext.requestedRoomMatched || !resolvedRoomContext.activeRoom) { + return reply.code(403).send({ + message: '이 공유 링크에서 접근할 수 없는 채팅방입니다.', + }); + } + const shareSnapshot = await buildChatShareSnapshot(tokenPayload, { sessionId: resolvedRoomContext.activeRoom.sessionId, requestId: resolvedRoomContext.activeRoom.requestId, @@ -2823,12 +3056,19 @@ export async function registerChatRoutes(app: FastifyInstance) { }); } - const saved = await saveChatAttachmentFile({ - sessionId: resolvedRoomContext.activeRoom.sessionId, - fileName: payload.fileName, - mimeType: payload.mimeType, - contentBase64: payload.contentBase64, - }); + const saved = isBinaryRequest + ? await saveChatAttachmentBuffer({ + sessionId: resolvedRoomContext.activeRoom.sessionId, + fileName: payload.fileName, + mimeType: payload.mimeType, + buffer: Buffer.isBuffer(request.body) ? request.body : Buffer.alloc(0), + }) + : await saveChatAttachmentFile({ + sessionId: resolvedRoomContext.activeRoom.sessionId, + fileName: payload.fileName, + mimeType: payload.mimeType, + contentBase64: chatAttachmentShareJsonBodySchema.parse(request.body ?? {}).contentBase64, + }); if (!saved.ok) { return reply.code(saved.statusCode).send({ @@ -3437,13 +3677,18 @@ export async function registerChatRoutes(app: FastifyInstance) { }); app.post('/api/chat/attachments', { bodyLimit: CHAT_ATTACHMENT_ROUTE_BODY_LIMIT }, async (request, reply) => { - const payload = z.object({ - sessionId: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9._:-]+$/), - fileName: z.string().trim().max(255).optional(), - mimeType: z.string().trim().max(200).optional(), - contentBase64: z.string().trim().min(1).max(CHAT_ATTACHMENT_ROUTE_BODY_LIMIT * 2), - }).parse(request.body ?? {}); - const saved = await saveChatAttachmentFile(payload); + const isBinaryRequest = isOctetStreamRequest(request.headers['content-type']); + const saved = isBinaryRequest + ? await (() => { + const binaryPayload = parseChatAttachmentBinaryHeaders(request.headers); + return saveChatAttachmentBuffer({ + sessionId: binaryPayload.sessionId ?? '', + fileName: binaryPayload.fileName, + mimeType: binaryPayload.mimeType, + buffer: Buffer.isBuffer(request.body) ? request.body : Buffer.alloc(0), + }); + })() + : await saveChatAttachmentFile(chatAttachmentJsonBodySchema.parse(request.body ?? {})); if (!saved.ok) { return reply.code(saved.statusCode).send({ diff --git a/etc/servers/work-server/src/services/chat-share-room-map-service.ts b/etc/servers/work-server/src/services/chat-share-room-map-service.ts index be6e185..50b42c6 100644 --- a/etc/servers/work-server/src/services/chat-share-room-map-service.ts +++ b/etc/servers/work-server/src/services/chat-share-room-map-service.ts @@ -20,11 +20,22 @@ export type ChatShareTokenRoomMapItem = { contextLabel: string | null; contextDescription: string | null; notifyOffline: boolean; + linkContext: ChatShareRoomLinkContext | null; createdAt: string | null; updatedAt: string | null; conversationUpdatedAt: string | null; }; +export type ChatShareRoomLinkContext = { + kind: 'linked-session'; + sourceSessionId: string; + sourceRequestId: string; + sourceTitle: string | null; + sourceRequestPreview: string | null; + sourceChatTypeLabel: string | null; + linkedAt: string | null; +}; + function normalizeOptionalText(value: unknown) { if (typeof value !== 'string') { return null; @@ -72,6 +83,72 @@ function normalizeDateTime(value: unknown) { return null; } +function parseChatShareRoomLinkContext(value: unknown) { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim(); + + if (!normalized) { + return null; + } + + try { + const parsed = JSON.parse(normalized) as Record; + + if (parsed.kind !== 'linked-session') { + return null; + } + + const sourceSessionId = normalizeRequiredText(parsed.sourceSessionId); + const sourceRequestId = normalizeRequiredText(parsed.sourceRequestId); + + if (!sourceSessionId || !sourceRequestId) { + return null; + } + + return { + kind: 'linked-session', + sourceSessionId, + sourceRequestId, + sourceTitle: normalizeOptionalText(parsed.sourceTitle), + sourceRequestPreview: normalizeOptionalText(parsed.sourceRequestPreview), + sourceChatTypeLabel: normalizeOptionalText(parsed.sourceChatTypeLabel), + linkedAt: normalizeDateTime(parsed.linkedAt), + } satisfies ChatShareRoomLinkContext; + } catch { + return null; + } +} + +function stringifyChatShareRoomLinkContext(value: ChatShareRoomLinkContext | null | undefined) { + if (!value) { + return null; + } + + if (value.kind !== 'linked-session') { + return null; + } + + const sourceSessionId = normalizeRequiredText(value.sourceSessionId); + const sourceRequestId = normalizeRequiredText(value.sourceRequestId); + + if (!sourceSessionId || !sourceRequestId) { + return null; + } + + return JSON.stringify({ + kind: 'linked-session', + sourceSessionId, + sourceRequestId, + sourceTitle: normalizeOptionalText(value.sourceTitle), + sourceRequestPreview: normalizeOptionalText(value.sourceRequestPreview), + sourceChatTypeLabel: normalizeOptionalText(value.sourceChatTypeLabel), + linkedAt: normalizeDateTime(value.linkedAt), + }); +} + function mapChatShareTokenRoomRow(row: Record): ChatShareTokenRoomMapItem { return { tokenId: normalizeRequiredText(row.shared_resource_token_id), @@ -87,6 +164,7 @@ function mapChatShareTokenRoomRow(row: Record): ChatShareTokenR contextLabel: normalizeOptionalText(row.context_label), contextDescription: normalizeOptionalText(row.context_description), notifyOffline: normalizeBoolean(row.notify_offline), + linkContext: parseChatShareRoomLinkContext(row.link_context_json), createdAt: normalizeDateTime(row.created_at), updatedAt: normalizeDateTime(row.updated_at), conversationUpdatedAt: normalizeDateTime(row.conversation_updated_at), @@ -121,6 +199,7 @@ export async function ensureChatShareTokenRoomMapTable() { ['is_default', (table) => table.boolean('is_default').notNullable().defaultTo(false)], ['sort_order', (table) => table.integer('sort_order').notNullable().defaultTo(0)], ['created_by_client_id', (table) => table.string('created_by_client_id', 120).nullable()], + ['link_context_json', (table) => table.text('link_context_json').nullable()], ['archived_at', (table) => table.timestamp('archived_at', { useTz: true }).nullable().index()], ['created_at', (table) => table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], ['updated_at', (table) => table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(db.fn.now())], @@ -164,6 +243,7 @@ export async function listChatShareTokenRoomMaps(tokenId: string) { 'conversation.context_label', 'conversation.context_description', 'conversation.notify_offline', + 'room_map.link_context_json', 'conversation.updated_at as conversation_updated_at', ) .where({ 'room_map.shared_resource_token_id': normalizedTokenId }) @@ -194,6 +274,7 @@ export async function upsertChatShareTokenRoomMap(args: { isDefault?: boolean; sortOrder?: number | null; createdByClientId?: string | null; + linkContext?: ChatShareRoomLinkContext | null; }) { const normalizedTokenId = args.tokenId.trim(); const normalizedSessionId = args.sessionId.trim(); @@ -240,6 +321,10 @@ export async function upsertChatShareTokenRoomMap(args: { is_default: args.isDefault === true, sort_order: nextSortOrder, created_by_client_id: normalizeOptionalText(args.createdByClientId), + link_context_json: + args.linkContext === undefined + ? (current?.link_context_json ?? null) + : stringifyChatShareRoomLinkContext(args.linkContext), archived_at: null, updated_at: db.fn.now(), }; diff --git a/etc/servers/work-server/src/services/plan-schedule-service.ts b/etc/servers/work-server/src/services/plan-schedule-service.ts index e5f2738..a1c4260 100644 --- a/etc/servers/work-server/src/services/plan-schedule-service.ts +++ b/etc/servers/work-server/src/services/plan-schedule-service.ts @@ -25,6 +25,7 @@ import { import { getKstNowParts } from './worklog-automation-utils.js'; export const PLAN_SCHEDULED_TASK_TABLE = 'plan_scheduled_tasks'; +const PLAN_SCHEDULE_REGISTRATION_ADVISORY_LOCK_NAMESPACE = 741_205_262; const scheduleModes = ['interval', 'daily'] as const; const repeatIntervalUnits = ['second', 'minute', 'hour', 'day', 'week', 'month'] as const; const scheduleExecutionModes = ['codex', 'managed-service'] as const; @@ -515,6 +516,39 @@ function normalizeBoolean(value: unknown, fallback: boolean) { return fallback; } +function readBooleanLikeValue(value: unknown) { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'number') { + return value !== 0; + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + return normalized === 'true' || normalized === 't' || normalized === '1'; + } + + return false; +} + +async function tryAcquirePlanScheduleRegistrationLock(scheduleId: number) { + const result = (await db.raw('select pg_try_advisory_lock(?, ?) as locked', [ + PLAN_SCHEDULE_REGISTRATION_ADVISORY_LOCK_NAMESPACE, + scheduleId, + ])) as { rows?: Array<{ locked?: unknown }> }; + + return readBooleanLikeValue(result.rows?.[0]?.locked); +} + +async function releasePlanScheduleRegistrationLock(scheduleId: number) { + await db.raw('select pg_advisory_unlock(?, ?)', [ + PLAN_SCHEDULE_REGISTRATION_ADVISORY_LOCK_NAMESPACE, + scheduleId, + ]); +} + function buildManagedServiceFailureSummary(result: { title?: string; skipped?: boolean; @@ -1510,162 +1544,179 @@ export async function registerDuePlanScheduledTasks(now = new Date()) { } async function registerPlanScheduledTaskRow(row: Record, now: Date) { - const executionMode = normalizeScheduleExecutionMode(row.execution_mode); - const automationContextIds = parseAutomationContextIds(row.automation_context_ids_json); - const shouldRefreshSnapshot = - !row.context_snapshot_generated_at || normalizeBoolean(row.context_snapshot_refresh_requested, false); - const scheduleSnapshot = shouldRefreshSnapshot - ? await ensureSchedulePromptSnapshot({ - scheduleId: Number(row.id), - workId: buildScheduledPlanWorkIdBase(row), - note: String(row.note ?? ''), - forceRefresh: true, - }) - : { - directory: `.auto_codex/schedule/${row.id}`, - requestPath: `.auto_codex/schedule/${row.id}/request.md`, - contextPath: `.auto_codex/schedule/${row.id}/context.md`, - manifestPath: `.auto_codex/schedule/${row.id}/manifest.json`, - }; - const managedServiceReady = await ensureManagedServiceExecutionReady({ - row, - scheduleSnapshot, - automationContextIds, - }); - const effectiveRow = managedServiceReady.row; - const scheduleNote = [ - String(effectiveRow.note ?? '').trim(), - '', - '## 스케줄 전용 참조 문서', - `- ${scheduleSnapshot.requestPath}`, - `- ${scheduleSnapshot.contextPath}`, - '', - '위 경로의 Markdown 문서를 먼저 읽고 처리하세요. 원본 소스/문서 재탐색은 꼭 필요한 경우에만 제한적으로 수행하세요.', - executionMode === 'managed-service' - ? [ - '', - '## 스케줄 관리 서비스', - `- 서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${effectiveRow.id}-service`)}`, - `- 서비스 경로: ${String(effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${effectiveRow.id}`)}`, - managedServiceReady.ready - ? '- 현재 생성된 스케줄 전용 서비스 파일을 직접 실행합니다.' - : `- 현재 서비스 패키지 생성 Plan을 ${managedServiceReady.generationTriggered ? '자동 접수했으며' : '이미 접수해 두었으며'} 생성 완료 전까지 실제 서비스 실행은 보류합니다.`, - managedServiceReady.reason - ? `- 생성 사유: ${managedServiceReady.reason === 'missing' ? '패키지 누락 감지' : '패키지 재생성 요청'}` - : null, - '- 서비스 구현은 Codex CLI가 `.auto_codex/schedule/{id}` 아래 파일을 생성한 결과물을 사용합니다.', - ].join('\n') - : null, - ] - .filter((value): value is string => Boolean(value)) - .join('\n') - .trim(); + const scheduleId = Number(row.id); - if (executionMode === 'managed-service') { - if (!managedServiceReady.ready) { - return { - createdPlan: managedServiceReady.createdPlan, - createdBoardPosts: managedServiceReady.createdBoardPosts, - }; - } - - const managedServiceDirectory = String( - effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${effectiveRow.id}`, - ); - const managedServiceResult = await runManagedScheduleService(managedServiceDirectory); - if (!managedServiceResult.ok) { - throw new Error( - `스케줄 서비스 실행 실패: ${buildManagedServiceFailureSummary(managedServiceResult)}`, - ); - } - - const createdPlan = await createCompletedPlanExecutionLogItem({ - workId: buildScheduledPlanWorkIdBase(effectiveRow), - note: scheduleNote, - automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'), - automationContextIds, - releaseTarget: String(effectiveRow.release_target ?? 'release'), - jangsingProcessingRequired: Boolean(effectiveRow.jangsing_processing_required ?? true), - autoDeployToMain: Boolean(effectiveRow.auto_deploy_to_main ?? true), - suppressWebPush: Boolean(effectiveRow.suppress_web_push ?? false), - repeatRequestEnabled: false, - repeatIntervalMinutes: 60, - }); - const managedServiceChangedFiles = [ - `${managedServiceDirectory}/README.md`, - `${managedServiceDirectory}/service.ts`, - `${managedServiceDirectory}/service.mjs`, - `${managedServiceDirectory}/service-manifest.json`, - ]; - - await createPlanSourceWorkHistory(Number(createdPlan.id), { - summary: [ - `스케줄 서비스 실행: schedule #${effectiveRow.id}`, - `서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${effectiveRow.id}-service`)}`, - `결과: ${ - managedServiceResult.skipped - ? `스킵 (${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? '사유 없음'})` - : `${managedServiceResult.itemCount}건 전송 시도` - }`, - ].join('\n'), - branchName: String(createdPlan.releaseTarget ?? createdPlan.assignedBranch ?? 'main'), - commitHash: null, - changedFiles: managedServiceChangedFiles, - commandLog: [ - `schedule-managed-service run scheduleId=${String(effectiveRow.id)}`, - `servicePath=${managedServiceDirectory}/service.mjs`, - `itemCount=${managedServiceResult.itemCount}`, - `webSent=${managedServiceResult.web.sentCount}`, - `webFailed=${managedServiceResult.web.failedCount}`, - `skipped=${managedServiceResult.skipped ? 'true' : 'false'}`, - `reason=${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? ''}`, - ].join('\n'), - diffText: null, - sourceFiles: [], - }); - await createPlanActionHistory( - Number(createdPlan.id), - '스케줄서비스실행', - `Plan 스케줄 #${effectiveRow.id} 전용 서비스 파일을 직접 실행했습니다.`, - ); - await db(PLAN_SCHEDULED_TASK_TABLE) - .where({ id: effectiveRow.id }) - .update({ - last_registered_at: now, - context_snapshot_generated_at: now, - context_snapshot_refresh_requested: false, - managed_service_generated_at: db.fn.now(), - updated_at: db.fn.now(), - }); + if (!Number.isInteger(scheduleId) || scheduleId <= 0) { + throw new Error('유효하지 않은 스케줄 ID입니다.'); + } + if (!(await tryAcquirePlanScheduleRegistrationLock(scheduleId))) { return { - createdPlan, + createdPlan: null, createdBoardPosts: [], }; } - const boardPost = await createBoardPost({ - title: buildScheduledBoardPostTitle(effectiveRow), - content: scheduleNote, - attachments: [], - automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'), - automationContextIds, - requestExecutionMode: 'all_at_once', - requestItems: [], - }); - - await db(PLAN_SCHEDULED_TASK_TABLE) - .where({ id: effectiveRow.id }) - .update({ - last_registered_at: now, - context_snapshot_generated_at: now, - context_snapshot_refresh_requested: false, - updated_at: db.fn.now(), + try { + const executionMode = normalizeScheduleExecutionMode(row.execution_mode); + const automationContextIds = parseAutomationContextIds(row.automation_context_ids_json); + const shouldRefreshSnapshot = + !row.context_snapshot_generated_at || normalizeBoolean(row.context_snapshot_refresh_requested, false); + const scheduleSnapshot = shouldRefreshSnapshot + ? await ensureSchedulePromptSnapshot({ + scheduleId: Number(row.id), + workId: buildScheduledPlanWorkIdBase(row), + note: String(row.note ?? ''), + forceRefresh: true, + }) + : { + directory: `.auto_codex/schedule/${row.id}`, + requestPath: `.auto_codex/schedule/${row.id}/request.md`, + contextPath: `.auto_codex/schedule/${row.id}/context.md`, + manifestPath: `.auto_codex/schedule/${row.id}/manifest.json`, + }; + const managedServiceReady = await ensureManagedServiceExecutionReady({ + row, + scheduleSnapshot, + automationContextIds, }); - return { - createdPlan: null, - createdBoardPosts: [boardPost], - }; + const effectiveRow = managedServiceReady.row; + const scheduleNote = [ + String(effectiveRow.note ?? '').trim(), + '', + '## 스케줄 전용 참조 문서', + `- ${scheduleSnapshot.requestPath}`, + `- ${scheduleSnapshot.contextPath}`, + '', + '위 경로의 Markdown 문서를 먼저 읽고 처리하세요. 원본 소스/문서 재탐색은 꼭 필요한 경우에만 제한적으로 수행하세요.', + executionMode === 'managed-service' + ? [ + '', + '## 스케줄 관리 서비스', + `- 서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${scheduleId}-service`)}`, + `- 서비스 경로: ${String(effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${scheduleId}`)}`, + managedServiceReady.ready + ? '- 현재 생성된 스케줄 전용 서비스 파일을 직접 실행합니다.' + : `- 현재 서비스 패키지 생성 Plan을 ${managedServiceReady.generationTriggered ? '자동 접수했으며' : '이미 접수해 두었으며'} 생성 완료 전까지 실제 서비스 실행은 보류합니다.`, + managedServiceReady.reason + ? `- 생성 사유: ${managedServiceReady.reason === 'missing' ? '패키지 누락 감지' : '패키지 재생성 요청'}` + : null, + '- 서비스 구현은 Codex CLI가 `.auto_codex/schedule/{id}` 아래 파일을 생성한 결과물을 사용합니다.', + ].join('\n') + : null, + ] + .filter((value): value is string => Boolean(value)) + .join('\n') + .trim(); + + if (executionMode === 'managed-service') { + if (!managedServiceReady.ready) { + return { + createdPlan: managedServiceReady.createdPlan, + createdBoardPosts: managedServiceReady.createdBoardPosts, + }; + } + + const managedServiceDirectory = String( + effectiveRow.managed_service_directory ?? `.auto_codex/schedule/${scheduleId}`, + ); + const managedServiceResult = await runManagedScheduleService(managedServiceDirectory); + if (!managedServiceResult.ok) { + throw new Error( + `스케줄 서비스 실행 실패: ${buildManagedServiceFailureSummary(managedServiceResult)}`, + ); + } + + const createdPlan = await createCompletedPlanExecutionLogItem({ + workId: buildScheduledPlanWorkIdBase(effectiveRow), + note: scheduleNote, + automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'), + automationContextIds, + releaseTarget: String(effectiveRow.release_target ?? 'release'), + jangsingProcessingRequired: Boolean(effectiveRow.jangsing_processing_required ?? true), + autoDeployToMain: Boolean(effectiveRow.auto_deploy_to_main ?? true), + suppressWebPush: Boolean(effectiveRow.suppress_web_push ?? false), + repeatRequestEnabled: false, + repeatIntervalMinutes: 60, + }); + const managedServiceChangedFiles = [ + `${managedServiceDirectory}/README.md`, + `${managedServiceDirectory}/service.ts`, + `${managedServiceDirectory}/service.mjs`, + `${managedServiceDirectory}/service-manifest.json`, + ]; + + await createPlanSourceWorkHistory(Number(createdPlan.id), { + summary: [ + `스케줄 서비스 실행: schedule #${scheduleId}`, + `서비스 키: ${String(effectiveRow.managed_service_key ?? `schedule-${scheduleId}-service`)}`, + `결과: ${ + managedServiceResult.skipped + ? `스킵 (${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? '사유 없음'})` + : `${managedServiceResult.itemCount}건 전송 시도` + }`, + ].join('\n'), + branchName: String(createdPlan.releaseTarget ?? createdPlan.assignedBranch ?? 'main'), + commitHash: null, + changedFiles: managedServiceChangedFiles, + commandLog: [ + `schedule-managed-service run scheduleId=${String(effectiveRow.id)}`, + `servicePath=${managedServiceDirectory}/service.mjs`, + `itemCount=${managedServiceResult.itemCount}`, + `webSent=${managedServiceResult.web.sentCount}`, + `webFailed=${managedServiceResult.web.failedCount}`, + `skipped=${managedServiceResult.skipped ? 'true' : 'false'}`, + `reason=${managedServiceResult.web.reason ?? managedServiceResult.ios.reason ?? ''}`, + ].join('\n'), + diffText: null, + sourceFiles: [], + }); + await createPlanActionHistory( + Number(createdPlan.id), + '스케줄서비스실행', + `Plan 스케줄 #${scheduleId} 전용 서비스 파일을 직접 실행했습니다.`, + ); + await db(PLAN_SCHEDULED_TASK_TABLE) + .where({ id: scheduleId }) + .update({ + last_registered_at: now, + context_snapshot_generated_at: now, + context_snapshot_refresh_requested: false, + managed_service_generated_at: db.fn.now(), + updated_at: db.fn.now(), + }); + + return { + createdPlan, + createdBoardPosts: [], + }; + } + + const boardPost = await createBoardPost({ + title: buildScheduledBoardPostTitle(effectiveRow), + content: scheduleNote, + attachments: [], + automationType: String(effectiveRow.automation_type_id ?? effectiveRow.automation_type ?? 'none'), + automationContextIds, + requestExecutionMode: 'all_at_once', + requestItems: [], + }); + + await db(PLAN_SCHEDULED_TASK_TABLE) + .where({ id: scheduleId }) + .update({ + last_registered_at: now, + context_snapshot_generated_at: now, + context_snapshot_refresh_requested: false, + updated_at: db.fn.now(), + }); + return { + createdPlan: null, + createdBoardPosts: [boardPost], + }; + } finally { + await releasePlanScheduleRegistrationLock(scheduleId); + } } export async function registerPlanScheduledTaskNow( diff --git a/etc/servers/work-server/src/services/server-command-service.test.ts b/etc/servers/work-server/src/services/server-command-service.test.ts index 878adfd..1fd86a3 100644 --- a/etc/servers/work-server/src/services/server-command-service.test.ts +++ b/etc/servers/work-server/src/services/server-command-service.test.ts @@ -13,6 +13,7 @@ import { listServerCommands, resolveDockerSocketPath, restartServerCommand, + readWorkServerDeploymentState, } from './server-command-service.js'; test('buildRestartFailureMessage includes exit info and stderr output', () => { @@ -115,6 +116,12 @@ test('test, release and prod restart scripts fall back to Docker socket when doc ); assert.match(workServerScript, /recover_interrupted_chat_requests "\$TARGET_CONTAINER"/); assert.match(workServerScript, /docker exec "\$PROXY_CONTAINER" nginx -s reload/); + assert.match(workServerScript, /wait_for_container_runtime_ready "\$TARGET_CONTAINER" "\$TARGET_SLOT"/); + assert.match(workServerScript, /wait_for_proxy_slot_health "\$TARGET_SLOT"/); + assert.match(workServerScript, /Promise.all\(\[fetch\(process.argv\[1\]\), fetch\(process.argv\[2\]\)\]\)/); + assert.match(workServerScript, /payload\?\.slot !== expectedSlot/); + assert.match(workServerScript, /runtime readiness check failed/); + assert.match(workServerScript, /STABLE_SUCCESS_COUNT=\$\(\(STABLE_SUCCESS_COUNT \+ 1\)\)/); assert.match(workServerScript, /work-server zero-downtime switch completed/); assert.match(socketRestartScript, /\/containers\/\$\{encodeURIComponent\(containerName\)\}\/restart\?t=30/); }); @@ -479,3 +486,105 @@ test('listServerCommands keeps work-server updateAvailable false when only a sta await rm(tempRoot, { recursive: true, force: true }); } }); + + +test('readWorkServerDeploymentState transitions stale running deployment to failed when the lock is gone', async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-deployment-')); + const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT; + const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT; + + try { + env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot; + env.SERVER_COMMAND_PROJECT_ROOT = tempRoot; + await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8'); + await writeFile(path.join(tempRoot, 'package.json'), '{"name":"temp-root"}\n', 'utf8'); + + const runtimeDir = path.join(tempRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime'); + await mkdir(runtimeDir, { recursive: true }); + await writeFile( + path.join(runtimeDir, 'deployment-state.json'), + JSON.stringify({ + status: 'running', + phase: 'drain-previous-slot', + summary: '이전 슬롯 요청을 새 슬롯으로 이관하는 중입니다.', + startedAt: '2026-05-28T01:45:37.000Z', + updatedAt: '2026-05-28T01:54:20.000Z', + activeSlot: 'blue', + targetSlot: 'blue', + previousSlot: 'green', + steps: [ + { key: 'build-target-slot', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:37.000Z' }, + { key: 'verify-target-health', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:53.000Z' }, + { key: 'switch-proxy', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:57.000Z' }, + { key: 'drain-previous-slot', status: 'running', detail: 'active 2 · queued 0', updatedAt: '2026-05-28T01:54:20.000Z' }, + { key: 'rebuild-previous-slot', status: 'pending', detail: null, updatedAt: null }, + { key: 'recover-interrupted-chat', status: 'pending', detail: null, updatedAt: null }, + ], + }) + '\n', + 'utf8', + ); + + const snapshot = await readWorkServerDeploymentState(); + + assert.ok(snapshot); + assert.equal(snapshot.status, 'failed'); + assert.equal(snapshot.phase, 'failed'); + assert.equal(snapshot.steps.find((item) => item.key === 'drain-previous-slot')?.status, 'failed'); + assert.match(String(snapshot.lastError ?? ''), /lock 파일이 없어서/); + } finally { + env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot; + env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot; + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test('readWorkServerDeploymentState keeps running deployment when the lock is active', async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ai-code-work-server-deployment-lock-')); + const originalMainRoot = env.SERVER_COMMAND_MAIN_PROJECT_ROOT; + const originalProjectRoot = env.SERVER_COMMAND_PROJECT_ROOT; + + try { + env.SERVER_COMMAND_MAIN_PROJECT_ROOT = tempRoot; + env.SERVER_COMMAND_PROJECT_ROOT = tempRoot; + await writeFile(path.join(tempRoot, 'AGENTS.md'), '# temp root\n', 'utf8'); + await writeFile(path.join(tempRoot, 'package.json'), '{"name":"temp-root"}\n', 'utf8'); + + const runtimeDir = path.join(tempRoot, 'etc', 'servers', 'work-server', '.docker', 'runtime'); + await mkdir(runtimeDir, { recursive: true }); + await writeFile( + path.join(runtimeDir, 'deployment-state.json'), + JSON.stringify({ + status: 'running', + phase: 'drain-previous-slot', + summary: '이전 슬롯 요청을 새 슬롯으로 이관하는 중입니다.', + startedAt: '2026-05-28T01:45:37.000Z', + updatedAt: '2026-05-28T01:54:20.000Z', + steps: [ + { key: 'build-target-slot', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:37.000Z' }, + { key: 'verify-target-health', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:53.000Z' }, + { key: 'switch-proxy', status: 'completed', detail: null, updatedAt: '2026-05-28T01:45:57.000Z' }, + { key: 'drain-previous-slot', status: 'running', detail: 'active 2 · queued 0', updatedAt: '2026-05-28T01:54:20.000Z' }, + { key: 'rebuild-previous-slot', status: 'pending', detail: null, updatedAt: null }, + { key: 'recover-interrupted-chat', status: 'pending', detail: null, updatedAt: null }, + ], + }) + '\n', + 'utf8', + ); + await writeFile( + path.join(runtimeDir, 'restart-in-progress.json'), + JSON.stringify({ startedAt: new Date().toISOString(), key: 'work-server', pid: 1234 }) + '\n', + 'utf8', + ); + + const snapshot = await readWorkServerDeploymentState(); + + assert.ok(snapshot); + assert.equal(snapshot.status, 'running'); + assert.equal(snapshot.phase, 'drain-previous-slot'); + assert.equal(snapshot.steps.find((item) => item.key === 'drain-previous-slot')?.status, 'running'); + } finally { + env.SERVER_COMMAND_MAIN_PROJECT_ROOT = originalMainRoot; + env.SERVER_COMMAND_PROJECT_ROOT = originalProjectRoot; + await rm(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/etc/servers/work-server/src/services/server-command-service.ts b/etc/servers/work-server/src/services/server-command-service.ts index adde632..dd95ad8 100644 --- a/etc/servers/work-server/src/services/server-command-service.ts +++ b/etc/servers/work-server/src/services/server-command-service.ts @@ -1,7 +1,7 @@ import { execFile, spawn } from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; -import { mkdir, open, readFile, rm, stat } from 'node:fs/promises'; +import { mkdir, open, readFile, rm, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; import { env } from '../config/env.js'; @@ -1031,10 +1031,97 @@ function normalizeWorkServerDeploymentSnapshot(value: unknown): WorkServerDeploy }; } +function isWorkServerDeploymentCompleted(snapshot: WorkServerDeploymentSnapshot) { + return snapshot.steps.every((step) => step.status === 'completed'); +} + +function reconcileStaleWorkServerDeploymentState(snapshot: WorkServerDeploymentSnapshot): WorkServerDeploymentSnapshot { + if (snapshot.status !== 'running') { + return snapshot; + } + + const now = new Date().toISOString(); + + if (isWorkServerDeploymentCompleted(snapshot)) { + return { + ...snapshot, + status: 'completed', + phase: 'completed', + summary: snapshot.summary || 'WORK-SERVER 무중단 배포를 완료했습니다.', + updatedAt: now, + completedAt: snapshot.completedAt ?? now, + lastError: null, + }; + } + + return { + ...snapshot, + status: 'failed', + phase: 'failed', + summary: 'WORK-SERVER 배포 상태가 중간 단계에서 종료되었습니다.', + updatedAt: now, + completedAt: snapshot.completedAt ?? now, + lastError: + snapshot.lastError + || '배포 lock 파일이 없어서 진행 중 상태를 종료된 상태로 보정했습니다.', + steps: snapshot.steps.map((step) => ( + step.status === 'running' + ? { + ...step, + status: 'failed', + updatedAt: now, + } + : step + )), + }; +} + +async function hasActiveWorkServerRestartLock() { + const lockPath = getWorkServerRestartLockPath(); + + try { + const [raw, lockStat] = await Promise.all([ + readFile(lockPath, 'utf8').catch(() => ''), + stat(lockPath), + ]); + const parsed = raw ? (JSON.parse(raw) as Partial) : null; + const freshnessSource = + normalizeDateTimeValue(typeof parsed?.startedAt === 'string' ? parsed.startedAt : null) + ?? normalizeDateTimeValue(lockStat.mtime.toISOString()); + + if (!freshnessSource || Date.now() - Date.parse(freshnessSource) > WORK_SERVER_RESTART_LOCK_STALE_MS) { + await rm(lockPath, { force: true }).catch(() => undefined); + return false; + } + + return true; + } catch { + return false; + } +} + +async function persistWorkServerDeploymentState(snapshot: WorkServerDeploymentSnapshot) { + const targetPath = getWorkServerDeploymentStatePath(); + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, JSON.stringify(snapshot) + '\n', 'utf8'); +} + export async function readWorkServerDeploymentState(): Promise { try { const raw = await readFile(getWorkServerDeploymentStatePath(), 'utf8'); - return normalizeWorkServerDeploymentSnapshot(JSON.parse(raw)); + const snapshot = normalizeWorkServerDeploymentSnapshot(JSON.parse(raw)); + + if (snapshot.status !== 'running' || await hasActiveWorkServerRestartLock()) { + return snapshot; + } + + const reconciled = reconcileStaleWorkServerDeploymentState(snapshot); + + if (JSON.stringify(reconciled) !== JSON.stringify(snapshot)) { + await persistWorkServerDeploymentState(reconciled).catch(() => undefined); + } + + return reconciled; } catch { return null; } diff --git a/etc/servers/work-server/src/services/server-restart-reservation-service.ts b/etc/servers/work-server/src/services/server-restart-reservation-service.ts index 89dbac0..1eae293 100644 --- a/etc/servers/work-server/src/services/server-restart-reservation-service.ts +++ b/etc/servers/work-server/src/services/server-restart-reservation-service.ts @@ -16,6 +16,7 @@ import { } from './server-command-service.js'; import { ensureVisitorHistoryTables, listVisitorClients } from './visitor-history-service.js'; import { syncMainProjectBranchForReservedRestart } from './git-service.js'; +import { isCurrentWorkServerSlotActive } from './work-server-slot-service.js'; const SERVER_RESTART_RESERVATION_TABLE = 'server_restart_reservations'; const SERVER_RESTART_RESERVATION_ROW_ID = 1; @@ -1353,6 +1354,10 @@ export class ServerRestartReservationWorker { this.running = true; try { + if (!(await isCurrentWorkServerSlotActive())) { + return; + } + const row = await readReservationRow(); if (!row?.enabled) { diff --git a/etc/servers/work-server/src/workers/plan-worker.ts b/etc/servers/work-server/src/workers/plan-worker.ts index dba59b8..e233fdb 100644 --- a/etc/servers/work-server/src/workers/plan-worker.ts +++ b/etc/servers/work-server/src/workers/plan-worker.ts @@ -44,6 +44,7 @@ import { } from '../services/git-service.js'; import { progressBoardPostAutomationByPlanResult } from '../services/board-service.js'; import { registerDuePlanScheduledTasks } from '../services/plan-schedule-service.js'; +import { isCurrentWorkServerSlotActive } from '../services/work-server-slot-service.js'; const STREAM_CAPTURE_LIMIT = 256 * 1024; const FIRST_PROGRESS_NOTIFICATION_MS = 60_000; @@ -857,6 +858,11 @@ export class PlanWorker { this.running = true; try { + if (!(await isCurrentWorkServerSlotActive())) { + this.logger.info({ workerId: this.workerId }, 'Plan worker skipped on inactive work-server slot'); + return; + } + const env = getEnv(); const appConfig = await getAppConfigSnapshot(); const autoRefreshEnabled = appConfig.automation?.autoRefreshEnabled ?? true; diff --git a/src/app/main/mainChatPanel/ChatConversationView.tsx b/src/app/main/mainChatPanel/ChatConversationView.tsx index 9d2dc03..c26f558 100644 --- a/src/app/main/mainChatPanel/ChatConversationView.tsx +++ b/src/app/main/mainChatPanel/ChatConversationView.tsx @@ -66,6 +66,7 @@ import { ChatActivityChecklist, buildChatActivityChecklistEntries } from './Chat import { describeExecutorCommand } from './executorActivitySummary'; import { buildComposerFilePickKey } from './composerFilePickKey'; import { ChatPromptCard, buildPromptTargetSignature, type PromptDraftSelection, type PromptSubmitPayload } from './ChatPromptCard'; +import { ChatStructuredPreviewCard } from './ChatStructuredPreviewCard'; import { openChatExternalLink } from './linkNavigation'; import { classifyPreviewKind } from './previewKind'; import { isPromptResolved } from './promptState'; @@ -495,6 +496,7 @@ type MessageRenderPayload = { diffBlocks: string[]; rankedLinkTargets: RankedLinkPreviewTarget[]; linkCardTargets: Extract[]; + previewCardTargets: Extract[]; promptTargets: Extract[]; }; @@ -1172,6 +1174,17 @@ function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload ...structuredParts.filter((part): part is Extract => part.type === 'link_card'), ...extractedMessageParts.parts.filter((part): part is Extract => part.type === 'link_card'), ].filter((part, index, collection) => collection.findIndex((candidate) => `${candidate.title}:${candidate.url}` === `${part.title}:${part.url}`) === index); + const previewCardTargets = [ + ...structuredParts.filter((part): part is Extract => part.type === 'preview_card'), + ...extractedMessageParts.parts.filter((part): part is Extract => part.type === 'preview_card'), + ].filter( + (part, index, collection) => + collection.findIndex( + (candidate) => + `${candidate.title}:${candidate.preview.type}:${candidate.preview.url ?? ''}:${candidate.preview.content ?? ''}` === + `${part.title}:${part.preview.type}:${part.preview.url ?? ''}:${part.preview.content ?? ''}`, + ) === index, + ); const promptTargets = (() => { const promptParts = [ ...structuredParts.filter((part): part is Extract => part.type === 'prompt'), @@ -1219,6 +1232,7 @@ function extractMessageRenderPayload(message: ChatMessage): MessageRenderPayload diffBlocks, rankedLinkTargets, linkCardTargets, + previewCardTargets, promptTargets, }; } @@ -6098,7 +6112,7 @@ export function ChatConversationView({ const baseMessageBodyClassName = `app-chat-message__body${shouldTruncateMessage ? ' app-chat-message__body--collapsed' : ''}${ isRecoveredMissingRequest || isRecoveredExecutionFailure ? ' app-chat-message__body--system-status' : '' }`; - const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets, promptTargets } = + const { previewSourceText, visibleText, diffBlocks, rankedLinkTargets, linkCardTargets, previewCardTargets, promptTargets } = messageRenderPayloadById.get(message.id) ?? extractMessageRenderPayload(message); const renderedText = isRecoveredMissingRequest ? getMissingRequestMessageText(message) @@ -6122,6 +6136,7 @@ export function ChatConversationView({ inlinePreviewTargets.length > 0 || rankedLinkTargets.length > 0 || linkCardTargets.length > 0 || + previewCardTargets.length > 0 || promptTargets.length > 0; const shouldRenderStandalonePreview = hasPreviewCards && !visibleText && (message.author === 'codex' || message.author === 'system'); @@ -6540,6 +6555,19 @@ export function ChatConversationView({ }} /> ))} + {previewCardTargets.map((target, index) => ( + { + markPreviewArtifactOpened(message.clientRequestId ?? null); + + if (previewUrl) { + markPreviewResourceOpened(previewUrl); + } + }} + /> + ))} {promptTargets.map((target, index) => ( (() => { const selectionKey = buildPendingPromptSelectionKey(message.id, index, target.title, target); diff --git a/src/app/main/mainChatPanel/ChatPromptCard.tsx b/src/app/main/mainChatPanel/ChatPromptCard.tsx index 95ed071..e3e9c2b 100644 --- a/src/app/main/mainChatPanel/ChatPromptCard.tsx +++ b/src/app/main/mainChatPanel/ChatPromptCard.tsx @@ -728,7 +728,7 @@ function usePromptPreviewContent(preview: PromptPreview | null | undefined) { return { remoteContent, remoteContentType, isLoading, loadError }; } -function PromptPreviewSurface({ +export function PromptPreviewSurface({ preview, compact = false, htmlMode = 'preview', @@ -872,6 +872,16 @@ function PromptPreviewSurface({ return
표시할 preview가 없습니다.
; } +function PromptPreviewViewport({ children }: { children: ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + function PromptPreviewCard({ option, onOpenPreview, @@ -975,7 +985,11 @@ function PromptPreviewCard({ /> )} - +
+ + + +
); } diff --git a/src/app/main/mainChatPanel/ChatStructuredPreviewCard.tsx b/src/app/main/mainChatPanel/ChatStructuredPreviewCard.tsx new file mode 100644 index 0000000..65807f8 --- /dev/null +++ b/src/app/main/mainChatPanel/ChatStructuredPreviewCard.tsx @@ -0,0 +1,111 @@ +import { ExportOutlined, LinkOutlined, ShareAltOutlined } from '@ant-design/icons'; +import { App, Button, Typography } from 'antd'; +import { PromptPreviewSurface } from './ChatPromptCard'; +import { sharePreviewLink } from './chatUtils'; +import { openChatExternalLink } from './linkNavigation'; +import type { ChatMessagePart } from './types'; + +const { Paragraph } = Typography; + +function resolveStructuredPreviewKindLabel(target: Extract) { + const explicit = target.kindLabel?.trim(); + + if (explicit) { + return explicit; + } + + switch (target.preview.type) { + case 'markdown': + return 'markdown card'; + case 'html': + return 'html card'; + case 'image': + return 'image card'; + default: + return 'resource card'; + } +} + +export function ChatStructuredPreviewCard({ + target, + onOpen, +}: { + target: Extract; + onOpen?: (previewUrl?: string | null) => void; +}) { + const { message } = App.useApp(); + const normalizedPreviewUrl = target.preview.url?.trim() || null; + + const handleShare = () => { + if (!normalizedPreviewUrl) { + message.info('이 카드는 공유할 링크가 없습니다.'); + return; + } + + onOpen?.(normalizedPreviewUrl); + void sharePreviewLink({ + url: normalizedPreviewUrl, + title: target.title, + }) + .then((result) => { + message.success(result === 'shared' ? '카드 링크를 공유했습니다.' : '카드 링크를 복사했습니다.'); + }) + .catch((error: unknown) => { + if (error instanceof DOMException && error.name === 'AbortError') { + return; + } + + message.error(error instanceof Error ? error.message : '카드 링크를 공유하지 못했습니다.'); + }); + }; + + return ( +
+
+
+ +
+ {target.title} + {resolveStructuredPreviewKindLabel(target)} +
+
+
+ {normalizedPreviewUrl ? ( + <> + + + ) : null} +
+
+
+ {target.description ? {target.description} : null} +
+
+ +
+
+
+
+ ); +} diff --git a/src/app/main/mainChatPanel/chatUtils.ts b/src/app/main/mainChatPanel/chatUtils.ts index 3c44bdb..b21b992 100644 --- a/src/app/main/mainChatPanel/chatUtils.ts +++ b/src/app/main/mainChatPanel/chatUtils.ts @@ -14,6 +14,7 @@ import type { ChatPromptContextRef, ChatConversationRequest, ChatConversationSummary, + ChatShareRoomLinkContext, ChatSourceChangeSnapshot, ChatSourceChangeSnapshotListResponse, ChatJobEvent, @@ -35,7 +36,7 @@ const CHAT_INTRO_MESSAGE = const CHAT_ACTIVITY_MESSAGE_PREFIX = '[[activity-log]]'; const CHAT_MISSING_REQUEST_MESSAGE_PREFIX = '[[missing-request]]'; const CHAT_EXECUTION_FAILURE_MESSAGE_PREFIX = '[[execution-failure]]'; -const CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT = 10 * 1024 * 1024; +const CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT = 300 * 1024 * 1024; const KST_TIME_ZONE = 'Asia/Seoul'; const chatSessionLastTypeMemory = new Map(); const chatLastEventIdMemory = new Map(); @@ -1691,8 +1692,20 @@ async function requestChatApi( window.clearTimeout(timeoutId); if (!response.ok) { + const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; const text = await response.text(); + if (response.status === 413) { + throw new ChatApiError( + '첨부 업로드 크기가 현재 허용 한도를 초과했습니다. 300MB 이하 파일로 다시 시도해 주세요.', + response.status, + ); + } + + if (contentType.includes('text/html') && text.trim().startsWith('<')) { + throw new ChatApiError('채팅 API가 HTML 오류 페이지를 반환했습니다. 프록시 업로드 한도를 확인해 주세요.', response.status); + } + if (text.trim()) { try { const payload = JSON.parse(text) as { message?: string; code?: string }; @@ -1728,26 +1741,8 @@ async function requestChatApi( } } -async function readFileAsBase64(file: File) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - - reader.onload = () => { - if (typeof reader.result !== 'string') { - reject(new Error('파일 내용을 읽지 못했습니다.')); - return; - } - - const commaIndex = reader.result.indexOf(','); - resolve(commaIndex >= 0 ? reader.result.slice(commaIndex + 1) : reader.result); - }; - - reader.onerror = () => { - reject(reader.error ?? new Error('파일 내용을 읽지 못했습니다.')); - }; - - reader.readAsDataURL(file); - }); +function encodeChatAttachmentHeaderValue(value: string) { + return encodeURIComponent(value); } const FALLBACK_UPLOAD_MIME_BY_EXTENSION: Record = { @@ -2036,6 +2031,38 @@ function normalizeChatSourceChangeSnapshot(item: ChatSourceChangeSnapshot): Chat }; } +async function uploadChatAttachmentBinary( + path: string, + file: File, + args: { + sessionId: string; + fileName: string; + mimeType: string; + allowUnauthenticated?: boolean; + sharePin?: string | null; + }, +) { + const response = await requestChatApi<{ ok: boolean; item: ChatComposerAttachment }>( + path, + { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + 'X-Chat-Attachment-Session-Id': encodeChatAttachmentHeaderValue(args.sessionId), + 'X-Chat-Attachment-File-Name': encodeChatAttachmentHeaderValue(args.fileName), + 'X-Chat-Attachment-Mime-Type': encodeChatAttachmentHeaderValue(args.mimeType), + }, + body: file, + }, + { + allowUnauthenticated: args.allowUnauthenticated, + sharePin: args.sharePin, + }, + ); + + return response.item; +} + export async function uploadChatComposerFile(sessionId: string, file: File) { const normalizedSessionId = sessionId.trim(); const resolvedMimeType = resolveUploadMimeType(file); @@ -2071,35 +2098,17 @@ export async function uploadChatComposerFile(sessionId: string, file: File) { } if (file.size > CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT) { - const uploadError = new Error(`첨부 파일은 10MB 이하만 업로드할 수 있습니다. (${resolvedFileName})`); + const uploadError = new Error(`첨부 파일은 300MB 이하만 업로드할 수 있습니다. (${resolvedFileName})`); await reportUploadFailure('validate-file', uploadError); throw uploadError; } - let contentBase64 = ''; - try { - contentBase64 = await readFileAsBase64(file); - } catch (error) { - const message = error instanceof Error && error.message.trim() ? error.message.trim() : '파일 내용을 읽지 못했습니다.'; - const uploadError = new Error(`${message} (${resolvedFileName})`); - uploadError.name = error instanceof Error && error.name ? error.name : 'FileReadError'; - await reportUploadFailure('read-file', uploadError); - throw uploadError; - } - - try { - const response = await requestChatApi<{ ok: boolean; item: ChatComposerAttachment }>('/attachments', { - method: 'POST', - body: JSON.stringify({ - sessionId: normalizedSessionId, - fileName: resolvedFileName, - mimeType: resolvedMimeType, - contentBase64, - }), + return await uploadChatAttachmentBinary('/attachments', file, { + sessionId: normalizedSessionId, + fileName: resolvedFileName, + mimeType: resolvedMimeType, }); - - return response.item; } catch (error) { const uploadError = error instanceof Error && error.message.trim() @@ -2153,41 +2162,22 @@ export async function uploadChatShareComposerFile(token: string, sessionId: stri } if (file.size > CHAT_COMPOSER_UPLOAD_FILE_SIZE_LIMIT) { - const uploadError = new Error(`첨부 파일은 10MB 이하만 업로드할 수 있습니다. (${resolvedFileName})`); + const uploadError = new Error(`첨부 파일은 300MB 이하만 업로드할 수 있습니다. (${resolvedFileName})`); await reportUploadFailure('validate-file', uploadError); throw uploadError; } - let contentBase64 = ''; - try { - contentBase64 = await readFileAsBase64(file); - } catch (error) { - const message = error instanceof Error && error.message.trim() ? error.message.trim() : '파일 내용을 읽지 못했습니다.'; - const uploadError = new Error(`${message} (${resolvedFileName})`); - uploadError.name = error instanceof Error && error.name ? error.name : 'FileReadError'; - await reportUploadFailure('read-file', uploadError); - throw uploadError; - } - - try { - const response = await requestChatApi<{ ok: boolean; item: ChatComposerAttachment }>( + return await uploadChatAttachmentBinary( `/shares/${encodeURIComponent(normalizedToken)}/attachments`, + file, { - method: 'POST', - body: JSON.stringify({ - sessionId: normalizedSessionId, - fileName: resolvedFileName, - mimeType: resolvedMimeType, - contentBase64, - }), - }, - { + sessionId: normalizedSessionId, + fileName: resolvedFileName, + mimeType: resolvedMimeType, allowUnauthenticated: true, }, ); - - return response.item; } catch (error) { const uploadError = error instanceof Error && error.message.trim() @@ -2341,6 +2331,11 @@ export async function createChatShareRoom( title: string; requestBadgeLabel?: string | null; seedMessage: string; + linkedSessionId?: string | null; + linkedRequestId?: string | null; + linkedTitle?: string | null; + linkedRequestPreview?: string | null; + linkedChatTypeLabel?: string | null; }, ) { const response = await requestChatApi<{ ok: boolean; room: ChatShareRoomSummary }>( @@ -2366,6 +2361,18 @@ export async function createChatShareRoom( contextLabel: normalizeOptionalText(response.room.contextLabel), contextDescription: normalizeOptionalText(response.room.contextDescription), notifyOffline: response.room.notifyOffline === true, + linkContext: + response.room.linkContext?.kind === 'linked-session' + ? { + kind: 'linked-session', + sourceSessionId: normalizeRequiredText(response.room.linkContext.sourceSessionId), + sourceRequestId: normalizeRequiredText(response.room.linkContext.sourceRequestId), + sourceTitle: normalizeOptionalText(response.room.linkContext.sourceTitle), + sourceRequestPreview: normalizeOptionalText(response.room.linkContext.sourceRequestPreview), + sourceChatTypeLabel: normalizeOptionalText(response.room.linkContext.sourceChatTypeLabel), + linkedAt: normalizeOptionalText(response.room.linkContext.linkedAt), + } + : null, createdAt: normalizeOptionalText(response.room.createdAt), updatedAt: normalizeOptionalText(response.room.updatedAt), } satisfies ChatShareRoomSummary; @@ -2516,11 +2523,13 @@ export type ChatShareRoomSummary = { contextLabel?: string | null; contextDescription?: string | null; notifyOffline?: boolean; + linkContext?: ChatShareRoomLinkContext | null; createdAt?: string | null; updatedAt?: string | null; }; export type ChatShareSnapshot = { + detailLevel?: 'full' | 'initial'; share: { kind: ChatShareKind; sessionId: string; @@ -2759,9 +2768,27 @@ export async function saveChatShareRoomSettings( }; } -export async function fetchChatShareSnapshot(token: string, options?: { sharePin?: string | null; sessionId?: string | null }) { +export async function fetchChatShareSnapshot( + token: string, + options?: { + sharePin?: string | null; + sessionId?: string | null; + view?: 'full' | 'initial'; + }, +) { + const query = new URLSearchParams(); + + if (options?.sessionId?.trim()) { + query.set('sessionId', options.sessionId.trim()); + } + + if (options?.view === 'initial') { + query.set('view', 'initial'); + } + const response = await requestChatApi<{ ok: boolean; + detailLevel?: ChatShareSnapshot['detailLevel']; share: ChatShareSnapshot['share']; conversation: ChatShareSnapshot['conversation']; rootRequestId: string; @@ -2775,7 +2802,7 @@ export async function fetchChatShareSnapshot(token: string, options?: { sharePin promptTarget?: ChatShareSnapshot['promptTarget']; refreshedAt: string; }>( - `/shares/${encodeURIComponent(token)}${options?.sessionId?.trim() ? `?sessionId=${encodeURIComponent(options.sessionId.trim())}` : ''}`, + `/shares/${encodeURIComponent(token)}${query.size > 0 ? `?${query.toString()}` : ''}`, undefined, { allowUnauthenticated: true, @@ -2785,6 +2812,7 @@ export async function fetchChatShareSnapshot(token: string, options?: { sharePin ); return { + detailLevel: response.detailLevel === 'initial' ? 'initial' : 'full', share: { ...response.share, createdAt: normalizeOptionalText(response.share?.createdAt), @@ -2855,6 +2883,18 @@ export async function fetchChatShareSnapshot(token: string, options?: { sharePin contextLabel: normalizeOptionalText(item.contextLabel), contextDescription: normalizeOptionalText(item.contextDescription), notifyOffline: item.notifyOffline === true, + linkContext: + item.linkContext?.kind === 'linked-session' + ? { + kind: 'linked-session', + sourceSessionId: normalizeRequiredText(item.linkContext.sourceSessionId), + sourceRequestId: normalizeRequiredText(item.linkContext.sourceRequestId), + sourceTitle: normalizeOptionalText(item.linkContext.sourceTitle), + sourceRequestPreview: normalizeOptionalText(item.linkContext.sourceRequestPreview), + sourceChatTypeLabel: normalizeOptionalText(item.linkContext.sourceChatTypeLabel), + linkedAt: normalizeOptionalText(item.linkContext.linkedAt), + } + : null, createdAt: normalizeOptionalText(item.createdAt), updatedAt: normalizeOptionalText(item.updatedAt), })) @@ -2897,6 +2937,34 @@ export async function submitChatShareMessage( ); } +export async function submitChatShareOriginReply( + token: string, + payload: { + sessionId?: string | null; + sourceSessionId: string; + sourceRequestId: string; + text: string; + mode?: 'queue' | 'direct'; + }, +) { + return requestChatApi<{ ok: boolean; queuedRequestId: string }>( + `/shares/${encodeURIComponent(token)}/origin-reply`, + { + method: 'POST', + body: JSON.stringify({ + sessionId: payload.sessionId?.trim() || undefined, + sourceSessionId: payload.sourceSessionId.trim(), + sourceRequestId: payload.sourceRequestId.trim(), + text: payload.text, + mode: payload.mode === 'direct' ? 'direct' : 'queue', + }), + }, + { + allowUnauthenticated: true, + }, + ); +} + export async function submitChatSharePrompt( token: string, payload: { diff --git a/src/app/main/mainChatPanel/messageParts.ts b/src/app/main/mainChatPanel/messageParts.ts index b75c5d0..98bc466 100644 --- a/src/app/main/mainChatPanel/messageParts.ts +++ b/src/app/main/mainChatPanel/messageParts.ts @@ -1,9 +1,11 @@ import type { ChatMessagePart } from './types'; const LINK_CARD_LINE_PATTERN = /^\s*\[\[link-card:(.+?)\]\]\s*$/i; +const CARD_LINE_PATTERN = /^\s*\[\[card:(.+?)\]\]\s*$/i; const PROMPT_LINE_PATTERN = /^\s*\[\[prompt:(.+?)\]\]\s*$/i; const STANDALONE_MARKDOWN_LINK_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?\[([^\]]+)\]\((.+)\)\s*$/; const STANDALONE_URL_LINE_PATTERN = /^\s*(?:[-*+]\s+|\d+\.\s+)?((?:https?:\/\/|\/)[^\s<>)\]]+)\s*$/i; +const CARD_BLOCK_START_PATTERN = /^\s*\[\[card:\s*$/i; const PROMPT_BLOCK_START_PATTERN = /^\s*\[\[prompt:\s*$/i; const PROMPT_BLOCK_END_PATTERN = /^\s*\]\]\s*$/; const PROMPT_CODE_BLOCK_START_PATTERN = /^\s*```(?:json|prompt)(?:\s+prompt)?\s*$/i; @@ -21,6 +23,7 @@ type PromptPart = Extract; type PromptOption = PromptPart['options'][number]; type PromptPreview = NonNullable; type PromptStep = NonNullable[number]; +type PreviewCardPart = Extract; function normalizeText(value: unknown) { return String(value ?? '').trim(); @@ -313,6 +316,51 @@ function normalizePromptPreview(value: unknown): PromptPreview | null { }; } +function buildPreviewCardPart(rawBody: string): PreviewCardPart | null { + const trimmed = rawBody.trim(); + + if (!trimmed) { + return null; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + + const record = parsed as Record; + const title = normalizeText(record.title) || normalizeText(record.label); + const preview = + normalizePromptPreview(record.preview) || + normalizePromptPreview({ + type: record.type, + url: record.url, + content: record.content, + alt: record.alt, + title: record.previewTitle ?? record.title, + }); + + if (!title || !preview) { + return null; + } + + return { + type: 'preview_card', + title, + description: normalizeText(record.description) || null, + kindLabel: normalizeText(record.kindLabel) || null, + actionLabel: normalizeText(record.actionLabel) || null, + preview, + }; +} + function isPromptOption(value: PromptOption | null): value is PromptOption { return value != null; } @@ -647,7 +695,20 @@ export function extractChatMessageParts(text: string) { const dedupeKey = nextPart.type === 'link_card' ? `${nextPart.type}:${nextPart.title}:${nextPart.url}:${nextPart.actionLabel ?? ''}` - : [ + : nextPart.type === 'preview_card' + ? [ + nextPart.type, + nextPart.title, + nextPart.description ?? '', + nextPart.kindLabel ?? '', + nextPart.actionLabel ?? '', + nextPart.preview.type, + nextPart.preview.url ?? '', + nextPart.preview.content ?? '', + nextPart.preview.alt ?? '', + nextPart.preview.title ?? '', + ].join(':') + : [ nextPart.type, nextPart.title, nextPart.options @@ -728,6 +789,43 @@ export function extractChatMessageParts(text: string) { continue; } + const cardMatched = line.match(CARD_LINE_PATTERN); + + if (cardMatched) { + if (!pushPart(buildPreviewCardPart(cardMatched[1] ?? ''))) { + keptLines.push(line); + } + continue; + } + + if (CARD_BLOCK_START_PATTERN.test(line)) { + const wrappedLines = [line]; + const cardBodyLines: string[] = []; + let cursor = lineIndex + 1; + let foundBlockEnd = false; + + for (; cursor < lines.length; cursor += 1) { + const nextLine = lines[cursor] ?? ''; + wrappedLines.push(nextLine); + + if (PROMPT_BLOCK_END_PATTERN.test(nextLine)) { + foundBlockEnd = true; + break; + } + + cardBodyLines.push(nextLine); + } + + if (foundBlockEnd && pushPart(buildPreviewCardPart(cardBodyLines.join('\n')))) { + lineIndex = cursor; + continue; + } + + keptLines.push(...wrappedLines); + lineIndex = foundBlockEnd ? cursor : lines.length; + continue; + } + const promptMatched = line.match(PROMPT_LINE_PATTERN); if (promptMatched) { diff --git a/src/app/main/mainChatPanel/styles/MainChatPanel.conversation.css b/src/app/main/mainChatPanel/styles/MainChatPanel.conversation.css index 8b7c726..241a320 100644 --- a/src/app/main/mainChatPanel/styles/MainChatPanel.conversation.css +++ b/src/app/main/mainChatPanel/styles/MainChatPanel.conversation.css @@ -1861,16 +1861,50 @@ flex-direction: column; gap: 6px; padding: 0 8px 8px; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: auto; + overscroll-behavior: contain; } .app-chat-preview-card__body--prompt-collapsed { display: none; } +.app-chat-preview-card--prompt, +.app-chat-preview-card--structured { + width: min(100%, 720px); + min-width: 0; + max-width: 100%; +} + +.app-chat-preview-card__body--structured { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 8px 8px; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: auto; + overscroll-behavior: contain; +} + +.app-chat-preview-card__description.ant-typography { + margin: 0; + color: #334155; + white-space: pre-wrap; + word-break: break-word; +} + .app-chat-prompt-card__description.ant-typography { margin: 0; font-size: 12px; color: #334155; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; } .app-chat-prompt-card__context { @@ -2054,6 +2088,8 @@ .app-chat-prompt-card__option-preview-inline { margin-top: 6px; + min-width: 0; + max-width: 100%; } @media (max-width: 640px) { @@ -2069,10 +2105,41 @@ .app-chat-prompt-card__preview-shell { display: flex; flex-direction: column; + width: 100%; + min-width: 0; + max-width: 100%; overflow: hidden; border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 10px; background: rgba(241, 245, 249, 0.8); + box-sizing: border-box; +} + +.app-chat-prompt-card__preview-body { + width: 100%; + min-width: 0; + max-width: 100%; + max-height: min(420px, 70vh); + overflow: hidden; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +.app-chat-prompt-card__preview-scroll { + width: 100%; + min-width: 0; + max-width: 100%; + max-height: inherit; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; +} + +.app-chat-prompt-card__preview-scroll-inner { + width: 100%; + min-width: 0; + max-width: 100%; } .app-chat-prompt-card__stepper { @@ -2328,6 +2395,18 @@ word-break: break-word; } +.app-chat-prompt-card__context, +.app-chat-prompt-card__description.ant-typography, +.app-chat-prompt-card__result.ant-typography, +.app-chat-prompt-card__stepper, +.app-chat-prompt-card__step-panel, +.app-chat-prompt-card__options, +.app-chat-prompt-card__free-text, +.app-chat-prompt-card__footer { + min-width: 0; + max-width: 100%; +} + .app-chat-prompt-card__submitted { display: inline-flex; align-items: center; diff --git a/src/app/main/mainChatPanel/types.ts b/src/app/main/mainChatPanel/types.ts index b048e28..159804c 100644 --- a/src/app/main/mainChatPanel/types.ts +++ b/src/app/main/mainChatPanel/types.ts @@ -9,6 +9,14 @@ export type ChatComposerAttachment = { mimeType: string; }; +export type ChatStructuredPreview = { + type: 'image' | 'markdown' | 'html' | 'resource'; + url?: string | null; + content?: string | null; + alt?: string | null; + title?: string | null; +}; + export type ChatPromptContextRef = { key: 'prompt_parent_question'; promptTitle: string; @@ -23,6 +31,14 @@ export type ChatMessagePart = url: string; actionLabel?: string | null; } + | { + type: 'preview_card'; + title: string; + description?: string | null; + kindLabel?: string | null; + actionLabel?: string | null; + preview: ChatStructuredPreview; + } | { type: 'prompt'; title: string; @@ -50,15 +66,7 @@ export type ChatMessagePart = value: string; label: string; description?: string | null; - preview?: - | { - type: 'image' | 'markdown' | 'html' | 'resource'; - url?: string | null; - content?: string | null; - alt?: string | null; - title?: string | null; - } - | null; + preview?: ChatStructuredPreview | null; }>; }>; readOnly?: boolean; @@ -71,15 +79,7 @@ export type ChatMessagePart = value: string; label: string; description?: string | null; - preview?: - | { - type: 'image' | 'markdown' | 'html' | 'resource'; - url?: string | null; - content?: string | null; - alt?: string | null; - title?: string | null; - } - | null; + preview?: ChatStructuredPreview | null; }>; }; @@ -163,6 +163,16 @@ export type ChatConversationSummary = { lastMessageAt: string | null; }; +export type ChatShareRoomLinkContext = { + kind: 'linked-session'; + sourceSessionId: string; + sourceRequestId: string; + sourceTitle: string | null; + sourceRequestPreview: string | null; + sourceChatTypeLabel: string | null; + linkedAt: string | null; +}; + export type ChatConversationRequestStatus = | 'accepted' | 'queued' diff --git a/src/app/main/pages/ChatSharePage.css b/src/app/main/pages/ChatSharePage.css index 3d14984..e95c8a2 100644 --- a/src/app/main/pages/ChatSharePage.css +++ b/src/app/main/pages/ChatSharePage.css @@ -178,6 +178,7 @@ .chat-share-page__conversation-panel { flex: 1 1 auto; + position: relative; display: flex; flex-direction: column; padding: 8px 10px 10px; @@ -192,6 +193,7 @@ .chat-share-page__composer-panel { display: flex; + position: relative; flex-direction: column; flex: 0 0 auto; min-height: var(--chat-share-page-composer-panel-min-height); @@ -204,6 +206,7 @@ } .chat-share-page__activity-panel { + position: relative; padding: 8px 10px; border-radius: 14px; background: rgba(248, 250, 252, 0.94); @@ -219,6 +222,16 @@ box-shadow: inset 0 0 0 1px rgba(219, 226, 236, 0.82); } +.chat-share-page__room-list-panel--floating { + position: fixed; + z-index: 1300; + overflow: hidden; + box-shadow: + 0 18px 42px rgba(15, 23, 42, 0.18), + inset 0 0 0 1px rgba(219, 226, 236, 0.9); + backdrop-filter: blur(18px); +} + .chat-share-page__room-filter-input.ant-input-affix-wrapper { width: 100%; border-radius: 12px; @@ -233,6 +246,111 @@ gap: 8px; width: 100%; min-width: 0; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +.chat-share-page__room-group { + display: grid; + gap: 8px; +} + +.chat-share-page__room-group--standalone { + gap: 0; +} + +.chat-share-page__room-group-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 10px 12px; + border-radius: 14px; + background: rgba(255, 255, 255, 0.82); + box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.9); +} + +.chat-share-page__room-group-copy { + display: grid; + gap: 3px; + min-width: 0; +} + +.chat-share-page__room-group-title { + color: #0f172a; + font-size: 13px; + font-weight: 700; + line-height: 1.35; +} + +.chat-share-page__room-group-meta { + color: #475569; + font-size: 12px; + line-height: 1.4; +} + +.chat-share-page__room-group-preview { + color: #64748b; + font-size: 12px; + line-height: 1.45; + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.chat-share-page__room-group-actions { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.chat-share-page__room-group-action.ant-btn { + padding-inline: 6px; +} + +.chat-share-page__room-group-list { + display: grid; + gap: 8px; +} + +.chat-share-page__room-card-counts { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.chat-share-page__room-card-count { + display: inline-flex; + align-items: center; + min-width: 0; + padding: 3px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + line-height: 1.3; + box-sizing: border-box; +} + +.chat-share-page__room-card-count--processing { + color: #0958d9; + background: rgba(230, 244, 255, 0.96); + box-shadow: inset 0 0 0 1px rgba(145, 213, 255, 0.95); +} + +.chat-share-page__room-card-count--unanswered { + color: #ad6800; + background: rgba(255, 247, 230, 0.96); + box-shadow: inset 0 0 0 1px rgba(255, 214, 102, 0.95); +} + +.chat-share-page__room-card-count--idle { + color: #64748b; + background: rgba(241, 245, 249, 0.96); + box-shadow: inset 0 0 0 1px rgba(203, 213, 225, 0.92); } .chat-share-page__room-item { @@ -342,6 +460,16 @@ line-height: 1.4; } +.chat-share-page__room-card-preview { + color: #475569; + font-size: 12px; + line-height: 1.45; + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + .chat-share-page__room-list-empty { display: flex; align-items: center; @@ -363,6 +491,35 @@ gap: 6px; } +.chat-share-page__create-room-hint { + font-size: 12px; + line-height: 1.45; +} + +.chat-share-page__source-detail-card { + display: grid; + gap: 8px; + padding: 14px; + border-radius: 14px; + background: rgba(248, 250, 252, 0.95); + box-shadow: inset 0 0 0 1px rgba(226, 232, 240, 0.9); +} + +.chat-share-page__source-detail-preview { + margin-bottom: 0; + white-space: pre-wrap; +} + +.chat-share-page__source-room-chip-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chat-share-page__source-room-chip.ant-btn { + border-radius: 999px; +} + .chat-share-page__message-list { display: flex; flex: 1 1 auto; @@ -375,6 +532,46 @@ padding-right: 0; } +.chat-share-page__message-list--switching, +.chat-share-page__panel--switching, +.chat-share-page__composer-shell--switching { + opacity: 0.68; +} + +.chat-share-page__composer-shell--switching { + pointer-events: none; +} + +.chat-share-page__panel-switching-indicator { + position: sticky; + top: 0; + z-index: 3; + display: inline-flex; + align-items: center; + gap: 8px; + align-self: flex-start; + margin: 0 0 10px; + padding: 6px 10px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + box-shadow: + inset 0 0 0 1px rgba(191, 204, 220, 0.92), + 0 8px 20px rgba(148, 163, 184, 0.16); + backdrop-filter: blur(10px); +} + +.chat-share-page__panel-switching-indicator--compact { + margin-bottom: 8px; +} + +.chat-share-page__panel-switching-indicator--composer { + position: absolute; + top: 10px; + left: 10px; + right: 10px; + margin: 0; +} + .chat-share-page__conversation-loading-block { display: grid; flex: 1 1 auto; @@ -1839,6 +2036,55 @@ background: #020617; } +.chat-share-page__server-command-drawer-shell .ant-drawer-content, +.chat-share-page__server-command-drawer-shell .ant-drawer-wrapper-body { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: + linear-gradient(180deg, #f7fafc 0%, #eef3f9 100%), + radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 26%); +} + +.chat-share-page__server-command-drawer-shell .ant-drawer-header, +.chat-share-page__server-command-drawer-shell .ant-drawer-header-title, +.chat-share-page__server-command-drawer-shell .ant-drawer-extra { + min-width: 0; +} + +.chat-share-page__server-command-drawer-shell .ant-drawer-header { + border-bottom: 1px solid rgba(196, 210, 226, 0.96); + padding: 14px 18px; + background: rgba(248, 250, 252, 0.94); +} + +.chat-share-page__server-command-drawer-shell .ant-drawer-body { + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + padding: 0; + overflow: hidden; +} + +.chat-share-page__server-command-drawer-body { + display: flex; + flex: 1 1 auto; + width: 100%; + min-width: 0; + min-height: 0; + height: 100%; +} + +.chat-share-page__server-command-drawer-body .server-command-page { + flex: 1 1 auto; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} + .chat-share-page__program-modal-content { display: flex; flex: 1 1 auto; @@ -1933,6 +2179,14 @@ gap: 10px; } + .chat-share-page__room-list-panel--floating { + border-radius: 16px; + } + + .chat-share-page__server-command-drawer-shell .ant-drawer-header { + padding-top: calc(14px + env(safe-area-inset-top, 0px)); + } + .chat-share-page__program-app-shell--system-chat-room { padding: 0; } @@ -2757,12 +3011,20 @@ .chat-share-page .app-chat-prompt-card__body, .chat-share-page .app-chat-prompt-card__content, .chat-share-page .app-chat-prompt-card__options, +.chat-share-page .app-chat-prompt-card__option, +.chat-share-page .app-chat-prompt-card__option-head, +.chat-share-page .app-chat-prompt-card__option-body, +.chat-share-page .app-chat-prompt-card__option-preview-inline, .chat-share-page .app-chat-prompt-card__preview-shell, +.chat-share-page .app-chat-prompt-card__preview-toolbar, +.chat-share-page .app-chat-prompt-card__preview-body, +.chat-share-page .app-chat-prompt-card__preview-image, .chat-share-page .app-chat-prompt-card__preview-frame, .chat-share-page .app-chat-prompt-card__preview-markdown, .chat-share-page .app-chat-prompt-card__summary, .chat-share-page .app-chat-prompt-card__submitted { width: 100%; + inline-size: 100%; max-width: 100%; min-width: 0; box-sizing: border-box; @@ -2793,6 +3055,166 @@ gap: var(--chat-share-page-prompt-footer-gap); } +.chat-share-page .app-chat-prompt-card__preview-body, +.chat-share-page .app-chat-preview-card__body--structured, +.chat-share-page .app-chat-prompt-card__option-preview-inline, +.chat-share-page .app-chat-prompt-card__preview-shell, +.chat-share-page .app-chat-prompt-card__preview-image, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-table, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-table-scroll, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-image, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-video, +.chat-share-page .app-chat-prompt-card__preview-body .codex-diff-previewer, +.chat-share-page .app-chat-prompt-card__preview-body .codex-diff-previewer__diff-body, +.chat-share-page .app-chat-prompt-card__preview-body .codex-diff-previewer__diff-list, +.chat-share-page .app-chat-prompt-card__preview-body .previewer-ui, +.chat-share-page .app-chat-prompt-card__preview-body .previewer-ui__body, +.chat-share-page .app-chat-prompt-card__preview-body .previewer-ui__editor, +.chat-share-page .app-chat-prompt-card__preview-body .previewer-ui__editor-body, +.chat-share-page .app-chat-prompt-card__preview-body > *, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview, +.chat-share-page .app-chat-prompt-card__preview-body .previewer-ui__editor-body > *, +.chat-share-page .app-chat-prompt-card__preview-body .codex-diff-previewer__diff-list > * { + width: 100%; + inline-size: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.chat-share-page .app-chat-prompt-card__preview-body, +.chat-share-page .app-chat-preview-card__body--structured, +.chat-share-page .app-chat-prompt-card__option-preview-inline, +.chat-share-page .app-chat-prompt-card__preview-shell, +.chat-share-page .app-chat-prompt-card__preview-image { + contain: layout; +} + +.chat-share-page .app-chat-message-group__body, +.chat-share-page .app-chat-message-group__activity, +.chat-share-page .app-chat-message-group__activity-stack, +.chat-share-page .app-chat-message-group__activity-children, +.chat-share-page .app-chat-message-group__activity-item, +.chat-share-page .app-chat-preview-card__body, +.chat-share-page .app-chat-preview-card__body--prompt, +.chat-share-page .app-chat-preview-card__body--structured, +.chat-share-page .app-chat-prompt-card__stepper, +.chat-share-page .app-chat-prompt-card__step-panel { + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.chat-share-page .app-chat-message-group__body, +.chat-share-page .app-chat-message-group__activity, +.chat-share-page .app-chat-message-group__activity-stack, +.chat-share-page .app-chat-message-group__activity-children, +.chat-share-page .app-chat-message-group__activity-item, +.chat-share-page .app-chat-preview-card__body, +.chat-share-page .app-chat-preview-card__body--prompt, +.chat-share-page .app-chat-preview-card__body--structured { + overflow-x: hidden; +} + +.chat-share-page .app-chat-prompt-card__option-preview-inline, +.chat-share-page .app-chat-prompt-card__preview-shell, +.chat-share-page .app-chat-prompt-card__preview-scroll, +.chat-share-page .app-chat-prompt-card__preview-scroll-inner, +.chat-share-page .app-chat-prompt-card__preview-body, +.chat-share-page .app-chat-prompt-card__preview-image { + overflow-x: hidden; +} + +.chat-share-page .app-chat-prompt-card__preview-scroll { + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; +} + +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-table-scroll, +.chat-share-page .app-chat-prompt-card__preview-body .codex-diff-previewer__diff-body, +.chat-share-page .app-chat-prompt-card__preview-body .previewer-ui__editor-body, +.chat-share-page .app-chat-prompt-card__preview-body .codex-diff-previewer__diff-list { + overflow-x: auto; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown, +.chat-share-page .app-chat-prompt-card__preview-body .previewer-ui__editor-body { + max-height: min(420px, 70vh); +} + +.chat-share-page .app-chat-prompt-card__preview-image, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-image, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-video { + display: block; + width: 100%; + max-width: 100%; + min-width: 0; +} + +.chat-share-page .app-chat-prompt-card__description.ant-typography, +.chat-share-page .app-chat-prompt-card__preview-title.ant-typography, +.chat-share-page .app-chat-prompt-card__context-text, +.chat-share-page .app-chat-prompt-card__summary, +.chat-share-page .app-chat-prompt-card__summary-detail { + overflow-wrap: anywhere; + word-break: break-word; +} + +.chat-share-page .app-chat-message-group__body .app-chat-panel__preview-rich .previewer-ui__editor-body, +.chat-share-page .app-chat-message-group__body .app-chat-panel__preview-rich--markdown, +.chat-share-page .app-chat-message-group__body .app-chat-panel__preview-table-scroll, +.chat-share-page .app-chat-message-group__body .app-chat-message__preview-text { + max-width: 100%; + max-height: min(420px, 70vh); + overflow-x: auto; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview, +.chat-share-page .app-chat-message-group__body .app-chat-panel__preview-rich--markdown .markdown-preview, +.chat-share-page .app-chat-message-group__body .app-chat-panel__preview-rich--markdown .markdown-preview > *, +.chat-share-page .app-chat-message-group__body .previewer-ui__editor-body > *, +.chat-share-page .app-chat-message-group__body .codex-diff-previewer__diff-list > * { + max-width: 100%; +} + +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview > *, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview .ant-typography, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview p, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview ul, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview ol, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview li, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview h1, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview h2, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview h3, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview h4, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview h5, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview h6 { + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; +} + +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview ul, +.chat-share-page .app-chat-prompt-card__preview-body .app-chat-panel__preview-rich--markdown .markdown-preview ol { + margin-inline: 0; + padding-inline-start: 18px; +} + .chat-share-page .app-chat-preview-card__header, .chat-share-page .app-chat-preview-card--prompt .app-chat-preview-card__header { align-items: center; diff --git a/src/app/main/pages/ChatSharePage.tsx b/src/app/main/pages/ChatSharePage.tsx index 0752d5a..b5a1ac4 100644 --- a/src/app/main/pages/ChatSharePage.tsx +++ b/src/app/main/pages/ChatSharePage.tsx @@ -1,7 +1,7 @@ import { AppstoreOutlined, CheckOutlined, CloseOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownOutlined, EditOutlined, EyeInvisibleOutlined, EyeOutlined, FileTextOutlined, FilterOutlined, FullscreenOutlined, LeftOutlined, MinusOutlined, PlusOutlined, ReloadOutlined, RightOutlined, SearchOutlined, SendOutlined, SettingOutlined, ThunderboltOutlined, UpOutlined } from '@ant-design/icons'; import { Alert, App, Button, Checkbox, Drawer, Dropdown, Input, Modal, Select, Spin, Tabs, Tag, Typography, type MenuProps } from 'antd'; import type { TextAreaRef } from 'antd/es/input/TextArea'; -import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type FocusEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'; +import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ClipboardEvent, type CSSProperties, type FocusEvent, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { useParams } from 'react-router-dom'; import { FullscreenPreviewModal } from '../../../components/previewer'; @@ -42,6 +42,8 @@ import { clearChatShareConversationRoom, completeChatShareManualBadge, createChatShareRoom, + fetchChatConversationDetail, + fetchChatConversations, deleteChatShareRoom, fetchChatShareRuntimeSnapshot, fetchChatShareSnapshot, @@ -50,6 +52,7 @@ import { retryChatShareRequest, saveChatShareRoomSettings, setStoredChatShareAccessPin, + submitChatShareOriginReply, submitChatShareMessage, submitChatSharePrompt, uploadChatShareComposerFile, @@ -64,9 +67,11 @@ import type { PreviewKind } from '../mainChatPanel/previewKind'; import { normalizeChatResourceUrl } from '../mainChatPanel/chatResourceUrl'; import type { ChatComposerAttachment, + ChatConversationSummary, ChatConversationRequest, ChatMessage, ChatMessagePart, + ChatShareRoomLinkContext, ChatRuntimeJobItem, ChatRuntimeSnapshot, ChatRuntimeTerminalStatus, @@ -75,7 +80,7 @@ import type { import { isPromptResolved } from '../mainChatPanel/promptState'; import { sendClientNotification, shouldFallbackToLocalNotification, showLocalClientNotification } from '../notificationApi'; import { copyTextToClipboard } from '../../../utils/clipboard'; -import { applyViewportCssVars } from '../viewportCssVars'; +import { applyViewportCssVars, scheduleViewportRecoverySync } from '../viewportCssVars'; import { isPreviewRuntime } from '../previewRuntime'; import { createInstallManifestObjectUrl, swapInstallDocumentMetadata } from '../pwa/installManifest'; import { getSavedNotificationDeviceId } from '../notificationIdentity'; @@ -98,6 +103,9 @@ const SHARE_TOKEN_USAGE_CLOCK_INTERVAL_MS = 1000; const SHARE_EXPIRY_CLOCK_INTERVAL_MS = 60 * 1000; const SHARE_IMMEDIATE_SEND_PINNED_STORAGE_KEY = 'codex-live-share-immediate-send-pinned-by-token'; const SHARE_LAST_ROOM_STORAGE_KEY = 'codex-live-share-last-room-by-token'; +const SHARE_ROOM_SNAPSHOT_SESSION_INDEX_STORAGE_KEY = 'codex-live-share-room-snapshot-index:v1'; +const SHARE_ROOM_SNAPSHOT_SESSION_STORAGE_KEY_PREFIX = 'codex-live-share-room-snapshot:v1'; +const SHARE_ROOM_SNAPSHOT_SESSION_CACHE_LIMIT = 6; const SHARE_IMMEDIATE_SEND_TOGGLE_HOLD_MS = 1000; const SHARE_ACCESS_PIN_PROMPT_TTL_OPTIONS = [ { value: 'always', label: '매번 묻기', minutes: 0 }, @@ -126,6 +134,12 @@ type ShareExpandMode = 'latest' | 'pending' | 'all'; type PendingSharePromptSelection = PromptDraftSelection & { status: 'draft' | 'submitted'; }; +type ShareProgramRestoreSnapshot = { + roomSessionId: string; + latestRequestId: string; + expandMode: ShareExpandMode; + scrollTop: number; +}; type ShareProgramTarget = { key: string; label: string; @@ -133,6 +147,7 @@ type ShareProgramTarget = { kind: PreviewKind; meta?: string; appId?: string; + restoreSnapshot?: ShareProgramRestoreSnapshot | null; }; type ShareMinimizedProgramItem = { target: ShareProgramTarget; @@ -188,6 +203,21 @@ type ShareProcessInspectorPayload = { checklist: ShareProcessChecklistStep[]; narratives: string[]; }; +type ShareRoomPendingCounts = { + processingCount: number; + unansweredCount: number; +}; + +type ShareRoomSourceGroup = { + key: string; + title: string; + requestPreview: string; + chatTypeLabel: string; + sourceSessionId: string | null; + sourceRequestId: string | null; + linkContext: ChatShareRoomLinkContext | null; + rooms: ChatShareRoomSummary[]; +}; const LazyTextMemoWidget = lazy(async () => { const module = await import('../../../widgets/text-memo-widget'); @@ -331,13 +361,13 @@ function writeStoredShareImmediateSendPinnedByToken(nextMap: Record; } try { - const raw = window.sessionStorage.getItem(SHARE_LAST_ROOM_STORAGE_KEY); + const raw = storage.getItem(SHARE_LAST_ROOM_STORAGE_KEY); if (!raw) { return {} as Record; @@ -360,6 +390,17 @@ function readStoredShareLastRoomByToken() { } } +function readStoredShareLastRoomByToken() { + if (typeof window === 'undefined') { + return {} as Record; + } + + return { + ...readStoredShareLastRoomMapFromStorage(window.sessionStorage), + ...readStoredShareLastRoomMapFromStorage(window.localStorage), + }; +} + function readStoredShareLastRoomSessionId(token: string) { const normalizedToken = token.trim(); @@ -390,7 +431,228 @@ function writeStoredShareLastRoomSessionId(token: string, sessionId: string | nu delete nextMap[normalizedToken]; } - window.sessionStorage.setItem(SHARE_LAST_ROOM_STORAGE_KEY, JSON.stringify(nextMap)); + const serialized = JSON.stringify(nextMap); + window.localStorage.setItem(SHARE_LAST_ROOM_STORAGE_KEY, serialized); + window.sessionStorage.setItem(SHARE_LAST_ROOM_STORAGE_KEY, serialized); +} + +function buildShareRoomSnapshotSessionStorageKey(token: string, sessionId: string) { + return `${SHARE_ROOM_SNAPSHOT_SESSION_STORAGE_KEY_PREFIX}:${token}:${sessionId}`; +} + +function canUseShareRoomSnapshotSessionStorage() { + return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined'; +} + +function readStoredShareRoomSnapshotSessionIndex() { + if (!canUseShareRoomSnapshotSessionStorage()) { + return {} as Record>; + } + + try { + const raw = window.sessionStorage.getItem(SHARE_ROOM_SNAPSHOT_SESSION_INDEX_STORAGE_KEY); + + if (!raw) { + return {} as Record>; + } + + const parsed = JSON.parse(raw) as Record; + return Object.entries(parsed).reduce>>((result, [token, entries]) => { + const normalizedToken = String(token ?? '').trim(); + + if (!normalizedToken || !Array.isArray(entries)) { + return result; + } + + result[normalizedToken] = entries.flatMap((entry) => { + if (!entry || typeof entry !== 'object') { + return []; + } + + const sessionId = String((entry as { sessionId?: unknown }).sessionId ?? '').trim(); + const savedAt = Number((entry as { savedAt?: unknown }).savedAt); + + if (!sessionId || !Number.isFinite(savedAt) || savedAt <= 0) { + return []; + } + + return [{ sessionId, savedAt }]; + }); + return result; + }, {}); + } catch { + return {} as Record>; + } +} + +function writeStoredShareRoomSnapshotSessionIndex(index: Record>) { + if (!canUseShareRoomSnapshotSessionStorage()) { + return; + } + + try { + const normalizedIndex = Object.entries(index).reduce>>((result, [token, entries]) => { + const normalizedToken = String(token ?? '').trim(); + + if (!normalizedToken || !Array.isArray(entries) || entries.length === 0) { + return result; + } + + const normalizedEntries = entries + .map((entry) => ({ + sessionId: String(entry.sessionId ?? '').trim(), + savedAt: Number(entry.savedAt), + })) + .filter((entry) => entry.sessionId && Number.isFinite(entry.savedAt) && entry.savedAt > 0); + + if (normalizedEntries.length > 0) { + result[normalizedToken] = normalizedEntries; + } + + return result; + }, {}); + + if (Object.keys(normalizedIndex).length === 0) { + window.sessionStorage.removeItem(SHARE_ROOM_SNAPSHOT_SESSION_INDEX_STORAGE_KEY); + return; + } + + window.sessionStorage.setItem(SHARE_ROOM_SNAPSHOT_SESSION_INDEX_STORAGE_KEY, JSON.stringify(normalizedIndex)); + } catch { + // Ignore sessionStorage failures and keep runtime fallback active. + } +} + +function resolveShareSnapshotCacheSessionId(snapshot: ChatShareSnapshot | null | undefined) { + return snapshot?.activeSessionId?.trim() || snapshot?.conversation.sessionId?.trim() || snapshot?.share.sessionId?.trim() || ''; +} + +function doesShareSnapshotMatchRequestedRoom( + snapshot: ChatShareSnapshot | null | undefined, + requestedSessionId: string | null | undefined, +) { + const normalizedRequestedSessionId = requestedSessionId?.trim() || ''; + + if (!normalizedRequestedSessionId) { + return true; + } + + return resolveShareSnapshotCacheSessionId(snapshot) === normalizedRequestedSessionId; +} + +function readStoredShareRoomSnapshot(token: string, sessionId: string) { + const normalizedToken = token.trim(); + const normalizedSessionId = sessionId.trim(); + + if (!canUseShareRoomSnapshotSessionStorage() || !normalizedToken || !normalizedSessionId) { + return null; + } + + try { + const raw = window.sessionStorage.getItem(buildShareRoomSnapshotSessionStorageKey(normalizedToken, normalizedSessionId)); + + if (!raw) { + return null; + } + + const parsed = JSON.parse(raw) as { snapshot?: ChatShareSnapshot | null }; + const snapshot = parsed?.snapshot ?? null; + + if (!snapshot) { + return null; + } + + if (snapshot.share.hasAccessPin && !getStoredChatShareAccessPin(normalizedToken)) { + return null; + } + + const cachedSessionId = resolveShareSnapshotCacheSessionId(snapshot); + return cachedSessionId === normalizedSessionId ? snapshot : null; + } catch { + return null; + } +} + +function removeStoredShareRoomSnapshot(token: string, sessionId: string) { + const normalizedToken = token.trim(); + const normalizedSessionId = sessionId.trim(); + + if (!canUseShareRoomSnapshotSessionStorage() || !normalizedToken || !normalizedSessionId) { + return; + } + + try { + window.sessionStorage.removeItem(buildShareRoomSnapshotSessionStorageKey(normalizedToken, normalizedSessionId)); + const nextIndex = readStoredShareRoomSnapshotSessionIndex(); + const currentEntries = nextIndex[normalizedToken] ?? []; + const remainingEntries = currentEntries.filter((entry) => entry.sessionId !== normalizedSessionId); + + if (remainingEntries.length > 0) { + nextIndex[normalizedToken] = remainingEntries; + } else { + delete nextIndex[normalizedToken]; + } + + writeStoredShareRoomSnapshotSessionIndex(nextIndex); + } catch { + // Ignore sessionStorage failures and keep runtime fallback active. + } +} + +function clearStoredShareRoomSnapshotCache(token: string) { + const normalizedToken = token.trim(); + + if (!canUseShareRoomSnapshotSessionStorage() || !normalizedToken) { + return; + } + + const nextIndex = readStoredShareRoomSnapshotSessionIndex(); + const currentEntries = nextIndex[normalizedToken] ?? []; + currentEntries.forEach((entry) => { + window.sessionStorage.removeItem(buildShareRoomSnapshotSessionStorageKey(normalizedToken, entry.sessionId)); + }); + delete nextIndex[normalizedToken]; + writeStoredShareRoomSnapshotSessionIndex(nextIndex); +} + +function writeStoredShareRoomSnapshot(token: string, snapshot: ChatShareSnapshot | null | undefined) { + const normalizedToken = token.trim(); + const normalizedSessionId = resolveShareSnapshotCacheSessionId(snapshot); + + if (!canUseShareRoomSnapshotSessionStorage() || !normalizedToken || !normalizedSessionId || !snapshot) { + return; + } + + const nextSavedAt = Date.now(); + + try { + window.sessionStorage.setItem( + buildShareRoomSnapshotSessionStorageKey(normalizedToken, normalizedSessionId), + JSON.stringify({ + savedAt: nextSavedAt, + snapshot, + }), + ); + const nextIndex = readStoredShareRoomSnapshotSessionIndex(); + const currentEntries = nextIndex[normalizedToken] ?? []; + const dedupedEntries = [ + { sessionId: normalizedSessionId, savedAt: nextSavedAt }, + ...currentEntries.filter((entry) => entry.sessionId !== normalizedSessionId), + ] + .sort((left, right) => right.savedAt - left.savedAt) + .slice(0, SHARE_ROOM_SNAPSHOT_SESSION_CACHE_LIMIT); + + nextIndex[normalizedToken] = dedupedEntries; + writeStoredShareRoomSnapshotSessionIndex(nextIndex); + + currentEntries + .filter((entry) => !dedupedEntries.some((keptEntry) => keptEntry.sessionId === entry.sessionId)) + .forEach((entry) => { + window.sessionStorage.removeItem(buildShareRoomSnapshotSessionStorageKey(normalizedToken, entry.sessionId)); + }); + } catch { + // Ignore sessionStorage quota failures and keep network refresh behavior. + } } function readShareRoomSessionIdFromLocation() { @@ -1689,6 +1951,49 @@ function resolveRenderedMessage(message: ChatMessage): ShareRenderedMessage { return extractShareMessageRenderPayload(message); } +function buildShareRequestMessagesById(messages: ChatMessage[]) { + const nextMap = new Map(); + + messages.forEach((message) => { + const requestId = message.clientRequestId?.trim() || ''; + + if (!requestId) { + return; + } + + const current = nextMap.get(requestId) ?? []; + current.push(message); + nextMap.set(requestId, current); + }); + + return nextMap; +} + +function resolveShareRoomPendingCounts(snapshot: Pick): ShareRoomPendingCounts { + const sortedRequests = [...snapshot.requests].sort(compareShareConversationRequests); + const sortedMessages = [...snapshot.messages].sort((left, right) => left.id - right.id); + const messageRenderPayloadById = new Map( + sortedMessages.map((message) => [message.id, extractShareMessageRenderPayload(message)] as const), + ); + const requestMessagesById = buildShareRequestMessagesById(sortedMessages); + const childRequestCountByParentId = buildShareChildRequestCountMap(sortedRequests); + const promptFollowupCountByParentId = buildSharePromptFollowupCountMap(sortedRequests); + const pendingCompletionRequests = sortedRequests.filter((request) => + isPendingCompletionShareRequest( + request, + requestMessagesById.get(request.requestId) ?? [], + childRequestCountByParentId, + promptFollowupCountByParentId, + messageRenderPayloadById, + )); + const processingCount = pendingCompletionRequests.filter((request) => isRequestInFlight(request.status)).length; + + return { + processingCount, + unansweredCount: Math.max(0, pendingCompletionRequests.length - processingCount), + }; +} + function buildVisibleMessageText(message: ChatMessage, payload?: ShareMessageRenderPayload) { return (payload ?? resolveRenderedMessage(message)).visibleText.trim(); } @@ -1807,6 +2112,102 @@ function resolveShareRequestLineage( }; } +function buildLinkedRoomDraftTitle(source: ChatConversationSummary) { + const titleBase = + source.title?.trim() + || source.requestBadgeLabel?.trim() + || source.contextLabel?.trim() + || '연결 작업'; + return `${titleBase} 작업방`; +} + +function buildLinkedRoomDraftSeedMessage(source: ChatConversationSummary) { + const preview = + source.lastRequestPreview?.trim() + || source.lastMessagePreview?.trim() + || source.lastResponsePreview?.trim() + || '원 세션 내용을 참고해 이어서 처리해 주세요.'; + return `참조 세션을 기준으로 작업을 이어갑니다.\n\n원 요청 요약: ${preview}`; +} + +function buildShareRoomSourceGroups( + rooms: ChatShareRoomSummary[], + conversationBySessionId: ReadonlyMap, +) { + const groupMap = new Map(); + + rooms.forEach((room) => { + const linkContext = room.linkContext?.kind === 'linked-session' ? room.linkContext : null; + const linkedConversation = linkContext ? conversationBySessionId.get(linkContext.sourceSessionId) ?? null : null; + const key = linkContext ? `linked:${linkContext.sourceSessionId}` : `room:${room.sessionId}`; + const current = groupMap.get(key); + const title = + linkContext?.sourceTitle?.trim() + || linkedConversation?.title?.trim() + || room.title.trim() + || '공유 채팅방'; + const requestPreview = + linkContext?.sourceRequestPreview?.trim() + || linkedConversation?.lastRequestPreview?.trim() + || linkedConversation?.lastMessagePreview?.trim() + || ''; + const chatTypeLabel = + linkContext?.sourceChatTypeLabel?.trim() + || linkedConversation?.contextLabel?.trim() + || room.contextLabel?.trim() + || ''; + + if (current) { + current.rooms.push(room); + return; + } + + groupMap.set(key, { + key, + title, + requestPreview, + chatTypeLabel, + sourceSessionId: linkContext?.sourceSessionId ?? null, + sourceRequestId: linkContext?.sourceRequestId ?? null, + linkContext, + rooms: [room], + }); + }); + + return Array.from(groupMap.values()).map((group) => ({ + ...group, + rooms: [...group.rooms].sort((left, right) => { + if (left.isDefault !== right.isDefault) { + return left.isDefault ? -1 : 1; + } + + if (left.sortOrder !== right.sortOrder) { + return left.sortOrder - right.sortOrder; + } + + return left.title.localeCompare(right.title, 'ko'); + }), + })); +} + +function dedupeShareRooms(rooms: ChatShareRoomSummary[]) { + const dedupedRooms: ChatShareRoomSummary[] = []; + const knownSessionIds = new Set(); + + rooms.forEach((room) => { + const normalizedSessionId = room.sessionId.trim(); + + if (!normalizedSessionId || knownSessionIds.has(normalizedSessionId)) { + return; + } + + knownSessionIds.add(normalizedSessionId); + dedupedRooms.push(room); + }); + + return dedupedRooms; +} + function buildSharePreviewItemsFromText(text: string, shareToken: string) { if (!shareToken) { return []; @@ -3578,6 +3979,14 @@ export function ChatSharePage() { const appConfig = useAppConfig(); const { token = '' } = useParams(); const normalizedToken = token.trim(); + const initialRequestedRoomSessionId = + typeof window === 'undefined' + ? '' + : readShareRoomSessionIdFromLocation() || readStoredShareLastRoomSessionId(normalizedToken); + const initialCachedSnapshot = + typeof window === 'undefined' || !normalizedToken + ? null + : readStoredShareRoomSnapshot(normalizedToken, initialRequestedRoomSessionId); const scrollContainerRef = useRef(null); const pageRef = useRef(null); const requestAnchorRefs = useRef(new Map()); @@ -3592,22 +4001,10 @@ export function ChatSharePage() { const scrollIdleTimerRef = useRef(null); const programmaticScrollTargetRef = useRef<'top' | 'bottom' | null>(null); const lastScrollTopRef = useRef(0); - const [snapshot, setSnapshot] = useState(null); - const [requestedRoomSessionId, setRequestedRoomSessionId] = useState(() => { - if (typeof window === 'undefined') { - return ''; - } - - const urlRoomSessionId = readShareRoomSessionIdFromLocation(); - - if (urlRoomSessionId) { - return urlRoomSessionId; - } - - return readStoredShareLastRoomSessionId(normalizedToken); - }); + const [snapshot, setSnapshot] = useState(initialCachedSnapshot); + const [requestedRoomSessionId, setRequestedRoomSessionId] = useState(initialRequestedRoomSessionId); const requestedRoomSessionIdRef = useRef(requestedRoomSessionId); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(() => initialCachedSnapshot == null); const [, setIsRefreshing] = useState(false); const [isLiveConnected, setIsLiveConnected] = useState(false); const [errorMessage, setErrorMessage] = useState(''); @@ -3632,6 +4029,7 @@ export function ChatSharePage() { const [pendingRequestCancellationIds, setPendingRequestCancellationIds] = useState([]); const [pendingRequestRetryIds, setPendingRequestRetryIds] = useState([]); const [isShareRoomListVisible, setIsShareRoomListVisible] = useState(false); + const [shareRoomListLayerStyle, setShareRoomListLayerStyle] = useState(null); const [isRoomSwitching, setIsRoomSwitching] = useState(false); const [replyReferenceRequestId, setReplyReferenceRequestId] = useState(''); const [previousQuestionModalRequestId, setPreviousQuestionModalRequestId] = useState(''); @@ -3644,6 +4042,7 @@ export function ChatSharePage() { const [isCreateRoomOpen, setIsCreateRoomOpen] = useState(false); const [isSavingRoomSettings, setIsSavingRoomSettings] = useState(false); const [isCreatingRoom, setIsCreatingRoom] = useState(false); + const [isLoadingConversationCandidates, setIsLoadingConversationCandidates] = useState(false); const [roomSettingsTabKey, setRoomSettingsTabKey] = useState<'chat-type' | 'default-contexts' | 'room-context' | 'notifications' | 'security' | 'runtime'>('chat-type'); const [editingRoomTitle, setEditingRoomTitle] = useState(''); const [editingRoomChatTypeId, setEditingRoomChatTypeId] = useState(null); @@ -3658,9 +4057,16 @@ export function ChatSharePage() { const [creatingRoomTitle, setCreatingRoomTitle] = useState(''); const [creatingRoomChatTypeId, setCreatingRoomChatTypeId] = useState(null); const [creatingRoomRequestBadgeLabel, setCreatingRoomRequestBadgeLabel] = useState(''); + const [creatingRoomLinkedSessionId, setCreatingRoomLinkedSessionId] = useState(''); const [creatingRoomSeedMessage, setCreatingRoomSeedMessage] = useState( '이 방에서 이어갈 작업 내용을 남겨 주세요. 필요한 파일이나 참고 문맥이 있으면 함께 적어 주세요.', ); + const [conversationCandidates, setConversationCandidates] = useState([]); + const [originReplyDraftText, setOriginReplyDraftText] = useState(''); + const [originReplyTargetGroupKey, setOriginReplyTargetGroupKey] = useState(''); + const [isOriginReplyModalOpen, setIsOriginReplyModalOpen] = useState(false); + const [isSubmittingOriginReply, setIsSubmittingOriginReply] = useState(false); + const [sourceGroupDetailKey, setSourceGroupDetailKey] = useState(''); const [roomNotificationClientStatus, setRoomNotificationClientStatus] = useState(() => buildShareNotificationClientStatus({ roomEnabled: false, @@ -3677,6 +4083,8 @@ export function ChatSharePage() { const [isProcessInspectorSummaryCollapsed, setIsProcessInspectorSummaryCollapsed] = useState(true); const [isProcessInspectorNarrativesCollapsed, setIsProcessInspectorNarrativesCollapsed] = useState(true); const [optimisticShareRooms, setOptimisticShareRooms] = useState([]); + const [shareRoomPendingCountsBySessionId, setShareRoomPendingCountsBySessionId] = useState>({}); + const [isLoadingShareRoomPendingCounts, setIsLoadingShareRoomPendingCounts] = useState(false); const [isRefreshingRoomNotificationStatus, setIsRefreshingRoomNotificationStatus] = useState(false); const [isSendingRoomNotificationTest, setIsSendingRoomNotificationTest] = useState(false); const [isDeletingRoom, setIsDeletingRoom] = useState(false); @@ -3690,6 +4098,12 @@ export function ChatSharePage() { const [programTarget, setProgramTarget] = useState(null); const [minimizedPrograms, setMinimizedPrograms] = useState([]); const [programReloadKey, setProgramReloadKey] = useState(0); + const shareRoomPendingCountFetchSequenceRef = useRef(0); + const shareRoomPendingCountRefreshPromiseBySessionIdRef = useRef | null>>({}); + const shareRoomPendingCountRefreshQueuedBySessionIdRef = useRef>({}); + const conversationHeaderRef = useRef(null); + const roomListTriggerButtonRef = useRef(null); + const roomListPanelRef = useRef(null); const processInspectorCardRef = useRef(null); const processInspectorDragStateRef = useRef<{ pointerId: number; @@ -3709,6 +4123,7 @@ export function ChatSharePage() { } | null>(null); const programMinimizedMovedRef = useRef(false); const minimizedProgramsRef = useRef([]); + const minimizedProgramPositionByKeyRef = useRef>({}); const composerRef = useRef(null); const composerAttachmentInputRef = useRef(null); const composerInputShellRef = useRef(null); @@ -3718,11 +4133,13 @@ export function ChatSharePage() { const composerFocusScrollTimerIdsRef = useRef([]); const [isComposerViewportCompacted, setIsComposerViewportCompacted] = useState(false); const [appLaunchUsage, setAppLaunchUsage] = useState(() => readShareAppLaunchUsage()); - const roomSwitchSequenceRef = useRef(0); + minimizedProgramsRef.current = minimizedPrograms; hasSnapshotRef.current = snapshot != null; requiresAccessPinRef.current = requiresAccessPin; requestedRoomSessionIdRef.current = requestedRoomSessionId; + const isRoomSwitchingRef = useRef(isRoomSwitching); + isRoomSwitchingRef.current = isRoomSwitching; const shareTokenSetting = snapshot?.share.tokenSetting ?? null; const shareAllowedAppIdSet = useMemo( @@ -3737,7 +4154,7 @@ export function ChatSharePage() { const snapshotRooms = snapshot?.rooms ?? []; if (optimisticShareRooms.length === 0) { - return snapshotRooms; + return dedupeShareRooms(snapshotRooms); } const nextRooms = [...snapshotRooms]; @@ -3749,14 +4166,22 @@ export function ChatSharePage() { } }); - return nextRooms; + return dedupeShareRooms(nextRooms); }, [optimisticShareRooms, snapshot?.rooms]); + const conversationCandidateBySessionId = useMemo( + () => new Map(conversationCandidates.map((item) => [item.sessionId, item])), + [conversationCandidates], + ); const activeShareRoomSessionId = snapshot?.activeSessionId?.trim() || snapshot?.share.sessionId?.trim() || ''; const selectedShareRoomSessionId = requestedRoomSessionId.trim() || activeShareRoomSessionId; const activeShareRoom = useMemo( () => shareRooms.find((item) => item.sessionId === selectedShareRoomSessionId) ?? null, [selectedShareRoomSessionId, shareRooms], ); + const hasVisibleSnapshot = snapshot != null; + const showRoomSwitchingOverlay = isRoomSwitching && hasVisibleSnapshot; + const showRoomSwitchingSkeleton = isRoomSwitching && !hasVisibleSnapshot; + const roomSwitchingStatusLabel = activeShareRoom?.title?.trim() || '선택한 채팅방'; const filteredShareRooms = useMemo(() => { const keyword = shareRoomFilterKeyword.trim().toLowerCase(); if (!keyword) { @@ -3769,6 +4194,9 @@ export function ChatSharePage() { room.contextLabel, room.requestBadgeLabel, room.sessionId, + room.linkContext?.sourceTitle, + room.linkContext?.sourceRequestPreview, + room.linkContext?.sourceChatTypeLabel, ] .map((value) => value?.trim().toLowerCase() ?? '') .filter(Boolean) @@ -3777,6 +4205,18 @@ export function ChatSharePage() { return searchIndex.includes(keyword); }); }, [shareRoomFilterKeyword, shareRooms]); + const filteredShareRoomGroups = useMemo( + () => buildShareRoomSourceGroups(filteredShareRooms, conversationCandidateBySessionId), + [conversationCandidateBySessionId, filteredShareRooms], + ); + const sourceGroupDetail = useMemo( + () => filteredShareRoomGroups.find((group) => group.key === sourceGroupDetailKey) ?? null, + [filteredShareRoomGroups, sourceGroupDetailKey], + ); + const originReplyTargetGroup = useMemo( + () => filteredShareRoomGroups.find((group) => group.key === originReplyTargetGroupKey) ?? null, + [filteredShareRoomGroups, originReplyTargetGroupKey], + ); const pendingDeleteRoom = useMemo( () => shareRooms.find((item) => item.sessionId === pendingDeleteRoomSessionId) ?? null, [pendingDeleteRoomSessionId, shareRooms], @@ -4225,6 +4665,7 @@ export function ChatSharePage() { () => [...(snapshot?.requests ?? [])].sort(compareShareConversationRequests), [snapshot], ); + const isServerCommandDrawerMobile = typeof window !== 'undefined' ? window.innerWidth <= 768 : false; const openSharedRoomSettings = useCallback(() => { if (!snapshot?.conversation.sessionId) { return; @@ -4270,9 +4711,23 @@ export function ChatSharePage() { setCreatingRoomTitle(nextChatTypeName ? `${nextChatTypeName} 작업방` : '새 공유 채팅방'); setCreatingRoomChatTypeId(nextChatTypeId); setCreatingRoomRequestBadgeLabel(''); + setCreatingRoomLinkedSessionId(''); setCreatingRoomSeedMessage('이 방에서 이어갈 작업 내용을 남겨 주세요. 필요한 파일이나 참고 문맥이 있으면 함께 적어 주세요.'); setIsCreateRoomOpen(true); - }, [currentSharedChatTypeId, enabledChatTypes]); + setIsLoadingConversationCandidates(true); + void fetchChatConversations() + .then((items) => { + const nextItems = items.filter((item) => item.sessionId !== snapshot?.conversation.sessionId); + setConversationCandidates(nextItems); + }) + .catch((error) => { + console.error('failed to load share room candidates', error); + setConversationCandidates([]); + }) + .finally(() => { + setIsLoadingConversationCandidates(false); + }); + }, [currentSharedChatTypeId, enabledChatTypes, snapshot?.conversation.sessionId]); const refreshShareRuntime = useCallback(async (options?: { silent?: boolean }) => { if (!normalizedToken || !selectedShareRoomSessionId) { setShareRuntimeSnapshot(null); @@ -4338,10 +4793,17 @@ export function ChatSharePage() { const nextSnapshot = await fetchChatShareSnapshot(normalizedToken, { sharePin: options?.sharePin, sessionId: requestedRoomSessionIdRef.current || undefined, + view: initialLoad ? 'initial' : 'full', }); + const requestedSessionId = requestedRoomSessionIdRef.current.trim(); + const matchedRequestedRoom = doesShareSnapshotMatchRequestedRoom(nextSnapshot, requestedSessionId); const shouldApplyImmediately = - !isInteractingRef.current || initialLoad || requiresAccessPinRef.current || !hasSnapshotRef.current; + !isInteractingRef.current + || initialLoad + || requiresAccessPinRef.current + || !hasSnapshotRef.current + || isRoomSwitchingRef.current; if (!shouldApplyImmediately) { deferredSnapshotRef.current = nextSnapshot; @@ -4349,6 +4811,9 @@ export function ChatSharePage() { setSnapshot(nextSnapshot); deferredSnapshotRef.current = null; } + if (nextSnapshot.detailLevel !== 'initial') { + writeStoredShareRoomSnapshot(normalizedToken, nextSnapshot); + } if (nextSnapshot.share.hasAccessPin) { const resolvedPin = normalizeAccessPinInput(options?.sharePin ?? ''); const persistedPin = resolvedPin || getStoredChatShareAccessPin(normalizedToken); @@ -4361,13 +4826,29 @@ export function ChatSharePage() { } else { setStoredChatShareAccessPin(normalizedToken, null); } + + if (matchedRequestedRoom) { + setIsRoomSwitching(false); + } else if (requestedSessionId) { + pendingSilentRefreshRef.current = true; + } + setErrorMessage(''); setRequiresAccessPin(false); setAccessPinSubmitError(''); + + if (initialLoad && nextSnapshot.detailLevel === 'initial') { + window.setTimeout(() => { + void refreshSnapshot({ silent: true }); + }, 0); + } + return true; } catch (error) { if (error instanceof ChatApiError && error.status === 401 && (error.code === 'share_pin_required' || error.code === 'share_pin_invalid')) { setStoredChatShareAccessPin(normalizedToken, null); + clearStoredShareRoomSnapshotCache(normalizedToken); + setSnapshot(null); setRequiresAccessPin(true); if (error.code === 'share_pin_invalid') { @@ -4381,6 +4862,7 @@ export function ChatSharePage() { if (!silent) { setErrorMessage(error instanceof Error ? error.message : '공유 화면을 불러오지 못했습니다.'); } + setIsRoomSwitching(false); return false; } finally { snapshotRefreshPromiseRef.current = null; @@ -4438,6 +4920,7 @@ export function ChatSharePage() { }); setPendingDeleteRoomSessionId(''); setSwipedRoomSessionId(''); + removeStoredShareRoomSnapshot(normalizedToken, room.sessionId); requestedRoomSessionIdRef.current = requestedRoomSessionIdRef.current === room.sessionId ? fallbackSessionId : requestedRoomSessionIdRef.current; setRequestedRoomSessionId((current) => (current === room.sessionId ? fallbackSessionId : current)); @@ -4866,6 +5349,7 @@ export function ChatSharePage() { const syncPosition = (key: string, position: { x: number; y: number }) => { const nextPosition = clampPosition(key, position); + minimizedProgramPositionByKeyRef.current[key] = nextPosition; setMinimizedPrograms((current) => current.map((item) => ( item.target.key === key ? { @@ -4877,10 +5361,14 @@ export function ChatSharePage() { }; const handleResize = () => { - setMinimizedPrograms((current) => current.map((item) => ({ - ...item, - position: clampPosition(item.target.key, item.position), - }))); + setMinimizedPrograms((current) => current.map((item) => { + const nextPosition = clampPosition(item.target.key, item.position); + minimizedProgramPositionByKeyRef.current[item.target.key] = nextPosition; + return { + ...item, + position: nextPosition, + }; + })); }; const handlePointerMove = (event: PointerEvent) => { @@ -4940,6 +5428,78 @@ export function ChatSharePage() { }; }, [minimizedPrograms.length]); + useLayoutEffect(() => { + if (!isShareRoomListVisible) { + setShareRoomListLayerStyle(null); + return; + } + + const updateLayerPosition = () => { + const triggerRect = roomListTriggerButtonRef.current?.getBoundingClientRect() ?? null; + const headerRect = conversationHeaderRef.current?.getBoundingClientRect() ?? null; + const anchorRect = headerRect ?? triggerRect; + + if (!anchorRect) { + return; + } + + const viewportPadding = 8; + const availableWidth = Math.max(280, window.innerWidth - (viewportPadding * 2)); + const preferredWidth = headerRect + ? Math.min(Math.max(headerRect.width, 280), 420) + : Math.min(360, availableWidth); + const width = Math.min(preferredWidth, availableWidth); + const preferredLeft = headerRect?.left ?? triggerRect?.left ?? viewportPadding; + const maxLeft = Math.max(viewportPadding, window.innerWidth - viewportPadding - width); + const left = Math.min(Math.max(preferredLeft, viewportPadding), maxLeft); + const top = Math.max(anchorRect.bottom, triggerRect?.bottom ?? 0) + 8; + const maxHeight = Math.max(220, window.innerHeight - top - viewportPadding); + + setShareRoomListLayerStyle({ + top: `${Math.round(top)}px`, + left: `${Math.round(left)}px`, + width: `${Math.round(width)}px`, + maxHeight: `${Math.round(maxHeight)}px`, + }); + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsShareRoomListVisible(false); + } + }; + + const handleOutsidePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + + if (roomListPanelRef.current?.contains(target) || roomListTriggerButtonRef.current?.contains(target)) { + return; + } + + setIsShareRoomListVisible(false); + }; + + const handleViewportChange = () => { + updateLayerPosition(); + }; + + updateLayerPosition(); + window.addEventListener('resize', handleViewportChange); + window.addEventListener('scroll', handleViewportChange, true); + document.addEventListener('pointerdown', handleOutsidePointerDown, true); + document.addEventListener('keydown', handleEscape); + + return () => { + window.removeEventListener('resize', handleViewportChange); + window.removeEventListener('scroll', handleViewportChange, true); + document.removeEventListener('pointerdown', handleOutsidePointerDown, true); + document.removeEventListener('keydown', handleEscape); + }; + }, [isShareRoomListVisible]); + const handleProcessInspectorPointerDown = useCallback((event: ReactPointerEvent) => { if (event.pointerType === 'mouse' && event.button !== 0) { return; @@ -5036,9 +5596,6 @@ export function ChatSharePage() { setRequiresAccessPin(false); }, [normalizedToken]); - const handleCloseProgram = useCallback(() => { - setProgramTarget(null); - }, []); const handleCloseMinimizedProgram = useCallback((targetKey: string) => { setMinimizedPrograms((current) => current.filter((item) => item.target.key !== targetKey)); }, []); @@ -5321,8 +5878,13 @@ export function ChatSharePage() { const urlRoomSessionId = readShareRoomSessionIdFromLocation(); const restoredRoomSessionId = urlRoomSessionId || readStoredShareLastRoomSessionId(normalizedToken); + const cachedSnapshot = readStoredShareRoomSnapshot(normalizedToken, restoredRoomSessionId); requestedRoomSessionIdRef.current = restoredRoomSessionId; + deferredSnapshotRef.current = null; + setSnapshot(cachedSnapshot); + setIsLoading(cachedSnapshot == null); + setIsRoomSwitching(false); setRequestedRoomSessionId(restoredRoomSessionId); }, [normalizedToken]); @@ -5333,7 +5895,13 @@ export function ChatSharePage() { const handlePopState = () => { const nextRoomSessionId = readShareRoomSessionIdFromLocation() || readStoredShareLastRoomSessionId(normalizedToken); + const cachedSnapshot = readStoredShareRoomSnapshot(normalizedToken, nextRoomSessionId); requestedRoomSessionIdRef.current = nextRoomSessionId; + deferredSnapshotRef.current = null; + if (cachedSnapshot) { + setSnapshot(cachedSnapshot); + } + setIsRoomSwitching(Boolean(nextRoomSessionId)); setRequestedRoomSessionId(nextRoomSessionId); setIsShareRoomListVisible(false); }; @@ -5349,13 +5917,32 @@ export function ChatSharePage() { return; } - const roomSwitchSequence = roomSwitchSequenceRef.current; - void refreshSnapshot({ silent: true }).finally(() => { - if (roomSwitchSequenceRef.current === roomSwitchSequence) { - setIsRoomSwitching(false); - } - }); + void refreshSnapshot({ silent: true }); }, [normalizedToken, refreshSnapshot, requestedRoomSessionId]); + useEffect(() => { + if (!normalizedToken || !snapshot || snapshot.detailLevel === 'initial') { + return; + } + + writeStoredShareRoomSnapshot(normalizedToken, snapshot); + }, [normalizedToken, snapshot]); + + useEffect(() => { + if (!normalizedToken || requestedRoomSessionIdRef.current.trim()) { + return; + } + + const stabilizedRoomSessionId = activeShareRoomSessionId.trim(); + + if (!stabilizedRoomSessionId || !shareRooms.some((room) => room.sessionId === stabilizedRoomSessionId)) { + return; + } + + requestedRoomSessionIdRef.current = stabilizedRoomSessionId; + writeStoredShareLastRoomSessionId(normalizedToken, stabilizedRoomSessionId); + writeShareRoomSessionIdToLocation(stabilizedRoomSessionId, 'replace'); + setRequestedRoomSessionId(stabilizedRoomSessionId); + }, [activeShareRoomSessionId, normalizedToken, shareRooms]); useEffect(() => { if (!requestedRoomSessionId) { @@ -5366,9 +5953,13 @@ export function ChatSharePage() { return; } - writeStoredShareLastRoomSessionId(normalizedToken, null); - setRequestedRoomSessionId(''); - }, [normalizedToken, requestedRoomSessionId, shareRooms]); + const fallbackRoomSessionId = + shareRooms.find((room) => room.sessionId === activeShareRoomSessionId)?.sessionId ?? ''; + + requestedRoomSessionIdRef.current = fallbackRoomSessionId; + writeStoredShareLastRoomSessionId(normalizedToken, fallbackRoomSessionId || null); + setRequestedRoomSessionId(fallbackRoomSessionId); + }, [activeShareRoomSessionId, normalizedToken, requestedRoomSessionId, shareRooms]); useEffect(() => { if (!normalizedToken) { return; @@ -5871,7 +6462,12 @@ export function ChatSharePage() { return; } - roomSwitchSequenceRef.current += 1; + const cachedSnapshot = readStoredShareRoomSnapshot(normalizedToken, normalizedSessionId); + + deferredSnapshotRef.current = null; + if (cachedSnapshot) { + setSnapshot(cachedSnapshot); + } setIsRoomSwitching(true); requestedRoomSessionIdRef.current = normalizedSessionId; writeStoredShareLastRoomSessionId(normalizedToken, normalizedSessionId); @@ -5888,6 +6484,9 @@ export function ChatSharePage() { const nextChatType = enabledChatTypes.find((item) => item.id === creatingRoomChatTypeId) ?? null; const normalizedTitle = creatingRoomTitle.trim(); const normalizedSeedMessage = creatingRoomSeedMessage.trim(); + const linkedConversation = creatingRoomLinkedSessionId + ? conversationCandidateBySessionId.get(creatingRoomLinkedSessionId) ?? null + : null; if (!nextChatType) { message.warning('새 방에 사용할 채팅유형을 먼저 선택하세요.'); @@ -5907,12 +6506,32 @@ export function ChatSharePage() { setIsCreatingRoom(true); try { + const linkedConversationDetail = linkedConversation + ? await fetchChatConversationDetail(linkedConversation.sessionId, { limit: 20 }) + : null; + const linkedRequest = linkedConversationDetail?.requests[linkedConversationDetail.requests.length - 1] ?? null; + + if (linkedConversation && !linkedRequest) { + message.warning('선택한 추천 세션에서 연결할 요청을 찾지 못했습니다.'); + setIsCreatingRoom(false); + return; + } + const createdRoom = await createChatShareRoom(normalizedToken, { chatTypeId: nextChatType.id, chatTypeLabel: nextChatType.name, title: normalizedTitle, requestBadgeLabel: creatingRoomRequestBadgeLabel.trim() || null, seedMessage: normalizedSeedMessage, + linkedSessionId: linkedConversation?.sessionId ?? null, + linkedRequestId: linkedRequest?.requestId ?? null, + linkedTitle: linkedConversation?.title ?? null, + linkedRequestPreview: + linkedRequest?.userText + || linkedConversation?.lastRequestPreview + || linkedConversation?.lastMessagePreview + || null, + linkedChatTypeLabel: linkedRequest?.chatTypeLabel ?? linkedConversation?.contextLabel ?? null, }); setIsCreateRoomOpen(false); @@ -5921,6 +6540,7 @@ export function ChatSharePage() { ? current : [...current, createdRoom] )); + setIsRoomSwitching(true); requestedRoomSessionIdRef.current = createdRoom.sessionId; writeStoredShareLastRoomSessionId(normalizedToken, createdRoom.sessionId); writeShareRoomSessionIdToLocation(createdRoom.sessionId, 'push'); @@ -5928,6 +6548,7 @@ export function ChatSharePage() { setDraftText(''); setComposerAttachments([]); setReplyReferenceRequestId(''); + setCreatingRoomLinkedSessionId(''); message.success('새 공유 채팅방을 추가했습니다.'); } catch (error) { message.error(error instanceof Error ? error.message : '새 공유 채팅방을 추가하지 못했습니다.'); @@ -5936,9 +6557,11 @@ export function ChatSharePage() { } }, [ creatingRoomChatTypeId, + creatingRoomLinkedSessionId, creatingRoomRequestBadgeLabel, creatingRoomSeedMessage, creatingRoomTitle, + conversationCandidateBySessionId, enabledChatTypes, isCreatingRoom, message, @@ -6197,12 +6820,75 @@ export function ChatSharePage() { ); }, []); + const restoreShareViewportAfterResume = useCallback(() => { + if (typeof window === 'undefined') { + return; + } + + scheduleViewportRecoverySync(); + + window.requestAnimationFrame(() => { + syncComposerViewportCompactedState(); + + const activeElement = document.activeElement; + const resumedFocusTarget = + isMobileShareInputTarget(activeElement) && scrollContainerRef.current?.contains(activeElement) + ? activeElement + : focusedMobileInputRef.current; + + if (!resumedFocusTarget || !scrollContainerRef.current?.contains(resumedFocusTarget)) { + focusedMobileInputRef.current = null; + clearComposerViewportSyncTimers(); + return; + } + + focusedMobileInputRef.current = resumedFocusTarget; + scheduleFocusedInputViewportSync(resumedFocusTarget, 'auto', { includeRetryTimers: true }); + }); + }, [clearComposerViewportSyncTimers, scheduleFocusedInputViewportSync, syncComposerViewportCompactedState]); + useEffect(() => { return () => { clearComposerViewportSyncTimers(); }; }, [clearComposerViewportSyncTimers]); + useEffect(() => { + if (typeof window === 'undefined') { + return undefined; + } + + const handlePageSuspend = () => { + clearComposerViewportSyncTimers(); + setIsComposerViewportCompacted(false); + }; + + const handlePageResume = () => { + restoreShareViewportAfterResume(); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + handlePageSuspend(); + return; + } + + handlePageResume(); + }; + + window.addEventListener('focus', handlePageResume); + window.addEventListener('pageshow', handlePageResume); + window.addEventListener('pagehide', handlePageSuspend); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + window.removeEventListener('focus', handlePageResume); + window.removeEventListener('pageshow', handlePageResume); + window.removeEventListener('pagehide', handlePageSuspend); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [clearComposerViewportSyncTimers, restoreShareViewportAfterResume]); + useEffect(() => { if (typeof window === 'undefined') { return undefined; @@ -6420,6 +7106,52 @@ export function ChatSharePage() { setIsClearingConversation(false); } }, [isClearingConversation, message, modal, normalizedToken, selectedShareRoomSessionId, snapshot]); + const handleMoveToSourceSession = useCallback((group: ShareRoomSourceGroup) => { + if (typeof window === 'undefined' || !group.sourceSessionId) { + return; + } + + window.open(`${buildChatPath('live')}?sessionId=${encodeURIComponent(group.sourceSessionId)}`, '_blank', 'noopener,noreferrer'); + }, []); + const handleSubmitOriginReply = useCallback(async () => { + if (!normalizedToken || !originReplyTargetGroup?.sourceSessionId || !originReplyTargetGroup.sourceRequestId) { + return; + } + + const outgoingText = originReplyDraftText.trim(); + + if (!outgoingText) { + message.warning('원 세션으로 보낼 답변 내용을 입력하세요.'); + return; + } + + setIsSubmittingOriginReply(true); + + try { + await submitChatShareOriginReply(normalizedToken, { + sessionId: selectedShareRoomSessionId, + sourceSessionId: originReplyTargetGroup.sourceSessionId, + sourceRequestId: originReplyTargetGroup.sourceRequestId, + text: outgoingText, + mode: 'queue', + }); + setIsOriginReplyModalOpen(false); + setOriginReplyDraftText(''); + setOriginReplyTargetGroupKey(''); + message.success('원 세션에 답변 전송을 등록했습니다.'); + } catch (error) { + message.error(error instanceof Error ? error.message : '원 세션 답변 전송에 실패했습니다.'); + } finally { + setIsSubmittingOriginReply(false); + } + }, [ + message, + normalizedToken, + originReplyDraftText, + originReplyTargetGroup?.sourceRequestId, + originReplyTargetGroup?.sourceSessionId, + selectedShareRoomSessionId, + ]); const shareBlockedReason = snapshot?.share.blockedReason?.trim() ?? ''; const canSendMessage = snapshot != null && !isPromptShare && (snapshot.share.canSendMessage ?? true); @@ -6553,6 +7285,21 @@ export function ChatSharePage() { (request) => isRequestInFlight(request.status), ).length; const pendingUnansweredCount = Math.max(0, pendingCompletionRequests.length - pendingProcessingCount); + const shareRoomListFetchKey = useMemo( + () => shareRooms.map((room) => room.sessionId.trim()).filter(Boolean).join('|'), + [shareRooms], + ); + const backgroundShareRoomSessionIds = useMemo( + () => + shareRooms + .map((room) => room.sessionId.trim()) + .filter((sessionId) => sessionId && sessionId !== selectedShareRoomSessionId.trim()), + [selectedShareRoomSessionId, shareRooms], + ); + const visibleBackgroundShareRoomSessionIds = useMemo( + () => (isShareRoomListVisible ? backgroundShareRoomSessionIds : []), + [backgroundShareRoomSessionIds, isShareRoomListVisible], + ); const requestProgressLabel = sortedRequests.length === 0 ? '' @@ -6633,6 +7380,32 @@ export function ChatSharePage() { return nextMap; }, [messageRenderPayloadById, requestMessagesById, sortedRequests]); + const creatingRoomLinkedConversation = useMemo( + () => (creatingRoomLinkedSessionId ? conversationCandidateBySessionId.get(creatingRoomLinkedSessionId) ?? null : null), + [conversationCandidateBySessionId, creatingRoomLinkedSessionId], + ); + useEffect(() => { + if (!creatingRoomLinkedConversation) { + return; + } + + setCreatingRoomTitle((current) => { + const normalizedCurrent = current.trim(); + if (!normalizedCurrent || normalizedCurrent === '새 공유 채팅방' || normalizedCurrent.endsWith('작업방')) { + return buildLinkedRoomDraftTitle(creatingRoomLinkedConversation); + } + + return current; + }); + setCreatingRoomSeedMessage((current) => { + const normalizedCurrent = current.trim(); + if (!normalizedCurrent || normalizedCurrent.startsWith('이 방에서 이어갈 작업 내용을')) { + return buildLinkedRoomDraftSeedMessage(creatingRoomLinkedConversation); + } + + return current; + }); + }, [creatingRoomLinkedConversation]); const replyReferenceRequest = useMemo( () => (replyReferenceRequestId.trim() ? requestById.get(replyReferenceRequestId.trim()) ?? null : null), [replyReferenceRequestId, requestById], @@ -7006,6 +7779,242 @@ export function ChatSharePage() { [aggregateStatusTag?.elapsedLabel, pendingProcessingCount, pendingUnansweredCount], ); + useEffect(() => { + const sessionId = selectedShareRoomSessionId.trim(); + + if (!sessionId) { + return; + } + + setShareRoomPendingCountsBySessionId((current) => { + const previousCounts = current[sessionId]; + + if ( + previousCounts?.processingCount === pendingProcessingCount + && previousCounts?.unansweredCount === pendingUnansweredCount + ) { + return current; + } + + return { + ...current, + [sessionId]: { + processingCount: pendingProcessingCount, + unansweredCount: pendingUnansweredCount, + }, + }; + }); + }, [pendingProcessingCount, pendingUnansweredCount, selectedShareRoomSessionId]); + + const refreshShareRoomPendingCount = useCallback(async (sessionId: string) => { + const normalizedSessionId = sessionId.trim(); + + if (!normalizedToken || !normalizedSessionId) { + return; + } + + const inFlightTask = shareRoomPendingCountRefreshPromiseBySessionIdRef.current[normalizedSessionId]; + + if (inFlightTask) { + shareRoomPendingCountRefreshQueuedBySessionIdRef.current[normalizedSessionId] = true; + await inFlightTask; + return; + } + + const sharePin = getStoredChatShareAccessPin(normalizedToken) || undefined; + const refreshTask = (async () => { + try { + const roomSnapshot = await fetchChatShareSnapshot(normalizedToken, { + sessionId: normalizedSessionId, + sharePin, + }); + + setShareRoomPendingCountsBySessionId((current) => { + const nextCounts = resolveShareRoomPendingCounts(roomSnapshot); + const previousCounts = current[normalizedSessionId]; + + if ( + previousCounts?.processingCount === nextCounts.processingCount + && previousCounts?.unansweredCount === nextCounts.unansweredCount + ) { + return current; + } + + return { + ...current, + [normalizedSessionId]: nextCounts, + }; + }); + } catch (error) { + console.error('failed to refresh share room pending counts', normalizedSessionId, error); + } finally { + shareRoomPendingCountRefreshPromiseBySessionIdRef.current[normalizedSessionId] = null; + + if (shareRoomPendingCountRefreshQueuedBySessionIdRef.current[normalizedSessionId]) { + shareRoomPendingCountRefreshQueuedBySessionIdRef.current[normalizedSessionId] = false; + window.setTimeout(() => { + void refreshShareRoomPendingCount(normalizedSessionId); + }, 0); + } + } + })(); + + shareRoomPendingCountRefreshPromiseBySessionIdRef.current[normalizedSessionId] = refreshTask; + await refreshTask; + }, [normalizedToken]); + + useEffect(() => { + if (!normalizedToken || !shareRoomListFetchKey) { + return; + } + + const targetRoomSessionIds = visibleBackgroundShareRoomSessionIds; + + if (targetRoomSessionIds.length === 0) { + setIsLoadingShareRoomPendingCounts(false); + return; + } + + const fetchSequence = shareRoomPendingCountFetchSequenceRef.current + 1; + shareRoomPendingCountFetchSequenceRef.current = fetchSequence; + let cancelled = false; + setIsLoadingShareRoomPendingCounts(true); + + void Promise.allSettled( + targetRoomSessionIds.map(async (sessionId) => { + await refreshShareRoomPendingCount(sessionId); + }), + ) + .finally(() => { + if (cancelled || shareRoomPendingCountFetchSequenceRef.current !== fetchSequence) { + return; + } + + setIsLoadingShareRoomPendingCounts(false); + }); + + return () => { + cancelled = true; + }; + }, [normalizedToken, refreshShareRoomPendingCount, shareRoomListFetchKey, visibleBackgroundShareRoomSessionIds]); + + useEffect(() => { + if (!normalizedToken || requiresAccessPin || typeof window === 'undefined' || visibleBackgroundShareRoomSessionIds.length === 0) { + return undefined; + } + + let isDisposed = false; + const sockets = new Map(); + const reconnectTimerIds = new Map(); + const refreshTimerIds = new Map(); + + const clearRefreshTimer = (sessionId: string) => { + const timerId = refreshTimerIds.get(sessionId); + if (timerId != null) { + window.clearTimeout(timerId); + refreshTimerIds.delete(sessionId); + } + }; + + const scheduleRoomCountRefresh = (sessionId: string) => { + if (isDisposed || refreshTimerIds.has(sessionId)) { + return; + } + + const timerId = window.setTimeout(() => { + refreshTimerIds.delete(sessionId); + void refreshShareRoomPendingCount(sessionId); + }, 150); + + refreshTimerIds.set(sessionId, timerId); + }; + + const connectRoomSocket = (sessionId: string) => { + if (isDisposed) { + return; + } + + const websocketUrl = resolveChatWebSocketUrl(sessionId, undefined, undefined, normalizedToken); + + if (!websocketUrl) { + return; + } + + const socket = new WebSocket(websocketUrl); + sockets.set(sessionId, socket); + + socket.addEventListener('message', (event) => { + if (isDisposed || typeof event.data !== 'string') { + return; + } + + try { + const payload = JSON.parse(event.data) as ChatServerEvent; + + if (payload.sessionId !== sessionId) { + return; + } + + if ( + payload.type === 'chat:init' + || payload.type === 'chat:status' + || payload.type === 'chat:runtime' + || payload.type === 'chat:runtime:detail' + || payload.type === 'notification:messages-updated' + ) { + return; + } + } catch { + // payload 해석 실패 시에도 해당 방 건수만 다시 확인한다. + } + + scheduleRoomCountRefresh(sessionId); + }); + + socket.addEventListener('error', () => { + if (isDisposed) { + return; + } + + socket.close(); + }); + + socket.addEventListener('close', () => { + sockets.delete(sessionId); + clearRefreshTimer(sessionId); + + if (isDisposed) { + return; + } + + const reconnectTimerId = window.setTimeout(() => { + reconnectTimerIds.delete(sessionId); + connectRoomSocket(sessionId); + }, 1500); + + reconnectTimerIds.set(sessionId, reconnectTimerId); + }); + }; + + visibleBackgroundShareRoomSessionIds.forEach((sessionId) => { + connectRoomSocket(sessionId); + }); + + return () => { + isDisposed = true; + + reconnectTimerIds.forEach((timerId) => { + window.clearTimeout(timerId); + }); + refreshTimerIds.forEach((timerId) => { + window.clearTimeout(timerId); + }); + sockets.forEach((socket) => { + socket.close(); + }); + }; + }, [normalizedToken, refreshShareRoomPendingCount, requiresAccessPin, visibleBackgroundShareRoomSessionIds]); + useEffect(() => { if (!activeProcessInspectorRequestId.trim()) { return; @@ -7081,6 +8090,62 @@ export function ChatSharePage() { ? 'chat-share-page__content-layout chat-share-page__content-layout--with-composer' : 'chat-share-page__content-layout'; const canToggleShareRoomList = shareRooms.length > 1 || canCreateSharedRooms; + const captureProgramRestoreSnapshot = useCallback( + (): ShareProgramRestoreSnapshot => ({ + roomSessionId: selectedShareRoomSessionId.trim(), + latestRequestId: latestRequestId.trim(), + expandMode, + scrollTop: Math.max(0, scrollContainerRef.current?.scrollTop ?? 0), + }), + [expandMode, latestRequestId, selectedShareRoomSessionId], + ); + const restoreProgramReturnSnapshot = useCallback((restoreSnapshot?: ShareProgramRestoreSnapshot | null) => { + if (typeof window === 'undefined' || !restoreSnapshot) { + return; + } + + const normalizedRoomSessionId = restoreSnapshot.roomSessionId.trim(); + + if (normalizedRoomSessionId && normalizedRoomSessionId !== selectedShareRoomSessionId) { + setIsRoomSwitching(true); + requestedRoomSessionIdRef.current = normalizedRoomSessionId; + writeStoredShareLastRoomSessionId(normalizedToken, normalizedRoomSessionId); + writeShareRoomSessionIdToLocation(normalizedRoomSessionId, 'replace'); + setRequestedRoomSessionId(normalizedRoomSessionId); + } + + setExpandMode(restoreSnapshot.expandMode); + setLatestRequestId(restoreSnapshot.latestRequestId.trim()); + setIsSearchOpen(false); + setIsShareRoomListVisible(false); + + const applyScrollPosition = () => { + const scrollContainer = scrollContainerRef.current; + + if (!scrollContainer) { + return; + } + + const maxScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight); + const nextScrollTop = Math.min(Math.max(0, restoreSnapshot.scrollTop), maxScrollTop); + + scrollContainer.scrollTo({ + top: nextScrollTop, + behavior: 'auto', + }); + lastScrollTopRef.current = nextScrollTop; + queueScrollJumpVisibilitySync(); + }; + + window.requestAnimationFrame(() => { + applyScrollPosition(); + window.setTimeout(applyScrollPosition, 80); + }); + }, [normalizedToken, queueScrollJumpVisibilitySync, selectedShareRoomSessionId]); + const handleCloseProgram = useCallback(() => { + restoreProgramReturnSnapshot(programTarget?.restoreSnapshot); + setProgramTarget(null); + }, [programTarget?.restoreSnapshot, restoreProgramReturnSnapshot]); const canLaunchShareProgram = useCallback( (appId?: ShareProgramTarget['appId']) => { if (!appId) { @@ -7122,8 +8187,11 @@ export function ChatSharePage() { recordShareAppLaunch(target.appId); setProgramReloadKey(0); setMinimizedPrograms((current) => current.filter((item) => item.target.key !== target.key)); - setProgramTarget(target); - }, [canLaunchShareProgram, message, recordShareAppLaunch]); + setProgramTarget({ + ...target, + restoreSnapshot: target.restoreSnapshot ?? captureProgramRestoreSnapshot(), + }); + }, [canLaunchShareProgram, captureProgramRestoreSnapshot, message, recordShareAppLaunch]); const openAllowedPlayAppEnvironment = useCallback((entry: PlayAppEntry, environment: ShareAppEnvironment) => { if (!shareAllowedAppIdSet.has(entry.id)) { message.warning('이 공유 링크에서는 허용되지 않은 앱입니다.'); @@ -7147,11 +8215,15 @@ export function ChatSharePage() { setMinimizedPrograms((current) => { const existingIndex = current.findIndex((item) => item.target.key === programTarget.key); - const nextPosition = existingIndex >= 0 ? current[existingIndex].position : getStackedProgramMinimizedPosition(current.length); + const rememberedPosition = minimizedProgramPositionByKeyRef.current[programTarget.key]; + const nextPosition = existingIndex >= 0 + ? current[existingIndex].position + : rememberedPosition ?? getStackedProgramMinimizedPosition(current.length); const nextItem: ShareMinimizedProgramItem = { target: programTarget, position: nextPosition, }; + minimizedProgramPositionByKeyRef.current[programTarget.key] = nextPosition; if (existingIndex >= 0) { return current.map((item, index) => (index === existingIndex ? nextItem : item)); @@ -7160,7 +8232,8 @@ export function ChatSharePage() { return [...current, nextItem]; }); setProgramTarget(null); - }, [programTarget]); + restoreProgramReturnSnapshot(programTarget.restoreSnapshot); + }, [programTarget, restoreProgramReturnSnapshot]); const handleSearchResultSelect = useCallback((result: ShareSearchResult) => { if (result.appEntry) { openAllowedPlayAppEnvironment(result.appEntry, selectedAppEnvironment); @@ -7230,6 +8303,7 @@ export function ChatSharePage() { const embeddedPlayAppContent = shouldInlineProgramTarget && programTarget ? renderEmbeddedSharePlayApp(programTarget.appId, closeProgramTarget, normalizedToken) : null; + const isServerCommandDrawerOpen = programTarget?.appId === 'server-command'; const searchResults = useMemo(() => { const keyword = normalizeSearchKeyword(searchKeyword); const results: ShareSearchResult[] = []; @@ -8173,90 +9247,8 @@ export function ChatSharePage() { ) : (
- {canToggleShareRoomList && isShareRoomListVisible ? ( -
- { - setShareRoomFilterKeyword(event.target.value); - }} - className="chat-share-page__room-filter-input" - placeholder="채팅방 필터" - prefix={} - aria-label="공유채팅 채팅방 필터" - /> -
-
- 채팅방 - - {activeShareRoom ? `${activeShareRoom.title} 사용 중` : '공유 토큰에 연결된 방 목록'} - -
- {canCreateSharedRooms ? ( - - ) : null} -
-
- {filteredShareRooms.map((room) => { - const isActive = room.sessionId === selectedShareRoomSessionId; - const canDeleteRoom = canDeleteShareRoom(room, shareRooms); - const isDeletingTarget = isDeletingRoom && pendingDeleteRoomSessionId === room.sessionId; - - return ( -
- {canDeleteRoom ? ( - - ) : null} - -
- ); - })} - {filteredShareRooms.length === 0 ? ( -
- 조건에 맞는 채팅방이 없습니다. -
- ) : null} -
-
- ) : null}
-
+
채팅 @@ -8274,6 +9266,7 @@ export function ChatSharePage() {
{canToggleShareRoomList ? (
- {isRoomSwitching ? ( + {showRoomSwitchingSkeleton ? (
채팅방 내용을 불러오는 중입니다.
) : ( -
+
+ {showRoomSwitchingOverlay ? ( +
+ + {`${roomSwitchingStatusLabel} 불러오는 중`} +
+ ) : null} {headerInquiryRequest ? (
@@ -8468,12 +9470,19 @@ export function ChatSharePage() { {canSendMessage ? ( <> - {!isRoomSwitching && expandMode === 'latest' && collapsedActivitySummary.length > 0 ? ( + {expandMode === 'latest' && collapsedActivitySummary.length > 0 ? (
+ {showRoomSwitchingOverlay ? ( +
+ + 새 방 상태 반영 중 +
+ ) : null}
현재 진행 상황 @@ -8487,12 +9496,21 @@ export function ChatSharePage() {
) : null}
- {isRoomSwitching ? ( + {showRoomSwitchingSkeleton ? (
) : ( -
+
+ {showRoomSwitchingOverlay ? ( +
+ + 전환이 끝나면 입력할 수 있습니다. +
+ ) : null}
+
+ setSourceGroupDetailKey('')} + > + {sourceGroupDetail ? ( +
+
+ {sourceGroupDetail.title} + {sourceGroupDetail.chatTypeLabel || '연결된 원 세션'} + + {sourceGroupDetail.requestPreview || '등록된 원 요청 미리보기가 없습니다.'} + +
+ + {sourceGroupDetail.rooms.some((room) => room.sessionId === selectedShareRoomSessionId) && lastResponseMessage?.text?.trim() ? ( + + ) : null} +
+
+
+ {sourceGroupDetail.rooms.map((room) => ( + + ))} +
+
+ ) : null} +
+ { + void handleSubmitOriginReply(); + }} + onCancel={() => { + if (isSubmittingOriginReply) { + return; + } + setIsOriginReplyModalOpen(false); + setOriginReplyTargetGroupKey(''); + }} + > +
+ + {originReplyTargetGroup?.title || '선택된 원 세션'} 기준으로 현재 작업 답변을 전달합니다. + + setOriginReplyDraftText(event.target.value)} + /> +
+
+ } + onClick={handleReloadProgram} + /> + )} + onClose={closeProgramTarget} + > + {isServerCommandDrawerOpen ? ( +
+ +
+ ) : null} +
- ) : programTarget.appId === 'server-command' ? ( -
- -
) : (
+ { + setShareRoomFilterKeyword(event.target.value); + }} + className="chat-share-page__room-filter-input" + placeholder="채팅방 필터" + prefix={} + aria-label="공유채팅 채팅방 필터" + /> +
+
+ 채팅방 + + {activeShareRoom ? `${activeShareRoom.title} 사용 중` : '공유 토큰에 연결된 방 목록'} + +
+ {canCreateSharedRooms ? ( + + ) : null} +
+
+ {filteredShareRoomGroups.map((group) => ( +
+ {group.linkContext ? ( +
+
+ {group.title} + + {group.chatTypeLabel || '연결된 원 세션'} + + {group.requestPreview ? ( + {group.requestPreview} + ) : null} +
+
+ + + {group.rooms.some((room) => room.sessionId === selectedShareRoomSessionId) && lastResponseMessage?.text?.trim() ? ( + + ) : null} +
+
+ ) : null} +
+ {group.rooms.map((room) => { + const isActive = room.sessionId === selectedShareRoomSessionId; + const canDeleteRoom = canDeleteShareRoom(room, shareRooms); + const isDeletingTarget = isDeletingRoom && pendingDeleteRoomSessionId === room.sessionId; + const pendingCounts = shareRoomPendingCountsBySessionId[room.sessionId] ?? null; + + return ( +
+ {canDeleteRoom ? ( + + ) : null} + +
+ ); + })} +
+
+ ))} + {filteredShareRoomGroups.length === 0 ? ( +
+ 조건에 맞는 채팅방이 없습니다. +
+ ) : null} +
+
, + document.body, + ) + : null} ); } diff --git a/src/app/main/systemChatStyles/MainChatPanel.conversation.css b/src/app/main/systemChatStyles/MainChatPanel.conversation.css index 66aa796..ccfa925 100644 --- a/src/app/main/systemChatStyles/MainChatPanel.conversation.css +++ b/src/app/main/systemChatStyles/MainChatPanel.conversation.css @@ -1835,12 +1835,43 @@ flex-direction: column; gap: 6px; padding: 0 8px 8px; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: auto; + overscroll-behavior: contain; } .app-chat-preview-card__body--prompt-collapsed { display: none; } +.app-chat-preview-card--prompt, +.app-chat-preview-card--structured { + width: min(100%, 720px); + min-width: 0; + max-width: 100%; +} + +.app-chat-preview-card__body--structured { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 8px 8px; + width: 100%; + min-width: 0; + max-width: 100%; + overflow: auto; + overscroll-behavior: contain; +} + +.app-chat-preview-card__description.ant-typography { + margin: 0; + color: #334155; + white-space: pre-wrap; + word-break: break-word; +} + .app-chat-prompt-card__description.ant-typography { margin: 0; font-size: 12px; @@ -2028,6 +2059,8 @@ .app-chat-prompt-card__option-preview-inline { margin-top: 6px; + min-width: 0; + max-width: 100%; } @media (max-width: 640px) { @@ -2043,10 +2076,24 @@ .app-chat-prompt-card__preview-shell { display: flex; flex-direction: column; + width: 100%; + min-width: 0; + max-width: 100%; overflow: hidden; border: 1px solid rgba(148, 163, 184, 0.2); border-radius: 10px; background: rgba(241, 245, 249, 0.8); + box-sizing: border-box; +} + +.app-chat-prompt-card__preview-body { + width: 100%; + min-width: 0; + max-width: 100%; + max-height: min(420px, 70vh); + overflow: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; } .app-chat-prompt-card__stepper { @@ -2287,6 +2334,18 @@ word-break: break-word; } +.app-chat-prompt-card__context, +.app-chat-prompt-card__description.ant-typography, +.app-chat-prompt-card__result.ant-typography, +.app-chat-prompt-card__stepper, +.app-chat-prompt-card__step-panel, +.app-chat-prompt-card__options, +.app-chat-prompt-card__free-text, +.app-chat-prompt-card__footer { + min-width: 0; + max-width: 100%; +} + .app-chat-prompt-card__submitted { display: inline-flex; align-items: center; diff --git a/src/features/serverCommand/ServerCommandPage.tsx b/src/features/serverCommand/ServerCommandPage.tsx index a8557e9..472b056 100644 --- a/src/features/serverCommand/ServerCommandPage.tsx +++ b/src/features/serverCommand/ServerCommandPage.tsx @@ -268,7 +268,7 @@ function resolveWorkServerControlStatus( return { statusTone: 'online', - statusLabel: '배포 가능', + statusLabel: '최신 실행 중', }; }