사이드 프로젝트/순수 자바로 스프링 만들기

순수 자바로 ApplicationContext 구현하기

마늘냄새폴폴 2025. 6. 5. 00:28

이번 포스팅에선 AnnotationApplicationContext를 깊이있게 파헤쳐보고 정리해보도록 하겠습니다. 코드는 깃허브를 참고해주시면 감사하겠습니다. 코드는 웬만하면 적지 않을 예정입니다. 제 블로그 포스팅은 기본적으로 코드를 되도록 적지 않는데 그 이유는 코드가 많아지면 설명해야할게 많아지고 그럼 가독성이 떨어진다고 생각하기 때문입니다. 

 

또한, 저도 공부하면서 포스팅을 수백개 봤지만 사실 코드를 잘 안봅니다... 그리고 깃허브를 들어가서 실제 코드를 보면 아시겠지만 변수명과 함수명이 실제 스프링 프로젝트와 다른 부분이 있습니다. 그 이유는 

 

  1. 실제 스프링 프로젝트는 훨씬 더 복잡한 계층 구조와 다양한 팩토리 클래스를 가지고 있습니다. 그래서 제 프로젝트는 최대한 가볍게 만들기 위해서 변수명을 조정했습니다. 
  2. 사실 코드는 별로 중요하지 않다고 생각하고 흐름만 공부하면 좋겠다고 생각했습니다. 
  3. AI의 도움을 받긴 했지만 커서와 같은 AI는 도움만 주었고 실제 코드 작성은 제가 하는 편입니다. 그 중에서 AI가 적어준 변수명보다 제가 이해하기 편한 변수명을 사용한 것도 있습니다. 

 

https://github.com/garlicpollpoll/springlite

 

GitHub - garlicpollpoll/springlite: 순수 자바로 스프링 만들기 프로젝트입니다.

순수 자바로 스프링 만들기 프로젝트입니다. Contribute to garlicpollpoll/springlite development by creating an account on GitHub.

github.com

 

AnnotationApplicationContext는 Spring boot에서도 아주 밀접하게 사용되는 Application Context 중 하나입니다. 어노테이션 기반으로 개발자가 가독성 좋게 개발할 수 있도록 도와줍니다. 

 

이번 포스팅에선 AnnotationApplicationContext의 동작 원리를 공부할 예정이고 Bean을 등록하는 과정, 그리고 그 과정에서 일어나는 재귀, 이렇게 두 가지 관점에서 포스팅을 정리해보겠습니다. 

 

Bean 등록

빈을 등록하는 과정도 크게 두가지 과정이 있습니다. 

 

  1. 애플리케이션이 시작될 때 스캔하는 과정
  2. 스캔한 빈을 등록하는 과정

 

두 가지 과정을 모두 정리해보려고합니다. 

 

Scan

스캔하는 과정은 크게 여섯 단계로 나눠집니다. 

 

  1. scan
  2. scanPackages
  3. scanPackage
  4. scanDirectory
  5. registerBean
  6. findAutowiredMembers

이제 이 여섯 단계를 더 자세히 풀어보겠습니다. 

 

  1. scan() : 스캔 단계에서는 @ComponentScan이 달려있는 최상위 패키지를 기준으로 아래로 내려가는 구조입니다. 이 베이스패키지를 기준으로 scanPackages를 호출합니다. 
  2. scanPackages() : basePackages를 for문을 돌면서 scanPackage()를 호출합니다. 
  3. scanPackage() : 여기서 클래스로더가 등장합니다. 클래스로더가 패키지의 베이스네임을 이용해서 getResource() 메서드를 호출해서 URL 형태로 가져옵니다. 그리고 이 것을 이용해서 new File()로 파일형태로 바꿔주죠. 
  4. scanDirectory() : 3번에서 만든 파일을 이용해서 이 파일이 디렉토리인지 파일인지 확인합니다. 운영체제 입장에선 폴더건 파일이건 모두 파일로 판단하기 때문에 이 곳에서 디렉토리인지 확인하게 됩니다. 

    만약 파일이라면 곧장 registerBean을 호출하고 디렉토리라면 다시 scanDirectory를 호출해서 재귀를 만들어내죠. 이렇게 파일들을 모두 찾아서 registerBean을 호출합니다. 
  5. registerBean() : 이 파일들 중에서 @Component가 붙어있는지 확인합니다. 일단 저는 지금 @Component만 빈으로 등록하게 했지만 추후에 업데이트를 통해 @Bean도 등록하도록 변경할 예정입니다. 

    5번 과정에서 눈여겨볼 점은 클래스에서 @Component가 붙은 빈을 클래스단위로 가져와서 이 클래스안에 다시 @Autowired가 붙어있는지 확인한다는 점입니다. 그럼 이제 마지막 스캔 단계인 findAutowiredMembers로 넘어가죠
  6. findAutowiredMembers() : 이 단계에서 @Autowired가 붙어있는 필드, 메서드, 생성자를 순서대로 찾습니다. 그리고 BeanDefinition에 차례대로 적재하죠. BeanDefinition에는 필드에 붙어있는 @Autowired를 List<Field> 형태로 가지고 있고 메서드에 붙어있는 @Autowired를 List<Method> 형태로 가지고 있고 생성자에 붙어있는 @Autowired를 Constructor<?> 형태로 들고 있습니다. 

    이 변수들을 이용해서 추후에 빈을 등록할 때 사용하는데 여기서이 변수들에 담겨있는 것은 실제로 스프링이 사용하는 것은 아니고 첫 번째 단계인 스캔 단계와 두 번째 단계인 등록 단계를 구분짓기 위해서 나눈 변수로 보입니다.

    이 변수들을 이용해서 실제로 AnnotationApplicationContext 안에 singletonBeans라는 스프링이 사용하는 빈 목록으로 변환해주는 것이죠. 

 

Refresh

두 번째 단계는 실제로 BeanDefinition에 있는 변수들에 접근해서 빈을 직접 등록하는 단계입니다. 여기서는 크게 네가지 단계로 다시 세분화되는데요. 

 

  1. refresh
  2. instantiateBeans
  3. getBean
  4. createBean

 

이제 다시 깊이있게 정리해보겠습니다. 

 

  1. refresh() : refresh 단계에서는 단순히 instantiateBeans를 호출하는 역할입니다. 
  2. instantiateBeans() : BeanDefinition에 있는 변수인 beanDefinitionMap을 돌면서 Bean을 가져옵니다. 
  3. getBean() : AnnotationApplicationContext에 있는 고유 변수인 singletonBeans (실제 스프링에서는 DefaultSingletonBeanRegistry라는 변수명을 사용하고 있습니다.) 을 돌면서 있으면 해당 객체를 바로 리턴하고 없으면 빈을 등록하는 과정을 거칩니다. 
  4. createBean() : createBean에서는 객체 인스턴스를 생성하는 instantiateBean, 빈을 직접 등록하는 populateBean으로 나뉩니다. 이제 populateBean까지 간다면 진짜 빈이 등록되는 것이죠. 

여기서 제가 제일 헷갈렸던 부분이 바로 instantiateBean과 populateBean인데요. 왜 이 둘을 나누게 되었는지 간단한 예제로 알아보도록 하겠습니다. 

 

@Controller
@RequestMapping("/api/users")
public class UserController {
    
    @Autowired
    private UserService userService; // 필드 주입
    
    // 생성자에 @Autowired가 없음
}

 

첫 번째 경우에서는 생성자 주입 없이 필드주입을 한 상태입니다. 

 

  • instantiateBean에서는 UserController의 생성자에 @Autowired가 없다는 것을 보고 기본 생성자를 만듭니다. new UserController()로 말이죠. 
  • 이 때 UserService는 아직 null인 상태입니다. 
  • populateBean을 호출하면서 @Autowired가 붙어있는 필드나 메서드를 확인합니다. 보니까 필드에 @Autowired가 붙어있네요. 이 객체를 getBean(UserService.class)로 빈을 찾아옵니다. 
  • 리플렉션으로 userService필드에 값을 주입
  • 이제 userService 필드가 실제 UserService 인스턴스를 가리킴

 

두 번째 경우는 생성자 주입이 있는 경우입니다. 

 

@Controller
@RequestMapping("/api/users")  
public class UserController {
    
    private final UserService userService;
    
    @Autowired
    public UserController(UserService userService) {
        this.userService = userService;
    }
}

 

  • instantiateBean에서 @Autowired가 붙은 생성자를 발견했습니다. 그럼 바로 getBean을 이용해서 UserService.class 타입의 빈을 가져옵니다. 
  • 그리고 생성자를 이용해서 new UserController(userService)를 실행합니다. 
  • 그리고 생성 시점에 바로 의존성이 주입됩니다. 
  • populateBean에서는 필드나 메서드가 없으므로 아무것도 하지 않습니다. 

 

실제 코드를 한번 볼까요? 

 

    private Object instantiateBean(BeanDefinition beanDefinition) {
        try {
            Class<?> beanClass = beanDefinition.getBeanClass();
            Constructor<?> autowiredConstructor = beanDefinition.getAutowiredConstructor();
            
            if (autowiredConstructor != null) {
                // @Autowired 생성자 사용
                Class<?>[] paramTypes = autowiredConstructor.getParameterTypes();
                Object[] args = new Object[paramTypes.length];
                
                for (int i = 0; i < paramTypes.length; i++) {
                    args[i] = getBean(paramTypes[i]);
                }
                
                return autowiredConstructor.newInstance(args);
            } else {
                // 기본 생성자 사용
                return beanClass.getDeclaredConstructor().newInstance();
            }
        } catch (Exception e) {
            throw new RuntimeException("Failed to instantiate bean: " + beanDefinition.getBeanName(), e);
        }
    }
    
    private void populateBean(Object instance, BeanDefinition beanDefinition) {
        // 필드 주입
        for (Field field : beanDefinition.getAutowiredFields()) {
            try {
                Object dependency = getBean(field.getType());
                field.set(instance, dependency);
            } catch (Exception e) {
                throw new RuntimeException("Failed to inject field: " + field.getName(), e);
            }
        }
        
        // 메소드 주입
        for (Method method : beanDefinition.getAutowiredMethods()) {
            try {
                Class<?>[] paramTypes = method.getParameterTypes();
                Object[] args = new Object[paramTypes.length];
                
                for (int i = 0; i < paramTypes.length; i++) {
                    args[i] = getBean(paramTypes[i]);
                }
                
                method.invoke(instance, args);
            } catch (Exception e) {
                throw new RuntimeException("Failed to inject method: " + method.getName(), e);
            }
        }
    }

 

instantiateBean에서는 이미 우리가 아까 스캔할 때 Constructor<?>에 집어넣은 변수를 이용해서 객체를 만들고 그 생성자에 포함되어있는 객체들을 훑으면서 인스턴스 (객체) 를 만듭니다. 

 

populateBean에서는 앞서 instantiateBean에서 만든 객체를 이용해서 리플렉션으로 값을 직접 세팅해줍니다. 

 

이렇게 빈을 등록하는 모든 과정이 끝나게 됩니다. 

 

이 때 스프링의 대단한 점이 엿보이는데 바로 getBean을 이용해서 재귀를 만들어 의존하고 있는 모든 클래스들을 돌면서 @Autowired가 붙은 모든 생성자, 필드, 메서드를 싹다 빈으로 등록하는 것인데요. 이제 스프링이 만든 노련함을 보겠습니다. 

 

재귀의 마술사 스프링

처음 scanDirectory를 할 때 재귀를 하는것은 크게 놀랍지 않았지만 빈을 등록하는 과정은 확실히 노련함이 엿보이더군요. 

 

예시는 UserController -> UserService -> UserRepository 이렇게 의존한다고 가정해보겠습니다. 그리고 각각 필드 주입으로 넣었다고 가정해보겠습니다. 

 

순서와 함께 살펴보시죠. 

 

  1. instantiateBeans()에서 getBean("userController") 호출
  2. getBean("userController") 실행
    1. singletonBeans에서 확인 -> 없음
    2. createBean("userController") 호출
    3. "userController"를 빈으로 등록
    4. instantiateBean(userController) 호출 -> 기본 생성자로 new UserController() 생성
    5. populateBean(userController 인스턴스) 호출
  3. UserController의 @Autowired UserService userService 필드 발견
  4. getBean(UserService.class) 호출 **이 때 재귀 시작**
    1. getBean("userService") 실행
    2. singletonBeans에서 확인 -> 없음
    3. createBean("userService") 호출
    4. "userService"를 빈으로 등록
    5. instantiateBean(userService) 호출 -> 기본 생성자로 new UserService() 생성
    6. populateBean(userService 인스턴스) 호출
  5. UserService의 @Autowired UserRepository userRepository 필드 발견
  6. getBean(UserRepository.class) 호출 **이 때 재귀 시작**
    1. getBean("userRepository") 실행
    2. singletonBeans에서 확인 -> 없음
    3. createBean("userRepository") 호출
    4. "userRepository"를 빈으로 등록
    5. instantiateBean(userRepository) 호출 -> 기본 생성자로 new UserRepository() 생성
    6. populateBean(userRepository 인스턴스) 호출
  7. UserService로 복귀하면서 리턴받은 UserRepository 인스턴스를 리플렉션으로 필드주입
  8. UserController로 복귀하면서 리턴받은 UserService 인스턴스를 리플렉션으로 필드주입

만약 이런 구조를 사용하고 있다면 의존하고 있는 클래스가 엄청나게 많아도 끝까지 찾아가서 모든 빈을 등록하는데요. 이 때 만약 이미 등록된 빈이라면 싱글톤 구조 때문에 재귀를 스킵할 수도 있습니다. 

 

이 재귀는 DFS이고 역시 알고리즘을 잘 쓸 수 있어야 스프링같은 대형 오픈소스 프로젝트를 할 수 있나봅니다.. 새삼 대단하게 느껴지네요. 

 

 

마치며

이렇게 AnnotationApplicationContext를 빈등록과 재귀의 관점에서 깊이있게 알아봤습니다. 사실 세군데서 작업해야해서 공부가 조금 바쁘긴 합니다. 프로젝트 개발과 깃허브 관리, 블로그 포스팅까지 엄청 빠듯한 일정인데요. 

 

그래도 간만에 너무 재밌는 공부였습니다. 앞으로 @Transactional을 추가하고 @Bean도 추가하면서 더 다채롭게 꾸며보도록 하겠습니다. 아직 추가해야할게 많아서 두근거리네요. 

 

이번 포스팅은 여기서 마치도록 하겠습니다. 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~