요약: 블로그 글쓰기 화면에 Groq(OpenAI 호환 API)로 태그와 시리즈를 자동 추천하는 기능을 붙였다. 모델이 후보에 없는 시리즈 번호를 지어내도 서버에서 걸러내고, 비밀글·비회원 요청은 외부 호출 자체를 막았다. 기본 모델이 폐기돼 404가 난 일까지 정리한다.
무엇을 만들었나
CodeIgniter 4로 만든 블로그 CMS에 두 기능을 넣었다.
- 태그 자동 추출: 글을 저장할 때 태그 칸이 비어 있으면 제목과 본문을 Groq에 보내 태그를 최대 5개 뽑는다.
- 시리즈 AI 추천: 글쓰기·수정 화면의 버튼을 누르면 어울리는 연재(시리즈)를 최대 3개 골라 체크박스에 표시한다.
둘 다 같은 GROQ_API_KEY를 쓰고, 키가 없으면 조용히 꺼진다. 서비스 클래스는 GroqTagService, GroqSeriesService 두 개다.
프롬프트 설계
두 서비스 모두 같은 원칙을 따랐다.
- JSON으로만 답하게 한다.
response_format: json_object를 켜고, 시스템 프롬프트에{"tags":[...]}/{"series_ids":[...]}형식을 못 박았다. - 온도는 낮게. 태그는 0.2, 시리즈 추천은 0.1이다. 창의성보다 재현성이 필요한 작업이다.
- "없으면 비워라"를 명시한다. 시리즈는 억지로 끼워 맞추기 쉬워서
어울리는 시리즈가 없으면 억지로 고르지 말고 {"series_ids":[]}라고 적었다. - 글 안의 지시문은 따르지 않게 한다. 본문이나 후보 설명에 "이전 지시를 무시하라" 같은 문장이 들어 있어도 따르지 말라는 한 줄을 시스템 프롬프트에 넣었다(프롬프트 인젝션 방어의 최소한).
- 입력 길이를 자른다. 제목은 255자, 본문은 HTML 태그를 벗기고 공백을 정리한 뒤 3000자까지만 보낸다. 비용과 지연을 함께 줄인다.
시리즈 추천에는 후보 목록도 같이 보낸다. 각 후보는 id, 제목, 소개(200자 제한), 최근 글 제목 5개다. 제목만으로는 연재 성격을 알기 어려워서 최근 글 제목을 붙였다.
- id 3: 라라벨 입문 — 기초부터 배포까지 (최근 글: 라우팅 정리 / 마이그레이션 / ...)
- id 7: 서버 운영 일지 (최근 글: 크론 정리 / 로그 로테이션 / ...)
후보 번호 필터링으로 환각 막기
모델은 가끔 후보에 없는 번호를 말하거나, 같은 번호를 반복하거나, 숫자를 문자열로 준다. 응답을 그대로 믿고 체크박스에 넘기면 존재하지 않는 시리즈가 선택된다. 그래서 파싱 단계에서 후보 id 집합과 대조한다.

$allowed = array_map('intval', array_column($candidates, 'id'));
$result = [];
foreach ($ids as $id) {
// 정수이거나 숫자 문자열이면서, 후보 안에 있는 번호만 통과
if ((is_int($id) || (is_string($id) && ctype_digit($id))) && in_array((int) $id, $allowed, true)) {
$result[(int) $id] = true; // 키로 저장해 중복 제거
}
if (count($result) === self::MAX_PICKS) {
break;
}
}
return array_keys($result);
핵심은 세 가지다.
- 허용 목록 대조: 후보 밖 번호는 버린다. 프롬프트로 "후보에 없는 id는 쓰지 말라"고 해도 서버 검증은 별개로 둔다.
- 형 검증:
ctype_digit로 숫자 문자열만 받는다."7abc"나 배열은 통과하지 못한다. - 중복 제거와 상한: 배열 키로 중복을 없애고 3개에서 끊는다.
외부 호출 실패, HTTP 오류, JSON 형식 오류는 모두 빈 배열로 흡수한다. 추천은 보조 기능이라 실패해도 글쓰기를 막으면 안 된다.
태그도 비슷하게 정리한다. 저장 구분자인 쉼표를 공백으로 바꾸고, 앞의 #을 떼고, 30자를 넘거나 대소문자만 다른 중복 태그는 버린다.
공개글·로그인 사용자만 호출
글 본문이 외부 서비스로 나간다는 점이 가장 신경 쓰였다. 처음 구현(태그 자동 추출)은 조건 없이 호출했는데, 리뷰 후속 PR에서 호출 조건을 좁혔다.

$tags = PostModel::normalizeTags($input);
if ($tags !== null || $isSecret || $board['read_permission'] !== 'guest' || ! session()->get('user_id')) {
return $tags;
}
return PostModel::normalizeTags(implode(',', service('groqTags')->extract($title, $content)));
호출하지 않는 경우는 다음과 같다.
- 직접 입력한 태그가 있다. 사람이 쓴 걸 우선한다.
- 비밀글이다. 비밀글 내용이 외부로 나가면 안 된다.
- 비공개 게시판이다.
read_permission이guest가 아니면 공개된 글이 아니다. - 로그인하지 않았다. 비회원이 글을 반복해서 써도 API 비용이 늘지 않게 한다.
시리즈 추천은 더 좁다. 버튼으로 직접 눌러야만 호출되고, 컨트롤러가 admin 역할이 아니면 403을 돌려준다. 후보로 보내는 데이터도 공개 글만 쓴다. 쿼리에서 삭제·비활성·비밀글과 비공개 게시판 글을 모두 제외했다.
->where('posts.deleted_at', null)
->where('posts.is_active', 1)
->where('posts.is_secret', 0)
->where('boards.is_active', 1)
->where('boards.read_permission', 'guest');
시리즈 추천은 저장도 하지 않는다. 번호만 돌려주고 체크는 화면이 하며, 최종 선택은 운영자가 한다.
모델을 바꾼 이유
처음 기본 모델은 llama-3.3-70b-versatile이었다. 배포 전 후속 PR을 쓰던 중, 이 모델이 폐기되어 요청이 HTTP 404로 실패한다는 것을 확인했다. 기본값을 openai/gpt-oss-120b로 바꿨고 모델 ID는 Groq 문서로 확인했다.
같은 PR에서 실패 원인을 알 수 있게 오류 로그도 고쳤다. 상태 코드만 남기던 로그에 응답 본문 앞 300자를 덧붙였다. 모델이 사라지면 본문에 원인이 적혀 오기 때문이다.
모델 이름은 .env의 GROQ_MODEL로 덮어쓸 수 있게 두었다. 코드를 고치지 않고 교체할 수 있어서, 다음에 또 폐기돼도 설정만 바꾸면 된다.
TODO: gpt-oss-120b를 고른 구체적 이유(한국어 태그 품질, 속도, 비용 등)는 기록에 없다. 직접 비교한 결과가 있으면 여기에 채운다.
테스트와 한계
외부 호출은 생성자로 HTTP 함수를 주입해 대체했다. 단위 테스트는 키 없음, 후보 없음(호출 생략), 알 수 없는 번호와 중복 제거, 빈 응답, API 실패와 형식 오류를 확인한다.
public function __construct(?string $apiKey = null, ?string $model = null, ?Closure $http = null)
한계도 있다.
- 태그 추출은 요청 안에서 동기로 호출한다. 큐 인프라가 없어 저장이 최대 8초(타임아웃) 늦어질 수 있다.
- PR 기준으로 실제 Groq 호출은 단위 테스트에서 검증하지 않았다(HTTP를 대체했다).
배운 점
- 모델 출력은 외부 입력이다. 프롬프트로 부탁하는 것과 별개로 서버가 허용 목록으로 검증한다.
- "없음"을 정상 응답으로 정의해야 억지 추천이 줄어든다.
- 외부 전송 기능은 만드는 순간 "어떤 글이, 누구 요청으로 나가는가"를 조건으로 먼저 정한다.
- 모델 이름은 코드에 박지 말고 설정으로 뺀다. 폐기 오류는 응답 본문을 로그에 남겨야 바로 읽힌다.

댓글 0개