스프링 리팩토링 패턴 시리즈 (1/5)

이 시리즈는 스프링 프로젝트에서 자주 발생하는 리팩토링 포인트를 정리합니다.

다음 편: 트랜잭션과 데이터 일관성

스프링 애플리케이션을 개발하다 보면 설정값을 다루는 코드가 여기저기 흩어지기 마련이다. AWS 키, 유효성 검증 규칙, 관리자 정보... 이런 값들을 어떻게 관리하느냐에 따라 코드의 유지보수성이 크게 달라진다.

@Value의 한계

@Value는 스프링에서 설정값을 주입받는 가장 기본적인 방법이다.

@Component
public class StorageConfig {
    @Value("${aws.s3.access-key}")
    private String accessKey;

    @Value("${aws.s3.secret-key}")
    private String secretKey;

    @Value("${aws.s3.region}")
    private String region;
}

간단해 보이지만 실제 프로젝트에서는 문제가 금방 드러난다.

타입 안전성 부재

@Value는 문자열 기반이다. ${aws.s3.regoin}처럼 오타가 있어도 컴파일 시점에는 아무 경고가 없다. 애플리케이션을 띄워야 비로소 실패하고, 그마저도 기본값이 설정돼 있으면 잘못된 값으로 조용히 동작한다.

설정 분산

하나의 논리적 설정 그룹(예: AWS S3 관련 설정)이 여러 클래스에 흩어질 수 있다. 설정 키가 바뀌면 프로젝트 전체를 뒤져야 한다.

검증 불가

@Value만으로는 주입된 값이 유효한지 검증할 방법이 없다. 비밀번호 최소 길이가 음수여도 그대로 주입된다.

@ConfigurationProperties로 전환하기

@ConfigurationProperties는 설정값을 타입 안전한 자바 객체로 바인딩한다. 이것이 스프링 팀이 공식적으로 권장하는 방식이다.

@ConfigurationProperties(prefix = "aws.s3")
public class AwsS3Properties {
    private String accessKey;
    private String secretKey;
    private String region;
    private String bucket;
    private Long presignedUrlExpiration;

    // getter, setter
}
  • prefix = "aws.s3"application.ymlaws.s3 아래 프로퍼티들이 자동으로 매핑된다.
  • 필드명과 yml 키가 자동으로 매칭된다. accessKeyaccess-key (relaxed binding).

활성화 방법

@ConfigurationProperties 클래스를 스프링이 인식하려면 두 가지 방법 중 하나를 택한다.

// 방법 1: 메인 클래스나 설정 클래스에서 스캔
@SpringBootApplication
@ConfigurationPropertiesScan
public class Application { }

// 방법 2: 특정 프로퍼티 클래스를 명시적으로 등록
@Configuration
@EnableConfigurationProperties(AwsS3Properties.class)
public class AppConfig { }

사용하는 쪽

@Configuration
@RequiredArgsConstructor
public class S3Config {
    private final AwsS3Properties awsS3Properties;

    @Bean
    public S3Client s3Client() {
        return S3Client.builder()
                .region(Region.of(awsS3Properties.getRegion()))
                .credentialsProvider(credentialsProvider())
                .build();
    }

    private StaticCredentialsProvider credentialsProvider() {
        return StaticCredentialsProvider.create(
                AwsBasicCredentials.create(
                        awsS3Properties.getAccessKey(),
                        awsS3Properties.getSecretKey()));
    }
}
  • @Value 어노테이션이 완전히 사라졌다.
  • 설정값의 출처가 AwsS3Properties 하나로 집중된다.
  • 생성자 주입으로 불변성까지 보장할 수 있다.

불변 프로퍼티 클래스

스프링 부트 2.2+부터는 @ConstructorBinding을 사용해 불변 프로퍼티 클래스를 만들 수 있다. 스프링 부트 3.x에서는 생성자가 하나뿐이면 자동으로 생성자 바인딩이 적용된다.

@ConfigurationProperties(prefix = "aws.s3")
public record AwsS3Properties(
        String accessKey,
        String secretKey,
        String region,
        String bucket,
        Long presignedUrlExpiration
) { }
  • Java record를 사용하면 getter, equals, hashCode, toString이 자동 생성된다.
  • 필드가 final이므로 런타임에 값이 바뀔 위험이 없다.

유효성 검증 통합

@ConfigurationProperties의 진짜 강점은 Bean Validation과 결합할 수 있다는 점이다.

@Validated
@ConfigurationProperties(prefix = "aws.s3")
public record AwsS3Properties(
        @NotBlank String accessKey,
        @NotBlank String secretKey,
        @NotBlank String region,
        @NotBlank String bucket,
        @Positive Long presignedUrlExpiration
) { }
  • 애플리케이션 시작 시점에 설정값이 검증된다. 빈 문자열이나 음수가 들어오면 즉시 실패한다.
  • "Fast Fail" 원칙 — 잘못된 설정은 배포 전에 잡는 것이 가장 좋다.
***************************
APPLICATION FAILED TO START
***************************
Binding to target AwsS3Properties failed:

    Property: aws.s3.access-key
    Value:
    Reason: must not be blank

설정 그룹화 전략

프로젝트 규모가 커지면 설정값도 많아진다. 이때 어떤 기준으로 프로퍼티 클래스를 나눌지가 중요하다.

graph TD A[application.yml] --> B[AwsS3Properties] A --> C[AdminProperties] A --> D[ValidationProperties] A --> E[SecurityProperties] B --> B1["prefix: aws.s3"] C --> C1["prefix: admin"] D --> D1["prefix: validation.user"] E --> E1["prefix: remember-me"]

핵심 원칙은 하나의 프로퍼티 클래스가 하나의 설정 관심사를 담당하는 것이다.

  • AwsS3Properties — S3 접속 정보와 버킷 설정
  • AdminProperties — 초기 관리자 계정 정보
  • ValidationProperties — 사용자 입력 검증 규칙
  • SecurityProperties — Remember-Me 같은 보안 관련 설정

이렇게 나누면 특정 설정을 변경할 때 영향 범위가 명확해진다.

심화 분석

@Value와 @ConfigurationProperties 비교

@Value는 SpEL(Spring Expression Language)을 지원한다. 단순 주입을 넘어 #{systemProperties['user.home']}이나 ${value:default} 같은 표현식을 사용할 수 있다. 이것이 @Value가 여전히 유용한 유일한 시나리오다.

반면 @ConfigurationProperties는 다음을 추가로 제공한다.

  • Relaxed BindingcamelCase, kebab-case, UPPER_CASE 모두 자동 매핑
  • 중첩 객체@NestedConfigurationProperties로 계층형 설정 표현 가능
  • IDE 지원spring-boot-configuration-processor를 추가하면 yml 편집 시 자동완성 제공
  • 프로파일별 오버라이드 — 프로퍼티 클래스 자체가 빈이므로, @Profile과 자연스럽게 결합

spring-boot-configuration-processor

dependencies {
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
}

이 의존성을 추가하면 빌드 시 META-INF/spring-configuration-metadata.json이 생성되어, IDE에서 application.yml 편집 시 프로퍼티 자동완성과 타입 힌트를 사용할 수 있다.

자주 하는 실수

static 필드에 @Value setter로 주입

@Component
public class ValidationProperties {
    public static int ID_MIN_LENGTH;

    @Value("${validation.user.id.min-length}")
    public void setIdMinLength(int value) {
        ID_MIN_LENGTH = value;  // static 필드에 주입
    }
}

이 패턴은 세 가지 문제가 있다.

  1. 멀티스레드 불안전 — static 필드 쓰기에 동기화가 없다. 빈 초기화 시점에 다른 스레드가 이 값을 읽으면 0(int 기본값)을 볼 수 있다.
  2. 테스트 어려움 — static 필드는 테스트 간에 상태가 공유되어 테스트 격리가 깨진다.
  3. 스프링 생명주기와 충돌@Value setter가 호출되기 전에 static 필드를 참조하는 코드가 실행될 수 있다.

올바른 해결책은 @ConfigurationProperties로 전환하고, 해당 빈을 필요한 곳에 주입하는 것이다.

하드코딩된 설정값

public class OnlineThreshold {
    public static final Duration ONLINE_THRESHOLD = Duration.ofMinutes(5);
}

application.yml에 설정값이 있는데도 하드코딩하는 경우가 있다. 이렇게 하면 환경별로 다른 값을 적용할 수 없고, 설정 변경 시 재컴파일이 필요하다.

user-status:
  online-threshold-minutes: 5

이미 yml에 값이 정의되어 있다면, @ConfigurationProperties로 읽어와야 한다.

prefix 누락

@ConfigurationProperties  // prefix가 없으면 모든 프로퍼티를 매핑 시도
public class AppProperties { }

반드시 prefix를 지정해서 매핑 범위를 한정해야 한다.