토비의 스프링 정복하기 15편 - XML에서 어노테이션으로

들어가며

스프링의 DI 설정 방식은 크게 세 단계를 거쳐 진화했습니다.

  1. XML 시대 — 모든 빈 설정을 XML에 기술
  2. Java Config 시대 (Spring 3.x) — XML을 @Configuration 클래스로 대체
  3. Auto Configuration 시대 (Spring Boot) — 관례 기반 설정과 의존성 감지로 설정 자동화

1. 어노테이션 기반 프로그래밍의 진화

1.1 메타정보로서의 어노테이션

어노테이션의 역할은 코드의 동작을 직접 기술하는 게 아니라, 프레임워크가 참조하는 메타정보를 제공하는 것입니다. XML은 모든 정보를 명시적으로 기술해야 했습니다.

<bean id="userService" class="com.example.UserServiceImpl">
    <property name="userDao" ref="userDao"/>
</bean>

어노테이션은 리플렉션 API를 통해 클래스 정보를 자동으로 수집합니다.

@Service
public class UserServiceImpl implements UserService { ... }

1.2 관례 기반(Convention over Configuration) 프로그래밍

Spring 3.x에서 Spring Boot로 넘어오면서 "개발자가 직접 선언"하던 것들이 "관례에 의해 자동 처리"되는 영역으로 바뀌었습니다.

  Spring 3.x Spring Boot
ComponentScan @ComponentScan(basePackages="com.example") 직접 지정 메인 클래스 패키지 기준 자동 스캔
트랜잭션 @EnableTransactionManagement 직접 선언 spring-boot-starter-data-jpa 추가만으로 자동 적용
DataSource @Bean으로 직접 생성 application.yml 값 선언만으로 자동 생성

2. DataSource 설정

가장 극적으로 달라진 부분입니다. DataSource 객체 생성부터 드라이버 로딩, TransactionManager 연결까지 Spring 3.x에서는 모두 개발자 몫이었습니다.

Spring 3.x

@Configuration
@EnableTransactionManagement
@PropertySource("classpath:/database.properties")
public class AppContext {

    @Value("${db.url}")      String url;
    @Value("${db.username}") String username;
    @Value("${db.password}") String password;

    @Bean
    public DataSource dataSource() {
        SimpleDriverDataSource ds = new SimpleDriverDataSource();
        try {
            ds.setDriverClass((Class<? extends Driver>)
                Class.forName("com.mysql.cj.jdbc.Driver")); // 드라이버 직접 로딩
        } catch (ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
        ds.setUrl(this.url);
        ds.setUsername(this.username);
        ds.setPassword(this.password);
        return ds;
    }

    @Bean
    public PlatformTransactionManager transactionManager() {
        DataSourceTransactionManager tm = new DataSourceTransactionManager();
        tm.setDataSource(dataSource()); // DataSource 직접 연결
        return tm;
    }
}
# database.properties
db.url=jdbc:mysql://localhost/mydb
db.username=root
db.password=1234

Spring Boot 3.x

최신의 스프링은 관례를 기반으로 구조에 맞춰 파일과 정보들을 작성해놓는다면 스프링 프레임워크 내부에서 자동으로 해당 위치에 있는 파일을 읽어 설정을 구성해줍니다.

@SpringBootApplication
// DataSource, TransactionManager, JdbcTemplate 모두 자동 구성
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}
# application.yml — 값 선언만, 객체 생성은 Auto Configuration이 처리
spring:
  datasource:
    url: jdbc:mysql://localhost/mydb
    username: root
    password: 1234
    driver-class-name: com.mysql.cj.jdbc.Driver

달라진 내용: spring-boot-starter-jdbc 의존성이 클래스패스에 있으면 DataSourceAutoConfiguration이 application.yml 값을 읽어 HikariCP 기반 DataSource를 자동 생성합니다. SimpleDriverDataSource는 커넥션 풀이 없어 프로덕션에 부적합하므로 더 이상 권장되지 않습니다.


3. 컴포넌트 스캔과 의존성 주입

Spring 3.x — 수정자 주입 + @Autowired

이전 스프링 구버전 방식은 스캔 범위를 직접 지정하고 @Autowired를 통해 프레임워크에서 자동으로 주입될 수 있도록 구성을 하였습니다. 하지만 모든 의존성에 @Autowired를 통해 주입하는 방식이 불필요한 보일러 플레이트를 만들고 순환 의존성을 가질 수 있는 문제도 있었습니다.

@Configuration
@ComponentScan(basePackages = "com.example") // 스캔 범위 직접 지정
public class AppContext { ... }
@Repository
public class UserDaoJdbc implements UserDao {

    private JdbcTemplate jdbcTemplate;

    @Autowired // 수정자에 @Autowired 명시
    public void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public User findById(String id) {
        return jdbcTemplate.queryForObject(
            "SELECT * FROM users WHERE id = ?",
            userRowMapper(), id
        );
    }
}

@Service
public class UserServiceImpl implements UserService {

    private UserDao userDao;

    @Autowired // 수정자에 @Autowired 명시
    public void setUserDao(UserDao userDao) {
        this.userDao = userDao;
    }

    @Transactional
    public void register(User user) {
        userDao.save(user);
    }
}

Spring Boot 3.x — 생성자 주입, @Autowired 불필요

이제는 @Autowired를 통해 주입하는 것이 아니라 final을 통해 생성자를 통해 주입하는 방식으로 바뀌었습니다. Lombock의 @RequiredConstructer를 통해 더욱 더 쉽게 주입될 수 있게 변화하였습니다. 이를 통해 보일러 플레이트 코드를 줄이고 순환 의존 또한 컴파일 시점에 파악할 수 있게 되었습니다.

// @SpringBootApplication이 메인 클래스 패키지 기준으로 자동 스캔
// @ComponentScan 선언 불필요
@Repository
public class UserRepository implements UserDao {

    private final JdbcTemplate jdbcTemplate; // final로 불변성 보장

    // 단일 생성자 → @Autowired 없이 자동 주입
    public UserRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public User findById(String id) {
        return jdbcTemplate.queryForObject(
            "SELECT * FROM users WHERE id = ?",
            userRowMapper(), id
        );
    }
}

@Service
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional
    public void register(User user) {
        userRepository.save(user);
    }
}

달라진 핵심: 수정자 주입은 객체 생성 후 상태가 바뀔 수 있어 불변성이 깨집니다. 생성자 주입은 final 필드를 사용할 수 있고, 주입 누락을 컴파일 시점에 감지합니다. Spring 4.3부터 단일 생성자는 @Autowired 없이도 자동 주입됩니다.


4. 설정 클래스 분리와 모듈화

Spring 3.x — @Import로 설정 클래스 직접 연결

이전에는 독립적인 모듈 설정 클래스를 만들어 @Import를 통해 설정 정보를 주입하였습니다.

// 독립 모듈 설정 클래스
@Configuration
public class SqlServiceContext {

    @Bean
    public SqlService sqlService() {
        OxmSqlService sqlService = new OxmSqlService();
        sqlService.setUnmarshaller(unmarshaller());
        sqlService.setSqlRegistry(sqlRegistry());
        return sqlService;
    }

    @Bean
    public SqlRegistry sqlRegistry() { ... }

    @Bean
    public Unmarshaller unmarshaller() { ... }
}

// 메인 설정에서 @Import로 직접 연결
@Configuration
@Import(SqlServiceContext.class)
public class AppContext { ... }

더 나아가 @Import를 메타 어노테이션으로 감싸 의미있는 이름을 부여했습니다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(SqlServiceContext.class)
public @interface EnableSqlService {}

// 사용
@Configuration
@EnableSqlService
public class AppContext { ... }

Spring Boot 3.x — Auto Configuration + 필요한 경우만 @Configuration

@Import@Enable* 패턴 자체는 현재도 동일하게 유효합니다. 허지만 달라진 점이 Spring Boot의 @EnableJpaRepositories, @EnableCaching도 같은 패턴입니다. 

// Spring 3.x: AppContext, SqlServiceContext, TestAppContext 모두 직접 작성
// Spring Boot: 기본값으로 불충분한 경우에만 작성

@Configuration
public class SqlServiceConfig {
    // Auto Configuration이 처리 못하는 SqlService 커스텀 설정만 담당
    @Bean
    public SqlService sqlService(SqlServiceProperties props) {
        OxmSqlService sqlService = new OxmSqlService();
        sqlService.setSqlmap(new ClassPathResource(props.getSqlmapLocation()));
        return sqlService;
    }
}

테스트하고 싶은 레이어에서 필요한 설정만 주입하여 테스트할 수 있습니다. @WebMvcTest도 컨트롤러 레이어를 테스트할 때 사용하고는 합니다.

@DataJpaTest
class MyRepositoryTest {
    
    @Autowired
    private MyRepository myRepository;
    
    @Test
    void testSave() {
        MyEntity entity = new MyEntity("Test");
        myRepository.save(entity);
        
        List<MyEntity> result = myRepository.findAll();
        assertThat(result).isNotEmpty();
    }
}

5. 프로파일 - @Profile의 현대적 활용

Spring 3.x — 중첩 static 클래스로 환경 분리

@Configuration
@EnableTransactionManagement
@ComponentScan(basePackages = "com.example")
@Import(SqlServiceContext.class)
public class AppContext {

    // 운영 환경 빈
    @Configuration
    @Profile("production")
    public static class ProductionAppContext {
        @Bean
        public MailSender mailSender() {
            JavaMailSenderImpl ms = new JavaMailSenderImpl();
            ms.setHost("smtp.mycompany.com");
            return ms;
        }
    }

    // 테스트 환경 빈
    @Configuration
    @Profile("test")
    public static class TestAppContext {
        @Bean
        public MailSender mailSender() {
            return new DummyMailSender(); // 실제 메일 전송 안 함
        }
    }
}
// 테스트에서 프로파일과 설정 클래스를 직접 지정
@RunWith(SpringJUnit4ClassRunner.class)
@ActiveProfiles("test")
@ContextConfiguration(classes = AppContext.class)
public class UserServiceTest { ... }

Spring Boot 3.x — @Profile + application-{profile}.yml 조합

@Configuration
@Profile("production")
public class ProductionMailConfig {
    @Bean
    public MailSender mailSender() {
        JavaMailSenderImpl ms = new JavaMailSenderImpl();
        ms.setHost("smtp.mycompany.com");
        return ms;
    }
}

@TestConfiguration // 테스트 전용 설정은 @TestConfiguration으로 분리
public class TestMailConfig {
    @Bean
    public MailSender mailSender() {
        return new JavaMailSenderImpl();
    }
}
# application-prod.yml — 운영 환경 인프라 설정
spring:
  datasource:
    url: jdbc:mysql://prod-db.mycompany.com/mydb

# application-test.yml — 테스트 환경 인프라 설정
spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=MySQL
// 테스트
@SpringBootTest
@ActiveProfiles("test")
class UserServiceTest { ... }

// 또는 슬라이스 테스트로 더 가볍게
@DataJpaTest // H2 자동 구성, JPA 계층만 로드
class UserRepositoryTest { ... }

달라진 핵심: Spring 3.x는 환경별 빈을 @Profile로만 분리했습니다. Spring Boot는 여기에 application-{profile}.yml이 더해져 인프라 프로퍼티(DB URL, 포트 등)도 코드 수정 없이 환경별로 전환됩니다.


6. 외부 설정 - @Value에서 @ConfigurationProperties로

Spring 3.x — @PropertySource + @Value

@Configuration
@PropertySource("classpath:/database.properties")
public class AppContext {

    // 프로퍼티마다 @Value 선언 반복
    @Value("${db.url}")         String url;
    @Value("${db.username}")    String username;
    @Value("${db.password}")    String password;
    @Value("${db.driverClass}") String driverClass;

    @Bean
    public DataSource dataSource() {
        SimpleDriverDataSource ds = new SimpleDriverDataSource();
        ds.setUrl(this.url);
        ds.setUsername(this.username);
        // ...
        return ds;
    }
}

Spring Boot 3.x — @ConfigurationProperties로 타입 안전하게 묶기

@ConfigurationProperties(prefix = "sql.service")
@Validated
public class SqlServiceProperties {

    @NotNull
    private String sqlmapLocation;            // sql.service.sqlmap-location
    private String registryType = "embedded"; // sql.service.registry-type

    // getters/setters ...
}

@Configuration
@EnableConfigurationProperties(SqlServiceProperties.class)
public class SqlServiceConfig {

    private final SqlServiceProperties props;

    public SqlServiceConfig(SqlServiceProperties props) {
        this.props = props;
    }

    @Bean
    public SqlService sqlService() {
        OxmSqlService sqlService = new OxmSqlService();
        sqlService.setSqlmap(new ClassPathResource(props.getSqlmapLocation()));
        return sqlService;
    }
}
# application.yml
sql:
  service:
    sqlmap-location: /sqlmap.xml
    registry-type: embedded

달라진 핵심: @Value는 프로퍼티가 많아질수록 선언이 분산되고 오타를 컴파일 시점에 잡지 못합니다. @ConfigurationProperties는 관련 프로퍼티를 하나의 클래스로 묶고, @Validated로 값 검증까지 적용할 수 있습니다.


7. 전체 구조 비교

Spring 3.x 최종 구조

@Configuration
@EnableTransactionManagement          // 트랜잭션 직접 활성화
@ComponentScan(basePackages = "com.example") // 스캔 범위 직접 지정
@Import(SqlServiceContext.class)      // 모듈 설정 직접 연결
@PropertySource("classpath:/database.properties") // 프로퍼티 직접 연결
public class AppContext {
    // DataSource @Bean 직접 정의
    // TransactionManager @Bean 직접 정의
    // @Value로 프로퍼티 바인딩
    // @Profile 중첩 클래스로 환경 분리
}

Spring Boot 3.x 최종 구조

@SpringBootApplication
// = @Configuration
// + @EnableAutoConfiguration  → DataSource, TransactionManager 등 자동 구성
// + @ComponentScan             → 메인 클래스 패키지 기준 자동 스캔
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

// 커스텀이 필요한 경우만 별도 @Configuration 작성
  Spring 3.x Spring Boot
DataSource 생성 @Bean으로 직접 생성 application.yml 선언 → 자동 생성
TransactionManager @Bean 직접 생성 + DataSource 연결 자동 구성
ComponentScan basePackages 직접 지정 메인 클래스 위치 기준 자동
프로퍼티 바인딩 @PropertySource + @Value @ConfigurationProperties
의존성 주입 스타일 수정자 주입 + @Autowired 생성자 주입, @Autowired 불필요
환경 분리 @Profile 중첩 클래스 @Profile + application-{profile}.yml
테스트 설정 @ContextConfiguration(classes=...) 직접 지정 @SpringBootTest 하나로 해결

맺음말

Spring 3.x에서 직접 작성하던 설정의 대부분은 Spring Boot Auto Configuration으로 대체됐습니다. 그러나 DI 컨테이너가 메타정보를 어떻게 활용하는지, 왜 관례 기반 프로그래밍이 코드를 단순하게 만드는지, 모듈을 어떻게 설계해야 독립성을 유지할 수 있는지에 대한 사고 방식은 동일합니다.

 

설정이 자동화될수록 내부 동작 원리를 이해하는 일이 더 중요해집니다. Auto Configuration이 무엇을 어떻게 처리하는지 모르면, 기본값이 맞지 않는 상황에서 어디를 재정의해야 할지 알 수 없기 때문입니다.