Spring 핵심 개념과 Validation 총정리

geoson·2025년 6월 5일

Spring & 백엔드

목록 보기
7/18

1. 객체 지향 설계와 SOLID 원칙

객체 지향 설계의 기본이 되는 5가지 SOLID 원칙에 대해 알아봅시다.

1.1 단일 책임 원칙 (SRP: Single Responsibility Principle)

  • 정의: 하나의 클래스는 하나의 책임만 가져야 함
  • 예시:
// 잘못된 예
public class User {
    private String name;
    public void login() { /* 로그인 기능 */ }
    public void saveUser() { /* 데이터베이스 저장 기능 */ }
}

// 올바른 예
public class User { /* 사용자 정보 관리 */ }
public class AuthService {
    public void login(User user) { /* 로그인 기능 */ }
}
public class UserRepository {
    public void saveUser(User user) { /* 데이터베이스 저장 */ }
}

1.2 개방 폐쇄 원칙 (OCP: Open Closed Principle)

  • 정의: 소프트웨어 요소는 확장에는 열려 있어야 하고, 수정에는 닫혀 있어야 함
  • 핵심: 다형성을 활용하여 새로운 기능 추가 시 기존 코드 변경 없이 확장
  • 예시:
// 잘못된 예
public class Shape {
    public String type;
}
public class AreaCalculator {
    public double calculate(Shape shape) {
        if (shape.type.equals("circle")) {
            return /* 원의 넓이 계산 */;
        } else if (shape.type.equals("square")) {
            return /* 사각형의 넓이 계산 */;
        }
    }
}

// 올바른 예
public interface Shape {
    double calculateArea();
}
public class Circle implements Shape {
    public double calculateArea() { return /* 원의 넓이 계산 */; }
}
public class Square implements Shape {
    public double calculateArea() { return /* 사각형의 넓이 계산 */; }
}
public class AreaCalculator {
    public double calculate(Shape shape) {
        return shape.calculateArea(); // 다형성 활용
    }
}

1.3 리스코프 치환 원칙 (LSP: Liskov Substitution Principle)

  • 정의: 자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 함
  • 핵심: 상속 관계에서 자식 클래스가 부모 클래스의 기능을 제대로 수행할 수 있어야 함
  • 예시:
// 잘못된 예
class Car {
    public void accelerate() {
        System.out.println("자동차가 휘발유로 가속합니다.");
    }
}
class ElectricCar extends Car {
    @Override
    public void accelerate() {
        throw new UnsupportedOperationException("전기차는 이 방식으로 가속하지 않습니다.");
    }
}

// 올바른 예
interface Acceleratable {
    void accelerate();
}
class Car implements Acceleratable {
    @Override
    public void accelerate() {
        System.out.println("내연기관 자동차가 가속합니다.");
    }
}
class ElectricCar implements Acceleratable {
    @Override
    public void accelerate() {
        System.out.println("전기차가 배터리로 가속합니다.");
    }
}

1.4 인터페이스 분리 원칙 (ISP: Interface Segregation Principle)

  • 정의: 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다
  • 핵심: 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 함
  • 예시:
// 잘못된 예
public interface Animal {
    void fly();
    void run();
    void swim();
}
public class Dog implements Animal {
    public void fly() { /* 사용하지 않음 */ }
    public void run() { /* 달리기 */ }
    public void swim() { /* 수영 */ }
}

// 올바른 예
public interface Runnable {
    void run();
}
public interface Swimmable {
    void swim();
}
public class Dog implements Runnable, Swimmable {
    public void run() { /* 달리기 */ }
    public void swim() { /* 수영 */ }
}

1.5 의존관계 역전 원칙 (DIP: Dependency Inversion Principle)

  • 정의: 구체적인 클래스에 의존하지 말고, 인터페이스나 추상 클래스에 의존해야 함
  • 핵심: 고수준 모듈이 저수준 모듈에 의존하지 않도록 함
  • 예시:
// 잘못된 예
class EmailNotifier {
    public void sendEmail(String message) {
        System.out.println("Email 알림: " + message);
    }
}
class NotificationService {
    private EmailNotifier emailNotifier;
    public NotificationService() {
        this.emailNotifier = new EmailNotifier(); // 구체 클래스에 의존
    }
    public void sendNotification(String message) {
        emailNotifier.sendEmail(message);
    }
}

// 올바른 예
interface Notifier {
    void send(String message);
}
class EmailNotifier implements Notifier {
    @Override
    public void send(String message) {
        System.out.println("Email 알림: " + message);
    }
}
class SMSNotifier implements Notifier {
    @Override
    public void send(String message) {
        System.out.println("SMS 알림: " + message);
    }
}
class NotificationService {
    private Notifier notifier; // 인터페이스에 의존
    public NotificationService(Notifier notifier) { // 의존성 주입
        this.notifier = notifier;
    }
    public void sendNotification(String message) {
        notifier.send(message);
    }
}

객체 지향의 핵심은 다형성이지만, 다형성만으로는 OCP와 DIP를 지킬 수 없습니다. Spring이 이 문제를 해결해줍니다!

2. Spring의 핵심 개념

2.1 Spring Container

Spring Container는 객체(Bean)를 생성하고 관리하는 핵심 요소입니다.

  • 역할:

    • Bean 생성, 관리, 의존성 주입
    • 애플리케이션의 설정 정보를 읽어 객체 생성과 의존관계 설정
  • 종류:

    • BeanFactory: 기본적인 Bean 관리 기능 제공
    • ApplicationContext: BeanFactory 확장, 국제화, 환경변수, 이벤트 등 추가 기능 제공

2.2 Spring Bean

Spring Container가 관리하는 객체를 의미합니다.

  • 특징:

    • Spring Container에 의해 생성, 관리됨
    • 기본적으로 싱글톤(Singleton)으로 설정
    • 의존성 주입(DI)을 통해 다른 객체와 관계를 맺음
    • 생성, 초기화, 사용, 소멸의 생명주기를 가짐
  • 등록 방법:

    • XML 설정
    • Java 설정 파일 (@Configuration, @Bean)
    • 컴포넌트 스캔 (@Component, @Controller, @Service, @Repository)

2.3 IoC(Inversion of Control)와 DI(Dependency Injection)

IoC(제어의 역전)

  • 정의: 객체의 생성과 생명주기 관리를 개발자가 아닌 Spring이 담당하는 것
  • 효과: 객체 간의 결합도 낮추고, 유연한 코드 작성 가능

DI(의존성 주입)

  • 정의: 객체가 필요로 하는 다른 객체를 외부에서 주입하는 것
  • 방법:
    • 생성자 주입(권장)
    • Setter 주입
    • 필드 주입(권장하지 않음)
    • 메서드 주입
// 생성자 주입 예시 (권장)
@Component
public class MyService {
    private final MyRepository repository;
    
    @Autowired // 생성자가 하나인 경우 생략 가능
    public MyService(MyRepository repository) {
        this.repository = repository;
    }
}

// Lombok 사용 시
@Component
@RequiredArgsConstructor // final 필드를 위한 생성자 자동 생성
public class MyService {
    private final MyRepository repository;
}

2.4 싱글톤 패턴

Spring은 기본적으로 Bean을 싱글톤으로 관리합니다.

  • 이유: 웹 애플리케이션에서 수많은 요청을 처리할 때 매번 객체를 생성하는 것은 비효율적
  • 주의점: 싱글톤으로 관리되는 Bean은 상태를 가지면 안 됨(무상태 stateless 설계)
  • Spring의 싱글톤: 일반적인 싱글톤 패턴의 단점(구체 클래스 의존, 테스트 어려움 등)을 극복
// 일반적인 싱글톤 패턴 예시
public class SingletonService {
    private static final SingletonService instance = new SingletonService();
    
    private SingletonService() { }
    
    public static SingletonService getInstance() {
        return instance;
    }
}

// Spring의 싱글톤 사용 예시
@Component
public class SpringSingletonService {
    // Spring이 알아서 싱글톤으로 관리
}

2.5 Component Scan

Spring이 자동으로 Bean을 등록하기 위한 기능입니다.

  • 역할: @Component, @Service, @Repository, @Controller 등의 애노테이션이 붙은 클래스를 자동으로 Bean으로 등록
  • 사용 방법: @ComponentScan 애노테이션 사용(Spring Boot에서는 @SpringBootApplication이 포함)
@SpringBootApplication // @ComponentScan 포함
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
    }
}

2.6 Bean 충돌 해결 방법

같은 타입의 Bean이 여러 개 존재할 때 해결 방법:

@Primary: 여러 Bean 중 우선 순위 부여

@Component
@Primary
public class MainRepository implements Repository { }

@Qualifier: Bean에 별도 구분자 부여

@Component
@Qualifier("mainRepo")
public class MainRepository implements Repository { }

@Component
public class MyService {
    @Autowired
    public MyService(@Qualifier("mainRepo") Repository repository) { /* ... */ }
}

필드명으로 매칭: 변수명을 Bean 이름과 일치시킴

@Component
public class MyService {
    @Autowired
    private Repository mainRepository; // mainRepository라는 이름의 Bean 주입
}

List나 Map으로 모든 Bean 주입 받기

@Component
public class MyService {
    private final List<Repository> repositories;
    
    @Autowired
    public MyService(List<Repository> repositories) {
        this.repositories = repositories; // Repository 타입의 모든 Bean이 주입됨
    }
}

3. Validation(유효성 검증)

3.1 Validation이란?

  • 정의: 시스템이 미리 정의한 사양에 입력 데이터가 부합하는지 확인하는 과정
  • 중요성:
    • 클라이언트 요청 데이터 검증으로 안정적인 서비스 제공
    • 에러 발생 시 적절한 메시지로 사용자 경험 향상

3.2 BindingResult

Spring에서 제공하는 Validation 오류를 보관하는 객체입니다.

  • 역할:
    • 검증 오류 정보 저장
    • 필드 에러(FieldError)와 객체 에러(ObjectError) 관리
@PostMapping("/member")
public String createMember(
        @ModelAttribute MemberDto request,
        BindingResult bindingResult,
        Model model
) {
    // 검증 로직
    if (request.getAge() == null || request.getAge() < 0) {
        bindingResult.addError(
                new FieldError("request", "age", "나이는 0 이상이어야 합니다.")
        );
    }
    
    // 에러 처리
    if (bindingResult.hasErrors()) {
        return "error";
    }
    
    // 성공 로직
    return "success";
}

3.3 Bean Validation

객체의 필드에 제약 조건을 설정하여 유효성을 검증하는 표준화된 방법입니다.

  • 사용 방법: Bean에 애노테이션으로 제약 조건 선언
public class MemberDto {
    @NotBlank(message = "이름은 필수입니다")
    private String name;
    
    @NotNull
    @Min(value = 0, message = "나이는 0세 이상이어야 합니다")
    @Max(value = 150, message = "나이는 150세 이하여야 합니다")
    private Integer age;
    
    @Email(message = "이메일 형식이 올바르지 않습니다")
    private String email;
}

@Controller
public class MemberController {
    @PostMapping("/members")
    public String createMember(
            @Validated @ModelAttribute MemberDto dto,
            BindingResult bindingResult
    ) {
        if (bindingResult.hasErrors()) {
            return "member/createForm";
        }
        
        // 성공 로직
        return "redirect:/";
    }
}

3.4 주요 Bean Validation 애노테이션

  • @NotNull: null을 허용하지 않음
  • @NotEmpty: null, 빈 문자열(""), 빈 컬렉션 허용하지 않음
  • @NotBlank: null, 빈 문자열(""), 공백만 있는 문자열(" ") 허용하지 않음
  • @Min, @Max: 숫자 최소/최대값 지정
  • @Size: 문자열, 컬렉션 등의 크기 범위 지정
  • @Pattern: 정규식 패턴 매칭
  • @Email: 이메일 형식 검증
  • @Range: 숫자 범위 지정 (Hibernate Validator 전용)

3.5 Object Error

객체 전체에 대한 검증을 위한 방법입니다.

@PostMapping("/order")
public String order(
        @Validated @ModelAttribute OrderDto dto,
        BindingResult bindingResult
) {
    // 객체 검증 (필드 간 관계 검증)
    if (dto.getPrice() * dto.getQuantity() < 10000) {
        bindingResult.reject("totalPriceMin", new Object[]{10000}, 
                "총 주문 금액이 10,000원 이상이어야 합니다.");
    }
    
    if (bindingResult.hasErrors()) {
        return "order/orderForm";
    }
    
    // 성공 로직
    return "redirect:/orders";
}

3.6 Bean Validation groups

동일한 객체에 대해 상황별로 다른 검증을 적용하는 기능입니다.

// 검증 그룹 인터페이스
public interface SaveCheck { }
public interface UpdateCheck { }

// DTO
public class ProductDto {
    @NotNull(groups = UpdateCheck.class) // 수정 시에만 필수
    private Long id;
    
    @NotBlank(groups = {SaveCheck.class, UpdateCheck.class}) // 모든 경우 필수
    private String name;
    
    @Min(value = 1000, groups = SaveCheck.class) // 저장 시에만 최소값 적용
    @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
    private Integer price;
}

// 컨트롤러
@PostMapping("/products")
public String save(
        @Validated(SaveCheck.class) @ModelAttribute ProductDto dto,
        BindingResult bindingResult
) {
    // ...
}

@PutMapping("/products/{id}")
public String update(
        @Validated(UpdateCheck.class) @ModelAttribute ProductDto dto,
        BindingResult bindingResult
) {
    // ...
}

3.7 @ModelAttribute vs @RequestBody

두 애노테이션 모두 Bean Validation을 적용할 수 있지만, 동작 방식에 차이가 있습니다.

  • @ModelAttribute:

    • 각 필드별로 바인딩
    • 일부 필드 바인딩 실패해도 다른 필드는 정상 처리
    • Controller 호출됨
  • @RequestBody:

    • 객체 단위로 JSON 변환 및 바인딩
    • JSON 파싱 실패 시 Controller 호출되지 않음
    • Validation은 JSON 파싱 성공 후에만 진행
// @ModelAttribute 예시
@PostMapping("/form")
public String processForm(
        @Validated @ModelAttribute FormDto dto,
        BindingResult bindingResult
) {
    // ...
}

// @RequestBody 예시
@PostMapping("/api")
public ResponseEntity<Object> processApi(
        @Validated @RequestBody ApiDto dto,
        BindingResult bindingResult
) {
    // ...
}

4. 정리 및 핵심 포인트

4.1 Spring 핵심 개념 요약

  1. Spring Container: Bean을 생성하고 관리하는 주체
  2. Spring Bean: Spring이 관리하는 객체
  3. IoC/DI: 객체 생성과 의존성 관리를 Spring이 담당
  4. 싱글톤 패턴: Spring Bean은 기본적으로 싱글톤으로 관리됨
  5. Component Scan: 자동으로 Bean을 찾아 등록하는 기능

4.2 Validation 핵심 포인트

  1. BindingResult: 검증 오류 정보를 담는 객체
  2. Bean Validation: 애노테이션 기반의 쉬운 검증 방법
  3. 객체 오류 검증: 객체 단위 검증은 코드로 직접 구현
  4. groups: 상황별 다른 검증 적용을 위한 기능
  5. @ModelAttribute vs @RequestBody: 파라미터 바인딩 방식의 차이

Spring은 개발자가 비즈니스 로직에 집중할 수 있도록 객체 생성과 의존관계 관리를 대신해주며, Bean Validation을 통해 편리한 데이터 검증 기능을 제공합니다.

0개의 댓글