우리가 자바(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)의 원리에 대해 다뤄보겠습니다.
'Framework > Spring Boot' 카테고리의 다른 글
| [Spring Boot] Spring Data JPA 쿼리메서드(QueryMethod)로 검색과 페이징을 구현하기 (0) | 2026.10.07 |
|---|---|
| [Spring Boot] JPAHibernate 기초 객체와 관계형 DB의 패러다임 불일치를 부수다 (0) | 2026.10.07 |
| [Spring Boot] 서버 로그(로깅) 완벽 가이드 (0) | 2026.10.06 |
| [Spring Boot] application.yml 완벽 가이드 (+ Profile 환경 분리) (0) | 2026.10.04 |
| [Spring Boot] Spring Initializr 실무 의존성 설계 가이드 (3) | 2026.10.03 |