Spring Boot의 Profile: dev, test, prod 환경 설정

1. 개요

애플리케이션은 보통 여러 환경에서 실행됩니다. 개발 머신은 H2 데이터베이스를 사용합니다. 테스트 서버는 PostgreSQL을 사용합니다. 프로덕션은 실제 클러스터와 실제 SMTP를 사용합니다. 매번 배포할 때마다 설정 파일을 수동으로 수정한다면, 결국 dev 설정을 prod에 실수로 푸시하게 될 것입니다.

Spring Boot는 profile을 통해 이 문제를 해결합니다. 이 글을 마치면, profile에 따라 설정 파일을 분리하는 방법, @Profile로 bean을 켜고 끄는 방법, profile을 다양한 방식으로 활성화하는 방법, @ActiveProfiles로 테스트를 작성하는 방법, 그리고 흔한 오류들을 피하는 방법을 알 수 있습니다.

2. Profile이란?

Profile은 이름이 있는 라벨입니다. 예를 들어 dev, test, prod 같은 것입니다. Spring은 이 라벨을 사용하여 두 가지를 결정합니다:

  • 어떤 설정을 로드할지: application-dev.yml 파일은 profile dev가 활성화될 때만 읽습니다.
  • 어떤 bean을 생성할지: @Profile("prod")가 붙은 bean은 profile prod가 활성화될 때만 등록됩니다.

활성화된 profile이 없으면 Spring은 default라는 이름의 기본 profile을 사용합니다. @Profile이 붙지 않은 bean은 어떤 profile이 활성화되든 항상 생성됩니다.

이 글의 예제들에는 기본 web dependency만 필요합니다:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

3. Profile별 설정 파일

3.1. 여러 파일로 분리하기

파일명 규칙은 application-{profile}.yml (또는 .properties)입니다. application.yml 파일은 공통 설정을 포함하며, 항상 로드됩니다:

# application.yml
spring:
  application:
    name: shop-service
  profiles:
    default: dev

app:
  mail:
    from: no-reply@shop.vn
    host: localhost

Profile별 파일에는 다른 부분만 정의합니다:

# application-dev.yml
server:
  port: 8080
logging:
  level:
    root: DEBUG
# application-test.yml
server:
  port: 0
app:
  mail:
    host: fake-smtp
# application-prod.yml
server:
  port: 80
logging:
  level:
    root: WARN
app:
  mail:
    host: ${MAIL_HOST}

설명:

  • spring.profiles.default: dev는 어떤 profile도 활성화되지 않을 때 Spring이 default 대신 dev를 사용한다는 뜻입니다. 개발 머신은 매개변수 없이 바로 실행할 수 있습니다.
  • profile prod가 활성화되면, Spring은 먼저 application.yml을 로드한 후 application-prod.yml을 로드합니다. profile 파일의 값이 공통 값을 덮어씁니다. 따라서 app.mail.host는 환경 변수 MAIL_HOST의 값이 되지만, app.mail.from은 여전히 no-reply@shop.vn입니다.
  • application-test.yml의 server.port: 0은 Spring이 임의의 포트를 선택하게 해서 병렬 실행 시 충돌을 피합니다.

3.2. YAML 파일 안에 여러 문서 병합하기

파일을 줄이려면, ---로 구분된 YAML 다중 문서를 사용할 수 있습니다:

# application.yml
app:
  mail:
    host: localhost
---
spring:
  config:
    activate:
      on-profile: prod
app:
  mail:
    host: smtp.shop.vn

두 번째 문서는 prod가 활성화될 때만 유효합니다. 각 profile의 차이가 적을 때는 이 방식이 깔끔합니다. 설정이 길면 파일을 분리하는 것이 읽기와 검토하기 쉽습니다.

주의: .properties 파일에서는 --- 대신 #---를 사용해 문서를 구분합니다.

4. Profile 활성화하기

여러 방법이 있습니다. 우선순위는 Spring Boot의 일반 규칙을 따릅니다: 커맨드라인 인자 > 환경 변수 > 설정 파일.

커맨드라인 인자:

java -jar shop-service.jar --spring.profiles.active=prod

JVM System property:

java -Dspring.profiles.active=prod -jar shop-service.jar

환경 변수, Docker와 Kubernetes에서 가장 흔한 방법:

export SPRING_PROFILES_ACTIVE=prod
java -jar shop-service.jar

Maven plugin으로 실행할 때:

./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

코드에서, 특정 profile을 항상 추가하고 싶을 때 유용합니다:

@SpringBootApplication
public class ShopApplication {

    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(ShopApplication.class);
        app.setAdditionalProfiles("metrics");
        app.run(args);
    }
}

setAdditionalProfiles는 profile 목록에 추가합니다. 외부에서 활성화된 profile을 대체하지 않습니다. --spring.profiles.active=prod로 실행하면 prod와 metrics 모두 활성화됩니다.

쉼표로 구분해 여러 profile을 동시에 활성화할 수 있습니다: --spring.profiles.active=prod,eu. 같은 key를 두 파일이 정의하면, 뒤에 오는 profile이 이깁니다. 이 예제에서 application-eu.yml이 application-prod.yml을 덮어씁니다.

애플리케이션 시작 시 로그에서 어떤 profile이 활성화되었는지 알 수 있습니다:

INFO  c.e.shop.ShopApplication : The following 1 profile is active: "prod"

어떤 profile도 활성화되지 않고 spring.profiles.default도 설정하지 않으면, 다음을 봅니다:

INFO  c.e.shop.ShopApplication : No active profile set, falling back to 1 default profile: "default"

매번 배포 후 이 로그를 확인하세요. 잘못된 profile로 실행 중인지 빠르게 발견하는 방법입니다.

5. @Profile로 bean 켜고 끄기

설정이 다른 것만으로는 부족합니다. 구현 자체가 다를 수도 있습니다. 예: dev는 이메일을 콘솔에 출력, test는 메모리에 저장해서 확인, prod는 SMTP로 전송.

공통 인터페이스를 정의합니다:

public interface NotificationService {
    void send(String to, String message);
}

dev 구현:

@Service
@Profile("dev")
public class ConsoleNotificationService implements NotificationService {

    @Override
    public void send(String to, String message) {
        System.out.println("[DEV] 전송 대상: " + to + ": " + message);
    }
}

test 구현, 메시지를 메모리에 저장해서 assert합니다:

@Service
@Profile("test")
public class InMemoryNotificationService implements NotificationService {

    private final List<String> sentMessages = new CopyOnWriteArrayList<>();

    @Override
    public void send(String to, String message) {
        sentMessages.add(to + "|" + message);
    }

    public List<String> getSentMessages() {
        return List.copyOf(sentMessages);
    }
}

prod 구현:

@Service
@Profile("prod")
public class SmtpNotificationService implements NotificationService {

    private final String host;

    public SmtpNotificationService(@Value("${app.mail.host}") String host) {
        this.host = host;
    }

    @Override
    public void send(String to, String message) {
        // 실제로는 JavaMailSender를 사용합니다; 여기서는 예시만 보여줍니다
        System.out.println("[SMTP " + host + "] 전송 대상: " + to);
    }
}

사용하는 클래스는 어떤 환경인지 알 필요가 없습니다:

@Service
public class OrderService {

    private final NotificationService notificationService;

    public OrderService(NotificationService notificationService) {
        this.notificationService = notificationService;
    }

    public void placeOrder(String customerEmail, long orderId) {
        notificationService.send(customerEmail, "주문 #" + orderId + "이(가) 생성되었습니다");
    }
}

한 번에 세 bean 중 하나만 생성됩니다. Spring은 올바른 bean을 OrderService에 주입합니다. profile dev로 실행하고 placeOrder("an@shop.vn", 42)를 호출하면:

[DEV] 전송 대상: an@shop.vn: 주문 #42이(가) 생성되었습니다

5.1. Profile 표현식

@Profile은 논리식을 지원합니다:

@Configuration
public class SecurityConfig {

    @Bean
    @Profile("!prod")
    public CommandLineRunner seedDemoData() {
        return args -> System.out.println("데모 데이터 로드 중...");
    }

    @Bean
    @Profile("dev | test")
    public CommandLineRunner relaxedCors() {
        return args -> System.out.println("모든 origin에서 CORS 허용");
    }
}
  • !prod: prod를 제외한 모든 profile에서 bean을 생성합니다.
  • dev | test: dev 또는 test가 활성화될 때 생성합니다. &를 사용해 “그리고”도 가능합니다. 예: prod & eu.

@Profile은 @Component/@Configuration 클래스나 @Bean 메서드에 붙일 수 있습니다. @Configuration에 붙으면 내부의 모든 bean이 그 조건을 따릅니다.

5.2. 코드에서 profile 확인하기

로직을 분기해야 할 때가 있습니다. Environment를 사용하세요:

@Component
public class StartupBanner implements CommandLineRunner {

    private final Environment environment;

    public StartupBanner(Environment environment) {
        this.environment = environment;
    }

    @Override
    public void run(String... args) {
        if (environment.acceptsProfiles(Profiles.of("prod"))) {
            System.out.println("프로덕션 환경에서 실행 중입니다");
        }
    }
}

이 방법은 절약해서 사용하세요. 코드에 if (prod)가 많다면, @Profile로 별도 bean으로 나누는 것을 고려하세요.

6. Profile 그룹

시스템이 커지면서 proddb (데이터베이스용)와 prodmq (메시지 브로커용) 같이 profile을 세분화할 수 있습니다. Profile 그룹은 여러 profile을 한 이름으로 활성화합니다:

# application.yml
spring:
  profiles:
    group:
      prod: proddb, prodmq
      dev: devdb

이제 --spring.profiles.active=prod를 실행하면 prod, proddb, prodmq가 모두 활성화됩니다. Spring은 차례로 application-prod.yml, application-proddb.yml, application-prodmq.yml을 로드합니다. 운영자는 하나의 이름만 기억하면 됩니다.

7. @ActiveProfiles로 테스트 작성하기

테스트에서는 @ActiveProfiles로 profile을 선택합니다. 아래 테스트는 두 가지를 확인합니다: 올바른 bean이 주입되었는지, 그리고 application-test.yml의 설정이 유효한지.

@SpringBootTest
@ActiveProfiles("test")
class OrderServiceProfileTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private NotificationService notificationService;

    @Value("${app.mail.host}")
    private String mailHost;

    @Test
    void shouldUseInMemoryNotificationInTestProfile() {
        assertThat(notificationService).isInstanceOf(InMemoryNotificationService.class);
    }

    @Test
    void shouldRecordNotificationWhenOrderPlaced() {
        orderService.placeOrder("binh@shop.vn", 7);

        var inMemory = (InMemoryNotificationService) notificationService;
        assertThat(inMemory.getSentMessages())
                .contains("binh@shop.vn|주문 #7이(가) 생성되었습니다");
    }

    @Test
    void shouldOverrideMailHostFromTestProfile() {
        assertThat(mailHost).isEqualTo("fake-smtp");
    }
}

설명:

  • @ActiveProfiles("test")는 이 클래스의 Spring context에 profile test를 활성화합니다. spring.profiles.default가 무엇이든 상관없습니다.
  • 첫 번째 테스트는 Spring이 실제 이메일 전송 대신 InMemoryNotificationService를 선택했는지 확인합니다.
  • 두 번째 테스트는 in-memory bean을 사용해 실제 메일을 보내지 않고도 메시지 내용을 검증합니다.
  • 세 번째 테스트는 application-test.yml의 app.mail.host가 application.yml의 localhost를 덮어썼음을 증명합니다.

주의: profile 조합이 다르면 각각 새로운 Spring context가 생성됩니다. 각 테스트 클래스가 다른 profile 세트를 사용하면, context 캐시가 재사용되지 않아 테스트 스위트가 훨씬 느려집니다. 테스트의 profile 조합 개수를 최소화하세요.

8. 흔한 오류들

Profile별 파일 안에 spring.profiles.active 설정하기. Spring Boot 2.4부터, application-dev.yml (또는 on-profile이 있는 문서) 안에 spring.profiles.active나 spring.profiles.include를 정의하면 애플리케이션 시작이 실패합니다:

InvalidConfigDataPropertyException: Property 'spring.profiles.active' imported from
location 'class path resource [application-dev.yml]' is invalid in a profile specific resource

대신 profile 그룹 (6번 섹션)을 사용하세요.

application.yml 안에 spring.profiles.active=dev 하드코딩하기. 이 파일은 jar에 포함됩니다. 누군가 서버에서 환경 변수를 전달하는 것을 잊으면, prod가 dev 설정으로 실행됩니다. 로컬 환경을 위해서는 spring.profiles.default를 사용하고, 서버에서는 항상 명시적으로 profile을 활성화하세요.

기밀 정보를 application-prod.yml에 두기. 데이터베이스 비밀번호나 API 키는 Git에 들어가면 안 됩니다. ${DB_PASSWORD} 같은 placeholder를 사용하고 환경 변수나 secret manager로 값을 제공하세요.

Profile과 일치하는 bean이 없기. profile staging으로 실행하는데 dev, test, prod용 구현만 있으면, 애플리케이션이 오류를 냅니다:

Parameter 0 of constructor in OrderService required a bean of type
'NotificationService' that could not be found.

@Profile("!prod")로 fallback bean을 만들거나 새 profile용 구현을 추가하세요.

Feature flag로 profile을 남용하기. Profile은 환경을 설명하기에 좋습니다. 환경과 독립적으로 기능을 켜고 끄려면, @ConditionalOnProperty가 더 명확합니다:

@Bean
@ConditionalOnProperty(name = "app.feature.loyalty-points", havingValue = "true")
public LoyaltyService loyaltyService() {
    return new LoyaltyService();
}

prod-with-loyalty나 dev-no-cache 같은 profile이 생기면, property로 전환할 때입니다.

9. 결론

Profile을 통해 코드를 수정하지 않고 환경별로 설정과 동작을 분리할 수 있습니다. application-{profile}.yml로 설정을 덮어쓰는 방법, @Profile로 구현을 선택하는 방법, profile 그룹으로 여러 profile을 묶는 방법, @ActiveProfiles로 테스트하는 방법을 배웠습니다.

기억할 몇 가지 원칙: 공통 설정은 application.yml에, 다른 부분만 profile 파일에 넣으세요. 서버에서는 환경 변수나 커맨드라인 인자로 명시적으로 profile을 활성화하세요. 기밀 정보는 Git에 넣지 마세요. 매번 배포 후 로그의 The following profiles are active 줄을 확인하세요.

spring-bootprofile설정테스트