[Spring] 스프링 데이터 JPA

배창민·2025년 10월 27일
post-thumbnail

스프링 데이터 JPA

Spring Data JPA로 JPA를 더 간단하게. Repository 인터페이스와 쿼리 메소드만으로 CRUD, 정렬, 페이징, 동적 조회까지 한 번에!


1. Spring Data JPA 개요

1-1. 무엇인가?

  • Spring에서 JPA 사용을 편리하게 해 주는 Spring Data 모듈.
  • Repository + 쿼리 메소드로 JPQL/SQL을 대체.
  • 특정 DB에 의존하지 않는 추상화.

1-2. 특징

  • Repository/쿼리 메소드 제공 (CRUD·정렬·페이징).
  • Querydsl 연동으로 타입 안전 쿼리 지원.
  • EntityManager* 직접 사용 X (대부분 대체).

1-3. Repository 인터페이스 상속 구조

  • (이미지 참조)
  • 주요 인터페이스
인터페이스역할
Repository마커 인터페이스
CrudRepositoryCRUD 기본 메소드
PagingAndSortingRepository정렬/페이징
JpaRepositoryJPA 확장(배치 삭제, flush 등)

자주 쓰는 메소드(일부)

메소드설명
long count()전체 개수
Optional<T> findById(ID)단건 조회
Iterable<T> findAll()전체 조회
S save(S) / saveAll저장·수정
void deleteById(ID)삭제

1-4. 쿼리 메소드(Query Methods)

1-4-1. 개념

  • 메소드명으로 JPQL 생성: find + [Entity] + By + 조건
  • Repository의 제네릭 엔티티면 엔티티명 생략 가능
    예) findByCode(...) == findMenuByCode(...) (제네릭이 Menu일 때)

1-4-2. 키워드 예시

키워드예시생성 SQL
And/OrfindByCodeAndName... where x.code=? and x.name=?
비교findByPriceBetween... where x.price between ? and ?
크기LessThan/GreaterThanEqual<, >=
NullfindByNameIsNullis null
LikefindByNameLike/StartingWithlike / like '값%'
InfindByNameIn(Collection)in (?)
정렬OrderBy...Descorder by ... desc

2. Spring Data JPA CRUD 실습

2-1. 기본 설정

2-1-1. 의존성 추가

  • Spring Boot DevTools, Spring Data JPA, Thymeleaf, Spring Web, MySQL Driver, Lombok

2-1-2. application.yml

spring:
  datasource:
    driver-class-name: org.mariadb.jdbc.Driver
    url: jdbc:mariadb://localhost:3306/menudb
    username: swcamp
    password: swcamp
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true

2-1-3. 메인 화면

@Controller
public class MainController {
  @GetMapping({"/", "/main"})
  public String main() { return "main/main"; }
}
<!-- templates/main/main.html -->
<h1>OHGIRAFFERS RESTAURANT에 오신 것을 환영합니다.</h1>

2-2. 메뉴 단건 조회 (코드로)

2-2-1. 요청 버튼

<button onclick="location.href='/menu/7'">7번 메뉴 목록 보기</button>

2-2-2. Entity

@Entity @Table(name="tbl_menu") @Getter @NoArgsConstructor(access=PROTECTED)
public class Menu {
  @Id @GeneratedValue(strategy=IDENTITY) private int menuCode;
  private String menuName; private int menuPrice; private int categoryCode;
  private String orderableStatus;

  // 엔티티 변경 메서드(예: 이름 변경)만 노출, Setter 지양
  public void modifyMenuName(String name){ this.menuName = name; }
}

2-2-3. Repository

public interface MenuRepository extends JpaRepository<Menu, Integer> {}

2-2-4. DTO & ModelMapper

implementation 'org.modelmapper:modelmapper:3.1.1'
@Configuration
public class BeanConfig {
  @Bean public ModelMapper modelMapper(){ return new ModelMapper(); }
}
public class MenuDTO {
  private int menuCode; private String menuName; private int menuPrice;
  private int categoryCode; private String orderableStatus;
  // getters/setters/toString
}

2-2-5. Service

@Service
public class MenuService {
  private final MenuRepository repo; private final ModelMapper mm;
  public MenuService(MenuRepository repo, ModelMapper mm){ this.repo=repo; this.mm=mm; }

  public MenuDTO findMenuByCode(int code){
    Menu menu = repo.findById(code).orElseThrow(IllegalArgumentException::new);
    return mm.map(menu, MenuDTO.class);
  }
}

2-2-6. Controller & View

@Controller @RequestMapping("/menu")
public class MenuController {
  private final MenuService service;
  public MenuController(MenuService s){ this.service=s; }

  @GetMapping("/{menuCode}")
  public String find(@PathVariable int menuCode, Model model){
    model.addAttribute("menu", service.findMenuByCode(menuCode));
    return "menu/detail";
  }
}
<!-- templates/menu/detail.html -->
<table border="1">
 <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>

2-3. 전체 메뉴 조회(+정렬)

Service

public List<MenuDTO> findMenuList(){
  return repo.findAll(Sort.by("menuCode").descending()).stream()
      .map(m -> mm.map(m, MenuDTO.class))
      .toList();
}

Controller

@GetMapping("/list")
public String list(Model model){
  model.addAttribute("menuList", service.findMenuList());
  return "menu/list";
}

View

th:each로 출력(생략)


2-4. 전체 메뉴 페이징 조회

Service (Pageable/Page)

public Page<MenuDTO> findMenuList(Pageable pageable){
  pageable = PageRequest.of(
      Math.max(0, pageable.getPageNumber()-1),
      pageable.getPageSize(),
      Sort.by("menuCode").descending());
  Page<Menu> page = repo.findAll(pageable);
  return page.map(m -> mm.map(m, MenuDTO.class));
}

Controller

@GetMapping("/list")
public String list(@PageableDefault Pageable pageable, Model model){
  Page<MenuDTO> menuPage = service.findMenuList(pageable);

  PagingButton paging = Pagination.getPagingButtonInfo(menuPage);
  model.addAttribute("paging", paging);
  model.addAttribute("menuList", menuPage);
  return "menu/list";
}

Pagination 헬퍼

public class PagingButton {
  private int currentPage, startPage, endPage;
  // ctor/getters
}
public class Pagination {
  public static PagingButton getPagingButtonInfo(Page<?> page){
    int current = page.getNumber()+1, block=10;
    int start = (int)(Math.ceil((double)current/block)-1)*block + 1;
    int end = Math.min(start+block-1, Math.max(1, page.getTotalPages()));
    return new PagingButton(current, start, end);
  }
}

View(페이징 버튼만 발췌)

<!-- 맨 앞으로 -->
<button th:onclick="'location.href=\'/menu/list?page=' + @{${paging.startPage}} + '\''"><<</button>
<!-- 이전 -->
<button th:onclick="'location.href=\'/menu/list?page=' + @{${paging.currentPage - 1}} + '\''"
        th:disabled="${menuList.first}"><</button>
<!-- 숫자 -->
<th:block th:each="p : ${#numbers.sequence(paging.startPage, paging.endPage)}">
  <button th:onclick="'location.href=\'/menu/list?page=' + @{${p}} + '\''"
          th:text="${p}" th:disabled="${paging.currentPage eq p}"></button>
</th:block>
<!-- 다음 -->
<button th:onclick="'location.href=\'/menu/list?page=' + @{${paging.currentPage + 1}} + '\''"
        th:disabled="${menuList.last}">></button>
<!-- 맨 끝 -->
<button th:onclick="'location.href=\'/menu/list?page=' + @{${paging.endPage}} + '\''">>></button>

2-5. 쿼리 메소드 사용 예

Repository

List<Menu> findByMenuPriceGreaterThan(Integer price);
List<Menu> findByMenuPriceGreaterThanOrderByMenuPrice(Integer price);
List<Menu> findByMenuPriceGreaterThan(Integer price, Sort sort);

Service

public List<MenuDTO> findByMenuPrice(Integer price){
  return repo.findByMenuPriceGreaterThan(price, Sort.by("menuPrice").descending())
             .stream().map(m -> mm.map(m, MenuDTO.class)).toList();
}

Controller & View

@GetMapping("/search")
public String search(@RequestParam Integer menuPrice, Model model){
  model.addAttribute("menuList", service.findByMenuPrice(menuPrice));
  model.addAttribute("menuPrice", menuPrice);
  return "menu/searchResult";
}

(뷰는 목록 테이블 출력)


2-6. 신규 메뉴 입력

요청 화면

<button onclick="location.href='/menu/regist'">메뉴 입력하기</button>

regist.html에서 카테고리 목록을 fetch로 로드:

<select name="categoryCode" id="categoryCode"></select>
<script>
fetch('/menu/category')
  .then(res => res.json())
  .then(data => {
    const sel = document.getElementById('categoryCode');
    data.forEach(c => {
      const opt = document.createElement('option');
      opt.value = c.categoryCode; opt.textContent = c.categoryName;
      sel.appendChild(opt);
    });
  });
</script>

Category Entity/DTO

@Entity @Table(name="tbl_category") @Getter @NoArgsConstructor(access=PROTECTED)
public class Category {
  @Id private int categoryCode; private String categoryName; private Integer refCategoryCode;
}
public class CategoryDTO { int categoryCode; String categoryName; Integer refCategoryCode; /*get/set*/ }

CategoryRepository (Native Query 예시)

public interface CategoryRepository extends JpaRepository<Category, Integer> {
  @Query(value = "SELECT category_code, category_name, ref_category_code FROM tbl_category ORDER BY category_code ASC",
         nativeQuery = true)
  List<Category> findAllCategory();
}
@Service
public class MenuService {
  private final MenuRepository menuRepo; private final CategoryRepository catRepo; private final ModelMapper mm;
  // ctor...

  public List<CategoryDTO> findAllCategory(){
    return catRepo.findAllCategory().stream().map(c -> mm.map(c, CategoryDTO.class)).toList();
  }

  @Transactional
  public void registNewMenu(MenuDTO dto){
    menuRepo.save(mm.map(dto, Menu.class));
  }
}

Controller

@GetMapping("/category") @ResponseBody
public List<CategoryDTO> categories(){ return service.findAllCategory(); }

@PostMapping("/regist")
public String regist(MenuDTO newMenu){
  service.registNewMenu(newMenu);
  return "redirect:/menu/list";
}

2-7. 메뉴 이름 수정

Service

@Transactional
public void modifyMenu(MenuDTO dto){
  Menu found = repo.findById(dto.getMenuCode()).orElseThrow(IllegalArgumentException::new);
  found.modifyMenuName(dto.getMenuName());   // 엔티티 메서드로 변경 감지
}

Controller

@PostMapping("/modify")
public String modify(MenuDTO dto){
  service.modifyMenu(dto);
  return "redirect:/menu/" + dto.getMenuCode();
}

2-8. 메뉴 삭제

Service

@Transactional
public void deleteMenu(Integer menuCode){ repo.deleteById(menuCode); }

Controller

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

핵심 요약

  • 엔티티 변경은 도메인 메서드로 캡슐화(setXxx 지양).
  • 컨트롤러 반환은 DTO 사용(영속 엔티티 직접 노출 금지).
  • @ToString에서 연관관계 필드 제외(순환 참조 위험).
  • 페이징은 Pageable/Page 적극 활용(뷰에 필요한 메타 함께 제공).
  • 정렬은 Sort, 동적 조건은 쿼리 메소드/Specification/Querydsl 검토.
  • Native Query가 필요하면 @Query(nativeQuery = true) 또는 Named Native Query결과 매핑 사용.
profile
개발자 희망자

0개의 댓글