https://daniellee09.tistory.com/56
[SKALA] 4주차 학습 정리 ②: Spring Boot 설정 관리와 MVC·Actuator 정리
Configuration, Profile부터 Spring MVC 요청 처리와 운영 모니터링까지이전 글에서는 객체지향 프로그래밍과 REST API, Spring Boot의 기본 구조, IoC와 의존성 주입을 정리했다.이번에는 Spring Boot 애플리케이
daniellee09.tistory.com

Bean 등록부터 Lombok과 입력값 검증까지
이전 글에서는 Configuration과 Profile, Spring MVC의 요청 처리 구조, Controller–Service–Repository 계층, Actuator를 이용한 운영 모니터링까지 정리했다.
이번에는 Spring이 객체를 어떻게 찾아 Bean으로 등록하는지, 등록한 객체를 어떻게 관리하고 주입하는지, 그리고 HTTP 요청 데이터를 Controller가 어떤 방식으로 전달받는지를 구체적으로 다룬다.
후반부에는 반복적인 Java 코드를 줄이는 Lombok과 클라이언트의 잘못된 데이터를 사전에 차단하는 입력값 검증도 등장한다.
전체 흐름은 다음과 같다.
컴포넌트 스캔
→ Spring Bean 자동 등록
→ Spring 컨테이너의 Bean 관리
→ IoC와 DI로 객체 연결
→ Controller에서 HTTP 요청 바인딩
→ Lombok으로 반복 코드 감소
→ Validation으로 잘못된 입력 차단
1. 컴포넌트 스캔과 DTO
Spring을 사용하지 않는 일반 Java 프로그램에서는 필요한 객체를 직접 생성한다.
HelloService helloService = new HelloService();
하지만 Spring Boot에서는 @Component, @Service, @Repository, @Controller 등의 어노테이션을 이용해 객체를 Spring이 직접 생성하고 관리하도록 만들 수 있다.
이를 가능하게 하는 기능이 컴포넌트 스캔이다.
컴포넌트 스캔이란?
컴포넌트 스캔은 지정된 패키지와 하위 패키지를 탐색하여 특정 어노테이션이 붙은 클래스를 찾고, 해당 클래스를 Spring Bean으로 등록하는 기능이다.
애플리케이션 실행
→ 컴포넌트 스캔
→ 스테레오타입 어노테이션 탐색
→ 객체 생성
→ Spring Bean 등록
Spring Boot에서는 기본적으로 @SpringBootApplication이 선언된 클래스가 위치한 패키지와 그 하위 패키지를 탐색한다.
예를 들어 다음과 같은 패키지 구조가 있다고 하자.
com.example.shop
├─ ShopApplication.java
├─ controller
│ └─ ProductController.java
├─ service
│ └─ ProductService.java
└─ repository
└─ ProductRepository.java
ShopApplication이 com.example.shop 패키지에 있다면 아래의 controller, service, repository 패키지는 자동으로 스캔된다.
반면 메인 클래스보다 상위나 전혀 다른 패키지에 클래스를 배치하면 자동으로 검색되지 않을 수 있다.
@SpringBootApplication(
scanBasePackages = {
"com.example.shop",
"com.example.common"
}
)
public class ShopApplication {
}
스캔 범위를 직접 지정하는 것도 가능하지만, 일반적으로는 메인 클래스를 프로젝트의 최상위 패키지에 두는 것이 가장 단순하다.
@SpringBootApplication의 내부 구성
@SpringBootApplication은 하나의 기능만 가진 어노테이션이 아니다.
주요 기능을 묶은 복합 어노테이션이다.
@SpringBootApplication
=
@SpringBootConfiguration
+ @EnableAutoConfiguration
+ @ComponentScan
| 구성 | 역할 |
| @SpringBootConfiguration | Spring Boot 설정 클래스임을 표시 |
| @EnableAutoConfiguration | 클래스패스와 환경을 기반으로 자동 설정 |
| @ComponentScan | 현재 패키지와 하위 패키지의 Bean 탐색 |
즉, 애플리케이션이 실행되면 자동 설정과 컴포넌트 스캔이 함께 진행된다.
스테레오타입 어노테이션
컴포넌트 스캔이 찾는 대표적인 어노테이션은 다음과 같다.
| 어노테이션 | 역할 |
| @Component | 일반적인 Spring Bean |
| @Controller | HTML View를 반환하는 웹 Controller |
| @RestController | JSON 등을 반환하는 REST Controller |
| @Service | 비즈니스 로직 담당 |
| @Repository | 데이터 접근 담당 |
| @Configuration | Java 기반 설정 클래스 |
이들을 스테레오타입 어노테이션이라고 한다.
모두 Bean으로 등록된다는 점은 같지만, 어떤 계층과 책임을 가진 클래스인지 명확하게 표현한다.
@RestController
public class HelloController {
}
@Service
public class HelloService {
}
@Repository
public class HelloRepository {
}
기능적으로는 @Component를 붙여도 Bean으로 등록되지만, 계층에 맞는 어노테이션을 사용하면 클래스의 책임이 명확해진다.
@RestController와 JSON 응답
@RestController는 다음 두 어노테이션을 합친 것이다.
@RestController
=
@Controller
+ @ResponseBody
Controller 메서드가 Java 객체를 반환하면 해당 객체가 HTTP 응답 본문으로 들어간다.
@RestController
public class HelloController {
@GetMapping("/hello")
public Map<String, String> hello() {
return Map.of(
"message",
"SKALA에 오신 것을 환영합니다."
);
}
}
응답은 다음과 같은 JSON으로 변환된다.
{
"message": "SKALA에 오신 것을 환영합니다."
}
이 과정에서는 Jackson과 HttpMessageConverter가 Java 객체를 JSON으로 직렬화한다.
Map 대신 DTO를 사용하는 이유
간단한 응답은 Map으로 만들 수 있다.
Map<String, String> response = new HashMap<>();
response.put("message", "안녕하세요.");
하지만 데이터의 구조가 커지면 Key 이름의 오타를 컴파일 단계에서 찾기 어렵고, 어떤 데이터가 들어가는지 명확하게 알기 어렵다.
이럴 때 DTO를 사용한다.
public class HelloResponse {
private String message;
public HelloResponse() {
}
public HelloResponse(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
@RestController
public class HelloController {
@GetMapping("/hello")
public HelloResponse hello() {
return new HelloResponse(
"SKALA에 오신 것을 환영합니다."
);
}
}
DTO는 계층 사이 또는 서버와 클라이언트 사이에서 데이터를 전달하기 위한 객체다.
Entity
→ DB와 연결되는 도메인 객체
Request DTO
→ 클라이언트 요청을 받는 객체
Response DTO
→ 클라이언트에 응답할 객체
DTO 작성 시 주의할 점
DTO는 데이터를 전달하는 상자에 가깝다.
따라서 가격 계산이나 DB 조회와 같은 비즈니스 로직은 DTO에 넣지 않는 것이 좋다.
// 좋지 않은 예
public class ProductResponse {
private int price;
public int calculateDiscountPrice() {
return price * 90 / 100;
}
}
할인 계산은 Service나 도메인 객체에서 처리해야 한다.
또한 JPA Entity를 DTO 대신 직접 사용하는 것도 피하는 것이 좋다.
Entity를 API 응답에 직접 노출하면 다음 문제가 발생할 수 있다.
- 비밀번호 등 원하지 않는 필드 노출
- DB 구조 변경이 API 응답 변경으로 연결
- 양방향 연관관계 직렬화 중 무한 반복
- 지연 로딩으로 인한 예외나 추가 쿼리
- 요청용 필드와 응답용 필드가 섞임
따라서 목적에 맞게 DTO를 분리한다.
ProductCreateRequest
ProductUpdateRequest
ProductResponse
ProductDetailResponse
입력값 검증 어노테이션은 Request DTO에 두어 유효하지 않은 값이 Service까지 넘어가지 않도록 차단하는 것이 일반적이다.
2. Spring 컨테이너와 Bean 생명주기
컴포넌트 스캔이 클래스를 찾았다면, 실제 객체 생성과 관리는 Spring 컨테이너가 담당한다.
Spring 컨테이너란?

Spring 컨테이너는 Bean의 생성, 설정, 의존성 주입, 초기화와 소멸까지 전체 생명주기를 관리한다.
Bean 클래스 탐색
→ 객체 생성
→ 의존성 주입
→ 초기화
→ 애플리케이션에서 사용
→ 종료 시 자원 정리
대표적인 컨테이너 인터페이스는 BeanFactory와 ApplicationContext다.
| 구분 | BeanFactory | ApplicationContext |
| 역할 | 기본적인 Bean 생성과 조회 | BeanFactory 기능과 부가 기능 |
| 생성 방식 | 필요할 때 생성 가능 | 기본적으로 싱글턴 Bean을 시작 시 생성 |
| 부가 기능 | 제한적 | 이벤트, 국제화, AOP, 환경설정 등 |
| 사용 빈도 | 거의 직접 사용하지 않음 | 일반적인 Spring 애플리케이션에서 사용 |
실제 Spring Boot 프로젝트에서는 대부분 ApplicationContext가 컨테이너 역할을 담당한다.
Bean이란?
Bean은 Spring 컨테이너가 생성하고 관리하는 객체다.
일반 Java 객체
→ 개발자가 new로 생성
Spring Bean
→ Spring 컨테이너가 생성하고 관리
@Service
public class ProductService {
}
ProductService는 컴포넌트 스캔을 통해 발견되고, Spring 컨테이너가 객체를 생성한다.
개발자는 필요할 때 직접 new ProductService()를 호출하지 않고 의존성 주입을 통해 Bean을 전달받는다.
기본 Bean Scope는 Singleton
Spring Bean의 기본 Scope는 Singleton이다.
이는 하나의 Spring 컨테이너 안에서 동일한 Bean 객체를 하나만 생성하고 여러 곳에서 공유한다는 뜻이다.
ProductService Bean
→ 한 번 생성
→ Controller A에서 사용
→ Controller B에서도 같은 객체 사용
장점은 다음과 같다.
- 반복적인 객체 생성 감소
- 메모리 사용 감소
- 객체 생성 비용 감소
- 동일한 인스턴스를 일관되게 사용
다만 여기서 중요한 점은 Singleton Bean에 요청별 변경 상태를 저장하면 안 된다는 것이다.
@Service
public class BadOrderService {
private String currentUserId;
}
여러 사용자가 같은 Service 객체를 공유하기 때문에 currentUserId처럼 요청마다 달라지는 값을 필드에 저장하면 다른 사용자의 요청과 충돌할 수 있다.
Spring의 Singleton Bean은 가능한 한 상태를 가지지 않는 Stateless 구조로 설계하는 것이 안전하다.
Bean 생명주기
Bean은 대략 다음 과정을 거친다.
객체 생성
→ 의존성 주입
→ 초기화 Callback
→ 실제 사용
→ 소멸 Callback
초기화와 종료 시점에 작업이 필요하면 @PostConstruct와 @PreDestroy를 사용할 수 있다.
@Component
public class LifecycleBean {
public LifecycleBean() {
System.out.println("Bean 생성");
}
@PostConstruct
public void init() {
System.out.println("Bean 초기화");
}
@PreDestroy
public void destroy() {
System.out.println("Bean 소멸 직전");
}
}
실행 순서는 다음과 같다.
생성자
→ 의존성 주입
→ @PostConstruct
→ 애플리케이션에서 사용
→ @PreDestroy
@PostConstruct는 의존성 주입까지 완료된 다음 실행되므로 외부 연결이나 초기 데이터 준비 등에 사용할 수 있다.
@PreDestroy는 애플리케이션 종료 전에 호출되며 파일, 네트워크 연결, Thread Pool 등 사용 중인 자원을 정리하는 데 활용할 수 있다.
3. IoC와 DI 다시 이해하기
Spring 컨테이너의 역할을 이해하면 IoC와 DI의 관계도 더 명확해진다.
IoC란?
IoC는 Inversion of Control, 즉 제어의 역전이다.
일반 Java에서는 개발자가 객체를 직접 만들고 연결한다.
StockRepository repository =
new StockRepository();
StockService service =
new StockService(repository);
이때 객체 생성과 연결의 제어권은 개발자에게 있다.
Spring에서는 컨테이너가 객체를 만들고 필요한 곳에 연결한다.
@Service
public class StockService {
private final StockRepository repository;
public StockService(
StockRepository repository
) {
this.repository = repository;
}
}
기존 방식
→ 개발자가 객체 생성과 연결
IoC 적용
→ 컨테이너가 객체 생성과 연결
IoC는 객체의 생성과 생명주기, 의존관계의 제어권을 개발자에게서 프레임워크로 넘기는 원리다.
DI란?
DI는 Dependency Injection, 즉 의존성 주입이다.
객체가 필요한 다른 객체를 직접 생성하지 않고 외부에서 전달받는 방식이다.
IoC
→ 제어권을 컨테이너에 위임하는 큰 원리
DI
→ 컨테이너가 의존 객체를 넣어 주는 구현 방법
DI를 적용하면 구현체를 교체하거나 테스트용 객체를 전달하기 쉬워진다.
생성자 주입
가장 권장되는 방식이다.
@Service
public class StockService {
private final StockRepository repository;
public StockService(
StockRepository repository
) {
this.repository = repository;
}
}
생성자 주입에서는 final을 사용할 수 있다.
객체 생성 시 의존성 전달
→ 한 번 초기화
→ 이후 변경 불가
주요 장점은 다음과 같다.
- 필수 의존성 보장
- 불변성 확보
- 테스트에서 Mock 또는 Fake 객체 전달 가능
- 어떤 의존성이 필요한지 생성자에 명확하게 표시
- 순환 참조를 애플리케이션 시작 시점에 발견
필드 주입
@Service
public class StockService {
@Autowired
private StockRepository repository;
}
코드는 짧지만 다음 단점이 있다.
- final을 사용할 수 없음
- Spring 컨테이너 없이 객체를 만들기 어려움
- 단위 테스트에서 의존성을 직접 전달하기 불편함
- 필요한 의존성이 클래스 외부에서 잘 드러나지 않음
간단한 실습에서는 볼 수 있지만 일반적인 애플리케이션 코드에는 생성자 주입이 더 적합하다.
Setter 주입
@Service
public class StockService {
private StockRepository repository;
@Autowired
public void setRepository(
StockRepository repository
) {
this.repository = repository;
}
}
Setter 주입은 선택적으로 변경될 수 있는 의존성에 사용할 수 있다.
하지만 필수 의존성이라면 객체 생성 시점에 반드시 전달되는 생성자 주입이 더 안전하다.
자료에서도 불변성, 필수 의존성 보장, 테스트 용이성과 순환 참조 조기 감지를 이유로 생성자 주입을 권장한다.
순환 참조
다음과 같이 두 Service가 서로를 필요로 한다고 가정해 보자.
@Service
public class AService {
private final BService bService;
public AService(BService bService) {
this.bService = bService;
}
}
@Service
public class BService {
private final AService aService;
public BService(AService aService) {
this.aService = aService;
}
}
A를 만들려면 B가 필요하고, B를 만들려면 다시 A가 필요하다.
A 생성
→ B 필요
→ B 생성
→ A 필요
→ 생성 불가능
Spring은 애플리케이션 시작 시 순환 참조를 감지하고 오류를 발생시킨다.
순환 참조는 단순히 어노테이션 설정의 문제가 아니라 역할 분리가 잘못되었다는 신호일 가능성이 크다.
해결하려면 다음을 검토해야 한다.
- 두 Service의 책임을 다시 나누기
- 공통 로직을 별도 Service로 분리
- 직접 호출 대신 이벤트 사용
- 인터페이스를 통한 의존 방향 재설계
순환 참조는 비즈니스 설계를 다시 점검해야 한다는 신호이며, 의존 구조를 리팩터링해야 한다고 설명한다.
동일 타입 Bean이 여러 개라면
하나의 인터페이스에 여러 구현체가 있을 수 있다.
public interface PaymentGateway {
void pay(int amount);
}
@Component
@Primary
public class KakaoPay
implements PaymentGateway {
}
@Component("naverPay")
public class NaverPay
implements PaymentGateway {
}
기본 구현체는 @Primary로 지정할 수 있다.
@Service
public class OrderService {
private final PaymentGateway gateway;
public OrderService(
PaymentGateway gateway
) {
this.gateway = gateway;
}
}
특정 구현체가 필요하다면 @Qualifier를 사용한다.
public OrderService(
@Qualifier("naverPay")
PaymentGateway gateway
) {
this.gateway = gateway;
}
@Primary
→ 기본 Bean 지정
@Qualifier
→ 이름으로 특정 Bean 지정
4. Controller와 HTTP 요청 데이터 바인딩
REST API Controller는 단순히 URL만 받는 것이 아니다.
클라이언트는 다양한 위치에 데이터를 담아 보낼 수 있다.
URL 경로
Query String
Request Body
Form Data
HTTP Header
Cookie
Spring MVC는 이러한 요청 데이터를 Controller 메서드의 파라미터로 변환해 준다.
이를 Parameter Binding이라고 한다.
대표적인 바인딩 어노테이션은 다음과 같다.
어노테이션받는 데이터
| @PathVariable | URL 경로 값 |
| @RequestParam | Query String, Form Parameter |
| @RequestBody | JSON, XML 형태의 요청 본문 |
| @ModelAttribute | Form Data나 Query Parameter를 객체로 변환 |
| @RequestHeader | HTTP Header |
| @CookieValue | Cookie |
HTTP Method와 Mapping 어노테이션
| GET | @GetMapping | 조회 |
| POST | @PostMapping | 생성 |
| PUT | @PutMapping | 전체 수정 |
| PATCH | @PatchMapping | 일부 수정 |
| DELETE | @DeleteMapping | 삭제 |
@RestController
@RequestMapping("/api/products")
public class ProductController {
@GetMapping
public List<ProductResponse> findAll() {
return List.of();
}
@PostMapping
public ProductResponse create() {
return null;
}
@DeleteMapping("/{id}")
public void delete(
@PathVariable Long id
) {
}
}
@PathVariable
URL 경로에 포함된 값을 받는다.
GET /api/products/10
@GetMapping("/{id}")
public ProductResponse findById(
@PathVariable Long id
) {
return productService.findById(id);
}
메서드 파라미터 이름과 경로 변수 이름이 같다면 어노테이션의 이름을 생략할 수 있다.
@PathVariable Long id
이름이 다르면 명시해야 한다.
@GetMapping("/{productId}")
public ProductResponse findById(
@PathVariable("productId") Long id
) {
return productService.findById(id);
}
@RequestParam
Query String이나 Form Parameter를 받는다.
GET /api/products?keyword=mouse&page=0
@GetMapping
public List<ProductResponse> search(
@RequestParam String keyword,
@RequestParam(defaultValue = "0") int page
) {
return productService.search(
keyword,
page
);
}
값이 선택 사항이라면 다음처럼 작성한다.
@RequestParam(required = false)
String category
또는 기본값을 설정할 수 있다.
@RequestParam(defaultValue = "all")
String category
배열과 List 파라미터
동일한 Query Parameter를 여러 번 전달할 수 있다.
/search?category=java
&category=spring
&category=boot
@GetMapping("/search")
public List<String> search(
@RequestParam List<String> category
) {
return category;
}
콤마로 구분한 값을 전달해도 List로 변환할 수 있다.
/search?category=java,spring,boot
Spring은 기본적으로 콤마로 구분된 문자열을 배열이나 List로 변환할 수 있다.
@RequestBody
HTTP Body에 담긴 JSON을 Java 객체로 변환한다.
POST /api/products
Content-Type: application/json
{
"name": "무선 마우스",
"price": 15000
}
public record ProductCreateRequest(
String name,
int price
) {
}
@PostMapping
public ProductResponse create(
@RequestBody ProductCreateRequest request
) {
return productService.create(request);
}
Jackson이 JSON의 Key와 Java 객체의 필드를 연결한다.
JSON
→ HttpMessageConverter
→ ProductCreateRequest 객체
@RequestBody는 주로 POST, PUT, PATCH 요청에서 사용한다.
@ModelAttribute
HTML Form이나 Query Parameter를 Java 객체에 묶어서 전달한다.
<form action="/users/form" method="post">
<input name="name">
<input name="age">
</form>
public class UserForm {
private String name;
private int age;
// getter, setter
}
@PostMapping("/users/form")
public String register(
@ModelAttribute UserForm form
) {
return "success";
}
name 속성과 객체의 필드명이 같으면 자동으로 바인딩된다.
Form name="name"
→ UserForm.name
Form name="age"
→ UserForm.age
JSON 요청에는 @RequestBody, 일반적인 Form Data에는 @ModelAttribute를 사용한다고 이해하면 된다.
@RequestHeader
HTTP Header 값을 받는다.
GET /api/users/me
Authorization: Bearer eyJhbGci...
@GetMapping("/me")
public UserResponse getMe(
@RequestHeader("Authorization")
String authorization
) {
return userService.findCurrentUser(
authorization
);
}
인증 토큰, User-Agent, 사용자 정의 Header 등을 읽을 때 사용할 수 있다.
@CookieValue
Cookie 값을 받는다.
@GetMapping("/visit")
public String visit(
@CookieValue("visitTime")
String visitTime
) {
return visitTime;
}
Cookie 기반 세션이나 사용자 설정값 등을 조회할 때 사용할 수 있다.
간단한 요청 바인딩 예제
다음 API를 생각해 보자.
POST /courses/홍길동
?topics=Spring,Java,AI
경로의 사용자 이름은 @PathVariable, 관심 분야는 @RequestParam으로 받는다.
@RestController
public class CourseController {
private final CourseService courseService;
public CourseController(
CourseService courseService
) {
this.courseService = courseService;
}
@PostMapping("/courses/{name}")
public CourseResponse createCourse(
@PathVariable String name,
@RequestParam List<String> topics
) {
return courseService.createCourse(
name,
topics
);
}
}
@Service
public class CourseService {
public CourseResponse createCourse(
String name,
List<String> topics
) {
String description = String.format(
"%s님이 관심 있는 분야: %s",
name,
String.join(", ", topics)
);
return new CourseResponse(
name,
topics,
description
);
}
}
public record CourseResponse(
String name,
List<String> topics,
String description
) {
}
응답은 다음과 같다.
{
"name": "홍길동",
"topics": [
"Spring",
"Java",
"AI"
],
"description": "홍길동님이 관심 있는 분야: Spring, Java, AI"
}
자료에서도 경로 변수와 List Query Parameter를 함께 전달하고, Service에서 응답 DTO를 생성하는 흐름으로 실습한다.
5. Lombok과 입력값 검증
Lombok이란?
Java에서는 객체 하나를 만들 때도 반복적인 코드가 많이 필요하다.
Getter
Setter
생성자
toString
equals
hashCode
이처럼 핵심 비즈니스 로직과 직접적인 관계는 없지만 반복적으로 작성해야 하는 코드를 Boilerplate Code라고 한다.
Lombok은 어노테이션을 이용해 이러한 코드를 컴파일 과정에서 자동 생성하는 라이브러리다.
주요 Lombok 어노테이션
| 어노테이션 | 역할 |
| @Getter | Getter 생성 |
| @Setter | Setter 생성 |
| @ToString | toString() 생성 |
| @EqualsAndHashCode | equals(), hashCode() 생성 |
| @NoArgsConstructor | 기본 생성자 생성 |
| @AllArgsConstructor | 전체 필드 생성자 |
| @RequiredArgsConstructor | final, @NonNull 필드 생성자 |
| @Data | Getter, Setter, toString 등 묶음 |
| @Builder | Builder Pattern 생성 |
| @Value | 불변 객체 생성 |
| @Slf4j | Logger 필드 생성 |
@Data
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Product {
private Long id;
private String name;
private int price;
}
다음과 같은 코드를 자동 생성한다.
- Getter
- Setter
- toString()
- equals()
- hashCode()
- Required Args Constructor
다만 @Data는 모든 필드에 Setter를 생성하므로, 값 변경을 제한해야 하는 Entity나 도메인 객체에서는 신중히 사용해야 한다.
DTO처럼 단순한 데이터 전달 객체에서는 편리하지만, 도메인 객체에는 필요한 어노테이션만 선택해 사용하는 편이 안전하다.
@RequiredArgsConstructor
생성자 주입 코드를 줄이는 데 자주 사용한다.
기존 코드는 다음과 같다.
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(
ProductRepository repository
) {
this.repository = repository;
}
}
Lombok을 사용하면 다음처럼 줄일 수 있다.
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository repository;
}
final 필드를 받는 생성자가 자동으로 생성되며, Spring은 생성자를 통해 Repository를 주입한다.
@Builder
@Builder
public class Product {
private Long id;
private String name;
private int price;
}
Product product = Product.builder()
.id(1L)
.name("무선 마우스")
.price(15_000)
.build();
생성자 파라미터의 순서를 기억할 필요가 없고, 어떤 필드에 어떤 값을 넣는지 명확하다.
@Value
Lombok의 @Value는 불변 객체를 만든다.
Spring의 설정값 주입 어노테이션인 org.springframework.beans.factory.annotation.Value와 이름은 같지만 서로 다른 어노테이션이다.
@Value
public class Stock {
String symbol;
String name;
int price;
}
자동으로 다음 특징을 갖는다.
모든 필드 private final
Setter 생성 안 됨
전체 생성자 생성
Getter 생성
equals, hashCode, toString 생성
객체를 생성한 후 값을 변경할 수 없다.
@Slf4j와 로그
@Slf4j
@Service
public class ProductService {
public void save(Product product) {
log.info(
"상품 저장: {}",
product.getName()
);
}
}
@Slf4j를 붙이면 다음 Logger 필드가 자동 생성된다.
private static final Logger log =
LoggerFactory.getLogger(ProductService.class);
System.out.println() 대신 Logging Framework를 사용하면 로그 레벨, 출력 형식, 파일 저장 등을 제어할 수 있다.
로그 레벨
TRACE
→ 가장 상세한 실행 흐름
DEBUG
→ 개발과 디버깅 정보
INFO
→ 서비스의 주요 동작
WARN
→ 잠재적인 문제
ERROR
→ 정상 동작을 방해하는 오류
| 레벨 | 사용 예 |
| TRACE | 메서드 내부의 세부 단계 |
| DEBUG | 파라미터, 중간 계산 결과 |
| INFO | 로그인, 주문 생성, 서비스 시작 |
| WARN | 재시도, 지원 중단 예정 기능 |
| ERROR | 예외 발생, DB 연결 실패 |
Controller에서는 요청 방식과 URI를 INFO로 남길 수 있다.
log.info(
"[REQUEST] method={}, uri={}",
request.getMethod(),
request.getRequestURI()
);
Service에서는 중요한 비즈니스 작업을 기록한다.
log.info(
"주문 생성 시작. userId={}",
userId
);
민감한 정보는 로그에 그대로 남기면 안 된다.
비밀번호
JWT
API Key
주민등록번호
카드번호
개인정보
요청 DTO의 toString()에 민감정보가 포함되어 있으면 DEBUG 로그에도 유출될 수 있으므로 주의해야 한다.
입력값 검증이 필요한 이유
클라이언트가 항상 정상적인 데이터를 보내지는 않는다.
{
"name": "",
"price": -1000,
"sellerEmail": "wrong-email"
}
검증 없이 Service와 DB까지 전달되면 다음 문제가 생길 수 있다.
- 예기치 않은 예외
- 잘못된 데이터 저장
- 비즈니스 규칙 위반
- 보안 취약점
- 데이터 신뢰성 저하
입력값 검증은 외부에서 들어온 값이 정의된 규칙을 만족하는지 확인하는 과정이다.
Validation 의존성
Gradle에서는 다음 의존성을 추가한다.
implementation(
'org.springframework.boot:spring-boot-starter-validation'
)
Maven에서는 다음과 같다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>
spring-boot-starter-validation
</artifactId>
</dependency>
Request DTO 검증
public record ProductCreateRequest(
@NotBlank(
message = "상품명은 필수입니다."
)
@Size(
min = 2,
max = 100,
message = "상품명은 2~100자여야 합니다."
)
String name,
@PositiveOrZero(
message = "가격은 0 이상이어야 합니다."
)
int price,
@Email(
message = "이메일 형식이 올바르지 않습니다."
)
String sellerEmail
) {
}
Controller에서는 @Valid를 붙인다.
@PostMapping
public ProductResponse create(
@Valid
@RequestBody
ProductCreateRequest request
) {
return productService.create(request);
}
처리 흐름은 다음과 같다.
JSON 요청
→ Request DTO 변환
→ Validation 실행
→ 검증 성공
→ Controller 메서드 실행
→ 검증 실패
→ 예외 발생
→ Service 호출 차단
@Valid와 @Validated
@Valid
Jakarta Bean Validation의 표준 어노테이션이다.
- 일반적인 객체 검증에 사용
- 기본 검증 그룹 사용
- Request DTO 검증에 대부분 충분
public ProductResponse create(
@Valid @RequestBody ProductCreateRequest request
) {
}
@Validated
Spring이 제공하는 확장 어노테이션이다.
- Validation Group 지원
- 클래스와 메서드 단위 검증
- Controller나 Service의 메서드 파라미터 검증
- Spring AOP를 이용한 검증
@RestController
@Validated
public class ProductController {
@GetMapping
public ProductResponse find(
@Min(1) @RequestParam Long id
) {
return productService.findById(id);
}
}
자료에서는 일반적인 DTO 검증은 @Valid로 충분하며, 상황별 그룹 검증이나 메서드 수준 검증에는 @Validated를 사용할 수 있다고 설명한다.
바인딩 위치에 따른 검증 예외
| 입력 방식 | 대표 예외 |
| @RequestBody | MethodArgumentNotValidException |
| @ModelAttribute | BindException |
| @RequestParam 등 메서드 파라미터 | ConstraintViolationException |
이 예외들은 이후 전역 예외 처리기를 통해 일관된 JSON 오류 응답으로 변환할 수 있다.
주요 검증 어노테이션
| 어노테이션 | 검증 내용 |
| @NotNull | null 금지 |
| @NotEmpty | null과 빈 문자열·컬렉션 금지 |
| @NotBlank | null·빈 문자열·공백만 있는 문자열 금지 |
| @Size | 문자열·컬렉션·배열 크기 |
| @Min, @Max | 숫자의 최소·최대 |
| @Positive | 양수 |
| @PositiveOrZero | 0 또는 양수 |
| @Negative | 음수 |
| 이메일 형식 | |
| @Pattern | 정규표현식 |
| @Past | 과거 날짜 |
| @Future | 미래 날짜 |
| @Digits | 정수부·소수부 자릿수 |
| @AssertTrue | 반드시 true |
| @Null | 반드시 null |
@NotNull, @NotEmpty, @NotBlank 차이
문자열 검증에서 자주 혼동하는 부분이다.
입력값@NotNull@NotEmpty@NotBlank
| 입력값 | @NotNull | @NotEmpty | @NotBlank |
| null | 실패 | 실패 | 실패 |
| "" | 성공 | 실패 | 실패 |
| " " | 성공 | 성공 | 실패 |
| "Spring" | 성공 | 성공 | 성공 |
문자열의 필수 입력을 검증할 때는 대체로 @NotBlank가 적합하다.
컬렉션이나 배열이 비어 있으면 안 되는 경우에는 @NotEmpty 또는 @Size를 사용할 수 있다.
전체 흐름 다시 보기
이번 범위의 내용을 하나로 연결하면 다음과 같다.
@SpringBootApplication
→ Component Scan 실행
@Component 계열 탐색
→ Controller·Service·Repository Bean 등록
ApplicationContext
→ Bean 생성과 생명주기 관리
IoC
→ 객체 관리 제어권을 컨테이너에 위임
DI
→ Bean 사이의 의존관계를 주입
HTTP 요청
→ Controller 파라미터로 바인딩
@PathVariable
@RequestParam
@RequestBody
@ModelAttribute
@RequestHeader
@CookieValue
Lombok
→ 반복적인 Java 코드 감소
Validation
→ 잘못된 입력값을 Controller 경계에서 차단
핵심 정리
컴포넌트 스캔
@SpringBootApplication이 위치한 패키지 이하를 탐색
→ @Component 계열 클래스 검색
→ Spring Bean으로 자동 등록
Spring 컨테이너
Bean 생성
→ 의존성 주입
→ 초기화
→ 사용
→ 소멸
Bean 기본 Scope
Singleton
→ 컨테이너당 하나의 객체를 공유
→ 요청별 상태를 필드에 저장하지 않도록 주의
IoC와 DI
IoC
→ 객체 생성과 관리 권한을 컨테이너에 위임
DI
→ 의존 객체를 외부에서 주입
권장 주입 방식
생성자 주입
→ final 사용
→ 필수 의존성 보장
→ 테스트 용이
→ 순환 참조 조기 발견
HTTP 요청 바인딩
@PathVariable
→ URL 경로
@RequestParam
→ Query String
@RequestBody
→ JSON Body
@ModelAttribute
→ Form Data
@RequestHeader
→ Header
@CookieValue
→ Cookie
Lombok
반복 코드 자동 생성
→ Getter·Setter·생성자·Builder·Logger
입력값 검증
Request DTO
+ 제약 어노테이션
+ @Valid 또는 @Validated
→ 잘못된 요청을 Service 진입 전에 차단
복습 질문
- 컴포넌트 스캔의 기본 탐색 범위는 어디인가?
- @Component, @Service, @Repository는 Bean 등록 측면에서 어떤 공통점이 있는가?
- DTO에 비즈니스 로직을 넣지 않는 것이 좋은 이유는 무엇인가?
- JPA Entity를 API 응답 객체로 직접 사용하면 어떤 문제가 생길 수 있는가?
- BeanFactory와 ApplicationContext는 어떤 차이가 있는가?
- Spring의 Singleton Bean에 요청별 상태를 저장하면 안 되는 이유는 무엇인가?
- @PostConstruct와 @PreDestroy는 각각 어느 시점에 호출되는가?
- IoC와 DI는 서로 어떤 관계인가?
- 생성자 주입이 필드 주입보다 권장되는 이유는 무엇인가?
- 순환 참조가 발생했다는 것은 설계상 어떤 문제를 의미할 수 있는가?
- @Primary와 @Qualifier는 각각 어떤 상황에 사용하는가?
- @RequestParam과 @PathVariable의 사용 목적은 어떻게 다른가?
- JSON 요청에는 @RequestBody, Form Data에는 @ModelAttribute를 사용하는 이유는 무엇인가?
- Lombok의 @Data를 모든 클래스에 무조건 사용하는 것이 좋지 않은 이유는 무엇인가?
- Lombok의 @Value와 Spring의 @Value는 어떻게 다른가?
- @Valid와 @Validated의 차이는 무엇인가?
- @NotNull, @NotEmpty, @NotBlank는 공백 문자열을 어떻게 다르게 처리하는가?
'개발 > SKALA 4기' 카테고리의 다른 글
| [SKALA] 데이터 분석 및 Python 기초 ① (0) | 2026.08.04 |
|---|---|
| [SKALA] 입력값 검증부터 Spring JPA와 API 문서화 (0) | 2026.07.31 |
| [SKALA] Spring Boot 설정 관리와 MVC·Actuator 정리 (0) | 2026.07.29 |
| [SKALA] 객체지향(OOP)과 Spring Boot 핵심 정리 (0) | 2026.07.27 |
| [SKALA] LLM 모델과 Transformer 아키텍처 이해 (0) | 2026.07.23 |