Spring Profiles 환경 분리 전략

Spring Profiles란

Spring Profiles는 환경별로 다른 설정과 빈을 활성화하는 메커니즘이다. 개발(dev), 테스트(test), 스테이징(staging), 운영(prod) 환경마다 DB 접속 정보, 로그 레벨, 외부 API URL, 캐시 전략 등이 다를 수밖에 없다. Profiles를 사용하면 코드 변경 없이 환경 변수나 설정 파일만으로 애플리케이션의 동작을 전환할 수 있다.

단순한 설정값 분리를 넘어 특정 환경에서만 등록되는 빈, 환경별 다른 구현체 주입 등 강력한 조건부 구성을 지원한다.

Profile 활성화 방법

방법 예시 우선순위
환경 변수 SPRING_PROFILES_ACTIVE=prod 높음
JVM 인자 -Dspring.profiles.active=prod 높음
application.yml spring.profiles.active: dev 낮음
프로그래밍 SpringApplication.setAdditionalProfiles() 낮음
테스트 @ActiveProfiles("test") 테스트 전용
# Docker에서 실행
docker run -e SPRING_PROFILES_ACTIVE=prod myapp:latest

# JAR 직접 실행
java -jar app.jar --spring.profiles.active=prod

# Kubernetes Deployment
env:
  - name: SPRING_PROFILES_ACTIVE
    value: "prod"

# 여러 프로필 동시 활성화
SPRING_PROFILES_ACTIVE=prod,redis,monitoring

YAML 설정 파일 분리

Spring Boot는 프로필별 설정 파일을 자동으로 로드한다. 공통 설정은 application.yml에, 환경별 설정은 application-{profile}.yml에 작성한다.

# application.yml — 공통 설정 (모든 환경)
spring:
  application:
    name: my-api
  jpa:
    open-in-view: false
    properties:
      hibernate:
        default_batch_fetch_size: 100

server:
  port: 8080
  shutdown: graceful

management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus
# application-dev.yml — 개발 환경
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/myapp_dev
    username: dev_user
    password: dev_pass
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true

logging:
  level:
    root: INFO
    com.myapp: DEBUG
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql: TRACE
# application-prod.yml — 운영 환경
spring:
  datasource:
    url: jdbc:postgresql://${DB_HOST}:5432/${DB_NAME}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 3000
      idle-timeout: 600000
      max-lifetime: 1800000
  jpa:
    hibernate:
      ddl-auto: none        # 운영에서 절대 auto DDL 금지
    show-sql: false

logging:
  level:
    root: WARN
    com.myapp: INFO
  pattern:
    console: '{"time":"%d","level":"%p","logger":"%logger","msg":"%m"}%n'
# application-test.yml — 테스트 환경
spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=PostgreSQL
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
    database-platform: org.hibernate.dialect.H2Dialect

logging:
  level:
    root: WARN

@Profile: 조건부 빈 등록

특정 프로필에서만 빈을 등록하여 환경별로 다른 구현체를 주입할 수 있다.

// 인터페이스
public interface StorageService {
    String upload(MultipartFile file);
    byte[] download(String key);
}

// 개발: 로컬 파일시스템
@Service
@Profile("dev")
public class LocalStorageService implements StorageService {
    private final Path uploadDir = Path.of("./uploads");

    @Override
    public String upload(MultipartFile file) {
        Path target = uploadDir.resolve(file.getOriginalFilename());
        Files.copy(file.getInputStream(), target);
        return target.toString();
    }

    @Override
    public byte[] download(String key) {
        return Files.readAllBytes(Path.of(key));
    }
}

// 운영: AWS S3
@Service
@Profile("prod")
public class S3StorageService implements StorageService {
    private final S3Client s3;
    private final String bucket;

    public S3StorageService(S3Client s3,
            @Value("${aws.s3.bucket}") String bucket) {
        this.s3 = s3;
        this.bucket = bucket;
    }

    @Override
    public String upload(MultipartFile file) {
        String key = UUID.randomUUID() + "/" + file.getOriginalFilename();
        s3.putObject(
            PutObjectRequest.builder().bucket(bucket).key(key).build(),
            RequestBody.fromInputStream(file.getInputStream(), file.getSize())
        );
        return key;
    }

    @Override
    public byte[] download(String key) {
        return s3.getObject(
            GetObjectRequest.builder().bucket(bucket).key(key).build()
        ).readAllBytes();
    }
}

@Profile 고급: NOT, OR 표현식

// 운영 환경이 아닐 때만 활성화
@Configuration
@Profile("!prod")
public class DevToolsConfig {
    @Bean
    public DataSourceInitializer dataSourceInitializer(DataSource ds) {
        // 개발/테스트에서만 샘플 데이터 삽입
        ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
        populator.addScript(new ClassPathResource("data-sample.sql"));

        DataSourceInitializer init = new DataSourceInitializer();
        init.setDataSource(ds);
        init.setDatabasePopulator(populator);
        return init;
    }
}

// OR 조건: dev 또는 staging에서 활성화
@Service
@Profile({"dev", "staging"})
public class MockPaymentGateway implements PaymentGateway {
    @Override
    public PaymentResult charge(PaymentRequest request) {
        // 항상 성공 반환 (테스트용)
        return PaymentResult.success("mock-txn-" + UUID.randomUUID());
    }
}

// AND 조건: @Conditional 조합
@Configuration
@Profile("prod")
@ConditionalOnProperty(name = "feature.redis-cache", havingValue = "true")
public class RedisCacheConfig {
    // prod + redis-cache=true일 때만 활성화
}

Profile Groups: 프로필 묶기

Spring Boot 2.4+에서 도입된 Profile Groups로 여러 프로필을 하나로 묶을 수 있다.

# application.yml
spring:
  profiles:
    group:
      prod:
        - prod-db
        - prod-cache
        - prod-monitoring
      dev:
        - dev-db
        - dev-tools

# SPRING_PROFILES_ACTIVE=prod 설정 시
# → prod-db, prod-cache, prod-monitoring 프로필도 함께 활성화

# application-prod-db.yml
spring:
  datasource:
    url: jdbc:postgresql://${DB_HOST}:5432/${DB_NAME}
    hikari:
      maximum-pool-size: 30

# application-prod-cache.yml
spring:
  cache:
    type: redis
  data:
    redis:
      host: ${REDIS_HOST}
      cluster:
        nodes: ${REDIS_CLUSTER_NODES}

# application-prod-monitoring.yml
management:
  metrics:
    export:
      prometheus:
        enabled: true

@ConfigurationProperties + Profile

타입 안전한 설정 바인딩과 Profile을 결합하면 환경별 설정을 객체로 관리할 수 있다.

@ConfigurationProperties(prefix = "app")
@Validated
public record AppProperties(
    @NotBlank String name,
    @NotNull ApiProperties api,
    @NotNull SecurityProperties security
) {
    public record ApiProperties(
        @NotBlank String baseUrl,
        @Min(1000) int timeoutMs,
        int maxRetries
    ) {}

    public record SecurityProperties(
        @NotBlank String jwtSecret,
        Duration jwtExpiration,
        List<String> corsOrigins
    ) {}
}

// application-dev.yml
app:
  name: my-api-dev
  api:
    base-url: http://localhost:8081
    timeout-ms: 10000
    max-retries: 1
  security:
    jwt-secret: dev-secret-not-for-production
    jwt-expiration: 24h
    cors-origins:
      - http://localhost:3000
      - http://localhost:5173

// application-prod.yml
app:
  name: my-api
  api:
    base-url: https://api.internal.example.com
    timeout-ms: 3000
    max-retries: 3
  security:
    jwt-secret: ${JWT_SECRET}
    jwt-expiration: 1h
    cors-origins:
      - https://app.example.com

테스트에서 Profile 활용

// 통합 테스트: test 프로필 자동 활성화
@SpringBootTest
@ActiveProfiles("test")
class OrderServiceIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private StorageService storageService;
    // → test 프로필이면 MockStorageService가 주입됨

    @Test
    void 주문_생성_성공() {
        // H2 인메모리 DB 사용 (application-test.yml)
        Order order = orderService.create(dto);
        assertThat(order.getStatus()).isEqualTo(PENDING);
    }
}

// 특정 프로필 조건 테스트
@SpringBootTest
@ActiveProfiles("prod")
class ProdConfigTest {

    @Autowired
    private StorageService storageService;

    @Test
    void 운영에서는_S3_구현체가_주입됨() {
        assertThat(storageService).isInstanceOf(S3StorageService.class);
    }
}

// 프로필별 설정값 검증
@SpringBootTest
@ActiveProfiles("prod")
class AppPropertiesTest {

    @Autowired
    private AppProperties props;

    @Test
    void 운영_타임아웃은_3초() {
        assertThat(props.api().timeoutMs()).isEqualTo(3000);
    }

    @Test
    void 운영_CORS는_단일_도메인() {
        assertThat(props.security().corsOrigins()).hasSize(1);
        assertThat(props.security().corsOrigins().get(0))
            .startsWith("https://");
    }
}

운영 안전 장치

// 시작 시 활성 프로필 로깅 + 위험 설정 검증
@Component
@Slf4j
public class ProfileValidator implements ApplicationRunner {

    @Value("${spring.profiles.active:default}")
    private String activeProfiles;

    @Value("${spring.jpa.hibernate.ddl-auto:none}")
    private String ddlAuto;

    @Override
    public void run(ApplicationArguments args) {
        log.info("활성 프로필: {}", activeProfiles);

        if (activeProfiles.contains("prod")) {
            // 운영에서 위험한 설정 차단
            if (!"none".equals(ddlAuto) && !"validate".equals(ddlAuto)) {
                throw new IllegalStateException(
                    "운영 환경에서 ddl-auto=" + ddlAuto + "는 허용되지 않습니다"
                );
            }
        }
    }
}

Spring Cache 추상화 실전에서 다룬 캐시 설정도 Profile로 환경별 전환이 가능하다. 개발에서는 Caffeine, 운영에서는 Redis로 무중단 교체하는 패턴이 대표적이다. Spring AOP 프록시 가이드의 Aspect도 @Profile과 결합하면 환경별로 활성화/비활성화할 수 있다.

정리: Profiles 설계 체크리스트

  • 공통은 application.yml: 환경 무관한 설정은 기본 파일에 유지
  • 환경별 파일 분리: application-{profile}.yml로 오버라이드
  • Profile Groups: 관련 프로필을 묶어 활성화 단순화
  • @Profile 빈: 환경별 다른 구현체 주입 (로컬 파일 vs S3)
  • 운영 안전: ddl-auto=none 강제, show-sql=false, 시크릿은 환경 변수
  • 테스트 격리: @ActiveProfiles(“test”)로 인메모리 DB 사용
  • 검증: @Validated + @ConfigurationProperties로 시작 시 설정값 타입 체크
위로 스크롤
WordPress Appliance - Powered by TurnKey Linux