Spring Boot Profiles &

Spring Boot Profiles & Configuration이란? 환경별 설정 관리의 핵심

실무에서 “로컬에서는 H2 인메모리 DB, 개발 서버에서는 MySQL, 프로덕션에서는 Aurora를 써야 한다”, “환경마다 로그 레벨, 외부 API 엔드포인트, 캐시 TTL이 다르다” — 이런 환경별 설정 분기를 체계적으로 관리하는 것이 Spring Boot의 ProfilesExternalized 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 운영의 출발점입니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux