23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
DEVOCEAN 블로그 글을 끝까지 내리면 맨 아래에 "DEVOTEE 추천 블로그" 라는 자리가 있습니다.
지금 읽는 글과 비슷한 주제의 글 4건을 슬쩍 띄워주는 곳인데, 언젠가부터 이 칸이 텅 비어 있었습니다.
이유는 허무할 만큼 단순했습니다. 추천을 만들어내던 벡터DB를 비용 때문에 걷어냈기 때문입니다.
벡터DB 인스턴스는 아무도 안 쓰는 새벽에도 24시간 떠 있어야 하고, 그 상시 비용이 이 기능 하나가 주는 가치보다 컸습니다.
그래서 목표를 이렇게 잡았습니다.
기능은 되살리되, 상시 인프라는 다시 세우지 않는다.
본 블로그 글은 그 과정을 담은 개발기입니다.
그리고 뒤에는 같은 Gemini 스택을 운영하다 오늘 실제로 터진 사고 2건도 함께 붙였습니다.
이론과 실전을 한 세트로 보시라고요. 🙂
겁먹고 코드를 열었는데, 상황이 생각보다 훨씬 나았습니다.
추천을 읽어서 화면에 뿌리는 경로는 멀쩡히 살아 있었고, 값을 채워 넣던 파이프라인만 사라진 상태였습니다.
추천 결과는 tb_board.RECOMMEND_BLOG_ID 컬럼에 이렇게 저장되는 구조였습니다.
RECOMMEND_BLOG_ID = "165214,166888,167060"id들을 콤마로 이어붙인 CSV 문자열 하나입니다. 화면은 이걸 잘라서 게시글을 조회할 뿐이고요. 즉:
이 컬럼만 다시 채우면 화면은 그대로 복구됩니다.
프런트도, 조회 로직도 손댈 필요가 없습니다. 롤백은 "컬럼 비우기"로 끝나고요.
작업 범위가 "추천 기능 재구축"에서 "컬럼을 채우는 배치 하나"로 확 줄어드는 순간이었습니다.
이게 이번 작업에서 가장 값진 판단이었어요. 큰일처럼 보이던 게 사실은 작은 일이었던 겁니다.
되살리는 김에 전제부터 다시 따졌습니다. 정말 벡터DB라는 무거운 물건이 있어야 하는 규모인지.
항목 | 값 |
|---|---|
대상 | 외부공개 + 요약 보유 블로그 1,291건 |
벡터 저장 | 1,291 × 1024차원 × 4byte ≈ 약 5MB |
유사도 계산 | 1,291² ≈ 167만 쌍 → 단일 프로세스로 수 초 |
임베딩 비용 | 전 건 1회 재색인 수십 원 수준 |
5MB입니다. MySQL MEDIUMBLOB 컬럼 하나면 남고요. 167만 번의 내적은 노트북에서도 몇 초면 끝납니다.
HNSW 같은 ANN 인덱스는 수십만 건은 돼야 의미가 생기는 물건이라, 이 규모에서는 오히려 전수 비교가 더 빠르고 더 정확합니다.
애초에 돈이 새던 지점은 임베딩 생성이 아니라 벡터DB를 24시간 켜두는 것이었습니다.
임베딩은 하루에 한 번, 신규 글 몇 건만 만들면 되니까요.
그래서 구조를 이렇게 못 박았습니다.
벡터는 MySQL 테이블에 float32 배열로 저장 (tb_ext_blog_embedding)
유사도는 배치가 메모리에서 전수 계산
상시 떠 있는 컴포넌트는 없음
기존 파이프라인이 남긴 교훈이 하나 있었습니다.
본문 전체가 아니라 요약(SUMMARY)만으로 비교했을 때 추천 품질이 더 좋았다는 것.
직관과 반대 같지만, 뜯어보면 납득이 됩니다.
요약은 짧고 정제돼 있어 주제 신호 대비 노이즈가 적습니다.
본문에는 코드 블록, 설치 로그, 목차, 참고 링크처럼 주제와 무관한 텍스트가 잔뜩입니다.
임베딩은 텍스트가 길수록 여러 주제가 뭉개져 평균으로 수렴합니다.
대신 트레이드오프가 하나. 요약이 없는 글은 추천 대상에서도, 피추천 대상에서도 빠집니다. 이건 감수하기로 했습니다.
임베딩 provider는 네 가지를 검토했고, 네 가지 전부 구현해뒀습니다.
저장 포맷과 선정 규칙을 provider와 무관하게 통일해서, 환경변수 하나로 갈아탈 수 있게요.
Gemini | Azure OpenAI | OpenAI | Bedrock Titan | |
|---|---|---|---|---|
인증 |
|
|
| SigV4 서명 |
데이터 경계 | 외부 SaaS | 사내 테넌트 | 외부 SaaS | 사내 AWS |
다건 입력 |
|
|
| 미지원(1건씩) |
응답 순서 | index 없음 |
|
| 단건 |
용도 지정 |
| 없음 | 없음 | 없음 |
최종 선택은 Gemini. 이유는 둘입니다.
첫째, 스택 통일. DEVOCEAN은 이미 블로그 요약과 댓글 생성(DEVOTEE)에 Gemini를 쓰고 있었습니다.
임베딩도 같은 provider로 가면 키·쿼터·청구·모니터링이 한곳에 묶입니다.
무엇보다 같은 성격의 데이터(블로그 텍스트)를 이미 같은 경로로 보내고 있어서, 데이터 반출 검토를 새로 할 필요가 없었습니다.
실무에선 이게 제일 큽니다.
💡 이 "스택 통일" 이 뒤에 나올 오늘의 사고 1번과 정확히 연결됩니다.
같은 Gemini를 쓰는 DEVOTEE 댓글이 오늘 갑자기 입을 다물었거든요. 잠깐만 기다려 주세요.
둘째, taskType. Gemini 임베딩에는 벡터의 용도를 지정하는 옵션이 있습니다.
taskType: SEMANTIC_SIMILARITY // 문서 간 유사도
taskType: RETRIEVAL_DOCUMENT // 검색 색인용유사글 추천은 정확히 전자입니다.
"검색용 벡터"와 "유사도 비교용 벡터"는 실제로 다르게 나오고, 우리에겐 후자가 필요했습니다.
OpenAI·Azure·Bedrock엔 이 옵션이 없습니다.
batchEmbedContents는 100건을 한 번에 보낼 수 있는데, 응답에 요청 인덱스가 없습니다.
요청 순서대로 돌려준다고 "믿어야" 합니다.
만약 응답 개수가 하나라도 어긋나면 그 뒤로 모든 글에 남의 벡터가 붙습니다.
조용히 엉뚱한 추천이 생기는, 가장 악질적인 버그죠. 그래서 개수 검증을 유일한 방어선으로 두고, 어긋나면 그 배치를 통째로 실패 처리했습니다.
// Gemini 임베딩 응답을 요청 순서에 맞춰 매핑합니다.
// 개수가 하나라도 어긋나면 즉시 실패시켜 '남의 벡터 붙기'를 원천 차단합니다.
export function mapGeminiEmbeddings(parsed, expectedCount) {
const list = parsed?.embeddings;
if (!Array.isArray(list) || list.length !== expectedCount) {
throw new Error(`임베딩 개수 불일치: ${list?.length} != ${expectedCount}`);
}
return list.map((e) => {
if (!Array.isArray(e?.values)) throw new Error("values 누락");
return e.values;
});
}전체 흐름은 이렇습니다.
설계에서 특히 신경 쓴 세 가지입니다.
① 증분 임베딩 — 매일 전 건을 다시 부르지 않습니다.
요약 원문의 SHA-256을 함께 저장해두고, 다음 실행에서 해시가 같으면 API를 아예 호출하지 않습니다.
모델이나 taskType이 바뀌면 캐시 키(gemini:<모델>:<taskType>)가 달라져 자동으로 전 건 재색인 대상이 됩니다.
덕분에 평상시 실행은 신규 글 몇 건만 임베딩하고 수 초 만에 끝납니다.
② 배치마다 즉시 저장 — 중단돼도 잃지 않습니다.
처음엔 전부 끝난 뒤 한 번에 저장했는데, 전 건 색인이 10분 넘게 걸리다 중간에 끊기면 그때까지 만든 벡터가 전부 증발했습니다.
쿼터 태워 만든 결과가 날아가는 건 진짜 뼈아파요. 그래서 100건 배치가 끝날 때마다 바로 저장하게 바꿨습니다.
이 덕에 "쿼터 회복될 때마다 나눠서 색인"도 가능해졌고요.
③ 하한 미만은 그냥 비워둡니다 — 4칸을 억지로 채우지 않습니다.
유사한 글이 2건뿐이면 2건만 보여줍니다. 관련 없는 글로 자리를 메우면 추천 섹션 전체의 신뢰가 무너지니까요.
처음엔 코사인 하한을 0.5로 잡고 돌렸습니다.
결과를 보니 거의 모든 글쌍이 0.75를 넘더군요. 0.5는 아무것도 못 거르는 값이었습니다.
임베딩 코사인 점수는 모델·taskType·차원에 따라 척도가 완전히 달라집니다. "보통 0.7 넘으면 비슷한 거지" 같은 감은 안 통합니다.
그래서 실제 데이터로 분포를 찍고, 하한을 바꿔가며 눈으로 검토했습니다.
하한 | 결과 |
|---|---|
0.5 | 전부 통과 — 무의미 |
0.80 | 관련 없는 글이 4번째 칸에 자주 붙음 |
0.85 | 주제가 확실히 겹치는 글만 남음 — 채택 ✅ |
0.87+ | 적절한 추천까지 잘려나감 |
0.85로 정한 뒤 실제 운영 데이터에서 나온 예시입니다.
— [Spring-API First Develop 연재-4] OpenAPI Spec…
0.863 [Spring-API First Develop 연재-5] FeignClient…
— AWS에서 Keycloak 기반 서비스 인증 체계 구축하기 (1편)
0.886 Keycloak 활용한 SSO 구현 : #1 eksctl로 쉽게 시작하는 K8s
0.863 Keycloak 활용한 SSO 구현 : #4 SSO 연동 샘플 애플리케이션
— 5G DNA(Digital Network Automation) 기술 개발
0.887 5G MEC 기반 데이터 트래픽 성능 최적화 방안 연구
0.878 5G Green AI Algorithm 개발연재물의 다음 편을 정확히 찾아냈고, 같은 시리즈끼리 잘 묶였습니다.
이걸 정할 수 있었던 건 --dry-run이 점수와 제목을 함께 찍어주도록 만들어뒀기 때문입니다.
숫자만 봐서는 0.86과 0.84 중 어디를 잘라야 할지 모릅니다. 사람이 제목을 보고 판단할 수 있어야 튜닝이 됩니다.
여기서 자연스러운 반문이 나옵니다.
"관리자가 글을 공개하는 순간, 그 글만 임베딩하면 되지 않아요?"
절반은 맞습니다. 하지만 비대칭이 생깁니다. 새 글 A의 추천은 그 자리에서 계산할 수 있어요.
그런데 기존 글 B의 RECOMMEND_BLOG_ID는 이미 저장돼 있어서, A가 B의 4위보다 유사해도 B를 다시 계산하지 않으면 반영되지 않습니다.
결과적으로 새 글일수록 남에게 추천되지 않고, 추천 노출이 오래된 글에만 고입니다.
역방향까지 갱신하려면 결국 전수 재계산이 필요한데, 그건 배치가 이미 하는 일이죠.
게다가 훅은 위험합니다.
src_hash 계산식·모델명·차원·taskType이 양쪽에서 정확히 일치해야 합니다.
훅은 한 번 실패하면 그 글이 영원히 빕니다. 배치는 멱등이라 다음 회차에 자동 복구되고요.
그래서 배치 하나만 두기로 했습니다. 추천에 실시간성 요구가 없다는 점도 컸어요. 공개 후 최대 하루 지연은 아무도 눈치 못 챕니다.
# stg 호스트에서 매일 17:30, flock 으로 중복 실행만 막고 하루 한 번.
30 17 * * * NODE_BIN=/usr/local/bin/node ~/devocean-recommend/blog-recommend.sh참고로 무료 등급 Gemini의 병목은 TPM(토큰)이 아니라 RPM(분당 요청) 이었습니다.
그리고 함정 하나 —
batchEmbedContents에 100건을 넣으면 요청 1건이 아니라 100건으로 카운트됩니다.배치를 키워도 RPM은 그대로 닳아요.
게다가 일일 한도(RPD) 리셋은 태평양시 자정(한국시간 16~17시) 기준이라, "아침에 돌렸는데 왜 아직 429지?" 로 시간 버리기 딱 좋습니다.
운영 첫 실행에서 추천이 통째로 갱신되지 않았습니다. 로그는 이랬어요.
[recommend] 임베딩 생성 대상 391건 (캐시 재사용 900건)
[recommend] gemini batchEmbedContents[0] 요청 한도(429) — 45초 대기 후 재시도 1/3
...
[recommend] 실패 : 임베딩 API 전면 실패(391건) — 기존 RECOMMEND_BLOG_ID 유지하고 중단원인은 제가 넣어둔 안전장치였습니다.
// 의도: API가 죽었을 때 쓰레기 값으로 기존 추천을 덮지 말자.
// 현실: stale = '이번 회차에 임베딩할 글'. 캐시 900건이 멀쩡한데도 여기서 멈춰버렸다.
if (failed === stale.length) {
throw new Error("임베딩 API 전면 실패");
}의도는 "API가 죽었을 때 기존 추천을 쓰레기로 덮지 말자"였습니다.
그런데 stale은 이번 회차에 새로 임베딩할 글입니다.
1,291건 중 900건이 이미 캐시에 있고 남은 391건이 쿼터로 실패한 상황에서, 이 조건이 참이 됩니다.
계산에 쓸 벡터 900건이 멀쩡히 있는데, 아무것도 안 하고 종료한 겁니다.
판단 기준이 틀렸어요. "이번에 새로 임베딩했는가"가 아니라 "계산에 쓸 코퍼스가 충분한가" 여야 했습니다.
// 고친 판단: 새로 임베딩했는지가 아니라, '계산에 쓸 코퍼스가 충분한지'로 결정합니다.
export function decideCorpus(cached, total, minCoverage = MIN_CORPUS_COVERAGE) {
const coverage = total > 0 ? cached / total : 0;
return {
proceed: coverage >= minCoverage,
coverage,
reason: `캐시 보유 ${cached}/${total}건(${(coverage * 100).toFixed(0)}%)`,
};
}더 오싹한 건, 이 버그가 전량 색인이 끝난 뒤에도 살아 있었다는 점입니다.
평상시 신규 글 3건이 일시적 장애로 실패하면 그것도 "stale 전건 실패"입니다.
1,291건 캐시를 두고도 그날 갱신을 통째로 건너뛰었겠죠. 조용히, 매일.
교훈: 방어 코드의 조건은 의도가 아니라 실제 변수의 의미로 검증해야 합니다.
failed === stale.length는 "API 전면 장애"처럼 읽히지만, 실제로는 "이번에 새로 시도한 게 전부 실패"일 뿐이었습니다.회귀 테스트를 추가해 못을 박았습니다.
여기까지가 "잘 설계한 이야기"였다면, 이제부터는 같은 스택을 실제로 운영하다 오늘 터진 날것의 사고입니다. 로컬 환경에서 서비스를 다시 띄우며 만난 두 장면이에요.
DEVOTEE는 글을 쓰면 AI가 답글을 달아주는 마스코트입니다. 그런데 로컬에서 데보티 댓글을 켰는데 아무것도 안 남았습니다. 글은 등록되는데 답글만 조용했죠.
로그를 열자 범인이 바로 튀어나왔습니다.
{
"code": 403,
"message": "Method doesn't allow unregistered callers (callers without established identity). Please use API Key or other form of API consumer identity to call this API."
}Gemini가 키 없이 호출돼 403으로 거부한 겁니다.
확인해 보니 로컬 설정 파일의 키가 비어 있었어요. 값은 안 찍고 길이만 확인하면 이렇게 나옵니다.
# 값은 노출하지 않고 '길이'만 확인합니다.
$ grep '^gemini.api.key=' local_eGov.properties | sed 's/^gemini.api.key=//' | wc -c
0 # ← 로컬: 빈 값 (dev/live 는 39자)로컬은 -Denvtype=local 로 떠서 local_eGov.properties를 읽는데, 거기 gemini.api.key가 0자였던 것. dev/live엔 39자짜리 실제 키가 있고요.
4장에서 "Gemini로 스택을 통일했다"고 했죠? 그 통일된 스택이 키 하나 비니까 임베딩도 댓글도 같이 조용해지는 구조인 겁니다. 통일의 빛과 그림자예요.
키를 채우고 재기동하니 로그가 바로 이렇게 바뀌었습니다.
aiServiceImpl.getResultFromAiOne === gemini generateContent
resultJson: 데보티님! 🤩 AI랑 클라우드라니, 완전 제가 요즘 푹 빠져있는 주제잖아요! …
----------success updateCommunityGptReply --------여기서 소소한 반전 하나. 데보티 답글은 일반 '댓글'이 아니라 글의 GPT_REPLY_CONTENTS 필드에 저장됩니다.
그래서 처음엔 "댓글 수 0" 만 보고 실패인 줄 알았는데, 사실 답글은 다른 칸에 정상 생성돼 있었어요.
증상을 볼 때 어디에 남는지부터 맞춰야 한다는, 흔하지만 매번 걸리는 교훈입니다.
정리: 통합 스택은 관측·비용·검토를 한곳에 묶어주는 대신, 단일 실패점(키 하나) 도 같이 만듭니다. 로컬처럼 키가 비기 쉬운 환경에선 "왜 조용하지?"의 1순위 용의자로 API 키를 의심하세요.
같은 날, 운영에 반영된 코드를 로컬 작업 브랜치로 머지했습니다.
git은 "충돌 0건, 깨끗하게 머지됨" 이라고 자신 있게 말했고요. 그런데 빌드를 돌리자 컴파일 에러 200개가 쏟아졌습니다. 🫠
대부분은 cannot find symbol 이라 처음엔 어리둥절했는데, 맨 위에 진짜 원인이 있었습니다.
ExternalMainController.java:[716] variable SESSION_KEY_EXTERNAL_USER_INX is already defined
ExternalMainController.java:[718] method resolveUserParam(...) is already defined중복 정의(already defined) 였습니다.
성능 최적화로 넣었던 세션 캐싱 헬퍼(resolveUserParam)를 작업 브랜치와 운영 쪽이 각자 다른 위치에 추가했는데,
위치가 달라서 git이 텍스트 충돌로 인식하지 못하고 둘 다 남겨버린 겁니다.
클래스에 같은 메서드가 둘이 되니 컴파일러가 멈췄고, 그 여파로 무관한 파일 196곳까지 cannot find symbol 로 줄줄이 무너진 것이었어요.
이걸 semantic conflict(의미 충돌) 라고 부릅니다. git의 3-way 머지는 줄 단위 텍스트만 봅니다. "같은 함수가 두 번 정의됐다" 같은 의미는 못 봐요.
해결은 의외로 단순했습니다. 두 사본 중 로그아웃 시 캐시를 지워주는(더 안전한) 버전만 남기고 나머지 중복 블록을 지웠더니, 200개 에러가 한 번에 사라졌습니다.
# 중복이 진짜 사라졌는지 개수로 확인 (1이어야 정상)
$ grep -c 'SESSION_KEY_EXTERNAL_USER_INX *=' ExternalMainController.java
1
$ mvn -q clean compile # → BUILD SUCCESS교훈: "git이 충돌 없다고 했다 = 코드가 맞다" 가 아닙니다.
같은 헬퍼가 양쪽에서 각자 진화하는 레거시↔신규 혼재 환경에선, 머지 후
mvn compile한 번이 가장 싼 보험입니다.그리고 이 중복은 사실 운영 master 쪽에도 그대로 있었어서, 같은 수정을 cherry-pick으로 함께 반영해 뒀습니다.
상시 인프라 | 없음 (벡터DB 제거 상태 유지) |
저장 | MySQL 테이블 1개, 약 5MB |
평상시 실행 시간 | 수 초 (신규 글만 임베딩) |
운영 비용 | 무료 등급 한도 내 |
코드 | 배치 1개 + 테스트 스위트(회귀 포함) + cron 래퍼 |
첫 색인에서 추천이 붙은 글은 전체의 약 **70%**였습니다.
나머지는 하한 0.85를 넘는 유사글이 없어 일부러 비워뒀습니다. 억지로 채우지 않는 게 이 기능의 신뢰를 지키는 방법이라고 봤어요.
남은 과제
품질 측정 장치가 없습니다. 추천 클릭이 어디에도 안 남아서 "좋아졌다"를 숫자로 못 말합니다. 클릭 로깅이 다음 과제예요.
하한 0.85는 현재 모델·taskType·1024차원 기준입니다. 셋 중 하나만 바꿔도 척도가 달라지니 반드시 다시 측정해야 합니다.
이번 작업에서 가장 값진 판단은 코드가 아니라 "어디까지가 진짜 필요한가"를 다시 따진 것이었습니다.
없어진 건 전부가 아니라 쓰기 경로뿐이었고,
1,291건에 벡터DB는 과했고,
실시간 훅보다 하루 한 번 배치가 오히려 더 견고했습니다.
그리고 오늘의 사고 2건이 덤으로 알려준 건 이겁니다.
잘 설계한 시스템도 결국 "키 하나", "중복 한 줄" 같은 사소한 데서 조용히 멈춘다는 것.
그래서 저는 오늘도 값을 찍기 전에 길이부터 재고, 머지한 뒤엔 컴파일부터 돌립니다. 🙂
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.