Java & Spring/Annotation

Spring Boot 어노테이션 시리즈 #2: 고급 설정과 조건부 빈 생성 마스터하기

ai-one 2025. 5. 31. 11:41
반응형

Spring Boot 어노테이션 시리즈 #2: 고급 설정과 조건부 빈 생성 마스터하기

첫 번째 시리즈에서 Spring Boot의 기본 어노테이션들을 살펴봤다면, 이제는 한 단계 더 나아가 실무에서 차별화를 만들어내는 고급 설정 어노테이션들을 마스터할 시간입니다.

현업에서 시니어 개발자와 주니어 개발자를 구분하는 중요한 요소 중 하나가 바로 이런 고급 어노테이션들의 활용 능력입니다. 환경별 설정 분리, 조건부 빈 생성, 성능 최적화 등 실무에서 직면하는 복잡한 요구사항들을 우아하게 해결하는 방법을 알아보겠습니다.

조건부 빈 생성 어노테이션 - 똑똑한 애플리케이션 만들기

1. @ConditionalOnProperty - 설정 기반 빈 생성

환경별로 다른 구현체를 사용해야 하는 상황은 실무에서 매우 빈번합니다. 개발 환경에서는 간단한 구현체를, 운영 환경에서는 고성능 구현체를 사용하고 싶을 때 @ConditionalOnProperty가 해답입니다.

@Configuration
public class CacheConfig {
    
    // Redis가 활성화된 경우에만 Redis 캐시 매니저 생성
    @Bean
    @ConditionalOnProperty(
        name = "app.cache.type", 
        havingValue = "redis",
        matchIfMissing = false
    )
    public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(60))
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));
                        
        return RedisCacheManager.builder(redisConnectionFactory)
                .cacheDefaults(config)
                .build();
    }
    
    // 기본 설정이거나 로컬 캐시가 지정된 경우
    @Bean
    @ConditionalOnProperty(
        name = "app.cache.type", 
        havingValue = "local",
        matchIfMissing = true  // 속성이 없어도 기본값으로 사용
    )
    public CacheManager localCacheManager() {
        ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager();
        
        // 캐시 이름 미리 설정
        cacheManager.setCacheNames(Arrays.asList(
            "users", "products", "categories", "settings"
        ));
        
        // 동적 캐시 생성 허용
        cacheManager.setAllowNullValues(false);
        
        return cacheManager;
    }
    
    // 캐시 비활성화 설정
    @Bean
    @ConditionalOnProperty(
        name = "app.cache.enabled", 
        havingValue = "false"
    )
    public CacheManager noCacheManager() {
        return new NoOpCacheManager();
    }
}

application.yml 설정 예시:

# 개발 환경 (application-dev.yml)
app:
  cache:
    type: local
    enabled: true

# 운영 환경 (application-prod.yml)
app:
  cache:
    type: redis
    enabled: true
    
# 테스트 환경 (application-test.yml)
app:
  cache:
    enabled: false

2. @ConditionalOnClass와 @ConditionalOnMissingClass - 클래스 존재 여부 기반 설정

라이브러리 의존성에 따라 다른 구현체를 자동으로 선택하는 경우에 사용됩니다.

@Configuration
public class DatabaseConfig {
    
    // PostgreSQL 드라이버가 클래스패스에 있을 때만 설정
    @Bean
    @ConditionalOnClass(name = "org.postgresql.Driver")
    @ConditionalOnProperty(name = "app.database.type", havingValue = "postgresql")
    public DataSource postgresDataSource() {
        return DataSourceBuilder.create()
                .driverClassName("org.postgresql.Driver")
                .url("${app.database.postgres.url}")
                .username("${app.database.postgres.username}")
                .password("${app.database.postgres.password}")
                .build();
    }
    
    // MySQL 드라이버가 있고 PostgreSQL이 없을 때
    @Bean
    @ConditionalOnClass(name = "com.mysql.cj.jdbc.Driver")
    @ConditionalOnMissingClass("org.postgresql.Driver")
    @ConditionalOnProperty(name = "app.database.type", havingValue = "mysql")
    public DataSource mysqlDataSource() {
        HikariConfig config = new HikariConfig();
        config.setDriverClassName("com.mysql.cj.jdbc.Driver");
        config.setJdbcUrl("${app.database.mysql.url}");
        config.setUsername("${app.database.mysql.username}");
        config.setPassword("${app.database.mysql.password}");
        
        // MySQL 최적화 설정
        config.setMaximumPoolSize(20);
        config.setMinimumIdle(5);
        config.setConnectionTimeout(30000);
        config.setIdleTimeout(600000);
        config.setMaxLifetime(1800000);
        
        return new HikariDataSource(config);
    }
    
    // H2 데이터베이스를 기본값으로 사용 (테스트 환경)
    @Bean
    @ConditionalOnClass(name = "org.h2.Driver")
    @ConditionalOnMissingBean(DataSource.class)
    public DataSource h2DataSource() {
        return DataSourceBuilder.create()
                .driverClassName("org.h2.Driver")
                .url("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE")
                .username("sa")
                .password("")
                .build();
    }
}

3. @ConditionalOnBean과 @ConditionalOnMissingBean - 빈 존재 여부 기반 설정

다른 빈의 존재 여부에 따라 조건부로 빈을 생성하는 경우에 사용합니다.

@Configuration
public class SecurityConfig {
    
    // 커스텀 UserDetailsService가 정의되지 않은 경우에만 기본 구현체 제공
    @Bean
    @ConditionalOnMissingBean(UserDetailsService.class)
    public UserDetailsService defaultUserDetailsService() {
        return new InMemoryUserDetailsManager(
            User.withDefaultPasswordEncoder()
                .username("user")
                .password("password")
                .roles("USER")
                .build(),
            User.withDefaultPasswordEncoder()
                .username("admin")
                .password("admin")
                .roles("USER", "ADMIN")
                .build()
        );
    }
    
    // AuthenticationManager가 존재할 때만 JWT 토큰 제공자 생성
    @Bean
    @ConditionalOnBean(AuthenticationManager.class)
    public JwtTokenProvider jwtTokenProvider() {
        return new JwtTokenProvider(
            "${app.jwt.secret}",
            Duration.ofHours(24).toMillis()
        );
    }
    
    // Redis가 사용 가능한 경우에만 Redis 기반 세션 저장소 사용
    @Bean
    @ConditionalOnBean(RedisConnectionFactory.class)
    @ConditionalOnProperty(name = "app.session.store", havingValue = "redis")
    public SessionRepository<? extends Session> redisSessionRepository(
            RedisConnectionFactory connectionFactory) {
        RedisIndexedSessionRepository repository = 
            new RedisIndexedSessionRepository(connectionFactory);
        repository.setDefaultMaxInactiveInterval(Duration.ofMinutes(30));
        return repository;
    }
}

프로파일 기반 설정 어노테이션

1. @Profile - 환경별 설정 분리

@Configuration
public class EnvironmentConfig {
    
    // 개발 환경 전용 설정
    @Configuration
    @Profile("dev")
    static class DevelopmentConfig {
        
        @Bean
        public DataSource devDataSource() {
            return DataSourceBuilder.create()
                    .url("jdbc:h2:mem:devdb")
                    .username("sa")
                    .password("")
                    .build();
        }
        
        @Bean
        public MailSender devMailSender() {
            return new LoggingMailSender(); // 실제 메일 발송 대신 로그 출력
        }
        
        @Bean
        @Primary
        public RestTemplate devRestTemplate() {
            RestTemplate template = new RestTemplate();
            
            // 개발 환경에서는 모든 요청을 로깅
            template.setInterceptors(Collections.singletonList(
                new LoggingClientHttpRequestInterceptor()
            ));
            
            return template;
        }
    }
    
    // 운영 환경 전용 설정
    @Configuration
    @Profile("prod")
    static class ProductionConfig {
        
        @Bean
        public DataSource prodDataSource() {
            HikariConfig config = new HikariConfig();
            config.setJdbcUrl("${app.database.url}");
            config.setUsername("${app.database.username}");
            config.setPassword("${app.database.password}");
            
            // 운영 환경 최적화 설정
            config.setMaximumPoolSize(50);
            config.setMinimumIdle(10);
            config.setConnectionTimeout(20000);
            config.setIdleTimeout(300000);
            config.setMaxLifetime(1200000);
            config.setLeakDetectionThreshold(60000);
            
            return new HikariDataSource(config);
        }
        
        @Bean
        public MailSender prodMailSender() {
            JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
            mailSender.setHost("${mail.host}");
            mailSender.setPort("${mail.port}");
            mailSender.setUsername("${mail.username}");
            mailSender.setPassword("${mail.password}");
            
            Properties props = mailSender.getJavaMailProperties();
            props.put("mail.transport.protocol", "smtp");
            props.put("mail.smtp.auth", "true");
            props.put("mail.smtp.starttls.enable", "true");
            props.put("mail.debug", "false");
            
            return mailSender;
        }
    }
    
    // 테스트 환경 전용 설정
    @Configuration
    @Profile("test")
    static class TestConfig {
        
        @Bean
        @Primary
        public Clock testClock() {
            // 테스트에서는 고정된 시간 사용
            return Clock.fixed(
                Instant.parse("2024-01-01T00:00:00Z"), 
                ZoneOffset.UTC
            );
        }
        
        @Bean
        public TestRestTemplate testRestTemplate() {
            return new TestRestTemplate();
        }
    }
    
    // 복수 프로파일 조건
    @Configuration
    @Profile({"dev", "test"})
    static class NonProductionConfig {
        
        @Bean
        public DevToolsPropertyDefaultsPostProcessor devToolsPropertyDefaults() {
            return new DevToolsPropertyDefaultsPostProcessor();
        }
        
        @Bean
        public H2ConsoleAutoConfiguration h2ConsoleConfig() {
            return new H2ConsoleAutoConfiguration();
        }
    }
    
    // 프로파일 제외 조건
    @Configuration
    @Profile("!prod")
    static class NonProductionSecurityConfig {
        
        @Bean
        public WebSecurityCustomizer webSecurityCustomizer() {
            // 개발/테스트 환경에서는 H2 콘솔 접근 허용
            return (web) -> web.ignoring()
                    .requestMatchers("/h2-console/**")
                    .requestMatchers("/actuator/**");
        }
    }
}

설정 프로퍼티 어노테이션

1. @ConfigurationProperties - 타입 안전한 설정 관리

@ConfigurationProperties(prefix = "app")
@ConstructorBinding
@Getter
public class AppProperties {
    
    private final Database database;
    private final Security security;
    private final Mail mail;
    private final Storage storage;
    
    public AppProperties(Database database, Security security, Mail mail, Storage storage) {
        this.database = database;
        this.security = security;
        this.mail = mail;
        this.storage = storage;
    }
    
    @Getter
    public static class Database {
        private final String url;
        private final String username;
        private final String password;
        private final Pool pool;
        
        public Database(String url, String username, String password, Pool pool) {
            this.url = url;
            this.username = username;
            this.password = password;
            this.pool = pool != null ? pool : new Pool();
        }
        
        @Getter
        public static class Pool {
            private final int maxSize;
            private final int minIdle;
            private final Duration connectionTimeout;
            
            public Pool() {
                this(20, 5, Duration.ofSeconds(30));
            }
            
            public Pool(int maxSize, int minIdle, Duration connectionTimeout) {
                this.maxSize = maxSize;
                this.minIdle = minIdle;
                this.connectionTimeout = connectionTimeout;
            }
        }
    }
    
    @Getter
    public static class Security {
        private final Jwt jwt;
        private final Session session;
        
        public Security(Jwt jwt, Session session) {
            this.jwt = jwt;
            this.session = session;
        }
        
        @Getter
        public static class Jwt {
            private final String secret;
            private final Duration expiration;
            private final Duration refreshExpiration;
            
            public Jwt(String secret, Duration expiration, Duration refreshExpiration) {
                this.secret = secret;
                this.expiration = expiration != null ? expiration : Duration.ofHours(24);
                this.refreshExpiration = refreshExpiration != null ? refreshExpiration : Duration.ofDays(7);
            }
        }
        
        @Getter
        public static class Session {
            private final Duration timeout;
            private final String store;
            
            public Session(Duration timeout, String store) {
                this.timeout = timeout != null ? timeout : Duration.ofMinutes(30);
                this.store = store != null ? store : "memory";
            }
        }
    }
    
    @Getter
    public static class Mail {
        private final String host;
        private final int port;
        private final String username;
        private final String password;
        private final boolean auth;
        private final boolean starttls;
        
        public Mail(String host, int port, String username, String password, 
                   boolean auth, boolean starttls) {
            this.host = host;
            this.port = port;
            this.username = username;
            this.password = password;
            this.auth = auth;
            this.starttls = starttls;
        }
    }
    
    @Getter
    public static class Storage {
        private final String type;
        private final String location;
        private final long maxFileSize;
        private final List<String> allowedExtensions;
        
        public Storage(String type, String location, long maxFileSize, 
                      List<String> allowedExtensions) {
            this.type = type != null ? type : "local";
            this.location = location != null ? location : "./uploads";
            this.maxFileSize = maxFileSize > 0 ? maxFileSize : 10 * 1024 * 1024; // 10MB
            this.allowedExtensions = allowedExtensions != null ? 
                allowedExtensions : Arrays.asList("jpg", "png", "pdf", "docx");
        }
    }
}

application.yml 설정 예시:

app:
  database:
    url: jdbc:postgresql://localhost:5432/myapp
    username: myuser
    password: mypassword
    pool:
      max-size: 30
      min-idle: 10
      connection-timeout: PT45S
      
  security:
    jwt:
      secret: ${JWT_SECRET:default-secret-key}
      expiration: PT24H
      refresh-expiration: P7D
    session:
      timeout: PT30M
      store: redis
      
  mail:
    host: smtp.gmail.com
    port: 587
    username: ${MAIL_USERNAME}
    password: ${MAIL_PASSWORD}
    auth: true
    starttls: true
    
  storage:
    type: s3
    location: my-app-bucket
    max-file-size: 52428800  # 50MB
    allowed-extensions:
      - jpg
      - jpeg
      - png
      - pdf
      - docx
      - xlsx

2. @EnableConfigurationProperties와 함께 사용하기

@Configuration
@EnableConfigurationProperties({AppProperties.class, FeatureProperties.class})
public class AppConfig {
    
    private final AppProperties appProperties;
    
    public AppConfig(AppProperties appProperties) {
        this.appProperties = appProperties;
    }
    
    @Bean
    public DataSource dataSource() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(appProperties.getDatabase().getUrl());
        config.setUsername(appProperties.getDatabase().getUsername());
        config.setPassword(appProperties.getDatabase().getPassword());
        
        AppProperties.Database.Pool pool = appProperties.getDatabase().getPool();
        config.setMaximumPoolSize(pool.getMaxSize());
        config.setMinimumIdle(pool.getMinIdle());
        config.setConnectionTimeout(pool.getConnectionTimeout().toMillis());
        
        return new HikariDataSource(config);
    }
    
    @Bean
    public JwtTokenProvider jwtTokenProvider() {
        AppProperties.Security.Jwt jwt = appProperties.getSecurity().getJwt();
        return new JwtTokenProvider(jwt.getSecret(), jwt.getExpiration().toMillis());
    }
    
    @Bean
    public StorageService storageService() {
        AppProperties.Storage storage = appProperties.getStorage();
        
        switch (storage.getType().toLowerCase()) {
            case "s3":
                return new S3StorageService(storage.getLocation(), 
                                          storage.getMaxFileSize(), 
                                          storage.getAllowedExtensions());
            case "local":
                return new LocalStorageService(storage.getLocation(), 
                                             storage.getMaxFileSize(), 
                                             storage.getAllowedExtensions());
            default:
                throw new IllegalArgumentException("지원하지 않는 스토리지 타입: " + storage.getType());
        }
    }
}

조건부 어노테이션의 고급 활용

1. 커스텀 조건부 어노테이션 만들기

// 커스텀 조건 어노테이션
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Conditional(OnDockerCondition.class)
public @interface ConditionalOnDocker {
}

// 조건 구현 클래스
public class OnDockerCondition implements Condition {
    
    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        // Docker 환경인지 확인하는 로직
        return isRunningInDocker() || hasDockerEnvironmentVariable(context);
    }
    
    private boolean isRunningInDocker() {
        try {
            Path cgroup = Paths.get("/proc/1/cgroup");
            if (Files.exists(cgroup)) {
                List<String> lines = Files.readAllLines(cgroup);
                return lines.stream().anyMatch(line -> line.contains("docker"));
            }
        } catch (Exception e) {
            // 무시
        }
        return false;
    }
    
    private boolean hasDockerEnvironmentVariable(ConditionContext context) {
        String dockerEnv = context.getEnvironment().getProperty("DOCKER_CONTAINER");
        return "true".equalsIgnoreCase(dockerEnv);
    }
}

// 사용 예시
@Configuration
public class ContainerConfig {
    
    @Bean
    @ConditionalOnDocker
    public HealthCheckService dockerHealthCheckService() {
        return new DockerHealthCheckService();
    }
    
    @Bean
    @ConditionalOnMissingBean(HealthCheckService.class)
    public HealthCheckService defaultHealthCheckService() {
        return new DefaultHealthCheckService();
    }
}

2. 복합 조건 어노테이션

@Configuration
public class CacheConfiguration {
    
    // Redis가 사용 가능하고 캐시가 활성화된 경우
    @Bean
    @ConditionalOnClass(RedisConnectionFactory.class)
    @ConditionalOnProperty(name = "app.cache.enabled", havingValue = "true")
    @ConditionalOnProperty(name = "app.cache.type", havingValue = "redis")
    public RedisCacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofHours(1))
                .disableCachingNullValues()
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));
                        
        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(config)
                .transactionAware()
                .build();
    }
    
    // Hazelcast가 사용 가능하고 분산 캐시가 필요한 경우
    @Bean
    @ConditionalOnClass(name = "com.hazelcast.core.HazelcastInstance")
    @ConditionalOnProperty(name = "app.cache.type", havingValue = "hazelcast")
    @ConditionalOnProperty(name = "app.clustering.enabled", havingValue = "true")
    public HazelcastCacheManager hazelcastCacheManager(HazelcastInstance hazelcastInstance) {
        return new HazelcastCacheManager(hazelcastInstance);
    }
}

실무 활용 시나리오

1. 마이크로서비스 환경에서의 설정 관리

@Configuration
@EnableConfigurationProperties(ServiceDiscoveryProperties.class)
public class ServiceDiscoveryConfig {
    
    // Consul이 사용 가능한 경우
    @Bean
    @ConditionalOnClass(name = "org.springframework.cloud.consul.discovery.ConsulDiscoveryClient")
    @ConditionalOnProperty(name = "spring.cloud.consul.enabled", havingValue = "true")
    public ConsulServiceDiscovery consulServiceDiscovery() {
        return new ConsulServiceDiscovery();
    }
    
    // Eureka가 사용 가능한 경우
    @Bean
    @ConditionalOnClass(name = "org.springframework.cloud.netflix.eureka.EurekaDiscoveryClient")
    @ConditionalOnProperty(name = "eureka.client.enabled", havingValue = "true")
    public EurekaServiceDiscovery eurekaServiceDiscovery() {
        return new EurekaServiceDiscovery();
    }
    
    // 서비스 디스커버리가 없는 경우 정적 설정 사용
    @Bean
    @ConditionalOnMissingBean(ServiceDiscovery.class)
    public StaticServiceDiscovery staticServiceDiscovery(ServiceDiscoveryProperties properties) {
        return new StaticServiceDiscovery(properties.getStaticServices());
    }
}

2. 다중 데이터베이스 환경 설정

@Configuration
public class MultiDatabaseConfig {
    
    @Primary
    @Bean(name = "primaryDataSource")
    @ConfigurationProperties("app.datasource.primary")
    public DataSource primaryDataSource() {
        return DataSourceBuilder.create().build();
    }
    
    @Bean(name = "secondaryDataSource")
    @ConfigurationProperties("app.datasource.secondary")
    @ConditionalOnProperty(name = "app.datasource.secondary.enabled", havingValue = "true")
    public DataSource secondaryDataSource() {
        return DataSourceBuilder.create().build();
    }
    
    @Primary
    @Bean(name = "primaryEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory(
            EntityManagerFactoryBuilder builder,
            @Qualifier("primaryDataSource") DataSource dataSource) {
        return builder
                .dataSource(dataSource)
                .packages("com.example.primary.entity")
                .persistenceUnit("primary")
                .build();
    }
    
    @Bean(name = "secondaryEntityManagerFactory")
    @ConditionalOnBean(name = "secondaryDataSource")
    public LocalContainerEntityManagerFactoryBean secondaryEntityManagerFactory(
            EntityManagerFactoryBuilder builder,
            @Qualifier("secondaryDataSource") DataSource dataSource) {
        return builder
                .dataSource(dataSource)
                .packages("com.example.secondary.entity")
                .persistenceUnit("secondary")
                .build();
    }
}

성능 최적화 팁

1. 조건부 빈 생성으로 메모리 절약

@Configuration
public class OptimizedConfig {
    
    // 메모리 집약적인 빈은 실제 필요할 때만 생성
    @Bean
    @Lazy
    @ConditionalOnProperty(name = "app.features.analytics", havingValue = "true")
    public AnalyticsEngine analyticsEngine() {
        return new HeavyAnalyticsEngine(); // 메모리를 많이 사용하는 객체
    }
    
    // 라이센스가 있는 경우에만 프리미엄 기능 활성화
    @Bean
    @ConditionalOnProperty(name = "app.license.premium", havingValue = "true")
    public PremiumFeatureService premiumFeatureService() {
        return new PremiumFeatureService();
    }
    
    // 개발 환경에서만 디버깅 도구 활성화
    @Bean
    @Profile("dev")
    @ConditionalOnProperty(name = "app.debug.enabled", havingValue = "true", matchIfMissing = true)
    public DebugToolsService debugToolsService() {
        return new DebugToolsService();
    }
}

2. 조건부 스케줄링

@Component
public class ScheduledTasks {
    
    // 운영 환경에서만 정리 작업 실행
    @Scheduled(cron = "0 0 2 * * ?") // 매일 새벽 2시
    @ConditionalOnProperty(name = "app.cleanup.enabled", havingValue = "true")
    @Profile("prod")
    public void cleanupOldData() {
        // 오래된 데이터 정리 로직
    }
    
    // 개발 환경에서는 더 자주 실행
    @Scheduled(fixedRate = 300000) // 5분마다
    @ConditionalOnProperty(name = "app.monitoring.enabled", havingValue = "true")
    @Profile("dev")
    public void developmentMonitoring() {
        // 개발 환경 모니터링 로직
    }
}

 

반응형