Notice
Recent Posts
Recent Comments
Link
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
Tags
more
Archives
Today
Total
관리 메뉴

일상

15주차 - JPA 본문

교육

15주차 - JPA

콜리/khgeung 2025. 9. 14. 20:52

1. JPA

1-1. 기본 application.properties 와 build.gradle

  • application.properties
spring.application.name=spring-jpa-rest-study1
# suppress inspection "UnusedProperty" for whole file

# MySQL 데이터베이스 연결 설정
spring.datasource.url=jdbc:mysql://localhost:3306/kosa?serverTimezone=UTC
spring.datasource.username=joyofbeing
spring.datasource.password=1234
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# JPA/Hibernate 기본 설정
# DDL 자동 생성 정책 (개발 환경용)
# create: 기존 테이블 삭제 후 새로 생성
# create-drop: 애플리케이션 종료 시 테이블 삭제 ( 운영에서는 사용 금지 )
# update: 기존 테이블 유지하며 변경사항만 반영
# validate: 엔티티와 테이블 매핑이 올바른지만 검증
# none: 아무것도 하지 않음 (운영 환경 권장)
spring.jpa.hibernate.ddl-auto=create

# 실행되는 SQL 쿼리를 콘솔에 출력 (개발 시 학습용 , 운영시에는 영향을 줄 수 있으므로 사용하지 않음 )
spring.jpa.show-sql=true

# SQL 쿼리 포맷팅 (보기 좋게 정렬)
spring.jpa.properties.hibernate.format_sql=true

# 추가 JPA 설정
# 엔티티 필드명을 테이블 컬럼명으로 자동 변환 전략
# SNAKE_CASE: camelCase -> snake_case 변환
spring.jpa.hibernate.naming.physical-strategy=org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy

# 로깅 레벨 설정
logging.level.root=WARN
# 콘솔 로그 컬러 출력 활성화
spring.output.ansi.enabled=always


# 우리가 작성한 패키지의 디버그 로그 출력
logging.level.org.kosa.myproject=DEBUG
  • build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.mysql:mysql-connector-j'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    // JUnit 테스트시 롬복을 위한 설정
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
}
/*
 *의존성 스코프 :
 *
 * implementation: 컴파일 + 런타임에 필요 (메인 코드)
 * runtimeOnly: 런타임에만 필요 (MySQL 드라이버)
 * compileOnly: 컴파일에만 필요 (Lombok)
 * annotationProcessor: 어노테이션 처리 (Lombok 코드 생성)
 *
 * testImplementation: 테스트 컴파일 + 런타임
 * testCompileOnly: 테스트 컴파일에만 필요
 * testAnnotationProcessor: 테스트 어노테이션 처리
 *
 */

1-2. Domain, Entity, Dto, Vo

  • Domain : 소프트웨어가 해결하고자 하는 특정 비즈니스 영역으로, 비즈니스 로직과 규칙의 집합이다.
    • JPA나 데이터베이스 같은 특정 기술에 종속되지 않는다.
    • 예) 상품, 주문, 회원 등
  • Entity : JPA와 같은 ORM(Object-Relational Mapping) 기술에서 사용되는 용어로, 데이터베이스 테이블에 매핑되는 객체를 의미
    • 데이터 영속성을 위해 존재한다. 따라서 JPA 기술에 종속된다.
  • Domain vs Entity
    • Domain 은 더 넓은 개념으로 비즈니스 영역과 로직을 포괄하는 반면, Entity는 그 도메인 모델 중 데이터 영속성을 담당하는 구체적인 구현체
  • Dto (Data Transfer Object) : 계층 간에 데이터를 교환하기 위해 사용하는 객체로, 주로 API 요청/응답이나 View 렌더링을 위한 데이터를 담는 데 사용
    • 엔티티와 달리 비즈니스 로직을 전혀 가지지 않는다.
    • 데이터 필드와 getter/setter만으로 구성
    • 외부와의 통신에 사용되므로, 엔티티의 모든 정보를 노출하지 않고 필요한 데이터만 선택적으로 포함
    • 예시
package org.kosa.myproject.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.kosa.myproject.entity.Car;

/*
    Dto : Data Transaction Object 계층 간 데이터 전송을 위한 객체
    응답 : Entity 를 Dto 로 변환, 요청 : Dto를 Entity 로 변환
 */
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class CarDto {
    private Long carId;
    private String modelName;
    private Long price;

    /*
        Entity -> Dto 변환 (응답용)
        언제 사용하는가? 자동차 정보 조회시 사용, 조회한 Car 엔티티를 클라이언트에게 전송할 때
     */
    public static CarDto from(Car car) {
        return CarDto.builder().carId(car.getCarId())
                .modelName(car.getModelName())
                .price(car.getPrice())
                .build();
    }

    /*
        DTO -> Entity 변환 (요청용)
        언제 사용하는가? Rest 기반 클라이언트가 요청 시 보낸 정보를 이용해
        새로운 Car 엔티티를 생성할 때 사용
     */
    public Car toEntity() {
        return Car.builder().modelName(this.modelName).price(this.price).build();
    }
}
  • Vo : 값 그 자체를 나타내는 객체
    • 불변성을 특징으로 하며 식별자(ID)를 가지지 않는다
    • 여러 필드를 묶어 의미 있는 하나의 값으로 다룰 때 유용하다.
  • Spring JPA 에서는 Entity 와 Dto로 기본 구성하면 된다.

1-3. ORM, JPA, Hibernate

  • ORM(Object Relational Mapping)
    • 어플리케이션 객체와 데이터베이스의 데이터를 자동 매핑
    • SQL이 아닌 객체의 메소드로 데이터를 제어 (SQL은 내부적으로 자동 생성되어 실행)
    • Object - ORM - RDBMS
  • 영속성 계층 프레임워크 (Persistence Layer Framework)
    • Mybatis 는 SQL Mapper Framework, JPA(Hibernate)는 ORM Framework 이다.
  • JPA(Java Persistence API)
    • 자바 진영 ORM 기술 표준 명세 -> 자바 어플리케이션에서 ORM 기반으로 DB를 연동하는 방식을 정의한 인터페이스(명세)
    • 과거 EJB 3.0에서 기존 EntityBean 을 대체한 ORM 기술 표준으로 등장
  • Hibernate : JPA의 대표적인 ORM 구현체(라이브러리)
    • JPA => Interface, Hibernate => Implementation (Spring Boot에서 기본 JPA 구현체로 Hibernate 제공)
  • Spring Data JPA
    • Spring Data JPA 는 JPA를 기반으로 하는 Spring Data 프로젝트의 일부로, 데이터 접근 계층을 더 쉽고 효율적으로 구현할 수 있도록 돕는 모듈
    • JPA의 복잡성을 추상화하여 개발자가 반복적인 코드를 작성하는 대신, 핵심 비즈니스 로직에 집중할 수 있도록 지원

1-4. EntityManager

  • JPA Entity 란?
    • 데이터베이스 테이블과 매핑되는 Java 객체
    • 객체지향 프로그래밍과 관계형 데이터베이스를 연결하는 다리 역할
  • EntityManagerFactory : 데이터베이스 연결 정보, JPA 설정 등을 담고 있는 객체로 애플리케이션 시작 시점에 하나만 생성된다. EntityManager 을 생성하는 '공장' 역할을 한다.
  • EntityManager : JPA의 핵심 인터페이스 중 하나로, 엔티티 객체를 관리하고 데이터베이스와 상호작용하는 역할을 한다.
    • 데이터베이스와 애플리케이션 사이에서 객체와 테이블 간의 매핑을 관리하고, 영속성 컨텍스트를 통해 객체의 생명주기를 관리하며, 데이터베이스 작업을 수행하는 역할을 한다.
    • EntityManagerFactory 로부터 생성되며, 보통 하나의 트랜잭션 단위로 사용된다. 즉 데이터베이스 작업을 시작할 때 EntityManager 를 얻어와서 사용하고, 트랜잭션이 끝나면 close()를 호출하여 자원을 반납하는 것이 일반적 패턴이다.
    • 예) 회원 가입이라는 하나의 비즈니스 로직(트랜잭션)을 처리할 때 하나의 EntityManager를 사용하고, 상품 구매라는 다른 비즈니스 로직에서는 또 다른 EntityManager 를 사용한다.
  • Persistence Context : EntityManager 은 내부적으로 영속성 컨텍스트 (Persistence Context)를 가진다. EntityManager이 관리하는 엔티티 인스턴스들을 모아두는 곳으로, EntityManager이 하나 생성될 때마다 새로운 Persistence Context 도 함께 생성된다. 이 컨텍스트 안에서 엔티티의 생명주기(생성, 조회, 수정, 삭제)가 관리된다.

1-5. Entity LifeCycle

  • Entity LifeCycle 의 중요성
    • 성능 최적화 : 불필요한 데이터베이스 접근을 막을 수 있음
    • 버그 방지 : detach 상태의 객체를 영속 상태인 것처럼 오해하여 데이터를 수정하려다 버그가 발생하는 경우를 막을 수 있음
    • 예) @Transactional 어노테이션이 붙은 메소드가 종료되면, 그 메소드 안에서 JPA가 관리하던 영속성 컨텍스트도 함께 종료된다. 이 시점에 영속성 컨텍스트가 관리하던 모든 엔티티 객체는 자동으로 준영속 상태가 된다.즉 만일 UserController 에서 user.setName("New Name") 코드가 실행되지만, user는 이미 준영속 상태이므로 JPA의 변경 감지 기능이 동작하지 않아 데이터베이스에 UPDATE 쿼리가 발생하지 않는다.
  • Entity LifeCycle 의 주요 상태
    • 비영속(New) 상태
      • 단순히 new 키워드로 생성한 상태
      • JPA가 전혀 관리하지 않는 순수 JAVA 객체
    • 영속(Managed) 상태
      • persist(), find(0, merge() 등으로 영속성 컨텍스트에 관리되는 상태
      • 변경 감지, 1차 캐시, 쓰기 지연 등 모든 JPA 기능 활용 가능
    • 준영속(Detached) 상태
      • 영속 상태였다가 영속성 컨텍스트에서 분리된 상태
      • ID값은 있지만 JPA가 관리하지 않음
    • 삭제(Removed) 상태 
      • 삭제하기로 예정된 상태
      • 실제 DELETE 는 flush 시점에 실행

1-6. Dirty Checking 

  • JPA 의 더티 체킹(Dirty Checking) : JPA가 엔티티의 변경사항을 자동으로 감지하여 DB에 반영하는 기능
  • 동작 과정
    1. 트랜잭션 시작 -> 영속석 컨텍스트에 엔티티 로드
    2. JPA가 엔티티의 초기 상태를 스냅샷으로 저장 (1차 캐시) 
    3. 비즈니스 로직 실행 중 엔티티 필드 변경 (product.addStock())
    4. 트랜잭션 커밋 직전 -> 현재 상태와 스냅샷 비교
    5. 변경사항 발견 시 자동으로 UPDATE 쿼리 실행
  • 장점
    • 개발자가 save() 메소드를 명시적으로 호출할 필요 없음
    • 변경된 필드만 업데이트 (효율적인 쿼리)
    • 코드가 간결해짐
  • 주의사항 
    • @Transactional 안에서만 동작
    • 영속 상태(Persistent) 의 엔티티에서만 작동
    • 준영속(Detached) 상태에서는 더티 체킹 안됨
  • 예시
@Transactional
public void updateProductInfo(Long id, String newName) {
     Product product = repository.findById(id).get(); // 영속 상태
     product.setName(newName);                        // 필드 변경
     // save() 호출 안해도 자동으로 UPDATE 실행됨!
 }
  • CarController.java
package org.kosa.myproject.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.kosa.myproject.dto.CarDto;
import org.kosa.myproject.entity.Car;
import org.kosa.myproject.service.CarService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.stream.Collectors;

/*
    Rest : REpresentational State Transfer 대표 상태 전송
            분산 환경에서 시스템 간의 통신을 위한 아키텍처
            웹 기본 프로토콜인 HTTP를 활용한 아키텍트 스타일
            책(자원) /books/123
            대출(동작) POST /books/123/borrow
            반납(동작) DELETE /books/123/borrow
    Rest : 프론트 엔드와 백엔드의 명확한 분리 가능
            MSA 환경에서의 통신 방식
            Open API 제공(외부 개발자들이 우리 서비스를 활용)
   Rest 원칙
   1. 자원 식별 : URL로 자원을 고유하게 식별
   2. HTTP 메소드 : Get 조회, Post 생성, Put 수정, Delete 삭제, Patch 부분 수정
 */
@RestController//Rest API Controller @Controller + @ResponseBody
@RequestMapping("/api/cars")
@RequiredArgsConstructor //lombok 에서 final 필드에 대한 생성자를 정의
@Slf4j
public class CarController {
    private final CarService carService;

    /*
          Get  /api/cars  -> 모든 자동차 조회 collection
          Get /api/cars/1 -> 특정 자동차 조회 Resource
          POST /api/cars -> 새 자동차 생성
          PUT /api/cars/1 -> 자동차 수정
          DELETE /api/cars/1 -> 자동차 삭제
          PATCH /api/cars/1 -> 자동차 부분 수정
     */
    @GetMapping
    public ResponseEntity<List<CarDto>> getAllCars() {
        log.info("getAllCars 모든 자동차 조회");
        //db에서 자동차 리스트를 조회
        List<Car> cars = carService.findAllCars();
        //Entity 요소들이 저장된 List 를 이용해 Dto 요소가 저장된 새로운 리스트를 생성
        List<CarDto> carDtos = cars.stream()
                .map(car -> CarDto.from(car))
                .collect(Collectors.toUnmodifiableList());
        // HTTP 200 OK + 데이터 응답
        return ResponseEntity.ok(carDtos);
    }

    /*
        자동차 id로 자동차 정보 조회
        Http 요청 예시 :
        GET http://localhost:8080/api/cars/1

     */
    @GetMapping("/{carId}")
    public ResponseEntity<CarDto> findCarById(@PathVariable Long carId) {
        try {
            Car car = carService.findCarById(carId);
            CarDto carDto = CarDto.from(car);
            return ResponseEntity.ok(carDto);
        } catch (Exception e) {
            log.warn("자동차 조회 실패 ID {} 에러 {}", carId, e.getMessage());
            return ResponseEntity.notFound().build();//HTTP Response status 404로 응답
        }
    }
    /**
     * 자동차 정보 등록
     POST http://localhost:8080/api/cars
     Content-Type application/json
     {
          "modelName": "프리우스",
          "price": 45000000
     }
     @RequestBody : Http 요청 body의 JSON 을 Java 객체로 자동 변환, Spring 의 Jackson 라이브러리가 처리
     */
    @PostMapping
    public ResponseEntity<CarDto> createCar(@RequestBody CarDto carDto){
        try{
            Car createdCar = carService.createCar(carDto.getModelName(), carDto.getPrice());
            CarDto responseDto = CarDto.from(createdCar); //Entity 를 DTO 로 변환
            // Created 201 생성 작업 완료
            return ResponseEntity.status(HttpStatus.CREATED).body(responseDto);
        }catch(Exception e){
            return ResponseEntity.badRequest().build(); //400 Bad Request
        }

    }
    /*
        PUT http://localhost:8080/api/cars/2
        Content-Type application/json
        {
            "modelName":"니로",
            "price":27000000
        }
     */
    //전체 정보 수정할 때 Put 을 이용한다
    @PutMapping("/{carId}")
    public ResponseEntity<CarDto> updateCar(@PathVariable Long carId, @RequestBody CarDto carDto){
        try {
            Car updatedCar = carService.updateCar(carId, carDto.getModelName(), carDto.getPrice());
            CarDto responseDto = CarDto.from(updatedCar);//Entity 를 Dto로 변환해서 응답
            return ResponseEntity.ok(responseDto);
        }catch (Exception e){
            return ResponseEntity.notFound().build();
        }
    }

    /*
        삭제
        DELETE http://localhost:8080/api/cars/2
        삭제 응답
        성공 : HTTP Response Status code 204 No Content (응답 없음)
        실패 : Http 404 Not found
     */
    @DeleteMapping("/{carId}")
    public ResponseEntity<Void> deleteCar(@PathVariable Long carId){
        try{
            carService.deleteCar(carId);
            return ResponseEntity.noContent().build(); //204 정상 처리하고 응답 본문 없음을 알림
        }catch (Exception e){
            return ResponseEntity.notFound().build(); //404
        }
    }

    /**
     * 모델명으로 자동차 검색
     * 검색 중심이면 쿼리 스트링 사용하면 좋다
     *
     * Get http://localhost:8080/api/cars/search?modelName=소나타
     */
    @GetMapping("/search")
    //매개변수에 @RequestParam 안써줘도 기본적으로 적용이 됨
    public ResponseEntity<List<CarDto>> searchCarsByModelName(@RequestParam String modelName){
        List<Car> cars = carService.findCarsByModelName(modelName);
        //Entity 요소들로 구성된 List 를 Dto요소들로 구성된 List로 변환해 생성
        List<CarDto> carDtos = cars.stream()
                .map(CarDto::from)
                .collect(Collectors.toUnmodifiableList());
        return ResponseEntity.ok(carDtos);
    }
    /**
     * 자동차 가격 할인 적용
     * @param carId 자동차 ID
     * @param rate 할인율
     * @return 할인 적용된 자동차 정보
     *
     * HTTP 요청 예시 :
     * PATCH  http://localhose:8080/api/cars/5/discount-rate?rate=0.1
     *
     * PUT : 전체 리소스 교체
     * PATCH : 부분 수정
     *
     * 할인 적용은 가격만 변경하므로 PATCH 가 적합
     */
    //patch : 부분 변경 (할인율 같은것)
    @PatchMapping("/{carId}/discount-rate")
    public ResponseEntity<CarDto> applyDiscount(@PathVariable Long carId, @RequestParam  double rate){
        try {
            Car discountedCar = carService.applyDiscount(carId, rate);
            CarDto responseDto = CarDto.from(discountedCar);
            return ResponseEntity.ok(responseDto);
        } catch (Exception e) {
            return  ResponseEntity.badRequest().build();
        }

        }

}
  • CarService.java
package org.kosa.myproject.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.kosa.myproject.entity.Car;
import org.kosa.myproject.repository.CarRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

/*
    Service (Business 계층) : 비즈니스 로직을 처리하는 계층
                                         Controller 와 Repository 의 중간 매개 역할
                                         트랜잭션 관리 : 데이터 무결성과 일관성 보장 (ACID)
                                         복잡한 업무 규칙과 검증 절차, 예외 처리

 */
@Service
@Transactional(readOnly = true) //읽기 전용 트랜잭션
@RequiredArgsConstructor //lombok : final 필드에 대한 생성자 생성 -> 스프링 컨테이너가 DI
@Slf4j //lombok : Logging 을 위한 선언부 자동 생성
public class CarService {

    private final CarRepository carRepository;
    public List<Car> findAllCars(){
        List<Car> cars = carRepository.findAll();
        log.info("조회된 자동차 수: {} 대", cars.size());
        return cars;

    }

    public Car findCarById(long carId) {
        //있으면 car반환, 없으면 예외 처리
        return carRepository.findById(carId).orElseThrow(()-> new IllegalArgumentException("Car Id "+carId+"에 해당하는 자동자를찾을 수 없습니다"));

    }
    @Transactional
    public Car createCar(String modelName, Long price) {
        Car car = Car.builder().modelName(modelName).price(price).build();
        Car savedCar = carRepository.save(car);
        return savedCar;
    }

    @Transactional
    public void deleteCar(Long carId) {
        findCarById(carId); //carId 에 해당하는 자동차 없으면 예외 발생
        carRepository.deleteById(carId);
    }

    @Transactional
    public Car updateCar(Long carId, String modelName, Long price){
        Car existingCar = findCarById(carId);
        //Entity 의 비즈니스 메소드 호출
        //carRepository 의 메소드를 호출하지 않고 Entity 의 인스턴스 변수들의 정보를 변경
        //Dirty Checking 변경 감지 : 트랜잭션 별로 생성되는 EntityManager 가 Persistence Context 에 저장된 Entity 의 정보가 변경되었는지 확인해 변경되었으면 데이터베이스에 update 시킨다.
        existingCar.updateCar(modelName, price);

        return existingCar;
    }
}

 

1-7. Builder Design Pattern

  • 객체 생성 과정을 캡슐화하여 객체 생성과 표현을 분리하는 디자인 패턴
  • 복잡한 객체를 단계별로 구성할 수 있어 가독성과 생산성을 높이는 디자인 패턴
  • 이후에는 lombok 라이브러리로 간편하게 만든다.
package org.kosa.myproject.warmup.step1.builder;
/*
    Builder Design Pattern 을 직접 구현해본다(이후에는 lombok 라이브러리로 간편하게 만듬)
    Builder Pattern 이란 객체 생성 과정을 캡슐화하여 객체 생성과 표현을 분리하는 디자인 패턴
    복잡한 객체를 단계별로 구성할 수 있어 가독성과 생산성을 높이는 디자인 패턴

 */
public class User {

    private Long id;
    private String username;
    private String email;
    // 생성자에 private 명시하여 외부에서 직접 생성자를 호출하지 못하도록 막는다.
    private User(){ }

        //Builder 인스턴스를 반환하는 static method
        public static UserBuilder builder() {
            return new UserBuilder();
        }

    public User(Long id, String username, String email) {
        this.id = id;
        this.username = username;
        this.email = email;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    @Override
    public String toString() {
        return "User{" +
                "id=" + id +
                ", username='" + username + '\'' +
                ", email='" + email + '\'' +
                '}';
    }
/*
        User 객체를 빌드하기 위한 Builder 내부 클래스
        이 클래스가 실제 빌더 로직을 담당합니다.
         */

            public static class UserBuilder {
                private Long id;
                private String username;
                private String email;
                //private 생성자로 외부에서 생성자 호출 못하게 하고 오직 User.builder() 를 통해서만 접근 가능하게 함
                private UserBuilder(){

                }

                /*
                  id 값을 설정하는 메소드
                   */
                public UserBuilder id(Long id){
                    this.id=id;
                    return this;
                }

                public UserBuilder username(String username){
                    this.username = username;
                    return this; //메소드 체이닝
                }

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

                public User build(){
                    //필수 필드 유효성 검증 (예: username)
                    if (username == null || username.trim().isEmpty()){
                        throw new IllegalStateException("username은 필수입니다.");
                    }

                    User user = new User();
                    user.setId(this.id);
                    user.setUsername(this.username);
                    user.setEmail(this.email);
                    return user;
                }
            }
        }
package org.kosa.myproject.warmup.step2.lombok.test2;

import lombok.*;

@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class UserWithBuilderLombok {
    private Long id;
    private String username;
    private String email;
}

2. Rest API

2-1. REST(Representational State Transfer) 

  • 대표 상태 전송으로 분산 환경에서 시스템 간 통신을 위한 소프트웨어 아키텍처
  • 자원에 고유한 식별자(URI)를 부여하고 HTTP Method 로 제어하는 소프트웨어 아키텍처
  • HTTP GET(조회), POST(생성), PUT(수정), DELETE(삭제) Method 를 통해 제어
    • URI (Uniform Resource Identifier) : 네트워크 상 자원을 구분하는 식별자
    • URL (Uniform Resource Locator) : 네트워크 상 자원의 위치
  • 특징
    • 자원 정보를 고유한 URI를 부여해 활용
    • 다양한 클라이언트에게 서비스를 제공, 클라이언트와 서버 역할의 명확한 분리가 가능
    • 모바일, 태블릿, PC, TV 등과 같은 다양한 디바이스에 대한 서비스 및 다른 시스템과의 통신을 위해 사용됨
  • REST API : REST 특징을 기반으로 서비스 API를 구현한 것으로, 애플리케이션 간의 데이터 통신을 위한 애플리케이션 프로그래밍 인터페이스
  • REST API 설계 원칙 : URI는 정보와 자원을 표현하고 자원에 대한 행위는 HTTP Method 로 표현한다.
    • 자원 (Resource) : URI, 자원의 이름은 동사가 아닌 명사를 사용한다 => 자원 표현에 집중, 계층은 / 로 표현하고 필요시 - 를 이용한다.
    • 행위 (Action) : Http Method, 자원에 대한 행위는 Http Get, Post, Put, Delete Method 를 통한다.
      • 예) GET /products/123 : 123 id 상품 조회
  • RESTful : REST API 를 제공하는 웹 서비스 시스템을 지칭한다.
  • Spring Rest Annotation
    • @RestController : @Controller + @ResponseBody
    • @GetMapping
    • @PostMapping
    • @PutMapping
    • @DeleteMapping

  • 예) MemberController.java
package org.kosa.myproject.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.kosa.myproject.dto.CarDto;
import org.kosa.myproject.entity.Car;
import org.kosa.myproject.service.CarService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.stream.Collectors;

/*
    Rest : REpresentational State Transfer 대표 상태 전송
            분산 환경에서 시스템 간의 통신을 위한 아키텍처
            웹 기본 프로토콜인 HTTP를 활용한 아키텍트 스타일
            책(자원) /books/123
            대출(동작) POST /books/123/borrow
            반납(동작) DELETE /books/123/borrow
    Rest : 프론트 엔드와 백엔드의 명확한 분리 가능
            MSA 환경에서의 통신 방식
            Open API 제공(외부 개발자들이 우리 서비스를 활용)
   Rest 원칙
   1. 자원 식별 : URL로 자원을 고유하게 식별
   2. HTTP 메소드 : Get 조회, Post 생성, Put 수정, Delete 삭제, Patch 부분 수정
 */
@RestController//Rest API Controller @Controller + @ResponseBody
@RequestMapping("/api/cars")
@RequiredArgsConstructor //lombok 에서 final 필드에 대한 생성자를 정의
@Slf4j
public class CarController {
    private final CarService carService;

    /*
          Get  /api/cars  -> 모든 자동차 조회 collection
          Get /api/cars/1 -> 특정 자동차 조회 Resource
          POST /api/cars -> 새 자동차 생성
          PUT /api/cars/1 -> 자동차 수정
          DELETE /api/cars/1 -> 자동차 삭제
          PATCH /api/cars/1 -> 자동차 부분 수정
     */
    @GetMapping
    public ResponseEntity<List<CarDto>> getAllCars() {
        log.info("getAllCars 모든 자동차 조회");
        //db에서 자동차 리스트를 조회
        List<Car> cars = carService.findAllCars();
        //Entity 요소들이 저장된 List 를 이용해 Dto 요소가 저장된 새로운 리스트를 생성
        List<CarDto> carDtos = cars.stream()
                .map(car -> CarDto.from(car))
                .collect(Collectors.toUnmodifiableList());
        // HTTP 200 OK + 데이터 응답
        return ResponseEntity.ok(carDtos);
    }

    /*
        자동차 id로 자동차 정보 조회
        Http 요청 예시 :
        GET http://localhost:8080/api/cars/1

     */
    @GetMapping("/{carId}")
    public ResponseEntity<CarDto> findCarById(@PathVariable Long carId) {
        try {
            Car car = carService.findCarById(carId);
            CarDto carDto = CarDto.from(car);
            return ResponseEntity.ok(carDto);
        } catch (Exception e) {
            log.warn("자동차 조회 실패 ID {} 에러 {}", carId, e.getMessage());
            return ResponseEntity.notFound().build();//HTTP Response status 404로 응답
        }
    }
    /**
     * 자동차 정보 등록
     POST http://localhost:8080/api/cars
     Content-Type application/json
     {
          "modelName": "프리우스",
          "price": 45000000
     }
     @RequestBody : Http 요청 body의 JSON 을 Java 객체로 자동 변환, Spring 의 Jackson 라이브러리가 처리
     */
    @PostMapping
    public ResponseEntity<CarDto> createCar(@RequestBody CarDto carDto){
        try{
            Car createdCar = carService.createCar(carDto.getModelName(), carDto.getPrice());
            CarDto responseDto = CarDto.from(createdCar); //Entity 를 DTO 로 변환
            // Created 201 생성 작업 완료
            return ResponseEntity.status(HttpStatus.CREATED).body(responseDto);
        }catch(Exception e){
            return ResponseEntity.badRequest().build(); //400 Bad Request
        }

    }
    /*
        PUT http://localhost:8080/api/cars/2
        Content-Type application/json
        {
            "modelName":"니로",
            "price":27000000
        }
     */
    //전체 정보 수정할 때 Put 을 이용한다
    @PutMapping("/{carId}")
    public ResponseEntity<CarDto> updateCar(@PathVariable Long carId, @RequestBody CarDto carDto){
        try {
            Car updatedCar = carService.updateCar(carId, carDto.getModelName(), carDto.getPrice());
            CarDto responseDto = CarDto.from(updatedCar);//Entity 를 Dto로 변환해서 응답
            return ResponseEntity.ok(responseDto);
        }catch (Exception e){
            return ResponseEntity.notFound().build();
        }
    }

    /*
        삭제
        DELETE http://localhost:8080/api/cars/2
        삭제 응답
        성공 : HTTP Response Status code 204 No Content (응답 없음)
        실패 : Http 404 Not found
     */
    @DeleteMapping("/{carId}")
    public ResponseEntity<Void> deleteCar(@PathVariable Long carId){
        try{
            carService.deleteCar(carId);
            return ResponseEntity.noContent().build(); //204 정상 처리하고 응답 본문 없음을 알림
        }catch (Exception e){
            return ResponseEntity.notFound().build(); //404
        }
    }

    /**
     * 모델명으로 자동차 검색
     * 검색 중심이면 쿼리 스트링 사용하면 좋다
     *
     * Get http://localhost:8080/api/cars/search?modelName=소나타
     */
    @GetMapping("/search")
    //매개변수에 @RequestParam 안써줘도 기본적으로 적용이 됨
    public ResponseEntity<List<CarDto>> searchCarsByModelName(@RequestParam String modelName){
        List<Car> cars = carService.findCarsByModelName(modelName);
        //Entity 요소들로 구성된 List 를 Dto요소들로 구성된 List로 변환해 생성
        List<CarDto> carDtos = cars.stream()
                .map(CarDto::from)
                .collect(Collectors.toUnmodifiableList());
        return ResponseEntity.ok(carDtos);
    }
    /**
     * 자동차 가격 할인 적용
     * @param carId 자동차 ID
     * @param rate 할인율
     * @return 할인 적용된 자동차 정보
     *
     * HTTP 요청 예시 :
     * PATCH  http://localhose:8080/api/cars/5/discount-rate?rate=0.1
     *
     * PUT : 전체 리소스 교체
     * PATCH : 부분 수정
     *
     * 할인 적용은 가격만 변경하므로 PATCH 가 적합
     */
    //patch : 부분 변경 (할인율 같은것)
    @PatchMapping("/{carId}/discount-rate")
    public ResponseEntity<CarDto> applyDiscount(@PathVariable Long carId, @RequestParam  double rate){
        try {
            Car discountedCar = carService.applyDiscount(carId, rate);
            CarDto responseDto = CarDto.from(discountedCar);
            return ResponseEntity.ok(responseDto);
        } catch (Exception e) {
            return  ResponseEntity.badRequest().build();
        }

        }

}
  • MemberService.java
package org.kosa.myproject.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.kosa.myproject.dto.MemberCreateRequestDto;
import org.kosa.myproject.dto.MemberResponseDto;
import org.kosa.myproject.entity.Member;
import org.kosa.myproject.repository.MemberRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional(readOnly = true)  // 클래스 차원에서 읽기 전용 트랜잭션 모드
@Slf4j
@RequiredArgsConstructor // final field 에 대한 생성자를 정의해준다
public class MemberService {
    private final MemberRepository memberRepository;
    /**
     *   회원가입
     */
    @Transactional // insert, delete, update 에 대한 트랜잭션
    public MemberResponseDto createMember(MemberCreateRequestDto memberCreateRequestDto){
        log.info("회원 가입 시작 {}",  memberCreateRequestDto.toString());
        // 중복 체크
        if(memberRepository.findByUsername(memberCreateRequestDto.getUsername()).isPresent()){
            throw new IllegalArgumentException("이미 사용 중인 회원명입니다:"+memberCreateRequestDto.getUsername());
        }
        if(memberRepository.findByEmail(memberCreateRequestDto.getEmail()).isPresent()){
            throw new IllegalArgumentException("이미 사용중인 이메일입니다:"+memberCreateRequestDto.getEmail());
        }
        // Entity 생성 및 저장
        Member member = Member.builder()
                .username(memberCreateRequestDto.getUsername())
                .email(memberCreateRequestDto.getEmail())
                .build();
        Member savedMember =  memberRepository.save(member);
        // Entity 를 Dto 로 변환해 반환
        return MemberResponseDto.from(savedMember);
    }
    /**
     *   회원 조회
     *   1.  MemberRepository 의 findById(id) 를 이용해 회원정보를 조회한다
     *       MemberRepository 의 find 메서드는 Optional 로 반환되므로
     *       orElseThrow()  를 이용해 RuntimeException 을 발생시켜 throws 한다 ( 별도 throws 명시 필요 x )
     *   2.  return 은 Entity 를 Dto로 변환해서 한다
     */
    public MemberResponseDto  findMemberById(Long memberId){
        Member member = memberRepository.findById(memberId).orElseThrow(()->
                new RuntimeException("회원을 찾을 수 없습니다 ID:"+memberId)
        );
        return MemberResponseDto.from(member);
    }
}
  • MemberRepository.java
package org.kosa.myproject.repository;

import org.kosa.myproject.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    //username 으로 회원 조회 (중복 체크용)
    Optional<Member> findByUsername(String username);
    //email로 회원 조회 (중복 체크용)
    Optional<Member> findByEmail(String email);
}

2-2. 전체 데이터 흐름

  • Controller : HTTP 요청/응답 처리
  • Service : 비즈니스 로직 처리
  • Repository : 데이터 접근 처리
클라이언트 → Controller → Service → Repository → Database
           ← (DTO)     ← (Entity)    ← (Entity)     ←

2-3. Controller 의 3가지 핵심 역할

  • HTTP 요청 받기 : @RequestBody, @PathVariable, @RequestParam
  • 비즈니스 로직 위임 : Service 계층 호출
  • HTTP 응답 보내기 : ResponseEntity 로 상태 코드 + 데이터

2-4. Dto Pattern

// Entity는 내부용 (JPA, 비즈니스 로직)
@Entity
public class Car { ... }

// DTO는 외부용 (API 통신)
public class CarDto { ... }
  • 보안 : 민감한 정보 노출 방지
  • 안정성 : Entity 변경이  API에 직접 영향 안줌
  • 유연성 : API 별로 다른 데이터 구조 가능

2-5. OpenAPI 와 Swagger 

  • OpenAPI : API의 명세를 정의하는 표준 규격
  • Swagger : OpenAPI의 표준 규격을 기반으로 실제 API 문서를 만들고, 시각화하며, 테스트하는 데 도움을 주는 도구
    • REST API 문서 자동 생성
    • API 테스트 UI 제공
    • 프론트엔드 개발자와의 협업 도구
    • 접속 URL: http://localhost:8080/swagger-ui/index.html

3. JPQL

3-1. JPQL(Java Persistence Query Language)

  • JPA에서 사용하는 객체지향 쿼리 언어
  • 특정 DataBase(MySQL, Oracle 등)에 종속되지 않는다.
  • JPA 구현체(Hibernate, EclipseLink 등)가 JPQL 을 해당 데이터베이스의 방언(Dialect)에 맞는 SQL로 변환하여 실행한다.
  • 데이터베이스를 변경하더라도 JPQL 코드를 수정할 필요가 없다.

3-2. N+1 문제

  • @ManyToOne : 다(Many) - 여러 개의 게시물 (post) | 일(One) - 한 명의 회원 (member)
  • JPA 설계의 모범 사례 : 단방향 @ManyToOne 관계를 설정하고, 양방향 관계가 정말로 필요한 경우에만 OneToMany를 추가한다. @ManyToOne 이 관계의 핵심이 되고, @OneToMany 는 편의성을 위한 보조적인 역할로 사용되는 경우가 많다.
    • 단방향 관계의 충분성 : 대부분의 비즈니스 로직에서는 단방향 @ManyToOne 관계만으로도 충분한 경우가 많다.(게시물에서 작성자를 찾는 경우는 빈번하지만 회원에서 모든 게시물을 찾는 경우는 상대적으로 적거나, 필요하다면 JPQL 등을 이용해 별도의 쿼리로 처리 가능)
    • @OneToMany 관계는 기본적으로 지연 로딩(Lazy Loading)으로 설정되지만, 잘못 사용하면 불필요한 쿼리가 많이 발생하는 N+1 문제를 유발하기 쉽다. (회원 목록을 조회할 때 각 회원의 게시물 목록까지 로드하려고 하면, 회원 수에 비례하여 추가적인 쿼리가 발생할 수 있다.
  • N+1 문제 : 연관 관계가 있는 엔티티를 조회할 때 발생하는 성능 문제
    • 게시물 100개를 조회할 때 1번의 쿼리로 100개의 게시물 목록을 가져오고, 각 게시물에 연결된 회원 정보를 가져오기 위해 100번의 추가 쿼리가 발생하는 상황
    • 결과적으로 총 101번의 쿼리가 실행되어 성능 저하를 초래한다. 이는 특히 @ManyToOne 관계가 즉시 로딩(Eager Loading)으로 설정되어 있거나, 지연 로딩(Lazy Loading)상태에서 반복문 내 연관 엔티티에 접근할 때 자주 발생한다.
    • 해결 원리 : JPQL의 FETCH JOIN
      • SQL의 JOIN 구문과 유사하지만, 단순히 두 테이블을 조인하는 것을 넘어 JPA가 연관된 엔티티를 처음부터 함께 로드하도록 지시하는 역할을 한다.
      • SELECT p FROM Post p JOIN FETCH p.member
        • JOIN FETCH p.memeber : JPA 에게 Post 엔티티를 조회할 때 연관된 member 엔티티도 함께 가져오라고 명령한다.
        • JPA는 이 명령을 받아 내부적으로 SQL JOIN을 사용하여 한 번의 쿼리로 Post 와 Member 데이터를 모두 조회한 후, 이를 바탕으로 객체 그래프를 완성한다.
        • 따라서 N번의 쿼리가 단 1번으로 줄어들어 성능이 크게 향상된다.
  • 예) PostService.java
package org.kosa.myproject.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.kosa.myproject.dto.PostCreateRequestDto;
import org.kosa.myproject.dto.PostDetailResponseDto;
import org.kosa.myproject.dto.PostListResponseDto;
import org.kosa.myproject.entity.Member;
import org.kosa.myproject.entity.Post;
import org.kosa.myproject.repository.MemberRepository;
import org.kosa.myproject.repository.PostRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class PostService {
    private final PostRepository postRepository;
    private final MemberRepository memberRepository;

    /**
     * 게시물 생성
     */
    @Transactional
    public PostDetailResponseDto createPost(PostCreateRequestDto postCreateRequestDto) {
        //작성자 조회, Member Table에 존재하는 회원 작성자인지 확인, 없으면 예외 발생
        Member author = memberRepository.findById(postCreateRequestDto.getMemberId()).orElseThrow(() -> new RuntimeException("회원을 찾을 수 없어 게시물을 등록할 수 없습니다 MEMBER ID:" + postCreateRequestDto.getMemberId()));

        //Post 생성 : ManyToOne 관계설정 -> .member(author)
        Post post = Post.builder()
                .title(postCreateRequestDto.getTitle())
                .content(postCreateRequestDto.getContent())
                .member(author) //ManyToOne 관계설정
                .build();
        //db에 저장
        Post savedPost = postRepository.save(post); //save가 반환하는 Post Entity 에는 자동 생성된 postId 와 생성일시가 할당되어 있음
        return PostDetailResponseDto.from(savedPost);//Dto로 변환하여 컨트롤러에 반환한다.
    }
    /**
     * 게시물 전체 조회 -> N+1 문제 발생 버전 (학습을 위해)
     * N+1 문제는 연관 관계가 있는 엔티티를 조회할 때 발생하는 성능 문제
     * 회원 100명이 작성한 게시물 100개를 조회할 때
     * 1번의 쿼리로 게시물 리스트를 가져오고
     * 각 게시물에 연결된 회원 정보를 가져오기 위해 100번의 추가 쿼리가 발생되는 상황을 말함
     * ==> 성능 저하가 초래 ==해결==> JPQL의 Fetch Join으로 해결함
     */

    public void demonstrateNPlusOneProblem(){
        log.info("===N+1 문제 발생 시연===");
        //1번 쿼리 : Select * FROM posts
        List<Post> posts = postRepository.findAll(); //JpaRepository 에 기본 제공되는 메소드를 이용한다.
        log.info("1번 쿼리 실행:{}개의 게시물 조회", posts.size());
        for(Post post : posts){
            String authorName = post.getMember().getUsername();
            log.info("추가 쿼리! 게시물:{} 작성자 {}", post.getTitle(), authorName);
        }
        log.info("N번의 쿼리 실행=>성능 저하");
    }
    /**
     *  N + 1 문제 해결
     *  JPQL Fetch Join 으로 한번의 SQL 로 조회
     *  -> N 번의 쿼리가 단 1번으로 줄어 성능이 크게 향상
     */
    public List<PostListResponseDto> findAllPostList(){
        List<Post> posts = postRepository.findAllWithmember(); //JPQL Fetch JOIN 적용된 repository 메소드 호출

        return posts.stream().map(PostListResponseDto::from).collect(Collectors.toUnmodifiableList());
    }
    /**
     * 상세 게시물 조회 - JPQL Fetch JOIN 이용한 메소드 사용
     */
    public PostDetailResponseDto findPostDetail(Long postId){
        log.info("게시물 상세 조회 : postId {}", postId);
        Post post = postRepository.findByIdWithMember(postId).orElseThrow(()->new RuntimeException("게시물을 찾을 수 없습니다 postId="+postId));
        return PostDetailResponseDto.from(post); //Dto로 변환하여 반환
    }
    /**
     * 특정 회원의 게시물 목록 조회 : @ManyToOne 으로 모두 가능
     */
    public List<PostListResponseDto> findPostListByMember(Long memberId){
        if(!memberRepository.existsById(memberId)){
            throw new RuntimeException("회원을 찾을 수 없습니다 memberId" + memberId);
        }
        List<Post> posts = postRepository.findPostListByMemberId(memberId);
        //stream(), map()으로 Entity List 를 Dto List 로 변환
        return posts.stream().map(PostListResponseDto::from).collect(Collectors.toUnmodifiableList());
    }
}
  • PostRepository.java
package org.kosa.myproject.repository;

import org.kosa.myproject.entity.Post;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
    //List<Post> findAll(); //만약 @ManyToOne 관계에서 조회시 N+1 문제 발생 가능성
    //JPQL Fetch Join 으로 N + 1 문제 해결
    /**
     *  N + 1 문제 해결
     *  JPQL Fetch Join 으로 한번의 SQL 로 조회
     *  -> N 번의 쿼리가 단 1번으로 줄어 성능이 크게 향상
     *
     *  JPQL의 FETCH JOIN 은 데이터베이스에 독립적이고 sql의 join 을 넘어서
     *  JPA가 연관된 엔티티를 (게시물 객체 조회 시 회원 (작성자) 객체까지 함께 조회해 할당) 함께 로드하도록 함
     *  ==> 게시물 엔티티 내에 회원 엔티티까지 저장되어 반환
     */
    @Query("SELECT p FROM Post p JOIN FETCH p.member")
    List<Post> findAllWithmember();
    /**
     * 특정 상세 게시물 조회 (작성자 (회원)정보 포함)
     * JPQL FETCH JOIN 을 이용해 게시물 조회시 작성자 정보도 함께 조회
     */
    @Query("SELECT p FROM Post p JOIN FETCH p.member WHERE p.postId = :postId")
    Optional<Post>findByIdWithMember(@Param("postId") Long postId);
    /**
     * 특정 회원의 게시물 목록 조회
     * JPQL FETCH JOIN을 이용
     */
    @Query("SELECT p FROM Post p JOIN FETCH p.member WHERE p.member.memberId= :memberId")
    List<Post> findPostListByMemberId(@Param("memberId") Long memberId);
}
  • PostListResponseDto.java
package org.kosa.myproject.dto;

import lombok.Builder;
import lombok.Getter;
import org.kosa.myproject.entity.Post;

import java.time.LocalDateTime;

/**
 * 게시물 리스트에 필요한 정보만 응답하기 위한 Dto
 */
@Getter
@Builder
public class PostListResponseDto {
    private Long postId;
    private String title;
    private String authorName;
    private LocalDateTime createdAt;
    //Entity -> Dto 변환
    public static PostListResponseDto from(Post post){
        return PostListResponseDto.builder()
                .postId(post.getPostId())
                .title(post.getTitle())
                .authorName(post.getMember().getUsername())
                .createdAt(post.getCreatedAt())
                .build();
    }

}
  • PostController.java
package org.kosa.myproject.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.kosa.myproject.dto.PostCreateRequestDto;
import org.kosa.myproject.dto.PostDetailResponseDto;
import org.kosa.myproject.dto.PostListResponseDto;
import org.kosa.myproject.entity.Post;
import org.kosa.myproject.service.PostService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

/**
 * Post Rest Controller
 * 게시물 관련 API 엔드포인트
 */
@RestController
@RequestMapping("/api/posts")
@RequiredArgsConstructor
@Slf4j
public class PostController {
    private final PostService postService;
    /**
     * 게시물 등록
     */
    @PostMapping
    public ResponseEntity<PostDetailResponseDto> createDto(@RequestBody PostCreateRequestDto postCreateRequestDto){
        PostDetailResponseDto postDetailResponseDto = postService.createPost(postCreateRequestDto);
        return ResponseEntity.status(HttpStatus.CREATED).body(postDetailResponseDto);
    }
    @GetMapping("/demo/n-plus-one")
    public ResponseEntity<String> demonstrateNPlusOne(){
        postService.demonstrateNPlusOneProblem();
        return ResponseEntity.ok("콘솔 로그 확인하세요");
    }
    /**
     * 전체 게시물 조회 (N+1문제 해결) => JPQL FETCH JOIN 으로 해결 -> 1번의 쿼리로 처리
     */
    @GetMapping
    public ResponseEntity<List<PostListResponseDto>> findAllPostList(){
        List<PostListResponseDto> posts = postService.findAllPostList();
        return ResponseEntity.ok(posts);
    }
    /**
     * 상세 게시물 조회 - JPQL FETCH JOIN
     */
    @GetMapping("/{id}")
    public ResponseEntity<PostDetailResponseDto> findPostDetail(@PathVariable Long id){
        PostDetailResponseDto postDetailResponseDto = postService.findPostDetail(id);
        return ResponseEntity.ok(postDetailResponseDto);
    }
    /*
        특정 회원의 게시물 조회
     */
    @GetMapping("/member/{memberId}")
    public ResponseEntity<List<PostListResponseDto>> findPostListByMember(@PathVariable Long memberId){
        List<PostListResponseDto> posts = postService.findPostListByMember(memberId);
        return ResponseEntity.ok(posts);
    }
}

4. ControllerAdvice

4-1. ControllerAdvice

  • 횡단 관심사(Cross-cutting Concerns) 담당
    • 모든 Controller 에서 공통으로 필요한 예외 처리를 한 곳에서 관리
    • AOP(관점 지향 프로그래밍)의 실제 구현체
    • 코드 중복 제거와 유지보수성 향상
    • 단일 책임 원칙 : 각 클래스가 하나의 책임만 가짐
    • 개방-폐쇄 원칙 : 새로운 예외 타입 추가 시 기존 코드 수정 없이 확장 가능
  • 관심사의 완전한 분리
    • Controller : 비즈니스 로직에만 집중
    • GlobalExceptionHandler : 예외 처리에만 집중
  • 표준화된 에러 응답
  • 로깅과 모니터링 
    • 모든 예외가 중앙에서 로깅되어 시스템 모니터링 용이
    • 에러 패턴 분석과 장애 대응 신속화
  • 클라이언트 친화적
    • 일관된 에러 형식으로 프론트엔드 개발자가 예측 가능한 예외 처리
    • 에러 코드를 통한 프로그래밍적 대응 가능
  • 보안과 안정성
    • 시스템 내부 정보는 로그에만 기록
    • 사용자에게는 안전한 메시지만 노출

'교육' 카테고리의 다른 글

16주차 - React  (0) 2025.09.21
14주차 - Ajax  (0) 2025.08.10
13주차 - Thymeleaf  (0) 2025.07.28
12주차 - Spring  (0) 2025.07.20
11주차 - Design Pattern  (0) 2025.07.13