2025.03.28
지금까지 정리한 JPA 관련 내용들은 Hibernate 기반의 순수 JPA 환경에서 직접 구현한 방식
이제부터는 Spring 프레임워크에서 제공하는 pring Data JPA 모듈을 활용
-> 보다 편리하고 자동화된 방식으로 JPA 사용
JPA를 더 쉽고 자동화된 방식으로 사용할 수 있도록 도와주는 Spring의 모듈
사용자 요청 -> Controller -> Service -> Repository -> DB(Entity) -> 결과
-> DTO -> View 렌더링


// https://mvnrepository.com/artifact/org.modelmapper/modelmapper
implementation("org.modelmapper:modelmapper:3.1.1")
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
@Controller
public class 클래스이름 {
<!--
View 반환 방식
1. String: 뷰 이름 반환
2. void: 요청 경로를 뷰 이름으로 사용
3. ModelAndView: 뷰와 데이터를 함께 전달
-->
@GetMapping(value = {"/", "/요청경로"})
public String 메서드이름() {
<!-- templates/뷰폴더/뷰파일명.html -->
return "뷰폴더/뷰파일명";
}
}
<!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>
<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>
<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>
<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>
<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>
<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>
<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>
@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;
@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;
}
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@ToString
public class MenuDTO {
// 화면 상에 넘어오는 데이터, 화면에 응답해줄 데이터를 여기에 맵핑
private int menuCode;
private String menuName;
private int menuPrice;
private int categoryCode;
private String orderableStatus;
}
@Data
public class CategoryDTO {
private int categoryCode;
private String categoryName;
private Integer refCategoryCode;
}
<!-- 로그 출력용 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";
}
Sercvice 레이어에서는 Controller 계층에서 전달 받은 DTO 타입의 객체를 Entity 타입으로 변환을 할 것이다.
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()만 호출하는 경우
명시적으로 @Transactional을 걸어주는 경우
ModelMapper 객체를 Bean으로 등록하여 서비스 및 컨트롤러 등에서 의존성 주입을 통해 사용할 수 있도록 한다.
설정 옵션 :
@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;
}
}
JpaRepository
public interface MenuRepository extends JpaRepository<Menu,Integer> {
<!-- 커스터마이징 한 쿼리메소드는 JpaRepository가 가지고 있지 않기 때문에
Repository에 추가가 되어야 한다. -->
List<Menu> findByMenuPriceGreaterThan(int menuPrice);
}
<!-- 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
application.yml
둘 다 JPA 설정 파일이지만, 목적이 다르기 때문에 보통 함께 사용하지 않는다.

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