본문 바로가기
  • 개발하는 곰돌이
Development/Spring & Spring Boot

[Spring Boot] @Value 대신 생성자 바인딩을 통해 프로퍼티 값을 바인딩하고 관리해보자

by 개발하는 곰돌이 2023. 12. 21.

목차

    들어가기 전에

    스프링부트 프로젝트를 개발하다 보면 운영/개발 등의 환경에 따라 코드에 들어가야 하는 값이 달라지는 경우가 있다. 단순히 현재 서버가 어떤 서버인지를 확인해줄 문자열이 될 수도 있고, 다른 API를 통해 서비스를 제공하는 프로젝트라면 해당 API를 제공하는 서버 호스트가 될 수도 있다.

     

    이렇게 구동 환경에 따라 달라지는 값들은 yml파일 또는 properties 파일에서 프로파일을 나눠서 관리하고 실제 코드에서는 @Value로 값을 바인딩해서 사용하게 된다.

     

    그런데 @Value로는 final 변수에 값을 바인딩하려면 롬복으로 생성자를 선언하는 방식을 사용할 수 없기 때문에 변경사항이 발생했을 때 굉장히 귀찮아진다. 이 경우 생성자 바인딩이라는 방식을 통해 프로퍼티 설정 값을 수월하게 final 변수로 사용할 수 있다.

    @Value로 프로퍼티 값 바인딩

    사실 코틀린에서는 자바의 final에 대응하는 val 변수에 @Value로 값을 바인딩하는게 어렵지 않다. 코틀린의 문법 상 프로퍼티를 선언할 때 동시에 생성자가 만들어지기 때문이다.

    @Service
    class TokenProvider(
        @Value("\${secret-key}")
        private val secretKey: String,
        @Value("\${expiration-hours}")
        private val expirationHours: Long,
        @Value("\${issuer}")
        private val issuer: String
    ) {
        ...
    }

    하지만 자바에서 final 변수에 @Value로 값을 바인딩하려면 롬복 없이 보일러 플레이트 코드로 생성자를 만들어야 한다. @RequiredArgsConstructorfinal로 선언되어있는 필드에 스프링 빈을 주입하려고 하기 때문이다.

    @RequiredArgsConstructor를 사용하면 모든 final 필드에 스프링 빈을 주입하려고 해서 에러가 발생한다.
    컴파일은 되지만 실행하자마자 빈이 필요하니 빈을 정의하라면서 애플리케이션이 종료되어 버린다.

    결국 보일러 플레이트 코드로 생성자를 만들어서 생성자의 파라미터에 @Value를 붙여줘야 final 변수에 프로퍼티 값을 바인딩할 수 있다.

    @Service
    public class TokenProvider {
        private final String secretKey;
        private final long expirationHours;
        private final String issuer;
    
        public TokenProvider(
                @Value("${secret-key}") String secretKey,
                @Value("${expiration-hours}") long expirationHours,
                @Value("${issuer}") String issuer
        ) {
            this.secretKey = secretKey;
            this.expirationHours = expirationHours;
            this.issuer = issuer;
        }
        
        ...
    }

    @Value의 단점

    자바에서 @Value를 사용할 때 발생하는 단점은 다음과 같이 정리할 수 있다.

    • 일반 바인딩 방식이나 롬복을 사용한 생성자 바인딩 방식은 final을 사용할 수 없기 때문에 값을 변경해버릴 수 있다.
    • 생성자를 사용해서 바인딩하면 final을 사용할 수 있으나 보일러 플레이트 코드를 사용해야 해서 해당 클래스에서 주입받는 스프링 빈 등이 추가되거나 제거되면 수정하기가 매우 귀찮다.

    이 문제에서 비교적 자유로운 코틀린이라고 하더라도 다음의 단점은 자바와 공유하게 된다.

    • 여러 클래스에서 동일한 프로퍼티 값을 바인딩해서 사용하게 된다면 중복 코드가 발생하고, @Value로 프로퍼티 값의 키를 매번 작성해가며 바인딩하다보니 코드가 지저분해질 수 있다.

    생성자 바인딩을 사용해보자

    Constructor Binding. 단어 그대로 생성자 바인딩이다. 이 방법을 사용하면 프로퍼티 값을 통째로 클래스의 필드 또는 프로퍼티에 바인딩할 수 있다. 이로 인해 여러 클래스에서 동일한 프로퍼티 값을 사용하는 경우에도 또한 해당 이렇게 프로퍼티 값이 통째로 바인딩된 객체를 주입받아서 사용해서 중복 코드를 줄일 수 있다.

     

    아래와 같은 프로퍼티를 클래스의 필드에 바인딩하는 경우를 생각해보자.

    file-path:
      log-path: /home/centos/demo/logs
      download-path: /home/centos/demo/download
    
    api-config:
      host: https://www.demo.com
      authorization: eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJjb2xhYmVhcjc1NCIsImlhdCI6MTcwMjYyNzE5MSwiZXhwIjoxNzAyNzEzNTkxfQ.-VRK5VRvaX4vvWl0q-LnCiThZNqMGThPX5RZ5stDbFj0pnnaCmtDzeVHMi0ttSR1ZVBpbAMtPX-0wWzbKdTePg

    우선 값을 바인딩 받을 클래스를 작성한다.

    @Getter
    @ConfigurationProperties("file-path")
    @RequiredArgsConstructor
    @ConstructorBinding		// 스프링부트 3.0 이상에서는 제외
    public class FilePathProperties {
        private final String logPath;
        private final String downloadPath;
    }
    @Getter
    @RequiredArgsConstructor
    @ConfigurationProperties("api-config")
    @ConstructorBinding		// 스프링부트 3.0 이상에서는 제외
    public class ApiConfigProperties {
        private final String host;
        private final String authorization;
    }

    @ConfigurationProperties의 값에는 yml 또는 properties 파일에서 바인딩하고자 하는 값의 키에서 상위 레벨의 키를 모두 작성해주면 된다. (aaa.bbb.ccc의 값을 바인딩하고 싶으면 @ConfigurationProperties("aaa.bbb")와 같은 식으로)

     

    클래스의 필드에는 바인딩하고자 하는 값의 최하위 키를 필드명과 매칭해서 작성해주면 된다. 이 때 하이픈으로 구분된 단어들은 자동으로 카멜케이스로 치환된다.(위 사진에서 FilePathProperties와 같은 경우)

     

    @ConstructorBinding 어노테이션은 스프링부트 3.0 미만에서만 붙여준다. 스프링부트 3.0 이상일 경우에는 컴파일 에러가 발생한다.

     

    그리고 스프링부트 메인 클래스(일반적으로는 프로젝트명Application)에 아래와 같이 어노테이션을 추가해준다.

    @EnableConfigurationProperties({FilePathProperties.class, ApiConfigProperties.class})	// 추가
    @SpringBootApplication
    public class PropertiesDemoApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(PropertiesDemoApplication.class, args);
        }
    
    }

    @EnableConfigurationProperties의 값으로는 프로퍼티 값을 바인딩해줄 클래스의 타입 배열을 넣어주면 된다.

     

    이렇게 생성자 바인딩을 통해 프로퍼티 값을 바인딩 받는 클래스는 일반 스프링 빈과 동일한 방식으로 주입받아서 사용할 수 있다.

     

    이제 테스트 코드를 작성해서 제대로 바인딩이 되었는지 확인해보자.

    @SpringBootTest
    public class ConstructorBindingTest {
        private final FilePathProperties filePathProperties;
        private final ApiConfigProperties apiConfigProperties;
    
        @Autowired
        public ConstructorBindingTest(FilePathProperties filePathProperties, ApiConfigProperties apiConfigProperties) {
            this.filePathProperties = filePathProperties;
            this.apiConfigProperties = apiConfigProperties;
        }
    
        @Test
        void 생성자_바인딩_테스트() {
            // given
            String logPath = filePathProperties.getLogPath();
            String downloadPath = filePathProperties.getDownloadPath();
            String host = apiConfigProperties.getHost();
            String authorization = apiConfigProperties.getAuthorization();
            // when
            // then
            assertThat(logPath).isEqualTo("/home/centos/demo/logs");
            assertThat(downloadPath).isEqualTo("/home/centos/demo/download");
            assertThat(host).isEqualTo("https://www.demo.com");
            assertThat(authorization).isEqualTo("eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJjb2xhYmVhcjc1NCIsImlhdCI6MTcwMjYyNzE5MSwiZXhwIjoxNzAyNzEzNTkxfQ.-VRK5VRvaX4vvWl0q-LnCiThZNqMGThPX5RZ5stDbFj0pnnaCmtDzeVHMi0ttSR1ZVBpbAMtPX-0wWzbKdTePg");
        }
    }

    테스트가 잘 통과하는 것을 볼 수 있다.

    번외1 - 클래스가 많아지면 어떡하지?

    이 방법은 프로퍼티 값을 바인딩 받을 클래스가 늘어날수록 스프링부트 메인 클래스의 @EnableConfigurationProperties에 클래스를 계속 추가해야 한다는 문제가 있다. 이렇게 되면 번거로울 뿐만 아니라 코드가 굉장히 지저분해질 수 있다.

     

    이 때 @ConfigurationPropertiesScan을 사용할 수 있다. 이 어노테이션을 사용하면 @ConfigurationProperties가 붙은 클래스를 자동으로 탐색해서 스프링 빈으로 등록해준다.

     

    스프링부트 메인 클래스의 내용만 아래처럼 수정해준다.

    //@EnableConfigurationProperties({FilePathProperties.class, ApiConfigProperties.class})	 얘를 지우고
    @ConfigurationPropertiesScan	// 이걸 추가해준다
    @SpringBootApplication
    public class PropertiesDemoApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(PropertiesDemoApplication.class, args);
        }
    
    }

    이 때, @ConfigurationPropertiesScan의 값으로 탐색하고자 하는 패키지를 지정할 수 있다. 이렇게 하면 해당 패키지 내부의 클래스만 탐색한다.(기본값 : 프로젝트 전체)

     

    인텔리제이에서 어노테이션 왼쪽의 아이콘을 클릭해서 탐색된 클래스들을 확인할 수도 있다.

    테스트 코드를 실행해보면 동일하게 테스트를 잘 통과하는 걸 볼 수 있다.

    번외2 - 코틀린이라면?

    코틀린도 자바 동일한 방법으로 생성자 바인딩을 통한 프로퍼티 값을 가져올 수 있다.

    @ConfigurationProperties("file-path")
    class FilePathProperties(
        val logPath: String,
        val downloadPath: String
    )
    @ConfigurationProperties("api-config")
    class ApiConfigProperties(
        val host: String,
        val authorization: String
    )

    문법의 차이로 인해 롬복이 사라졌을 뿐 동일한 구조라는 것을 알 수 있다.

    @ConfigurationPropertiesScan
    @SpringBootApplication
    class PropertiesDemoKtApplication
    
    fun main(args: Array<String>) {
        runApplication<PropertiesDemoKtApplication>(*args)
    }

    메인 클래스에도 동일하게 @EnableConfigurationProperties 또는 @ConfigurationPropertiesScan을 붙여준다.

    @EnableConfigurationProperties(ApiConfigProperties::class, FilePathProperties::class)
    @SpringBootApplication
    class PropertiesDemoKtApplication
    
    fun main(args: Array<String>) {
        runApplication<PropertiesDemoKtApplication>(*args)
    }

    또는

    @ConfigurationPropertiesScan
    @SpringBootApplication
    class PropertiesDemoKtApplication
    
    fun main(args: Array<String>) {
        runApplication<PropertiesDemoKtApplication>(*args)
    }

    테스트 코드를 작성해서 실행해보면 정상적으로 바인딩 된 것을 볼 수 있다.

    @SpringBootTest
    class ConstructorBindingTest @Autowired constructor(
        val apiConfigProperties: ApiConfigProperties,
        val filePathProperties: FilePathProperties
    ) {
        @Test
        fun `생성자 바인딩 테스트`() {
            // given
            val logPath = filePathProperties.logPath
            val downloadPath = filePathProperties.downloadPath
            val host = apiConfigProperties.host
            val authorization = apiConfigProperties.authorization
            // when
            // then
            assertThat(logPath).isEqualTo("/home/centos/demo/logs")
            assertThat(downloadPath).isEqualTo("/home/centos/demo/download")
            assertThat(host).isEqualTo("https://www.demo.com")
            assertThat(authorization).isEqualTo("eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJjb2xhYmVhcjc1NCIsImlhdCI6MTcwMjYyNzE5MSwiZXhwIjoxNzAyNzEzNTkxfQ.-VRK5VRvaX4vvWl0q-LnCiThZNqMGThPX5RZ5stDbFj0pnnaCmtDzeVHMi0ttSR1ZVBpbAMtPX-0wWzbKdTePg")
        }
    }

    여담

    자바에서 @Valuefinal 변수에 프로퍼티 값을 바인딩하려면 보일러 플레이트 코드를 사용해야 해서 수정이 필요할 때 번거로워지는 문제가 있는데 이 방법은 롬복을 사용하면서도 손쉽게 final 변수에 프로퍼티 값을 바인딩할 수 있다는 장점이 있다고 생각한다.

     

    코틀린은 그냥 기본 생성자의 프로퍼티에 @Value를 붙여주기만 해도 val 변수에 스프링부트 프로퍼티 값을 바인딩할 수 있지만 생성자 바인딩을 사용해서 중복되는 @Value를 제거하고 코드를 좀 더 깔끔하게 작성할 수 있다고 생각한다.

    참조 링크

     

    [SpringBoot] final 변수를 갖는 클래스에 프로퍼티(Properties) 설정 값 불러오기, 생성자 바인딩(Construct

    Spring 프레임워크로 개발을 하다 보면 프로퍼티(Properties)에 저장된 특정한 설정 값들을 불러와야 하는 경우가 있다. 많은 글 들에서 프로퍼티(Properties)를 불러오는 내용들을 설명하고 있는데, 이

    mangkyu.tistory.com

     

    Spring Boot 3.0 Migration Guide

    Spring Boot. Contribute to spring-projects/spring-boot development by creating an account on GitHub.

    github.com

     

    댓글