🔌 SPARTA/Courses

[숙련 Spring] Spring Boot 활용하기

eunjiom 2026. 2. 10. 20:59

SOLID 원칙

1. 단일 책임 원칙

  • 클래스는 단 하나의 책임만 가져야 한다
  • 올바른 코드 예시)
// 직원 정보만 관리
public class Employee {
    private String name;
    private double salary;

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public double getSalary() { return salary; }
}

// 급여 계산 담당
public class PayrollCalculator {
    public double calculatePay(Employee employee) {
        return employee.getSalary() * 1.1;
    }
}

// 데이터베이스 관리 담당
public class EmployeeRepository {
    public void save(Employee employee) {
        System.out.println("Saving " + employee.getName() + " to database");
    }
}

// 리포트 생성 담당
public class ReportGenerator {
    public void generateReport(Employee employee) {
        System.out.println("Generating report for " + employee.getName());
    }
}

 

2. 개방/폐쇄 원칙

  • 클래스는 확장에는 열려있고, 수정에는 닫혀있어야 한다
  • 올바른 코드 예시)
// 추상화를 통한 확장 가능한 설계
public interface DiscountStrategy {
    double calculateDiscount(double amount);
}

public class RegularCustomerDiscount implements DiscountStrategy {
    @Override
    public double calculateDiscount(double amount) {
        return amount * 0.05;
    }
}

public class VIPCustomerDiscount implements DiscountStrategy {
    @Override
    public double calculateDiscount(double amount) {
        return amount * 0.15;
    }
}

// 새로운 할인 정책 추가시 기존 코드 수정 없이 확장 가능
public class PremiumCustomerDiscount implements DiscountStrategy {
    @Override
    public double calculateDiscount(double amount) {
        return amount * 0.20;
    }
}

public class DiscountCalculator {
    public double calculateDiscount(DiscountStrategy strategy, double amount) {
        return strategy.calculateDiscount(amount);
    }
}

 

3. 리스코프 치환 원칙

  • 자식 클래스는 부모 클래스를 대체할 수 있어야 한다
  • 올바른 코드 예시)
// 공통 인터페이스 사용
public interface Shape {
    int getArea();
}

public class Rectangle implements Shape {
    private int width;
    private int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public int getArea() {
        return width * height;
    }
}

public class Square implements Shape {
    private int side;

    public Square(int side) {
        this.side = side;
    }

    @Override
    public int getArea() {
        return side * side;
    }
}

 

4. 인터페이스 분리 원칙

  • 클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다
  • 올바른 코드 예시)
// 인터페이스를 작은 단위로 분리
public interface Workable {
    void work();
}

public interface Eatable {
    void eat();
}

public interface Sleepable {
    void sleep();
}

public interface Payable {
    void receiveSalary();
}

// 필요한 인터페이스만 구현
public class Human implements Workable, Eatable, Sleepable, Payable {
    @Override
    public void work() {
        System.out.println("Human working");
    }

    @Override
    public void eat() {
        System.out.println("Human eating");
    }

    @Override
    public void sleep() {
        System.out.println("Human sleeping");
    }

    @Override
    public void receiveSalary() {
        System.out.println("Human receiving salary");
    }
}

public class Robot implements Workable {
    @Override
    public void work() {
        System.out.println("Robot working");
    }
    // 불필요한 메서드 구현 없음!
}

 

5. 의존 역전 원칙

  • 고수준 모듈은 저수준 모듈에 의존하면 안 된다. 둘 다 추상화에 의존해야 한다
  • 올바른 코드 예시)
// 추상화 (인터페이스)
public interface MessageSender {
    void sendMessage(String message);
}

// 저수준 클래스들이 인터페이스 구현
public class EmailSender implements MessageSender {
    @Override
    public void sendMessage(String message) {
        System.out.println("Sending email: " + message);
    }
}

public class SMSSender implements MessageSender {
    @Override
    public void sendMessage(String message) {
        System.out.println("Sending SMS: " + message);
    }
}

// 고수준 클래스는 추상화에 의존
public class NotificationService {
    private MessageSender messageSender;

    // 의존성 주입을 통해 유연성 확보
    public NotificationService(MessageSender messageSender) {
        this.messageSender = messageSender;
    }

    public void notify(String message) {
        messageSender.sendMessage(message);
    }
}

// 사용 예제
public class Main {
    public static void main(String[] args) {
        // 이메일로 알림
        NotificationService emailNotification = 
            new NotificationService(new EmailSender());
        emailNotification.notify("Hello via Email");

        // SMS로 알림 - 코드 변경 없이 교체 가능!
        NotificationService smsNotification = 
            new NotificationService(new SMSSender());
        smsNotification.notify("Hello via SMS");
    }
}

 

6. SOLID 장점

  • 유지보수성 향상 / 확장성 / 테스트 용이성 / 코드 재사용성 / 팀 협업

IoC/DI

  • 의존성: 한 클래스가 다른 클래스(혹은 객체)를 사용하는 관계
  • IoC: 스프링이 대신 객체 생성
  • DI: 스프링이 개발자 대신 생성한 객체를 관리하고 있는데, 그 객체(=의존성)를 가져와서 사용(주입)

Bean

1. IoC Container와 Bean

 

1) Bean: 스프링이 관리하는 객체

  • Spring 컨테이너에 의해 생성, 관리, 소멸됨
  • 애플리케이션 전역에서 재사용 가능
  • 기본적으로 싱글톤 스코프로 관리

2) 싱글톤

  • 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴
  • 애플리케이션 전체에서 해당 클래스의 객체를 하나만 만들고, 그것을 공유해서 사용
  • 사용하는 이유: 메모리 효율성 / 데이터 공유와 일관성

3) 스프링 IoC Container

  • Bean의 생성 및 생명주기 관리
  • 의존성 주입 (DI)
  • Bean 설정 정보 관리
  • Bean 간의 의존 관계 설정

 

4) new VS IoC/DI

  • new: 매번 새로운 상태를 가지는 데이터 객체
  • IoC/DI: 재사용되거나 교체 가능한 비즈니스 로직/인프라

2. 어노테이션 합성

  • 여러 개의 어노테이션을 조합하여 하나의 새로운 어노테이션을 만드는 것

3. 스프링 Bean 등록

 

1) 자동 등록

  • @Component 클래스를 찾아서 Bean으로 등록하는 방식
  • @ComponentScan에 설정되어있는 패키지를 기준으로 하위의 모든 @Component 클래스를 탐색하여 Bean으로 등록
  • @Component: 붙은 클래스는 Bean으로 등록됩니다 ( @Controller(@RestController), @Service, @Repository)
  • @ComponentScan: 어디에서 빈으로 사용할 클래스들을 찾아야 할지 알려주는 안내자 역할 / 지정된 경로와 그 하위 패키지를 모두 탐색 > @SpringBootApplication 어노테이션에 이미 포함

2) 수동 등록

  • @Configuration 클래스와 @Bean 메소드를 사용하여 명시적으로 Bean을 등록하는 방식
  • @Configuration: 스프링 빈을 어떻게 만들지 적어놓은 설정 클래스 / @Bean 메소드들이 Bean 정의를 포함
// 무선마우스로 사용

@Configuration
class AppConfig {

    @Bean
    Mouse mouse() {
        return new WirelessMouse();
    }
}

 

4. DI 방식 비교

 

1) 필드 주입

  • @Autowired를 필드에 직접 선언하여 의존성을 주입받는 방식
  • final X > 테스트 코드에서만 사용
@Service
public class MemberService {
    @Autowired
    private MemberRepository memberRepository;
}

 

2) 생성자 주입

  • 생성자를 통해 의존성을 주입받는 방식
  • final O > 불변성 보장
@Service
public class MemberService {
    private final MemberRepository memberRepository;

    @Autowired  // 생성자가 1개만 있으면 @Autowired 생략 가능
    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

 

5. Bean 우선순위 : 빈이 2개 일 때

@Component
public class KakaoPayService implements PayService { ... }

@Component
public class NaverPayService implements PayService { ... }

 

1) 해결 1: @Primary

  • 우선적으로 선택될 기본(Default) 빈을 지정하는 어노테이션
@Component
public class KakaoPayService implements PayService { ... }

@Primary // PayService 타입의 빈이 여러 개일 때, 우선권을 갖는다!
@Component
public class NaverPayService implements PayService { ... }

@RestCotroller
@RequiredArgsConstructor
public class PayController {

    private final PayService payService; // NaverPayService가 자동으로 주입됨

        // ...
}

 

2) 해결 2: @Qualifier

  • 특정 빈을 직접 지정하여 주입하는 어노테이션
@Component("kakaoPay") // "kakaoPay"라는 별명을 붙여줌
public class KakaoPayService implements PayService { ... }

@Component("naverPay")
public class NaverPayService implements PayService { ... }

@Service
public class OrderService {
    private final PayService payService;

    // "kakaoPay"라는 이름을 가진 빈을 주입해줘!
    public OrderService(@Qualifier("kakaoPay") PayService payService) {
        this.payService = payService;
    }
}

 

6. Bean 스코프

  • 스프링 빈이 얼마나 오래, 그리고 어떻게 존재할지를 정의하는 개념
  • 기본(Default) 값은 싱글톤 > 외에 잘 사용 X
  • 싱글톤 스코프: 단 한 번만 생성되고, 애플리케이션이 끝날 때까지 계속 재사용
  • 프로토타입 스코프: 요청이 올 때마다 계속 새로운 객체를 생성하여 반환

7. 라이프사이클 콜백

  • 생성 → 의존성 주입 → 초기화 → 사용 → 소멸
  • 초기화와 소멸 단계에서 특정 작업을 수행하도록 콜백(Callback) 메서드를 지정
  • 초기화 단계: @PostConstruct 어노테이션이 붙은 메서드는 빈의 생성과 모든 의존성 주입이 완료된 직후에 딱 한 번 호출
    의존성을 사용하여 외부 리소스를 가져오거나 초기 설정 값을 세팅하는 등 무거운 초기화 작업에 사용
  • 소멸 단계: @PreDestroy는 스프링 컨테이너에서 빈이 제거되기 직전에 호출
    뒷정리 작업에 사용
@Component
public class MusicPlayer {

    private List<String> playlist = new ArrayList<>();

    // 초기화 콜백: 의존성 주입이 끝난 후 실행
    @PostConstruct
    public void loadPlaylist() {
        System.out.println("--- @PostConstruct 호출 ---");
        playlist.add("아이유 - 라일락");
        playlist.add("BTS - Dynamite");
        System.out.println("플레이리스트 로딩 완료!");
    }

    // 소멸 전 콜백: 빈이 사라지기 직전 실행
    @PreDestroy
    public void saveProgress() {
        System.out.println("--- @PreDestroy 호출 ---");
        System.out.println("뮤직 플레이어를 종료합니다...");
    }
}

 

Validation

1. 개념

  • 특정 데이터의 값이 유효한지 확인하는 단계
  • 시스템이 미리 정의한 사양에 부합하고 있는지 검증

2. 역할

  • 검증을 통해 적절한 메세지를 유저에게 보임
  • 검증 오류로 인해 정상적인 동작을 못하는 경우는 X
  • 사용자가 입력한 데이터는 유지된 상태

3. 검증 종류

 

1) 프론트엔드 검증

  • 해당 검증은 유저가 조작할 수 있음으로 보안에 취약
  • 보안에 취약하지만 그럼에도 꼭 필요

2) 서버 검증

  • 프론트 검증없이 서버에서만 검증한다면 유저 사용성이 떨어짐
  • API 스펙을 정의해서 Validation 오류를 Response 예시에 남겨야 함 > API 명세서 잘 만들어야 함
  • 서버 검증은 선택이 아닌 필수

3) 데이터베이스 검증

  • Not Null, Default와 같은 제약조건을 설정
  • 최종 방어선의 역할을 수행

4. Bean Validation

  • 검증 로직을 어노테이션으로 표현하는 기술
  • 코드 예시
더보기
  • 의존성
implementation 'org.springframework.boot:spring-boot-starter-validation'
  • 코드 예시
public class SaveMemberRequestDto {

    @NotBlank  // 1. "값이 꼭 있어야 해요!" (null, "", " " 모두 거부)
    private String name;

    @Email     // 2. "이메일 형식이어야 해요!" (xxx@xxx.xxx)
    private String email;

    @Size(min = 8, max = 20)  // 3. "8~20자 사이여야 해요!"
    private String password;

    @Min(19)   // 4. "최소 19 이상이어야 해요!"
    private Integer age;

    @Pattern(regexp = "^010-\\d{4}-\\d{4}$")  // 5. "이 패턴과 일치해야 해요!"
    private String phone;
}
  • 많이 쓰이는 어노테이션:
    @NotBlank: 공백이 아닌 문자가 1개 이상 (null, "", " " 모두 거부)
    @Email: @가 포함된 이메일 형식
    @Size: 문자열 길이나 컬렉션 크기 제한
    @Min/@Max: 숫자의 최소/최대값
    @Pattern: 정규표현식 패턴 (전화번호, 주민번호 형식 등)
  • 컨트롤러에서 검증 실행하기
더보기
  • @Valid만 붙이면 자동으로 검증
@RestController
public class MemberController {

    // @Valid만 붙이면 자동으로 검증 실행!
    @PostMapping("/signup")
    public String signup(@Valid @RequestBody SaveMemberRequestDto request) {
        // ...
        return "가입이 완료되었습니다.";
    }
}
@Getter
public class SaveMemberRequestDto {

    @NotBlank  // 1. "값이 꼭 있어야 해요!" (null, "", " " 모두 거부)
    private String name;

    @Email(message = "올바른 이메일 형식이 아닙니다.")     // 2. "이메일 형식이어야 해요!" (xxx@xxx.xxx)
    private String email;

    @Size(min = 8, max = 20)  // 3. "8~20자 사이여야 해요!"
    private String password;

    @Min(19)   // 4. "최소 19 이상이어야 해요!"
    private Integer age;

    @Pattern(regexp = "^010-\\d{4}-\\d{4}$")  // 5. "이 패턴과 일치해야 해요!"
    private String phone;
}

 

5. 정규식

  • 문자열의 패턴을 표현하는 특별한 문자 조합
  • 정규표현식, Regex
  • 필요성: 코드예시
더보기
  • 정규식X
public boolean isValidPhone(String phone) {
    if (phone == null) return false;
    if (phone.length() != 13) return false;
    if (!phone.startsWith("010")) return false;
    if (phone.charAt(3) != '-') return false;
    if (phone.charAt(8) != '-') return false;

    String[] parts = phone.split("-");
    try {
        Integer.parseInt(parts[1]);
        Integer.parseInt(parts[2]);
    } catch (NumberFormatException e) {
        return false;
    }

    return true;
}
  • 정규식O
public boolean isValidPhone(String phone) {
    return phone.matches("^010-\\d{4}-\\d{4}$");
}

 

예외처리

1. 스프링 예외처리 전략

 

1) 스프링 예외 처리 전략 3가지

  • @ExceptionHandler
  • @RestControllerAdvice
  • Spring 기본 예외 처리

2) @ExceptionHandler - 컨트롤러별 예외 처리

  • 개별적으로 컨트롤러마다 설정하는 방법
@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    @GetMapping("/members/{memberId}")
    public ResponseEntity<GetMemberResponse> getMember(
            @PathVariable Long memberId
    ) {
        return ResponseEntity.status(HttpStatus.OK).body(memberService.findOne(memberId));
    }

    // 🚨 예외 처리 메서드
    @ExceptionHandler(IllegalStateException.class)
    public ResponseEntity<String> handlerIllegalStateException(IllegalStateException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body("요청 오류: " + e.getMessage());
    }
}

 

3) @RestControllerAdvice - 전역 예외 처리

  • 모든 컨트롤러의 예외를 한 곳에서 처리
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 🎯 커스텀 비즈니스 예외 처리
    @ExceptionHandler(IllegalStateException.class)
    public ResponseEntity<String> handleIllegalStateException(IllegalStateException e) {
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body("요청 오류: " + e.getMessage());
    }
}

 

2. 로깅

 

1) 로그

  • 프로그램이 예상대로 잘 동작하는지, 혹은 문제가 생겼을 때 어디서 왜 문제가 발생했는지 추적하기 위해 기록
  • 로깅: 기록을 남기는 행위

2) 로그 레벨 이해하기

// 로그 레벨 낮음 -> 높음
TRACE → DEBUG → INFO → WARN → ERROR

 

3) 로그 레벨 설정

  • application.properties에서 로그 레벨을 설정
// 소문자로도 설정 가능!
logging.level.root=WARN
  • 아무 것도 설정하지 않았을 때 기본 로그 레벨은 INFO

4) 로깅

  • Lombok 라이브러리의 @Slf4j 어노테이션을 사용 > log.xxx 형태 = 간편
  • 로깅 예시
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@Slf4j // <-- 바로 이 어노테이션입니다!
@RestController
public class LogController {

    @GetMapping("/log-test/{userId}")
    public String logTest(@PathVariable String userId) {
        String userName = "봉이 김선달";

        // 각 로그 레벨별로 메시지를 출력합니다.
        log.trace("TRACE 로그: 사용자 ID = {}, 사용자 이름 = {}", userId, userName);
        log.debug("DEBUG 로그: 사용자 ID = {}, 사용자 이름 = {}", userId, userName);
        log.info("INFO  로그: 사용자 ID = {} 님이 로그인했습니다.", userId);
        log.warn("WARN  로그: 사용자 ID = {} 님의 비밀번호 만료가 임박했습니다.", userId);

        try {
            // 일부러 예외를 발생시킵니다.
            int errorResult = 10 / 0;
        } catch (Exception e) {
            log.error("ERROR 로그: 사용자 ID = {} 님 처리 중 예외 발생!", userId, e);
        }

        return "로그 테스트 완료!";
    }
}
  • System.out.println 사용 X

3. 커스텀 에러

 

1) 커스텀 에러 만들기

더보기
  • RuntimeException 상속받기 > 예외 클래스 만들기
// extends RuntimeException 중요!
public class MovieNotFoundException extends RuntimeException {
    public MovieNotFoundException(String message) {
        super(message);
    }
}
  • 커스텀 에러 쓰기
// Service 코드
@Transactional(readOnly = true)
public GetMovieResponse findOne(Long movieId) {
    Movie movie = movieRepository.findById(movieId).orElseThrow(
            () -> new MovieNotFoundException("없는 영화입니다.")
    );
    return new GetMovieResponse(
            movie.getId(),
            movie.getTitle(),
            movie.getEmail(),
            movie.getPassword(),
            movie.getPhoneNumber()
    );
}
  • GlobalExceptionhHandler로 커스텀 에러를 핸들링 하기
@RestControllerAdvice
public class GlobalExceptionHandler {

	  // MovieNotFoundException 커스텀 에러 핸들링
    @ExceptionHandler(MovieNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleMovieNotFoundException(MovieNotFoundException ex) {
        ErrorResponse response = new ErrorResponse(ex.getMessage());
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(response);
    }
}
  • 문제점: 커스텀 에러 생길 때마다 GlobalExceptionHandler 하나하나 만들어야 함 > 공통된 부모 에러 타입 만들기

2) 공통 부모 커스텀 에러 타입 만들기

더보기
  • 공통 상위 커스텀 에러 클래스 만들기
import lombok.Getter;

@Getter
public class ServiceException extends RuntimeException {

    private final HttpStatus status;

    public ServiceException(HttpStatus status, String message) {
        super(message);
        this.status = status;
    }
}
  • MovieNotFoundException을 작성
// extends ServerException 중요!
public class MovieNotFoundException extends ServiceException {
    public MovieNotFoundException(String message) {
        super(HttpStatus.NOT_FOUND, message); // HttpStatus.NOT_FOUND 지정
    }
}
  • 새로 만든 커스텀 에러 쓰기
// Service 코드
@Transactional(readOnly = true)
public GetMovieResponse findOne(Long movieId) {
    Movie movie = movieRepository.findById(movieId).orElseThrow(
            () -> new MovieNotFoundException("없는 영화입니다.")
    );
    return new GetMovieResponse(
            movie.getId(),
            movie.getTitle(),
            movie.getEmail(),
            movie.getPassword(),
            movie.getPhoneNumber()
    );
}
  • GlobalExceptionhHandler로 커스텀 에러들을 단 하나의 메서드로 모두 핸들링 가능
@RestControllerAdvice
public class GlobalExceptionHandler {

    // MovieNotFoundException 커스텀 에러 핸들링
    @ExceptionHandler(ServiceException.class)
    public ResponseEntity<String> handleServiceException(ServiceException ex) {
        return ResponseEntity
                .status(ex.getStatus())
                .body(ex.getMessage());
    }
}
  • 예시) 이메일 중복 에러 / 에러코드 400\
// extends ServerException 중요!
public class DuplicateEmailException extends ServiceException {
    public DuplicateEmailException(String message) {
        super(HttpStatus.BAD_REQUEST, message);
    }
}

4. Bean Validation 에러 핸들링

  • 클라이언트에게 에러 보여주지 X
  •  Bean Validation은 내부적으로 MethodArgumentNotValidException 에러 발생
  • GlobalExceptionHandler 클래스에 해당 에러를 핸들링 > 클라이언트에게 원하는 에러 메시지 보여줌
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<String> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
    String errorMessage = ex.getBindingResult().getFieldErrors().stream()
            .findFirst() // 첫 번째 에러를 Optional로 가져옴
            .map(fieldError -> fieldError.getDefaultMessage()) // 있다면 메시지로 변환
            .orElse("입력 값이 올바르지 않습니다."); // 없다면 기본 메시지 사용

    return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorMessage);
}
  • 에러 2개 이상 O