반응형
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() {
// 개발 환경 모니터링 로직
}
}
반응형
'Java & Spring > Annotation' 카테고리의 다른 글
Spring Boot 어노테이션 시리즈 #3: 검증과 보안 어노테이션 마스터하기 (0) | 2025.06.01 |
---|---|
Spring Boot 어노테이션 시리즈 #1: 기본 핵심 어노테이션 완벽 가이드 (1) | 2025.05.31 |