2025. 2. 4. 22:36ㆍ개발 회고/TIL
오늘은 일정관리 서버 과제를 마무리하고 Spring 기본 + CRUD 구현을 이제 마무리 지었다. 이제 기본 CRUD는 할 수 있는 것 같은데 뭔가..? 제대로 올바른 방식으로 하고 있지 않는 것 같다. 컨트롤러, 서비스, 리포지토리 각각에 맞는 로직을 구현해야하는데 제대로된 곳에서 하지 않고 있다던가 Dto도 나눠서 하는게 아닌 한가지 Dto로만 쓰고 있는 것, 필드 값 검증도 하지 않았고 예외가 발생하면 처리하는 것도 구현하지 않았다. 그 다음 과제에서는 내가 문제점으로 알게된 것들을 꼭 지키면서 구현해보아야겠다.
Spring 숙련 주차에 들어갔다. 오늘은 SOLID 원칙, 스프링 빈, 빈 등록, 의존성 주입, 검증에 대해서 배웠다.
배우면서 헷갈렸던 개념들을 다시 정리해보았다.
Spring Bean 이란?
Spring 컨테이너가 관리하는 객체를 의미한다. 스프링이 직접 생성하고 관리하는 자바 객체라고 보면된다.
스프링 빈을 사용하는 이유?
- 객체를 직접 생성하지 않아도 된다
- 개발자가 new 객체()를 직접 만들 필요 없이, 스프링이 대신 생성하고 관리해준다.
- 객체의 생명 주기(Lifecycle) 관리
- 스프링이 객체를 생성하고, 필요 없으면 자동으로 삭제해 준다.
- 객체를 재사용 가능
- 동일한 객체를 여러 곳에서 사용 가능 → 메모리 절약 & 효율적 관리
- 싱글턴 패턴이 자동 적용됨된다(기본적으로 하나의 객체만 생성).
만약 스프링 빈 없이 객체를 생성한다면
public class CoffeeService {
public String makeCoffee() {
return "☕ 커피 한 잔 완성!";
}
}
public class CoffeeApp {
public static void main(String[] args) {
CoffeeService coffeeService1 = new CoffeeService(); // 객체 직접 생성
CoffeeService coffeeService2 = new CoffeeService(); // 또 생성
System.out.println(coffeeService1.makeCoffee()); // ☕ 커피 한 잔 완성!
System.out.println(coffeeService2.makeCoffee()); // ☕ 커피 한 잔 완성!
}
}
객체가 new 키워드로 매번 새로 생성됨 → 메모리 낭비
CoffeeService를 여러 곳에서 쓰려면 계속 new CoffeeService() 해야한다.
스프링 빈을 사용한다면
import org.springframework.stereotype.Service;
@Service // 스프링이 자동으로 객체를 관리하도록 지정
public class CoffeeService {
public String makeCoffee() {
return "☕ 커피 한 잔 완성!";
}
}
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Component // 스프링이 관리하는 객체 (스프링 빈)
public class CoffeeApp {
private final CoffeeService coffeeService;
@Autowired // 스프링이 자동으로 CoffeeService를 주입해 줌
public CoffeeApp(CoffeeService coffeeService) {
this.coffeeService = coffeeService;
}
public void run() {
System.out.println(coffeeService.makeCoffee()); // ☕ 커피 한 잔 완성!
}
}
📌 실행 흐름
1️⃣ @Service 또는 @Component가 붙은 클래스는 스프링이 자동으로 객체를 생성
2️⃣ @Autowired를 사용하면 자동으로 CoffeeService 객체를 주입해 줌
3️⃣ 개발자는 new CoffeeService()를 하지 않아도 됨 (스프링이 알아서 관리)
빈을 등록하는 법은?
XML이나 Java 설정파일로도 등록할 수 있으나 주로 어노테이션을 이용한다.
@Component
public class MyApp {
private final MyService myService;
@Autowired // 의존성 자동 주입
public MyApp(MyService myService) {
this.myService = myService;
}
public void run() {
myService.doSomething();
}
}
// com.example 패키지를 스캔하여 Bean 등록
@Configuration
@ComponentScan(basePackages = "com.example")
public class AppConfig {
public static void main(String[] args) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
MyApp app = context.getBean(MyApp.class);
app.run();
}
}
@Component 혹은 @Controller, @Service, @Repository 어노테이션이라면 스프링 빈으로 등록된다.
@ComponentScan이 특정 패키지 내에서 @Component, @Service, @Repository, @Controller 같은 Annotation이 붙은 클래스를 자동으로 검색하고, 이를 Bean으로 등록한다. 개발자가 Bean을 직접 등록하지 않고도 Spring이 자동으로 관리할 객체들을 찾는다.
SpringBoot로 프로젝트를 생성하면 main() 메서드가 있는 클래스 상단에 @SpringBootApplication Annotation 이 존재한다.
해당 어노테이션에 @ComponentScan이 포함되어있다.
@ComponentScan의 동작 순서
- Spring Application이 실행되면 @ComponentScan이 지정된 패키지를 탐색한다.
- 해당 패키지에서 @Component 또는 Annotation이 붙은 클래스를 찾습니다.
- 찾은 클래스를 Spring 컨테이너에 빈으로 등록합니다.
- 등록된 빈은 **의존성 주입(DI)**과 같은 방식으로 다른 빈과 연결됩니다.
의존성 주입이란?
"의존성 주입(Dependency Injection, DI)"이라는 개념이 어려운 이유는 **"의존성"**이라는 단어 때문이다.
쉽게 말하면 **"어떤 객체가 필요로 하는 것을 외부에서 대신 넣어주는 것"**이다.
1️⃣ 의존성 주입이 없는 경우 (직접 만드는 경우)
👉 커피를 마시고 싶을 때
- 직접 원두를 사야 함
- 직접 원두를 갈아야 함
- 물을 끓여야 함
- 드립 커피를 내려야 함
- 직접 마심 ☕
public class Person {
public void makeCoffee() {
System.out.println("원두를 사고, 갈아서, 물 끓이고 커피 내림 ☕");
}
}
문제점
- 커피 한 잔 마시려면 직접 모든 과정을 해야 함 → 번거롭고 시간 낭비
2️⃣ 의존성 주입을 적용한 경우 (자동 판매기 이용)
👉 자동 판매기를 이용하면?
- 자동 판매기에 동전만 넣으면
- 커피가 자동으로 나옴! ☕
public class CoffeeMachine {
public String makeCoffee() {
return "☕ 자동 판매기에서 커피 나옴!";
}
}
public class Person {
private CoffeeMachine coffeeMachine;
// ☕ 커피 머신(자동 판매기)을 외부에서 받아서 사용!
public Person(CoffeeMachine coffeeMachine) {
this.coffeeMachine = coffeeMachine;
}
public void drinkCoffee() {
System.out.println(coffeeMachine.makeCoffee());
}
}
// 외부에서 CoffeeMachine을 만들어서 Person에게 주입 (DI)
public class Main {
public static void main(String[] args) {
CoffeeMachine coffeeMachine = new CoffeeMachine();
Person person = new Person(coffeeMachine); // 커피 머신을 주입받음 (DI)
person.drinkCoffee(); // ☕ 자동 판매기에서 커피 나옴!
}
}
✅ 장점:
- Person 클래스는 "커피를 어떻게 만드는지"를 몰라도 됨
- 자동 판매기만 있으면 커피를 마실 수 있음
- 새로운 TeaMachine을 만들어도 쉽게 바꿀 수 있음 → 확장성 증가
위 예제를 스프링 스타일로 한다면
import org.springframework.stereotype.Component;
// 1. 커피 머신을 스프링이 관리하도록 함
@Component
public class CoffeeMachine {
public String makeCoffee() {
return "☕ 자동 판매기에서 커피 나옴!";
}
}
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
// 2. 사람이 커피 머신을 직접 만들지 않고, 스프링이 넣어줌
@Component
public class Person {
private final CoffeeMachine coffeeMachine;
@Autowired // 자동으로 CoffeeMachine 객체를 주입받음
public Person(CoffeeMachine coffeeMachine) {
this.coffeeMachine = coffeeMachine;
}
public void drinkCoffee() {
System.out.println(coffeeMachine.makeCoffee());
}
}
// 스프링이 자동으로 CoffeeMachine을 만들어서 Person에게 넣어줌
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
Person person = context.getBean(Person.class);
person.drinkCoffee(); // ☕ 자동 판매기에서 커피 나옴!
따라서
✅ 의존성 주입(DI)이란?
- 필요한 객체를 직접 만들지 않고, 외부에서 넣어주는 것
- 스프링이 @Autowired 등을 통해 자동으로 객체를 주입해줌
✅ 일상 속 예시: 자동 판매기
- 커피를 직접 만들 필요 없이, 자동 판매기에 돈만 넣으면 커피가 나옴
- "자동 판매기"가 객체를 대신 생성해주고 공급하는 역할
✅ 스프링에서 DI 사용하면?
- new를 직접 하지 않아도 됨 (스프링이 대신 생성)
- 유지보수 & 확장성 증가 (필요하면 CoffeeMachine → TeaMachine으로 쉽게 변경 가능)
'의존성'이라 말해서 조금 어려운 감이 있는 것 같은데 쉽게 말하자면 클래스에서 원하는 객체가 있고 바로 쓰고 싶다면 해줘잉~ 하면 스프링이 알아서 원하는 객체를 가져다 주는 느낌(이게 제어의 역전, IOC)이다. 해줘잉~은 의존성 주입을 통해서 하면 된다.
그럼 스프링은 의존성 주입이 되어있으면 그에 맞게 객체를 생성해서 가져다 줄것이다.
위의 예제코드에서는 생성자를 이용한 의존성 주입을 @Autowired를 사용했는데 그냥 원하는 객체를 필드에 final 로 선언하고 클래스 상단에 @RequiredArgsConstructor를 사용하면 된다. @Autowired는 늙다리....?방식이라고 튜터님이 말했었다.
그렇지만 의존성 주입을 구현하여도 객체를 스프링이 관리해준다는 점에서 달라지는 것이다. 의존성 주입을 우리가 구현하고 객체를 우리가 직접 관리한다면 그건 스프링을 사용하지 않는것과 똑같다.
이 코드를 보면 (이 경우는 객체를 개발자가 직접 관리하는 경우다)
// Service 인터페이스
public interface MyService {
void doSomething();
}
// Repository 인터페이스
public interface MyRepository {
void queryDatabase();
}
// Service 구현체
public class MyServiceImpl implements MyService {
private MyRepository myRepository;
// 의존성 주입
public MyServiceImpl(MyRepository myRepository) {
this.myRepository = myRepository;
}
@Override
public void doSomething() {
System.out.println("서비스 작업 실행");
myRepository.queryDatabase();
}
}
// Repository 구현체
public class MyRepositoryImpl implements MyRepository {
@Override
public void queryDatabase() {
System.out.println("데이터베이스 쿼리 실행");
}
}
public class MyApp {
public static void main(String[] args) {
MyRepository repo = new MyRepositoryImpl();
// MyRepository repo2 = new MyRepositoryImplV2();
MyService myService = new MyServiceImpl(repo);
// MyService myService2 = new MyServiceImpl(repo2);
myService.doSomething();
}
}
// 새로운 Repository 구현체
public class MyRepositoryImplV2 implements MyRepository {
@Override
public void queryDatabase() {
System.out.println("데이터베이스 쿼리 실행 V2");
}
}
myService 객체의 doSomething 메서드를 호출하기 위해 repo라는 객체를 new 키워드로 만들어서 myService를 만들때 파라미터로 직접 넣어주었다.
그런데 새로운 리포지토리 구현체를 만들면 또 그에 맞게 new 키워드 객체를 만들어서 다시 파라미터로 넣어주어야 한다.
🔍 특징
✅ 개발자가 직접 new 키워드를 사용해 객체 생성
✅ MyService와 MyRepository 간의 의존성을 직접 주입 (new MyServiceImpl(repo))
✅ 새로운 MyRepositoryImplV2를 사용하려면 코드를 직접 수정해야 함
✅ 객체 관리 및 생성 로직이 많아지고 유지보수가 어려움
반면 이 경우는 스프링이 객체를 관리하는 경우다.
// Service 구현체
@Service
public class MyIocService implements MyService {
private final MyRepository myRepository;
// 생성자 주입(DI 적용)
@Autowired
public MyIocService(MyRepository myRepository) {
this.myRepository = myRepository;
}
@Override
public void doSomething() {
System.out.println("IOC 서비스 작업 실행");
myRepository.queryDatabase();
}
}
// Repository 구현체
@Repository
public class MyIocRepository implements MyRepository {
@Override
public void queryDatabase() {
// 데이터베이스와 상호작용
System.out.println("IOC 데이터베이스 쿼리 실행");
}
}
// Spring Container 관리(IoC 적용)
@ComponentScan(basePackages = "com.example")
public class MyIocApp {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(MyIocApp.class);
// Service 빈을 가져와서 실행
MyService service = context.getBean(MyService.class);
service.doSomething();
}
}
// 새로운 Repository 구현체
@Repository
public class MyIocRepositoryV2 implements MyRepository {
@Override
public void queryDatabase() {
// 데이터베이스와 상호작용
System.out.println("IOC 데이터베이스 쿼리 실행 V2");
}
}
반면 스프링이 객체를 관리하면 스프링이 MyService 클래스의 객체를 만들어서 가져오기 때문에 (해당 클래스가 스프링 빈으로 등록되어 있기 때문) 자동으로 의존성을 주입해서 service 객체의 doSomething 메서드를 실행할 수 있게 해준다.
그렇지만 위 코드는 @Repository 어노테이션이 두군데 있어서 아마 빈 충돌이 일어날 것이다.
🔍 특징
✅ 스프링이 객체를 자동으로 생성하고 관리 (@Service, @Repository 사용)
✅ 개발자는 new 키워드를 사용하지 않고 @Autowired로 의존성을 주입받음
✅ MyRepositoryImplV2로 변경하려면 빈(bean) 설정만 바꾸면 됨 (코드 수정이 필요 없음)
✅ 확장성이 높고 유지보수가 쉬움
싱글톤 패턴?
클래스의 인스턴스가 오직 하나만 생성되도록 보장하는 디자인 패턴이다.
스프링 빈은 싱글톤으로 관리되는 객체이다. 라는 것만 기억하면 된다! 그리고 Spring Bean은 항상 무상태(stateless)로 설계를 해야한다. 아주 중요!
Spring Bean을 등록하는 방법에는 수동, 자동 두가지가 존재한다.
Spring Bean은 Bean의 이름으로 등록된다.
자동 Bean 등록(@ComponentScan, @Component)
@Component 이 있는 클래스의 앞글자만 소문자로 변경하여 Bean 이름으로 등록한다. @ComponentScan 을 통해 @Component로 설정된 클래스를 찾는다.
수동 Bean 등록(@Configuration, @Bean)
@Configuration 이 있는 클래스를 Bean으로 등록하고 해당 클래스를 파싱해서 @Bean 이 있는 메서드를 찾아 Bean을 생성한다. 이때 해당 메서드의 이름으로 Bean의 이름이 설정된다.
// 인터페이스
public interface TestService {
void doSomething();
}
// 인터페이스 구현체
public class TestServiceImpl implements TestService {
@Override
public void doSomething() {
System.out.println("Test Service 메서드 호출");
}
}
// 수동으로 빈 등록
@Configuration
public class AppConfig {
// TestService 타입의 Spring Bean 등록
@Bean
public TestService testService() {
// TestServiceImpl을 Bean으로 등록
return new TestServiceImpl();
}
}
// Spring Bean으로 등록이 되었는지 확인
public class MainApp {
public static void main(String[] args) {
// Spring ApplicationContext 생성 및 설정 클래스(AppConfig) 등록
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
// 등록된 TestService 빈 가져오기
TestService service = context.getBean(TestService.class);
// 빈 메서드 호출
service.doSomething();
}
}
위 코드에서 @Bean이 붙은 메서드는 직접 객체를 생성하고 반환하는 역할을 한다.
스프링이 @Bean을 보고 해당 메서드를 실행한 후, 그 반환값을 Spring Container에 등록한다.
빈 충돌이란?
자동 Bean 등록 VS 자동 Bean 등록
public interface ConflictService {
void test();
}
// Bean의 이름을 service로 설정
@Component("service")
public class ConflictServiceV1 implements ConflictService {
@Override
public void test() {
System.out.println("Conflict V1");
}
}
// Bean의 이름을 service로 설정
@Component("service")
public class ConflictServiceV2 implements ConflictService {
@Override
public void test() {
System.out.println("Conflict V2");
}
}
// componentScan의 범위를 conflict 패키지 하위로 설정
@ComponentScan(basePackages = "com.example.springconcept.conflict")
public class ConflictApp {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(ConflictApp.class);
// Service 빈을 가져와서 실행
ConflictService service = context.getBean(ConflictService.class);
service.test();
}
}
ConflictingBeanDefinitionException 발생한다.
수동 Bean 등록 VS 자동 Bean 등록
// conflictService 이름으로 Bean 생성
@Component
public class ConflictService implements MyService {
@Override
public void doSomething() {
System.out.println("ConflictService 메서드 호출");
}
}
public class ConflictServiceV2 implements MyService {
@Override
public void doSomething() {
System.out.println("ConflictServiceV2 메서드 호출");
}
}
// 수동으로 Bean 등록
@Configuration
public class ConflictAppConfig {
// conflictService 이름으로 Bean 생성
@Bean(name = "conflictService")
MyService myService() {
return new ConflictServiceV2();
}
}
@ComponentScan(basePackages = "com.example.springconcept.conflict2")
public class ConflictApp2 {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(ConflictApp2.class);
// Service 빈을 가져와서 실행
MyService service = context.getBean(MyService.class);
service.doSomething();
}
}
수동 Bean 등록이 자동 Bean 등록을 오버라이딩해서 우선권을 가진다.
의도한 결과라면 다행이지만, 아닌 경우(실수)가 대부분이다. → 버그 발생
Spring Boot에서는 수동과 자동 Bean등록의 충돌이 발생하면 오류가 발생한다.
생성자 주입과 Setter 주입
의존성 주입을 해줄 때에 위에서 보았던 생성자 주입 이외에도 Setter를 이용해 주입해줄 수도 있다.
@Component
public class MyApp {
private MyService myService;
// Setter 주입
@Autowired
public void setMyService(MyService myService) {
this.myService = myService;
}
public void run() {
myService.doSomething();
}
}
선택하거나, 변경 가능한 의존관계에 사용한다.(생성자 주입은 필수 값)
// MyService가 Spring Bean으로 등록되지 않은 경우에도 주입이 가능하다.
@Autowired(required = false)
public void setMyService(MyService myService) {
this.myService = myService;
}
// 실행 도중 인스턴스를 바꾸고자 하는 경우
// setMyService(); 메서드를 외부에서 호출하면 된다.(이런 경우는 거의 없음)
빈 충돌 해결방법?
@Qualifier, @Primary를 이용한다.
@Autowired + 필드명 사용
@Autowired 는 타입으로 먼저 주입을 시도하고 같은 타입의 Bean이 여러개라면 필드 이름 혹은 파라미터 이름으로 매칭한다.
public interface MyService { ... }
@Component
public class MyServiceImplV1 implements MyService { ... }
@Component
public class MyServiceImplV2 implements MyService { ... }
@Component
public class ConflictApp {
// 필드명을 Bean 이름으로 설정
@Autowired
private MyService myServiceImplV2;
...
}
@Qualifier 사용
Bean 등록 시 추가 구분자를 붙여 준다.
@Component
@Qualifier("firstService")
public class MyServiceImplV1 implements MyService { ... }
@Component
@Qualifier("secondService")
public class MyServiceImplV2 implements MyService { ... }
@Component
public class ConflictApp {
private MyService myService;
// 생성자 주입에 구분자 추가
@Autowired
public ConflictApp(@Qualifier("firstService") MyService myService) {
this.myService = myService;
}
// setter 주입에 구분자 추가
@Autowired
public void setMyService(@Qualifier("firstService") MyService myService) {
this.myService = myService;
}
...
}
@Primary 사용
@Primary로 지정된 Bean이 우선 순위를 가진다.
@Component
public class MyServiceImplV1 implements MyService { ... }
@Component
@Primary
public class MyServiceImplV2 implements MyService { ... }
@Component
public class ConflictApp {
private MyService myService;
@Autowired
public ConflictApp(MyService myService) {
this.myService = myService;
}
...
}
실제 적용 사례
- Database가 (메인 MySQL, 보조 Oracle) 두개 존재하는 경우
- 기본적으로 MySQL을 사용할 때 @Primary를 사용하면 된다.
- 필요할 때 @Qualifier로 Oracle을 사용하도록 만들 수 있다.
- 동시에 사용되는 경우 @Qualifier 의 우선순위가 높다
검증이란?(Validation)
특정 데이터(주로 클라이언트의 요청 데이터)의 값이 유효한지 확인하는 단계를 의미한다.
Controller의 주요한 역할 중 하나는 Validation 이다. HTTP 요청이 정상인지 검증한다.
Validation의 역할
- 검증을 통해 적절한 메세지를 유저에게 보여주어야 한다.
- 검증 오류로 인해 정상적인 동작을 하지 못하는 경우는 없어야 한다.
- 사용자가 입력한 데이터는 유지된 상태여야 한다.
검증의 종류
- 프론트 검증
- 해당 검증은 유저가 조작할 수 있음으로 보안에 취약하다.
- 보안에 취약하지만 그럼에도 꼭 필요하다
- ex) 비밀번호에 특수문자가 포함되어야 한다면 즉각적인 alert 가능 → 유저 사용성 증가
- 서버 검증
- 프론트 검증없이 서버에서만 검증한다면 유저 사용성이 떨어진다.
- API Spec을 정의해서 Validation 오류를 Response 예시에 남겨주어야 한다.
- API 명세서를 잘 만들어야 그에 맞는 대응을 할 수 있다.
- 서버 검증은 선택이 아닌 필수이다.
- 데이터베이스 검증
- Not Null, Default와 같은 제약조건을 설정한다.
- 최종 방어선의 역할을 수행한다.
BindingResult란?
Spring에서 기본적으로 제공되는 Validation 오류를 보관하는 객체이다. 주로 사용자 입력 폼을 검증할 때 많이 쓰이고 Field Error와 ObjectError를 보관한다.
- Errors 인터페이스를 상속받은 인터페이스이다.
- Errors 인터페이스는 에러의 저장과 조회 기능을 제공한다.
- BindingResult는 addError() 와 같은 추가적인 기능을 제공한다.
- Spring이 기본적으로 사용하는 구현체는 BeanPropertyBindingResult 이다.
@Data
public class MemberCreateRequestDto {
private Long point;
private String name;
private Integer age;
}
// View 반환
@Controller
public class BingdingResultController {
@PostMapping("/v1/member")
public String createMemberV1(@ModelAttribute MemberCreateRequestDto request, Model model) {
// Model에 저장
System.out.println("/V1/member API가 호출되었습니다.");
model.addAttribute("point", request.getPoint());
model.addAttribute("name", request.getName());
model.addAttribute("age", request.getAge());
// Thymeleaf Template Engine View Name
return "complete";
}
}
이 코드에서 BindingResult가 없다면?
잘못된 요청을 했을 시 검증 오류(400 Bad Request)가 발생하고 Controller가 호출되지 않는다.
뷰가 반환되지 않는다.
만약 BindingResult가 있다면?
@Controller
public class BindingResultController {
@PostMapping("/v2/member")
public String createMemberV2(
// 1. @ModelAttribute 뒤에 2. BindingResult가 위치한다.
@ModelAttribute MemberCreateRequestDto request,
BindingResult bindingResult,
Model model
) {
System.out.println("/V2/member API가 호출되었습니다.");
// BindingResult의 에러 출력
List<ObjectError> allErrors = bindingResult.getAllErrors();
System.out.println("allErrors = " + allErrors);
// Model에 저장
model.addAttribute("point", request.getPoint());
model.addAttribute("name", request.getName());
model.addAttribute("age", request.getAge());
return "complete";
}
}
주의사항 : BindingResult 파라미터는 검증대상 파라미터 뒤에 위치해야만 한다.
잘못된 요청을 했을 시 BindingResult에 오류가 보관되고 Controller는 정상적으로 호출된다.
데이터가 빵꾸난 뷰가 반환된다.
@ModelAttribute는 파라미터를 필드 하나하나에 바인딩한다. 파라미터에 Binding Result가 함께 있는 경우 만약 그중 하나의 필드에 오류가 발생하면 해당 필드를 제외하고 나머지 필드들만 바인딩 된 후 Controller가 호출된다.
Bean Validation이란?
특정 필드 검증의 경우 빈값, 길이, 크기, 형식 과 같은 간단한 로직이다. 이러한 로직들을 모든 프로젝트에 적용할 수 있도록 표준화 한 것이 Bean Validation이다.
이렇게 매번 BindingResult를 통해 검증하는 로직을 짜는 것은 귀찮다.
@PostMapping("/v3/member")
public String createMemberV3(
// 1. @ModelAttribute 뒤에 2. BindingResult가 위치한다.
@ModelAttribute MemberCreateRequestDto request,
BindingResult bindingResult,
Model model
) {
System.out.println("/V3/member API가 호출되었습니다.");
// 3. Validation
if (request.getAge() == null || request.getAge() < 0) {
// BindingResult FieldError 추가
bindingResult.addError(
new FieldError("request", "age", "age 필드는 필수이며 0 이상의 값이어야 합니다.")
);
}
// error 처리
if (bindingResult.hasErrors()) {
System.out.println("Error를 처리하는 로직");
// error 페이지 반환
return "error";
}
// Model에 저장
model.addAttribute("point", request.getPoint());
model.addAttribute("name", request.getName());
model.addAttribute("age", request.getAge());
return "complete";
}
객체의 필드나 메서드에 제약 조건을 설정하여, 올바른 값을 가지고 있는지 검증하는 표준화된 방법이다.
- Bean Validation은 기술 표준 인터페이스이다.
- 다양한 Annotation들과 여러가지 Interface로 구성되어 있다.
- Bean Validation(인터페이스) 구현체인 Hibernate Validator를 사용한다.
@Getter
public class SignUpRequestDto {
@NotBlank
private String name;
@NotNull
@Range(min = 1, max = 120)
private Integer age;
}
@Controller
public class BeanValidationController {
@PostMapping("/model-attribute")
public String beanValidationV1(
@Validated @ModelAttribute SignUpRequestDto dto
) {
// 로직
// ViewName
return "complete";
}
}
@RestController
public class BeanValidationRestController {
@PostMapping("/request-body")
public String beanValidationV2(
@Validated @RequestBody SignUpRequestDto dto
) {
// 로직
// 문자 Data 반환
return "회원가입 완료";
}
}
Annotation을 적용시키는것 만으로 Validation을 아주 쉽게 적용할 수 있다.
Controller에 개발자가 기본적인 검증 로직을 작성할 필요가 없어졌다.
RestController의 @RequestBody에도 사용할 수 있다.
Bean Validation 적용하려면 build.gradle에 의존성 추가해주어야 한다.
@NotBlank
- null을 허용하지 않는다.
- 공백(” “)을 허용하지 않는다. 하나 이상의 문자를 포함해야한다.
- 빈값(””)을 허용하지 않는다.
- CharSequence 타입 허용
- String은 CharSequence(Interface)의 구현체이다.
@NotNull
- null을 허용하지 않는다.
- 모든 타입을 허용한다.
@NotEmpty
- null을 허용하지 않는다.
- 빈값(””)을 허용하지 않는다.
- CharSequence, Collection, Map, Array 허용
Hibernate Validator 8.0.2.Final - Jakarta Bean Validation Reference Implementation: Reference Guide
Validating data is a common task that occurs throughout all application layers, from the presentation to the persistence layer. Often the same validation logic is implemented in each layer which is time consuming and error-prone. To avoid duplication of th
docs.jboss.org
밸리데이션 어노테이션이 총정리된 문서다. 보면서 개발하는 것을 추천!
밸리데이션 어노테이션 동작 순서
Spring Boot Application 실행 시 자동으로 Bean Validator가 통합된다.
- LocalValidatorFactoryBean 을 Global Validator로 등록한다.
- Global Validator가 Default로 적용되어 있으니 @Valid, @Validated 만 적용하면 된다.
- Bean Validation Annotation이 있으면 검증을 수행한다.
- Validation Error가 발생하면 FieldError, ObjectError를 생성하여 BindingResult에 담아준다.
@Valid, @Validated 차이점
- @Valid 는 JAVA 표준이고 @Validated 는 Spring 에서 제공하는 Annotation이다.
- @Validated 를 통해 Group Validation 혹은 Controller 이외 계층에서 Validation이 가능하다.
- @Valid 는 MethodArgumentNotValidException 예외를 발생시킨다.
- @Validated 는 ConstraintViolationException 예외를 발생시킨다.
Validator 적용
- Validator 적용 전
- @ModelAttribute 각각의 필드 타입에 맞추어 바인딩(변환) 시도
- 성공 : Controller 정상 호출
- 실패 : TypeMismatch FieldError 발생
- @ModelAttribute 각각의 필드 타입에 맞추어 바인딩(변환) 시도
- Validator 적용 후
- @ModelAttribute → 각 필드 바인딩 → 성공한 필드만 Bean Validation 적용
- Integer 타입 필드에 문자가 오면 애초에 검증의 의미가 없다.
- 성공 : String 필드에 문자입력 → 바인딩 성공 → String 필드에 Bean Validation 적용
- 실패 : Integer 필드에 문자입력 → 바인딩 실패 → bindingResult에 TypeMismatch FieldError 추가 → 바인딩에 실패한 필드는 값이 없음(null) → Bean Validation 적용하지 않음
- @ModelAttribute → 각 필드 바인딩 → 성공한 필드만 Bean Validation 적용
에러메세지 수정하는 방법
Spring의 Bean Validation은 Default로 제공하는 Message들이 존재하고 임의로 수정할 수 있다.
Bean Validation을 적용하고 BindingResult에 등록된 검증 오류를 확인해보면 오류가 Annotation 이름으로 등록되어 있다.
Annotation의 message 속성 사용
@Data
public class TestDto {
@NotBlank(message = "메세지 수정 가능")
private String stringField;
}
Object Error 란?
드 단위가 아닌 객체 전체에 대한 오류를 나타낸다. 예를들어 두 필드 간의 관계를 검증할 때 ObjectError를 통해 해당 오류를 BindingResult에 기록할 수 있다.
Object Error는 로직을 구현해서 직접 처리한다.
요구사항 : 총 구매 가격이 10000원 이상이여야 한다.
@Getter
@AllArgsConstructor
public class OrderRequestDto {
@NotNull
@Range(min = 1000)
private Integer price;
@NotNull
@Range(min = 1)
private Integer count;
}
@Slf4j
@RestController
public class BeanValidationController {
@PostMapping("/object-error")
public String objectError(
@Validated @ModelAttribute OrderRequestDto requestDto,
BindingResult bindingResult
) {
// 합이 10000원 이상인지 확인
int result = requestDto.getPrice() * requestDto.getCount();
if (result < 10000) {
// Object Error
bindingResult.reject("totalMin", new Object[]{10000, result}, "총 합이 10000 이상이어야 합니다.");
}
// Error가 있으면 출력
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return bindingResult.getAllErrors().get(0).getDefaultMessage();
}
// 성공로직 ...
return "성공";
}
}
@ModelAttribute는 기본 생성자만 있을 때는 setter를 통해 파라미터를 바인딩하고 다른 매개변수를 가진 생성자가 있다면 그 생성자를 이용해서 파라미터 바인딩을 수행한다.
만약 등록, 수정 API에서 각각 다른 Validation이 적용된다면?
해결방법
- 저장할 Object를 직접 사용하지 않고 SaveRequestDto, UpdateRequestDto 따로 사용한다. (dto를 나눈다)
- Bean Validation의 groups 기능을 사용한다.
groups 기능?
Bean Validation의 groups 속성은 다양한 유효성 검사 시나리오를 정의할 때 사용된다. 동일한 객체에 대한 검증을 상황에 따라 다르게 적용하고 싶을 때 groups를 활용할 수 있다.
// 저장용 group
public interface SaveCheck {
}
// 수정용 group
public interface UpdateCheck {
}
@Data
public class ProductRequestDtoV2 {
// 저장, 수정 @NotBlank Validation 적용
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String name;
// 사용하는 모든곳에서 @NotNull Validation 적용
@NotNull
// 저장만 @Range 반영
@Range(min = 10, max = 10000, groups = SaveCheck.class)
private Integer price;
@NotNull
@Range(min = 1, max = 999)
private Integer count;
}
@Slf4j
@RestController
public class ProductController {
@PostMapping("/v2/product")
public String save(
// 저장 속성값 설정
@Validated(SaveCheck.class) @ModelAttribute ProductRequestDtoV2 requestDtoV2
) {
log.info("생성 API가 호출 되었습니다.");
// Validation 성공시 repository 저장로직 호출
return "상품 생성이 완료되었습니다";
}
@PutMapping("/v2/product/{id}")
public String update(
@PathVariable Long id,
// 수정 속성값 설정
@Validated(UpdateCheck.class) @ModelAttribute ProductRequestDto test
) {
log.info("수정 API가 호출 되었습니다.");
// Validation 성공시 repository 수정로직 호출
return "상품 수정이 완료되었습니다.";
}
}
groups VS DTO 분리
- groups 속성을 사용하면 등록과 수정시 각각 다르게 Validation이 적용된다.
- 가독성이 떨어지고 코드 복잡도가 올라간다.
- 실무에서는 등록 폼과 수정 폼 자체를 분리해서 사용하기 때문에 DTO 분리 방법을 사용하면 된다.
- 단, 네이밍은 일관성있게 작성해야 한다.(SaveRequestDto, UpdateRequestDto)
- DTO 분리
- 실제로 간단한 프로젝트를 개발해보면 저장, 수정시 Request가 비슷한 경우가 있다.
- 각각의 장단점이 존재하지만 어설프게 하나로 합칠 경우 유지보수시 엄청난 경험을 할 수 있다.
- RequestDto가 변한다는건 해당 API의 스펙 자체가 변경되어 많은 수정이 발생한다.
- 실무에서는 거의 발생하지 않는 경우기 때문에 간단한게 아니라면 대부분 분리하도록 하자!
@Valid, @Validated는 @ModelAttribute뿐만 아니라 @RequestBody에도 적용할 수 있다. @ModelAttribute는 요청 파라미터 혹은 Form Data(x-www-urlencoded)를 다룰 때 사용하고 @RequestBody 는 HTTP Body Data를 Object로 변환할 때 사용한다.
RequestBody에 적용한다면
@Data
public class ExampleRequestDto {
@NotBlank
private String field1;
@NotNull
@Range(min = 1, max = 150)
private Integer field2;
}
@Slf4j
@RestController
public class RequestBodyController {
@PostMapping("/example")
public Object save(
@Validated @RequestBody ExampleRequestDto dto,
BindingResult bindingResult
) {
log.info("RequestBody Controller 호출");
if(bindingResult.hasErrors()) {
log.info("validation errors={}", bindingResult);
// Field, Object Error 모두 JSON으로 반환
return bindingResult.getAllErrors();
}
// 성공 시 RequestDto 반환(의미 없음)
return dto;
}
}
이 경우에 잘못된 요청을 하면 JSON을 객체로 변환하는 것 자체가 실패한다.
중요! Controller가 호출되지 않는다.
반드시 JSON → Object로 변환이 되어야 Validation이 진행된다.
@ModelAttribute와 @RequestBody 차이점
- @ModelAttribute
- 각각의 필드 단위로 바인딩한다.
- 특정 필드 바인딩이 실패하여도 나머지 필드는 정상적으로 검증 처리할 수 있다.
- 특정필드 변환 실패
- 컨트롤러 호출, 나머지 필드 Validation 적용
- @RequestBody
- 필드별로 적용되는것이 아니라 객체 단위로 적용된다.
- MessageConverter가 정상적으로 동작하여 Object로 변환하여야 Validation이 동작한다.
- 특정필드 변환 실패
- 컨트롤러 미호출, Validation 미적용
- 추가내용
- bindingResult.getAllErrors()는 FieldError와 ObjectError 모두 반환한다.
- Spring은 MessageConverter를 이용해 Error 객체들을 변환하여 응답한다.
- RequestDTO 의 경우, 생성, 수정, 삭제, 모두 비슷하게 생겼어도 따로 분리해서 사용하자.
- 작성한 코드는 예시일 뿐 실제로는 API Spec에 맞는 응답을 만들어 클라이언트에 전달 해야한다.
- @ControllerAdvice
오늘도 머릿속에 집어넣어야 할 것이 너무 많은 하루였다...
앞으로 TIL 쓸때 블로그 짤들을 하나씩 넣어보기로 하였다. 너무 글만 쓰기에는 글이 너무 메말라 보이고... 나도 쓸 재미가 떨어진다.
힘들었지만 과거에 이런 어노테이션들 뭔지도 모르고 쓰고 의존성 주입이 이해가 안되서 머리를 쥐뜯던 날들을 생각해보면 오늘도 잘 성장했다 ^^
다시 정리해보자면
new 쓰기 귀찮으니 스프링 니가 객체 찵여오거라 -> 스프링 빈 등록
내가 의존성 직접 주입하기 싫으니 스프링 니가 찵여오거라 -> 의존성 주입 스프링이 해줌
생각해보니 쉬운거였다 ㅋㅋ
앞으로 내가 이해못할 건 없다 퐈이팅^^
'개발 회고 > TIL' 카테고리의 다른 글
[2/13] TIL - 일정 앱 Develop 프로젝트 트러블슈팅 (0) | 2025.02.13 |
---|---|
[2/5] TIL - 쿠키, 세션, JWT (0) | 2025.02.05 |
[1/24] TIL - Spring Annotation, Client에서 Server로 데이터를 보내는 방식 (1) | 2025.01.24 |
[1/22] TIL - 포트, 쿠키, 세션 (0) | 2025.01.22 |
[1/17] TIL - 키오스크 프로젝트에 Enum, Stream, IntStream 적용해보기 (1) | 2025.01.17 |