스터디 4주차 : Spring Boot의 코어 개념

📝 UMC 8기 Spring Boot 스터디 4주차 Spring Boot의 코어 개념
avatar
2025.04.24
·
17 min read

25/4/22
UMC 동아리 8기 스프링 부트 스터디 4주차 Spring Boot의 코어 개념을 진행하며, 공부했던 내용과 미션을 정리하는 포스트입니다.

📚 개념 정리

Spring

Java 애플리케이션 개발을 편하게 할 수 있게 해주는 오픈소스 경량급 애플리케이션 프레임워크

프레임워크는 목적을 쉽게 달성할 수 있게 하기 위해 목적과 관련된 코드의 구조를 미리 만들어둔 것이다.

API도 외부에서 구성된 것을 사용한다는 점에서 프레임워크와 비슷하다고 생각할 수 있지만, API는 소프트웨어 간의 상호작용을 위한 인터페이스를 제공하는 역할을 맡고 있으며 개발자가 호출하여 사용하기에 제어권을 개발자가 갖고 있다. 하지만, 프레임워크는 애플리케이션을 개발하기 위한 구조를 제공하는 역할이며, 프레임워크가 제어권을 지니고 있어 개발자는 규칙에 따라 코드를 작성하면 된다.

IoC(제어의 역전) 컨테이너

방금 말한 프레임워크가 제어권을 지니고 있다는 말과 비슷하다.
개발자가 아닌 것이 대신 객체를 생성하고 관리해주기에 제어가 개발자가 아닌 프레임워크에 있어 = 제어권이 역전되었다는 표현을 쓰는 것이다.

Spring에서는 이러한 제어의 역전을 IoC 컨테이너에서 수행한다.
1. 객체를 Class로 정의하고
2. Config, annotation을 통해 객체 간의 연관성을 지정하면 (의존성 주입)
3. IoC 컨테이너가 객체를 생성하고 필요한 곳에 주입해준다.

  • 기존의 방법 : new 생성자를 통한 객체 생성

MyObject mObj = new MyObject(...);

개발자가 직접 new를 작성해 객체를 생성하고 의존성 관리를 담당하고 있다. IoC가 적용되지 않은 순수한 POJO로 구현되었다.


Spring Container가 관리하는 자바 객체를 빈(Bean)이라고 한다.
Spring은 Bean을 통해 객체를 인스턴스화 하고, 의존 관계를 관리한다.

  • 묵시적으로 빈을 정의 : @Component @Autowired

// 클래스 생성
@Component
public class MyObject {
	...
}

// 클래스 사용 시
	@Autowired
    public Service(MyObject mObj) {
    	...
	}

클래스를 생성할 때 @Component 어노테이션을 붙여 자동으로 Bean으로 등록하게 하고, 클래스를 사용할 때 @Autowired 어노테이션을 작성한다.

  • 명시적으로 빈을 정의 : @Configuration @Bean

// Spring 설정 파일
@Configuration
public class AppConfig {

	// @Bean 어노테이션을 붙여 빈을 확실하게! 명시적으로! 정의
    @Bean
    public MemberService memberService() {
    	return ...
    }
    ...
}

Spring 설정 파일(예시에서는 AppConfig)에 @Configuration 어노테이션을 추가하고, 빈을 지정할 때 @Bean 어노테이션을 함께 작성한다.

Dependency Injection

객체지향 프로그래밍을 위해서는 의존성을 외부에서 주입받는 것이 바람직하다.
특정 구현체에 종속되지 않고 기능 추가, 변경이 쉬워져 모듈의 결합도는 낮아지고 확장성이 증가하기 때문이다. (= 재사용, 유지보수하기 쉽다)
추가적으로 느슨한 결합을 구현할 수 있고 테스트를 하기도 쉬워지고... 많은 장점이 있다.

예시를 위해, GameUser가 게임을 시작할 때마다 메세지를 작성해야 한다고 하자.

public static class Game {
	private String gameName;
    
    public String getGameName() {
    	return "롤";
    }
}

public static class GameUser {
	private Game game;
    
	public GameUser() {
    	this.game = new Game();
    }
    
	public void startGame() {
    	System.out.println(game.getGameName() + "켰다.");
    }
}

이러면 롤이 아니고 롤체를 하고 싶으면 Game, GameUser 모든 클래스를 다 수정해야 한다! 이러한 상황을 방지하기 위해 의존성 주입을 하는 것이다.

의존성을 주입하기 위해서는 세 가지 방법을 사용할 수 있다.
우선, Game을 인터페이스로 정의해야 한다.

public interface Game {
    String getGameName();
}

이후 인터페이스를 구현하는 구현체 클래스를 만든다.

public static class LeagueOfLegends implements Game {
	@Override
    public String getGameName() {
    	return "롤";
    }
}

롤체를 하고 싶으면 구현체 클래스만 새롭게 정의해주면 된다. 부품화가 이루어진 것이다.

  1. 생성자(Constructor) 주입
    생성자를 호출 할 때 필요한 의존성을 모두 함께 설정

public class GameUserService {
	private final LeagueOfLegends lol;
    
    @Autowired
    public GameUserService(final LeagueOfLegends lol) {
    	this.lol = lol;
    }
}

객체의 불변성을 보장해주며, 생성할 때 필수이므로 필수적인 의존성(필드값) 보장 => 의존성 없는 객체 만들 수 없다.

  1. setter 주입
    setter에 인자로 작성

public class GameUserService {
	private final LeagueOfLegends lol;
    
    @Autowired
    public setGameUserService(final LeagueOfLegends lol) {
    	this.lol = lol;
    }
}

런타임 중에 의존성을 주입하기에 의존성이 없어도 객체가 생성될 수 있다. 선택적인 주입이 가능하나, 필수 보장은 하지 못 한다.

  1. 필드 주입
    필드로 주입

public class GameUserService {
	@Autowired
	private final LeagueOfLegends lol;
}

런타임에 주입하므로 의존성이 없어도 객체 생성이 가능하다.
코드가 깔끔하고 단순해지지만, 테스트 중에 의존성 주입이 힘들고 명시적으로 드러나지 않아 이해가 힘들고 Spring 컨테이너를 헷갈리게 해 순환 참조 문제가 발생할 수 있다.

순환 참조 문제는 두 개 이상의 객체가 서로를 직/간접적으로 참조해 무한 루프가 발생하는 상황을 의미한다. 메모리 누수나 런타임 에러를 발생시킬 수 있다.
직접 참조 대신 추상화 계층을 도입하거나 @Autowired 대신 생성자 주입을 사용하면 최대한 문제를 피할 수 있다.

Servlet

MVC 패턴에서 Controller에 해당하는 부분으로, 클라이언트의 요청을 처리하고 응답을 생성한다.

Servlet Container는 Servlet을 관리하는 것이다. (e.g., Apache Tomcat)
요청이 들어오면 컨테이너는 web.xml(servlet)을 기반으로 사용자가 요청한 url이 어느 서블릿에 대한 요청인지 확인하고
→ 인스턴스가 없으며 최초 요청인 경우 init()을 통해 인스턴스를 새로 생성
→ 서블릿의 코드 등이 변경되어 재배포 된 경우 기존 인스턴스를 destroy() 후 init()
→ 이미 인스턴스가 있는 경우 service()를 통해 doGet(), doPost()로 나뉘어 response 생성 (원래 네트워크 소통을 위해서는 socket, listen(), accept(), connect()를 일일히 구현해야 하지만 servlet이 제공하는 API를 통해 doGet()... 등만 사용해도 된다!)

Dispatcher Servlet

DispatcherServlet은 Spring에서 Front Controller 역할을 하는 서블릿이다. 프론트 컨테이너는 모든 클라이언트 요청을 하나의 진입점인 컨트롤러에서 받아 일관적으로 처리하는 구조이다.

Spring MVC에서 Dispatcher Servlet는 다음과 같은 순서로 동작한다.
1. 클라이언트에서 HTTP 요청이 온다.
2. HTTP 요청은 DispatcherServlet으로 전달된다.
3. HandlerMapping 객체를 사용하여 요청 URL에 따른 적합한 컨트롤러를 찾는다. (DispatcherServlet → HandlerMapping → Controller)
4. 컨트롤러를 실행하기 위해 적합한 HandlerAdapter를 선택해 비즈니스 로직을 수행하고, ModelAndView 객체를 반환한다. (Controller → ModelAndView)
5. ModelAndView에서 반환된 뷰 이름을 기반으로 적절한 뷰 객체를 찾고, 클라이언트에게 응답을 보낸다. (ModelAndView → ViewResolver → View → Client Response)

💥 미션 및 해결 과정

전통적인 서블릿 기반 개발과 Spring MVC의 차이는?

서블릿 기반 개발

서블릿 기반 개발은 HttpServlet 클래스를 상속 받아 doGet(), doPost() 메서드를 구현하여 요청을 처리하는 개발 방식을 말한다.

개발자가 요청을 직접 다루므로 요청 매핑, 데이터 바인딩, 응답 생성을 모두 수동으로 처리해야 한다. 수동으로 처리하는 만큼 서블릿에서 비슷한 로직이 반복될 가능성이 있으며, 추상화의 수준이 낮아진다. MVC 패턴을 구현하려면 개발자가 직접 컨트롤러, 모델, 뷰를 구성해야 해서 패턴의 구현이 어려워진다.

Spring MVC

Spring MVC는 서블릿을 기반으로 동작하지만, 추상화를 제공한다. DispatcherServlet으로 요청을 중앙에서 처리한다.

어노테이션을 사용해 자동화 된 요청을 처리하므로 요청 매핑, 데이터 바인딩을 간단하게 설정할 수 있다. 높은 추상화 수준을 유지할 수 있으며, MVC 패턴을 쉽게 구현할 수 있다. JSP, Thymeleaf 등의 뷰 기술을 지원하고 인터셉터와 필터를 통해 기능을 쉽게 확장할 수 있어 확장성이 좋다.

AOP (Aspect-Oriented Programming)?

AOP는 객체지향프로그래밍(OOP)를 보완하는 프로그래밍 패러다임으로, 여러 모듈에 걸쳐 공통적으로 발생하는 Cross-Cutting Concerns(횡단 관심사)를 분리하여 관리하는 기술이다. 기능을 단위로 하여 분리(수평적 분리)한다. 그러니까 여러 군데에서 공통적으로 사용되는 코드(로그 찍을 때나 보안 관련)를 분리하겠다는 것이다.

로깅을 위해 AOP를 사용한다고 해보자. 어느 메서드든 메서드가 실행되기 전에 로깅 코드가 실행되어야 하는데, 이와 같이 로깅 코드가 적용되는 지점을 JoinPoint라고 부르며, 로깅 코드는 Advice라고 부른다. Advice를 적용할 JoinPoint를 지정하는 표현식은 PointCut이라고 부른다. Aspect는 Advice와 PointCut을 조합한 모듈이며, Weaving은 Aspect를 핵심 코드에 통합하는 과정을 말한다.

Weaving(이하 위빙)은 컴파일 타임 위빙과 런타임 위빙, 클래스 로딩 타임 위빙이 있다. 이름에서도 알 수 있듯이, 위빙이 진행되는 시점에 따라 이름이 다르다.

컴파일 타임 위빙은 소스 코드 컴파일 시점에 위빙이 진행된다. 컴파일 시점에 직접 바이트코드를 수정하여 Advice 코드를 삽입하기에 프록시 생성, 메서드를 가로채는 과정이 없어 런타임 오버헤드가 없고 성능이 최적화된다. 하지만 Spring에서는 제공하지 않는 방식이라 AspcetJ나 Maven/Gradle 플러그인을 빌드 프로세스에 통합해야 해 빌드 프로세스가 복잡해지고 도구 의존성이 증가한다.

런타임 위빙은 프록시 객체를 사용한다. Spring 프레임워크의 일부인 Spring AOP가 사용하는 방식이다. 구성이 간단하고 동적으로 변경할 수 있으나, 프록시를 생성할 때 오버헤드가 존재한다.

Spring에서 사용하는 프록시는 JDK 동적 프록시와 CGLIB 프록시 두 가지가 있다.
인터페이스가 존재하면 JDK 동적 프록시를 사용하고, 구체 클래스를 사용하면 CGLIB 프록시를 사용한다. JDK 동적 프록시는 Reflection을 사용하기에 성능이 CGLIB 프록시에 비해 약간 떨어진다. CGLIB는 상속 방식을 사용해 오버라이딩 한다.
AOP가 진행되는 방식은 DispatcherServlet이 요청을 Handler에 요청하기 전, 프록시가 가로채서 Advice를 먼저 실행한 후 실제 객체의 메서드를 호출한다. 이렇게 메서드 실행 전 Advice를 실행할 수 있는 것이다.

💭 회고

기존에 진행했던 프로그램들에서는 로깅을 따로 코드를 작성하여 진행한 적이 없었는데, 만약 로깅 기능을 추가하게 된다면 AOP를 통해서 서비스 클래스의 메서드가 실행될 때마다 실행되도록 구현해보고 싶다.

@Around("execution(* com.project.service..*(..))")
public Object logServiceMethods(ProceedingJoinPoint joinPoint) throws Throwable {
    String methodName = joinPoint.getSignature().getName();
    log.info("API Service 메서드 시작: " + methodName);
    Object result = joinPoint.proceed();
    log.info("API Service 메서드 종료: " + methodName);
    return result;
}

이런 식으로...?!







- 컬렉션 아티클