JPA 5

j0yy00n0·2025년 4월 22일
post-thumbnail

2025.03.28

JPA


JPA spring-data-jpa

지금까지 정리한 JPA 관련 내용들은 Hibernate 기반의 순수 JPA 환경에서 직접 구현한 방식
이제부터는 Spring 프레임워크에서 제공하는 pring Data JPA 모듈을 활용
-> 보다 편리하고 자동화된 방식으로 JPA 사용

JPA 단점

  • 엔티티 매핑 직접 설정
  • EntityManager 직접 사용
  • CRUD 메서드 매번 직접 구현
  • JPQL 쿼리도 직접 작성해야 함

spring-data-jpa

JPA를 더 쉽고 자동화된 방식으로 사용할 수 있도록 도와주는 Spring의 모듈

  • 기본 CRUD 자동 생성 : findAll(), save(), deleteById() 등 직접 구현 안 해도 됨
  • 쿼리 메서드 지원 : findByMenuNameLike()처럼 메서드 이름만으로 쿼리 실행 가능
  • @Query로 직접 쿼리 작성도 가능 : 복잡한 조건이 필요할 땐 JPQL 또는 Native Query 사용
  • 페이징 / 정렬 기본 제공 : Pageable, Sort 매개변수로 페이지 처리도 자동
  • 인터페이스 기반 : JpaRepository만 상속하면 구현체는 자동으로 만들어줌

개발 흐름 순서

  1. 의존성 추가 (build.gradle / pom.xml)
  2. application.yml 설정
  3. Entity 생성 (DB 테이블 기준으로 우선)
  4. Repository 생성 (Entity와 연결)
  5. DTO 생성 (화면에 전달할 데이터 구조)
  6. ModelMapper 설정 (BeanConfig 등에서)
  7. Service 생성 (비즈니스 로직 구현)
  8. Controller 생성 (요청 처리)
  9. templates 및 static 리소스 구성 (Thymeleaf 뷰, css/js 등)
  10. application class의 base package 경로 확인 (컴포넌트 스캔 범위)
  11. View 화면 구성 (HTML 템플릿 작업)

사용자 요청 흐름 순서

사용자 요청 -> Controller -> Service -> Repository -> DB(Entity) -> 결과
-> DTO -> View 렌더링


Spring Data JPA 프로젝트 생성 - IntelliJ 기준, 사용자 요청 흐름 순서

1. 의존성 추가

  • Spring Web : 내장 톰캣(Embedded Tomcat) 기반 웹 서버 구동용
  • Spring Boot DevTools : 코드 수정 시 자동 리로딩 / 빠른 재시작 지원
  • Thymeleaf : HTML 템플릿 엔진, 서버에서 받은 데이터를 뷰에 출력
  • MySQL Driver : MySQL DB와 연결하기 위한 드라이버
  • Spring Data JPA : JPA를 더 편리하게 쓰도록 도와주는 Spring 모듈
  • Lombok : @Getter, @Setter, @Builder 등 코드 자동 생성 도와줌
  • ModelMapper 라이브러리 추가 : DTO ↔ Entity 변환 자동화를 도와주는 라이브러리
    @Bean으로 등록 후 modelMapper.map(source, targetType) 형식으로 사용
// https://mvnrepository.com/artifact/org.modelmapper/modelmapper
    implementation("org.modelmapper:modelmapper:3.1.1")

2. application.yml 생성

  • 스프링 부트 프로젝트의 전반적인 환경 설정을 담당하는 파일
  • 기존의 application.properies보다 계층적 구조와 가독성이 좋은 .yml로 변경
spring:
  datasource:
  	<!-- 사용할 JDBC 드라이버 -->
    <!-- 현재 mysql 사용 -->
    driver-class-name: com.mysql.cj.jdbc.Driver
    <!-- 접속할 DB의 URL -->
    url: jdbc:mysql://localhost:3306/DB이름
    <!-- DB 접속 계정 -->
    username: DB사용자ID
    <!-- DB 접속 비밀번호 -->
    password: DB비밀번호

  jpa:
  	<!-- 실행되는 SQL 콘솔에 출력 여부 -->
    show-sql: true
    <!-- 사용 중인 데이터베이스 종류 -->
    database: mysql
    properties:
      hibernate:
      	<!-- SQL을 보기 좋게 포맷 출력 -->
        format_sql: true
    hibernate:
      <!-- 테이블 자동 생성 전략 (none / update / create 등) -->
      <!-- none은 기존 DB 테이블을 그대로 사용 -->
      ddl-auto: none

3. Spring MVC 컨트롤러 생성 후 Application 경로 지정

@Controller
public class 클래스이름 {

    <!--
      View 반환 방식
      1. String: 뷰 이름 반환
      2. void: 요청 경로를 뷰 이름으로 사용
      3. ModelAndView: 뷰와 데이터를 함께 전달
     -->

    @GetMapping(value = {"/", "/요청경로"})
    public String 메서드이름() {
    	<!-- templates/뷰폴더/뷰파일명.html -->
        return "뷰폴더/뷰파일명";
    }

}

4. templates 안에 .html view 파일 만들기

  • main view 만들기
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Main</title>
</head>
<body>

    <h1>🍽️ 서비스 메인 페이지 🍽️</h1>

    <button onclick="location.href='/menu/1'">단일 항목 조회</button>

    <button onclick="location.href='/menu/list'">전체 목록 보기</button>

    <button onclick="location.href='/menu/querymethod'">조건 검색 테스트</button>

    <button onclick="location.href='/menu/regist'">항목 등록</button>

    <button onclick="location.href='/menu/modify'">항목 수정</button>

    <button onclick="location.href='/menu/delete'">항목 삭제</button>

</body>
</html>
  • 특정 목록 조회 view
<head>
    <meta charset="UTF-8">
    <title>selected menu</title>
</head>
<body>
    <table>
        <tr>
            <th>메뉴번호</th>
            <th>메뉴이름</th>
            <th>메뉴가격</th>
            <th>카테고리코드</th>
            <th>판매상태</th>
        </tr>
        <tr>
            <td th:text="${ menu.menuCode }"></td>
            <td th:text="${ menu.menuName }"></td>
            <td th:text="${ menu.menuPrice }"></td>
            <td th:text="${ menu.categoryCode }"></td>
            <td th:text="${ menu.orderableStatus }"></td>
        </tr>
    </table>
</body>
  • 전체 목록 조회 view
<head>
    <meta charset="UTF-8">
    <title>list</title>
</head>
<body>
    <table>
        <tr>
            <th>메뉴번호</th>
            <th>메뉴이름</th>
            <th>메뉴가격</th>
            <th>카테고리코드</th>
            <th>판매상태</th>
        </tr>
        <tr th:each="menu : ${ menuList }">
            <th th:text="${ menu.menuCode }"></th>
            <th th:text="${ menu.menuName }"></th>
            <th th:text="${ menu.menuPrice }"></th>
            <th th:text="${ menu.categoryCode }"></th>
            <th th:text="${ menu.orderableStatus }"></th>
        </tr>
    </table>
</body>    
  • 조건 검색 테스트 view
<head>
    <meta charset="UTF-8">
    <title>query method</title>
</head>
<body>
    <h3>입력 가격을 초과하는 메뉴 목록 조회</h3>
    <form action="/menu/search">
        <input type="number" name="menuPrice">원을 초과하는 메뉴 목록 조회
        <input type="submit">
    </form>
</body>
<head>
    <meta charset="UTF-8">
    <title>search result</title>
</head>
<body>
    <h1>[[${menuPrice}]]원을 초과하는 메뉴 목록</h1>
    <table>
        <tr>
            <th>메뉴번호</th>
            <th>메뉴이름</th>
            <th>메뉴가격</th>
            <th>카테고리코드</th>
            <th>판매상태</th>
        </tr>
        <tr th:each="menu : ${ menuList }">
            <th th:text="${ menu.menuCode }"></th>
            <th th:text="${ menu.menuName }"></th>
            <th th:text="${ menu.menuPrice }"></th>
            <th th:text="${ menu.categoryCode }"></th>
            <th th:text="${ menu.orderableStatus }"></th>
        </tr>
    </table>
</body>
  • 등록 view
<head>
    <meta charset="UTF-8">
    <title>regist menu</title>
</head>
<body>
    <h1>신규 메뉴 등록</h1>

    <form action="/menu/regist" method="post">
        <label>메뉴 이름 : </label><input type="text" name="menuName"><br>
        <label>메뉴 가격 : </label><input type="number" name="menuPrice"><br>
        <label>카테고리 : </label>
        	<!-- 비동기 처리 -->
            <select name="categoryCode" id="categoryCode">
            </select><br>
        <label>판매 상태 : </label>
            <select name="orderableStatus">
                <option value="Y">판매가능</option>
                <option value="N">판매불가</option>
            </select><br>
        <input type="submit" value="메뉴 등록">
    </form>

	<!-- 비동기로 데이터를 잡아 챈다. -->
    <script>
        fetch('/menu/category')
            .then(res => res.json())
            .then(data => {
                const $categoryCode = document.getElementById('categoryCode');

                for(let index in data) {
                    const $option = document.createElement('option');
                    $option.value = data[index].categoryCode;
                    $option.textContent = data[index].categoryName;

                    $categoryCode.appendChild($option);
                }
            });
    </script>
</body>
  • 수정 view
<head>
    <meta charset="UTF-8">
    <title>modify menu</title>
</head>
<body>
    <h1>메뉴 이름 수정</h1>

    <form action="/menu/modify" method="post">
        <label>수정할 메뉴 번호 : </label>
        <input type="number" name="menuCode"><br>
        <label>수정할 메뉴 이름 : </label>
        <input type="text" name="menuName"><br>
        <input type="submit" value="메뉴 수정">
    </form>
</body>
</html>
  • 삭제 view
<head>
    <meta charset="UTF-8">
    <title>delete menu</title>
</head>
<body>
    <h1>메뉴 삭제</h1>
    <form action="/menu/delete" method="post">
        <label>삭제할 메뉴 번호 : </label>
        <input type="number" name="menuCode"><br>
        <input type="submit" value="메뉴 삭제">
    </form>
</body>
</html>

5. Entity 생성 (DB 테이블 기준으로 우선)

  • Menu
@Entity
@Table(name = "tbl_menu")
@NoArgsConstructor // 기본 생성자
@AllArgsConstructor // 모든 필드 초기화 하는 생성자
@Getter
//@Setter Setter 는 엔티티에서 지양
@ToString
<!-- @Builder : 객체 생성 시, 
	 빌더 패턴을 적용하여 유연하게 인스턴스를 생성할 수 있게 해준다.
     (toBuilder = true) : 기존 인스턴스를 기반으로 수정된 필드만 
     변경한 새로운 인스턴스를 생성할 수 있다. -->
@Builder(toBuilder = true) // 2.@Builder 사용
public class Menu {

    @Id
    @Column(name = "menu_code")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int menuCode;

    @Column(name = "menu_name")
    private String menuName;

    @Column(name = "menu_price")
    private int menuPrice;

    @Column(name = "category_code")
    private int categoryCode;

    @Column(name = "orderable_status")
    private String orderableStatus;
    
    
    
  • Category
@Entity
@Table(name = "tbl_category")
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@ToString
public class Category {

    @Id
    @Column(name = "category_code")
    private int categoryCode;

    @Column(name = "category_name")
    private String categoryName;

    @Column(name = "ref_category_code")
    <!-- int 로 설정하면 초기화 값은 0으로 자동 설정된다.
         Integer로 설정하면 null값을 허용 -->
    private Integer refCategoryCode;
}

6. DTO 생성 (화면에 전달할 데이터 구조)

  • MenuDTO
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@ToString
public class MenuDTO {
    // 화면 상에 넘어오는 데이터, 화면에 응답해줄 데이터를 여기에 맵핑

    private int menuCode;
    private String menuName;
    private int menuPrice;
    private int categoryCode;
    private String orderableStatus;

}
  • CategoryDTO
@Data
public class CategoryDTO {

    private int categoryCode;
    private String categoryName;
    private Integer refCategoryCode;
}

7. Controller 생성 (요청 처리)

<!-- 로그 출력용 Lombok 어노테이션 -->
@Slf4j
@Controller
@RequestMapping("/menu")
<!-- 필드에 final 붙은 객체를 자동으로 생성자 주입을 해준다. -->
@RequiredArgsConstructor // DI 의존성 주입
public class MenuController {

	<!-- 서비스 계층 연결 (의존성 주입) -->
    private final MenuService menuService;

    <!-- 특정 메뉴를 조회하는 메소드 -->
    @GetMapping("/{menuCode}")
    public String findMenuByCode(@PathVariable int menuCode, Model model) {

		<!-- Service를 통해 메뉴 조회 -->
        MenuDTO foundMenu = menuService.findMenuByCode(menuCode);
		
        <!-- 조회된 메뉴 정보를 모델에 담아 view로 전달 -->
        model.addAttribute("menu", foundMenu);

        return "menu/detail";
    }
    
    <!-- 메뉴 전체조회를 실행하는 핸들러 메소드 -->
    @GetMapping("/list")
    public String findMenuList(Model model){

		<!-- 여러가지 행을 조회하므로 List타입으로 작성 -->
        List<MenuDTO> menuList = menuService.findAllMenu();

        model.addAttribute("menuList", menuList);

        return "menu/list";

    }
    
    <!-- 서브메뉴 페이지로 이동할 수 있도록 설정 -->
    @GetMapping("/querymethod")
    <!-- 메서드의 반환 타입이 void일 경우,
         요청 경로와 동일한 이름의 뷰 템플릿(.html)을 자동으로 렌더링 -->
    public void queryMethodSubPage(){}
    
    
    <!-- 등록 -->
    @GetMapping("/regist")
    public void registPage(){}
    
    <!-- 페이지를 return 하지 않고 Rest-API 방식으로 클라이언트에
    	 data 만 리턴하는 메소드(JSON) -->
    @GetMapping("/category")
    <!-- view가 아닌 data를 리턴할 의무가 있기 때문에 
    	 서비스 계층에 dto 형식으로 리턴 -->
    @ResponseBody
    public List<CategoryDTO> findCategoryList(){

        return menuService.findAllCategory();
    }
    
    @PostMapping("/regist")
    public String registNewMenu(MenuDTO menuDTO) {
        menuService.registNewMenu(menuDTO);

        return "redirect:/menu/list";
    }
    
    <!-- 수정 -->
    <!-- 메뉴 수정 페이지 이동 GET -->
    @GetMapping("/modify")
    public void modifyPage(){}

	<!-- 메뉴 수정처리 POST -->
    @PostMapping("/modify")
    <!-- @ModelAttribute : 폼에서 전달된 값을 MenuDTO 객체로 자동 매핑
    	 (데이터 바인딩) -->
    public String modifyMenu(@ModelAttribute MenuDTO modifyMenu){

        menuService.modify(modifyMenu);

		<!-- 수정이 끝나면 해당 메뉴 상세 페이지로 리다이렉트
        	 /menu/{menuCode} 경로 -->
        return "redirect:/menu/"+ modifyMenu.getMenuCode();

    }
    
    <!-- 삭제하기 -->
    @GetMapping("/delete")
    public void deletePage(){}

    @PostMapping("/delete")
    public String deleteMenu(@RequestParam Integer menuCode){
        menuService.deleteMenu(menuCode);
        return "redirect:/menu/list";
    }
    
    
    

8. Service 생성 (비즈니스 로직 구현)

Sercvice 레이어에서는 Controller 계층에서 전달 받은 DTO 타입의 객체를 Entity 타입으로 변환을 할 것이다.

  • modelmapper 라이브러리 사용으로 편하게 변환

JPA에서 제공하는 기본 메서드만 사용하는 것이 아니라, 비즈니스 로직에 맞는 사용자 지정(커스텀) 데이터베이스 조회/처리 메서드도 직접 정의하여 사용

@Service
@RequiredArgsConstructor
public class MenuService {

	<!-- repository 계층 연결 -->
    private final MenuRepository repository;
    <!-- ModelMapper 연결 -->
    private final ModelMapper modelMapper;
    private final CategoryRepository categoryRepository;
    private final MenuRepository menuRepository;

    public MenuDTO findMenuByCode(int menuCode) {

        <!-- 기대값은 Menu -> 실제 값은 Optional<Menu>
        	 따라서 findById 메소드는 에러 핸들링을 반드시 구현하게 만들어두었다. -->
        <!--
          findById()는 Optional<Menu>를 반환하므로,
          조회 결과가 없을 경우 예외를 던지도록 .orElseThrow()로 명시한다.
          - 존재하지 않는 ID가 들어올 수 있기 때문.
          - 현재는 IllegalAccessError로 처리하고 있으며, 커스텀 예외로 교체 가능.
         -->
        Menu foundMenu = repository.findById(menuCode)
                                   .orElseThrow(IllegalAccessError::new);


        <!-- 엔티티 타입을 조회해왔는데 실제 리턴 타입은 DTO 타입이다.
        	 따라서 이제 필요한 것이 Entity 타입을 DTO 로 변환해야 하는 과정이 필요
        	 1 번째 인자 -> 변환 대상  // 2 번째 인자 -> 대상 타입 -->
        return modelMapper.map(foundMenu, MenuDTO.class);
    }
    
    public List<MenuDTO> findAllMenu() {

        List<Menu> menuList = repository.findAll();

        return menuList.stream()
                .map(menu -> modelMapper.map(menu,MenuDTO.class))
                .collect(Collectors.toList());
    }
    
    <!-- JpaRepository 에서 구현 된 querymethod 만 사용하는 것이 아닌
    	 메서드 커스터마이징 -->
    public List<MenuDTO> findByMenuPrice(int menuPrice) {

		<!-- Entity가 가지고 있는 필드값을 알아서 찾아준다. -->
        List<Menu> menuList = repository.findByMenuPriceGreaterThan(menuPrice);
        <!-- menuPrice의 값보다 더 큰 값만 조회(GreaterThan) -->

        return menuList.stream()
                .map(menu -> modelMapper.map(menu, MenuDTO.class))
                .collect(Collectors.toList());

    }
    
    <!-- Category 엔티티를 조회 -> CategoryDTO로 변환하여 반환-->
    public List<CategoryDTO> findAllCategory() {

        List<Category> categoryList = categoryRepository.findAllCategory();

        return categoryList.stream()
                .map(category -> modelMapper.map(category, CategoryDTO.class))
                .collect(Collectors.toList());
    }
    
    <!-- 등록하기 save() -->
    @Transactional
    public void registNewMenu(MenuDTO newMenu) {
        menuRepository.save(modelMapper.map(newMenu, Menu.class));
    }
    
    <!-- 수정하기, 영속성 -->
    @Transactional
    public void modify(MenuDTO modifyMenu) {

        <!-- 수정하기 위해 찾아오기 -->
        Menu foundMenu = repository.findById(modifyMenu.getMenuCode()).orElseThrow(IllegalArgumentException::new);

        <!-- builder 패턴 
           	 메뉴 이름만 수정 후 인스턴스 다시 생성 -> 다른 값들은 그대로
         	 Entity는 setter 메소드를 지양하기 때문에 builder 패턴을 사용 -->
        foundMenu = foundMenu.toBuilder().menuName(modifyMenu.getMenuName()).build();

        <!-- 빌드한 메뉴 객체 다시 저장 -->
        repository.save(foundMenu);

    }
    
    <!-- 삭제하기 deleteById() -->
    @Transactional
    public void deleteMenu(Integer menuCode){
        menuRepository.deleteById(menuCode);
    }
    
    

단순하게 Repository의 save()만 호출하는 경우

  • Spring Data JPA의 Repository 인터페이스 (save() 같은 기본 메서드)는 내부적으로 이미 기본 트랜잭션이 설정
  • 단순 등록만 할 때는 트랜잭션을 명시적으로 안 써도 동작

명시적으로 @Transactional을 걸어주는 경우

  • 여러 개의 DB 작업을 묶어서 처리할 때
  • 서비스 계층에서 비즈니스 로직과 DB 작업을 함께 다룰 때
  • 엔티티 상태를 변경해야 할 때

9. ModelMapper 설정 (BeanConfig 등 에서)

ModelMapper 객체를 Bean으로 등록하여 서비스 및 컨트롤러 등에서 의존성 주입을 통해 사용할 수 있도록 한다.
설정 옵션 :

  • PRIVATE 필드에도 접근 가능하도록 설정
  • 필드 이름을 기준으로 자동 매핑 가능하도록 설정
@Configuration
public class BeanConfig {

    @Bean
    public ModelMapper modelMapper() {

        ModelMapper modelMapper = new ModelMapper();
        modelMapper.getConfiguration()
                <!-- 매핑 시 어느 접근 수준까지 필드를 접근할 수 있을지를 지정하는 설정 
                	 현재 private 필드에 접근 설정-->
                .setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE)
                <!-- Entity 필드, DTO 필드 둘 다 접근 가능해지며, 
                	 매핑 될 수 있게 설정 -->
                .setFieldMatchingEnabled(true);
        return modelMapper;
    }

}

10. Repository 생성 (Entity와 연결)

JpaRepository

  • EntityManager, Factory, Transaction 을 구현한 클래스이다.
  • 따라서 명시적으로 구현할 필요 없이 구현 클래스를 상속받아 사용할 수 있다.
  • JpaRepository < 대상 엔티티, 대상 엔티티의 식별자 타입>
    • JpaRepository<Menu, Integer>의 제네릭 타입 설명
      • 첫 번째는 엔티티 클래스(Menu)
      • 두 번째는 해당 엔티티의 기본 키(id)의 타입 (기본형이 아닌 Wrapper 타입으로 작성해야 함)
  • MenuRepository
public interface MenuRepository extends JpaRepository<Menu,Integer> {

	<!-- 커스터마이징 한 쿼리메소드는 JpaRepository가 가지고 있지 않기 때문에
     	 Repository에 추가가 되어야 한다. -->
    
    List<Menu> findByMenuPriceGreaterThan(int menuPrice);
}
  • CategoryRepository
<!-- JPQL 사용 -->
@Repository
public interface CategoryRepository extends JpaRepository<Category, Integer> {

    <!-- JPQL 사용 -->
    <!-- @Query() 는 JPQL을 위한 문법이지 nativeQuery를 위한 문법이 아니다
    	 ,nativeQuery = true 를 붙여야 에러가 나지 않는다 -->
    @Query(value = "SELECT category_code, category_name, ref_category_code FROM tbl_category"
            ,nativeQuery = true)

    /* 커스텀 추가 */
    List<Category> findAllCategory();
}

참고

persistence.xml

  • 전통적인 Java SE, Java EE 환경에서 사용하는 JPA 설정 파일
  • Spring을 사용하지 않는 순수 Java 애플리케이션
  • Hibernate, EclipseLink 같은 JPA 구현체를 직접 설정할 때
  • src/main/resources/META-INF/persistence.xml

application.yml

  • Spring Boot 환경에서 JPA와 DB 설정을 하기 위한 설정 파일
  • Spring Boot + Spring Data JPA 조합에서 사용
  • src/main/resources/application.yml

둘 다 JPA 설정 파일이지만, 목적이 다르기 때문에 보통 함께 사용하지 않는다.



Entity는 JPA 내부에서 관리되는 객체이기 때문에 민감한 정보가 들어있을 수 있다
-> 이를 그대로 View나 API로 노출되면 보안 문제 / 데이터 일관성 문제
-> View, 클라이언트에게 전달할 데이터는 반드시 DTO로 변환하여 전달해야한다.
Controller → Service 계층까지는 DTO 형태로 데이터를 주고받고,
Service 계층 내부에서 트랜잭션이 시작되면, 해당 시점부터 Entity를 기반으로 DB 작업(persist, 조회 등)을 수행한다


config 파일 없이도 개발이 가능할까?

  • @Component, @Service, @Repository 등으로 Bean 자동 등록도 되니까 어느 정도까지는 가능하다
  • 외부 라이브러리 객체, 설정이 필요한 객체, 직접 만든 유틸 클래스 같은 건
    직접 new 하지 말고 config 파일에서 Bean 등록해줘야 한다.
    -> 즉, ModelMapper는 Spring이 기본적으로 관리해주지 않는 외부 라이브러리 객체이기 때문에 직접 config 파일에서 @Bean으로 등록해줘야 한다.

Controller

  • View 리턴 의무
    RestContorller
  • Data 리턴
  • @ResponseBody
profile
잔디 속 새싹 하나

0개의 댓글