스프링 초급 (8) - Config: 설정을 코드 밖으로 빼기
application.yml 구조와 @Value, @ConfigurationProperties, 프로파일로 환경마다 다른 설정을 적용하는 방법을 다룹니다.
스프링 초급 시리즈의 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
sender가 dev@example.com → no-reply@example.comHibernate:로 시작하는 SQL 로그가 사라졌습니다.show-sql: false이고 로그 레벨도warn이라서입니다
코드는 한 줄도 안 바꿨고 다시 빌드하지도 않았습니다. 빌드된 결과물 하나로 여러 환경을 돌릴 수 있게 된 것이 프로파일의 핵심입니다. 빌드한 jar로도 똑같이 됩니다.
1
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod
값이 여러 군데서 오면 우선순위가 있습니다. 아래로 갈수록 셉니다.
application.ymlapplication-{프로파일}.yml- 환경변수 (
SPRING_PROFILES_ACTIVE=prod) - 명령행 인자 (
--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.yml은 git에 올라갑니다. 저장소에 접근할 수 있는 사람 전부가 운영 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 기초 시리즈로 가면 됩니다
둘 중 지금 더 답답한 쪽부터 보면 됩니다.