[ 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()을 사용
변환 과정:
List<Product>→Stream<Product>(stream)Stream<Product>→Stream<ProductDTO>(map)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()을 사용
변환 과정:
Optional<Product>→Optional<ProductDTO>(map)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();
빌더 패턴 장점
- 가독성 향상 : 어떤 필드에 어떤 값이 들어가는지 명확
- 유연성 향상 : 메서드 체이닝으로 일부 필드만 설정 가능
- 안정성 향상 : 생성자 매개변수 순서 헷갈림 방지
- 불변성 보장 : 객체 생성 후 상태 변경 불가
- 코드 중복 감소 : 여러 생성자 오버로딩 필요 없음