경계의 경계

Spring Framework란 무엇인가 본문

Spring

Spring Framework란 무엇인가

gigyesik 2024. 4. 2. 00:32

들어가며

자바로 프로그래밍을 시작하게 되면, 대부분 Spring Boot 를 사용하는 개발자가 된다.

하지만 Spring Boot 를 사용하기 전에 Spring 을 이해하는 것이 중요하다.

  • Spring 은 Spring Boot, Spring Data, Spring Batch 등 하위 프로젝트의 상위 개념이다.
  • 따라서 Spring 에 대한 이해 없이는 핵심을 이해하였다고 볼 수 없다.

Spring 이란 무엇인가

Spring 을 간단하게 이해하면, ‘Dependency Injection Container’ 이다.

우리에게 친숙한 레이어 (DB, 프록시, MVC) 등의 의존성을 주입해줌으로써 자바 어플리케이션을 편리하고 빠르게 구축할 수 있는 프레임워크인 것이다.

Dependency Injection (의존성 주입)

Dependency (의존성) 란?

자바 프로젝트 내에서 DB 의 유저 테이블에 접근하고 싶은 경우, 우리는 아래와 같은 DAO (Data Access Object) 또는 Repository 를 작성하게 된다.

public class UserDao {
    public User findById(Integer id) {
    }
}

위의 findById() 메서드는 DB에서 유저를 각각의 id로 조회하는 기능이다.

SQL 쿼리를 실행하기 위해, UserDAO 는 DB 연결을 필요로 한다. 자바에서는 이를 Connection 클래스를 통해 수행한다.

public class UserDao {
    public User findById(Integer id) {
        try (Connection connection = dataSource.getConnection()) {
            PreparedStatement selectStatement = connection.prepareStatement("select * from users where id =  ?");
        }
    }
}

여기서 의문이 발생한다. UserDao 는 DB의 존재를 어떻게 알고 있는가? UserDao 의 메서드는 DB에 연결되어 있어야만 실행된다.

이를 구현하는 방법 중 하나는, DataSource가 필요할 때 new 와 생성자를 통해 받아오는 것이다.

public class UserDao {
    public User findById(Integer id) {
        MysqlDataSource dataSource = new MysqlDataSource();
        dataSource.setURL("jdbc:mysql://localhost:3306/datasource");
        dataSource.setUser("root");
        dataSource.setPassword("password");

        try (Connection connection = dataSource.getConnection()) {
            PreparedStatement selectStatement = connection.prepareStatement("select * from users where id =  ?");
        }
    }
}

MySql DB 를 사용하고 싶고, DB 접속정보를 입력하였다. DB에 연결된다면 위에서 작성한 쿼리를 사용할 수 있다.

만약 DB에 다른 쿼리를 전송하고 싶다면, DataSource 선언부를 공통화해야한다.

public class UserDao {
    public User findById(Integer id) {
        try (Connection connection = newDataSource().getConnection()) {
            PreparedStatement selectStatement = connection.prepareStatement("select * from users where id =  ?");
        }
    }

    public User findByFirstName(String firstName) {
        try (Connection connection = newDataSource().getConnection()) {
            PreparedStatement selectStatement = connection.prepareStatement("select * from users where first_name =  ?");
        }
    }

    public DataSource newDataSource() {
        MysqlDataSource dataSource = new MysqlDataSource();
        dataSource.setUser("root");
        dataSource.setPassword("secret");
        dataSource.setURL("jdbc:mysql://localhost:3306/db");
        return dataSource;
    }
}

DataSource 선언부를 공통화했으므로, findById() 메서드와 findByFirstName() 메서드의 쿼리를 모두 전송할 수 있게 되었다.

하지만 또 다른 문제점이 발생한다.

  • 만약 UserDao 뿐 아니라 ProductDao 와 같은 다른 클래스에서도 DB에 쿼리를 보내야 한다면, 또사디 DataSource 를 공통화하기 위한 작업이 필요하다.
  • 지금의 코드는 메서드를 실행할 때마다 DB와의 커넥션을 새로 생성하고 있다. 이는 리소스 낭비이다.
  • DataSource 를 한번만 연결하고 이를 계속 재사용하여 여러 DAO에서 사용할 수 있도록 해야 한다.

따라서 DataSource 를 global scope 에서 관리한다.

public enum Application {
    INSTANCE;

    private DataSource dataSource;

    public DataSource dataSource() {
        if (dataSource == null) {
            MysqlDataSource dataSource = new MysqlDataSource();
            dataSource.setUser("root");
            dataSource.setPassword("sectet");
            dataSource.setURL("jdbc:mysql://localhost:3306/db");
            this.dataSource = dataSource;
        }
        return dataSource;
    }
}

public class UserDao {
    public User findById(Integer id) {
        try (Connection connection = Application.INSTANCE.dataSource().getConnection()) {
            PreparedStatement selectStatement = connection.prepareStatement("select * from users where id =  ?");
        }
    }

    public User findByFirstName(String firstName) {
        try (Connection connection = Application.INSTANCE.dataSource().getConnection()) {
            PreparedStatement selectStatement = connection.prepareStatement("select * from users where first_name =  ?");
        }
    }

}
  • 이제 UserDao와 다른 Dao들은 Application Enum 을 통해 DataSource를 어디서나 사용할 수 있게 되었다.
  • Application.INSTANCE 는 한 번만 선언되었으므로 싱글톤이 유지된다.

여전히 문제점은 있다.

  • dataSource를 불러오려면 항상 Application.INSTANCE.dataSource() 를 사용해야만 한다.
  • 이는 프로그램이 고도화될수록 공통화된 Application이 거대해짐을 의미한다. 언젠가는 분할해야만 한다.

Inversion of Control (제어 역전) 이란?

위의 예시에서, 우리는 dataSource 를 Application.INSTANCE.dataSource()로 사용하지 않고 싶다.

어디서든 바로 DataSource 를 불러오는 것을 제어 역전이라고 한다.

public class UserDao {
    private DataSource dataSource;

    public UserDao(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public User findById(Integer id) {
        try (Connection connection = dataSource.getConnection()) {
            PreparedStatement selectStatement = connection.prepareStatement("select * from users where id =  ?");
        }
    }

    public User findByFirstName(String firstName) {
        try (Connection connection = dataSource.getConnection()) {
            PreparedStatement selectStatement = connection.prepareStatement("select * from users where first_name =  ?");
        }
    }

}
  • UserDao를 생성할 때마다 DataSource를 포함하게 된다.
  • UserDao의 메서드들은 DataSource를 바로 사용한다.

UserDao의 관점에서는 이것이 훨씬 편리하다. 더이상 Application Enum을 import 하지 않아도 되고, DataSource가 어떻게 구성되어 있는지 몰라도 되기 때문이다.

그저 UserDao를 생성할 때 DataSource가 필요하다고 선언할 뿐이다.

하지만 UserDao를 외부에서 사용할 때는 생성자에 DataSource를 포함시켜 주어야 한다.

public class Main {
    public static void main(String[] args) {
        UserDao userDao = new UserDao(Application.INSTANCE.dataSource());
        
        User user1 = userDao.findById(1);
        User user2 = userDao.findById(2);
    }
}

Dependency Injection Containers (의존성 주입 컨테이너)

위의 코드들은 아래의 사항들을 의미한다.

  • 프로그래머가 UserDao를 구현하기 위해서는 DataSource를 한 번은 의존성으로 넣어주는 과정이 필요하다.
  • UserDao에 DataSource라는 의존성을 주입해주고 생성자까지 만들어주는 도구가 있으면 좋을 것이다.

Spring Framework 가 그 대안이다.

Spring의 의존성 주입 컨테이너

ApplicationContext

ApplicationContext란 Spring이 어플리케이션 내 클래스들을 관리하는 체계를 의미한다.

Spring 프레임워크를 통해 달성하고자 하는 부분을 살펴보자.

public class SpringBasicApplication {

    public static void main(String[] args) {
        ApplicationContext ctx = new AnnotationConfigApplicationContext(someConfigClass);
        
        UserDao userDao = ctx.getBean(UserDao.class);
        User user1 = userDao.findById(1);
        User user2 = userDao.findById(2);

        DataSource dataSource = ctx.getBean(DataSource.class);
    }

}
  • Spring ApplicationContext 객체를 사용하였다.
  • ApplicationContext 는 UserDao와 DataSource의 구성요소들을 사용할 수 있게 해준다.
  • 또한 UserDao에 DataSource가 정의되어 있더라도 DataSource에 직접 접근할 수 있다.

ApplicationContext 생성하기

위의 코드에서 someConfigClass 부분은 구현이 필요하다. AnnotationConfigApplicationContext 객체의 생성자에 파라미터로 필요하기 때문이다.

@Configuration
public class MyApplicationContextConfiguration {
    @Bean
    public DataSource dataSource() { 
        MysqlDataSource dataSource = new MysqlDataSource();
        dataSource.setUser("root");
        dataSource.setPassword("secret");
        dataSource.setURL("jdbc:mysql://localhost:3306/db");
        return dataSource;
    }

    @Bean
    public UserDao userDao() { 
        return new UserDao(dataSource());
    }
}
  • @Configuration 어노테이션은 어플리케이션 내의 글로벌 설정을 관리하겠다는 의미로 스인다.
  • @Bean 어노테이션을 사용하여 DataSource를 리턴하는 메서드를 정의하였다.
  • 또한 DataSource 를 주입한 UserDao를 리턴하는 메서드를 정의하였다.

이제 메인 클래스에 MyApplicationContextConfiguration 을 적용한다. 이제 직접 만든 설정을 ApplicationContext에서 사용할 수 있게 되었다.

public class SpringBasicApplication {

    public static void main(String[] args) {
        ApplicationContext ctx = new AnnotationConfigApplicationContext(MyApplicationContextConfiguration.class);
				...
    }

}

AnnotationConfigApplicationContext는 왜 사용하였는가

Spring ApplicationContext를 사용하는 방법에는 여러가지가 있고, 그 중 MyApplicationContextConfiguration이 내부에서 어노테이션을 사용한 구현을 하고 있으므로 AnnotationConfigApplicationContext를 사용하였다.

만약 XML 파일이나 Plain Java 등 다른 방법으로 설정을 구현하였다면 다른 ApplicationContext를 사용할 수 있다. 예를 들어 XML 파일로 사용하려면 ClassPathXmlApplicationContext를 사용한다.

Spring의 가장 보편적 구현이 어노테이션 기반이라 사용한 것이다.

@Bean 어노테이션은 무엇인가? Spring Bean 은 무엇인가?

MyApplicationContextConfiguration 클래스는 팩토리 메서드(factory method)를 가지고 있다. UserDao와 DataSource를 생성하는 메서드가 그것이다.

팩토리 메서드를 쉽게 설명하는 문장이 있다. ‘Spring Container가 인스턴스를 생성해 주고 제어해 준다’.

그렇다면 Spring Container는 인스턴스를 어느 범위까지 제어하는 것인가?

Spring bean의 제어 범위

Spirng이 프로젝트의 DAO를 제어하기 위해 몇 개의 인스턴스를 생성하는 지 알기 위해서는 scope를 이해해야 한다.

  • 싱글톤 (Singleton) : 모든 인스턴스가 같은 DB로부터 생성되어야 하는 경우
  • 프로토타입 (Prototype) : 모든 인스턴스가 각자 바라보고 있는 DB로부터 생성되어야 하는 경우
  • 그 밖에 동적으로 할당되는 등 더욱 복잡한 scope

Bean의 scope는 어노테이션에서 쉽게 설정할 수 있다.

@Configuration
public class MyApplicationContextConfiguration {
    @Bean
    @Scope("singleton")
    public DataSource dataSource() {
        MysqlDataSource dataSource = new MysqlDataSource();
        dataSource.setUser("root");
        dataSource.setPassword("secret");
        dataSource.setURL("jdbc:mysql://localhost:3306/db");
        return dataSource;
    }
}

이 어노테이션 설정은 spring bean이 몇 개의 인스턴스를 설정할지를 제어한다.

  • singleton : 인스턴스를 단 한개만 생성한다.
  • prototype : bean 이 호출될 때마다 새로운 인스턴스를 생성한다.
  • sesstion : 모든 유저의 http 세션에 bean을 생성한다.

대부분의 Spring 어플리케이션은 singleton 옵션을 설정하고 있다.

Spring 의 자바 설정

  @Bean
    public UserDao userDao() {
        return new UserDao(dataSource());
    }

위 어플리케이션에 UserDao와 DataSource가 전부 Bean으로 등록되어 있는데 userDao() 안에 dataSource()를 주입하는 이유는 무엇일까?

@ComponentScan 이란

@Configuration
@ComponentScan
public class MyApplicationContextConfiguration {
    @Bean
    @Scope("singleton")
    public DataSource dataSource() {
        ...
    }

}

이 어노테이션을 사용하면 Configuration클래스 내에 UserDao를 선언할 필요가 없다.

Configuration 클래스가 Bean처럼 동작하는 다른 클래스들을 찾아 자동으로 등록해 주기 때문이다.

@Component와 @Autowired

@Component
public class UserDao {
    ...
}

이 어노테이션을 등록하면 @ComponentScan을 사용한 Configuration 클래스에서 UserDao를 Bean으로 인식하고 DataSource 를 주입한다.

그런데 어떤 DataSource인지 알고? 이 때 사용되는 것이 @Autowired이다.

 public UserDao(@Autowired DataSource dataSource) {
        this.dataSource = dataSource;
    }
  • UserDao를 @Component 로 등록했으므로 Spring이 Bean 을 생성한다.
  • UserDao 생성자가 @Autowired 를 갖고 있으므로 Spring이 Bean으로 등록된 DataSource 를 주입해준다.

@Autowired 의 사용 (Field 또는 Setter에 사용)

필드나 setter 에도 @Autowired를 사용할 수 있다. 둘은 동일한 기능을 갖는다.

@Component
public class UserDao {

    @Autowired
    private DataSource dataSource;

}

@Component
public class UserDao {

    private DataSource dataSource;

    @Autowired
    public void setDataSource(DataSource dataSource) {
        this.dataSource = dataSource;
    }

}

Spring의 관점 지향 프로그래밍 (Aspect Oriented Programming)

의존성을 주입하면 구조적으로 더 우수한 프로그램을 만들 수 있지만, @Bean 을 사용한 전략이 늘 정답은 아닐 수 있다.

@Configuration
public class MyApplicationContextConfiguration {    
    @Bean
    public UserService userService() {
        return new UserService();
    }
}

위의 UserService가 Bean으로 주입되면 Spring이 UserService 객체를 생성해 주지만, 사실상의 UserService 클래스와는 다른 객체가 생성될 수 있다.

Spring의 프록시 기능 (Proxy Facilities)

위에서 언급한 대로 UserService는 설계와 동일하게 동작할 것 같지만, 실제로는 그렇지 않다.

이는 프록시를 반환하는데, 프록시는 자신만의 고유한 기능을 갖는다.

프록시를 왜 사용하는가?

프록시는 AOP의 대표적 기능으로, Spring이 어플리케이션의 코드를 수정하지 않고 부가기능을 제공하기 위해 사용한다. 대표적으로 @Transactional이 있다.

Spring의 @Transactional

@Component
public class UserService {
    @Transactional
    public User activateUser(Integer id) {
        //
    }
}
  • activeUser() 메서드는 DB에 있는 user의 상태를 업데이트 하는 기능이다.
  • @Transactional 어노테이션은 DB에 트랜젝션을 거는 기능이다.

이때 UserService를 Bean으로 주입하게 되면 DB에 커넥션을 생성해둔 상태이므로 트랜젝션이 일어나지 않는다.

이 때 Spring은 트랜젝션을 발생시키기 위해 프록시를 사용한다.

@Configuration
@EnableTransactionManagement
public class MyApplicationContextConfiguration {
    @Bean
    public UserService userService() {
        return new UserService();
    }
}

Resources