Spring Boot 애플리케이션은 환경(개발, 테스트, 운영)에 따라 다른 설정이 필요하다. DB 접속 정보, 외부 API 키, 로깅 레벨, 캐시 TTL 같은 값들이 환경마다 달라진다. Java에서는 @Value로 값을 주입받거나 @ConfigurationProperties로 바인딩하는데, Kotlin에서는 이 과정에서 몇 가지 주의할 점이 있다.
이 문서에서는 Kotlin + Spring Boot에서 설정을 타입 안전하게 관리하는 방법과, 프로파일 전략을 다룬다.
@Value — 단순하지만 한계가 있다
가장 기본적인 설정 주입 방법이다.
@Service
class NotificationService(
@Value("\${notification.email.from}")
private val fromAddress: String,
@Value("\${notification.retry-count:3}")
private val retryCount: Int
)
\${...}— Kotlin에서는$가 문자열 템플릿이므로, 백슬래시로 이스케이프해야 한다. 이걸 빠뜨리면 Kotlin이 변수를 찾으려 해서 컴파일 에러가 난다.:3— 기본값. 프로퍼티가 없으면 3을 사용한다.
@Value는 간단하지만 문제가 있다.
- 타입 안전하지 않다 — 프로퍼티 키가 문자열이므로, 오타를 내면 런타임에서야 에러가 난다.
- 구조화가 안 된다 — 관련된 설정값이 흩어져서 관리하기 어렵다.
- 테스트가 번거롭다 — 설정값을 바꾸려면 프로퍼티 파일을 수정하거나
@TestPropertySource를 써야 한다.
설정이 2~3개 이하로 단순하면 @Value로 충분하다. 하지만 그 이상이면 @ConfigurationProperties가 낫다.
@ConfigurationProperties — 타입 안전한 설정
관련된 설정값을 하나의 클래스로 묶어서 바인딩하는 방법이다. Kotlin에서는 data class와 결합하면 깔끔하다.
기본 사용법
# application.yml
blog:
title: "My Blog"
posts-per-page: 10
upload:
max-size: 10MB
allowed-types:
- image/jpeg
- image/png
- image/webp
@ConfigurationProperties(prefix = "blog")
data class BlogProperties(
val title: String,
val postsPerPage: Int = 10,
val upload: UploadProperties = UploadProperties()
) {
data class UploadProperties(
val maxSize: String = "5MB",
val allowedTypes: List<String> = emptyList()
)
}
@ConfigurationProperties(prefix = "blog")—blog.*프로퍼티를 이 클래스에 바인딩한다.data class— 설정 객체는 불변이 좋으므로data class에val을 쓴다.- kebab-case → camelCase 자동 변환 —
posts-per-page가postsPerPage에 바인딩된다. Spring Boot의 relaxed binding이 이걸 처리한다. - 중첩 구조 —
upload.max-size가UploadProperties.maxSize에 바인딩된다.
이 클래스를 활성화하려면 설정이 하나 필요하다.
@Configuration
@EnableConfigurationProperties(BlogProperties::class)
class AppConfig
또는 Spring Boot 3.x에서는 @ConfigurationPropertiesScan을 메인 클래스에 붙이면 자동으로 스캔한다.
@SpringBootApplication
@ConfigurationPropertiesScan
class BlogApplication
사용법
@ConfigurationProperties 클래스는 일반 빈처럼 주입받아 사용한다.
@Service
class PostService(
private val blogProperties: BlogProperties,
private val postRepository: PostRepository
) {
fun getRecentPosts(page: Int): Page<Post> {
val pageable = PageRequest.of(page, blogProperties.postsPerPage)
return postRepository.findByIsPublishedTrue(pageable)
}
}
@Value와 달리 문자열 키가 없다. blogProperties.postsPerPage처럼 프로퍼티 접근이 타입 안전하고, IDE 자동완성이 된다.
Validation 적용
설정값에도 Validation을 적용할 수 있다. 잘못된 설정으로 애플리케이션이 시작되는 것을 방지한다.
@Validated
@ConfigurationProperties(prefix = "blog")
data class BlogProperties(
@field:NotBlank
val title: String,
@field:Min(1)
@field:Max(100)
val postsPerPage: Int = 10,
@field:Valid
val upload: UploadProperties = UploadProperties()
) {
data class UploadProperties(
@field:NotBlank
val maxSize: String = "5MB",
val allowedTypes: List<String> = emptyList()
)
}
@Validated— 클래스 레벨에 붙여야 Validation이 활성화된다.@field:— 앞에서 다뤘듯이 Kotlin에서는@field:접두사가 필요하다.@field:Valid— 중첩 객체의 Validation도 수행하려면 필수.
설정이 잘못되면 애플리케이션 시작 시점에 에러가 나서, 런타임에 잘못된 값으로 동작하는 것을 방지한다.
프로파일 전략
Spring 프로파일은 환경별 설정을 분리하는 메커니즘이다. 개발, 테스트, 운영 환경에서 다른 설정을 적용할 수 있다.
설정 파일 분리
src/main/resources/
├── application.yml # 공통 설정
├── application-local.yml # 로컬 개발
├── application-dev.yml # 개발 서버
├── application-prod.yml # 운영 서버
└── application-test.yml # 테스트
application-{profile}.yml 파일은 해당 프로파일이 활성화될 때 application.yml 위에 덮어쓰기 방식으로 적용된다. 공통 설정은 application.yml에, 환경별 차이만 각 프로파일 파일에 넣는다.
# application.yml — 공통
spring:
jpa:
open-in-view: false
hibernate:
ddl-auto: none
blog:
title: "My Blog"
posts-per-page: 10
# application-local.yml — 로컬 개발
spring:
datasource:
url: jdbc:h2:mem:blog
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
logging:
level:
org.hibernate.SQL: debug
# application-prod.yml — 운영
spring:
datasource:
url: ${DB_URL}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate
logging:
level:
root: warn
운영 환경에서는 민감한 정보를 ${환경변수}로 참조한다. YAML 파일에 비밀번호를 직접 적지 않는 것이 원칙이다.
프로파일 활성화
프로파일을 활성화하는 방법은 여러 가지다.
# 1. 실행 시 인자로 지정
java -jar blog.jar --spring.profiles.active=prod
# 2. 환경 변수로 지정
SPRING_PROFILES_ACTIVE=prod java -jar blog.jar
# 3. application.yml에서 기본 프로파일 지정
# spring:
# profiles:
# active: local
로컬 개발에서는 application.yml에 spring.profiles.active: local을 넣어두고, 운영 배포 시에는 환경 변수로 덮어쓰는 것이 일반적인 패턴이다.
@Profile로 빈 분리
설정 파일뿐 아니라 빈 자체를 프로파일별로 다르게 등록할 수 있다.
interface StorageService {
fun upload(file: MultipartFile): String
}
@Service
@Profile("local", "dev")
class LocalStorageService : StorageService {
override fun upload(file: MultipartFile): String {
// 로컬 파일 시스템에 저장
val path = Path.of("uploads", file.originalFilename ?: "unknown")
file.transferTo(path)
return path.toString()
}
}
@Service
@Profile("prod")
class S3StorageService(
private val s3Client: S3Client,
private val storageProperties: StorageProperties
) : StorageService {
override fun upload(file: MultipartFile): String {
// S3에 업로드
// ...
return "https://s3.amazonaws.com/..."
}
}
- 로컬/개발에서는 파일 시스템에 저장하고, 운영에서는 S3에 업로드한다.
- 사용하는 쪽은
StorageService인터페이스만 의존하므로, 프로파일이 바뀌어도 코드 변경이 없다.
설정 그룹 패턴
프로젝트가 커지면 설정 클래스도 많아진다. 관련된 설정을 그룹으로 묶어서 관리하면 가독성이 좋아진다.
@ConfigurationProperties(prefix = "app")
data class AppProperties(
val blog: BlogConfig = BlogConfig(),
val storage: StorageConfig = StorageConfig(),
val security: SecurityConfig = SecurityConfig()
) {
data class BlogConfig(
val title: String = "My Blog",
val postsPerPage: Int = 10,
val summaryLength: Int = 200
)
data class StorageConfig(
val type: StorageType = StorageType.LOCAL,
val uploadPath: String = "uploads",
val maxSize: Long = 10 * 1024 * 1024 // 10MB
)
data class SecurityConfig(
val jwtSecret: String = "",
val jwtExpirationMs: Long = 3600000, // 1시간
val allowedOrigins: List<String> = emptyList()
)
enum class StorageType {
LOCAL, S3
}
}
app:
blog:
title: "My Kotlin Blog"
posts-per-page: 15
storage:
type: S3
max-size: 20971520
security:
jwt-secret: ${JWT_SECRET}
jwt-expiration-ms: 7200000
allowed-origins:
- https://myblog.com
하나의 AppProperties를 주입받으면 모든 설정에 접근할 수 있다. 관련 설정이 계층적으로 구조화되어 찾기 쉽다.
심화 분석
@ConstructorBinding
Spring Boot 3.x에서는 @ConfigurationProperties가 생성자 바인딩을 기본으로 사용한다. data class의 생성자 파라미터에 값이 바인딩되므로 var이 아닌 val을 쓸 수 있고, 불변 객체가 된다.
Spring Boot 2.x에서는 @ConstructorBinding을 명시적으로 붙여야 했지만, 3.x에서는 생성자가 하나뿐이면 자동으로 생성자 바인딩을 사용한다. 생성자가 여러 개라면 바인딩에 사용할 생성자에 @ConstructorBinding을 붙여야 한다.
테스트에서 설정 오버라이드
테스트에서 특정 설정값을 바꾸고 싶을 때는 @TestPropertySource를 사용한다.
@SpringBootTest
@TestPropertySource(properties = [
"blog.posts-per-page=5",
"blog.title=Test Blog"
])
class PostServiceTest {
@Autowired
lateinit var blogProperties: BlogProperties
@Test
fun `설정이 오버라이드되었는지 확인`() {
assertEquals(5, blogProperties.postsPerPage)
assertEquals("Test Blog", blogProperties.title)
}
}
또는 application-test.yml 파일을 src/test/resources에 두고 @ActiveProfiles("test")를 사용할 수도 있다.
설정 메타데이터
@ConfigurationProperties를 사용하면 IDE에서 application.yml 편집 시 자동완성이 가능하다. 이를 위해 spring-boot-configuration-processor를 추가한다.
// build.gradle.kts
kapt("org.springframework.boot:spring-boot-configuration-processor")
빌드 시 META-INF/spring-configuration-metadata.json이 생성되고, IDE가 이 파일을 읽어서 프로퍼티 자동완성과 문서를 제공한다. 개발 생산성에 의외로 큰 도움이 된다.
자주 하는 실수
$ 이스케이프 빠뜨리기
// 컴파일 에러 — Kotlin이 ${ }를 문자열 템플릿으로 해석
@Value("${server.port}")
// 올바른 사용 — 백슬래시로 이스케이프
@Value("\${server.port}")
Kotlin에서 $는 문자열 보간(string interpolation) 문자이다. @Value의 SpEL 표현식에서 $를 쓰려면 반드시 \$로 이스케이프해야 한다. @ConfigurationProperties를 쓰면 이 문제를 아예 피할 수 있다.
민감 정보를 코드에 직접 작성
# 나쁜 예 — 비밀번호가 코드에 노출
spring:
datasource:
password: mySecretPassword123
# 좋은 예 — 환경 변수 참조
spring:
datasource:
password: ${DB_PASSWORD}
비밀번호, API 키, JWT 시크릿 같은 민감 정보는 절대 YAML 파일에 직접 쓰지 않는다. 환경 변수, Spring Cloud Config, AWS Secrets Manager 같은 외부 설정 소스를 사용해야 한다. 특히 Git에 커밋되는 파일에는 더더욱 주의가 필요하다.
프로파일별 설정 파일에 공통 설정 중복
# application-local.yml에 공통 설정까지 넣은 경우
blog:
title: "My Blog" # 이건 공통 설정인데?
posts-per-page: 10 # 이것도 공통인데?
spring:
datasource:
url: jdbc:h2:mem:blog # 이것만 환경별 설정
프로파일 파일에는 그 환경에서만 달라지는 설정만 넣어야 한다. 공통 설정은 application.yml에 넣고, 프로파일 파일에서는 덮어쓸 것만 작성한다. 공통 설정이 중복되면 값을 바꿀 때 모든 프로파일 파일을 수정해야 한다.