Java 17 + Spring Boot 3: @Value를 쓸까, @ConfigurationProperties를 쓸까

Letsdev·2023년 5월 14일
6

Prev >
Java 16 Record Class 신택스: 쉬운 Record Class 문법
Java 16 Record Class 사용처: DTO 클래스에는 어떻게 쓸까

계속 똑같은 그림인데, 그냥 record는 불변성이라는 걸 표현한 그림이다.

Record는

여태껏 말한 대로 record는 불변 객체를 만들라고 나온 쉬운 클래스 유형이다.

사용이 쉬운 덕분에 DTO 작성 때 딱히 뭘 안 하고 필드만 나열하면 알아서 final로 인식되고 getter도 toString도 생성자도 equals도 hashCode도 implements Serializable도 알아서 생겨서 편하게 쓸 수 있었다.

그런데 그게 Configuration Properties에서도 마찬가지다.

스프링 Configuration Properties 객체

스프링에서 Configuration Properties 객체는 application.propertiesapplication.yml 등에 작성한 속성들을 객체로 읽어 올 수 있는 기능이다.

기존에는 @Value 어노테이션을 통해 낱개 데이터를 읽어 오는 게 대중적인 방식이지만, 속성을 일정 단위에서 객체로 취급하고 확장성을 편하게 확보하려면, Configuration Properties도 괜찮은 선택지가 된다.

당연히 액티브 프로파일에 따라 다른 값을 넣거나 하는 것도 된다. 단일 값만 쓸 때는 @Value 어노테이션이 훨씬 간단하지만, 매번 뭐.뭐.뭐더라를 생각할 필요 없이 정해진 Bean으로 받아다 쓰면서 객체로 가져올 때 쓸 수 있으려면 @ConfigurationProperties를 택할 수 있다.

뭐가 어디어디 쓰이는지 바로 추적하기 쉽기 때문에, 변경이 생길 때 디버깅에도 좀 용이하다.

솔직히 익숙해지는 게 별건 아니지만, 익숙해지기 전엔 이게 @Value보다 귀찮다.
익숙해지고 나서도 당장만 보면 한 단계 귀찮은데, 속성 뎁스를 변경하기 용이한 점이 있어서 어지간하면 계속 얘만 택하게 됐다. 단계적인 리팩토링으로 경험했을 때도 이 친구와 함께하니 속성 파일의 구조를 변경하는 게 편했다. 올바른 선택이었다.

속성 파일 특성상 확장성이 생기다 보면 데이터 뎁스가 금방 변경이 되는데, @ConfigurationProperties로 관리해 왔으면 딱히 대공사가 아니다.

예시

스캔 설정

우선 스캔 범위를 정해 준다. 다른 빈(Bean) 스캔 범위를 정하듯이, Configuration Properties 스캔 범위도 정해 줘야 한다. 아무래도 설정을 주입해 주어야 하니, 별도로 스캔을 지정하는 것으로 보인다.

Application.java에서 해도 되고, 뭔 말인지 모르겠으면 그냥 Configutaion 만들면 된다.

// 가급적 실제 베이스패키지보다 한 단계 높게 적어도 된다.
// 그냥 "com.그룹명"까지 적는 게 편할 수 있다. 우리가 만드는 건 어차피 다 스캔할 거 아닌가.
@Configuration
@ConfigurationPropertiesScan(basePackages = "com.example")
public class ScanningPropertiesConfiguration {
}

CORS Properties 예시

커스텀으로 작성한 속성 파일 예시다.

resources/app/cors.yml

app:
  security:
    cors:
      exposed-headers: "*"
      allowed:
        methods: "*"
        headers: "*"
        origins:
          - https://abc.project.sample-react.com
          - https://admin.project.sample-react.com

resources/app/cors-dev.yml

app:
  security:
    cors:
      allowed:
        origins:
          - https://dev-project.sample-react.com
          - https://admin.dev-project.sample-react.com
          - http://local-project.sample-react.com
          - http://admin.local-project.sample-react.com
          - http://localhost:3000

resources/app/cors-local.yml

app:
  security:
    cors:
      allowed:
        origins:
          - http://local-project.sample-react.com
          - http://admin.local-project.sample-react.com
          - http://localhost:3000

application.yml의 일부분

spring:
  config:
    import:
      - classpath:/app/cors.yml

이런 식으로 작성돼 있으면 이제 스캔해 오면 된다.
우선 app.security.cors를 최상위 객체로 삼는다.

YAML에서 적절하게 사용한 exposed-headers 같은 kebab-case 네이밍은 대부분 프로그래밍 언어에서 빼기 연산자(-) 때문에 컨벤션에 쓸 수 없기 때문에, 이 경우 자바의 네이밍 컨벤션인 camelCase로 알아서 변환이 된다.
이제 이런 변환 정도는 눈치로 알아보는 게 좋다.

// @ConfigurationPropertiesScan(...)은 이루어지고 있다는 전제로 작성

@ConfigurationProperties("app.security.cors")
@ConfigurationPropertiesBinding
public record WebCorsProperties(
        @NestedConfigurationProperty WebCorsAllowedProperties allowed,
        String[] exposedHeaders // ← exposed-headers to camelCase
) {
    public WebCorsProperties {
    	// 기본값 작성
        if (exposedHeaders == null || exposedHeaders.length == 0) {
            exposedHeaders = new String[] {"*"};
        }
        
        // CorsAllowedProperties 생성자의 모든 필드에 null을 넣으면,
        // 그곳에서 생성자 파라미터를 기본값으로 대치하면 됨.
        if (allowed == null) allowed = new CorsAllowedProperties(null, null, null);
    }
}

패키지 전략은, 그냥 allowed라는 하위 패키지를 두고 그곳에 이 클래스를 두는 식으로 작성 중이다. 어떻게 둘지는 각자 마음대로 하면 된다.
다만 패키지에서 하이픈은 언더바로 대치하는 게 일반적인 네이밍 컨벤션이라 앞으로 참고하는 게 좋다.

@ConfigurationPropertiesBinding
public record WebCorsAllowedProperties(
        String[] headers,
        String[] methods,
        String[] origins
) {
    public WebCorsAllowedProperties {
    	// 기본값
        if (headers == null || headers.length == 0) headers = new String[] {"*"};
        if (methods == null || methods.length == 0) methods = new String[] {"*"};
        if (origins == null) {
            origins = new String[] {}; // origins는 비허용 정책.
            // 아니면 origins에 대해서만 이런 null 처리를 안 해도 된다.
            // 그러면 애플리케이션이 안 켜지게 하고, 무조건 origins를 작성해야 실행할 수 있도록 제약을 강하게 걸 수 있다.
        }
    }
}

사용한 예시

이제 저렇게 만들어 놓으면 다른 데서 쓰면 된다.
다만 알아야 할 것은, 컴파일 타임 상수로 활용되진 않는다는 거다.

애초에 프로파일 결정 자체가 컴파일 때가 아니라 실행할 때 이루어지다 보니까, 어떤 컴파일 타임에 필요한 값은 이걸 참조하지 못하게 두는 것이 맞는 거다.

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

	// 생성자 주입(RequiredArgConstructor가 알아서 주입해 줌.)
    private final WebCorsProperties webCorsProperties;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .exposedHeaders(webCorsProperties.exposedHeaders())
                .allowedOrigins(webCorsProperties.allowed().origins())
                .allowedMethods(webCorsProperties.allowed().methods())
                .allowedHeaders(webCorsProperties.allowed().headers())
    }
}

생성자 주입이 이루어지면 알아서 스캔이 된다.

뭔가 안 되면

보통의 실수

보통 spring.config.import에 작성을 안 한 경우도 빈번한 실수다.
그 외에는 그냥 오타를 잘 살피면 어떨까 싶다.

멀티 모듈인 경우

그리고 멀티 모듈인 경우는 설정 파일(ex: cors.yml)을 가진 모듈을 직접 implementation 해야만 application.yml에서 접근이 되는데, implementation을 빼 먹는 실수도 있을 수 있다(건너건너 implementation이면 안 된다. 적당히 은닉되는 특성 때문에 직접 써야 접근됨.).

애초에 그런 실수가 없게끔, 서버 애플리케이션마다 설정해 줄 값들이면 실행할 애플리케이션이 있는 모듈의 resources에 대부분을 몰아 놓는 게 낫다. (또는 그 모듈의 서브모듈 중 properties를 관리하는 모듈에 위치.)

근데 이렇게 잘해 줘서 접근 가능한 상태더라도, IDE가 아직 다른 모듈의 설정 파일을 인식하지 못하는 경우가 있다.

spring.config.import를 작성할 때, 분명히 implementation 해 놓은 모듈의 파일들이나 경로들이 다 붉게 달아 올라 있는데, implementation 되어 있는 모든 resources 폴더를 classpath로 인식하니까 눈으로 검증해서라도 맞으면 된다.

파일 이름이나 경로가 잘못됐다는 건 IDE 얘기고, 실행시키는 애들은 알아서 잘 찾아가니까 막상 실행은 잘된다.

실제 그렇게 쓰고 있기도 하고, 배포했을 때도 잘 작동하는 거 보면 문제가 안 될 가능성이 높다.

profile
아 성장판 쑤셔 (블로그 이전) https://letsdev.hashnode.dev

0개의 댓글