본문 바로가기
Framework/Spring Boot

[Spring Boot] 의존성 주입DI과 IoC 컨테이너 객체의 생성 소멸 자동화

우리가 자바(Java) 학원에서 처음 프로그래밍을 배울 때, 어떤 클래스의 기능을 사용하려면 무조건 new 키워드를 사용해 메모리에 객체를 찍어내야 한다고 배웠습니다. 예를 들어 UserService에서 UserRepository가 필요하면 UserRepository repository = new UserRepository(); 처럼 직접 생성했습니다. 하지만 실무 스프링 부트(Spring Boot) 프로젝트 코드를 아무리 뒤져봐도 new 키워드를 찾아보기는 하늘의 별 따기입니다.
개발자가 객체를 직접 만들지 않는데 도대체 프로그램이 어떻게 굴러가는 걸까요? 이 거대한 미스터리를 푸는 열쇠이자, 전 세계 수많은 자바 개발자들을 스프링의 노예(?)로 만들어버린 가장 위대하고 핵심적인 철학이 바로 IoC(Inversion of Control, 제어의 역전)와 DI(Dependency Injection, 의존성 주입)입니다.

1. IoC (제어의 역전): 주도권을 스프링에게 뺏기다

과거의 자바 개발자들은 프로그램의 제어권(Control)을 온전히 자신이 쥐고 있었습니다. 언제 객체를 생성할지(new), 언제 삭제할지 개발자가 직접 통제했습니다. 하지만 스프링 프레임워크를 도입하는 순간, 이 제어권은 '개발자'에서 '스프링'으로 통째로 넘어갑니다. 이것이 바로 제어의 역전(Inversion of Control)입니다.

 

스프링 부트가 실행될 때, 스프링은 프로젝트 패키지들을 싹 다 뒤져서 @Controller, @Service, @Repository, @Component 등 특별한 어노테이션이 붙은 클래스들을 찾습니다. 그리고 개발자 대신 자기가 직접 new를 호출하여 객체를 생성한 뒤, 거대한 'IoC 컨테이너(또는 ApplicationContext)'라는 바구니에 담아서 관리합니다. 이 바구니에 담긴 객체들을 우리는 특별히 빈(Bean)이라고 부릅니다. 보통 빈은 메모리 낭비를 막기 위해 단 1개만 생성되어 돌려 쓰는 싱글톤(Singleton) 패턴으로 관리됩니다.

 

2. DI (의존성 주입): 바구니에서 꺼내서 꽂아주다

제어권이 넘어갔으니, 이제 A 클래스에서 B 클래스가 필요할 때 A 클래스가 직접 new B()를 할 수 없습니다. 대신 스프링에게 "나 B 객체 좀 필요한데, 네 바구니에 있으면 나한테 줘!"라고 요청(의존)해야 합니다. 그러면 스프링이 바구니에서 B 객체(Bean)를 꺼내서 A 클래스 안에 쏙 주입(Injection)해 줍니다. 이것이 바로 DI입니다.

 

3. 실무 DI 3가지 방법 (생성자 주입이 진리인 이유)

스프링에서 의존성을 주입받는 방법은 크게 3가지가 있습니다. 하지만 실무에서는 무조건 인 생성자 주입(Constructor Injection)만을 사용해야 합니다.

1. 필드 주입 (Field Injection) - 절대 금지</p

@Service
public class UserService {
    @Autowired // 코드가 아주 짧아서 과거에 유행했으나, 지금은 최악의 안티 패턴입니다.
    private UserRepository userRepository;
}

이 방식은 코드가 짧아 편하지만, 외부에서 userRepository를 변경할 방법이 원천 차단됩니다. 따라서 순수한 자바 코드로 단위 테스트(Unit Test)를 작성할 때 모의 객체(Mock)를 집어넣을 수가 없어서 테스트가 박살 납니다.

2. Setter 주입 (Setter Injection) - 사용 안함

setUserRepository() 메서드에 @Autowired를 붙이는 방식입니다. 외부에서 값을 바꿀 수는 있지만, 누군가 실수로 런타임 중에 Setter를 호출해버리면 시스템 구조가 무너지는 치명적 결함이 있습니다.

3. 생성자 주입 (Constructor Injection) - 실무 표준

@Service
public class UserService {
    
    // final 키워드를 붙일 수 있어서 불변성(Immutability)이 보장됩니다.
    private final UserRepository userRepository;

    // 스프링 4.3부터 생성자가 단 1개뿐이면 @Autowired를 생략해도 스프링이 알아서 주입해 줍니다!
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
}

생성자 주입의 가장 강력한 장점은 '안전성'입니다. final 키워드를 쓸 수 있어서 앱이 실행된 이후에 누군가 실수로 객체를 바꿔치기할 수 없습니다. 또한 테스트 코드를 짤 때 new UserService(mockRepository) 처럼 내가 원하는 가짜 객체를 쉽게 집어넣을 수 있습니다.

 

4. Lombok의 @RequiredArgsConstructor 마법

위의 생성자 주입 코드가 완벽하긴 한데, 의존해야 할 객체가 10개로 늘어나면 생성자 코드가 30줄이 넘어가는 지옥이 펼쳐집니다. 이때 롬복(Lombok)이 구원투수로 등장합니다.

@Service
@RequiredArgsConstructor //  final이 붙은 필드들을 모아서 자동으로 생성자를 만들어주는 궁극의 마법!
public class UserService {
    
    // 이제 개발자는 그냥 final 선언만 툭툭 던져놓으면 DI 세팅이 끝납니다.
    private final UserRepository userRepository;
    private final EmailService emailService;
    private final PasswordEncoder passwordEncoder;
    
    // 눈에는 보이지 않지만, 롬복이 생성자 코드를 알아서 짜서 스프링에게 넘겼습니다.
}

 

5. 마무리 및 주요 발전 방향

지금까지 자바의 핵심 철학인 결합도(Coupling)를 극도로 낮추어 부품을 쉽게 갈아 끼울 수 있게 만들고, 유지보수와 테스트를 천국으로 이끄는 스프링의 궁극기, IoC 컨테이너와 생성자 기반 DI(의존성 주입)에 대해 다루어 보았습니다. 이제 여러분은 빈(Bean)과 생명주기를 완벽히 통제할 수 있습니다.

객체 간의 통신이 완벽해졌으니 이제 서비스를 운영할 차례입니다. 그런데 어떤 악성 유저가 회원가입 창에서 숫자만 들어가야 할 나이 칸에 "안녕"이라는 문자를 넣거나, 존재하지 않는 상품 아이디를 URL로 찔러대서 시스템 내부 깊은 곳에서 NullPointerException이 터져버렸습니다. 스프링 부트는 기본적으로 하얀색의 끔찍한 "Whitelabel Error Page"를 사용자에게 뱉어냅니다. 이는 실무에서 절대 용납될 수 없는 아마추어의 증거입니다! 본 설명에서는 "모든 에러를 한 곳으로 빨아들여 예쁜 JSON 에러 포맷으로 클라이언트에게 반환한다!" @RestControllerAdvice를 활용한 전역 예외 처리(Global Exception Handling) 완벽 실무 가이드에 대해 아주 뼈 때리게 파헤쳐 보겠습니다!


우리가 정성스럽게 만든 REST API 서버가 실제 세상에 배포되는 순간, 상상을 초월하는 사용자(Client)들을 만나게 됩니다. 숫자가 들어가야 할 나이 필드에 '스물다섯'이라는 문자를 던지거나, 존재하지 않는 user_id=999를 집요하게 요청하는 등 무수한 공격과 실수가 쏟아집니다. 이때 서버 내부에서 NullPointerException이나 IllegalArgumentException 같은 자바 에러가 터지면 어떻게 될까요? 스프링 부트(Spring Boot)는 아무 설정을 안 하면 시뻘건 스택 트레이스(Stack Trace) 로그와 함께 아주 끔찍하고 못생긴 "Whitelabel Error Page"나 알 수 없는 HTML 덩어리를 응답으로 뱉어냅니다. 만약 프론트엔드(React, Vue) 개발자가 JSON 응답을 기다리다가 저런 HTML 에러 페이지를 받게 되면 즉시 슬랙(Slack)으로 욕설(?)이 날아올 것입니다.

@RestControllerAdvice 전역 예외 처리 아키텍처에 대하여 다뤄보겠습니다.

1. try-catch 지옥의 도래

에러를 처리하라고 하면 초보 개발자들은 컨트롤러의 모든 메서드에 try-catch 문을 도배하기 시작합니다.

@GetMapping("/users/{id}")
public ResponseEntity<?> getUser(@PathVariable Long id) {
    try {
        User user = userService.findById(id);
        return ResponseEntity.ok(user);
    } catch (UserNotFoundException e) {
        return ResponseEntity.status(404).body("유저를 찾을 수 없어요.");
    } catch (IllegalArgumentException e) {
        return ResponseEntity.status(400).body("잘못된 값입니다.");
    } catch (Exception e) {
        return ResponseEntity.status(500).body("서버 펑!");
    }
}

컨트롤러 API가 100개라면 100곳에 똑같은 try-catch를 써야 합니다. 비즈니스 로직(1줄)보다 에러 잡는 코드(10줄)가 더 길어지는 심각한 배보다 배꼽이 더 큰 상황, 즉 중복 코드의 지옥이 펼쳐집니다.


2. 구원자: @RestControllerAdvice의 등장

스프링 프레임워크는 AOP(관점 지향 프로그래밍) 기술을 활용해 이 지옥을 끝내버렸습니다. "컨트롤러에서는 try-catch를 싹 다 지우고 마음 편히 에러를 던져라(throw)! 내가 밖에서 그물망을 치고 있다가 에러가 떨어지면 다 낚아채서 일괄 처리해 줄게!" 이것이 바로 @RestControllerAdvice입니다.

: 프론트엔드와 약속할 공통 에러 규격(DTO) 만들기

@Getter
@AllArgsConstructor
public class ErrorResponse {
    private final String errorCode;    // 예: "USER_NOT_FOUND"
    private final String errorMessage; // 예: "해당 ID의 유저가 없습니다."
    private final int status;          // 예: 404
}

: 전역 예외 처리기(GlobalExceptionHandler) 작성하기

@Slf4j
@RestControllerAdvice //  전 프로젝트의 모든 컨트롤러에서 발생하는 예외를 감지합니다.
public class GlobalExceptionHandler {

    // 1. 우리가 직접 만든 비즈니스 예외 처리 (예: 유저 없음)
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
        log.warn("UserNotFoundException 발생 : {}", e.getMessage());
        ErrorResponse response = new ErrorResponse("USER_NOT_FOUND", e.getMessage(), 404);
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
    }

    // 2. 클라이언트가 파라미터를 잘못 보냈을 때 (예: 타입 불일치)
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
        log.warn("잘못된 요청 파라미터 : {}", e.getMessage());
        ErrorResponse response = new ErrorResponse("INVALID_INPUT", "요청 파라미터가 잘못되었습니다.", 400);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
    }

    // 3. 누군가 뚫고 들어온 예상치 못한 모든 최상위 에러 처리 (500 에러)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAllUncaughtException(Exception e) {
        log.error(" 예상치 못한 치명적 서버 에러 발생!", e); // 서버 에러는 로그를 빡세게(ERROR) 남겨야 함
        ErrorResponse response = new ErrorResponse("INTERNAL_SERVER_ERROR", "서버 내부에 알 수 없는 문제가 발생했습니다.", 500);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
    }
}

3. 컨트롤러의 놀라운 변화 (클린 코드)

이제 위의 글로벌 핸들러를 켜두고 다시 컨트롤러 코드를 보겠습니다.

@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
    // try-catch가 싹 다 사라졌습니다! 오직 행복한 성공 경로(Happy Path) 코드만 남습니다.
    // 만약 서비스 로직 안에서 throw new UserNotFoundException() 이 터지면?
    // 여기서 안 잡고 바로 튕겨나가서 @RestControllerAdvice가 낚아챕니다.
    User user = userService.findById(id);
    return ResponseEntity.ok(user);
}

이 얼마나 아름답고 가독성이 뛰어난 코드입니까! 비즈니스 로직과 에러 처리 로직이 완벽하게 분리(관심사의 분리)된 실무 최적화 아키텍처입니다.


4. 마무리 및 주요 발전 방향

지금까지 지저분한 try-catch 도배를 걷어내고, 서버 내의 모든 에러를 한 곳에서 통제하여 프론트엔드 개발자에게 규격화되고 친절한 JSON 응답을 내려주는 마법, @RestControllerAdvice를 활용한 예외 처리 고도화 전략에 대해 다루어 보았습니다. 이제 여러분의 서버는 어떤 외부 공격이나 에러에도 당황하지 않고 예쁜 JSON을 뱉어낼 것입니다.

웹 요청을 받고 응답을 내리는 껍데기 세팅은 이제 실무 수준으로 완벽해졌습니다.
이제 진짜 데이터베이스(DB)와 통신하며 알맹이를 채울 차례입니다. 과거 MyBatis 시절에는 자바 개발자인지 SQL 쿼리 작성 기계인지 헷갈릴 정도로 긴 쿼리를 짰습니다. 하지만 이제 시대가 바뀌었습니다.

 

다음 글에서는 자바 객체(Object)를 만들면 스프링이 알아서 DB 테이블과 매핑하고 SQL을 실행하는 JPA(Hibernate)의 기본 개념과 영속성 컨텍스트(Persistence Context)의 원리에 대해 다뤄보겠습니다.