ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring Boot + OpenAI API로 의류 소재 설명 자동 생성하기
    프로젝트, 트러블슈팅 2026. 3. 5. 10:04

     

    시작하면서

    온길 프로젝트는 60대 이상 어르신을 위한 패션 쇼핑몰이다. 상품 페이지에는 "면 60%, 폴리에스터 40%" 같은 소재 정보가 표시되는데, 어르신들 입장에서는 이게 무슨 의미인지 바로 알기 어렵다.

    그래서 이 소재 정보를 AI에게 넘겨서 이런 식으로 자동 변환해주는 기능을 만들었다.

    면 60%, 폴리에스터 40%
             ↓ AI 변환
    장점: 땀 흡수가 잘 돼요 / 피부에 자극 없어요
    단점: 구겨지기 쉬워요 / 오래 입으면 늘어나요
    세탁법: 찬물에 손세탁해요 / 그늘에서 말려요
    

    LLM이란?

    LLM(Large Language Model)은 대규모 언어 모델이다. 엄청난 양의 텍스트를 학습해서 문맥을 이해하고, 자연스러운 글을 생성할 수 있는 AI다. ChatGPT가 대표적인 예시다.

    LLM을 서비스에 연동하면 사람이 직접 작성해야 했던 텍스트를 자동으로 생성할 수 있다. 소재 설명처럼 패턴이 반복되는 작업에 특히 유용하다.


    어떤 AI를 선택했나?

    OpenAI의 GPT-3.5-turbo 모델을 선택했다.

    비교 항목 GPT-3.5-turbo GPT-4

    비용 매우 저렴 약 20배 비쌈
    응답 속도 빠름 느림
    성능 중급 고급
    우리 태스크 적합성 충분 과분

    소재 정보를 쉬운 말로 바꾸는 작업은 복잡한 추론이 필요 없다. 정해진 형식으로 짧은 문장을 생성하면 되는 단순 작업이라 GPT-3.5-turbo로도 충분하고, 비용을 대폭 절감할 수 있다.


    OpenAI API 연동하는 법

    1. API Key 발급

    platform.openai.com에서 회원가입 후 API Key를 발급받는다.

    발급받은 Key는 절대 코드에 하드코딩하면 안 된다. Git에 올라가는 순간 악용될 수 있으므로 반드시 환경변수로 관리해야 한다.

    # application.yml
    openai:
      api-key: ${OPENAI_API_KEY}  # 환경변수로 관리
    

    2. 의존성 추가

    OpenAI는 공식 Java SDK를 제공하지 않는다. Python, Node.js SDK는 있지만 Java는 없다. 그래서 openai-gpt3-java 라이브러리를 사용했다.

    // build.gradle
    dependencies {
        implementation 'com.theokanning.openai-gpt3-java:service:0.18.2'
    }
    

    이 라이브러리가 내부적으로 Retrofit2 + OkHttp를 사용해서 OpenAI API를 호출해준다. HTTP 통신 코드를 직접 짤 필요 없이 Java 객체로 편하게 다룰 수 있다.

    3. Bean 등록

    // OpenAiConfig.java
    @Configuration
    public class OpenAiConfig {
    
        @Value("${openai.api-key}")
        private String apiKey;
    
        @Bean
        public OpenAiService openAiService() {
            return new OpenAiService(apiKey, Duration.ofSeconds(60));
        }
    }
    

    Duration.ofSeconds(60)으로 타임아웃을 60초로 설정했다. LLM 응답은 일반 API보다 느려서 충분한 시간을 줘야 한다. 기본값으로 두면 타임아웃이 짧아서 응답 중에 연결이 끊길 수 있다.

    4. API 호출 코드

    ChatGPT API는 대화 형식으로 작동한다. 메시지에는 역할(role)이 있다.

    Role 역할 예시

    system AI의 역할/성격 설정 "당신은 의류 소재 전문가입니다"
    user 실제 사용자 질문 "면 100%에 대해 설명해줘"
    assistant AI의 이전 답변 (멀티턴 대화 시 사용)
    // OpenAiClient.java
    @Component
    @RequiredArgsConstructor
    public class OpenAiClient {
    
        private final OpenAiService openAiService;
    
        public String call(OpenAiRequest request) {
            // SYSTEM + USER 메시지 구성
            List<ChatMessage> messages = List.of(
                new ChatMessage(ChatMessageRole.SYSTEM.value(), request.systemPrompt()),
                new ChatMessage(ChatMessageRole.USER.value(), request.userMessage())
            );
    
            ChatCompletionRequest completionRequest = ChatCompletionRequest.builder()
                .model(request.model())
                .messages(messages)
                .temperature(request.temperature())
                .build();
    
            // API 호출 → 첫 번째 응답 텍스트 추출
            return openAiService.createChatCompletion(completionRequest)
                .getChoices().get(0).getMessage().getContent().trim();
        }
    }
    

    getChoices().get(0) → AI가 여러 개의 응답 후보를 생성할 수 있는데, 첫 번째 것만 사용한다.

    요청 DTO는 Java Record로 만들어서 나중에 다른 AI 기능(AI 검색 등)에서도 재사용할 수 있게 공통 추상화했다.

    // OpenAiRequest.java
    public record OpenAiRequest(
        String model,        // 사용할 모델명 (예: "gpt-3.5-turbo")
        String systemPrompt, // AI 역할 설정
        String userMessage,  // 실제 요청 내용
        double temperature   // 창의성 조절 (0.0 ~ 2.0)
    ) { ... }
    

    temperature란?

    • 0.0에 가까울수록 → 일관되고 예측 가능한 답변 (항상 비슷한 결과)
    • 2.0에 가까울수록 → 창의적이고 다양한 답변 (매번 다른 결과)

    소재 설명은 매번 다른 창의적인 표현이 필요 없고 형식도 고정이라 0.3으로 낮게 설정했다. 처음엔 기본값(1.0)으로 했더니 매번 다른 형식이 나왔다.


    전체 아키텍처

    [클라이언트]
        ↓ GET /api/products/{productId}
    [ProductService]
        ↓ needsAiDescription() 체크 → DB에 없을 때만
    [AiMaterialService]
        ↓
    [OpenAiClient] → OpenAI API 호출
        ↓
    응답 파싱 → product.updateAiMaterialDescription()
        ↓
    DB 저장 (이후 조회는 DB에서 바로 읽음)
        ↓
    [ProductConverter] → DB 텍스트 → List<String> 변환
        ↓
    [ProductDetailResponse] 반환
    

    핵심 설계는 동일 소재는 AI를 딱 한 번만 호출한다는 것이다. 최초 상품 상세 조회 시에만 API를 호출하고 결과를 DB에 저장해두면, 이후 모든 조회는 DB에서 읽기만 한다. API 비용과 응답 지연을 동시에 줄이는 방식이다.


    프롬프트 엔지니어링

    프롬프트 엔지니어링은 AI에게 원하는 결과를 얼마나 잘 지시하느냐의 기술이다. 코드보다 프롬프트 설계가 더 중요할 때도 있다.

    private String createPrompt(String material) {
        return String.format("""
            다음 의류 소재에 대해 설명해주세요.
    
            소재: %s
    
            아래 형식으로 정확히 작성해주세요:
    
            [장점]
            장점1
            장점2
    
            [단점]
            단점1
            단점2
    
            [세탁방법]
            세탁1
            세탁2
    
            조건:
            - 각 섹션당 최대 4개 문장
            - 한 문장은 공백 포함 최대 16자
            - 전문 용어, 추상적 표현 사용 금지
            - 초등학생도 이해할 수 있는 쉬운 말만 사용
            - '~해요' 말투 사용
    
            [단점 작성 규칙]
            - 단점에는 세탁, 빨래, 물, 건조 등 관리 관련 내용 절대 작성 금지
            - 착용감, 촉감, 무게, 내구성만 작성
            """, material);
    }
    

    프롬프트 설계에서 신경 쓴 부분이 네 가지 있다.

    출력 형식 고정: [장점], [단점], [세탁방법] 태그를 명시했다. AI 응답을 코드에서 파싱해야 하기 때문에 형식이 일정해야 한다. 형식이 자유롭게 나오면 파싱이 불가능하다.

    글자수 제한 16자: 모바일 화면의 카드 UI에 맞게 한 문장이 너무 길면 안 된다. 처음엔 이 제약 없이 만들었다가 UI에서 줄바꿈이 깨지는 문제가 생겨서 추가했다.

    타겟 사용자 명시: System 프롬프트에 "60대 이상 어르신이 이해하기 쉽게"라고 명시했다. 그냥 "설명해줘"라고 하면 "폴리우레탄 코팅으로 방수 효과가 뛰어남" 같은 전문 용어가 나온다. 타겟을 명시하면 "물이 튕겨 나와요" 같은 쉬운 표현이 나온다.

    단점 중복 방지: 초기에 단점에 "물세탁하면 줄어들어요"가 나왔다. 세탁법과 내용이 겹쳐서 단점과 세탁법의 경계를 명확하게 프롬프트에 명시했다.


    응답 파싱

    private AiMaterialDescriptionResponse parseResponse(String response) {
        // [장점], [단점], [세탁방법] 태그로 분리
        String[] sections = response.split("\\[장점\\]|\\[단점\\]|\\[세탁방법\\]");
    
        // sections[0]: 태그 앞 빈 문자열
        // sections[1]: 장점 내용
        // sections[2]: 단점 내용
        // sections[3]: 세탁방법 내용
        if (sections.length < 4) {
            return AiMaterialDescriptionResponse.createDefault();
        }
    
        return AiMaterialDescriptionResponse.builder()
            .advantages(cleanSection(sections[1]))
            .disadvantages(cleanSection(sections[2]))
            .care(cleanSection(sections[3]))
            .build();
    }
    
    private String cleanSection(String section) {
        return section.trim()
            .replaceAll("^-\\s*", "")    // 맨 앞 "- " 제거
            .replaceAll("\n-\\s*", "\n") // 각 줄 앞 "- " 제거
            .trim();
    }
    

    AI가 가끔 - 장점1 처럼 앞에 -를 붙여서 줄 때가 있어서 cleanSection()으로 정리한다. sections.length < 4 체크는 AI가 형식을 어겼을 때를 대비한 방어 코드다. 처음엔 이 처리 없이 만들었다가 파싱 오류가 발생했다.


    DB 캐싱 전략

    Product 엔티티에 AI 결과 컬럼을 추가했다.

    // Product.java
    @Column(name = "ai_material_advantages", columnDefinition = "TEXT")
    private String aiMaterialAdvantages;
    
    @Column(name = "ai_material_disadvantages", columnDefinition = "TEXT")
    private String aiMaterialDisadvantages;
    
    @Column(name = "ai_material_care", columnDefinition = "TEXT")
    private String aiMaterialCare;
    

    columnDefinition = "TEXT" → 일반 VARCHAR는 길이 제한이 있어서, AI 결과는 TEXT 타입으로 저장했다.

    Redis 같은 캐시 서버 대신 DB에 직접 저장한 이유는, AI 결과는 영구적으로 재사용되어야 하기 때문이다. Redis는 TTL이 있어서 만료될 수 있고, 소재가 바뀌지 않는 한 장점/단점/세탁법도 변하지 않는다.

    최초 1회만 호출하는 로직은 이렇다.

    // ProductService.java
    @Transactional
    public ProductDetailResponse getProductDetail(Long productId) {
        Product product = productRepository.findById(productId)
            .orElseThrow(() -> new EntityNotFoundException(ErrorCode.PRODUCT_NOT_FOUND));
    
        // AI 설명이 아직 없는 경우에만 생성
        if (needsAiDescription(product)) {
            generateAndSaveAiDescription(product);
        }
    
        return productConverter.toDetailResponse(product);
    }
    
    private boolean needsAiDescription(Product product) {
        return product.getAiMaterialAdvantages() == null
            || product.getAiMaterialDisadvantages() == null
            || product.getAiMaterialCare() == null;
    }
    
    private void generateAndSaveAiDescription(Product product) {
        AiMaterialDescriptionResponse ai =
            aiMaterialService.generate(product.getMaterialOriginal());
    
        // @Transactional + 더티 체킹으로 자동 저장 (save() 호출 불필요)
        product.updateAiMaterialDescription(
            ai.getAdvantages(),
            ai.getDisadvantages(),
            ai.getCare()
        );
    }
    

    @Transactional이 붙어 있어서 product.updateAiMaterialDescription()만 호출하면 JPA 더티 체킹이 변경을 감지해서 메서드 종료 시 자동으로 DB에 저장된다. 별도의 save() 호출이 필요 없다.


    Fallback 처리

    AI 호출이 실패해도 서비스는 정상 운영돼야 한다.

    // AiMaterialService.java
    public AiMaterialDescriptionResponse generate(String material) {
        try {
            String response = openAiClient.call(request);
            return parseResponse(response);
    
        } catch (OpenAiException e) {
            // API 호출 자체가 실패한 경우 (네트워크, 인증 오류 등)
            log.warn("[AiMaterialService] AI 호출 실패, 기본값 반환. 소재: {}", material);
            return AiMaterialDescriptionResponse.createDefault();
    
        } catch (Exception e) {
            // 응답은 받았지만 파싱이 실패한 경우 (AI가 형식을 어겼을 때)
            log.warn("[AiMaterialService] 응답 파싱 실패, 기본값 반환. 소재: {}", material);
            return AiMaterialDescriptionResponse.createDefault();
        }
    }
    
    // 기본값
    public static AiMaterialDescriptionResponse createDefault() {
        return builder()
            .advantages("착용감이 좋습니다\n품질이 우수합니다")
            .disadvantages("특별한 단점이 없습니다")
            .care("제품 라벨의 세탁 방법을 따라주세요")
            .build();
    }
    

    예외를 두 종류로 나눈 이유는 원인 파악을 위해서다. API 호출 실패와 파싱 실패는 원인이 다르기 때문에 로그를 다르게 남긴다.

    상황 처리 방법

    OpenAI API 서버 장애 OpenAiException catch → 기본값 반환
    API Key 만료/초과 동일
    AI가 형식을 어긴 응답 Exception catch → 기본값 반환
    네트워크 타임아웃 60초 후 예외 → 기본값 반환

    응답 변환

    DB에는 줄바꿈으로 구분해서 저장하고, 응답 시 List<String>으로 변환해서 프론트에 전달한다.

    // "땀 흡수가 잘 돼요\n피부에 자극 없어요" → ["땀 흡수가 잘 돼요", "피부에 자극 없어요"]
    private List<String> splitByNewLine(String text) {
        if (text == null || text.trim().isEmpty()) {
            return Collections.emptyList();
        }
        return Arrays.stream(text.split("\n"))
            .map(String::trim)
            .filter(s -> !s.isEmpty())
            .collect(Collectors.toList());
    }
    

    최종 API 응답 형태는 이렇다.

    {
      "materialOriginal": "면 100%",
      "materialDescription": {
        "advantages": ["땀 흡수가 잘 돼요", "피부에 자극 없어요", "가볍고 시원해요"],
        "disadvantages": ["구겨지기 쉬워요", "오래 입으면 늘어나요"],
        "care": ["찬물에 손세탁해요", "그늘에서 말려요"]
      }
    }
    

    구현하면서 겪은 이슈들

    이슈 1 — AI가 형식을 가끔 어겼다: 처음엔 sections.length < 4일 때를 처리 안 했다가 파싱 오류가 발생했다. AI는 항상 정해진 형식을 지키지 않을 수 있어서 파싱 실패에 대한 방어 코드가 필수다.

    이슈 2 — 단점에 세탁 내용이 섞였다: 초기에 단점에 "물세탁하면 변형돼요" 같은 문장이 나왔다. 세탁법과 내용이 겹쳐서 프롬프트에 명시적인 금지 규칙을 추가했다.

    이슈 3 — 글자수가 들쭉날쭉했다: 16자 제한을 줬는데도 가끔 초과하는 문장이 나왔다. "16자를 초과하면 반드시 다시 줄여서 수정"이라고 강조해서 어느 정도 해결했지만, LLM은 100% 제약을 보장하지 않는다. 파싱 후 후처리 코드로 잘라내는 방법도 고려할 수 있다.

    이슈 4 — temperature 튜닝: 처음엔 기본값(1.0)으로 했더니 매번 다른 형식이 나왔다. 0.3으로 낮추니 형식이 훨씬 일관성 있게 나왔다.


    마치며

    • LLM 연동은 생각보다 코드가 간단하다. 핵심은 프롬프트 설계
    • 출력 형식을 고정하고, 타겟 사용자를 명시하고, 중복 방지 규칙을 추가하는 것만으로도 결과가 크게 달라진다
    • AI 호출 결과는 DB에 캐싱해서 동일 소재는 한 번만 호출하도록 설계하면 비용과 응답 속도를 모두 잡을 수 있다
    • Fallback 처리는 필수다. AI는 언제든 실패할 수 있다
Designed by Tistory.