스프링 부트에 대한 간단한 고찰 - 1

25년 KSUG에서 주니어 및 취준생 분들과 함께한 스터디 진행 기록입니다.
스프링 프레임워크 혹은 데이터베이스에 대한 스터디를 진행 했고, 저는 스프링 프레임워크를 선택했습니다.

기여해주시는 멘토님들께 항상 감사드립니다.

 

스프링 프레임워크는 자바에서 가장 많이 사용하는 프레임워크이다.

프레임워크와 라이브러리

구분 프레임워크 라이브러리
제어 흐름 프레임워크가 제어(IoC) 개발자가 제어
역할 애플리케이션 뼈대 제공 특정 기능을 쉽게 사용할 수 있게 만든 도구 제공
호출 주체 프레임워크가 내 코드를 호출 내가 라이브러리를 호출
유연성 상대적으로 낮음 상대적으로 높음

IoC(Inversion of Control): 제어의 역전

일반적인 코드 작성이라면 개발자가 직접 객체를 만들고 사용한다.

public class UserController {
    private UserService userService;

    public UserController() {
        this.userService = new UserService(); // 직접 생성
    }

    public void printUser() {
        System.out.println(userService.getUser());
    }
}

 

하지만 IoC를 적용한 환경의 경우 객체의 생명주기 관리를 외부에 위임한다.

import org.springframework.stereotype.Service;

@Service
public class UserService {
    public String getUser() {
        return "User";
    }
}

@RestController
public class UserController {
    private final UserService userService;

    // Spring이 자동으로 UserService를 주입해줌
    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/user")
    public String getUser() {
        return userService.getUser();
    }
}

 

이 때, @Service 어노테이션을 사용하여 해당 클래스를 빈(bean)으로 자동 등록되게 되는데, Spring은 @Component, @Service, @RestController 등으로 등록된 클래스의 생성자에 필요한 의존성을 자동으로 찾아서 넣어준다.

 

UserService라는 Bean이 등록되어 있으니 UserController를 생성할 때 Spring이 생성자를 확인하고 bean을 알아서 넣어주는 것이다.

 

이를 의존성 주입(DI)라고 한다.

물론, 의존성 주입을 하는 방법에는 생성자를 사용하지 않고도 할 수 있는 방법도 있다.

 

Spring에서 의존성 주입받는 방법

  1. 생성자를 통한 의존성 주입
  2. 필드 객체 선언을 통한 의존성 주입(@Autowired 어노테이션 사용)
  3. setter 메서드를 통한 의존성 주입

Spring에서는 1번 방법인 생성자를 통한 주입을 권장하는데, 그 이유는 생성자 주입이 객체 생성 시점에 필요한 의존성을 명확하게 전달함으로써 불변성을 보장하고, 필수 의존성 주입을 강제할 수 있으며, 테스트 시에도 목 객체를 직접 주입하기 쉬워 테스트 용이성이 높기 때문이다.  또한 생성자가 하나뿐이면 @Autowired 없이도 작동하므로 프레임워크에 대한 의존도가 낮아지고, 순환 참조 문제가 애플리케이션 시작 시점에 즉시 발견되어 안정적인 코드 구조를 유지할 수 있다.

 

cf.

  • 불변성 보장 방법
    private final UserService userService; // final로 변경되지 않도록 강제 가능
  • 필수 의존성 주입 강제
    생성자는 객체가 생성될 때 반드시 호출되므로 의존성이 누락되면 오류 발생
  • 테스트가 쉬움
    UserController controller = new UserController(mockUserService);
    테스트 시 필요한 객체를 넣어줌으로써 테스트가 쉬워짐

 

Spring이 이렇게 객체의 생성과 의존성 주입을 관리함으로써, 개발자는 핵심 비즈니스 로직에 집중할 수 있게 된다. 또한 Spring은 이처럼 객체를 직접 제어하고 있으므로, 공통적인 부가 기능(로깅, 트랜잭션, 보안 등)을 핵심 로직과 분리하여 처리할 수 있는 AOP(Aspect Oriented Programming) 도 제공한다.

 

AOP(Aspect Oriented Programming): 관점 지향 프로그래밍

비즈니스 로직에서 DB에 존재하는 데이터를 이용하기 위해서는 다음과 같은 공통된 순서를 따른다.

  1. transactionManager를 통해 트랜잭션을 시작한다.
  2. 비즈니스 로직을 수행한다.
  3. transaction을 커밋하거나 롤백한다.

하지만 트랜잭션의 시작과 종료는 대부분의 비즈니스 로직에서 반복적으로 사용되는 공통 작업이다. 이 로직을 매번 코드에 직접 작성하면 중복이 생기고, 코드도 복잡해지기 쉽다.

 

이런 문제를 해결하기 위해 AOP(관점 지향 프로그래밍)라는 개념이 생겼다. AOP를 사용하면 트랜잭션 같은 공통 작업을 비즈니스 로직과 분리해서 처리할 수 있기 때문에, 중복 없이 깔끔하고 일관된 방식으로 적용할 수 있다.

 

AOP를 구현하는 방법은 크게 3가지로 나뉜다.

  1. 컴파일 과정에 삽입하는 방식
  2. 바이트코드를 메모리에 로드하는 과정에 삽입하는 방식
  3. 프락시 패턴을 이용한 방식

스프링 AOP는 IoC/DI 컨테이너와 다이내믹 프록시, 데코레이터 패턴, 프록시 패턴, 자동 프록시 생성 기법, 빈 오브젝트의 후처리 조작 기법 등의 다양한 기술을 조합해 AOP를 지원하고 있지만 그 중 가장 햇김은 프록시를 이용했다는 것이다.

프록시를 사용하면 자바의 기본 JDK와 스프링 컨테이너 외에는 특별한 기술이나 환경을 요구하지 않는다.

 

AOP 기술의 원조이자 가장 강력한 AOP 프레임워크로 꼽히는 AspectJ는 컴파일된 타깃의 클래스 파일 자체를 수정하거나 클래스가 JVM에 로딩되는 시점을 가로채서 바이트코드를 조작하는 복잡한 방법을 사용한다.

이러한 방법을 사용하면 스프링과 같은 컨테이너가 사용되지 않는 환경에서도 손쉽게 AOP 적용이 가능해지고, 프록시 방식보다 훨씬 강력하고 유연한 AOP가 가능해지기 때문에 프록시 적용이 불가능한 private 메서드를 호출하거나 스태틱 메소드 등을 호출 할 수 있다.

Spring framework의 다양한 모듈

https://docs.spring.io/spring-framework/docs/4.0.x/spring-framework-reference/html/overview.html#overview-modules

스프링 프레임워크는 기능별로 구분된 약 20개의 모듈로 구성되어 있다. 물론 모든 모듈을 사용할 필요가 없고, 애플리케이션 개발에 필요한 모듈만 선택해서 사용하게끔 설계되어 있기 때문에 경량 컨테이너 설계라고 부른다.

 

스프링 프레임워크와 스프링 부트

스프링 부트는 'spring-boot-starter'라는 의존성(라이브러리)을 제공한다.

'spring-boot-starter'의 여러 라이브러리를 함께 사용할 때는 의존성이 겹칠 수 있는데, 이 때문에 버전 충돌이 발생할 수 있고 의존성 조합 충돌 문제가 없도록 'spring-boot-starter-parent'가 검증된 조합을 제공한다.

  • spring-parent는 maven 프로젝트에만 사용가능한 의존성이다.
  • gradle에서도 의존성을 관리할 수 있도록 spring-boot-dependencies(plugin: io.spring.dependency-management)라는 의존성을 제공해주고 있는데, 해당 의존성은 spring-boot-starter-parent를 부모로 사용하고 있다고 한다.

 

자동 설정(Auto Configuration)

스프링 부트는 스프링 프레임워크의 기능을 사용하기 위한 자동 설정을 지원하는데, 해당 기능은 애플리케이션에 추가된 라이브러리를 실행하는 데 필요한 환경 설정을 알아서 찾아준다.

@SpringBootApplication
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }

}

 

예를 들어 스프링 부트의 메인 애플리케이션을 보면 @SpringBootApplication이라는 애노테이션을 사용하는 것을 볼 수 있다.

@SpringBootApplication 의 경우 이와 같은 모습을 하는 것을 볼 수 있다.

 

@Target(ElementType.TYPE)  
@Retention(RetentionPolicy.RUNTIME)  
@Documented  
@Inherited  
@SpringBootConfiguration  
@EnableAutoConfiguration  
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),  
       @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })  
public @interface SpringBootApplication { }

 

@SpringBootApplication는 주로 다음 세 가지 어노테이션의 조합으로 이뤄진다.

  1. @SpringBootConfiguration
  2. @EnableAutoConfiguration
  3. @ComponentScan

 

스프링 부트 애플리케이션이 실행되면

Spring은 어노테이션의 “순서대로 실행”하지는 않지만, 내부적으로 처리되는 순서가 있는데, 대표적으로 다음과 같은 순서로 동작한다.

1. @ComponentScan
→ 먼저 현재 클래스 기준으로 컴포넌트(@Component, @Service, @Controller 등)를 찾아 Context에 Bean으로 등록합니다.

2. @Configuration (@SpringBootConfiguration)
→ Java Config 클래스로 인식되고, 스프링 IoC 컨테이너에 @Bean 메서드 등을 등록합니다.

3. @EnableAutoConfiguration
→ spring.factories에서 정의된 자동 설정 클래스를 불러와서 조건에 따라 Bean을 등록합니다.
→ @ConditionalOnXxx 어노테이션에 따라 필요할 때만 활성화됩니다.

즉, 1. 컴포넌트 스캔 → 2. 사용자 설정 빈 등록 → 3. 자동 설정 등록 순으로 Bean 구성이 진행됩니다.
  1. @ComponentScan 애너테이션이 @Component 시리즈 애너테이션이 붙은 클래스를 발견해 ApplicationContext가 관리하는 Bean Container에 스프링 빈으로 등록한다.
    • Spring에서는 모든 Bean 객체가 ApplicationContext 내부의 BeanFactory (정확히는 DefaultListableBeanFactory) 에 등록
      • // DefaultListableBeanFactory 내부
      • Map<String, BeanDefinition> beanDefinitionMap; // Bean 정의 정보 저장
      • Map<String, Object> singletonObjects; // 실제 생성된 싱글톤 객체 저장
  2. @EnableAutoConfiguration 애너테이션을 통해 AutoConfigurationImportSelector 클래스가 동작하게 되며 스프링 컨테이너에 등록해서 사용할 수 있게 된다.

spring에서 말하는 component에는 여러가지가 있는데, 대표적으로는 @Controller, @RestController, @Service, @Repository, @Configuration이 있다.

참고로, @Bean을 사용하는 경우 애노테이션 클래스나 메서드에서만 사용 가능하고, @Component의 경우 클래스에 사용 가능하다.

@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})  
@Retention(RetentionPolicy.RUNTIME)  
@Documented  
public @interface Bean {  
    @AliasFor("name")  
    String[] value() default {};  

    @AliasFor("value")  
    String[] name() default {};
}

@Target({ElementType.TYPE})  
@Retention(RetentionPolicy.RUNTIME)  
@Documented  
@Indexed  
public @interface Component {  
    String value() default "";  
}

 

Bean이 어떻게 만들어지는지 궁금해서 찾아보았다.

스프링과 자바를 사용할 때 Bean의 종류에는 2가지가 존재하는데, JavaBean과 Spring Bean 두가지이다.

구분 Java Bean Spring Bean
소속 Java 표준 Spring Framework
관리 주체 개발자 또는 일반 자바 코드 Spring IoC 컨테이너
필요 조건 기본 생성자, getter/setter 등 없음 (어노테이션이나 설정으로 정의 가능)
목적 데이터 캡슐화, GUI 도구 지원 등 의존성 주입, 트랜잭션, AOP 등 스프링 기능 활용
관련 클래스 Introspector, BeanInfo ApplicationContext, @Component, @Bean

 

JavaBean의 경우 POJO를 뜻한다고 하고 ...여기서 알아볼 것은 Spring Bean이다.

 

스프링 IoC 컨테이너는 각 빈에 대한 정보를 담은 설정 메타정보를 읽어들인 뒤에, 이를 참고해서 빈 오브젝트를 생성하고, 프로퍼티나 생성자를 통해 의존 오브젝트를 주입해주는 DI 작업을 수행한다.

 

이 작업을 통해 만들어지고, DI로 연결되는 오브젝트들이 모여서 하나의 애플리케이션을 구경하고 동작하게 된다. IoC 컨테이너의 역할이 바로 이것이다.

 

토비의 스프링 3.1 vol.2 참고

*IoC 컨테이너가 관리하는 빈은 Object 단위이지 클래스 단위가 아님을 기억하자.

 

결국 스프링 애플리케이션이란 POJO 클래스와 설정 메타정보를 이용해 IoC 컨테이너가 만들어주는 오브젝트의 조합이라고 할 수 있다.

IoC 컨테이너의 핵심 기능은 빈 오브젝트를 생성하는 것인데, 어떻게 생성되는 것일까?

 

스프링의 빈 팩토리와 애플리케이션 컨텍스트는 각각 BeanFactory와 ApplicationContext라는 두 개의 인터페이스로 정의되어 있다. ApplicationContext 인터페이스는 BeanFactory 인터페이스를 상속한 서브 인터페이스이다.

 

컨테이너가 본격적인 IoC 컨테이너로서 동작하려면 POJO 클래스와 설정 메타정보가 필요한데, 스프링의 설정 메타정보는 BeanDefinition 인터페이스로 표현되는 순수한 추상 정보로 이뤄진다.

 

스프링에는 다양한 용도의 ApplicationContext 구현 클래스가 존재하는데, 가장 일반적인 애플리케이션 컨텍스트의 구현 클래스는 GenericApplicationContext 클래스이고, 이 클래스가 빈 설정 정보를 읽어 오기 위해 Reader 클래스를 설정 해야하는데, 이 때 xml을 사용하기 위해서는 GenericXmlApplicationContext 라는 클래스를 사용하여 자동으로 읽을 수 있게 설정하면된다.

 

하지만 우리가 가장 많이 사용하는 방법은 웹 애플리케이션으로 만들어지기 때문에 WebApplicationContext(인터페이스)를 이용하게 된다. 이 경우 구현체로서 Xml 설정 파일을 사용하려면 WxmlWebApplicationContext(기본), 애노테이션을 이용한 설정의 경우 AnnotationConfigWebApplicationContext를 사용하게 된다. 사내에서는 xml 파일을 거의 사용하지 않으니 웬만하면 Annotation 관련 클래스를 사용하지 않을까싶다.

 

스프링 프로젝트가 시작될 때 특정 빈 오브젝트를 호출해서 동작시키는 방법은 무엇일까?

 

웹 환경에서는 서블릿 컨테이너가 브라우저로부터 오는 HTTP 요청을 받아서 해당 요청에 매핑되어 있는 서블릿을 실행해주는 방식으로 동작한다. 요청이 해당 서블릿으로 들어올 때마다 getBean()으로 필요한 빈을 가져와 정해진 메서드를 실행하게 되는 것이다.

 

토비의 스프링 3.1 vol.2 참고

 


이것과 다르게 Bean은 어떤 순서로 생성되는 것인지 궁금했다.
특히 이름 생성 방식부터 알아야 한다고 생각했다.

 

빈 이름이 생성될 때 사용하는 많은 클래스들이 있지만,

 

애노테이션 기반 컴포넌트 스캔(@Component, @Service 등)으로 생성되는 Bean이 만들어질 때는 AnnotationBeanNameGenerator 클래스를 이용한다고 한다.

 

BeanNameGenerator 클래스와 상속 클래스

 

해당 클래스에는 generateBeanName 메서드가 존재하는데,

메서드 역할
generateBeanName() 전체 이름 생성 과정의 진입점: (우선 @Component("이름") 같이 명시된 이름 사용)
buildDefaultBeanName() 명시된 이름이 없을 경우, 클래스명을 기반으로 기본 이름 생성

예시 1: 명시된 이름 있는 경우

@Component("customService")
public class MyService {}

generateBeanName()
determineBeanNameFromAnnotation() 에서 "customService" 추출

예시 2: 명시된 이름 없는 경우

@Component
public class MyService {}

generateBeanName()
determineBeanNameFromAnnotation() → null
buildDefaultBeanName() 호출
"myService" 생성

이 때, buildDefaultBeanName() 메서드를 보면

 

 

String shortClassName = ClassUtils.getShortName(beanClassName);  
return StringUtils.uncapitalizeAsProperty(shortClassName);

StringUtils를 사용해서 빈 이름을 만드는데, 해당 클래스의 규칙은 다음과 같다.

public static String uncapitalizeAsProperty(String str) {
    return hasLength(str) &&
           (str.length() <= 1 || 
            !Character.isUpperCase(str.charAt(0)) || 
            !Character.isUpperCase(str.charAt(1)))
        ? changeFirstCharacterCase(str, false)
        : str;
}

public static boolean hasLength(@Nullable String str) {  
    return (str != null && !str.isEmpty());  
}
  • Java 클래스 이름을 Bean 이름으로 만들 때, JavaBeans 명세에 맞춰 첫 글자만 소문자로 바꿔주는 메서드
  • 단, 앞 글자 2개가 모두 대문자일 경우는 바꾸지 않음

기존에는 Introspector.decapitalize() 메서드를 사용했다고 하는데, 불완전해서 deprecated되었고 현재는 StringUtils를 사용하고 있다고 한다.

 

Introspector 클래스가 존재하고 있는 곳은 java.beans에 존재하고 있기에 아까 잠시 살펴봤던 자바 빈이 생길 때 사용되는 클래스이다.

Introspector 클래스의 decapitalize 메서드

참고: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/util/StringUtils.html#uncapitalizeAsProperty(java.lang.String)

SpringUtils.uncapitalizeAsProperty() 의 See Also 를 보면 알 수 있다.

Introspector docs: https://docs.oracle.com/javase/8/docs/api/java/beans/Introspector.html

 

 

SpringUtils.uncapitalizeAsProperty() 메서드와 Introspector.decapitalize() 메서드는 거의 동일한 로직이지만, 해당 메서드로 변경한 이유는 다음과 같다.

https://github.com/spring-projects/spring-framework/issues/29320

 

Perform basic property determination without java.beans.Introspector · Issue #29320 · spring-projects/spring-framework

Out of a discussion in #26884, we should replace our beans introspection with a basic property determination algorithm that discovers the common scenarios for Spring applications but nothing more: ...

github.com

 

spring project #29320

 

Introspector를 사용하면 불필요한 Java 모듈을 의존(java.desktop)하게 되어 더 작고 빠른 Spring 애플리케이션 실행 환경을 만들기 위함과 함께, 네이밍 규칙을 프레임워크에서 직접 하기 위해 없앴다고 한다.

 


내장 WAS

스프링 부트의 각 웹 애플리케이션에는 내장 WAS(Web Application Server)가 존재한다. 가장 기본이 되는 의존성인 'spring-boot-starter-web'에는 톰캣이 내장되어 있다.

물론 톰캣이 아닌 다른 웹서버(Jetty, Undertow 등)를 사용할 수도 있다.


모니터링

스프링 부트에는 Spring Boot Actuator라는 자체 모니터링 도구가 존재한다.

 

 

 

 

 




더 궁금한 점(더 알아봐야 할 것)과 느낀점

  • spring bean이 어떻게 생성되는지 순서
    • 어디에 어떻게 생성이 되는지
    • AnnotationConfigApplicationContext 클래스를 좀 더 파봐야 할 것 같다
  • WAS vs Server
    • tomcat의 구동 방법 및 생김새(?)
  • 모니터링
  • 그 외...

이번 스터디를 준비하면서 그동안 미뤄왔던 토비의 스프링 3.1 vol.1을 드디어 완독했다. 완독 후에는 vol.2의 챕터 1 일부도 정리해서 함께 추가해보았다.

 

아직 궁금하거나 완전히 해소되지 않은 부분들은 vol.2의 챕터 1 나머지 내용을 읽으면서 풀리지 않을까 생각한다.

 

시간이 부족하다는 핑계로 뒷부분 주제는 충분히 준비하지 못한 점이 아쉽다. 하지만 꼭 알아야 할 주제인 만큼, 앞으로도 스터디를 이어가며 부족한 부분을 채워 나갈 계획이다. 다음 글에서는 이번에 다루지 못한 내용도 함께 채워 글을 써봐야겠다.