본문 바로가기

(10.30) Spring Boot - JPA에서 Entity와 DTO, builder 패턴, Optional & Stream 활용, @ControllerAdvice

@starweb2025. 10. 30. 18:30

[ 11주차 - 1030 ]

    금일 커리큘럼
        ├ 09:00 ~ 12:00 backend 프로그래밍 (JPA에서 Entity와 DTO, 빌더 패턴)
        └ 13:00 ~ 18:00 backend 프로그래밍 (Optional & Stream 활용, @ControllerAdvice)

1.JPA에서 Entity와 DTO 개념 정리

왜 Entity와 DTO를 구분해야 할까?

개념 설명
Entity DB와 비즈니스 로직 모델
DTO 요청/응답 데이터를 담는 전달 모델
Controller 요청/응답 처리
Service 비즈니스 로직 & DTO ↔ Entity 변환

엔티티(Entity)는 시스템 내부에서 데이터와 비즈니스 규칙을 다루는 객체이고,
DTO는 외부 요청/응답에 사용되는 데이터 전달 객체다.
두 객체의 책임을 분리해야 DB 구조가 외부로 노출되지 않고,
보안·유지보수·유연성 측면에서 장점을 얻을 수 있다.

 

서비스 계층에서 직접 엔티티를 반환하지 않는 이유

1) 보안 문제 (민감 정보 노출 위험)

  • 엔티티를 그대로 반환하면 password 등이 노출될 수 있음 → 보안 사고
@Entity
public class User {
    private Long id;
    private String email;
    private String password; // 노출되면 안 됨
}

2) API 스펙과 DB 스키마가 결합됨 (강결합 문제)

  • DB 구조 변경 → API 응답 변경 → 프론트 수정까지 들어감
  • DTO 사용하면 API 스펙 독립 유지됨
@Entity
public class Product {
    Long id;
    String name;
    Integer price;
    String internalCode; // DB 내부용 / 외부 응답 X
}

3) 필요 이상 데이터 전송 → 성능 저하

@GetMapping("/products")
public List<Product> findAll() {
    return productRepository.findAll(); // 프론트에 필요없는 필드값까지 노출됨
}

 

DTO로 필요한 필드만:

  • 응답 크기 감소 → 성능 ↑
@Getter
@AllArgsConstructor
public class ProductDTO {
    private Long id;
    private String name;
}

4) Lazy Loading & 순환 참조 위험

@Entity
public class Order {
    @ManyToOne(fetch = FetchType.LAZY)
    private User user;
}
@GetMapping("/orders")
public List<Order> getOrders() {
    return orderRepository.findAll(); // LazyInitializationException 가능
}

 

DTO로 해결

@Getter
@AllArgsConstructor
public class OrderResponseDTO {
    private Long id;
    private String userEmail;
}

@GetMapping("/orders")
public List<OrderResponseDTO> getOrders() {
    return orderRepository.findAll().stream()
        .map(o -> new OrderResponseDTO(
                o.getId(),
                o.getUser().getEmail()
        ))
        .toList();
}

핵심 요약 정리

  • Entity와 DTO는 역할이 다르다
    • Entity → DB & 비즈니스 로직을 담당하는 내부 도메인 모델
    • DTO → 외부 요청/응답을 위한 데이터 전달 전용 객체 (API 전용 모델)
  • Controller에서 Entity를 직접 반환하지 않는다
    • Service 계층에서 Entity → DTO 변환 후 반환
  • 이렇게 설계하는 이유
    • ✅ 민감정보 보호 (보안 강화)
    • ✅ API 변경 시 DB 영향 최소화 (유지보수성 ↑)
    • ✅ 필요한 데이터만 전달 (성능 최적화)
    • ✅ Lazy 로딩/순환 참조 등 JPA 이슈 방지

즉, "Entity는 내부 로직, DTO는 외부 통신"

  • 역할을 분리하는 것이 올바른 JPA 설계 패턴이다.

2. DTO 빌더 패턴 활용 - 서비스 계층에서 변환 방식

빌더 패턴 적용 이유

  • 실무에서는 Entity와 DTO 모두에 빌더 패턴을 적용하는 것이 일반적
    • 필드가 많거나 선택적 값이 많은 객체를 만들 때 특히 유용
    • 생성자 오버로딩 없이, 필요한 값만 명확하게 지정
    • 불변성도 보장
  • 빌더 패턴 관련 설명 : 하단 etc - 빌더 패턴

Entity → DTO 변환 (정적 메서드 활용)

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder // 빌더 패턴 적용
public class ProductDTO {
    private Long id;
    private String name;
    private Integer price;

    // Entity -> DTO 변환 메서드
    public static ProductDTO fromEntity(Product product) {
        return ProductDTO.builder()
                .id(product.getId())
                .name(product.getName())
                .price(product.getPrice())
                .build();
    }
}

DTO → Entity 변환 (서비스 계층에서 변환)

public ProductDTO saveProduct(ProductDTO pDTO) {
    Product product = Product.builder()
            .name(pDTO.getName())
            .price(pDTO.getPrice())
            .build();

    Product saved = prodRepo.save(product);
    return ProductDTO.fromEntity(saved);
}

포인트 정리

구분 설명
fromEntity() Entity → DTO 변환 메서드
builder() 객체 생성 시 가독성 + 안정성
Service 계층 DTO -> Entity -> DB 저장 후 DTO 반환
  • DTO ↔ Entity 변환 책임은 Service 계층에서 수행

3. Optional & Stream을 활용한 DTO 변환 방식

JPA Repository 메서드 반환 타입

JPA Repository 메서드들은 보통 Optional 또는 List를 반환된다.

  • findAll() : 여러 엔티티를 조회할 때 List<Entity> 반환
  • findById() : 단일 엔티티 조회 시 Optional<Entity> 반환

리스트 변환 - Stream.map()

엔티티 리스트를 DTO 리스트로 변환할 때 Stream의 map()을 사용

변환 과정:

  1. List<Product>Stream<Product> (stream)
  2. Stream<Product>Stream<ProductDTO> (map)
  3. Stream<ProductDTO>List<ProductDTO> (toList)
@Transactional(readOnly = true)
public List<ProductDTO> getProducts() {
    // prodRepo.findAll() -> List<Product>
    // List<Product> -> Stream<Product> : 리스트를 스트림으로 변환 (stream)
    // Stream<Product> -> Stream<ProductDTO> : 각 Product를 ProductDTO로 변환 (map)
    // Stream<ProductDTO> -> List<ProductDTO> : 스트림을 리스트로 변환 (toList)
    return prodRepo.findAll().stream()
            .map(ProductDTO::fromEntity)
            .toList();
}

단일 변환 - Optional.map()

단일 엔티티를 DTO로 변환할 때 Optional의 map()을 사용

변환 과정:

  1. Optional<Product>Optional<ProductDTO> (map)
  2. Optional<ProductDTO>에서 실제 값 추출 (get, orElseThrow 등)
public ProductDTO getProductById(Long id) {
    // prodRepo.findById(id) -> Optional<Product>
    // Optional<Product> -> Optional<ProductDTO> : 엔티티를 DTO로 변환 (map)
    // Optional<ProductDTO> -> ProductDTO : Optional에서 실제 값 추출 (get, orElse 등)
    return prodRepo.findById(id)
            .map(ProductDTO::fromEntity)
            .orElseThrow(
                    () -> new IllegalArgumentException("[getProductById] 찾을수없음 :: id=" + id)
            );
}

Optional에서 주의할 점

Optional은 값이 없을 수도 있음을 나타내는 컨테이너 객체

  • get() 메서드는 값이 없을 때 NoSuchElementException이 발생하므로 직접 사용은 지양해야 한다.
  • 안전하게 값을 꺼내려면 orElse(), orElseThrow(), ifPresent() 등 안전한 메서드를 사용하는 것이 좋다.
    • orElse() : 값이 없으면 기본값 반환
    • orElseThrow() : 값이 없으면 예외 발생 (예외 직접 지정 가능)
    • ifPresent() : 값이 있을 때만 동작 수행

4. 글로벌 예외 처리 - @ControllerAdvice

서비스 계층에서 발생한 예외를 컨트롤러에서 일괄 처리하는 과정

@ControllerAdvice란?

  • 스프링에서 컨트롤러 전역에 적용되는 예외 처리 클래스를 만들 때 사용하는 어노테이션
  • 여러 컨트롤러에서 발생하는 예외를 한 곳에서 일괄적으로 처리할 수 있음
  • 코드 중복을 줄이고, 일관된 에러 응답을 제공할 수 있음

주요 특징

  • @ExceptionHandler 메서드로 다양한 예외별 처리 가능
  • API 응답에 맞춰 커스텀 메시지, 상태코드 반환 가능
  • @RestControllerAdvice는 JSON 응답에 특화됨

예시 코드

import java.util.HashMap;
import java.util.Map;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.server.ResponseStatusException;

@ControllerAdvice // html 기반 view, api, 컨트롤러 어노테이션에 적용됨
// @RestControllerAdvice //  api 특화
public class GlobalExceptionHandler {

    // 예상치 못한 런타임 에러 (예: NullPointer ...)
    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<String> handleRuntimeException(RuntimeException ex) {
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body("Runtime Error :: " + ex.getMessage());
    }

    // HTTP 상태 코드 직접 지정 예외 (예: 400, 404 ...)
    @ExceptionHandler(ResponseStatusException.class)
    public ResponseEntity<String> handleResponseStatusException(ResponseStatusException ex) {
        return ResponseEntity
                .status(ex.getStatusCode())
                .body("ResponseStatus Error :: " + ex.getMessage());
    }

    // @Valid 유효성 검사 실패 예외 (예: @NotNull, @Size ...)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<String> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getAllErrors().forEach((error) -> {
            errors.put(error.getObjectName(), error.getDefaultMessage());
        });
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body("NotValid :: \n" + errors.toString());
    }
}

테스트용 컨트롤러

@RestController
public class ErrorTestController {
    @GetMapping("/api/etest")
    public ResponseEntity<String> test(){
        throw new RuntimeException("test");
    }

    @GetMapping("/api/ntest") // localhost:8080/api/ntest?id=
    public String test2(@RequestParam(name = "id", required = false) Long id){
        if(id == null){
            throw new RuntimeException("test");
        }
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "찾을 수 없음");
    }
}

예외 처리 결과 예시

/api/etest 호출 시

  • RuntimeException 발생
  • → 응답:
HTTP/1.1 500 Internal Server Error
Runtime Error :: test

/api/ntest?id= 호출 시

  • id 파라미터가 없으면
  • → 응답:
HTTP/1.1 500 Internal Server Error
Runtime Error :: test

/api/ntest?id=123 호출 시

  • ResponseStatusException 발생
  • → 응답:
HTTP/1.1 404 Not Found
ResponseStatus Error :: 찾을 수 없음

유효성 검사 실패(@Valid 등) 시

  • MethodArgumentNotValidException 발생
  • → 응답:
HTTP/1.1 400 Bad Request
NotValid ::
{필드명=에러메시지, ...}

정리

  • 글로벌 예외 처리를 통해 서비스 계층에서 발생한 다양한 예외를 일관된 방식으로 응답할 수 있음
  • 유지보수성, 확장성, 사용자 경험 모두 향상됨

etc - 빌더 패턴

빌더 패턴(Builder Pattern)은 복잡한 객체 생성 과정을 단순화하고 가독성을 높이기 위한 디자인 패턴

기존 자바빈(JavaBean) 방식

  • 필드가 많아지면 생성자 매개변수가 길어지고, 삽입 순서 혼동이 올 수 있음
  • 가독성이 떨어지고 유지보수가 어려워짐
  • 추가 생성자 없이 세터 남용 시 객체 불변성 보장 어려움
public class User {
    private String name;
    private int age;
    private String email;

    // 생성자
    public User(String name, int age, String email) {
        this.name = name;
        this.age = age;
        this.email = email;
    }
    // 필요에 따른 생성자가 계속 추가되어야 함
    public User(String name) {
        this.name = name;
    }
    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }
    public User(int age, String email) {
        this.age = age;
        this.email = email;
    }
    // getter, setter...
}

빌더 패턴 방식

// 빌더 패턴 적용한 User 클래스
public class User {
    private String name;
    private int age;
    private String email;

    public User() {}

    // private 생성자
    private User(Builder builder) {
        this.name = builder.name;
        this.age = builder.age;
        this.email = builder.email;
    }

    // 내부 정적 빌더 클래스
    public static class Builder {
        private String name;
        private int age;
        private String email;

        public Builder name(String name) {
            this.name = name;
            return this;
        }

        public Builder age(int age) {
            this.age = age;
            return this;
        }

        public Builder email(String email) {
            this.email = email;
            return this;
        }

        public User build() {
            return new User(this);
        }
    }
}

// 빌더 패턴 사용시
User user = new User.Builder()
        .email("alice@example.com")
        .age(30)
        .name("Alice")
        .build();

빌더 패턴 장점

  • 가독성 향상 : 어떤 필드에 어떤 값이 들어가는지 명확
  • 유연성 향상 : 메서드 체이닝으로 일부 필드만 설정 가능
  • 안정성 향상 : 생성자 매개변수 순서 헷갈림 방지
  • 불변성 보장 : 객체 생성 후 상태 변경 불가
  • 코드 중복 감소 : 여러 생성자 오버로딩 필요 없음
starweb
@starweb :: starweb 님의 블로그

starweb 님의 블로그 입니다.

공감하셨다면 구독도 환영합니다!

목차