Skip to main content
Summary

스프링은 객체를 대신 준비해 준다

이전 장에서 Spring Boot는 BoardApplication이 있는 기본 패키지와 그 하위 패키지를 살펴보며 컨트롤러, 서비스 같은 클래스를 찾는다고 했다. 이번 장에서는 그 다음 일을 이해한다.

스프링은 단순히 클래스를 찾기만 하지 않는다. 필요한 객체를 만들고, 그 객체들이 서로 사용할 수 있도록 연결해 둔다. 이 원리가 스프링의 핵심인 IoC와 DI다.

먼저 용어를 가볍게 잡고 가자.

  • IoC: Inversion of Control의 줄임말이다. 우리말로는 "제어의 역전"이라고 한다. 객체를 만들고 관리하는 주도권이 개발자 코드에서 스프링으로 넘어간다는 뜻이다.

  • DI: Dependency Injection의 줄임말이다. 우리말로는 "의존성 주입"이라고 한다. 어떤 객체가 필요한 다른 객체를 직접 만들지 않고, 외부에서 전달받는 방식이다.

둘은 완전히 따로 노는 개념이 아니다. 스프링이 객체 생성과 연결을 대신 관리하는 큰 흐름을 IoC라고 보고, 그중에서 필요한 객체를 밖에서 넣어 주는 방식을 DI라고 이해하면 된다.

처음에는 말이 어렵다. 식당으로 비유하면 쉽다. 내가 직접 재료를 사고, 손질하고, 요리까지 하면 모든 일을 내가 제어한다. 반대로 식당에 주문하면 요리는 식당이 준비하고 나는 완성된 음식을 받는다. 스프링도 비슷하다. 개발자가 매번 new로 객체를 직접 만들지 않아도, 스프링이 필요한 객체를 준비해서 건네준다.

스프링의 핵심 원리인 IoC와 DIAI 생성 이미지

직접 new로 만들면 무엇이 불편할까

먼저 스프링 없이 게시글 수를 조회하는 코드를 생각해 보자.

public class PostService {

    private final MemoryPostRepository postRepository = new MemoryPostRepository();

    public int countPosts() {
        return postRepository.count();
    }
}

PostService는 게시글과 관련된 기능을 처리하는 클래스다. 그리고 MemoryPostRepository는 게시글 데이터를 메모리에 저장한다고 가정하자.

이 코드는 작동할 수 있다. 하지만 문제가 있다.

첫째, PostService가 MemoryPostRepository를 직접 알고 있다. 나중에 메모리가 아니라 데이터베이스를 사용하는 JpaPostRepository로 바꾸고 싶다면 PostService 코드를 수정해야 한다.

둘째, 테스트하기 어렵다. 테스트에서는 진짜 저장소 대신 가짜 저장소를 넣어 빠르게 확인하고 싶을 때가 많다. 그런데 코드 안에서 이미 new MemoryPostRepository()를 해 버렸기 때문에 바꿔 끼우기 어렵다.

셋째, 객체가 많아질수록 생성 순서가 복잡해진다. 컨트롤러가 서비스를 만들고, 서비스가 리포지토리를 만들고, 리포지토리가 또 다른 객체를 만든다면 애플리케이션 전체가 얽힌 실타래처럼 된다.

이때 필요한 생각이 "필요한 객체를 직접 만들지 말고 밖에서 받자"이다.

public class PostService {

    private final PostRepository postRepository;

    public PostService(PostRepository postRepository) {
        this.postRepository = postRepository;
    }

    public int countPosts() {
        return postRepository.count();
    }
}

이제 PostService는 MemoryPostRepository를 직접 만들지 않는다. 대신 생성자를 통해 PostRepository를 전달받는다. 여기서 PostRepository는 인터페이스다.

public interface PostRepository {
    int count();
}

그리고 실제 구현 클래스는 이렇게 만들 수 있다. 예제 이해를 쉽게 하기 위해 List, ArrayList 같은 import 문은 생략했다.

public class MemoryPostRepository implements PostRepository {

    private final List<String> posts = new ArrayList<>();

    @Override
    public int count() {
        return posts.size();
    }
}

이 구조에서는 PostService가 구체적인 저장 방식을 몰라도 된다. 메모리 저장소를 쓰든, 데이터베이스 저장소를 쓰든 PostRepository라는 약속만 지키면 된다.

이처럼 어떤 클래스가 필요한 객체를 밖에서 전달받는 방식이 DI다.

Bean, ApplicationContext, Component Scan

스프링이 객체를 대신 만들고 연결하려면 세 가지 흐름을 알아야 한다.

첫째, Bean이다. Bean은 스프링이 관리하는 객체다. 일반 자바 객체와 생긴 것은 같다. 차이는 누가 관리하느냐에 있다. 우리가 new로 직접 만든 객체는 그냥 객체이고, 스프링이 만들고 보관하는 객체는 Bean이다.

둘째, ApplicationContext다. ApplicationContext는 스프링의 객체 보관함이자 관리자다. 초보자 입장에서는 "스프링이 만든 Bean을 담아 두는 큰 상자"라고 생각하면 된다. 애플리케이션이 실행될 때 스프링은 이 상자에 필요한 객체를 등록하고, 필요할 때 꺼내 연결한다.

셋째, Component Scan이다. Component Scan은 스프링이 특정 패키지 아래를 훑어보며 Bean으로 등록할 클래스를 찾는 과정이다. 이때 기준이 되는 표시가 애너테이션이다. 애너테이션은 코드에 붙이는 라벨이다.

대표적인 라벨은 다음과 같다.

@Component
public class SampleComponent {
}

@Component가 붙은 클래스는 "스프링이 이 클래스를 찾아서 객체로 만들어도 됩니다"라는 뜻이다.

전체 흐름은 다음과 같다.

  1. Spring Boot 애플리케이션이 실행된다.

  2. @SpringBootApplication이 있는 기본 패키지부터 하위 패키지를 탐색한다.

  3. @Component, @Service, @Repository, @Controller 등이 붙은 클래스를 찾는다.

  4. 찾은 클래스를 객체로 만들어 ApplicationContext에 Bean으로 등록한다.

  5. 어떤 Bean이 다른 Bean을 필요로 하면 생성자를 보고 자동으로 연결한다.

즉, 스프링이 하는 일은 "찾기 → 만들기 → 보관하기 → 연결하기"로 이해하면 된다.

역할에 따라 붙이는 애너테이션

@Component, @Service, @Repository, @Controller는 모두 스프링이 Bean으로 등록할 수 있게 하는 표시다. 다만 역할을 더 분명하게 표현하기 위해 이름을 나누어 쓴다.

@Component

가장 일반적인 컴포넌트 표시다. 특정 계층에 딱 들어맞지 않는 공통 기능 클래스에 사용할 수 있다.

@Component
public class TimeProvider {

    public LocalDateTime now() {
        return LocalDateTime.now();
    }
}

위 코드도 LocalDateTime import 문은 생략했다. 중요한 것은 @Component가 붙은 클래스를 스프링이 Bean 후보로 본다는 점이다.

@Service

비즈니스 로직을 처리하는 클래스에 붙인다. 비즈니스 로직은 애플리케이션의 실제 규칙이다. 예를 들어 "게시글을 작성한다", "삭제 권한을 확인한다", "조회 수를 증가시킨다" 같은 흐름이 여기에 들어간다.

@Service
public class PostService {

    private final PostRepository postRepository;

    public PostService(PostRepository postRepository) {
        this.postRepository = postRepository;
    }

    public int countPosts() {
        return postRepository.count();
    }
}

@Repository

데이터 저장소 역할을 하는 클래스에 붙인다. 지금은 메모리 저장소로 시작하지만, 나중에는 데이터베이스와 연결되는 JPA 리포지토리를 만들게 된다.

@Repository
public class MemoryPostRepository implements PostRepository {

    private final List<String> posts = new ArrayList<>();

    @Override
    public int count() {
        return posts.size();
    }
}

여기서는 MemoryPostRepository가 PostRepository 인터페이스의 실제 구현체다. 스프링은 이 클래스를 Bean으로 만들고, PostRepository가 필요한 곳에 넣어 줄 수 있다.

@Controller

웹 요청을 받는 클래스에 붙인다. 사용자의 HTTP 요청을 받아 서비스에 일을 맡기고, 결과를 응답으로 돌려준다.

@Controller
public class HomeController {
}

REST API처럼 문자열이나 JSON 데이터를 바로 응답할 때는 보통 @RestController를 사용한다. @RestController도 컨트롤러 역할을 하는 애너테이션이다. 조금 더 정확히 말하면, @Controller에 "응답 본문을 바로 돌려준다"는 성격이 더해진 형태라고 보면 된다.

@RestController
public class PostController {

    private final PostService postService;

    public PostController(PostService postService) {
        this.postService = postService;
    }

    @GetMapping("/posts/count")
    public String countPosts() {
        return "현재 게시글 수: " + postService.countPosts();
    }
}

여기서 중요한 점은 PostController가 new PostService()를 하지 않는다는 것이다. PostService도 new MemoryPostRepository()를 하지 않는다. 필요한 객체는 생성자를 통해 전달받고, 그 전달은 스프링이 처리한다.

생성자 주입을 권장하는 이유

DI에는 여러 방식이 있다. 그중 초보자에게 가장 먼저 권장하는 방식은 생성자 주입이다.

@Service
public class PostService {

    private final PostRepository postRepository;

    public PostService(PostRepository postRepository) {
        this.postRepository = postRepository;
    }
}

생성자 주입을 권장하는 이유는 분명하다.

첫째, 필요한 의존성이 한눈에 보인다. 생성자에 적힌 매개변수를 보면 PostService가 실행되기 위해 무엇이 필요한지 바로 알 수 있다.

둘째, final을 사용할 수 있다. final은 한 번 값이 들어가면 바꿀 수 없다는 뜻이다. 서비스가 실행 중간에 다른 리포지토리로 바뀌는 실수를 막아 준다.

셋째, 테스트하기 쉽다. 스프링 없이도 직접 가짜 구현체를 넣어 객체를 만들 수 있다.

PostRepository fakeRepository = new FakePostRepository();
PostService postService = new PostService(fakeRepository);

참고로 생성자가 하나뿐이라면 @Autowired를 생략해도 스프링이 자동으로 주입한다.

@Service
public class PostService {

    private final PostRepository postRepository;

    @Autowired // 생성자가 하나라면 생략 가능
    public PostService(PostRepository postRepository) {
        this.postRepository = postRepository;
    }
}

@Autowired는 "스프링아, 이 자리에 맞는 Bean을 넣어 줘"라는 뜻이다. 하지만 요즘 스프링 코드에서는 생성자가 하나일 때 @Autowired를 생략하는 형태를 자주 사용한다.

필드 주입은 왜 피할까

다음과 같은 코드를 볼 수도 있다.

@Service
public class PostService {

    @Autowired
    private PostRepository postRepository;
}

이 방식은 필드 주입이다. 코드가 짧아 보여서 편해 보이지만, 초보자일수록 피하는 것이 좋다.

필드 주입은 필요한 의존성이 생성자에 드러나지 않는다. 객체를 만들 때 무엇이 필요한지 숨겨진다. 또 final을 사용할 수 없어 값이 반드시 들어와야 한다는 조건을 코드로 강하게 표현하기 어렵다. 테스트할 때도 직접 객체를 만들고 의존성을 넣기가 불편하다.

그래서 이 책에서는 기본 원칙을 이렇게 잡는다.

스프링 Bean끼리 연결할 때는 생성자 주입을 사용한다.

짧은 코드보다 중요한 것은 읽기 쉽고 안전한 구조다.

자주 만나는 오류: Bean을 찾지 못했습니다

DI를 배우기 시작하면 다음과 비슷한 오류를 만날 수 있다.

Parameter 0 of constructor in ... required a bean of type ... that could not be found.

뜻은 단순하다. "생성자에 넣어야 하는 객체가 필요한데, 스프링이 그 Bean을 찾지 못했다"는 말이다.

주요 원인은 다음과 같다.

  • 클래스에 @Service, @Repository, @Component 같은 애너테이션을 붙이지 않았다.

  • 클래스가 BoardApplication이 있는 기본 패키지 바깥에 있다.

  • 인터페이스는 주입받고 있는데 실제 구현 클래스가 Bean으로 등록되어 있지 않다.

처음에는 대부분 첫 번째나 두 번째 문제다. 클래스에 애너테이션이 붙어 있는지, 패키지 위치가 올바른지 먼저 확인하자.

비슷하게, 같은 인터페이스의 구현체가 여러 개라 스프링이 무엇을 넣어야 할지 고르지 못하는 경우도 있다. 이때는 "Bean이 없다"기보다는 "후보가 여러 개라 하나를 고르지 못했다"는 쪽에 가깝다. 지금 단계에서는 이런 상황이 많지 않지만, 나중에 같은 인터페이스를 구현한 클래스가 둘 이상 생기면 다시 만나게 된다.

미니 프로젝트에 적용해 보기

게시판 API 프로젝트에서는 앞으로 다음 흐름을 계속 사용한다.

Controller → Service → Repository

컨트롤러는 요청을 받는다. 서비스는 핵심 규칙을 처리한다. 리포지토리는 데이터를 저장하거나 조회한다. 그리고 이 객체들은 서로 직접 new로 만들지 않는다. 각 클래스에 역할에 맞는 애너테이션을 붙이고, 생성자를 통해 필요한 객체를 받는다.

예를 들어 현재 구조는 이렇게 볼 수 있다.

@RestController
public class PostController {

    private final PostService postService;

    public PostController(PostService postService) {
        this.postService = postService;
    }
}
@Service
public class PostService {

    private final PostRepository postRepository;

    public PostService(PostRepository postRepository) {
        this.postRepository = postRepository;
    }
}
@Repository
public class MemoryPostRepository implements PostRepository {
}

이 코드의 핵심은 "각자 자기 역할만 알고 있다"는 점이다. 컨트롤러는 저장 방식에 관심이 없다. 서비스는 웹 요청 처리 방법에 관심이 없다. 리포지토리는 HTTP가 무엇인지 몰라도 된다.

스프링은 이 객체들을 Bean으로 만들고, ApplicationContext에 보관하고, 생성자를 통해 연결한다. 이것이 IoC와 DI가 실제 코드에서 하는 일이다.

정리

IoC는 객체 생성과 관리의 주도권이 스프링으로 넘어가는 구조다. DI는 필요한 객체를 직접 만들지 않고 외부에서 전달받는 방식이다.

스프링은 Component Scan으로 클래스를 찾고, Bean으로 만들고, ApplicationContext에 보관한다. 그리고 생성자를 보고 필요한 Bean을 자동으로 연결한다.

@Component, @Service, @Repository, @Controller는 모두 Bean 등록 대상이라는 공통점이 있다. 차이는 역할 표현이다. 공통 기능은 @Component, 비즈니스 로직은 @Service, 데이터 접근은 @Repository, 웹 요청 처리는 @Controller 또는 @RestController를 사용한다.

앞으로 작성할 게시판 API 코드는 이 원칙 위에 쌓인다. 직접 new로 얽는 코드보다, 역할을 나누고 스프링이 연결하게 하는 코드가 더 유연하고 테스트하기 쉽다.

책 목록

초보자를 위한 자바 스프링 사용법

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기