이전 글들을 통해서 톰캣이 클라이언트와 TCP 연결을 맺고 요청을 처리할 스레드를 관리하는 지에 대해서 알아보았다.
이제는, 스레드 내부에서 전달받은 HttpServletRequest 객체를 처리하는 과정에 있어서, 스프링이 어떤 역할을 하는 지 알아보려 한다.
“스프링 없이 직접 객체를 만들고 의존성을 연결하는 경우”와 “스프링 IoC 컨테이너를 활용하는 경우”를 비교해서 스프링 컨테이너의 객체 관리의 필요성에 대해 알아보자.
클라이언트 요청을 처리하는 과정에서, 무엇을 읽는 과정이 필요하다고 생각해 보자. 대상은 파일일 수도 있고, 네트워크 소켓일 수도 있다.
바닐라 Java로 이를 구현해 보자.
public interface Reader {
String read();
}
public class SimpleReader implements Reader {
@Override
public String read() {
return "[SimpleReader] 읽기 완료";
}
}
public class BufferedReader implements Reader {
@Override
public String read() {
return "[BufferedReader] 읽기 완료";
}
}
Reader 인터페이스를 구현한 구현체는 SimpleReader, BufferedReader 두 개가 있다. 자세한 구현은 생략하겠다.
public class Executor {
private final Reader reader;
// 생성 시점에 어떤 Reader를 쓸지 결정
public Executor(Reader reader) {
this.reader = reader;
}
public void read() {
System.out.println(reader.read());
}
}
Executor 클래스는 Reader를 이용해서 실제로 읽기를 수행하게 된다. 이 클래스는 생성 시점에 실제 구현체 Reader를 주입받는다.
public class MainService {
public void doService() {
// 1) Executor 객체 생성 및 의존성 연결
Executor executor = new Executor(new SimpleReader());
// 2) 로직 실행
executor.read();
// 이후 요청 처리 로직...
}
}
MainService 는 Executor에 정의된 메서드를 이용해서 읽기 동작을 수행하게 된다.
만약 스레드가 클라이언트의 요청을 받아서 MainService#doService()를 수행한다면, 요청을 수행할 때마다 매번 객체가 새로 생성되고 의존성이 연결될 것이다.
요청이 끝나면, 해당 객체들은 더 이상 필요 없어져서 GC의 대상이 될 것이다.
만약 Executor#read() 로직이 많이 사용되는 로직이라면? 해당 클래스는 무의미하게 생성되었다가 GC로 삭제될 것이고, 그 과정에서 서버 리소스를 낭비하게 된다.
파일의 형식 등이 바뀌어서 새로운 Reader를 사용해야 할 경우에도, Executor를 생성하는 모든 곳에서 의존성 연결에 대한 코드를 다시 작성해야 할 것이다. 즉, 코드의 재사용성 측면에서도 좋지 않은 방법이다.
그렇다면, 서버가 처음 실행될 때 여러 스레드에서 공유해서 사용할 클래스들은 미리 의존성을 연결해서 생성해 놓고 재사용하면 되지 않을까?
그렇다, 이 방식이 바로 스프링이 객체 관리를 수행하는 방식이다.
IoC(Inversion of Control) 란 제어의 역전을 의미하는데, 객체를 생성하고 의존성 및 생명주기를 관리하는 것을 개발자가 아닌 프레임워크가 수행하는 것을 말한다.
스프링에서는 위 예시 코드처럼 개발자가 직접 객체를 생성하고 의존성을 주입할 필요가 없다. 개발자는 “어떤 클래스들이 빈(Bean)으로 등록되는지, 어떤 구현체를 사용해야 하는지” 정도만 선언적으로 명시하면 되며, 나머지는 스프링이 대신 처리하게 된다.
예를 들어, @Component, @Controller, @Service, @Configuration, @Bean, @Autowired 등의 Annotation을 통해서 스프링이 관리할 객체들을 명시하고 의존성을 연결할 수 있다.
설명만으로는 이해하기 어렵다. 바로 코드로 확인해 보자.
import org.springframework.stereotype.Component;
// Reader는 인터페이스 그대로 둠
public interface Reader {
String read();
}
@Component
public class SimpleReader implements Reader {
@Override
public String read() {
return "[SimpleReader] 읽기 완료";
}
}
@Component
public class BufferedReader implements Reader {
@Override
public String read() {
return "[BufferedReader] 읽기 완료";
}
}
이와 같이 @Component 를 달아두면, 스프링은 이 클래스를 자동으로 Bean으로 등록하게 된다. 이 객체들은 이제 Singleton으로 스프링에서 관리되는데, 싱글톤이란 최초 1회만 생성되고 이후에 재사용되는 객체라는 뜻이다.
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
@Service
public class ExecutorService {
private final Reader reader;
// Reader 구현체 중 하나를 IoC 컨테이너가 자동 주입(Autowired)해준다
@Autowired
public ExampleService(Reader reader) {
this.reader = reader;
}
public void read() {
System.out.println(reader.read());
}
}
기존 Executor와 같이, 특정 객체를 이용해서 실제 비즈니스 로직을 수행하는 객체들은 스프링에서 보통 @Service 어노테이션으로 명시한다. @Service는 @Component와 기능적인 차이는 없으나, 가독성 및 레이어 분리를 위해 따로 명시된다.
위 코드를 잘 살펴보면, Reader 타입이 결정되지 않은 것처럼 보이지만, 실제로는 스프링 컨테이너가 Reader 타입으로 등록된 빈들 중에서, @Primary 등을 이용해서 실제 구현체를 주입하게 된다.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.beans.factory.annotation.Autowired;
@RestController
public class ExecutorController {
private final ExecutorService executorService;
@Autowired
public ExecutorController(ExecutorService exampleService) {
this.executorService = exampleService;
}
@GetMapping("/read")
public String handleReadRequest() {
executorService.read();
return "Reading complete!";
}
}
이전 코드에서는 MainService#doService() 형식으로 모든 클라이언트의 요청을 처리했는데, 실제 스프링에서는 해당 방식으로 처리하지 않는다.
코드가 너무 난잡해질까봐 적지 않았었는데, 실제로는 Request의 URL을 보고 어느 요청인 지를 확인하고 URL에 대응되는 메소드를 실행하고, 요청들이 공통으로 수행하는 작업들을 요청 전-후로 해주는 것 또한 필요하다.
스프링 부트에서 DispatcherServlet은 등록된 스프링 컨테이너에서 @Controller로 등록된 빈들 중, 현재 URL에 대응되는 Controller를 찾아서 실행하는데, 그 과정에서 요청 전후에 필요한 동작들도 실행하게 된다. 자세한 내용은 다음 글에서 설명하겠다.
이런 방식으로 Annotation을 통해서 객체들의 의존 관계를 설정해 둔 뒤 관리하면 앞서 언급한 객체 생성 방식의 단점을 모두 극복할 수 있다.
의존성이 스프링 컨테이너 실행 시점에 자동으로 주입되고, 재사용될 객체들은 싱글톤으로 스프링이 관리해서, 객체가 필요할 때마다 의존성을 고려해서 객체를 생성할 필요가 없어진다.
만약 특정Service에 다른 구현체를 연결해야 한다면, @Primary나 @Qualifier를 통해서 연결할 수 있다.
또한, 스프링이 계층적 레이어로 구성한 @Controller, @Service 등의 어노테이션을 통해서, 이 클래스가 어떤 동작을 하는 지를 유추하기도 쉽다.
스프링 IoC 컨테이너에서는 모든 객체가 싱글톤으로만 관리되어야 하는 것은 아니다. 필요하다면 다음의 스코프를 적용해서 관리할 수 있다.
실제로 클라이언트 요청에 대해서 응답을 작성해야 할 때, 스프링 컨테이너가 없다면 어떻게 코드를 작성해야 할 지에 대해서부터 시작해서 스프링 IoC 컨테이너가 어떤 역할을 하는 지에 대해 알아보았다.
다음 글에서는 HTTP 요청이 DispatcherServlet에서 처리되는 과정을 확인하면서, 스프링이 웹 요청에서의 공통 작업들을 어떻게 편리하기 관리할 수 있도록 만드는 지 알아볼 것이다.