포스트

스프링 초급 (8) - Config: 설정을 코드 밖으로 빼기

application.yml 구조와 @Value, @ConfigurationProperties, 프로파일로 환경마다 다른 설정을 적용하는 방법을 다룹니다.

스프링 초급 (8) - Config: 설정을 코드 밖으로 빼기

스프링 초급 시리즈의 8편입니다. 전체 목차는 0편에 있습니다.

코드 안에 박혀 있는 값들

지금까지 만든 코드에는 이런 값들이 섞여 있습니다.

1
2
3
4
5
6
7
8
9
@Service
public class NotificationService {

    public void sendWelcomeMail(Member member) {
        String sender = "no-reply@example.com";   // 보내는 주소
        int retryCount = 3;                       // 실패 시 재시도 횟수
        // ...
    }
}

지금은 잘 돌아갑니다. 문제는 이 값들이 환경마다 달라져야 한다는 것입니다.

  • 내 노트북에서는 메일을 실제로 보내면 안 됩니다. 로그만 찍혀야 합니다
  • 실제 서버에서는 진짜 주소로 보내야 합니다
  • 재시도 횟수도 개발 중엔 1번, 운영에선 3번이 맞습니다

이 값들이 자바 코드 안에 있으면 환경을 바꿀 때마다 코드를 고치고 다시 빌드해야 합니다. 배포하기 직전에 값을 바꾸고, 돌아와서 되돌리는 일이 반복됩니다. 되돌리는 걸 깜빡하면 운영 설정으로 로컬을 돌리게 됩니다.

이번 편은 이 값들을 코드 밖으로 빼는 이야기입니다.

application.yml 구조

5편에서 이미 application.yml을 만들었습니다. 이 파일은 스프링이 시작할 때 자동으로 읽는 설정 파일입니다. 위치는 src/main/resources/application.yml입니다.

.properties.yml 둘 다 쓸 수 있습니다. 같은 내용입니다.

1
2
3
4
# application.properties
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.username=sa
spring.jpa.hibernate.ddl-auto=create
1
2
3
4
5
6
7
8
# application.yml
spring:
  datasource:
    url: jdbc:h2:mem:testdb
    username: sa
  jpa:
    hibernate:
      ddl-auto: create

.yml은 같은 앞부분(spring.datasource)을 반복하지 않고 들여쓰기로 묶습니다. 설정이 많아질수록 읽기 편해서 이쪽을 많이 씁니다.

.yml들여쓰기가 곧 문법입니다. 탭 문자는 쓸 수 없고 스페이스만 됩니다. 콜론 뒤에는 반드시 공백이 하나 있어야 합니다. 설정이 안 먹으면 값이 틀린 게 아니라 들여쓰기가 틀린 경우가 많습니다.

자주 쓰는 설정들입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
server:
  port: 8080                          # 서버 포트

spring:
  application:
    name: demo                        # 애플리케이션 이름 (로그에 찍힌다)

  datasource:
    url: jdbc:h2:mem:testdb
    username: sa
    password:

  jpa:
    hibernate:
      ddl-auto: create                # create / update / validate / none
    show-sql: true
    properties:
      hibernate:
        format_sql: true

logging:
  level:
    root: info                        # 전체 기본 로그 레벨
    com.example.demo: debug           # 내 패키지만 자세히
    org.hibernate.SQL: debug          # 실행되는 SQL

logging.level패키지 단위로 얼마나 자세히 찍을지를 정합니다. 레벨은 trace < debug < info < warn < error 순서로, 지정한 레벨 이상만 출력됩니다. root: info로 두고 내 패키지만 debug로 내리면, 남의 라이브러리 로그에 묻히지 않고 내 코드만 자세히 볼 수 있습니다.

@Value로 값 하나 받기

이제 아까 하드코딩한 값을 설정 파일로 옮깁니다. 스프링이 정한 키 말고 우리가 만든 키도 넣을 수 있습니다.

1
2
3
4
5
6
app:
  name: 스프링 초급 데모
  notification:
    sender: no-reply@example.com
    retry-count: 3
    enabled: true

@Value로 하나씩 꺼내 씁니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class NotificationService {

    private final String sender;
    private final int retryCount;

    public NotificationService(
            @Value("${app.notification.sender}") String sender,
            @Value("${app.notification.retry-count}") int retryCount) {
        this.sender = sender;
        this.retryCount = retryCount;
    }
}

${...} 안에 yml의 키를 점으로 이어서 씁니다. String으로 적힌 값을 int로 받으면 스프링이 알아서 변환해 줍니다.

없는 키를 적으면 어떻게 될까요. 애플리케이션이 시작조차 못 합니다.

1
2
3
Caused by: java.lang.IllegalArgumentException:
Could not resolve placeholder 'app.notification.sendr' in value
"${app.notification.sendr}"

오타가 실행 중이 아니라 시작 시점에 잡힙니다. 값이 없어도 되게 하려면 기본값을 콜론 뒤에 적습니다.

1
@Value("${app.notification.retry-count:1}") int retryCount   // 없으면 1

확인 1: 주입된 값을 직접 찍어보기

정말 들어왔는지 눈으로 봅니다. 애플리케이션이 뜬 직후 한 번 실행되는 ApplicationRunner를 만듭니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Component
public class ConfigPrinter implements ApplicationRunner {

    private static final Logger log = LoggerFactory.getLogger(ConfigPrinter.class);

    private final String appName;
    private final String sender;
    private final int retryCount;

    public ConfigPrinter(
            @Value("${app.name}") String appName,
            @Value("${app.notification.sender}") String sender,
            @Value("${app.notification.retry-count:1}") int retryCount) {
        this.appName = appName;
        this.sender = sender;
        this.retryCount = retryCount;
    }

    @Override
    public void run(ApplicationArguments args) {
        log.info("app.name    = {}", appName);
        log.info("sender      = {}", sender);
        log.info("retry-count = {}", retryCount);
    }
}

실행하면 시작 로그 마지막에 이렇게 찍힙니다.

1
2
3
c.e.demo.common.ConfigPrinter : app.name    = 스프링 초급 데모
c.e.demo.common.ConfigPrinter : sender      = no-reply@example.com
c.e.demo.common.ConfigPrinter : retry-count = 3

자바 코드 어디에도 no-reply@example.com이라는 문자열이 없습니다. yml에만 있고, 스프링이 그걸 읽어 생성자로 넣어줬습니다. 4편에서 본 의존성 주입과 같은 방식인데, 주입하는 게 객체가 아니라 값일 뿐입니다.

@ConfigurationProperties로 묶어서 받기

@Value는 값이 두세 개일 때는 편한데, 늘어나면 불편해집니다.

1
2
3
4
5
6
public NotificationService(
        @Value("${app.notification.sender}") String sender,
        @Value("${app.notification.retry-count}") int retryCount,
        @Value("${app.notification.enabled}") boolean enabled,
        @Value("${app.notification.timeout-seconds}") int timeoutSeconds,
        @Value("${app.notification.template-path}") String templatePath) {

문제가 세 가지입니다.

  • app.notification. 이 다섯 번 반복됩니다
  • 키 이름이 문자열이라 오타가 컴파일 때 안 잡힙니다. 실행해 봐야 압니다
  • 이 값들이 한 덩어리라는 게 코드에 안 드러납니다

@ConfigurationProperties는 같은 앞부분을 가진 값들을 객체 하나로 묶어서 받습니다. 6편에서 쓴 record를 그대로 씁니다.

1
2
3
4
5
6
@ConfigurationProperties(prefix = "app.notification")
public record NotificationProperties(
        String sender,
        int retryCount,
        boolean enabled
) {}

그리고 메인 클래스에 @ConfigurationPropertiesScan을 붙여 이런 클래스를 찾게 합니다.

1
2
3
4
5
6
7
@SpringBootApplication
@ConfigurationPropertiesScan
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

이제 객체 하나만 주입받으면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class NotificationService {

    private final NotificationProperties properties;

    public NotificationService(NotificationProperties properties) {
        this.properties = properties;
    }

    public void sendWelcomeMail(Member member) {
        if (!properties.enabled()) {
            return;
        }
        // properties.sender(), properties.retryCount() 사용
    }
}

yml의 retry-count가 자바의 retryCount로 들어간 걸 보세요. 스프링은 하이픈으로 나뉜 이름을 카멜 케이스로 알아서 맞춰줍니다. RETRY_COUNT, retry_count도 같은 값으로 인식합니다. 이걸 완화된 바인딩(relaxed binding)이라고 합니다.

@Value와 비교하면 이렇습니다.

  @Value @ConfigurationProperties
받는 단위 값 하나 접두사 아래 전부
키 오타 시작 시점에 예외 필드 이름이 곧 키라 오타가 날 자리가 없음
쓰기 좋은 때 값이 한두 개 값이 여러 개거나 한 덩어리일 때

annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'를 추가하면 IDE가 application.yml에서 우리가 만든 키까지 자동완성해 줍니다. 필수는 아니지만 오타를 줄여줍니다.

환경마다 다른 설정: 프로파일

값을 밖으로 뺐지만 아직 한 벌입니다. 로컬과 운영이 다른 값을 써야 하는 문제는 그대로입니다.

프로파일은 설정 묶음에 이름을 붙이고 실행할 때 고르는 기능입니다. 파일 이름 규칙만 지키면 됩니다.

1
2
3
4
src/main/resources/
├── application.yml            공통 설정 + 어떤 프로파일을 쓸지
├── application-local.yml      local 프로파일에서만 추가로 읽는다
└── application-prod.yml       prod 프로파일에서만 추가로 읽는다

application.yml — 어디서나 같은 것만 둡니다.

1
2
3
4
5
6
7
8
spring:
  application:
    name: demo
  profiles:
    active: local          # 아무것도 안 주면 local로 실행

app:
  name: 스프링 초급 데모

application-local.yml — 내 노트북용입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
server:
  port: 8080

spring:
  datasource:
    url: jdbc:h2:mem:testdb
    username: sa
  jpa:
    hibernate:
      ddl-auto: create
    show-sql: true

logging:
  level:
    com.example.demo: debug

app:
  notification:
    sender: dev@example.com
    retry-count: 1
    enabled: false          # 로컬에선 실제로 안 보낸다

application-prod.yml — 서버용입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server:
  port: 9090

spring:
  jpa:
    hibernate:
      ddl-auto: validate    # 테이블을 건드리지 않는다
    show-sql: false

logging:
  level:
    root: warn
    com.example.demo: info

app:
  notification:
    sender: no-reply@example.com
    retry-count: 3
    enabled: true

application.yml은 항상 읽히고, 거기에 프로파일 파일이 덮어씌워집니다. 그래서 공통은 application.yml에 두고, 다른 부분만 프로파일 파일에 적으면 됩니다.

확인 2: 프로파일을 바꿔 실행해 보기

먼저 그냥 실행합니다.

1
./gradlew bootRun
1
2
3
4
5
6
7
8
9
10
11
com.example.demo.DemoApplication : Starting DemoApplication using Java 21
com.example.demo.DemoApplication : The following 1 profile is active: "local"
...
Hibernate: 
    create table member (
        id bigint generated by default as identity,
        ...
    )
o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http)
c.e.demo.common.ConfigPrinter : sender      = dev@example.com
c.e.demo.common.ConfigPrinter : retry-count = 1

이번엔 프로파일을 지정합니다.

1
./gradlew bootRun --args='--spring.profiles.active=prod'
1
2
3
4
5
com.example.demo.DemoApplication : The following 1 profile is active: "prod"
...
o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 9090 (http)
c.e.demo.common.ConfigPrinter : sender      = no-reply@example.com
c.e.demo.common.ConfigPrinter : retry-count = 3

세 가지가 한 번에 바뀌었습니다.

  • 포트가 8080 → 9090
  • senderdev@example.com → no-reply@example.com
  • Hibernate: 로 시작하는 SQL 로그가 사라졌습니다. show-sql: false이고 로그 레벨도 warn이라서입니다

코드는 한 줄도 안 바꿨고 다시 빌드하지도 않았습니다. 빌드된 결과물 하나로 여러 환경을 돌릴 수 있게 된 것이 프로파일의 핵심입니다. 빌드한 jar로도 똑같이 됩니다.

1
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

값이 여러 군데서 오면 우선순위가 있습니다. 아래로 갈수록 셉니다.

  1. application.yml
  2. application-{프로파일}.yml
  3. 환경변수 (SPRING_PROFILES_ACTIVE=prod)
  4. 명령행 인자 (--spring.profiles.active=prod)

나중에 정해지는 것이 이깁니다. 배포 스크립트에서 명령행이나 환경변수로 지정하면 파일을 안 고쳐도 됩니다.

비밀값은 yml에 두지 않는다

프로파일까지 오면 이런 코드를 쓰고 싶어집니다.

1
2
3
4
5
6
# application-prod.yml — 이렇게 하면 안 된다
spring:
  datasource:
    url: jdbc:mysql://prod-db.example.com:3306/demo
    username: admin
    password: 실제비밀번호

application-prod.ymlgit에 올라갑니다. 저장소에 접근할 수 있는 사람 전부가 운영 DB 비밀번호를 보게 되고, 저장소가 공개로 바뀌는 순간 전 세계에 공개됩니다. 커밋 기록에 남으면 나중에 지워도 히스토리에 남습니다.

비밀값은 파일이 아니라 실행하는 환경에서 주입합니다.

1
2
3
4
5
spring:
  datasource:
    url: jdbc:mysql://prod-db.example.com:3306/demo
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

${DB_PASSWORD}환경변수에서 읽으라는 뜻입니다. 파일에는 “여기에 비밀번호가 들어간다”는 사실만 적히고 값은 안 적힙니다.

실행할 때 넘깁니다.

1
2
DB_USERNAME=admin DB_PASSWORD=실제비밀번호 \
  java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

환경변수가 없으면 시작할 때 터집니다. 값 없이 조용히 돌다가 나중에 실패하는 것보다 낫습니다.

1
Could not resolve placeholder 'DB_PASSWORD' in value "${DB_PASSWORD}"

로컬에서 편하게 쓰려고 application-local.yml에 비밀번호를 적어두는 습관이 위험합니다. 로컬용이라고 적어둔 파일이 나중에 그대로 복사돼서 운영으로 가는 일이 흔합니다. 비밀값은 처음부터 환경변수로 받는 걸 권합니다.

정리

  • 환경마다 달라지는 값이 자바 코드에 있으면 환경을 바꿀 때마다 코드를 고치고 다시 빌드해야 한다
  • application.yml은 스프링이 시작할 때 자동으로 읽는다. logging.level로 패키지별 로그 상세도를 조절한다
  • @Value는 값 하나를, @ConfigurationProperties는 접두사 아래 값들을 객체로 묶어 받는다. 값이 여러 개면 후자가 낫다
  • 키 오타는 실행 중이 아니라 시작 시점에 잡힌다
  • 프로파일을 나누면 빌드 결과물 하나로 여러 환경을 돌릴 수 있다. 명령행 인자나 환경변수가 파일보다 우선한다
  • 비밀값은 yml에 적지 않고 ${DB_PASSWORD}로 선언만 하고 환경변수로 넣는다

시리즈를 마치며

이 편으로 스프링 초급 시리즈가 끝납니다. 여기까지 오면서 만든 것을 한 줄로 이으면 이렇게 됩니다.

1
2
3
4
5
6
7
8
요청이 들어오면
  → 컨트롤러가 받아서 (3편: 계층 분리)
  → 필요한 객체는 스프링이 넣어주고 (4편: DI)
  → 서비스가 규칙을 판단하고
  → 리포지토리가 DB에 저장하고 (5편: JPA)
  → 엔티티가 아닌 DTO로 바꿔서 내보내고 (6편: DTO)
  → 실패하면 정해진 형태로 알려주고 (7편: 예외 처리)
  → 환경마다 다른 값은 설정으로 분리한다 (8편: Config)

이 시리즈에서 계속 반복한 방식이 하나 있습니다. 먼저 스프링 없이, 혹은 그 기능 없이 만들어 보고 불편해진 다음에 도구를 붙였습니다. @JsonIgnore로 막아도 문제가 남는 걸 보고 DTO로 갔고, try-catch가 반복되는 걸 보고 @RestControllerAdvice로 갔습니다.

애노테이션을 외우는 것보다 그게 없으면 뭐가 불편한지를 아는 게 오래갑니다. 새로운 기능을 만날 때도 “이건 어떤 불편을 없애려고 나왔나”를 먼저 찾으면 훨씬 빨리 이해됩니다.

다음으로 갈 곳은 두 갈래입니다.

  • 스프링이 어떻게 이걸 다 해주는지가 궁금하다면 — 빈 컨테이너, 스프링이 내 객체를 프록시로 감싸는 이야기, AOP, 트랜잭션이 실제로 어떻게 동작하는지를 다루는 스프링 중급 시리즈로 가면 됩니다
  • DB 쪽이 더 궁금하다면 — 5편에서 넘긴 영속성 컨텍스트와 변경 감지, 연관관계 매핑, N+1 문제를 다루는 JPA 기초 시리즈로 가면 됩니다

둘 중 지금 더 답답한 쪽부터 보면 됩니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.