문서 읽는 데 95분 · day07-5

Day 7.5. ai-friends prod 결합 — 5트랙 캐릭터 만들기 + 챗 셀카 요청 (Day 7 부록)

전체 26강 중 8강 · 스프링 AI
난이도 · 심화선수지식자바 기초스프링 부트

ℹ️스프링 부트로 만든 ai-friends 프로젝트 위에 얹어 진행해요. 자바·스프링이 처음이라면 먼저 “서버 만들기” 트랙부터 권해요.

이 문서는 Day 7 (ImageModel 이미지 생성) 의 부록 (Day 7.5) 입니다. Day 7 본 교안의 Step 1~7 에서 학습용 lab (ImageGenerationController POST /api/images/portraits) 까지 닫고 호흡 한 박자 끊었어요. 이번 부록은 그 lab 이 ai-friends 본 게임의 prod 흐름에 어떻게 흡수되는지 두 군데 — (a) 캐릭터 만들기 5트랙 외모 선택 (Step 8) + (b) 챗 셀카 요청 (Step 9) 을 손으로 깔러 갑니다. 약 60~80분 분량.

🎯 이 부록의 학습 목표

  • 학습용 lab → ai-friends prod 흡수 두 군데 (SoulmateService 5트랙 + SelcaService 셀카 분기) 를 직접 짜본다. Before/After 리팩토링 패턴 (lab → prod 흡수) 이 같은 Day 안에 닫히는 첫 사례입니다.
  • 비용 의식이 UX 분기에 들어가는 모습 — 4 프리셋 (즉시·비용 0) + 1 커스텀 (외모 prompt·1회 비용) 의 사용자 의지로 비용 발생 패턴을 만든다.
  • 기술 차단이 캐릭터 인격으로 변환되는 장면ImageDailyQuotaGuard 가 한도 초과 시 IMAGE_QUOTA_EXCEEDED 를 던지면, SelcaService 가 그걸 캐릭터 인격 톤 ("카메라 배터리 다 됐어...") 으로 우회 응답하는 모양을 만든다.
  • prod 시연 (실제 채팅 / 캐릭터 만들기 클릭) 으로만 보이는 두 군데 (정규식 정밀화 + chat 커스텀 portrait 분기) 의 hotfix 흐름을 살펴본다.

들어가기 전에

본 부록은 Day 7 Step 1~7 을 손으로 다 따라온 학생 을 전제로 합니다. 코드베이스 상태는 day07-image-generation 브랜치의 Step 7 커밋 시점 (Step 7: feat: add /uploads static resource mapping ...) 이 출발점이에요. Step 8 의 첫 커밋부터 이 부록이 시작합니다.

학습 순서 추천 — Day 7 본 교안 마무리 → Day 8 진입 전에 부록으로 한 박자 끊고 prod 결합 두 군데를 살피는 호흡. 강의 시간이 빠듯하면 부록은 과제 형태로 학생 자율 학습 에 넘겨도 됩니다 (강사 시연 30분 + 학생 자율 따라하기로 압축 가능).


Step 8: prod 결합 ① — 캐릭터 만들기 5트랙 외모 선택 (4 프리셋 + 커스텀 자동 생성)

자, (a) 시나리오 — 캐릭터 만들기 흐름의 마지막 1 회 자동 생성 을 prod 에 넣는 단계입니다. 다만 모든 사용자가 매번 자동 생성을 부담하면 비용 운영 위험 이 있죠. 그래서 5트랙 외모 선택 으로 — 4 프리셋 (즉시·비용 0) + 1 커스텀 (외모 prompt 입력·1회 비용 발생) 의 사용자 의지로 비용 발생 UX 를 깝니다.

💡 Before/After 리팩토링 — lab → prod 흡수의 깔끔한 패턴

Step 6 의 ImageGenerationController POST /api/images/portraits학습용 lab 신축 이었어요. Step 8 에서 — 기존 ai-friends 의 SoulmateService.createSoulmate() 흐름이 이 lab 의 ImageGenerationService 를 같은 Day 안에 흡수 합니다. 수렴 시점이 Day 7 자체 에서 닫혀요 — 보통의 lab 형 강의라면 "다음 Day 에 흡수" 로 흘려보내는데, 오늘은 한 Day 안에 lab 시연 + prod 흡수 두 단계를 같이 적었어요. Spring AI 가치의 정중앙입니다.

1. 5트랙 외모 선택 매트릭스 펼치기 🎯

먼저 트랙 매트릭스부터 표로 정리할게요.

트랙 액션 appearancePrompt characterImageUrl 비용
① male-cheerful 카드 클릭 enum 메타로 미리 들어감 /static/images/characters/character-male-cheerful-face.jpg 0
② male-calm 카드 클릭 enum 메타 /static/.../character-male-calm-face.jpg 0
③ female-warm 카드 클릭 enum 메타 /static/.../character-female-warm-face.jpg 0
④ female-bright 카드 클릭 enum 메타 /static/.../character-female-bright-face.jpg 0
커스텀 외모 prompt 입력 사용자 입력 그대로 /uploads/portraits/{uuid}.jpg 1회 발생

핵심 두 가지를 먼저 짚을게요.

첫째, 프리셋 4개도 appearancePrompt 메타가 미리 들어가 있어요. 비용 0 이라 이미지 생성은 호출하지 않지만 — Step 9 의 챗 셀카 요청에서 외모 일관성을 유지하려면 프리셋 트랙도 외모 묘사 한 줄이 필요해요. 프리셋 = 비용 0 + 외모 메타 보존 의 원칙.

둘째, 커스텀 트랙은 비용이 실제로 발생합니다. 사용자가 "단발머리에 안경 쓴 차분한 인상의 여성" 같은 외모를 직접 입력하면 — ImageGenerationService.generate() 가 1회 호출되고, 결과 이미지가 /uploads/portraits/... 에 저장돼요. 사용자가 비용을 의식하고 선택하는 단계 — 비용 가드의 UX 측 분기 가 여기서 일어납니다.

💡 튜터의 운영 결정 메모 — 모든 회원이 자동 생성을 강제로 받으면 운영자가 비용을 통제 못 하는 자동 시스템이 됩니다. 3트랙 이상의 분기 (프리셋 / 커스텀) 로 사용자 의지로 비용 발생을 깔면, Step 5 가드의 비용 의식 메시지가 두 군데에서 보여요 — UX 분기 (지금) + 가드 차단 (Step 9). 운영 시점의 비용 통제 정책이 코드 + UX 둘 다에 들어가는 패턴.

2. Soulmate 엔티티에 외모 일관성 컬럼 추가

먼저 Soulmate 엔티티에 appearancePrompt 컬럼 한 줄을 적을게요. 외모 묘사가 영속 되어야 셀카 요청 시 같은 캐릭터의 외모 가 유지됩니다.

package kr.spartaclub.aifriends.domain;

@Entity
@Table(name = "soulmate")
public class Soulmate {

    // ... 기존 컬럼들 (gender · characterImageId · characterImageUrl · name · ...) ...

    /**
     * 외모 일관성 prompt (Day 7 Step 8 추가).
     * <p>캐릭터 만들기 시 들어간 외모 묘사. 챗 셀카 요청(Step 9) 시 사용자 요청(포즈/표정/장소) 앞에
     * 합성되어 *같은 캐릭터의 외모* 를 셀카마다 유지한다. 프리셋 트랙은 {@code CharacterPreset}
     * 의 메타가, 커스텀 트랙은 사용자 입력이 그대로 들어간다. nullable 인 이유는 Day 7 이전에
     * 만들어진 기존 데이터(레거시 row)와의 호환성 때문이다.</p>
     */
    @Column(length = 1000)
    private String appearancePrompt;
}

nullable 한 이유 는 — Day 7 이전에 만들어진 레거시 Soulmate 와의 호환성. 강의 환경에선 DB 가 매번 휘발 되니 큰 의미 없지만, 운영 환경에서 마이그레이션 시점에 기존 row 의 appearancePrompt = null 이 깨지지 않도록 깔아두는 식의 패턴이에요. 운영 마이그레이션 시 기본값 backfill 정책은 Day 19~20 에서 다시 만나요.

💡 튜터의 운영 결정 — appearancePrompt 의 길이 제한 1000 은 학습용 단순값. 운영에선 모델별 prompt 한도 (Pollinations 약 2KB / OpenAI 4KB) 와 한국어 UTF-8 인코딩 (한글 1자 = 3바이트) 을 고려해 1500~2000 정도가 적정. 본 강의는 학습용 단순 으로 1000 을 적었어요.

3. CharacterPreset enum — 4 프리셋 외모 메타 보존

이번엔 4 프리셋 외모 메타를 enum 한 군데에 적을게요. 기존 thumb 4장 (/static/images/characters/character-{id}-thumb.jpg) 의 시각 정체성과 일치하는 한국어 + 영어 보강 prompt 입니다.

package kr.spartaclub.aifriends.domain;

import kr.spartaclub.aifriends.common.exception.BusinessException;
import kr.spartaclub.aifriends.common.exception.ErrorCode;

public enum CharacterPreset {

    MALE_CHEERFUL(
            "male-cheerful",
            """
            20대 후반 한국인 남성, 짧고 자연스럽게 헝클어진 다크브라운 머리, 옅은 회청색 눈, \\
            부드러운 옅은 미소, 운동을 좋아하는 듯한 다정하고 활기찬 인상, 하늘색 라운드 셔츠. \\
            anime portrait illustration, soft cel shading, korean male, late 20s, \\
            messy short dark brown hair, friendly cheerful smile, light blue t-shirt"""
    ),

    MALE_CALM(
            "male-calm",
            """
            30대 초반 한국인 남성, 옆가르마로 정돈된 다크브라운 머리, 짙은 갈색 눈, \\
            차분하고 옅은 표정, 지적이고 부드러운 분위기, 검은색 셔츠. \\
            anime portrait illustration, soft cel shading, korean male, early 30s, \\
            side-parted dark brown hair, calm gentle expression, black shirt"""
    ),

    FEMALE_WARM(
            "female-warm",
            """
            20대 후반 한국인 여성, 어깨 아래까지 내려오는 긴 갈색 스트레이트 머리, 따뜻한 갈색 눈, \\
            부드럽고 다정한 미소, 단아하고 포근한 인상, 아이보리색 니트 스웨터. \\
            anime portrait illustration, soft cel shading, korean female, late 20s, \\
            long straight brown hair, warm gentle smile, ivory knit sweater"""
    ),

    FEMALE_BRIGHT(
            "female-bright",
            """
            20대 초반 한국인 여성, 어깨 길이 단발의 살짝 구불구불한 다크브라운 머리, 큰 갈색 눈, \\
            환하고 발랄한 미소, 작은 귀걸이, 발랄하고 생기 넘치는 캠퍼스 분위기, 민트색 라운드 티. \\
            anime portrait illustration, soft cel shading, korean female, early 20s, \\
            wavy short bob dark brown hair, bright lively smile, mint green t-shirt"""
    );

    /** 커스텀 트랙을 가리키는 sentinel — characterImageId 가 이 값이면 5번째 트랙. */
    public static final String CUSTOM_IMAGE_ID = "custom";

    private final String characterImageId;
    private final String appearancePrompt;

    CharacterPreset(String characterImageId, String appearancePrompt) {
        this.characterImageId = characterImageId;
        this.appearancePrompt = appearancePrompt;
    }

    public String getCharacterImageId() { return characterImageId; }
    public String getAppearancePrompt() { return appearancePrompt; }

    public static CharacterPreset fromImageId(String characterImageId) {
        for (CharacterPreset preset : values()) {
            if (preset.characterImageId.equals(characterImageId)) {
                return preset;
            }
        }
        throw new BusinessException(ErrorCode.SOULMATE_INVALID_PRESET);
    }
}

여기서 짚을 4가지 설계 의도 가 있어요.

첫째 — 한국어 시각화 + 영어 보강 키워드

각 prompt 는 한국어 시각 묘사 가 앞쪽에, 영어 키워드 보강이 뒤쪽에 들어가 있어요. 한국어는 학생 가독성 위함 — prompt 의 시각 의도 를 익히기 좋아요. 영어 보강은 Pollinations.ai 의 결과 일관성을 위한 기술적 헷지 — 한국어 단독으로 prompt 를 보내면 모델이 일본/서양 인물 로 흘려버리는 경우가 많아요. 영어 변환의 본격적인 논의는 Day 11 (Tool Calling) 또는 Day 15 (RAG) 의 prompt engineering 시점에서 다시 만나요.

둘째 — anime portrait illustration, soft cel shading 강제 스타일 락

기존 thumb 4장 이 애니풍 일러스트 식이라 — 셀카 요청 결과도 같은 모양을 유지해야 캐릭터 정체성 이 안 깨져요. 사진 방식으로 흘러가는 걸 막는 키워드 두 개가 prompt 끝에 들어가 있어요. 이게 없으면 — "한국인 여성, 긴 갈색 머리, 따뜻한 미소" 만으로는 모델이 사진 모양 으로 응답해버리는 경우가 흔해요. 스타일 락은 prompt engineering 의 기본 감각.

셋째 — korean male / female 명시

동아시아 인물 이라는 시그널만으로는 일본 / 중국 / 한국 어디로 흘러갈지 모델이 헷갈려요. korean 한 단어로 얼굴 결 이 한국인 톤으로 락됩니다.

넷째 — CUSTOM_IMAGE_ID = "custom" sentinel

5번째 트랙을 가리키는 문자열 상수 한 줄입니다. SoulmateService 의 5트랙 분기에서 문자열 비교 한 줄 로 프리셋 vs 커스텀 을 갈라요. 매직 스트링을 enum 의 static final 로 묶어둔 패턴입니다.

🙋 한 학생의 날카로운 질문 — "왜 prompt 안에 의상까지 적었어요? 셀카 요청에서 의상이 고정되면 오늘 카페에서 한복 입은 셀카 같은 자유 입력이 안 먹히지 않을까요?"

정확한 부분에 의문을 던지셨어요. 답은 — 자유 입력이 의상 키워드를 덮어쓰는 느낌이에요. prompt 합성이 appearancePrompt + 사용자 자유 입력 + selfie suffix 순서라, 사용자 입력이 나중에 들어감 — 모델은 겹치는 묘사 중 마지막을 우선하는 경향이 있어요. "한복" 이 들어오면 "라운드 셔츠" 보다 "한복" 이 우선. 단 완벽히 보장되진 않음 — 의상이 섞인 결과가 나올 수 있어요. prompt engineering 의 재미와 한계 가 동시에 보이는 대목입니다. Day 11 의 Tool Calling 으로 의상만 동적으로 빼는 방식으로 진화시킬 수 있어요. 🪞

4. SoulmateCreateRequest DTO 에 커스텀 트랙 필드 추가

DTO 한 군데에 커스텀 트랙용 외모 prompt 필드를 더해줘요.

public record SoulmateCreateRequest(
        @NotBlank(message = "성별을 입력해 주세요")
        String gender,

        @NotBlank(message = "캐릭터 이미지를 선택해 주세요")
        String characterImageId,

        String characterImageUrl,
        String name,

        @NotEmpty(message = "성격 키워드를 1개 이상 선택해 주세요")
        List<String> personalityKeywords,

        @NotEmpty(message = "취미를 1개 이상 선택해 주세요")
        List<String> hobbies,

        @NotEmpty(message = "말투 스타일을 1개 이상 선택해 주세요")
        List<String> speechStyles,

        /**
         * 커스텀 트랙(⑤) 전용 — 사용자 입력 외모 묘사. 프리셋 4 트랙일 때는 null.
         * 검증은 서비스 레이어가 트랙 분기와 함께 수행한다 — DTO 단계에서는 트랙 정체를
         * 모르기에 @NotBlank 같은 단순 검증으로는 표현할 수 없는 *조건부 필수* 라서.
         */
        String customAppearancePrompt
) {
        public Soulmate toEntity(String appearancePrompt, String characterImageUrl) {
                String personalityStr = String.join(",", personalityKeywords);
                String hobbiesStr = String.join(",", hobbies);
                String speechStr = String.join(",", speechStyles);
                return new Soulmate(
                        null, gender, characterImageId, characterImageUrl,
                        name, personalityStr, hobbiesStr, speechStr,
                        0, 1, null, appearancePrompt
                );
        }
}

조건부 필수 검증 이 핵심 포인트예요. customAppearancePrompt 는 — 항상 필수도 아니고 항상 선택도 아니고 characterImageId == "custom" 일 때만 필수 인 조건부 필수. @NotBlank 같은 애노테이션 기반 검증으로는 표현이 안 되니, 서비스 레이어에서 트랙 분기와 함께 검증해요. 이게 Bean Validation 의 한계 — 필드 단위 검증까지만 표현 가능하고 교차 필드 조건부 검증은 서비스 레이어 비즈니스 로직으로 내려갑니다.

💡 트레이드오프 메모 — Bean Validation 의 @AssertTrue + 사용자 정의 메서드로 교차 필드 검증을 적을 수도 있어요. 하지만 그렇게 가면 DTO 가 비즈니스 정책을 들고 가게 되어 DTO 의 책임이 부풀어요. 학습 단계에서는 서비스 레이어 검증이 더 명확해요. Day 11 에서 Validator 인터페이스를 별도 클래스로 추출 하는 방식을 다시 만나요.

5. SoulmateService.createSoulmate() — 5트랙 분기의 본체

자, 본격 5트랙 분기 의 감각을 펼칠 시간이에요.

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SoulmateService {

    private final SoulmateRepository soulmateRepository;
    private final SoulmateAchievementRepository achievementRepository;
    private final ImageGenerationService imageGenerationService;  // ← Day 7 Step 8 신규 의존성

    @Transactional
    public SoulmateResponse createSoulmate(SoulmateCreateRequest request) {
        // 1. 5트랙 외모 선택 분기 — 트랙별로 appearancePrompt + characterImageUrl 결정
        String appearancePrompt;
        String characterImageUrl;

        if (CharacterPreset.CUSTOM_IMAGE_ID.equals(request.characterImageId())) {
            // ⑤ 커스텀 트랙 — 외모 prompt 검증
            String customPrompt = request.customAppearancePrompt();
            if (customPrompt == null || customPrompt.isBlank()) {
                throw new BusinessException(ErrorCode.SOULMATE_CUSTOM_PROMPT_REQUIRED);
            }
            appearancePrompt = customPrompt;

            // 프론트 Step 2 의 *미리보기 + 컨펌* 흐름에서 이미지가 *이미 생성된 상태* 면 — 그 URL 을 그대로 넣는다.
            // *컨펌 흐름이 도입되기 전* 의 단일 호출 결과 호환성을 위해, URL 이 비어있으면 여기서 한 번 더 호출.
            String preGeneratedUrl = request.characterImageUrl();
            if (preGeneratedUrl != null && !preGeneratedUrl.isBlank()) {
                characterImageUrl = preGeneratedUrl;
                log.info("[SoulmateService] custom portrait pre-generated by frontend (preview/confirm flow): url={}", preGeneratedUrl);
            } else {
                String fileNameHint = "soulmate-portrait-" + UUID.randomUUID();
                ImageGenerationResult result = imageGenerationService.generate(
                        customPrompt, null, null, fileNameHint);
                characterImageUrl = result.localPath();
                log.info("[SoulmateService] custom portrait generated inline: localPath={}, costUsd={}",
                        result.localPath(), result.estimatedCostUsd());
            }
        } else {
            // ① ~ ④ 프리셋 트랙 — enum 메타에서 외모 prompt 만 가져오고 이미지 URL 은 클라이언트가 보낸 정적 경로 그대로
            CharacterPreset preset = CharacterPreset.fromImageId(request.characterImageId());
            appearancePrompt = preset.getAppearancePrompt();
            characterImageUrl = request.characterImageUrl();
        }

        // 2. 트랙 분기 결과로 엔티티 빌드 + DB 저장
        Soulmate soulmate = request.toEntity(appearancePrompt, characterImageUrl);
        Soulmate saved = soulmateRepository.save(soulmate);

        // 3. 저장된 엔티티를 응답 DTO로 변환하여 반환
        return SoulmateResponse.from(saved);
    }
}

여기서 짚을 세 가지 감각 이 있어요.

첫째 — 비용 가드 책임을 한 군데에 모음 🛡️

ImageDailyQuotaGuard.checkAndIncrement()ImageGenerationService 내부 에서 동작해요. 여기 (SoulmateService) 에서는 가드 호출 코드가 안 보여요. 이게 책임 분리의 가치예요 — 비용 가드는 이미지 생성의 책임이고, Soulmate 도메인 은 5트랙 분기 의 책임. 가드를 두 군데에서 호출하면 책임 누수 + 중복 호출 위험. 한 군데에서만 동작해야 해요. 🎯

둘째 — 엔티티 빌드의 책임 분리

SoulmateCreateRequest.toEntity(appearancePrompt, characterImageUrl) 는 두 인자를 서비스 레이어가 결정한 값 으로 받아요. DTO 가 트랙 정체를 모름 — 5트랙 분기는 비즈니스 로직 이라 서비스 레이어 책임. DTO 는 입력 데이터의 변환만 책임. 책임 경계가 깔끔하게 갈리는 패턴이에요. 🪞

셋째 — 예외의 명시적 변환

커스텀 트랙에서 customAppearancePrompt 가 null/blank 면 SOULMATE_CUSTOM_PROMPT_REQUIRED 로 명시 throw. 잘못된 프리셋 ID 면 CharacterPreset.fromImageId()SOULMATE_INVALID_PRESET 로 throw. 모두 도메인 ErrorCode 로 매핑IllegalArgumentException / RuntimeException 직접 throw 금지 (Day 7 Step 4 에서 적은 예외 정책 규약).

// ErrorCode 추가 (Step 8 신규)
SOULMATE_INVALID_PRESET(HttpStatus.BAD_REQUEST, "S002", "선택할 수 있는 캐릭터 프리셋이 아닙니다."),
SOULMATE_CUSTOM_PROMPT_REQUIRED(HttpStatus.BAD_REQUEST, "S003", "커스텀 외모를 선택했다면 외모 prompt 를 입력해야 합니다."),

6. 💡 튜터의 결론 — Step 8 한 줄

"5트랙 외모 선택 (4 프리셋 + 커스텀) 은 비용 의식을 UX 에 넣는 장치다. 모든 사용자에게 자동 생성을 강제하면 비용 통제 불능, 모든 사용자에게 입력 강제하면 진입장벽 ↑ — 두 식의 절충이 프리셋 즉시·커스텀 의지의 5트랙 매트릭스로 풀린다. appearancePrompt 컬럼은 외모 정체성을 영속 시키는 한 줄 — 이 컬럼이 다음 Step (셀카 요청) 의 외모 일관성 prompt 의 베이스 로 흐른다."

7. 후속 UX fix — 2026-05 prod 시연에서 발견한 두 군데 (Step 간 state 의존성 · 컨펌 흐름)

자, § 5~6 의 5트랙 분기 가 prod 에서 실제로 굴러가니 — 학생이 손으로 클릭하는 단계에서 깐 직후엔 안 보였던 구멍 두 개가 튀어나왔어요. 백엔드 강의의 정체성을 지키면서도 Step state 의존성 · 컨펌 흐름의 비용 분기 두 부분은 백엔드 설계 영역에 속하는 게 맞아 — 학습 메시지로만 짧게 적어두고 갈게요. (프론트엔드 코드 자체는 코드베이스 브랜치 day07-image-generationstatic/js/pages/soulmate-create/ · templates/soulmate-new.html 에 있으니, ./run.sh up 으로 띄워 직접 클릭해보세요.)

Step 1 성별 → Step 2 카드 필터링 — Step state 의존성의 패턴

원래 Step 1 에서 성별을 선택했어도 Step 2 에선 4 프리셋 카드가 모두 표시 됐어요. 학생이 남을 선택했는데 여성 캐릭터 카드 가 보이는 — Step 간 약속이 깨진 사례. Wizard UI 의 흔한 함정 — Step 1 에서 결정된 state 가 후속 Step 의 표시 범위를 제약하지 않으면 Step 1 의 의도 가 Step 2 에서 무의미 해지는 패턴.

보호 한 줄의 패턴 — 각 프리셋 카드에 data-gender 메타 적고, Step 2 진입 시 applyGenderFilter()state.gender 와 일치하지 않는 카드를 hidden 토글. 커스텀 카드 는 성별 메타 없음 → 항상 표시 (외모 prompt 가 성별을 직접 넣었으므로). 이전에 선택된 카드가 Step 1 의 성별 변경으로 hidden 되면 선택 자동 해제 — state 불일치 자동 정리.

영역 깨진 결 보호 한 줄
Step 2 카드 표시 Step 1 성별과 무관하게 4 카드 전부 data-gender 메타 + applyGenderFilter()
Step 1 성별 변경 시 이전 선택이 hidden 카드에 들어간 채 남음 Step 1 변경 시 hidden 카드의 선택 자동 해제

학습 메시지 (백엔드 방식으로 추상화) — Wizard UI 의 Step state 는 단방향이 아니라 양방향 의존성을 갖는다 의 느낌이에요. Step 1 → Step 2 표시 범위 (전방 의존성) 도 있지만 Step 1 변경 → Step 2 기존 선택의 정합성 (후방 정리) 도 있어요. 후방 정리를 빼먹으면 — DB 저장 직전에 성별 = 남 + characterImageId = female-warm 같은 불일치 데이터 가 백엔드까지 도달해요. 백엔드 검증 (CharacterPreset.fromImageId()gender 의 교차 필드 검증) 을 별도로 두지 않는 한, 프론트의 state 정리가 데이터 무결성의 첫 방어선이 됩니다. 🛡️

🙋 한 학생의 날카로운 질문 — "백엔드에서도 성별 ↔ 프리셋 ID 매칭 검증 을 적어야 하는 거 아닌가요? 프론트 UX 만 믿으면 누군가 직접 API 호출로 gender=MALE + characterImageId=female-warm 보낼 수 있잖아요?"

정확합니다. 프론트 UX 정리는 정상 사용자 흐름의 방어선, 백엔드 검증은 모든 호출 경로의 방어선. 본 강의에서는 학습 단순화를 위해 프론트 UX 정리만 적었어요. 운영에선 SoulmateService.createSoulmate() 의 분기 안에 gender ↔ preset.gender 교차 검증 한 줄 (if (!preset.matchesGender(request.gender())) throw new BusinessException(ErrorCode.SOULMATE_GENDER_MISMATCH)) 을 넣는 게 정석. CharacterPreset enum 에 gender 메타를 한 칸 추가 + 서비스 레이어 교차 검증의 패턴 — Day 4 (구조화 출력) 에서 다룬 조건부 필수 검증의 결 과 같은 패턴이에요. 🎯

⑥ 커스텀 트랙의 blind submit → preview/confirm 진화 — Step 6 lab 컨트롤러의 재활용 과 백엔드 분기 갱신 🪞

원래 커스텀 트랙 은 blind submit 였어요 — 학생이 prompt 입력 → 바로 Step 3 진입 → Step 4 의 생성 버튼 누르고 나서야 결과 face 확인. 만약 원하는 외모가 아니면 — 전체 wizard 를 처음부터 다시 해야 했어요. 비용 한 번 발생 + UX 좌절감 ↑ 의 문제.

보호 한 줄의 패턴 — Step 6 의 lab 컨트롤러 (POST /api/images/portraits) 를 외모 미리보기 용도로 재활용. Step 2 의 다음 버튼이 바로 Step 3 진입하지 않고 — POST /api/images/portraits 호출 → 생성된 portrait 미리보기 → 이 장면으로 / 다른 형태으로 버튼 분기. 이 모습으로 → state.characterImageUrl 적고 Step 3 진입. 다른 형태으로 → preview 닫고 prompt 재입력으로 돌아가요.

Before / After — 백엔드 SoulmateService.createSoulmate() 의 분기 갱신 (§ 5 에서 갱신한 코드의 왜 갱신됐는지)

// Before — 단일 호출 (preview 도입 전)
String fileNameHint = "soulmate-portrait-" + UUID.randomUUID();
ImageGenerationResult result = imageGenerationService.generate(
        customPrompt, null, null, fileNameHint);
appearancePrompt = customPrompt;
characterImageUrl = result.localPath();
log.info("[SoulmateService] custom portrait generated: localPath={}, costUsd={}", ...);

// After — pre-generated URL 분기 (preview/confirm 도입 후)
String preGeneratedUrl = request.characterImageUrl();
if (preGeneratedUrl != null && !preGeneratedUrl.isBlank()) {
    characterImageUrl = preGeneratedUrl;       // ✅ 프론트가 이미 생성한 URL 그대로 넣음 — 재호출 X
    log.info("[SoulmateService] custom portrait pre-generated by frontend (preview/confirm flow): url={}", preGeneratedUrl);
} else {
    // *컨펌 흐름이 도입되기 전* 의 단일 호출 결과 호환성을 위해, URL 이 비어있으면 여기서 한 번 더 호출
    String fileNameHint = "soulmate-portrait-" + UUID.randomUUID();
    ImageGenerationResult result = imageGenerationService.generate(...);
    characterImageUrl = result.localPath();
}

왜 분기인가 — 중복 호출 방지 + back-compat 두 마리를 한 군데에

깨진 부분 보호 한 줄
중복 호출 방지 프론트가 preview 로 1 회 호출 + 백엔드가 저장 시 1 회 더 호출 = 한 사용자당 2 회 = 비용 2 배 if (preGeneratedUrl != null) characterImageUrl = preGeneratedUrl;재호출 차단
Back-compat 기존 프로그래매틱 호출 (테스트 · curl)characterImageUrl 없이 prompt 만 보낼 수 있음 else { imageGenerationService.generate(...); }비어있으면 inline 호출

§ 5 의 비용 가드 책임 분리의 진화 — § 5 에서 비용 가드는 ImageGenerationService 내부에서 동작 한다고 적었죠. 그 약속은 그대로 살아있어요 — 단 호출 시점이 백엔드 단일 호출 (Before) 에서 프론트 preview 호출 (After) 로 이동. 가드는 프론트의 preview 호출에서 1 회 발동, 백엔드 저장 단계는 재호출 안 함 → 가드의 중복 호출 자체가 차단. 책임 분리의 느낌이 호출 경로 변경에도 그대로 살아남는 패턴이에요. 🎯

💡 튜터의 운영 메모 — Step 6 lab 의 재활용 은 프로바이더 추상화 원칙의 또 다른 형태. Step 6 의 POST /api/images/portraits 는 원래 lab 용으로 만든 컨트롤러 였어요. prod 결합 시점에 Step 2 의 preview 용도로 그대로 끌어와 재활용 — 컨트롤러를 두 번 만들지 않고 한 곳에서 두 흐름 (lab 데모 + prod preview) 을 떠받침. 얇은 컨트롤러 + 표준 ApiResponse 응답의 가치가 재활용성으로 환산 되는 패턴이에요. 얇은 컨트롤러 → 5트랙 분기 → 재활용 의 느낌이 한 Day 안에 닫힙니다. 🔁

🎯 면접관을 홀리는 핵심 멘트 — "커스텀 외모 흐름을 blind submit → preview/confirm 으로 갈아끼우면서 백엔드 SoulmateService 에 pre-generated URL 분기 한 줄을 적었어요. 프론트 preview 가 이미 비용 1 회를 발생시켰으니 백엔드는 재호출 안 한다 는 중복 호출 방지와, 기존 프로그래매틱 호출이 prompt 만 보내면 백엔드가 inline 생성한다 는 back-compat 을 한 if/else 로 떠받쳐요. 비용 가드 (ImageDailyQuotaGuard) 는 손대지 않았는데 — 호출 시점 이동만으로 가드의 발동 위치가 자동으로 prod 데이터플로우의 정중앙으로 이동합니다. 책임 분리의 가치가 호출 경로 변경에도 살아남는다 의 정중앙입니다."

이 방식으로 — Step 9 에서 (b) 시나리오 인 챗 셀카 요청을 손으로 깔러 갑니다. Step 5 가드가 prod 에서 진짜 일하는 단계, 비용 차단이 캐릭터 인격으로 변환되는 미연시 몰입감의 정중앙. 🎭


Step 9: prod 결합 ② — 챗 셀카 요청 (외모 일관성 + 가드 한도 초과 시 캐릭터 인격 우회)

자, 마지막 Step. (b) 시나리오 — 챗 셀카 요청의 prod 결합이에요. 사용자가 "오늘 카페에서 책 읽는 셀카 보내줘" 같은 메시지를 보내면 — 캐릭터가 그 모습 그대로 셀카로 응답합니다. 미연시 판타지의 정중앙 단계예요. 🎭

💡 Step 5 가드의 prod 가치 — 마침내 시연

Step 5 에서 깐 ImageDailyQuotaGuardprod 에서 진짜 발동 하는 단계예요. 학생이 "오늘 셀카 31번 보내줘" 하다가 30번째에서 캐릭터가 피곤해 한다 의 장면. 비용 가드의 기술적 차단 이 캐릭터 인격으로 변환되는 감각. 가드를 가르치고 끝이 아니라 깔고 작동시키고 닫는 모습이 한 Day 안에 닫힙니다. 🛡️→🎭

1. 책임 분리 의 첫 결정 — 새 SelcaService 신축 🪞

먼저 어디에 분기 코드를 적을지 의 결정부터. 두 후보가 있어요.

후보 A: SoulmateChatService.chat() 안에 적기.

  • 장점: LLM 호출 진입점이 곧 분기점 — 한 곳에 모임.
  • 단점: chat 도메인이 image 도메인을 직접 의존 — 결합도 ↑. SoulmateChatService 의 책임이 LLM 호출 + 이미지 생성 둘로 부풀어 SRP 위반.

후보 B (채택): 새 SelcaService 분리 + AiChatService 가 facade 로 분기.

  • 장점: chat 도메인 ↔ image 도메인의 결합을 한 곳 (SelcaService) 으로 캡슐화. SoulmateChatService 는 손대지 않음.
  • 단점: 클래스 한 개 추가.

B 가 깔끔합니다. 셀카 요청이라는 교차 관심사는 채팅의 책임도 이미지 생성의 책임도 아니라 — 둘을 합치는 별도 컴포넌트. SelcaService 가 그 역할을 정확히 채워줘요. 🎯

2. SelcaService — 키워드 매칭 + prompt 합성 + 가드 우회

@Slf4j
@Service
@RequiredArgsConstructor
public class SelcaService {

    /**
     * 셀카 요청 *명령형* 만 매칭 — 키워드(셀카·셀피·사진 등) + *요청 동사*(보내·찍어·찍자·보여·줘·줄래·찍·보낼래)
     * 가 12자 이내에 결합된 경우만. 단일 키워드 매칭의 한계 (LLM 감상 응답 "셀카 이쁘다" 까지 잡아
     * 무한 셀카 루프) 회피. 진짜 정확도는 Day 11 Tool Calling 의 의도 분류 단계에서.
     */
    private static final Pattern SELCA_PATTERN = Pattern.compile(
            "(?i)(셀카|셀피|사진|selfie|selca).{0,12}(보내|찍어|찍자|찍|보여|줄래|보낼래|줘\\b|줘$|줘\\s)");

    private static final String QUOTA_EXCEEDED_FALLBACK =
            "오늘 셀카 너무 많이 찍었나봐 ㅠㅠ 카메라 배터리 다 됐어... 내일 또 찍어줄게!";

    private static final String GENERATION_FAILED_FALLBACK =
            "셀카 보내려고 했는데 핸드폰이 말썽이네 ㅠㅠ 다음에 다시 시도해보자!";

    private final ImageGenerationService imageGenerationService;

    public boolean isSelcaRequest(String userMessage) {
        if (userMessage == null || userMessage.isBlank()) return false;
        return SELCA_PATTERN.matcher(userMessage).find();
    }

    public SelcaResult generate(Soulmate soulmate, String userMessage) {
        String prompt = composePrompt(soulmate.getAppearancePrompt(), userMessage);
        String fileNameHint = "selca-" + UUID.randomUUID();
        try {
            ImageGenerationResult result = imageGenerationService.generate(
                    prompt, null, null, fileNameHint);
            log.info("[SelcaService] generated selca: localPath={}, costUsd={}",
                    result.localPath(), result.estimatedCostUsd());
            return SelcaResult.success(result.localPath());
        } catch (ImageException e) {
            if (e.getErrorCode() == ErrorCode.IMAGE_QUOTA_EXCEEDED) {
                log.info("[SelcaService] quota exceeded — falling back to character voice");
                return SelcaResult.quotaExceeded(QUOTA_EXCEEDED_FALLBACK);
            }
            log.warn("[SelcaService] image generation failed: {}", e.getMessage());
            return SelcaResult.failed(GENERATION_FAILED_FALLBACK);
        }
    }

    String composePrompt(String appearancePrompt, String userMessage) {
        String userRequest = SELCA_PATTERN.matcher(userMessage).replaceAll("").trim();
        StringBuilder sb = new StringBuilder();
        sb.append(appearancePrompt == null ? "" : appearancePrompt);
        if (!userRequest.isEmpty()) {
            if (sb.length() > 0) sb.append(", ");
            sb.append(userRequest);
        }
        if (sb.length() > 0) sb.append(", ");
        sb.append("selfie, casual photo angle");
        return sb.toString();
    }
}

세 가지 핵심 포인트를 짚을게요.

첫째 — 키워드 매칭의 한계 → Day 11 Tool Calling 으로 가는 복선

SELCA_PATTERN명령형 키워드 매칭. (왜 명령형 인지는 § 8 의 ⑦ 정규식 정밀화 부분에서 깊게 풀어요 — 단일 키워드만 잡으면 "셀카 이쁘다" 같은 감상 응답 까지 잡혀 무한 루프가 생기거든요.) 한계는 그래도 또렷해요 — "이 사진 한번 봐줘" 같은 동사 보유 감상 은 여전히 오감지 가능. 의도 분류 정확도를 올리려면 — LLM 의 intent classification 호출 1회를 추가해야 하는데, 그것이 곧 Day 11 (Tool Calling) 의 영역이에요. LLM 이 "이 메시지가 selca 요청인가" 를 도구 호출로 직접 판단하면 키워드 매칭의 한계가 사라져요. 오늘은 명령형 매칭으로 prod 결합부터 닫고, 정확도 진화는 다음 시기에 의 방침. 🪞

둘째 — 외모 일관성 prompt 합성

composePrompt() 의 합성 순서:

[Soulmate.appearancePrompt 그대로]
+ ", " + [사용자 메시지에서 셀카 키워드 제거한 자유 입력]
+ ", selfie, casual photo angle"

예: 사용자 "오늘 카페에서 책 읽는 셀카 보내줘" + Soulmate.appearancePrompt = FEMALE_WARM 메타 → Pollinations 에 흘려보내는 prompt:

20대 후반 한국인 여성, 어깨 아래까지 내려오는 긴 갈색 스트레이트 머리, 따뜻한 갈색 눈,
부드럽고 다정한 미소, 단아하고 포근한 인상, 아이보리색 니트 스웨터.
anime portrait illustration, soft cel shading, korean female, late 20s,
long straight brown hair, warm gentle smile, ivory knit sweater,
오늘 카페에서 책 읽는 보내줘, selfie, casual photo angle

외모 5요소 (성별/나이대 · 머리 · 눈 · 표정 · 의상) + 애니풍 스타일 락 + 사용자 자유 입력 + selfie suffix — 이 합성으로 같은 캐릭터의 셀카가 매번 일관된 외모로 도착 합니다. 🎯

셋째 — 가드 우회 응답의 톤 분리 🎭

ImageException 이 throw 되면 — getErrorCode() 로 종류를 식별해 우회 메시지를 분리 해요.

상황 fallback 메시지 사용자 체감
IMAGE_QUOTA_EXCEEDED "오늘 셀카 너무 많이 찍었나봐 ㅠㅠ 카메라 배터리 다 됐어..." 기다림이 의미를 가짐 (내일 또 보낼 수 있다)
그 외 (IMAGE_GENERATION_FAILED 등) "셀카 보내려고 했는데 핸드폰이 말썽이네 ㅠㅠ 다음에 다시!" 일시적 실패가 의미를 가짐 (재시도 가능)

기술적 차단의 정체 가 캐릭터 인격으로 변환되는 미연시 몰입감의 핵심이에요. 사용자는 "한도 초과: 30/30" 같은 시스템 메시지를 안 보고, 캐릭터의 인격적 응답 만 봐요. 비용 가드의 운영 가치 + 몰입감 둘 다 살아남는 패턴.

💡 운영 시점의 디테일 — X-Image-Quota-Exceeded 헤더 흘려보내기

사용자에겐 캐릭터 인격으로만 보여주지만 — 운영자/QA 가 왜 이미지가 안 도착했는지 디버깅하려면 기술적 시그널이 필요해요. 응답 헤더 X-Image-Quota-Exceeded: true 같은 채널로 시스템 디테일을 별도로 흘려보내면 — 사용자 화면엔 안 보이지만 DevTools 로 확인 가능. 본 강의는 학습용 단순화로 SelcaResult.quotaExceeded boolean 까지만 적었어요. Day 19~20 의 Observability 시점에 헤더/메트릭으로 다시 만나요.

3. SelcaResult — 세 상태를 한 record 로

public record SelcaResult(
        String imageUrl,
        String fallbackMessage,
        boolean quotaExceeded
) {
    public static SelcaResult success(String imageUrl) {
        return new SelcaResult(imageUrl, null, false);
    }

    public static SelcaResult quotaExceeded(String fallbackMessage) {
        return new SelcaResult(null, fallbackMessage, true);
    }

    public static SelcaResult failed(String fallbackMessage) {
        return new SelcaResult(null, fallbackMessage, false);
    }
}

세 상태를 한 record + 정적 팩토리 메서드 3개 로 캡슐화. enum + sealed type 으로 더 엄격하게 풀 수도 있지만 — 학습 단계에선 가독성이 정답. if (selca.imageUrl() != null) ... else ... 분기가 한 줄로 명확해요. 정적 팩토리는 호출자가 어떤 상태를 만드는지 가 코드에 들어가는 패턴.

4. AiChatResponseimageUrl 필드 추가

public record AiChatResponse(
        String userMessage,
        String aiMessage,
        List<String> choices,
        Long soulmateId,
        Integer affectionScore,
        Integer level,
        List<String> newBadges,
        /**
         * 셀카 응답 시 캐릭터 셀카 이미지의 정적 리소스 경로 (Day 7 Step 9).
         * 일반 채팅이거나 한도 초과/생성 실패로 우회된 응답이면 null.
         */
        String imageUrl
) {
}

8번째 필드 추가. nullable — 셀카 요청이 아닌 일반 채팅 응답일 때나 가드 우회로 이미지 없는 응답 일 때 null. 기존 7개 positional 호출자 (AiChatService, AiChatControllerTest) 도 함께 갱신했어요.

5. AiChatService.processChat() 의 분기 흐름

@Service
@RequiredArgsConstructor
public class AiChatService {

    private final SoulmateRepository soulmateRepository;
    private final ChatLogRepository chatLogRepository;
    private final SoulmateAchievementRepository achievementRepository;

    private final SoulmateChatService soulmateChatService;
    private final SelcaService selcaService;  // ← Step 9 신규 의존성

    @Transactional
    public AiChatResponse processChat(AiChatRequest request) {
        Long soulmateId = request.soulmateId();
        String userMessage = request.userMessage();

        Soulmate soulmate = soulmateRepository.findById(soulmateId)
                .orElseThrow(() -> new BusinessException(ErrorCode.SOULMATE_NOT_FOUND));

        // 2. LLM 호출 — 셀카 요청이든 아니든 *반드시* LLM 응답은 받아온다 (캐릭터의 텍스트 응답)
        AiReply reply = soulmateChatService.chat(soulmateId, userMessage);

        // 2-1. 셀카 요청 분기 — 키워드 매칭 시 이미지 1장 추가 생성
        String imageUrl = null;
        if (selcaService.isSelcaRequest(userMessage)) {
            SelcaResult selca = selcaService.generate(soulmate, userMessage);
            if (selca.imageUrl() != null) {
                imageUrl = selca.imageUrl();
            } else {
                // 한도 초과 / 생성 실패 — LLM 응답 본문을 캐릭터 인격 우회 메시지로 덮어쓰기
                // (choices · affectionDelta 는 살림)
                reply = new AiReply(selca.fallbackMessage(), reply.choices(), reply.affectionDelta());
            }
        }

        // 3. ChatLog · 호감도 · 뱃지는 셀카 응답에도 동일하게 적용
        chatLogRepository.save(new ChatLog(null, soulmateId, "USER", userMessage, null));
        chatLogRepository.save(new ChatLog(null, soulmateId, "AI", reply.aiMessage(), null));

        soulmate.addAffection(reply.affectionDelta());
        int newLevel = 1 + (soulmate.getAffectionScore() / 10);
        soulmate.setLevel(newLevel);

        List<String> newBadges = checkAndGrantBadges(soulmate);

        return new AiChatResponse(
                userMessage, reply.aiMessage(), reply.choices(),
                soulmate.getId(), soulmate.getAffectionScore(), soulmate.getLevel(),
                newBadges, imageUrl
        );
    }
}

세 가지 식의 감각 이 있어요.

첫째 — LLM 호출은 분기와 무관하게 항상 일어남

셀카 요청이든 일반 채팅이든 LLM 은 반드시 호출돼요. 셀카 요청에도 캐릭터의 텍스트 응답 (예: "응 카페에서 막 책 읽고 있었어 ") 이 함께 도착해야 자연스럽기 때문. 이미지만 도착하고 텍스트가 빠지면 어색한 침묵 이에요.

둘째 — 호감도 · 뱃지 · ChatLog 는 분기와 무관하게 동일 처리

셀카 응답에도 호감도 변화 가 일어나요. 셀카를 부탁한 행위 자체가 친밀감의 시그널이라 — 호감도가 올라가는 게 자연스러움. 또 ChatLog 에 셀카 요청과 응답 텍스트가 그대로 저장돼요 (이미지 URL 은 별도 컬럼에 두지 않음 — 학습용 단순). 운영 시엔 ChatLog.imageUrl 컬럼 추가해서 과거 셀카 이력 을 다시 볼 수 있게 하는 게 정석.

셋째 — 가드 우회 시 덮어쓰기는 aiMessage 만, choices · affectionDelta 는 살림 🎭

new AiReply(selca.fallbackMessage(), reply.choices(), reply.affectionDelta())텍스트만 바꾸고 선택지와 호감도 변화는 LLM 의 원래 결정 그대로. 사용자가 "셀카 보내줘" 했을 때 LLM 이 "응 한 장 찍어줄게!" 라고 응답하면서 "기대돼" 같은 choices 를 만들었는데 — 가드 한도 초과로 텍스트만 "오늘 카메라 배터리 ㅠㅠ" 로 바뀌어도 기대돼 같은 choices 는 어색하지 않게 살아남아요. 호감도 변화 (캐릭터가 셀카 요청을 받았다는 친밀감) 도 살아남고요. 덮어쓸 자리만 정확히 덮어쓰는 방식.

6. 🙋 한 학생의 날카로운 질문

"SelcaService.composePrompt() 에서 사용자 입력에서 셀카 키워드를 빼고 합성하는데 — 그러면 셀카 만 입력한 경우 (예: '셀카') 사용자 자유 입력이 빈 문자열이 되잖아요. 그 경우는 어떻게 되나요?"

정확한 부분에 의문을 던지셨어요. 답은 — 빈 문자열일 때는 자유 입력 부분만 빼고 합성해요. composePrompt() 의 흐름이:

StringBuilder sb = new StringBuilder();
sb.append(appearancePrompt);
if (!userRequest.isEmpty()) {  // ← 빈 문자열이면 이 블록 건너뜀
    sb.append(", ").append(userRequest);
}
sb.append(", selfie, casual photo angle");

빈 문자열이면 — appearancePrompt + selfie suffix 만 붙어서 기본 self portrait 식의 셀카가 도착해요. Soulmate 의 외모로 평범한 셀카 — 사용자가 "아무 셀카 보내줘" 라고 한 결과로 자연스러움. ✅

7. 💡 튜터의 결론 — Step 9 한 줄

"챗 셀카 요청은 세 군데에서 책임이 분리되는 모습이다. SelcaService.isSelcaRequest() 가 키워드 분기, SelcaService.composePrompt() 가 외모 일관성 합성, SelcaService.generate() 가 가드 우회 응답 변환 — 셋이 한 클래스에 모이지만, 각각이 다른 식의 책임이라 메서드 단위로 또렷이 갈린다. 가드 한도 초과 시 LLM 응답의 aiMessage 만 정확히 덮어쓰고 choices · affectionDelta 는 살리는 방식 — 덮어쓸 부분만 정확히 덮어쓰는 감각이 prod 코드의 정중앙이다."

8. 후속 fix — 2026-05 prod 시연에서 발견한 두 군데 (정규식 정밀화 · 동적 자산 분기)

자, § 1~7 의 책임 분리 세 군데 + LLM 응답 덮어쓰기 가 prod 에서 실제로 굴러가니 — 학생이 손으로 채팅을 치는 단계에서 깐 직후엔 안 보였던 구멍 두 개가 튀어나왔어요. 하나는 정규식의 함정 (코드 차원), 다른 하나는 정적 경로 가정의 함정 (백엔드 ↔ 프론트 데이터 정합성 차원). 두 가지 모두 "공통 가정이 prod 의 다양한 모양을 만나면 어떻게 깨지는지" 의 모습이라 — 학습 메시지로 적어두고 갈게요.

정규식 정밀화 — 단일 키워드가 만든 무한 루프 🔁

처음 깐 SELCA_PATTERN단일 키워드 만 잡았어요 — (?i)(셀카|셀피|사진|selfie|selca). 깔끔하고 짧죠. 그런데 prod 시연 에서 순환 루프 가 터졌어요. 시나리오:

  1. 사용자: "셀카 보내줘"isSelcaRequest true → 셀카 한 장 도착 + LLM 응답 aiMessage = "응 방금 찍었어" + choices = ["사진 잘 나왔어", "셀카 이쁘다", "오늘 사진 너무 좋아"]
  2. 사용자가 "셀카 이쁘다" choice 클릭 → 그 문자열이 새 user message 로 전송
  3. isSelcaRequest("셀카 이쁘다")단일 키워드 매칭 으로 true! → 또 셀카 한 장 생성
  4. 응답의 choices 에 또 "사진 잘 나왔어" 같은 감상 들어옴 → 사용자 클릭 → 또 true → ️

무한 루프 + 비용 누수 + UX 좌절감 의 삼중주. 학생이 셀카 한 장 받고 답 한 마디 했을 뿐인데 30회 한도가 순식간에 차서 가드의 캐릭터 인격 우회만 보게 되는 함정이에요.

보호 한 줄의 패턴 — 명령형 정밀화

// Before — 단일 키워드 (오감지의 패턴)
private static final Pattern SELCA_PATTERN = Pattern.compile(
        "(?i)(셀카|셀피|사진|selfie|selca)");

// After — 키워드 + *요청 동사* 12자 이내 결합 (명령형만)
private static final Pattern SELCA_PATTERN = Pattern.compile(
        "(?i)(셀카|셀피|사진|selfie|selca).{0,12}(보내|찍어|찍자|찍|보여|줄래|보낼래|줘\\b|줘$|줘\\s)");

오감지 차단 사례:

사용자 입력 Before 매칭 After 매칭 비고
"셀카 보내줘" 진짜 요청 — 둘 다 잡음
"셀카 이쁘다" ❌ → ✅ (오감지) 동사 없음 — After 만 정확
"사진 잘 나왔어" ❌ → ✅ (오감지) 동사 없음 — After 만 정확
"오늘 사진 너무 좋아" ❌ → ✅ (오감지) 동사 없음 — After 만 정확
"이쁜 셀카 보여줘" 진짜 요청 — 둘 다 잡음
"이 사진 한번 봐줘" ❌ → ✅ (오감지) ✅ (여전히 오감지) 동사 보유 감상 한계 — Day 11 에서 해결

.{0,12} 의 12 자 결정 — 한국어로 "셀카 한 장 더 보내줘" 같은 키워드와 동사 사이에 자연어 부속어 가 끼는 모양을 흡수하기 위한 폭이에요. 너무 길게 (.*) 잡으면 "셀카 잘 안 나왔는데 다음에 다시 찍어줘" 같은 불만 → 요청의 혼합 메시지에서 불만 부분 까지 매칭. 12 자가 명령형만 정확히 잡는 경험적 sweet spot. 🎯

프론트/백엔드 정규식 100% 일치 강조 — 백엔드만 정밀화하면 안 돼요. 프론트 (chat/index.js) 의 입력 자리 도 셀카 요청 감지 → 로딩 폴라로이드 표시 같은 낙관적 UI 렌더 를 같은 정규식으로 분기. 백엔드만 갱신하면 프론트는 옛 정규식으로 로딩 표시 → 백엔드는 새 정규식으로 셀카 생성 안 함 → 로딩 폴라로이드가 영원히 깜빡이는 상태. 백엔드 ↔ 프론트 정규식이 글자 단위로 같아야 데이터플로우가 닫힌다.

💡 튜터의 운영 메모 — 정규식을 두 군데에 넣는 건 위험 신호. 운영에선 정규식 한 줄을 백엔드 API (GET /api/chat/selca-pattern) 로 노출하고 프론트가 부팅 시 받아오는 방식, 또는 코드 생성기로 단일 source 에서 백/프 양쪽 코드 자동 생성 같은 패턴이 정석. 본 강의는 학습용 단순화로 양쪽 정규식을 손으로 넣는 방식을 택했어요. 정규식 변경 시 반드시 양쪽 같이 — Day 19 (Harness) 시점에 contract test 에서 다시 만나요.

🎯 면접관을 홀리는 핵심 멘트 ① — "SELCA_PATTERN 의 정밀화는 정규식의 정정확도가 중요한 자리가 아니라 LLM 응답 choices 가 사용자 입력으로 환류하는 결 에 대한 방어선이에요. 단일 키워드 매칭이 LLM 이 만든 "셀카 이쁘다" 같은 감상 응답 까지 셀카 요청 으로 잡으니 무한 루프 + 비용 누수 가 발생. 키워드 + 요청 동사 12자 이내 결합으로 명령형만 잡는 방식으로 막았어요. 진짜 정확도는 Day 11 Tool Calling 의 의도 분류로 진화 — 오늘은 단순 명령형 매칭 으로 prod 결합부터 닫는 모양입니다."

chat 화면의 커스텀 캐릭터 portrait 분기 — 정적 경로 가정의 함정 🪞

이 부분은 프론트엔드 식이라 코드 인용은 코드베이스 브랜치에 맡겨두고 (chat/index.jsapplyCharacterTheme · setStageBg), 학습 메시지의 맥락 만 적을게요. 백엔드 강의의 정체성 유지 + 데이터 정합성 두 마리.

깨진 부분 — chat 화면이 캐릭터의 portrait + 배경 을 표시할 때, 정적 경로 조립 공식 으로만 깔려 있었어요:

/images/characters/${characterImageId}-face.jpg
/images/chat-bg/chat-bg-${characterImageId}-${mood}.jpg

이 공식은 프리셋 4 트랙 (male-cheerful · male-calm · female-warm · female-bright) 에선 정확히 작동해요 — 그 네 경로에 실제 파일이 자리잡혀있으니까. 그런데 커스텀 트랙 (characterImageId = "custom") 은 — 그 정적 경로에 파일이 존재하지 않아요. 404 떨궈서 broken image 가 화면에 들어감. 캐릭터 만들기 wizard 의 동적 생성 portrait (./uploads/portraits/portrait-xxx.jpg) 은 어디서도 참조 안 됨.

보호 한 줄의 패턴 — 분기 한 줄. characterImageId === "custom" 이면 — 정적 경로 조립 공식을 건너뛰고 Soulmate.characterImageUrl (= 동적 생성된 portrait 의 정적 리소스 경로) 을 직접 portrait/background CSS 변수에 넣음. 프리셋이면 공식 작동, 커스텀이면 직접 URL.

트랙 portrait URL 결정 배경 URL 결정
프리셋 (①~④) 정적 경로 조립 공식 /images/characters/${id}-face.jpg 정적 경로 조립 공식 + mood /images/chat-bg/chat-bg-${id}-${mood}.jpg
커스텀 (⑤) Soulmate.characterImageUrl 직접 사용 같은 URL 사용 (mood 분기 없음 — 생성된 portrait 1 장만 존재)

학습 메시지 (백엔드 방식으로 추상화) — 동적 자산이 정적 경로 가정과 만나는 지점 의 느낌이에요. 정적 경로 조립 공식은 디자인 자산이 코드와 함께 빌드되는 가정 위에 깔려 있어요 — 4 프리셋의 face/bg 이미지가 src/main/resources/static/images/... 에 같이 들어가는 방식. 그런데 유저가 생성한 동적 자산 은 — 런타임에 만들어지는 식이라 빌드 시점의 정적 경로 가정 안에 안 들어가요. 두 식의 충돌 지점에 분기 한 줄 이 깃듭니다. 🎯

이 결은 Day 8 (Vision) 으로도 흘러가요. Day 8 의 사용자 업로드 이미지도 동적 자산이라 — 정적 경로 조립 공식 안에 들어가지 않는 흐름이 또 등장합니다. Day 7 의 분기 한 줄 이 Day 8 의 multipart 업로드 패턴 과 느낌이 닮아있어요. 🪞

🎯 면접관을 홀리는 핵심 멘트 ② — "chat 화면의 정적 경로 조립 공식 (/images/characters/${id}-face.jpg) 이 프리셋 4 트랙 에선 작동하지만 커스텀 트랙 (id = "custom") 에선 404 broken image. 보호는 분기 한 자리 — id === "custom" 이면 Soulmate.characterImageUrl 을 직접 넣음. 동적 자산 vs 정적 경로 가정 의 충돌 자리는 Day 8 의 사용자 업로드 이미지 자리와 느낌이 같아요. 디자인 자산이 코드와 함께 빌드되는 가정 이 유저 생성 동적 자산 을 만나면 항상 분기 한 자리 가 깃들어요."

🎯 § 8 한 줄 요약 — 공통 가정의 깨진 두 군데 모두 prod 시연에서만 보이는 결

구분 깨진 결 우리가 적은 보호 한 줄
⑦ 정규식 정밀화 단일 키워드 매칭이 LLM choices 의 감상 응답까지 잡아 무한 루프 키워드 + 요청 동사 12자 이내 결합으로 명령형만
⑧ chat 커스텀 portrait 정적 경로 조립 공식이 동적 생성 자산 (id="custom") 을 못 잡아 404 id === "custom" 분기로 Soulmate.characterImageUrl 직접 사용

"§ 1~7 의 책임 분리 3 자리 가 코드 작성 시점에 보이는 모양이었다면, ⑦⑧ 은 prod 시연을 돌려야 보이는 자리들이다. 컨트롤러/서비스 단위로 보기엔 정상으로 보이는 자리가, 학생이 실제로 채팅을 치고 캐릭터 만들기를 끝까지 클릭해야 드러나는 패턴. 시연의 가치는 코드 작성 시점에 안 보이는 부분 을 드러내는 것 이다. 우리가 prod 시연을 자가검증의 한 축 으로 적어두는 이유의 정중앙."

이 방식으로 — Day 7.5 부록의 두 prod 결합이 닫혔어요. 본 교안 Day 7 의 마무리 섹션 으로 돌아가서 오늘 손에 쥔 도구들 을 회상하고, lab → prod 흡수 흐름을 정리하고, 다음 시간 (Day 8) 만날 생성 → 인식 → 대화 의 장면을 닫는 한 줄로 마무리하러 갑시다.


🔁 본 교안으로 돌아가기

부록을 다 따라왔다면 — Day 7 본 교안의 🎯 마무리 섹션 으로 돌아가서 오늘의 일곱 가지 모습 + 브랜치 박제 + Day 8 복선 클로저까지 닫아주세요. 부록까지 따라온 학생은 Before/After 리팩토링 흐름의 lab → prod 흡수가 같은 Day 안에 닫힌 첫 사례 라는 표를 그대로 가져갈 수 있어요.

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.