
문서 기반 AI 서비스를 개발하다 보면, 한 가지 불편한 진실과 마주하게 됩니다.
로컬에서 ONNX 임베딩 모델 파일이 없거나, GPU 런타임에 문제가 생기면 스프링 부트 애플리케이션 자체가 기동에 실패한다는 점입니다.
HTTP 서버가 아예 뜨지 않으니, PDF 뷰어도, 문서 업로드도, 목록 조회도 임베딩과 아무 관련 없는 기능들까지 전부 못 쓰게 됩니다.
처음에는 "ONNX 파일만 제대로 세팅하면 되지 않나?"라고 생각했습니다. 하지만 현실은 그렇게 단순하지 않았습니다. 팀원마다 개발 환경이 다르고, CI 파이프라인에서는 임베딩 모델을 매번 내려받기 부담스럽고, 때로는 "AI 기능만 잠깐 끄고 나머지 기능을 테스트하고 싶다"는 지극히 합리적인 요구도 있었습니다.
그리고 이 문제는 임베딩만의 문제가 아니었습니다.
PaperLens 프로젝트에서는 Flyway 마이그레이션, PgVector 초기화, Hibernate 스키마 검증이 서로 얽혀 있었고, 이 중 하나라도 순서가 어긋나면 서버가 통째로 안 뜨는 상황이 반복되고 있었습니다.
이번 글에서는 이런 문제들을 하나씩 풀어가면서 설계한 "옵셔널 임베딩 아키텍처"에 대해 정리해보겠습니다.
핵심은 단순합니다.
서버는 항상 안정적으로 뜨되, AI 기능은 프로필 하나로 켜고 끌 수 있게 만드는 것입니다.
이 작업을 시작하기 전에, 먼저 문제의 본질을 정의할 필요가 있었습니다.
기존 구조에서는 ONNX 기반 임베딩 모델이 필수 빈으로 등록되어 있었습니다.
Spring AI의 TransformersEmbeddingModelAutoConfiguration이 자동으로 EmbeddingModel 빈을 생성하려 하는데, ONNX 파일이 없으면 빈 생성 자체가 실패하고, 그 여파로 EntityManagerFactory 초기화까지 연쇄적으로 무너지는 구조였습니다.
여기서 엔지니어링 관점으로 한 발 물러서서 생각해볼 필요가 있었습니다.
이 두 가지를 같은 레벨에서 처리하고 있었던 것이 근본 원인이었습니다.
임베딩 모델이 없는 상태를 "장애"로 취급할 것이 아니라, "옵션이 꺼진 상태"로 취급해야 했습니다.
실무에서 흔히 보는 패턴을 떠올려보면, 결제 모듈이 아직 연동되지 않은 환경에서도 상품 목록은 볼 수 있어야 하고, 검색 엔진이 내려가 있어도 기본 CRUD는 동작해야 합니다.
임베딩도 마찬가지입니다.
외부 리소스에 의존하는 기능이 전체 시스템의 기동을 좌우해서는 안 됩니다.
옵셔널 임베딩 설계에 들어가기 전에, 먼저 기반 인프라부터 정리해야 했습니다. 솔직히 고백하면, 이 과정에서 꽤 시간을 썼습니다.
PaperLens는 Flyway로 스키마를 관리하고, Hibernate의 ddl-auto=validate로 기동 시 스키마를 검증하는 구조입니다.
그런데 로컬에서 개발하다 보면, DB를 수동으로 리셋하거나 테이블을 직접 삭제하는 경우가 생깁니다.
이때 Flyway의 flyway_schema_history 테이블에는 "V1은 이미 실행했다"고 기록되어 있는데, 실제로는 users 테이블이 없는 상태가 됩니다.
결과는 이렇습니다:
Schema-validation: missing table [users]
Hibernate가 스키마를 검증하는 시점에 users 테이블이 없으니 EntityManagerFactory 생성이 실패하고, 그 여파로 UserJpaRepository → CustomUserDetailsService → JwtAuthFilter까지 빈 생성이 연쇄적으로 무너집니다.
서버가 아예 뜨지 않습니다.
이 문제를 해결하기 위해 V4 마이그레이션(V4__repair_missing_users_schema.sql)을 만들었습니다:
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'USER',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP
);
INSERT INTO users (email, password, name, role)
SELECT 'admin@paperlens.com',
'$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy',
'Admin', 'ADMIN'
WHERE NOT EXISTS (
SELECT 1 FROM users WHERE email = 'admin@paperlens.com'
);
CREATE TABLE IF NOT EXISTS와 WHERE NOT EXISTS로 멱등성을 보장했습니다.
테이블이 이미 있으면 조용히 넘어가고, 없으면 새로 만듭니다.
하지만 이것만으로는 끝이 아니었습니다.
Flyway 히스토리 테이블 자체가 없는 상태에서 DB에 이미 테이블들이 존재하는 경우, 또 다른 에러가 발생합니다.
Found non-empty schema(s) "public" but no schema history table.
Use baseline() or set baselineOnMigrate to true
Flyway는 빈 스키마에서 처음부터 마이그레이션을 실행하는 것을 전제로 설계되어 있기 때문에, 이미 테이블이 있는데 히스토리만 없으면 "이거 위험하다"고 판단하고 실행을 거부합니다.
합리적인 안전장치이지만, 로컬 개발에서는 반복적으로 마주치는 짜증나는 상황이기도 합니다.
해결은 application.yml에 한 줄 추가하는 것으로 가능합니다:
spring:
flyway:
baseline-on-migrate: true
baseline-version: 0
다만 이건 로컬 개발용 설정이라는 점을 명확히 해두어야 합니다.
운영 환경에서 baseline-on-migrate: true를 무심코 켜두면, 기존 스키마를 무시하고 마이그레이션이 엉뚱하게 돌아갈 수 있습니다.
프로필별로 분리하는 것이 안전합니다:
# application-local.yml (로컬 개발 전용)
spring:
flyway:
baseline-on-migrate: true
baseline-version: 0
# application-prod.yml (운영)
spring:
flyway:
baseline-on-migrate: false
이 경험에서 얻은 교훈은, Flyway 마이그레이션 스크립트는 반드시 멱등성을 갖춰야 한다는 것입니다. CREATE TABLE IF NOT EXISTS, ALTER TABLE ADD COLUMN IF NOT EXISTS, INSERT ... WHERE NOT EXISTS — 이 세 가지 패턴을 습관적으로 사용하면, 히스토리와 실제 상태가 어긋나더라도 서버가 깨지지 않습니다.
Flyway 문제를 잡았다고 안심하기엔 일렀습니다.
PaperLens는 spring-ai-starter-vector-store-pgvector를 사용하는데, 이 자동 설정이 빈 초기화 시점에 PgVector 관련 테이블과 vector 익스텐션이 이미 존재할 것으로 기대합니다.
문제는 초기화 순서입니다.
Spring Boot의 자동 설정은 다음과 같은 순서로 빈을 올립니다:
DataSource (HikariCP 커넥션 풀)Flyway 마이그레이션 실행EntityManagerFactory PgVectorStore 관련 빈이상적으로는 Flyway가 먼저 돌면서 CREATE EXTENSION IF NOT EXISTS "vector"와 document_chunks 테이블을 만들고, 그 다음에 PgVectorStore 빈이 올라와야 합니다. 하지만 Spring AI의 자동 설정이 EntityManagerFactory보다 먼저 PgVector 커넥션을 시도하는 경우가 있었고, 이때 Flyway가 아직 마이그레이션을 완료하지 않은 상태라 vector 타입을 인식하지 못하는 문제가 발생했습니다.
이 문제도 결국 옵셔널 아키텍처의 일부로 풀었습니다.
embedding 프로필이 꺼져 있으면 PgVectorStore 자동 설정 자체를 exclude하는 방식입니다.
# application.yml (기본)
spring:
autoconfigure:
exclude:
- org.springframework.ai.autoconfigure.vectorstore.transformer.TransformersEmbeddingModelAutoConfiguration
- org.springframework.ai.autoconfigure.vectorstore.pgvector.PgVectorStoreAutoConfiguration
# application-embedding.yml
spring:
autoconfigure:
exclude: []
ai:
vectorstore:
pgvector:
initialize-schema: true
dimensions: 384
index-type: hnsw
distance-type: cosine_distance
embedding 프로필에서 initialize-schema: true를 명시적으로 설정한 것도 중요합니다. 이렇게 하면 Spring AI가 자체적으로 PgVector 테이블 존재 여부를 확인하고, 없으면 생성합니다.
Flyway와의 순서 충돌을 한 겹 더 방어하는 셈입니다.
여기서 한 가지 설계 판단이 있었습니다.
Flyway에서도 document_chunks 테이블과 vector 컬럼을 만들고, Spring AI에서도 initialize-schema로 만드는 건 중복 아닌가? 맞습니다.
하지만 이 중복은 의도적입니다. Flyway 마이그레이션은 전체 스키마의 버전 관리 관점에서 필요하고, initialize-schema는 Spring AI가 런타임에 자기 자신이 동작할 수 있는 환경인지 확인하는 방어 수단입니다.
레이어가 다른 두 개의 안전장치가 겹치는 것은, 제 경험상 오히려 바람직한 방향입니다.
인프라 기반이 정리된 후, 본격적으로 임베딩 on/off 경계를 설계했습니다.
가장 먼저 한 일은, 스프링 프로필을 기준으로 "임베딩이 실제로 활성화된 환경"과 "그렇지 않은 환경"을 명확히 나누는 것이었습니다.
# application.yml (기본 — 임베딩 없이 기동)
spring:
autoconfigure:
exclude:
- org.springframework.ai.autoconfigure.vectorstore.transformer.TransformersEmbeddingModelAutoConfiguration
- org.springframework.ai.autoconfigure.vectorstore.pgvector.PgVectorStoreAutoConfiguration
# application-embedding.yml (임베딩 활성화)
spring:
autoconfigure:
exclude: []
이렇게 하면 기본 bootRun으로는 ONNX 자동 설정과 PgVector 자동 설정이 모두 빠지기 때문에, 모델 파일이 없어도 서버가 깨끗하게 뜹니다.
임베딩을 쓰고 싶을 때는 --spring.profiles.active=embedding 한 줄만 추가하면 됩니다.
여기서 중요한 점이 있습니다.
exclude를 프로필별로 분리하는 이유는 자동 설정의 on/off를 런타임 조건이 아닌 설정 레벨에서 제어하기 위해서입니다. @ConditionalOnProperty 같은 조건부 빈 등록도 방법이 될 수 있지만, Spring AI의 자동 설정은 내부적으로 여러 빈을 연쇄적으로 등록하기 때문에, 자동 설정 자체를 통째로 빼는 것이 더 깔끔합니다.
자동 설정을 exclude했다면, EmbeddingModel 타입의 빈이 컨텍스트에 없게 됩니다.
그런데 다른 빈들이 EmbeddingModel을 주입받고 있다면, 이번에는 NoSuchBeanDefinitionException으로 또 서버가 안 뜹니다.
그래서 Stub을 넣었습니다. 빈은 존재하지만, 실제로 호출하면 의도적으로 예외를 던지는 구조입니다.
@Configuration
@Profile("!embedding")
class StubEmbeddingModelConfig {
@Bean
fun embeddingModel(): EmbeddingModel = object : EmbeddingModel {
private fun fail(): Nothing = throw EmbeddingNotAvailableException(
"임베딩이 비활성화되어 있습니다. " +
"프로필 'embedding'으로 기동(예: --spring.profiles.active=embedding) 후 " +
"spring.ai.embedding.transformer.onnx.modelUri에 유효한 ONNX 파일 경로를 설정하세요."
)
override fun call(request: EmbeddingRequest): EmbeddingResponse = fail()
override fun embed(document: Document): FloatArray = fail()
}
}
이 설계에는 두 가지 의도가 담겨 있습니다.
첫째, 잘못된 환경에서 임베딩을 호출한 버그를 숨기지 않겠다는 것입니다.
Stub이 빈 벡터를 반환하거나 null을 돌려주면, 호출 자체는 성공하지만 이후 로직에서 엉뚱한 결과가 나올 수 있습니다.
차라리 호출 시점에 즉시 실패하는 것이 디버깅에 훨씬 유리합니다.
둘째, 예외 메시지 자체가 해결 가이드가 됩니다. "어떤 프로필을 켜야 하고, 어떤 설정을 잡아야 하는지"를 예외 메시지에 담았기 때문에, 로그만 보고도 다음 행동을 바로 알 수 있습니다. 이런저런 프로젝트를 하면서 느낀 건데, 좋은 예외 메시지 하나가 슬랙 질문 열 개를 줄여줍니다.
이 예외는 paperlens-application 모듈에 정의했습니다.
class EmbeddingNotAvailableException(
message: String = "임베딩이 비활성화되어 있습니다. " +
"프로필 'embedding'으로 기동하고 유효한 ONNX 모델 URI를 설정하세요.",
) : RuntimeException(message)
IllegalStateException이나 UnsupportedOperationException 같은 범용 예외를 쓰지 않은 이유가 있습니다.
이 예외는 "시스템 상태"가 아니라 "서비스 기능의 의도적 비활성화"를 의미합니다.
타입 자체가 의미를 가져야, 예외 핸들러에서 정확하게 잡아서 적절한 HTTP 상태 코드와 메시지로 매핑할 수 있습니다.
인프라 문제(DB 커넥션 실패 등)는 500이 맞지만, 의도적으로 꺼둔 기능은 503(Service Unavailable)이 맞습니다. 이 구분은 프론트엔드의 에러 핸들링에도, 모니터링/알림 설정에도 직접적으로 영향을 줍니다.
PaperLens는 paperlens-domain, paperlens-application, paperlens-infrastructure 세 개의 모듈로 구성되어 있습니다. 예외와 Stub을 "어디에 둘 것인가"는 사소해 보이지만, 아키텍처에서 의존성 방향을 유지하는 데 핵심적인 판단입니다.
아키텍처에서 의존성은 항상 바깥에서 안쪽으로 흘러야 합니다.
infrastructure → application → domain
infrastructure는 application을 알고, application은 domain을 알지만, 그 반대는 성립하지 않습니다.
이 원칙을 지키면 도메인 로직이 프레임워크 세부사항에 오염되지 않습니다.
paperlens-domain — 여기에는 아무것도 두지 않았습니다. EmbeddingNotAvailableException을 도메인에 둘 수도 있었지만, "임베딩 비활성화"라는 개념은 순수한 도메인 규칙이 아니라 애플리케이션의 실행 환경에 관한 것입니다.
도메인 모듈이 "ONNX"나 "프로필"같은 인프라 개념을 인지하게 되는 순간, 도메인의 순수성이 깨집니다.
paperlens-application — EmbeddingNotAvailableException을 여기에 정의했습니다.
이 예외는 "서비스 실행 중에 임베딩 기능이 필요한데 사용할 수 없다"는 애플리케이션 레벨의 시그널입니다.
서비스가 이 예외를 던지거나 전파하는 것은 자연스럽습니다.
// paperlens-application 모듈
package com.sleekydz86.paperlens.application.exception
class EmbeddingNotAvailableException(
message: String = "임베딩이 비활성화되어 있습니다. " +
"프로필 'embedding'으로 기동하고 유효한 ONNX 모델 URI를 설정하세요.",
) : RuntimeException(message)
paperlens-infrastructure — StubEmbeddingModelConfig와 GlobalExceptionHandler를 여기에 두었습니다. 이유는 명확합니다.
StubEmbeddingModelConfig는 Spring의 @Configuration, @Profile, @Bean 어노테이션을 사용합니다.GlobalExceptionHandler는 @RestControllerAdvice를 사용하며, HTTP 상태 코드와 응답 포맷을 결정합니다. 이것 역시 웹 프레임워크 관심사입니다.// paperlens-infrastructure 모듈
package com.sleekydz86.paperlens.infrastructure.global.config
@Configuration
@Profile("!embedding")
class StubEmbeddingModelConfig {
// ... EmbeddingNotAvailableException은 application 모듈에서 import
}
이 배치에서 핵심은, infrastructure가 application의 예외 타입을 import하는 것은 허용되지만, 그 반대는 안 된다는 것입니다.
StubEmbeddingModelConfig이 EmbeddingNotAvailableException을 던지는 것은 의존성 방향에 부합합니다.
만약 이 예외를 infrastructure에 두었다면, application 계층의 서비스 코드가 infrastructure를 import해야 하는 역전이 발생합니다.
"예외 클래스 하나 어디에 두든 뭐가 달라지나"라고 생각할 수 있습니다.
하지만 모듈이 10개, 20개로 늘어나고, 각 모듈에 여러 팀원이 코드를 추가하다 보면, 의존성 방향이 한 번 깨지면 순식간에 순환 의존이 생깁니다.
Gradle 빌드가 깨지는 건 그나마 나은 경우이고, 더 나쁜 경우는 "빌드는 되는데 아키텍처가 스파게티가 된 상태"입니다.
의존성 방향을 코드 레벨에서 강제하려면, Gradle의 모듈 간 의존성 선언이 자연스러운 가드레일이 됩니다.
// paperlens-infrastructure/build.gradle.kts
dependencies {
implementation(project(":paperlens-application"))
implementation(project(":paperlens-domain"))
}
// paperlens-application/build.gradle.kts
dependencies {
implementation(project(":paperlens-domain"))
// infrastructure에 대한 의존 없음!
}
이 구조에서 application 모듈이 infrastructure의 클래스를 import하려 하면 컴파일 에러가 나기 때문에, 아키텍처 위반을 빌드 시점에 잡을 수 있습니다.
GlobalExceptionHandler에서 EmbeddingNotAvailableException을 전담 처리하도록 구성했습니다.
@ExceptionHandler(EmbeddingNotAvailableException::class)
fun handleEmbeddingNotAvailable(
e: EmbeddingNotAvailableException
): ResponseEntity<ErrorResponse> =
ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(ErrorResponse(e.message ?: "임베딩이 비활성화되어 있습니다."))
여기에 더해서, 기존 코드 여기저기에서 IllegalStateException으로 던지고 있던 임베딩 관련 에러들도 같은 방식으로 처리되도록 보완했습니다.
@ExceptionHandler(IllegalStateException::class)
fun handleIllegalState(
e: IllegalStateException
): ResponseEntity<ErrorResponse> {
val msg = e.message ?: ""
if (msg.contains("Embedding", ignoreCase = true) ||
msg.contains("임베딩", ignoreCase = true)) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(
ErrorResponse(
if (msg.contains("임베딩")) msg
else "임베딩이 비활성화되어 있습니다. " +
"프로필 'embedding'으로 기동 후 ONNX 모델 URI를 설정하세요."
)
)
}
if (msg.contains("파일", ignoreCase = true) ||
msg.contains("File not found", ignoreCase = true)) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse(
if (msg.contains("파일")) msg else "파일을 찾을 수 없습니다."
))
}
throw e
}
솔직히 말씀드리면, 예외 메시지의 문자열을 검사해서 분기하는 방식이 아름다운 코드는 아닙니다. 장기적으로는 FileNotFoundForDocumentException 같은 도메인 예외로 타입을 세분화하는 것이 맞습니다.
하지만 기존 코드 전체를 한 번에 리팩토링하기는 현실적으로 어렵고, 지금 당장 사용자에게 보여지는 에러 메시지를 개선해야 하는 상황에서는 이런 pragmatic한 중간 단계가 필요했습니다.
리팩토링은 점진적으로 하되, 사용자 경험 개선은 지금 당장 하는 것이 실무적인 판단이라고 생각합니다.
같은 흐름에서 NoSuchElementException과 UsernameNotFoundException도 정리했습니다.
@ExceptionHandler(NoSuchElementException::class)
fun handleNoSuchElement(
e: NoSuchElementException
): ResponseEntity<ErrorResponse> =
ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse(e.message ?: "요청한 항목을 찾을 수 없습니다."))
// ViewerController — 기존
?: throw NoSuchElementException("Document not found")
// ViewerController — 변경 후
?: throw NoSuchElementException("문서를 찾을 수 없습니다.")
// CustomUserDetailsService
userJpaRepository.findByEmail(username)
?: throw UsernameNotFoundException("해당 이메일로 등록된 사용자가 없습니다.")
사소해 보이지만, 에러 메시지의 언어가 섞여 있으면 사용자 입장에서 혼란스럽습니다.
한국어 서비스라면 에러 메시지도 한국어로 일관성 있게 가져가는 게 맞다고 생각합니다.
보안 관점에서도 UsernameNotFoundException의 메시지가 "이메일이 존재하지 않는다"는 것을 지나치게 구체적으로 알려주지 않도록 주의하면서, 동시에 사용자가 "내가 뭘 잘못 입력했는지" 정도는 알 수 있는 수준으로 조율했습니다.
질문/답변 패널에서 AI API 호출이 실패했을 때의 처리입니다.
try {
const res = await api.post<QaResponse>('/ai/qa', {
question,
documentId: props.documentId
})
msg.answer = res.data.answer
msg.sources = res.data.sources
} catch (err: any) {
const serverMessage = err.response?.data?.message
msg.answer = typeof serverMessage === 'string'
? serverMessage
: '답변을 생성하는 중 오류가 발생했습니다.'
}
핵심 원칙은 간단합니다. 백엔드가 보내준 메시지가 있으면 그대로 보여주고, 없으면 일반적인 fallback 문구를 보여준다. HTTP 상태 코드는 사용자에게 드러내지 않습니다.
이렇게 하면 백엔드에서 메시지를 바꾸는 것만으로 프론트엔드의 에러 표시가 자동으로 달라집니다. 프론트엔드를 재배포할 필요가 없어지는 거죠.
"임베딩이 비활성화되어 있습니다"라는 메시지가 "현재 AI 모델을 업데이트 중입니다"로 바뀌어야 할 때, 백엔드만 수정하면 됩니다.
유사 문서 패널도 동일한 패턴입니다.
try {
const res = await api.get<SimilarDocument[]>(
`/ai/similar/${props.documentId}`
)
documents.value = res.data
} catch (err: any) {
const msg = err.response?.data?.message
errorMessage.value = typeof msg === 'string'
? msg
: '유사 문서를 불러오는 중 오류가 발생했습니다.'
}
결과적으로, 임베딩이 꺼진 환경에서 AI 관련 화면을 열면 "임베딩이 비활성화되어 있습니다.
프로필 'embedding'으로 기동 후…"라는 안내 메시지가 표시됩니다.
화면이 깨지거나, 빨간 에러 팝업이 뜨거나, 무한 로딩에 빠지는 것이 아니라, 정상적으로 렌더링된 화면 안에서 기능이 비활성화되어 있다는 정보를 알려주는 것입니다.
옵셔널 아키텍처의 숨겨진 가장 큰 장점은 테스트에 있습니다. Stub이 있으면, ONNX 모델 파일이 없어도 전체 스프링 컨텍스트를 로드하는 통합 테스트를 돌릴 수 있습니다.
CI 서버에서 통합 테스트를 돌릴 때 가장 곤란한 것이, 테스트 환경에 ONNX 모델 파일을 매번 내려받아야 하는지의 문제입니다.
all-MiniLM-L6-v2 모델만 해도 수십 MB이고, CI가 돌 때마다 다운로드하면 시간과 비용이 낭비됩니다.
Stub 기반 구조에서는 이 고민이 사라집니다. 기본 프로필로 테스트를 돌리면 StubEmbeddingModel이 주입되므로, 임베딩과 무관한 모든 기능의 통합 테스트가 AI 의존성 없이 실행됩니다.
@SpringBootTest
@ActiveProfiles("test") // embedding 프로필 미포함 → Stub 사용
class DocumentUploadIntegrationTest {
@Autowired
lateinit var documentService: DocumentService
@Test
fun `문서 업로드 후 메타데이터가 정상 저장된다`() {
// ONNX 모델 없이도 이 테스트는 통과합니다
val result = documentService.upload(testPdfFile)
assertThat(result.status).isEqualTo(DocumentStatus.PENDING)
assertThat(result.pageCount).isGreaterThan(0)
}
}
AI 기능을 직접 테스트해야 하는 경우에는 두 가지 접근이 가능합니다.
첫째, Stub이 예외를 던지는 것 자체를 테스트할 수 있습니다.
이건 "임베딩이 꺼진 상태에서 AI 엔드포인트를 호출하면 503이 응답되는가?"를 검증하는 것으로, 옵셔널 아키텍처의 핵심 동작을 확인하는 테스트입니다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test") // embedding 미포함
class AiEndpointFallbackTest {
@Autowired
lateinit var restTemplate: TestRestTemplate
@Test
fun `임베딩 비활성화 상태에서 QA 요청 시 503과 한국어 메시지를 반환한다`() {
val response = restTemplate.postForEntity(
"/api/ai/qa",
QaRequest(question = "테스트 질문", documentId = 1L),
ErrorResponse::class.java
)
assertThat(response.statusCode).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE)
assertThat(response.body?.message).contains("임베딩이 비활성화")
}
}
둘째, 실제 임베딩 로직을 테스트해야 하는 경우에는, embedding 프로필을 활성화하되 테스트 전용 ONNX 모델 경로를 설정합니다.
@SpringBootTest
@ActiveProfiles("test", "embedding")
@TestPropertySource(properties = [
"spring.ai.embedding.transformer.onnx.modelUri=file:src/test/resources/test-model.onnx",
"spring.ai.embedding.transformer.tokenizer.uri=file:src/test/resources/test-tokenizer.json"
])
class EmbeddingIntegrationTest {
@Autowired
lateinit var embeddingModel: EmbeddingModel
@Test
fun `임베딩 모델이 384차원 벡터를 반환한다`() {
val result = embeddingModel.embed("테스트 문장입니다.")
assertThat(result).hasSize(384)
}
}
이런 전략을 지원하려면, application-test.yml은 다음과 같이 구성됩니다:
# application-test.yml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/paperlens_test
flyway:
baseline-on-migrate: true
baseline-version: 0
jpa:
hibernate:
ddl-auto: validate
임베딩 관련 설정은 application-test.yml에 포함하지 않습니다.
임베딩이 필요한 테스트만 @ActiveProfiles("test", "embedding")으로 명시적으로 활성화하고, 나머지 테스트는 Stub 기반으로 가볍게 돌립니다.
이 구분이 CI 파이프라인에서 실질적인 차이를 만듭니다.
ONNX 모델 파일이 필요 없는 테스트는 수초 내에 끝나고, 임베딩 통합 테스트는 별도 스테이지에서 모델 파일을 캐싱해서 실행하는 식으로 파이프라인을 분리할 수 있습니다.
GitHub Actions 기준으로 예를 들면
# .github/workflows/ci.yml
jobs:
unit-and-integration:
# ONNX 모델 없이 빠르게 실행
steps:
- run: ./gradlew test -Pprofiles=test
ai-integration:
# 모델 파일 캐싱 후 실행
needs: unit-and-integration
steps:
- uses: actions/cache@v3
with:
path: models/
key: onnx-all-MiniLM-L6-v2
- run: ./gradlew test -Pprofiles=test,embedding
이렇게 하면 일반 테스트는 매 PR마다 빠르게 돌리고, AI 통합 테스트는 모델 캐시를 활용해 효율적으로 실행할 수 있습니다.
이 구조가 적용된 후, 로컬 개발 흐름이 눈에 띄게 편해졌습니다.
기본 bootRun(프로필 없이)으로 서버를 띄우면, PDF 뷰어, 문서 업로드, 목록 조회 등 핵심 기능을 바로 확인할 수 있습니다.
AI 관련 부분은 503과 함께 한국어 안내 메시지가 나오고, 이것만 봐도 "아, 아직 임베딩 설정을 안 했구나"라는 걸 알 수 있습니다.
ONNX 모델 세팅이 끝나면 프로필만 추가해서 재시작하면 되고, 프론트엔드 코드는 건드릴 필요가 없습니다.
운영 환경에서는 프로필 조합으로 자연스럽게 확장됩니다. spring.profiles.active=prod,embedding처럼 조합해서 사용할 수 있고, 임베딩 인프라에 문제가 생겼을 때는 embedding 프로필만 빼서 나머지 기능은 정상 운영하는 선택지도 열려 있습니다.
모니터링 관점에서도, 503 응답과 500 응답을 구분해서 알림을 설정할 수 있습니다.
503이 발생했다면 "AI 기능이 의도적으로 꺼져 있거나, 임베딩 인프라를 확인해야 한다"는 의미이고, 500이 발생했다면 "코드 레벨에서 예상치 못한 에러가 있다"는 의미입니다.
이 구분 하나만으로도 장애 대응 속도가 달라집니다.
이번에는 임베딩을 예시로 들었지만, 이 패턴은 외부 리소스에 의존하는 모든 기능에 동일하게 적용할 수 있습니다.
패턴은 항상 같습니다. Profile로 경계를 만들고, Stub으로 빈 의존성을 충족시키고, 도메인 예외로 의도를 표현하고, 예외 핸들러에서 UX에 맞는 응답을 만드는 것입니다.
이번 작업에서 가장 중요한 포인트는 기술적인 구현 자체가 아니라, "장애"와 "옵션"을 구분해서 설계하자는 관점입니다.
ONNX 파일이 없어서 서버가 안 뜨는 것은 장애처럼 보이지만, 본질적으로는 "아직 그 기능을 안 켠 것"에 불과합니다. Flyway 히스토리가 꼬여서 테이블이 없는 것도 마찬가지입니다.
이 두 가지를 같은 레벨에서 처리하면, 개발 환경에서는 불필요한 세팅 강요가 생기고, 운영 환경에서는 부분 장애가 전체 장애로 확대됩니다.
돌이켜보면, 이번 작업에서 다룬 기술들 — Profile 분리, Stub 빈, 도메인 예외, 멱등 마이그레이션, 모듈 간 의존성 방향, 테스트 프로필 전략 은 어느 것 하나 새로운 기술이 아닙니다.
하지만 이것들을 "서버 기동은 반드시 성공해야 한다"는 하나의 원칙 아래에서 의도적으로 조합한 것, 그리고 그 결과가 개발자 경험, 테스트 전략, 운영 유연성까지 자연스럽게 이어지도록 설계한 것 다시한번 숙지하자는 의미에서 포스팅하게 되어 메모하고자하는 내용입니다.
앞으로도 외부 의존성이 있는 기능을 설계할 때, "이 기능이 없으면 서버가 안 뜨나? 아니면 그 기능만 안 되나?"라는 질문을 먼저 던져보시길 생각해보는 시간을 갖게되었습니다.