본문 바로가기
UMC스터디

Spring Boot의 핵심 개념 정리

by devnewr1n 2025. 4. 8.

 

의존성 주입(DI)이란?


| 의존성 주입이 왜 필요한가 (강한 결합, 약한 결합)

우선 의존성 주입이 왜 필요한 지 알려면, 강한 결합과 약한 결합이 무엇인지부터 알아야 한다.

티비라는 객체를 만들어서 사용한다 생각해보자.

TV tv = new TV();
tv.powerOn(); //강한 결합

위와 같이 코드를 짜면, 매번 TV 켜고 싶을 때마다 직접 티비의 버튼을 눌러야 한다.

즉 TV가 바뀌면 버튼도 바뀌므로 코드를 다 뜯어고쳐야한다 >> 강한결합

Remote remote = new SamsungRemote();// 리모콘 종류는 바뀌어도
TV tv = new TV(remote);  // TV는 똑같이 동작!
tv.pressPowerButton();

이번에는 위와 같이 코드를 바꿔서, 바깥에서 TV를 켤 수 있는 리모콘을 넣어준다.

여기는 리모콘만 바꾸면 TV 내부는 안 고쳐도 된다 >> 약한 결합

즉 강하게 연결된 코드는 바꿀 때 다 부숴야 하지만, 느슨하게 연결된 코드(의존성 주입)은 그냥 갈아끼우면 된다. 따라서 관리하기가 편하다는 장점이 있다. 특히 DB를 바꾼다던가, 외부 api를 쓴다던가, 테스트를 자동화한다던가 이러한 변화가 있을 때 코드 확장성이 있다는 점에서 장점이 있다.

 

| 그래서 의존성 주입이 뭔데?

▼ 의존성 주입을 하지 않은 코드

public class OrderService {
    private final PaymentService paymentService;

    public OrderService() {
        this.paymentService = new PaymentService(); // 직접 생성
    }
}

의존성 주입을 하지 않으면 OrderService 클래스 안에서 PaymentService 객체를 직접 생성해야 한다. 이렇게 된다면 너무 단단히 엮여있어서(강한 결합) 교체가 어렵고 확장에 불편함이 생긴다.

 

이러한 강한 결합을 느슨하게 하기 위해서, 외부에서 객체 paymentService를 매개변수로 넣어주는 것이다.

즉 의존성 주입은, 여기서 OrderService가 PaymentService를 필요로 할 때, 이 paymentService를 직접 만들지 않는 것이다.
대신 생성자의 매개변수로 받아서 필드에 저장한다(생성자 주입의 경우).

즉, 의존성을 외부에서 "주입" 받게 된다,

 

의존성 = 내가 필요로 하는 다른 객체

@Service
public class UserService {

    private UserRepository userRepository;
    private MemberService memberService;

    @Autowired
    public UserService(UserRepository userRepository, MemberService memberService) {
        this.userRepository = userRepository;
        this.memberService = memberService;
    }
    
}

위 코드에서 UserService는 UserRepository 와 MemberService가 꼭 필요하다. 즉 UserService는 UserRepository 와 MemberService에 의존하고 있는 것이다. 즉 UserService의 의존성은 UserRepository 와 MemberService인 것인데, 이 의존성을 바깥에서 넣어주는 것이 의존성 주입이다. 위 코드를 보면 알 수 있듯이 UserService가 직접 new로 객체를 만들지 않았고, 대신 스프링이 바깥에서 넣어준 것이다. 내가 필요한 걸 내가 만드는 것(new를 쓰기)은 의존성 주입이 아니고, @Autowired을 통해서 "나 얘네 필요해요!"라고 요청만 하는 것이 의존성 주입이다(만드는건 스프링이 알아서).

한마디로, "의존성 주입"은 내가 필요로 하는 객체(의존성)을 내가 직접 만들지 않고, 외부 스프링이 대신 만들어서 넣어주는 것이다. 

저 위의 UserRepository나 MemberService 같은 객체들은 Spring에서 @Component나 @Service로 등록하게 되고, 이 등록된 빈들은 내부에서 new로 객체를 생성해준다. 스프링은 생성자에 필요한 타입을 보고 자동으로 이 객체들을 주입해주는 것이다.

 

| 스프링 없이 수동 주입하는 경우와의 차이점

그러면 이렇게 생각할 수도 있다. 그럼 그냥 의존성 주입은, 생성자 만들 때 외부 매개변수 두는 거랑 똑같은 거 아닌가?

public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
}

public class Main {
    public static void main(String[] args) {
        PaymentService paymentService = new PaymentService(); // 직접 생성
        OrderService orderService = new OrderService(paymentService); // 생성자에 수동으로 주입

        orderService.order();
    }
}

위 코드처럼 스프링에서 @autowired, @service같은 빈 등록 없이 그냥 생성자에 외부 매개변수를 두는 방법도 있다. 다만 이렇게 하면 나중에 저렇게 main함수같은 곳에서 orderService 객체를 만들 때, paymentService를 직접 내가 new로 객체 생성을 해서 수동으로 넣어주어야 한다. 스프링의 의존성 주입을 하게 되면 내가 매개변수 객체를 직접 생성해서 수동으로 주입하지 않아도 된다는 게 큰 차이점이다.

 

| 의존성 주입의 종류

  1. 생성자 주입

@Service
public class UserService {
    private final UserRepository userRepository;

    @Autowired
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
}

가장 흔한 방법으로, 말 그대로 생성자에 의존성을 넣어주는 방법이다. 생성자에 의존성을 넣기 때문에, 객체가 생성될 때 딱 한번 주입된다(원래 객체가 생성될 때 생성자가 한번 패시브로 실행되니까). 또한 final를 붙이기 때문에 변경이 불가능해져서 객체의 불변성(immunability)를 보장해준다. 

또한 새로운 UserService 객체를만들 때, 필수적인 의존성을 보장하기 때문에, 의존성이 없는 객체를 만들 수가 없다. 무슨 뜻이냐면, new UserService(); 처럼 매개변수 없이는 못 만들고 new UserService(userRepository); 이렇게 무조건 필드값을 넣어야 하기 때문에 필수적 의존성이 생긴다는 것이다. UserService 객체를 새로 만들기 위해서는 userRepository라는 객체가 또 필요하니까 의존성(내가 필요로하는 객체)가 필수적으로 생긴다.

보통 생성자 주입이 가장 많이 권장되는 방법이다.

 

  2. Setter 주입

@Service
public class UserService {
    private UserRepository userRepository;

    @Autowired
    public void setUserRepository(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
}

Setter 주입은 객체가 먼저 만들어지고, 나중에 setter 메소드의 인자로 주입하는 것이다. 이는 생성자 주입처럼 필수가 아니기 때문에 선택적인 의존성을 넣을 때 적합하다(생성자처럼 객체를 만들면 자동으로 실행되는 게 아니라, 메소드니까 호출 안하면 실행X).

Setter 주입이 생성자 주입보다 더 유연성은 있겠지만, 실수로 의존성을 주입 안해도 실행 되어버릴 수 있다는 점에서 오류 발생 위험이 있다. 

public class Burger {
    private Sauce sauce;

    @Autowired
    public void setSauce(Sauce sauce) {
        this.sauce = sauce;
    }

    public void eat() {
        System.out.println(sauce.getTaste());  // ❗ sauce가 null이면 에러!
    }
}

무슨 말이냐면, 내가 위의 코드처럼 햄버거 클래스를 만들고 소스 setter 메소드를 통해 의존성 주입을 했다고 치자. 하지만 내가 만약 setter를 통해 sauce의 값을 정해주지 않았다면, sauce는 null값인데, 이때 sauce.getTaste();로 메소드에 접근하면 null을 참조하게 되면서 NullPointerException 에러가 터지는 것이다. 결국 세터 주입은 sauce가 없어도 프로그램은 돌아가니까, 나중에 코드 실행하다가 갑자기 에러가 날 수 있다는 점에서 단점이 있다. 차라리 생성자 주입처럼 애초에 무조건 인자를 넣어줘야지만 객체를 생성할 수 있는 경우에는 이런 일이 안 일어나는데, 이 setter 주입은 객체를 먼저 그냥 생성할 수 있기 때문에 결국 이런 일이 일어나는 것이다. 이렇듯 강제성이 없다는 점이 단점이 될 수 있겠다.

 

  3. 필드 주입

@Service
public class MemberSignupService {
	@Autowired
	private MemberRepository memberRepository;
	
}

필드에 주입하는 방법은, 가장 간단하지만 의존성이 언제 어떻게 들어오는지 명확하지 않다는 점에서 잘 쓰이지는 않는 편이다. 필드에 private으로 직접 주입되기 때문에 테스트 중에 의존성을 주입하는 것(객체를 넣는 것)이 어렵다. 

 

 

  • 생성자 주입은 객체 생성 시 바로 주입 → "의존성 없으면 객체 못 만들어" → 에러 빨리 찾기 쉬움
  • 세터/필드 주입은 런타임 중에 주입 → "객체는 만들어졌는데 의존성이 빠졌음" → 에러 늦게 터짐

 

IoC(Inversion of Control)란?


Ioc, Inversion of Control"제어의 역전"이라는 의미이다. 제어의 역전은 말 그대로, 원래는 내가 하던 걸 이제는 다른 애가 대신 해준다는 뜻이다. 즉 메소드, 객체의 호출 작업을 개발자가 결정하는 것이 아니라, 외부에서 결정되는 것을 말한다. 객체를 직접 만들지 않고, 의존성도 자동으로 넣어준다.

 이 구조를 구현하는 도구가 바로 스프링 IoC 컨테이너이다. 스프링의 IoC 컨테이너는 말 그대로 '컨테이너', 즉 무언가(Bean들)을 담는 상자라는 의미이다. IoC 컨테이너는 Bean들을 만들고, 필요한 곳에 주입해주는 역할을 한다. 이 컨테이너 덕분에 우리는 객체를 만들고 연결하는 코드를 줄이고, 비지니스 로직에만 집중할 수 있다.

 

| IoC 컨테이너의 종류

1. BeanFactory

가장 기본적인 IoC 컨테이너 인터페이스이다. Bean을 관리하는 역할을 하고, 지연 로딩 방식으로써 실제로 빈이 필요할 때 만들어진다.  @Autowired, AOP, 이벤트처리 모두 안된다.

 

2. ApplicationContext

스프링에서 가장 널리 쓰이는 Ioc 컨테이너로, BeanFactory를 상속받고 있기 때문에 BeanFactory의 기능을 포함하여 더 많은 부가기능을 쓸 수 있다. BeanFactory와 달리 즉시 로딩 방식으로, 애플리케이션이 시작할 때 대부분의 빈이 미리 만들어지게 된다. @ComponentScan, @Autowired, @Service 등이 다 여기서 가능하다.

 

| IoC와 DI의 관계

IoC와 DI는 비슷한 개념처럼 느껴져서, 둘을 헷갈릴 수도 있을 것 같다. DI는 IoC를 구현하는 하나의 방법으로, DI < IoC 이렇게 보면 될 것 같다. 즉 IoC는 큰 원칙이고, DI(의존성 주입)은 그 원칙을 구현하기 위한 방법·세부기술이다.

 

 

프레임워크와 API의 차이는?


| 프레임워크란?

프레임워크에서  frame은 "틀"이란 뜻이고, work은 "일하다"라는 뜻이다. 이것을 합쳐보면 "틀을 가지고 일하다"가 된다. 즉 프레임워크는 일정한 틀과 뼈대를 가지고 일하다라는 뜻으로 제공받은 일정한 요소와 틀, 규약을 가지고 무언가를 만드는 일이다. 개발자는 그 틀 안에서 룰을 지키면서 코드를 짜야 한다. 프레임워크의 대표적인 예로는 Spring, Django, node.js 등이 있다.

 

| API란?

API는 I가 interface의 약자인만큼, 서로 상호 작용할 수 있도록 하는 것이 핵심이다. 두 개 이상의 소프트웨어 컴포넌트 사이에서 상호 작용할 수 있도록 정의된 인터페이스로, 필요한 부분을 요청하여 응답받는 서비스 간의 다리와 같은 역할을 한다. api를 통해 미리 만들어 놓은 기능들을 개발자가 필요할 때 직접 호출해서 가져다가 사용할 수 있다. System, StringBuffer, Math 이런 클래스들이 모두 자바의 API이다.

 

| 둘의 차이점?

둘의 차이점은, 프레임워크는 프레임워크가 개발 흐름을 제어하고(제어의 역전) 사용자는 필요한 부분만 채워 넣는 반면, API는 흐름 제어를 전부 사용자가 한다는 점에서 다르다. 즉 주도권이 프레임워크는 프레임워크 중심으로, API는 개발자 중심으로 가있는 것이다.

가구를 만드는 것으로 쉽게 비유를 하자면, 프레임워크는 공방 시스템 자체이다. 자동화된 공방 시스템에서 일정한 규칙에 따라 의자를 만드는 것으로, 틀 안에 맞춰서 내가 정해진 부분만 참여하고 나머지 부분은 시스템이 자동으로 처리해준다. API는 내가 "의자 다리 하나 만들어줘! 색은 파랑색, 다리는 철제로" 라고 요청하면 외부 공장이 이 요청을 받고, 가구를 만들어서, 나에게 다시 보내주는 주문제작 시스템으로 비유할 수 있을 것 같다.

 

AOP란?


AOP는 Aspect Oriented Programming의 약자로, 관점 지향 프로그래밍이라고 불린다. 관점 지향은 관심사를 어떤 로직을 기준으로 핵심적인 관점, 부가적인 관점으로 나누어서 보고 그 관점을 기준으로 각각 모듈화하겠다는 의미이다. 간단히 말하면 관심사를 분리해서 프로그래밍하는 방식이라 할 수 있다.

| 여기서 "관심사"란 무엇일까?

우리가 코드를 짤 때 진짜 중요한 핵심 로직이 있고, 거기에 자꾸 따라붙는 공통된 반복 작업들이 있다.

예를 들어 웹 애플리케이션에서:

회원가입 → 회원 저장
로그인 → 세션 저장
게시글 작성 → DB에 저장

이런 게 핵심 로직이라면, 이 코드에 자주 끼어드는 것들:

로그 출력
실행 시간 측정
트랜잭션 시작/종료
보안 체크

이런 건 핵심은 아닌데 공통적으로 자주 등장하기 때문에, 이런 것들을 핵심 로직과 분리해서 따로 처리하자는 것이 AOP이다.

쉬운 비유를 들자면, 의자를 만들 때마다 품질 검사, 사진 촬영, 스티커 붙이기를 반복해야 할 때, 이걸 모든 조립 과정 안에 직접 넣으면 코드가 난잡해진다. 따라서 조립이 끝날 때마다 자동으로 실행되는 로봇을 붙여서 깔끔하게 처리하는 것이 AOP 방식인 것이다.

Spring의 AOP는, Spring이 특정 메서드 실행 전후로 부가 작업을 끼워넣을 수 있게 해준다. 스프링에서는 일반적으로 사용하는 클래스(Service, Dao 등)에서 중복되는 공통 코드 부분(commit, rollback, log처리)을 별도의 영역으로 분리해 내고, 코드가 시행 되기 전이나 이 후의 시점에 해당 코드를 붙여 넣음으로써 소스 코드의 중복을 줄이고, 필요할 때다 가져다 쓸 수 있게 객체화 한다.

 

스프링에서는 AOP를 구현하기 위해, 프록시를 활용한다. 스프링이 @Component, @Service 같은 걸 보고 Bean으로 등록할 때,
AOP 처리가 필요하다면, 프록시 객체를 대신 만들어서 IoC 컨테이너에 넣어준다. 내가 만든 객체 앞에 '프록시 객체'를 자동으로 생성하는 것이다. 이렇게 만들어진 프록시 객체의 내부에는 실제 객체를 가지고 있고, 이 안에 @Before, @After 같은 부가 기능을 끼워넣을 수 있다.

사용자 → userService.createUser("철수") 호출
    ↓
실제로는 [프록시 객체]가 먼저 받음
    ↓
프록시가 [Before Advice 실행]
    ↓
프록시가 진짜 객체인 [UserService.createUser()] 실행
    ↓
프록시가 [After Advice 실행]

 

내가 만든 객체를 통해 메소드를 호출하면, 실제로는 프록시 객체가 먼저 받는다. 

즉 프록시는 진짜 객체 앞에 서서, 요청을 가로채고, 필요한 추가 작업을 해주는 대리인이다.

| AOP 핵심 용어 정리

Aspect 공통 기능을 모아놓은 모듈 로그 찍기, 트랜잭션 관리
Join Point 실행 가능한 지점 메서드 호출 시점 등
Advice 언제 무슨 일을 할지 @Before, @After, @Around
Pointcut 어떤 JoinPoint에 적용할지 execution(* com.service.*(..))
Weaving 부가 기능을 엮는 행위 프록시 객체로 엮는 것
Target Object 실제 핵심 기능이 있는 객체 UserService, OrderService 등
Proxy AOP 기능을 담은 객체 Advice를 끼워넣은 wrapper

 

 

| Advice 종류 정리

@Before 메서드 실행 로그 찍기, 권한 체크 등
@After 메서드 실행 로그 남기기
@AfterReturning 정상 실행 후 성공 결과 처리
@AfterThrowing 예외 발생 시 에러 로깅
@Around 실행 전후 모두 가장 강력함! 직접 실행까지 조절 가능

어드바이스는 관점의 동작을 구체적으로 정의한 것이다. 어드바이스는 핵심 로직의 특정 시점에 실행될 수 있다.

 

 

서블릿(Servlet)이란?


서블릿이란, 클라이언트의 요청을 처리하고 그 결과를 반환하는 Servlet 클래스의 구현 규칙을 지킨 자바 웹 프로그래밍기술을 말한다. 즉 클라이언트가 어떤 요청을 하면, 서버 쪽에서 처리한 다음, 그에 대한 결과를 다시 전송해주는 자바 프로그램인 것이다.

서블릿이 그냥 돌아가는 것은 아니고, 서블릿을 실행시키려면 특별한 프로그램인 WAS가 필요하다. 대표적으로 서블릿을 실행할 수 있는 WAS는 톰캣(Tomcat)이 있고, WAS는 동적인 요청을 받았다면 서블릿에게 전달해서 처리한다.

[ 브라우저 ]
     ↓
[ 웹 서버 (Nginx, Apache) ]
     ↓ (동적 요청이면)
[ WAS (Tomcat) ]
     ↓
[ 서블릿 ] → 자바 코드로 로직 처리 → HTML 응답

| 서블릿의 구체적인 동작 과정

  1. 클라이언트의 요청이 들어오면, 서블릿 컨테이너는 web.xml을 기반으로 사용자가 요청한 URL이 어느 서블릿에 대한 요청인지 찾는다.
  2. 해당 서블릿이 메모리에 없을 경우 init()을 통해 생성하고, 서블릿이 변경되었을 경우 destroy() 후 init()을 통해 새로운 내용을 적재한다.
  3. 서블릿이 있는 경우 service() 메소드를 호출한 후, 클라이언트의 GET, POST 여부에 따라 요청에 대한 응답이 doGet(), doPost()으로 나뉘어 response가 생성된다.
  4. 컨테이너가 서블릿을 종료시킬 때에는 destroy()를 통해 종료한다. (자바의 가비지컬렉션 이용)

 

| 스프링과 서블릿의 관계?

스프링도 사실은 서블릿 위에 얹어진 프레임워크이다. 우리가 @SpringBootApplication만 쓰고 실행할 때, 그 안에서 자동으로 DispatcherServlet이라는 서블릿을 등록해주는 과정이 있다. DispatcherServlet은 요청을 받고 해당 HTTP Request를 처리할 수 있는 적합한 Controller을 찾는다. 

 

  1. 클라이언트가 요청을 보냄 (예: /users)
  2. 톰캣(WAS)이 이 요청을 받음
  3. 스프링이 등록해놓은 DispatcherServlet이 요청을 받음
  4. DispatcherServlet은 URL과 매핑된 컨트롤러를 찾아서 실행함
  5. 컨트롤러가 처리하고 → 결과를 DispatcherServlet에 반환
  6. DispatcherServlet이 응답을 만들어서 다시 클라이언트에 돌려줌
  7. ViewResolver를 통해 렌더링(html, json의 형태로 전달) 
[브라우저 요청]
   ↓
DispatcherServlet
   ↓
HandlerMapping → 어떤 컨트롤러가 처리해야 해?
   ↓
HandlerAdapter → 어떻게 실행할까?
   ↓
Controller (직접 요청 처리)
   ↓
응답 내용 (JSON, HTML 등)
   ↓
ViewResolver or HttpMessageConverter
   ↓
DispatcherServlet이 브라우저에 응답 반환

 

 

'UMC스터디' 카테고리의 다른 글

JPQL과 QueryDSL  (1) 2025.06.13
도메인 설계와 N+1 문제  (0) 2025.06.13
SQL 기본 정리  (0) 2025.03.27
서버 관련 공부  (0) 2025.03.21
TCP/IP 4계층 모델을 활용한 홈페이지 접속 방법 설명  (0) 2025.03.21