Spring Boot Profiles & Configuration이란? 환경별 설정 관리의 핵심
실무에서 “로컬에서는 H2 인메모리 DB, 개발 서버에서는 MySQL, 프로덕션에서는 Aurora를 써야 한다”, “환경마다 로그 레벨, 외부 API 엔드포인트, 캐시 TTL이 다르다” — 이런 환경별 설정 분기를 체계적으로 관리하는 것이 Spring Boot의 Profiles와 Externalized Configuration입니다.
단순히 application-{profile}.yml 파일을 나누는 것은 시작일 뿐입니다. 이 글에서는 17단계 설정 우선순위(Property Source Ordering), Profile 활성화 전략, @ConfigurationProperties를 활용한 타입 안전 설정, Profile별 Bean 등록, 설정 유효성 검증, 그리고 Kubernetes/Docker 환경에서의 외부 설정 주입까지 운영 수준에서 완전히 다룹니다.
Property Source 우선순위: 17단계 설정 로딩 순서
Spring Boot는 여러 소스에서 설정을 로딩하며, 나중에 로딩된 값이 이전 값을 덮어씁니다. 이 우선순위를 정확히 이해해야 “왜 내 설정이 적용되지 않는가?”라는 문제를 해결할 수 있습니다:
| 우선순위 | 설정 소스 | 예시 |
|---|---|---|
| 1 (최고) | 커맨드 라인 인자 | --server.port=9090 |
| 2 | SPRING_APPLICATION_JSON | 환경변수로 JSON 전달 |
| 3 | ServletConfig/ServletContext 파라미터 | WAR 배포 시 |
| 4 | JNDI | java:comp/env |
| 5 | System Properties | -Dserver.port=9090 |
| 6 | OS 환경변수 | SERVER_PORT=9090 |
| 7 | RandomValuePropertySource | random.int, random.uuid |
| 8 | JAR 외부 Profile별 설정 | ./config/application-prod.yml |
| 9 | JAR 외부 기본 설정 | ./config/application.yml |
| 10 | JAR 내부 Profile별 설정 | classpath:application-prod.yml |
| 11 | JAR 내부 기본 설정 | classpath:application.yml |
| 12 (최저) | @PropertySource | @PropertySource("classpath:custom.properties") |
핵심 원칙: JAR 외부 설정이 JAR 내부 설정보다 우선합니다. 환경변수가 파일 설정보다 우선합니다. 커맨드 라인 인자가 모든 것을 덮어씁니다. 이 구조 덕분에 코드 변경 없이 환경별로 설정을 주입할 수 있습니다.
Profile 활성화: 5가지 방법
# 1. application.yml에서 기본 Profile 설정
spring:
profiles:
active: dev
# 2. 환경변수 (Kubernetes/Docker에서 가장 일반적)
SPRING_PROFILES_ACTIVE=prod
# 3. 커맨드 라인 인자
java -jar app.jar --spring.profiles.active=prod
# 4. JVM System Property
java -Dspring.profiles.active=prod -jar app.jar
# 5. 프로그래밍 방식
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(Application.class);
app.setAdditionalProfiles("prod", "metrics");
app.run(args);
}
}
복수 Profile 활성화
# 쉼표로 구분하여 여러 Profile 동시 활성화
SPRING_PROFILES_ACTIVE=prod,metrics,kafka
# 활성화 순서: 뒤에 오는 Profile이 앞의 값을 덮어씀
# prod → metrics → kafka 순서로 적용
# 같은 키가 있으면 kafka의 값이 최종 적용
Profile Groups (Spring Boot 2.4+)
# application.yml
spring:
profiles:
group:
prod:
- proddb # application-proddb.yml
- prodmq # application-prodmq.yml
- prodmetrics # application-prodmetrics.yml
dev:
- devdb
- devmq
# SPRING_PROFILES_ACTIVE=prod만 설정하면
# proddb, prodmq, prodmetrics가 자동 활성화
application.yml 구조 설계: 환경별 설정 분리
방법 1: 파일 분리 (가장 일반적)
src/main/resources/
├── application.yml # 공통 설정 (모든 환경에 적용)
├── application-local.yml # 로컬 개발
├── application-dev.yml # 개발 서버
├── application-staging.yml # 스테이징
└── application-prod.yml # 프로덕션
# application.yml (공통)
server:
shutdown: graceful
spring:
jackson:
default-property-inclusion: non_null
time-zone: Asia/Seoul
logging:
pattern:
console: "%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
---
# application-local.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
h2:
console:
enabled: true
logging:
level:
com.example: DEBUG
org.hibernate.SQL: DEBUG
---
# application-prod.yml
spring:
datasource:
url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:myapp}
username: ${DB_USERNAME}
password: ${DB_PASSWORD}
hikari:
maximum-pool-size: 20
minimum-idle: 5
logging:
level:
root: WARN
com.example: INFO
방법 2: 단일 파일 내 멀티 Document (— 구분)
# application.yml 하나에 모두 포함
server:
port: 8080
---
spring:
config:
activate:
on-profile: dev
datasource:
url: jdbc:mysql://dev-db:3306/myapp
---
spring:
config:
activate:
on-profile: prod
datasource:
url: jdbc:mysql://prod-db:3306/myapp
실무 권장: 파일 분리 방식이 가독성과 유지보수에 더 좋습니다. 단일 파일 방식은 설정이 적을 때만 사용하세요.
@ConfigurationProperties: 타입 안전 설정 바인딩
@Value보다 @ConfigurationProperties가 실무에서 권장되는 이유:
| 특성 | @Value | @ConfigurationProperties |
|---|---|---|
| 타입 안전성 | ❌ 문자열 | ✅ Java 타입 |
| 중첩 객체 | ❌ | ✅ |
| 유효성 검증 | ❌ | ✅ @Validated + JSR-303 |
| 리스트/맵 | 제한적 | ✅ 완전 지원 |
| IDE 지원 | ❌ | ✅ 자동완성 |
| Relaxed Binding | ❌ | ✅ kebab/camel/snake 모두 인식 |
# application.yml
app:
jwt:
secret: ${JWT_SECRET:default-secret-for-dev}
expiration: 3600
refresh-expiration: 86400
issuer: myapp
cors:
allowed-origins:
- https://example.com
- https://app.example.com
max-age: 3600
rate-limit:
enabled: true
requests-per-minute: 60
burst-size: 10
// 타입 안전 설정 클래스
@ConfigurationProperties(prefix = "app")
@Validated
public record AppProperties(
@Valid JwtProperties jwt,
@Valid CorsProperties cors,
@Valid RateLimitProperties rateLimit
) {
public record JwtProperties(
@NotBlank String secret,
@Min(60) long expiration,
@Min(60) long refreshExpiration,
@NotBlank String issuer
) {}
public record CorsProperties(
@NotEmpty List<String> allowedOrigins,
@Positive long maxAge
) {}
public record RateLimitProperties(
boolean enabled,
@Positive int requestsPerMinute,
@Positive int burstSize
) {}
}
// 활성화
@Configuration
@EnableConfigurationProperties(AppProperties.class)
public class AppConfig {}
// 사용
@Service
public class AuthService {
private final AppProperties.JwtProperties jwtConfig;
public AuthService(AppProperties appProperties) {
this.jwtConfig = appProperties.jwt();
}
public String generateToken(User user) {
return Jwts.builder()
.setIssuer(jwtConfig.issuer())
.setExpiration(new Date(
System.currentTimeMillis() + jwtConfig.expiration() * 1000))
.signWith(Keys.hmacShaKeyFor(jwtConfig.secret().getBytes()))
.compact();
}
}
Relaxed Binding: 유연한 프로퍼티 매핑
# 아래 4가지 모두 같은 프로퍼티로 인식됨
app.jwt.refresh-expiration=86400 # kebab-case (권장)
app.jwt.refreshExpiration=86400 # camelCase
app.jwt.refresh_expiration=86400 # snake_case
APP_JWT_REFRESHEXPIRATION=86400 # 환경변수 (UPPER_CASE)
@Profile로 Bean 조건부 등록
// Profile별 다른 구현체 등록
public interface StorageService {
void upload(String key, byte[] data);
}
@Service
@Profile("local")
public class LocalStorageService implements StorageService {
@Override
public void upload(String key, byte[] data) {
// 로컬 파일시스템에 저장
Files.write(Path.of("/tmp/uploads/" + key), data);
}
}
@Service
@Profile("!local") // local이 아닌 모든 환경
public class S3StorageService implements StorageService {
private final S3Client s3Client;
@Override
public void upload(String key, byte[] data) {
s3Client.putObject(
PutObjectRequest.builder().bucket("my-bucket").key(key).build(),
RequestBody.fromBytes(data)
);
}
}
// Profile 표현식 조합
@Profile("prod & !maintenance") // prod이면서 maintenance가 아닐 때
@Profile("dev | staging") // dev 또는 staging일 때
@Profile("!test") // test가 아닐 때
@Configuration 클래스 레벨 적용
@Configuration
@Profile("prod")
public class ProductionConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(20);
config.setMinimumIdle(5);
config.setConnectionTimeout(3000);
return new HikariDataSource(config);
}
@Bean
public CacheManager cacheManager() {
// Redis 캐시 매니저
return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30)))
.build();
}
}
@Configuration
@Profile("local")
public class LocalConfig {
@Bean
public CacheManager cacheManager() {
// 로컬에서는 간단한 인메모리 캐시
return new ConcurrentMapCacheManager("orders", "users");
}
}
환경변수 플레이스홀더와 기본값
# 환경변수 참조 + 기본값
spring:
datasource:
url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:myapp}
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD:} # 기본값 빈 문자열
server:
port: ${SERVER_PORT:8080}
# 다른 프로퍼티 참조
app:
base-url: https://${APP_DOMAIN:localhost:8080}
api-docs-url: ${app.base-url}/swagger-ui.html
Kubernetes에서 환경변수 주입
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
image: myapp:latest
env:
# Profile 활성화
- name: SPRING_PROFILES_ACTIVE
value: "prod"
# ConfigMap에서 주입
- name: DB_HOST
valueFrom:
configMapKeyRef:
name: app-config
key: db-host
# Secret에서 주입
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: app-secrets
key: db-password
# 리소스 기반 JVM 설정
- name: JAVA_OPTS
value: "-XX:MaxRAMPercentage=75.0"
이 패턴은 Kubernetes Secrets 관리와 결합하여 민감 정보를 안전하게 주입합니다.
설정 유효성 검증: 시작 시점에 실패하기
@ConfigurationProperties(prefix = "app.database")
@Validated // JSR-303 검증 활성화
public record DatabaseProperties(
@NotBlank(message = "DB URL은 필수입니다")
String url,
@NotBlank
String username,
@NotBlank
String password,
@Min(value = 1, message = "최소 커넥션 풀 크기는 1 이상이어야 합니다")
@Max(value = 100, message = "최대 커넥션 풀 크기는 100 이하여야 합니다")
int maxPoolSize,
@DurationMin(seconds = 1)
@DurationMax(minutes = 5)
Duration connectionTimeout
) {}
// 커스텀 검증
@ConfigurationProperties(prefix = "app")
@Validated
public class AppProperties implements InitializingBean {
private String adminEmail;
private List<String> allowedDomains;
@Override
public void afterPropertiesSet() {
// 복잡한 검증 로직
if (allowedDomains.isEmpty()) {
throw new IllegalStateException("최소 하나의 도메인이 필요합니다");
}
for (String domain : allowedDomains) {
if (!domain.startsWith("https://")) {
throw new IllegalStateException(
"모든 도메인은 https://로 시작해야 합니다: " + domain);
}
}
}
}
원칙: 설정 오류는 애플리케이션 시작 시점에 즉시 실패해야 합니다. 런타임에 NullPointerException으로 발견하는 것보다 시작 실패가 훨씬 안전합니다.
@ConditionalOn*: 조건부 자동 설정
@Configuration
public class ConditionalConfig {
// 프로퍼티 값에 따라 Bean 등록
@Bean
@ConditionalOnProperty(
name = "app.cache.enabled",
havingValue = "true",
matchIfMissing = false // 미설정 시 등록 안 함
)
public CacheManager redisCacheManager() { ... }
// 특정 클래스가 classpath에 있을 때만
@Bean
@ConditionalOnClass(name = "io.lettuce.core.RedisClient")
public RedisConnectionFactory redisConnectionFactory() { ... }
// 특정 Bean이 없을 때만 (기본값 제공)
@Bean
@ConditionalOnMissingBean(CacheManager.class)
public CacheManager defaultCacheManager() {
return new ConcurrentMapCacheManager();
}
// Profile + Property 조합
@Bean
@Profile("prod")
@ConditionalOnProperty("app.monitoring.enabled")
public MetricsExporter metricsExporter() { ... }
}
설정 암호화: Jasypt 통합
# application-prod.yml
spring:
datasource:
# ENC()로 감싼 값은 Jasypt가 자동 복호화
password: ENC(G8MKl3/5VjGH8R2pN3kABkU7GQq3PxJz)
# 복호화 키는 환경변수로 전달 (코드에 포함하지 않음!)
JASYPT_ENCRYPTOR_PASSWORD=my-encryption-key
Actuator로 현재 설정 확인
# 활성 Profile 확인
GET /actuator/env
{
"activeProfiles": ["prod", "metrics"],
"propertySources": [
{ "name": "systemEnvironment", ... },
{ "name": "application-prod.yml", ... }
]
}
# 특정 프로퍼티 출처 확인
GET /actuator/env/spring.datasource.url
{
"property": {
"source": "application-prod.yml",
"value": "jdbc:mysql://prod-db:3306/myapp"
}
}
# ⚠️ 프로덕션에서는 env 엔드포인트 비활성화!
management:
endpoints:
web:
exposure:
include: health,info,metrics # env 제외!
실무 안티패턴 5가지
1. 프로덕션 비밀번호를 application.yml에 하드코딩
# ❌ Git에 커밋되는 파일에 비밀번호
spring:
datasource:
password: SuperSecret123!
# ✅ 환경변수 참조
spring:
datasource:
password: ${DB_PASSWORD}
2. Profile 이름으로 로직 분기
// ❌ 코드에서 Profile 이름 직접 체크
@Service
public class OrderService {
@Value("${spring.profiles.active}")
private String activeProfile;
public void createOrder(OrderRequest req) {
if ("prod".equals(activeProfile)) {
// 프로덕션 전용 로직
}
}
}
// ✅ 설정 값으로 분기
@Service
public class OrderService {
private final boolean notificationEnabled;
public OrderService(AppProperties props) {
this.notificationEnabled = props.notification().enabled();
}
public void createOrder(OrderRequest req) {
if (notificationEnabled) {
// 알림 전송
}
}
}
3. @Value에 기본값 없이 사용
// ❌ 환경변수 미설정 시 시작 실패
@Value("${EXTERNAL_API_URL}")
private String apiUrl;
// ✅ 기본값 제공 또는 @ConfigurationProperties + @Validated 사용
@Value("${EXTERNAL_API_URL:http://localhost:8081}")
private String apiUrl;
4. 환경별 application.yml에 공통 설정 중복
# ❌ 모든 Profile 파일에 같은 설정 반복
# application-dev.yml, application-staging.yml, application-prod.yml 모두에:
spring:
jackson:
default-property-inclusion: non_null
# ✅ 공통 설정은 application.yml에 한 번만
# application.yml (공통)
spring:
jackson:
default-property-inclusion: non_null
5. 테스트에서 프로덕션 Profile 사용
// ❌ 프로덕션 DB에 연결될 위험
@SpringBootTest
@ActiveProfiles("prod")
class OrderServiceTest { ... }
// ✅ 테스트 전용 Profile
@SpringBootTest
@ActiveProfiles("test")
class OrderServiceTest { ... }
// application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
정리: Profiles & Configuration 설계 체크리스트
| 항목 | 체크 |
|---|---|
| 공통 설정은 application.yml, 환경별은 application-{profile}.yml | ☐ |
| 민감 정보는 환경변수로 주입 (yml에 하드코딩 금지) | ☐ |
| @ConfigurationProperties + @Validated로 타입 안전 + 검증 | ☐ |
| @Value 대신 @ConfigurationProperties 우선 사용 | ☐ |
| 코드에서 Profile 이름 직접 참조하지 않음 | ☐ |
| 테스트 전용 Profile (test) 분리 | ☐ |
| 프로덕션 Actuator env 엔드포인트 비활성화 | ☐ |
| Profile Groups로 관련 Profile 묶기 | ☐ |
| 설정 오류 시 시작 시점에 즉시 실패 | ☐ |
| Kubernetes ConfigMap/Secret과 환경변수 연동 | ☐ |
Spring Boot의 Profiles & Configuration은 단순한 설정 파일 관리가 아니라 환경 격리, 보안, 타입 안전성, 유효성 검증을 아우르는 체계입니다. @Transactional이나 AOP 같은 핵심 기능도 결국 설정에 의존하므로, 설정 관리를 제대로 설계하는 것이 안정적인 Spring Boot 운영의 출발점입니다.