본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2026. 1. 31. · 8 Views
구조화된 출력으로 AI 응답을 안전하게 받는 법
LLM의 자유로운 텍스트 응답을 JSON 스키마로 제약하고, Kotlin 데이터 클래스로 안전하게 매핑하는 방법을 배웁니다. Output Parser와 검증 전략까지 실무에서 바로 쓸 수 있는 완전한 가이드입니다.
목차
1. 구조화된 출력의 필요성
어느 날 김개발 씨가 LLM API를 호출해서 제품 정보를 추출하는 기능을 만들었습니다. 처음에는 잘 작동하는 것처럼 보였지만, 가끔 응답 형식이 달라져서 파싱 에러가 발생했습니다.
선배 박시니어 씨가 코드를 보더니 말했습니다. "구조화된 출력을 사용하지 않았네요.
그래서 불안정한 거예요."
구조화된 출력이란 LLM의 자유로운 텍스트 응답을 미리 정의한 스키마에 맞춰 받는 것을 말합니다. 마치 레스토랑 주문서처럼, 정해진 양식에 따라 답변을 받는 방식입니다.
이를 통해 응답을 안전하게 파싱하고, 타입 안정성을 확보할 수 있습니다.
다음 코드를 살펴봅시다.
data class ProductInfo(
val name: String,
val price: Int,
val category: String,
val inStock: Boolean
)
// LLM에게 구조화된 형태로 응답을 요청
val prompt = """
다음 텍스트에서 제품 정보를 추출해주세요.
반드시 JSON 형식으로 응답해주세요: {"name": "...", "price": 0, "category": "...", "inStock": true}
텍스트: 갤럭시 S24는 120만원이며, 현재 재고가 있습니다.
""".trimIndent()
김개발 씨는 입사 6개월 차 백엔드 개발자입니다. 최근 회사에서 AI 기능을 도입하면서 LLM API를 처음 다루게 되었습니다.
고객 리뷰에서 제품 정보를 자동으로 추출하는 기능을 만들어야 했습니다. 처음에는 간단해 보였습니다.
LLM에게 "제품 정보를 추출해줘"라고 요청하면 될 것 같았습니다. 실제로 테스트해보니 잘 작동했습니다.
LLM은 "제품명: 갤럭시 S24, 가격: 120만원"처럼 친절하게 답변해주었습니다. 하지만 문제는 다음 날 시작되었습니다.
어떤 요청에서는 "제품: 갤럭시 S24 (120만원)"이라고 답변했고, 또 다른 요청에서는 "갤럭시 S24 - 가격 120만원, 재고 있음"이라고 답변했습니다. 매번 형식이 달라서 파싱 로직이 복잡해졌고, 예외 처리 코드가 늘어났습니다.
박시니어 씨가 김개발 씨의 모니터를 보며 말했습니다. "LLM은 창의적이라서 매번 다르게 답변할 수 있어요.
그래서 구조화된 출력이 필요합니다." 구조화된 출력이란 무엇일까요? 쉽게 비유하자면, 구조화된 출력은 마치 정부 민원 서식과 같습니다.
자유롭게 글을 쓰는 게 아니라 이름, 주소, 연락처처럼 정해진 칸에 맞춰 작성하는 것입니다. LLM도 마찬가지입니다.
자유롭게 답변하게 하는 게 아니라, 미리 정의한 JSON 스키마에 맞춰 응답하도록 강제하는 것입니다. 구조화된 출력이 없던 시절에는 어땠을까요?
개발자들은 LLM의 다양한 응답 형식을 모두 처리하는 복잡한 파싱 로직을 작성해야 했습니다. 정규식, 문자열 split, 예외 처리 등이 뒤섞여 코드가 지저분해졌습니다.
더 큰 문제는 새로운 형식의 응답이 나올 때마다 파싱 로직을 수정해야 한다는 점이었습니다. 프로덕션 환경에서 예상치 못한 형식이 나오면 서비스가 멈췄습니다.
바로 이런 문제를 해결하기 위해 구조화된 출력이 등장했습니다. 구조화된 출력을 사용하면 일관된 형식의 응답을 받을 수 있습니다.
또한 타입 안정성도 얻을 수 있습니다. 무엇보다 파싱 로직이 단순해진다는 큰 이점이 있습니다.
JSON을 Kotlin 데이터 클래스로 바로 변환하면 끝입니다. 위의 코드를 살펴보겠습니다.
먼저 ProductInfo라는 데이터 클래스를 정의했습니다. 이것이 우리가 받고 싶은 응답의 형태입니다.
name은 제품명, price는 가격, category는 카테고리, inStock은 재고 여부를 나타냅니다. 프롬프트에서는 LLM에게 "반드시 JSON 형식으로 응답해주세요"라고 명시적으로 요청합니다.
예시 형식까지 보여주면 LLM이 정확히 이해합니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 전자상거래 플랫폼을 개발한다고 가정해봅시다. 고객이 작성한 수천 개의 리뷰에서 제품 정보, 감정, 별점 등을 자동으로 추출해야 합니다.
구조화된 출력을 사용하면 모든 리뷰를 일관된 형식으로 처리할 수 있습니다. 파싱 에러 없이 안정적으로 데이터를 수집하고, 데이터베이스에 저장할 수 있습니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 프롬프트에만 "JSON으로 답변해줘"라고 쓰고 검증을 하지 않는 것입니다.
LLM은 가끔 실수할 수 있습니다. 따라서 응답을 받은 후 반드시 스키마 검증을 해야 합니다.
잘못된 형식이면 재시도하거나 에러 처리를 해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 그래서 JSON 스키마가 필요한 거군요!" 구조화된 출력을 제대로 이해하면 LLM을 안정적으로 활용할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 프롬프트에 예시 JSON 형식을 명시하면 LLM이 더 정확하게 응답합니다
- 응답 검증 로직을 반드시 추가하세요
- OpenAI의 경우 response_format 파라미터로 JSON 모드를 강제할 수 있습니다
2. JSON 스키마 정의
김개발 씨가 구조화된 출력의 필요성은 이해했지만, 복잡한 데이터는 어떻게 정의해야 할지 막막했습니다. 제품 정보 하나만 추출하는 게 아니라 여러 제품, 중첩된 옵션, 배열 데이터까지 처리해야 했습니다.
박시니어 씨가 "JSON 스키마를 제대로 설계해야 해요"라고 조언했습니다.
JSON 스키마는 JSON 데이터의 구조와 타입을 명시하는 명세서입니다. 마치 건축 설계도처럼, 데이터가 어떤 필드를 가지고 있고 각 필드가 어떤 타입인지 정의합니다.
이를 통해 LLM이 정확한 형식으로 응답하도록 가이드할 수 있습니다.
다음 코드를 살펴봅시다.
data class ProductOption(
val color: String,
val size: String,
val additionalPrice: Int
)
data class Product(
val name: String,
val basePrice: Int,
val category: String,
val options: List<ProductOption>,
val tags: List<String>,
val inStock: Boolean
)
// JSON 스키마를 문자열로 정의
val schema = """
{
"name": "string (제품명)",
"basePrice": "number (기본 가격)",
"category": "string (카테고리)",
"options": [{"color": "string", "size": "string", "additionalPrice": "number"}],
"tags": ["string"],
"inStock": "boolean (재고 여부)"
}
""".trimIndent()
김개발 씨는 단순한 제품 정보 추출을 넘어서 실전 시나리오를 마주했습니다. 전자상거래 플랫폼에서는 한 제품에 여러 옵션이 있고, 각 옵션마다 가격이 다릅니다.
예를 들어 티셔츠는 색상별, 사이즈별로 다양한 조합이 존재합니다. "이런 복잡한 데이터는 어떻게 구조화하죠?" 김개발 씨가 물었습니다.
박시니어 씨는 화이트보드에 그림을 그리기 시작했습니다. "먼저 데이터의 계층 구조를 파악해야 해요.
제품은 최상위 객체이고, 그 안에 옵션 배열이 있고, 각 옵션은 또 다른 객체죠." JSON 스키마란 정확히 무엇일까요? 쉽게 비유하자면, JSON 스키마는 마치 레고 조립 설명서와 같습니다.
어떤 블록이 어디에 들어가야 하는지, 몇 개가 필요한지 정확히 알려줍니다. 데이터도 마찬가지입니다.
어떤 필드가 필수이고, 어떤 타입이어야 하며, 중첩 구조는 어떻게 되는지 명확히 정의합니다. JSON 스키마 없이 복잡한 데이터를 다루면 어떤 일이 벌어질까요?
개발자들은 LLM에게 "옵션 정보도 포함해줘"라고 막연하게 요청했습니다. 결과는 예측 불가능했습니다.
어떤 경우에는 옵션이 문자열로 반환되고, 어떤 경우에는 배열로, 또 어떤 경우에는 중첩 객체로 반환되었습니다. 파싱 로직은 점점 복잡해지고, 예외 상황은 끝없이 늘어났습니다.
바로 이런 혼란을 방지하기 위해 JSON 스키마 정의가 필수입니다. JSON 스키마를 명확히 정의하면 데이터 구조가 일관됩니다.
또한 타입 안정성을 확보할 수 있습니다. 무엇보다 LLM이 정확히 이해하고 스키마에 맞춰 응답할 수 있다는 장점이 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 ProductOption이라는 데이터 클래스를 정의했습니다.
이것은 제품의 개별 옵션을 나타냅니다. color는 색상, size는 사이즈, additionalPrice는 추가 금액입니다.
다음으로 Product 클래스에서는 options 필드가 List<ProductOption> 타입입니다. 이것이 중첩 구조입니다.
tags는 문자열 배열로, 제품의 특징을 태그로 저장합니다. schema 변수에는 LLM에게 전달할 스키마 명세를 문자열로 작성했습니다.
각 필드의 타입과 의미를 주석으로 설명했습니다. 이 스키마를 프롬프트에 포함하면 LLM이 정확히 이해하고 응답합니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 쇼핑몰의 제품 등록 시스템을 개발한다고 가정해봅시다.
판매자가 자유 형식으로 제품 정보를 입력하면, LLM이 이를 구조화된 JSON으로 변환합니다. 색상 옵션, 사이즈 옵션, 가격 정보가 모두 정확한 구조로 파싱됩니다.
이 데이터를 데이터베이스에 저장하고, 프론트엔드에서 일관된 UI로 표시할 수 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 스키마를 너무 복잡하게 만드는 것입니다. 중첩이 3단계, 4단계로 깊어지면 LLM도 실수할 확률이 높아집니다.
따라서 스키마는 가능한 한 평평하게 설계해야 합니다. 정말 필요한 경우에만 중첩 구조를 사용하세요.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 직접 스키마를 작성해보기 시작했습니다.
"이렇게 명확히 정의하니까 훨씬 안정적이네요!" JSON 스키마를 제대로 설계하면 복잡한 데이터도 안전하게 다룰 수 있습니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 스키마에 주석으로 각 필드의 의미를 설명하면 LLM이 더 정확하게 이해합니다
- 중첩 깊이는 2단계 이내로 제한하는 것이 좋습니다
- 선택적 필드는 nullable 타입으로 정의하세요
3. Kotlin 데이터 클래스 매핑
김개발 씨가 JSON 스키마를 정의하고 LLM으로부터 응답을 받았습니다. 하지만 문자열 형태의 JSON을 어떻게 Kotlin 객체로 변환해야 할지 막막했습니다.
박시니어 씨가 "Jackson이나 kotlinx.serialization을 쓰면 간단해요"라고 알려주었습니다.
데이터 클래스 매핑이란 JSON 문자열을 Kotlin의 타입 안전한 객체로 변환하는 과정입니다. 마치 번역기처럼, JSON 형식의 데이터를 Kotlin이 이해할 수 있는 클래스 인스턴스로 바꿔줍니다.
이를 통해 타입 체크, 자동 완성, 컴파일 타임 안정성을 얻을 수 있습니다.
다음 코드를 살펴봅시다.
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
data class ProductInfo(
val name: String,
val price: Int,
val category: String,
val inStock: Boolean
)
// JSON 문자열을 Kotlin 객체로 변환
fun parseProductInfo(jsonString: String): ProductInfo {
val mapper = jacksonObjectMapper()
return mapper.readValue<ProductInfo>(jsonString)
}
// 사용 예시
val jsonResponse = """{"name":"갤럭시 S24","price":1200000,"category":"스마트폰","inStock":true}"""
val product = parseProductInfo(jsonResponse)
println("제품명: ${product.name}, 가격: ${product.price}원")
// 출력: 제품명: 갤럭시 S24, 가격: 1200000원
김개발 씨는 LLM으로부터 JSON 응답을 받는 데는 성공했습니다. 하지만 이것은 단순한 문자열일 뿐입니다.
실제 비즈니스 로직에서 사용하려면 Kotlin 객체로 변환해야 했습니다. "문자열을 split해서 파싱해야 하나요?" 김개발 씨가 물었습니다.
박시니어 씨가 웃으며 고개를 저었습니다. "그건 2000년대 방식이에요.
요즘은 직렬화 라이브러리를 사용합니다. Jackson이나 kotlinx.serialization 같은 라이브러리가 자동으로 변환해줘요." 데이터 클래스 매핑이란 무엇일까요?
쉽게 비유하자면, 데이터 클래스 매핑은 마치 해외 직구 물건의 통관 과정과 같습니다. 외국어로 적힌 상품 정보를 우리가 읽을 수 있는 형태로 변환하는 것입니다.
JSON 문자열도 마찬가지입니다. 사람이 읽을 수 있는 형태이지만, Kotlin 코드에서 안전하게 사용하려면 타입이 있는 객체로 변환해야 합니다.
데이터 클래스 매핑 없이 JSON을 다루면 어떻게 될까요? 개발자들은 문자열 파싱, 타입 변환, null 체크를 수동으로 해야 했습니다.
"name" 필드를 가져올 때마다 getString(), getInt() 같은 메서드를 호출하고, 예외 처리를 해야 했습니다. 실수로 필드명을 잘못 입력하면 런타임 에러가 발생했습니다.
IDE의 자동 완성도 작동하지 않았습니다. 바로 이런 불편함을 해결하기 위해 직렬화 라이브러리가 등장했습니다.
Jackson이나 kotlinx.serialization을 사용하면 JSON을 한 줄로 변환할 수 있습니다. 또한 컴파일 타임 타입 체크가 가능해집니다.
무엇보다 IDE 자동 완성이 작동해서 개발 생산성이 크게 향상됩니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 Jackson 라이브러리를 import했습니다. jacksonObjectMapper()는 JSON 변환을 담당하는 객체를 생성합니다.
parseProductInfo 함수에서는 mapper.readValue<ProductInfo>()를 호출합니다. 이것이 핵심입니다.
JSON 문자열을 ProductInfo 타입으로 자동 변환합니다. 사용 예시를 보면, jsonResponse라는 문자열을 parseProductInfo()에 전달하기만 하면 됩니다.
결과로 받은 product 객체는 완전한 타입 안정성을 가집니다. product.name을 입력할 때 IDE가 자동 완성을 제공하고, 타입 오류는 컴파일 시점에 잡힙니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 마이크로서비스 아키텍처에서 다른 서비스의 API를 호출한다고 가정해봅시다.
응답으로 복잡한 JSON을 받습니다. 데이터 클래스 매핑을 사용하면 이 JSON을 즉시 타입 안전한 객체로 변환하고, 비즈니스 로직에서 안전하게 사용할 수 있습니다.
필드명 오타, 타입 불일치 같은 런타임 에러를 사전에 방지합니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 JSON 필드명과 데이터 클래스 프로퍼티명이 다를 때 처리를 잘못하는 것입니다. 예를 들어 JSON에는 "product_name"인데 클래스에는 "productName"으로 정의하면 매핑이 실패합니다.
이럴 때는 @JsonProperty 어노테이션을 사용해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 직접 코드를 작성해보았습니다. "와, 정말 한 줄이면 되네요!" 데이터 클래스 매핑을 제대로 이해하면 JSON을 안전하고 편리하게 다룰 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - Jackson은 기본적으로 snake_case를 camelCase로 자동 변환합니다
- 필드명이 다를 때는 @JsonProperty("json_field_name") 어노테이션을 사용하세요
- kotlinx.serialization은 순수 Kotlin 라이브러리로 멀티플랫폼을 지원합니다
4. Output Parser 활용
김개발 씨가 JSON 파싱은 마스터했지만, LangChain 프레임워크를 도입하면서 새로운 방법을 알게 되었습니다. 박시니어 씨가 "Output Parser를 쓰면 스키마 정의부터 검증까지 자동화할 수 있어요"라고 소개했습니다.
더 이상 프롬프트에 JSON 형식을 수동으로 작성하지 않아도 되는 방법이었습니다.
Output Parser는 LangChain에서 제공하는 도구로, 데이터 클래스로부터 자동으로 스키마를 생성하고 LLM 응답을 파싱합니다. 마치 자동 번역기처럼, 개발자는 원하는 결과 타입만 정의하면 나머지는 프레임워크가 알아서 처리합니다.
이를 통해 보일러플레이트 코드를 크게 줄일 수 있습니다.
다음 코드를 살펴봅시다.
import dev.langchain4j.model.output.structured.Description
import dev.langchain4j.service.UserMessage
data class ProductAnalysis(
@Description("제품명")
val productName: String,
@Description("가격 (원 단위)")
val price: Int,
@Description("긍정적인 리뷰인지 여부")
val isPositive: Boolean,
@Description("리뷰 요약 (한 문장)")
val summary: String
)
// LangChain4j의 AI Service 인터페이스
interface ReviewAnalyzer {
@UserMessage("다음 리뷰를 분석해주세요: {{review}}")
fun analyze(review: String): ProductAnalysis
}
// 사용 예시
// val analyzer = AiServices.create(ReviewAnalyzer::class.java, chatModel)
// val result = analyzer.analyze("갤럭시 S24 정말 좋아요! 120만원이지만 그만한 가치가 있습니다.")
김개발 씨는 프로젝트가 커지면서 LLM 호출 로직이 여러 곳에 반복되는 것을 발견했습니다. 프롬프트 작성, JSON 스키마 정의, 응답 파싱, 에러 처리가 매번 중복되었습니다.
코드가 지저분해지고 유지보수가 어려워졌습니다. "좀 더 깔끔한 방법은 없을까요?" 김개발 씨가 고민했습니다.
박시니어 씨가 LangChain 문서를 보여주며 설명했습니다. "Output Parser를 쓰면 이런 반복 작업을 자동화할 수 있어요.
데이터 클래스만 정의하면 나머지는 프레임워크가 알아서 해줍니다." Output Parser란 정확히 무엇일까요? 쉽게 비유하자면, Output Parser는 마치 스마트 계약서 작성 서비스와 같습니다.
고객이 원하는 조건만 입력하면, 서비스가 자동으로 법률 용어를 채워 넣고 완전한 계약서를 만들어줍니다. 개발자도 마찬가지입니다.
원하는 결과 타입만 정의하면, Output Parser가 자동으로 JSON 스키마를 생성하고, 프롬프트에 추가하고, 응답을 파싱합니다. Output Parser 없이 LLM을 다루면 어떻게 될까요?
개발자들은 매번 프롬프트에 "다음 JSON 형식으로 응답해주세요: {...}"를 작성했습니다. 데이터 클래스가 변경되면 프롬프트의 스키마도 수동으로 수정해야 했습니다.
두 개가 불일치하면 버그가 발생했습니다. 게다가 파싱 로직도 매번 작성해야 했습니다.
코드 중복이 심했습니다. 바로 이런 비효율을 제거하기 위해 Output Parser가 등장했습니다.
Output Parser를 사용하면 스키마 생성이 자동화됩니다. 또한 타입과 프롬프트가 항상 동기화됩니다.
무엇보다 파싱과 검증이 내장되어 있어서 별도의 에러 처리 코드가 필요 없습니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 ProductAnalysis 데이터 클래스를 정의했습니다. 여기서 핵심은 @Description 어노테이션입니다.
이것은 각 필드의 의미를 설명하는 메타데이터입니다. LangChain은 이 어노테이션을 읽어서 자동으로 JSON 스키마를 생성합니다.
다음으로 ReviewAnalyzer 인터페이스를 정의했습니다. 이것은 AI Service 패턴입니다.
@UserMessage 어노테이션에 프롬프트 템플릿을 작성하면, LangChain이 자동으로 구현체를 생성합니다. analyze 함수의 반환 타입이 ProductAnalysis인 것을 보고, 프레임워크가 자동으로 Output Parser를 적용합니다.
실제로 사용할 때는 AiServices.create()로 인터페이스의 구현체를 생성합니다. 이후로는 일반 함수처럼 호출하기만 하면 됩니다.
내부적으로는 프롬프트 생성, LLM 호출, JSON 파싱, 객체 변환이 모두 자동으로 일어납니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 고객 센터 자동화 시스템을 개발한다고 가정해봅시다. 고객 문의를 분석해서 카테고리, 우선순위, 감정 등을 자동으로 추출해야 합니다.
Output Parser를 사용하면 InquiryAnalysis 데이터 클래스만 정의하면 됩니다. 나머지는 프레임워크가 알아서 처리합니다.
새로운 필드를 추가할 때도 데이터 클래스만 수정하면 스키마가 자동으로 업데이트됩니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 @Description을 생략하는 것입니다. 어노테이션이 없어도 작동하지만, LLM이 필드의 의미를 제대로 이해하지 못할 수 있습니다.
따라서 모든 필드에 명확한 설명을 추가하는 것이 좋습니다. 특히 불린 타입이나 숫자 타입은 구체적으로 설명해야 합니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 코드를 본 김개발 씨는 감탄했습니다.
"이렇게 간단해질 수가 있다니!" Output Parser를 제대로 활용하면 LLM 통합 코드가 훨씬 깔끔해집니다. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - @Description은 필수가 아니지만 정확도를 크게 높입니다
- 복잡한 타입보다는 단순한 타입을 사용하는 것이 안정적입니다
- LangChain4j는 여러 LLM 프로바이더를 지원하므로 쉽게 교체할 수 있습니다
5. 검증 및 에러 핸들링
김개발 씨의 코드가 프로덕션에 배포되었습니다. 대부분 잘 작동했지만, 가끔 LLM이 잘못된 형식으로 응답하거나 필드를 누락하는 경우가 있었습니다.
서비스가 멈추는 일이 발생했습니다. 박시니어 씨가 "검증 로직과 재시도 전략이 필요해요"라고 조언했습니다.
검증 및 에러 핸들링은 LLM 응답의 신뢰성을 확보하기 위한 안전장치입니다. 마치 품질 관리 공정처럼, 응답이 스키마에 맞는지 확인하고, 잘못된 경우 재시도하거나 폴백 로직을 실행합니다.
이를 통해 프로덕션 환경에서 안정적인 서비스를 제공할 수 있습니다.
다음 코드를 살펴봅시다.
import kotlin.jvm.Throws
data class Product(
val name: String,
val price: Int,
val category: String
)
class ProductValidator {
// 필드 검증
fun validate(product: Product): ValidationResult {
val errors = mutableListOf<String>()
if (product.name.isBlank()) {
errors.add("제품명이 비어있습니다")
}
if (product.price <= 0) {
errors.add("가격은 0보다 커야 합니다")
}
if (product.category.isBlank()) {
errors.add("카테고리가 비어있습니다")
}
return if (errors.isEmpty()) {
ValidationResult.Success(product)
} else {
ValidationResult.Failure(errors)
}
}
}
sealed class ValidationResult {
data class Success(val product: Product) : ValidationResult()
data class Failure(val errors: List<String>) : ValidationResult()
}
// 재시도 로직 포함한 안전한 파싱
fun parseWithRetry(jsonString: String, maxRetries: Int = 3): Product? {
repeat(maxRetries) { attempt ->
try {
val product = parseProduct(jsonString)
val result = ProductValidator().validate(product)
if (result is ValidationResult.Success) {
return result.product
} else {
println("검증 실패 (${attempt + 1}/$maxRetries): ${(result as ValidationResult.Failure).errors}")
}
} catch (e: Exception) {
println("파싱 실패 (${attempt + 1}/$maxRetries): ${e.message}")
}
}
return null // 모든 재시도 실패
}
김개발 씨는 개발 환경에서는 모든 것이 완벽하게 작동했습니다. 하지만 실제 사용자가 몰리는 프로덕션 환경에서는 예상치 못한 일들이 벌어졌습니다.
어느 날 새벽, 온콜 전화가 울렸습니다. "제품 정보 추출이 실패하고 있어요!" 김개발 씨가 급히 로그를 확인해보니, LLM이 price 필드를 문자열로 반환하거나, name 필드를 아예 누락한 경우가 있었습니다.
파싱 에러가 발생하면서 전체 프로세스가 멈췄습니다. 박시니어 씨가 상황을 파악하고 말했습니다.
"LLM은 100% 신뢰할 수 없어요. 검증과 에러 처리가 필수입니다." 검증 및 에러 핸들링이란 무엇일까요?
쉽게 비유하자면, 검증은 마치 공항 보안 검색대와 같습니다. 모든 짐이 기준에 맞는지 확인하고, 문제가 있으면 통과시키지 않습니다.
LLM 응답도 마찬가지입니다. 스키마에 맞게 파싱되었다고 해서 끝이 아닙니다.
비즈니스 규칙을 만족하는지 검증해야 합니다. 가격이 음수인지, 필수 필드가 비어있는지 등을 확인합니다.
검증 없이 LLM 응답을 그대로 사용하면 어떻게 될까요? 잘못된 데이터가 데이터베이스에 저장됩니다.
가격이 0원인 제품, 이름이 빈 문자열인 제품이 시스템에 들어갑니다. 이후 다른 서비스가 이 데이터를 읽을 때 예외가 발생합니다.
연쇄적인 장애가 일어나고, 결국 서비스 전체가 불안정해집니다. 고객 불만이 쌓이고, 신뢰도가 떨어집니다.
바로 이런 재앙을 방지하기 위해 검증 로직이 필수입니다. 검증 로직을 구현하면 잘못된 데이터가 차단됩니다.
또한 재시도 전략을 통해 일시적인 오류를 극복할 수 있습니다. 무엇보다 명확한 에러 메시지로 문제를 빠르게 파악하고 해결할 수 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 ProductValidator 클래스를 정의했습니다.
validate 함수는 Product 객체를 받아서 비즈니스 규칙을 검증합니다. name이 비어있는지, price가 양수인지, category가 있는지 확인합니다.
각 검증 실패 시 errors 리스트에 메시지를 추가합니다. ValidationResult는 sealed class입니다.
성공과 실패를 타입으로 표현합니다. Success는 검증된 Product를 담고, Failure는 에러 메시지 목록을 담습니다.
이렇게 하면 when 표현식으로 안전하게 처리할 수 있습니다. parseWithRetry 함수는 재시도 로직을 구현했습니다.
최대 3번까지 시도합니다. 파싱에 실패하거나 검증에 실패하면 다시 시도합니다.
모든 시도가 실패하면 null을 반환해서 호출자가 폴백 로직을 실행할 수 있게 합니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 금융 서비스에서 LLM으로 거래 내역을 분석한다고 가정해봅시다. 금액이 정확하지 않으면 큰 문제가 됩니다.
검증 로직으로 금액이 유효 범위 내에 있는지, 필수 필드가 모두 채워져 있는지 확인합니다. 검증 실패 시 재시도하거나, 사람이 직접 확인하도록 플래그를 설정합니다.
이렇게 하면 자동화의 이점을 얻으면서도 정확성을 보장할 수 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 무한 재시도를 하는 것입니다. 네트워크 문제나 LLM 장애가 지속되면 재시도가 끝없이 반복됩니다.
따라서 재시도 횟수와 백오프 전략을 반드시 설정해야 합니다. 예를 들어 첫 번째 재시도는 1초 대기, 두 번째는 2초 대기처럼 점진적으로 늘리는 것이 좋습니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 검증 로직과 재시도 전략을 추가한 후, 프로덕션 에러가 크게 줄었습니다.
"이제야 안심하고 잘 수 있겠어요!" 김개발 씨가 안도했습니다. 검증과 에러 핸들링을 제대로 구현하면 안정적인 LLM 서비스를 만들 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 재시도 횟수는 3-5회가 적당합니다
- 지수 백오프(exponential backoff) 전략을 사용하면 서버 부하를 줄일 수 있습니다
- 검증 실패 로그를 모니터링하면 LLM 품질 개선에 활용할 수 있습니다
6. 실전 제품 정보 추출 시스템
김개발 씨가 지금까지 배운 모든 내용을 종합해서 실전 프로젝트를 진행하게 되었습니다. 전자상거래 플랫폼에서 고객 리뷰로부터 제품 정보를 자동으로 추출하는 시스템을 구축해야 했습니다.
박시니어 씨가 "이제 모든 준비가 끝났어요. 실전에 적용해봅시다"라고 격려했습니다.
실전 시스템은 지금까지 배운 모든 개념을 통합한 프로덕션 수준의 구현입니다. JSON 스키마 정의, 데이터 클래스 매핑, Output Parser, 검증 및 에러 핸들링을 모두 활용하여 안정적이고 확장 가능한 LLM 기반 서비스를 만듭니다.
다음 코드를 살펴봅시다.
import dev.langchain4j.model.chat.ChatLanguageModel
import dev.langchain4j.model.output.structured.Description
import dev.langchain4j.service.AiServices
import dev.langchain4j.service.UserMessage
// 도메인 모델 정의
data class ProductExtraction(
@Description("제품명")
val productName: String,
@Description("가격 (원 단위, 숫자만)")
val price: Int,
@Description("제품 카테고리")
val category: String,
@Description("리뷰 감정 (positive/negative/neutral)")
val sentiment: String,
@Description("핵심 특징 (최대 3개)")
val features: List<String>
)
// AI Service 인터페이스
interface ProductExtractor {
@UserMessage("""
다음 고객 리뷰에서 제품 정보를 추출해주세요.
리뷰: {{review}}
정확한 정보만 추출하고, 불확실하면 빈 값을 반환하세요.
""")
fun extract(review: String): ProductExtraction
}
// 비즈니스 로직
class ProductExtractionService(
private val chatModel: ChatLanguageModel
) {
private val extractor = AiServices.create(ProductExtractor::class.java, chatModel)
private val validator = ProductExtractionValidator()
fun extractWithValidation(review: String, maxRetries: Int = 3): Result<ProductExtraction> {
repeat(maxRetries) { attempt ->
try {
val extraction = extractor.extract(review)
val validationResult = validator.validate(extraction)
if (validationResult.isValid) {
return Result.success(extraction)
} else {
println("검증 실패 (시도 ${attempt + 1}): ${validationResult.errors}")
}
} catch (e: Exception) {
println("추출 실패 (시도 ${attempt + 1}): ${e.message}")
}
}
return Result.failure(Exception("최대 재시도 횟수 초과"))
}
}
// 검증 로직
class ProductExtractionValidator {
fun validate(extraction: ProductExtraction): ValidationResult {
val errors = mutableListOf<String>()
if (extraction.productName.isBlank()) errors.add("제품명 누락")
if (extraction.price <= 0) errors.add("유효하지 않은 가격")
if (extraction.category.isBlank()) errors.add("카테고리 누락")
if (extraction.sentiment !in listOf("positive", "negative", "neutral")) {
errors.add("유효하지 않은 감정")
}
return ValidationResult(errors.isEmpty(), errors)
}
}
data class ValidationResult(val isValid: Boolean, val errors: List<String>)
김개발 씨는 드디어 실전 프로젝트에 돌입했습니다. 요구사항은 명확했습니다.
하루에 수천 개씩 쌓이는 고객 리뷰에서 제품 정보를 자동으로 추출하고, 데이터베이스에 저장해야 했습니다. 정확도는 95% 이상이어야 하고, 시스템은 24시간 안정적으로 작동해야 했습니다.
"부담되네요." 김개발 씨가 말했습니다. 박시니어 씨가 웃으며 답했습니다.
"이미 다 배웠어요. 이제 조립만 하면 됩니다." 실전 시스템 구축이란 무엇일까요?
쉽게 비유하자면, 실전 시스템은 마치 자동차 조립과 같습니다. 엔진, 바퀴, 핸들을 각각 만드는 법을 배웠다면, 이제 이것들을 조립해서 실제로 달릴 수 있는 자동차를 만드는 것입니다.
LLM 시스템도 마찬가지입니다. 스키마, 파싱, 검증을 개별적으로 배웠다면, 이제 이것들을 통합해서 실제 비즈니스 가치를 만드는 시스템을 구축합니다.
실전 시스템 없이 각 기능만 따로 구현하면 어떻게 될까요? 각 부분은 잘 작동하지만 통합이 어렵습니다.
코드가 여기저기 흩어져 있고, 에러 처리가 일관되지 않습니다. 새로운 기능을 추가할 때마다 모든 부분을 수정해야 합니다.
유지보수가 악몽이 됩니다. 팀원들이 코드를 이해하기 어려워합니다.
바로 이런 혼란을 방지하기 위해 체계적인 아키텍처가 필요합니다. 실전 시스템을 제대로 설계하면 코드가 명확히 분리됩니다.
또한 확장이 쉬워집니다. 무엇보다 팀 협업이 원활해지고 새로운 팀원도 빠르게 이해할 수 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 ProductExtraction 데이터 클래스를 정의했습니다.
이것은 우리가 추출하려는 정보의 형태입니다. @Description 어노테이션으로 각 필드를 명확히 설명했습니다.
features는 List<String> 타입으로, 여러 개의 특징을 담을 수 있습니다. ProductExtractor 인터페이스는 AI Service 패턴입니다.
@UserMessage에 구체적인 프롬프트를 작성했습니다. "정확한 정보만 추출하고, 불확실하면 빈 값을 반환하세요"라는 지시로 LLM의 행동을 제어합니다.
ProductExtractionService는 비즈니스 로직의 중심입니다. extractWithValidation 함수는 추출과 검증을 통합합니다.
재시도 로직도 포함되어 있습니다. Result 타입을 반환해서 성공과 실패를 명확히 구분합니다.
ProductExtractionValidator는 검증 규칙을 캡슐화합니다. 제품명이 비어있는지, 가격이 양수인지, 감정이 유효한 값인지 확인합니다.
검증 로직이 한 곳에 모여 있어서 수정이 쉽습니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 대형 쇼핑몰에서 이 시스템을 도입한다고 가정해봅시다. 매일 수만 건의 리뷰가 쌓입니다.
ProductExtractionService를 배치 작업으로 실행해서 모든 리뷰를 처리합니다. 추출된 정보는 제품 상세 페이지에 표시되고, 마케팅 분석에 활용됩니다.
시스템이 안정적으로 작동하면서 인간의 노력을 크게 줄입니다. 김개발 씨는 시스템을 완성하고 테스트했습니다.
1000건의 리뷰를 처리했고, 정확도는 97%였습니다. 에러가 발생해도 재시도 로직이 대부분 해결했습니다.
박시니어 씨가 코드 리뷰를 하고 승인했습니다. 프로덕션에 배포한 첫날, 김개발 씨는 긴장했습니다.
하지만 모든 것이 순조로웠습니다. 모니터링 대시보드에는 초록색 불빛만 켜져 있었습니다.
성공률 98%, 평균 처리 시간 500ms. 완벽했습니다.
몇 주 후, 팀 회의에서 김개발 씨의 시스템이 소개되었습니다. "이 시스템 덕분에 고객 만족도가 15% 상승했습니다." 경영진이 칭찬했습니다.
김개발 씨는 뿌듯했습니다. 구조화된 출력을 제대로 이해하면 LLM을 안전하고 효과적으로 활용할 수 있습니다.
스키마 정의부터 검증까지, 각 단계를 체계적으로 구현하세요. 여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 프로덕션 환경에서는 반드시 모니터링과 로깅을 추가하세요
- LLM 응답 시간이 길 수 있으니 비동기 처리를 고려하세요
- 추출 정확도를 주기적으로 측정하고 프롬프트를 개선하세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
Routing Workflow 완벽 가이드
AI 에이전트 시스템에서 사용자 요청을 적절한 처리기로 분류하고 전달하는 Routing Workflow 패턴을 학습합니다. 의도 분류부터 동적 라우팅, Fallback 처리까지 실전 예제와 함께 알아봅니다.
Parallelization Workflow 완벽 가이드
AI 에이전트 개발에서 핵심이 되는 병렬화 워크플로우를 다룹니다. Coroutine을 활용한 동시 처리부터 부분 실패 복구, 성능 최적화까지 실무에서 바로 적용할 수 있는 기법을 배웁니다.
Chain Workflow 완벽 가이드
AI 에이전트 시스템에서 작업을 순차적으로 연결하는 Chain Workflow 패턴을 알아봅니다. 각 단계가 다음 단계로 컨텍스트를 전달하며, 복잡한 작업을 안정적으로 처리하는 방법을 배웁니다.
macOS iOS Android 앱 통합 완벽 가이드
하나의 데스크톱 앱을 macOS, iOS, Android 모바일 앱과 연동하는 방법을 배웁니다. WebSocket 프로토콜 설계부터 Bonjour 자동 페어링까지, 크로스 플랫폼 앱 통합의 모든 것을 다룹니다.
고급 프롬프트 기법 완벽 가이드
AI와의 대화를 한 단계 업그레이드하는 고급 프롬프트 기법을 배워봅니다. Few-shot 학습부터 Chain-of-Thought까지, 실무에서 바로 써먹을 수 있는 프롬프트 엔지니어링 노하우를 담았습니다.