[SpringBoot] 게시판 구현하기 17 (스프링 데이터 JPA 사용해보기)
FRAMEWORK/Spring

[SpringBoot] 게시판 구현하기 17 (스프링 데이터 JPA 사용해보기)

반응형

들어가기 전에

하기 포스팅은 "스프링부트 시작하기(김인우 저)" 책을 공부하며 적은 포스팅입니다. 이번 포스팅에서는 JPA에 대해 알아보겠습니다.

JPA

JPA(Java Persistence API)란, 자바 객체와 데이터베이스 테이블 간 매핑을 처리하는 ORM(Object Relational Mapping) 기술의 표준입니다. ORM은 쉽게 말해 객체와 관계를 설정하는 것으로, 특정 언어에 종속적인 개념이 아닌 객체와 관계형 데이터베이스를 매핑시키는 것입니다.

 

JPA는 각 기능의 동작이 어떻게 되어야 한다는 것을 정의한 기술 명세이기 때문에 해당 명세에 따라 실제로 기능을 구현한 구현체가 필요합니다. 이렇게 구현된 제품이나 프레임워크로 하이버네이트, 이클립스링크 등이 있고, 이러한 JPA 구현체를 JPA 프로바이더라고 합니다.

 

실제 프로젝트에서는 하이버네이트를 많이 사용하지만, 초심자가 하기에는 어렵게 느껴질 수 있어 스프링 데이터(Spring Data) 프로젝트의 하위 프로젝트인 스프링 데이터 JPA를 사용하여 실습해보겠습니다.

JPA의 장점

  1. 개발이 편리합니다.
    • 웹 어플리케이션에서 반복적으로 작성하는 기본적인 CRUD(Create, Read, Update, Delete)용 SQL을 직접 작성하지 않아도 됩니다.
  2. 데이터베이스에 독립적인 개발이 가능합니다.
    • JPA는 특정 데이터베이스에 종속적이지 않아 데이터베이스가 변경되어도 JPA는 해당 데이터베이스에 맞는 쿼리를 생성해줍니다.
  3. 유지보수가 쉽습니다.
    • myBatis와 같은 매퍼 프레임워크를 사용해 데이터베이스 중심의 개발을 하면, 테이블이 변경될 때 관련 코드가 모두 변경되어야 합니다. 하지만, JPA를 사용하면 객체만 수정하면 됩니다.

JPA의 단점

  1. 학습곡선이 큽니다.
    • 기존 데이터베이스 위주 개발 방식에 비해 배워야할 것들이 많습니다. 또한, SQL을 직접 작성하지 않아 튜닝 등을 할 때 어려움이 있습니다.
  2. 특정 데이터베이스의 기능을 사용할 수 없습니다.
    • 오라클과 같이 강력한 함수를 많이 제공하는 데이터베이스에는 종속적인 기능이 존재합니다. 이러한 기능을 쓰면, 데이터베이스에 독립적인 개발이 불가능해, 데이터베이스에 독립적인 개발이 가능한 JPA 장점을 잃게됩니다.
  3. 객체지향 설계가 필요합니다.

스프링 데이터 JPA

스프링 데이터 JPA란, JPA를 스프링에서 쉽게 사용할 수 있게 해주는 라이브러리입니다. 하이버네이트와 같은 JPA 프로바이더를 직접 사용하면 엔티티 매니저(EntityManager)를 설정학 이용하는 등 여러 진입장벽이 존재합니다. 스프링 데이터 JPA는 레포지터리(Repository)라는 인터페이스를 제공하여, 이 인터페이스만 상속받아 정해진 규칙에 맞게 메소드를 작성하면 개발자가 작성해야할 코드가 완성됩니다.

 

스프링 데이터 JPA 내부적으로는 실제 기능을 담당하는 JPA의 구현체(JPA 프로바이더)로 하이버네이트를 사용하여 하이버네이트를 모르더라도 프레임워크가 하이버네이트를 이용해 적절한 코드를 생성해줍니다.

스프링 데이터 JPA를 위한 기본 설정

JPA 설정 추가하기

application.properties 설정파일 내에 하기 내용을 추가합니다.

spring.jpa.database=mysql
spring.jpa.database-platform=org.hibernate.dialect.MySQL5InnoDBDialect
spring.jpa.generate-ddl=true
spring.jpa.hibernate.use-new-id-generator-mappings=false
  • spring.jpa.database=mysql: 사용할 데이터베이스를 MySQL로 설정합니다.
  • spring.jpa.database-platform=org.hibernate.dialect.MySQL5InnoDBDialect: MySQL은 InnoDB, MyISAM 등 여러 엔진을 지원합니다. 그 중 일반적으로 많이 사용하는 엔진은 InnoDB로 성능 및 트랜잭션 지원 등에서 우세합니다.
    • database-platform을 따로 지정하지 않으면 MyISAM이 선택되므로 해당 설정값을 넣어야 합니다.
  • spring.jpa.generate-ddl=true: JPA의 엔티티 연관관계를 바탕으로 테이블 생성과 같은 스크립트를 자동으로 실행하도록 합니다.
  • spring.jpa.hibernate.use-new-id-generator-mappings=false: 하이버네이트의 새로운 ID 생성 옵션의 사용 여부를 결정합니다. 여기서는 MySQL의 자동 증가(Auto Increment) 속성을 사용하므로 false로 설정합니다.

 

빈 등록하기

DatabaseConfiguration 클래스에 JPA 설정 빈을 등록합니다.

...
import java.util.Properties;
...
	@Bean
	@ConfigurationProperties(prefix="spring.jpa")
	public Properties hibernateConfig() {
		return new Properties();
	}
...

 

JAVA 8의 날짜 API 설정하기

JAVA 8에서는 시간 관련 클래스들이 추가되었습니다. 하지만, JAVA 8의 날짜 및 시간 관련 클래스를 그대로 사용할 경우 환경에 따라 문제가 발생할 수 있습니다. 이 문제를 해결하기 위한 방법 중 간단한 Jsr310JpaConverters 적용 방법을 사용해보겠습니다.

 

BoardApplication 클래스에 아래와 같이 Jsr310JpaConverters 클래스를 등록합니다.

package board;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.boot.autoconfigure.web.servlet.MultipartAutoConfiguration;
import org.springframework.data.jpa.convert.threeten.Jsr310JpaConverters;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@EntityScan(
		basePackageClasses = {Jsr310JpaConverters.class},
		basePackages = {"board"})
@SpringBootApplication(exclude={MultipartAutoConfiguration.class})
public class BoardApplication {

	public static void main(String[] args) {
		SpringApplication.run(BoardApplication.class, args);
	}
}
  • @EntityScan: @EntityScan 어노테이션은 어플리케이션이 실행될 때 basePackages로 지정된 패키지 하위에 JPA 엔티티(@Entity 어노테이션이 설정된 도메인 클래스들)를 검색합니다. 
    • 여기에 Jsr310JpaConverters 클래스를 등록하면 JAVA 8 날짜 및 시간 관련 클래스 사용에 문제가 발생하지 않습니다.

이때, 만약 org.springframework.data.jpa.*가 import되지 않는다면, build.gradle 파일에 하기 내용을 dependencies에 추가한 후 gradle refresh를 해주어야 합니다.

	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

JPA를 사용한 게시판으로 변경하기

앞서 만든 RESTful 게시판을 JPA를 사용하도록 변경해보겠습니다. 게시판의 기본적인 개념 및 구현방법은 비슷하지만, 쿼리를 사용하지 않고 스프링 데이터 JPA가 제공하는 기능을 사용하므로 전체적인 코드가 많이 달라집니다.

 

엔티티 생성하기

기존에 사용하던 BoardDto와 BoardFileDto 클래스를 그대로 사용하지 않고, JPA용 entity 클래스를 만들어보겠습니다.

  • JPA에서 변경된 사항을 기존 클래스와 비교하기 쉽도록 새로 만듬

먼저, board.board.entity 패키지를 생성하고, BoardEntity, BoardFileEntity 클래스를 생성하여 하기와 같이 코드를 작성합니다.

// BoardEntity.java
package board.board.entity;

import java.time.LocalDateTime;
import java.util.Collection;

import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToMany;
import javax.persistence.Table;

import lombok.Data;
import lombok.NoArgsConstructor;

@Entity
@Table(name="t_jpa_board")
@NoArgsConstructor
@Data
public class BoardEntity {
	
	@Id
	@GeneratedValue(strategy=GenerationType.AUTO)
	private int boardIdx;
	
	@Column(nullable=false)
	private String title;
	
	@Column(nullable=false)
	private String contents;
	
	@Column(nullable=false)
	private int hitCnt = 0;
	
	@Column(nullable=false)
	private String creatorId;
	
	@Column(nullable=false)
	private LocalDateTime createdDatetime = LocalDateTime.now();
	
	private String updaterId;
	
	private LocalDateTime updatedDatetime;
	
	@OneToMany(fetch=FetchType.EAGER, cascade=CascadeType.ALL)
	@JoinColumn(name="board_idx")
	private Collection<BoardFileEntity> fileList;
}
  • @Entity: 해당 클래스가 JPA의 엔티티임을 나타냅니다. 엔티티 클래스는 테이블과 매핑됩니다.
  • @Table(name="t_jpa_board"): t_jpa_board 테이블과 매핑되도록 나타냅니다.
  • @Id: boardIdx가 엔티티의 기본키임을 나타냅니다.
  • @GeneratedValue(strategy=GenerationType.AUTO): 기본키 생성 전략을 설정합니다.
    • GenerationType.AUTO로 지정할 경우 데이터베이스에서 제공하는 기본키 생성 전략을 따르게 됩니다. 
    • MySQL은 자동 증가를 지원하므로 기본키가 자동으로 증가하며, 자동 증가가 지원하지 않는 오라클의 경우 기본키에 사용할 시퀀스를 생성하게 됩니다.
  • @Column(nullable=false): 컬럼에 Not Null 속성을 지정합니다.
  • private LocatlDateTime createdDatetime = LocalDateTime.now(): 작성시간의 초기값을 설정합니다. @Column 어노테이션을 이용해 초기값을 지정할 수도 있지만, 그렇게 할 경우 데이터베이스에 따라 초기값을 다르게 설정(JPA의 장점 퇴색)해야할 수 있습니다. 
  • @OneToMany(fetch=FetchType.EAGER, cascade=CascadeType.ALL): 1:N 관계를 표현하는 JPA 어노테이션입니다. 하나의 게시글은 첨부파일이 없거나 1개 이상을 가질 수 있어 1:N 관계로 설정합니다.
  • @JoinColumn(name="board_idx"): 릴레이션 관계가 있는 테이블의 컬럼을 지정합니다.
// BoardFileEntity.java
package board.board.entity;

import java.time.LocalDateTime;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

import lombok.Data;
import lombok.NoArgsConstructor;

@Entity
@Table(name="t_jpa_file")
@NoArgsConstructor
@Data
public class BoardFileEntity {

		@Id
		@GeneratedValue(strategy=GenerationType.AUTO)
		private int idx;
		
		@Column(nullable=false)
		private String originalFileName;
		
		@Column(nullable=false)
		private String storedFilePath;
		
		@Column(nullable=false)
		private long fileSize;
		
		@Column(nullable=false)
		private String creatorId;
		
		@Column(nullable=false)
		private LocalDateTime createdDatetime = LocalDateTime.now();
		
		private String updaterId;
		
		private LocalDateTime updatedDatetime;
}
  • @Table(name="t_jpa_file"): t_jpa_file 태이블을 매핑하도록 합니다.

 

컨트롤러 작성하기

앞서 만든 RestBoardController 클래스와 마찬가지로 JpaBoardController 클래스를 새로 만들어보겠습니다. 

package board.board.controller;

import java.io.File;
import java.net.URLEncoder;
import java.util.List;

import javax.servlet.http.HttpServletResponse;

import org.apache.commons.io.FileUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.util.ObjectUtils;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartHttpServletRequest;
import org.springframework.web.servlet.ModelAndView;

import board.board.entity.BoardEntity;
import board.board.entity.BoardFileEntity;
import board.board.service.JpaBoardService;

@Controller
public class JpaBoardController {

	@Autowired
	private JpaBoardService jpaBoardService;
	
	@RequestMapping(value="/jpa/board", method=RequestMethod.GET)
	public ModelAndView openBoardList() throws Exception {
		ModelAndView mv = new ModelAndView("/board/jpaBoardList");
		
		List<BoardEntity> list = jpaBoardService.selectBoardList();
		mv.addObject("list", list);
		
		return mv;
	}
	
	@RequestMapping(value="/jpa/board/write", method=RequestMethod.GET)
	public String openBoardWrite() throws Exception {
		return "/board/jpaBoardWrite";
	}
	
	@RequestMapping(value="/jpa/board/write", method=RequestMethod.POST)
	public String insertBoard(BoardEntity board, MultipartHttpServletRequest multipartHttpServletRequest) throws Exception {
		int hitCnt = board.getHitCnt();
        
        jpaBoardService.saveBoard(board, multipartHttpServletRequest, hitCnt);
		return "redirect:/jpa/board";
	}
	
	@RequestMapping(value="/jpa/board/{boardIdx}", method=RequestMethod.GET)
	public ModelAndView openBoardDetail(@PathVariable("boardIdx") int boardIdx) throws Exception {
		ModelAndView mv = new ModelAndView("/board/jpaBoardDetail");
		
		BoardEntity board = jpaBoardService.selectBoardDetail(boardIdx);
		mv.addObject("board", board);
		
		return mv;
	}
	
	@RequestMapping(value="/jpa/board/{boardIdx}", method=RequestMethod.PUT)
	public String updateBoard(BoardEntity board) throws Exception {
		int hitCnt = board.getHitCnt();
        
        jpaBoardService.saveBoard(board, null, hitCnt + 1);
		return "redirect:/jpa/board";
	}
	
	//@RequestMapping(value="/board/{boardIdx}", method=RequestMethod.DELETE)
	@DeleteMapping(value="/jpa/board/{boardIdx}")
	public String deleteBoard(@PathVariable("boardIdx") int boardIdx) throws Exception {
		jpaBoardService.deleteBoard(boardIdx);
		return "redirect:/jpa/board";
	}
	
	@RequestMapping(value="/jpa/board/file", method=RequestMethod.GET)
	public void downloadBoardFile(@RequestParam int idx, @RequestParam int boardIdx, HttpServletResponse response) throws Exception {
		BoardFileEntity boardFile = jpaBoardService.selectBoardFileInformation(idx, boardIdx);
		if(ObjectUtils.isEmpty(boardFile) == false) {
			String fileName = boardFile.getOriginalFileName();
			
			byte[] files = FileUtils.readFileToByteArray(new File(boardFile.getStoredFilePath()));
			
			response.setContentType("application/octet-stream");
			response.setContentLength(files.length);
			response.setHeader("Content-Disposition", "attachment; fileName=\"" + URLEncoder.encode(fileName, "UTF-8") + "\";");
			response.setHeader("Content-Transfer-Encoding", "binary");
			
			response.getOutputStream().write(files);
			response.getOutputStream().flush();
			response.getOutputStream().close();
		}
	}
	
	@RequestMapping(value="/jpa/board/file", method=RequestMethod.DELETE)
	public String deleteBoardFile(@RequestParam int idx, @RequestParam int boardIdx) throws Exception {
		jpaBoardService.deleteBoardFile(idx, boardIdx);
		
		return "redirect:/jpa/board/"+boardIdx;
	}
}
  • 전체적으로 RestBoardController 클래스와 동일합니다. URI를 겹치지 않게 사용하지 않기 위해 앞에 /jpa를 붙여 JPA용 URI로 변경했습니다.
  • URI가 변경되었기 때문에, 뷰에서도 호출하는 URI가 변경되어야 합니다. 따라서, JPA용 뷰인 jpaBoardList.html, jpaBoardWrite.html, jpaBoardDetail.html로 호출할 뷰를 변경할 것입니다.
  • 게시글 작성과 수정 시, 동일한 서비스 메소드를 호출하기 위해 해당 메소드들 내에서 호출하는 서비스를 saveBoard로 통일하였습니다. 현재, 게시글 수정시에는 첨부 파일을 수정할 수 없어(PUT 호출 떄문) 수정 메소드의 파일 관련 파라미터로 null을 넘깁니다.
  • 책에서는, hitCnt를 saveBoard로 넘기지 않았으나, 책 내용을 그대로하게 되면 게시글 수정 시 게시글을 새로 등록한 것마냥 조회수가 0으로 변경이 됩니다.
    • 따라서, 위 코드와 같이 saveBoard에 hitCnt를 넘겨 사용했습니다(수정시에는 + 1을 하여 조회수 증가시켰습니다).

서비스 작성하기

// JpaBoardService.java
package board.board.service;

import java.util.List;

import org.springframework.web.multipart.MultipartHttpServletRequest;

import board.board.entity.BoardEntity;
import board.board.entity.BoardFileEntity;

public interface JpaBoardService {

		List<BoardEntity> selectBoardList() throws Exception;
		
		void saveBoard(BoardEntity board, MultipartHttpServletRequest multipartHttpServletRequest, int hitCnt) throws Exception;
		
		BoardEntity selectBoardDetail(int boardIdx) throws Exception;
		
		void deleteBoard(int boardIdx) throws Exception;
		
		BoardFileEntity selectBoardFileInformation(int idx, int boardIdx) throws Exception;
		
		void deleteBoardFile(int idx, int boardIdx) throws Exception;
}
  • 인터페이스는 기존과 변경되는 부분이 별로 없습니다.
  • 기존에 BoardDto, BoardFileDto를 사용하던 부분을 엔티티로 변경해주었고 게시글 작성 및 수정 시 메소드 명이 변경되었습니다.
// JpaBoardServiceImpl.java
package board.board.service;

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

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import org.springframework.web.multipart.MultipartHttpServletRequest;

import board.board.entity.BoardEntity;
import board.board.entity.BoardFileEntity;
import board.common.FileUtils;

@Service
public class JpaBoardServiceImpl implements JpaBoardService {
	
	@Autowired
	JpaBoardRepository JpaBoardRepository;
	
	@Autowired
	FileUtils fileUtils;
	
	@Override
	public List<BoardEntity> selectBoardList() throws Exception {
		return JpaBoardRepository.findAllByOrderByBoardIdxDesc();
	}
	
	@Override
	public void saveBoard(BoardEntity board, MultipartHttpServletRequest multipartHttpServletRequest, int hitCnt) throws Exception {
		board.setCreatorId("admin");
		board.setHitCnt(hitCnt);
        List<BoardFileEntity> list = fileUtils.parseFileInfo(multipartHttpServletRequest);
		if(CollectionUtils.isEmpty(list) == false) {
			board.setFileList(list);
		}
		jpaBoardRepository.save(board);
	}
	
	@Override
	public BoardEntity selectBoardDetail(int boardIdx) throws Exception {
		Optional<BoardEntity> optional = jpaBoardRepository.findById(boardIdx);
		if(optional.isPresent()) {
			BoardEntity board = optional.get();
			board.setHitCnt(board.getHitCnt() + 1);
			jpaBoardRepository.save(board);
			
			return board;
		}
		else {
			throw new NullPointerException();
		}
	}
	
	@Override
	public void deleteBoard(int boardIdx) throws Exception {
		jpaBoardRepository.deleteById(boardIdx);
	}
	
	@Override
	public BoardFileEntity selectBoardFileInformation(int idx, int boardIdx) throws Exception {
		BoardFileEntity boardFile = jpaBoardRepository.findBoardFile(idx, boardIdx);
		return boardFile;
	}
	
	@Override
	public void deleteBoardFile(int idx, int boardIdx) throws Exception {
		jpaBoardRepository.deleteBoardFile(idx, boardIdx);
	}
}
  • return jpaBoardRepository.findAllByOrderByBoardIdxDesc(): 게시글 번호로 정렬해서 전체 게시글 목록을 조회합니다.
  • List<BoardFileEntity> list = fileUtils.parseFileInfo(multipartHttpServletRequest): 첨부파일 정보를 저장하는 클래스가 BoardFileDto 클래스에서 BoardFileEntity 클래스로 변경되어, FileUtils 클래스에 parseFileInfo 메소드를 새로 만들었습니다(기존 메소드와 이름이 동일해 파라미터를 다르게 가져갑니다).
  • board.setFileList(list): 첨부파일 목록을 BoardFileEntity 클래스에 추가합니다. 이전에는 첨부파일 정보를 저장하는 쿼리를 따로 실행했지만, 여기서는 게시글 저장 시 게시글에 포함된 첨부파일 목록도 자동으로 저장합니다.
    • @OneToMany 어노테이션을 사용하여 가능
  • jpaBoardRepository.save(board): 레포지터리의 save 메소드는 insert와 update 두 가지 역할을 합니다. 저장할 내용이 새로 생성될 경우, insert를 수행하고 기존 내용이 변경되었을 경우 update를 수행합니다.
  • jpaBoardRepository.findById(boardIdx): JPA의 CrudRepository에서 제공하는 기능으로 주어진 id를 가진 엔티티를 조회합니다.
  • jpaBoardRepository.deleteById(boardIdx): 주어진 id를 가진 엔티티를 삭제합니다.
  • board.setHitCnt(hitCnt): 책의 코드와 다르게 조회수를 설정해주었습니다(해당 구문을 삭제하면 게시글 수정 시 조회수가 초기화됩니다).

 

FildUtils 클래스 변경하기

게시글을 저장하는 JpaBoardServiceImpl 클래스의 saveBoard 메소드에서 사용하는 parseBoardFile 메소드를 만들어보겠습니다. 기존 parseBoardFile과 기능은 동일하지만 첨부파일 정보를 BoardFileDto 클래스 대신 BoardFileEntity 클래스를 이용한다는 점에서 달라 새로이 작성이 필요합니다.

// FileUtils.java
...
	public List<BoardFileEntity> parseFileInfo(MultipartHttpServletRequest multipartHttpServletRequest) throws Exception{
		if(ObjectUtils.isEmpty(multipartHttpServletRequest)){
			return null;
		}
		
		List<BoardFileEntity> fileList = new ArrayList<>();
		
		DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyyMMdd"); 
    	ZonedDateTime current = ZonedDateTime.now();
    	String path = "images/"+current.format(format);
    	File file = new File(path);
		if(file.exists() == false){
			file.mkdirs();
		}
		
		Iterator<String> iterator = multipartHttpServletRequest.getFileNames();
		
		String newFileName, originalFileExtension, contentType;
		
		while(iterator.hasNext()){
			List<MultipartFile> list = multipartHttpServletRequest.getFiles(iterator.next());
			for (MultipartFile multipartFile : list){
				if(multipartFile.isEmpty() == false){
					contentType = multipartFile.getContentType();
					if(ObjectUtils.isEmpty(contentType)){
						break;
					}
					else{
						if(contentType.contains("image/jpeg")) {
							originalFileExtension = ".jpg";
						}
						else if(contentType.contains("image/png")) {
							originalFileExtension = ".png";
						}
						else if(contentType.contains("image/gif")) {
							originalFileExtension = ".gif";
						}
						else{
							break;
						}
					}
					
					newFileName = Long.toString(System.nanoTime()) + originalFileExtension;
					BoardFileEntity boardFile = new BoardFileEntity();
					boardFile.setFileSize(multipartFile.getSize());
					boardFile.setOriginalFileName(multipartFile.getOriginalFilename());
					boardFile.setStoredFilePath(path + "/" + newFileName);
					boardFile.setCreatorId("admin");
					fileList.add(boardFile);
					
					file = new File(path + "/" + newFileName);
					multipartFile.transferTo(file);
				}
			}
		}
		return fileList;
	}
  • public List<BoardFileEntity> parseFileInfo(MultipartHttpServletRequest multipartHttpServletRequest) throws Exception: JPA의 @OneToMany 어노테이션으로 연관관계를 가지고 있어 첨부파일 클래스(BoardFileEntity)에 게시글 번호를 따로 저장할 필요가 없습니다.

 

레포지터리(Repository) 작성하기

레포지터리(Repository)스프링 데이터 JPA가 제공하는 인터페이스입니다. 스프링 데이터 JPA가 제공하는 레포지터리 인터페이스는 몇 종류가 있습니다. 여기서는 가장 간단히 사용할 수 있는 CrudRepository 인터페이스를 사용해보겠습니다.

 

board.board.repository 패키지를 만들고 JpaBoardRepository 인터페이스를 생성합니다.

package board.board.repository;

import java.util.List;

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;

import board.board.entity.BoardEntity;
import board.board.entity.BoardFileEntity;

public interface JpaBoardRepository extends CrudRepository<BoardEntity, Integer> {

		List<BoardEntity> findAllByOrderByBoardIDxDesc();
		
		@Query("SELECT file FROM BoardFileEntity file WHERE board_idx = :boardIdx AND idx = :idx")
		BoardFileEntity findBoardFile(@Param("idx") int idx, @Param("boardIdx") int boardIdx);
        
		@Query("DELETE FROM BoardFileEntity file WHERE board_idx = :boardIdx AND idx = :idx")
		void deleteBoardFile(@Param("idx") int idx, @Param("boardIdx") int boardIdx);
}
  • public interface JpaBoardRepository extends CrudRepository<BoardEntity, Integer>: 스프링 데이터 JPA에서 제공하는 CrudRepository 인터페이스를 상속받습니다.
    • CrudRepository 인터페이스는 레포지토리에서 사용할 도메인 클래스와 도메인의 id 타입을 파라미터로 받습니다.
    • 여기서는 도메인 클래스로 BoardEntity 클래스를 사용하고, BoardEntity 클래스의 id 타입인 integer로 설정했습니다.
  • List<BoardEntity> findAllByOrderByBoardIdxDesc(): 게시글 번호로 정렬해 전체 게시글을 조회합니다.
    • 규칙에 맞도록 레포지토리에 메소드를 추가하면, 실행 시 메소드 이름에 따라 쿼리가 생성되어 실행됩니다.
  • @Query("SELECT file FROM BoardFileEntity file WHERE board_idx = :boardIdx AND idx = :idx"): @Query 어노테이션을 이용해 첨부파일 정보를 조회합니다.
    • @Query 어노테이션을 사용하면 실행하고 싶은 쿼리를 직접 정의할 수 있습니다.

레포지토리에서 발생 가능 에러 - java.lang.illegalStateException: org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations

첨부파일 삭제 시 발생하는 에러

위와 같은 에러가 발생하여 위 레포지토리 쿼리를 아래와 같이 변경했습니다.

package board.board.repository;

import java.util.List;

import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;

import board.board.entity.BoardEntity;
import board.board.entity.BoardFileEntity;

public interface JpaBoardRepository extends CrudRepository<BoardEntity, Integer> {

		List<BoardEntity> findAllByOrderByBoardIdxDesc();
		
		@Query("SELECT file FROM BoardFileEntity file WHERE board_idx = :boardIdx AND idx = :idx")
		BoardFileEntity findBoardFile(@Param("idx") int idx, @Param("boardIdx") int boardIdx);
		
		@Transactional
		@Modifying
		@Query("DELETE FROM BoardFileEntity file WHERE board_idx = :boardIdx AND idx = :idx")
		void deleteBoardFile(@Param("idx") int idx, @Param("boardIdx") int boardIdx);
}

참고한 사이트는 아래와 같습니다.

stackoverflow.com/questions/48762310/spring-data-jpa-not-supported-for-dml-operations/48785871

 

Spring Data JPA: Not supported for DML operations

I have written a query for an Amazon Aurora DB, to delete some objects, in my interface extending CrudRepository, but when I excute the query It throws an exception! @Transactional @Query("delete...

stackoverflow.com

 

JpaBoardServiceImpl 클래스에서 여러 레포지터리 메소드를 호출했는데, JpaBoardRepository 인터페이스에는 단 두 개의 메소드만 정의되어 있습니다. CrudRepository 인터페이스에서 제공하는 기능을 이용했기 때문에, 앞의 Mapper 인터페이스처럼 모든 메소드를 정의하지 않아도 됩니다.

 

스프링 데이터 JPA 레포지터리 인터페이스

레포지터리 구조

스프링 데이터 JPA에서 제공하는 레포지토리 인터페이스는 위와 같은 구조를 가지고 있습니다.

 

최상위의 Repository 인터페이스는 아무런 기능이 없어 잘 사용하지 않습니다. CrudRepository 인터페이스는 이름에서 알 수 있듯이 CRUD(Create, Read, Update, Delete) 기능을 기본적으로 제공합니다. 따라서, CrudRepository 인터페이스를 이용할 경우, CRUD에 해당하는 기능을 작성하지 않아도 인터페이스에서 제공되는 기능을 바로 사용할 수 있습니다. 

 

PagingAndSortingRepository 인터페이스는 CrudRepository 인터페이스의 기능에 페이징 및 정렬 기능이 추가된 인터페이스입니다. JpaRepository 인터페이스는 PagingAndSortingRepository 인터페이스의 기능 뿐 아니라 JPA에 특화된 기능까지 추가된 인터페이스입니다.

 

스프링 데이터 JPA를 이용하면 레포지터리 인터페이스를 기준으로 동적으로 실행할 수 있는 메소드를 생성해줍니다. 아래 표는 게시판을 만들 때 사용한 CrudRepository 인터페이스가 제공하는 메소드입니다.

메소드 설명
<S extends T> S save(S entity) 주어진 엔티티를 저장합니다.
<S extends T> Iterable<S> saveAll(Iterable<S> entities) 주어진 엔티티 목록을 저장합니다.
Option<T> findById(Id id) 주어진 아이디로 식별된 엔티티를 반환합니다.
boolean existsById(Id id) 주어진 아이디로 식별된 엔티티가 존재하는지를 반환합니다.
Iterable<T> findAll() 모든 엔티티를 반환합니다.
Iterable<T> findAllById(Iterable<ID> ids) 주어진 아이디 목록에 맞는 모든 엔티티 목록을 반환합니다.
long count() 사용 가능한 엔티티의 개수를 반환합니다.
void deleteById(ID id) 주어진 아이디로 식별된 엔티티를 삭제합니다.
void delete(T entity) 주어진 엔티티를 삭제합니다.
void deleteAll(Iterable<? extends T> entities) 주어진 엔티티 목록으로 식별된 엔티티를 모두 삭제합니다.
void deleteAll 모든 엔티티를 삭제합니다.

위와 같이 기본적으로 제공하는 메소드 외에도 레포지터리에 규칙에 맞도록 메소드를 추가하면 실행 시 메소드 이름에 따라 쿼리 생성이 가능합니다. 이를 쿼리 메소드(Query Methods)라고 합니다.

 

쿼리 메소드

스프링 데이터 JPA는 규칙에 맞게 메소드를 추가하면 그 메소드 이름으로 쿼리를 생성하는 기능을 제공합니다.

  • 쿼리 메소드는 find...By, read...By, query...By, count...By, get...By로 시작해야 합니다.
    • 첫 번째 By 뒤쪽은 컬럼 이름으로 구성됩니다. 즉, 첫 By는 쿼리의 검색조건이 됩니다.
    • 예를 들어, BoardEntity의 제목으로 검색하려면 findByTitle(String title)과 같이 작성해야 합니다.
      • findByTitle(String title) 메소드가 실행되면 다음과 같은 JPQL(Java Persistence Query Language) 문이 실행됩니다.
      • select jpaboard0_.board_idx as board_id1_0, ... from t_jpa_board jpaboard0_ where jpaboard0_.title=?
    • 두 개 이상의 속성을 조합하려면 And 키워드를 사용하면 됩니다. 제목과 내용으로 검색하려면 아래와 같이 메소드를 작성하면 됩니다.
      • findByTitleAndContents(String title, String contents)
키워드 예시 JPQL 변환
And findByLastnameAndFirstname ... where x.lastname = ?1 and x.firstname = ?2
Or findByLastnameOrFirstname ... where x.lastname = ?1 or x.firstname = ?2
Is, Equals findByFirstname
findByFirstnameIs
findByFirstnameEquals
... where x.firstname = ?1
Between findByStartDateBetween ... where x.startDate between ?1 and ?2
LessThan findByAgeLessThan ... where x.age < ?1
LessThanEqual findByAgeLessThanEqual ... where .xage <= ?1
GreaterThan findByAgeGreaterThan ... where x.age > ?1
GreaterThanEqual findByAgeGreaterThanEqual ... where x.age >= ?1
After findByStartDateAfter ... where x.startDate > ?1
Before findByStartDateBefore ... where x.startDate < ?1
IsNull findByAgeIsNull ... where x.age is null
IsNotNull, NotNull findByAge(Is)NotNull ... where x.age is not null
Like findByFirstnameLike ... where x.firstname like ?1
NotLike findByFirstnameNotLike ... where x.firstname not like ?1
StartingWith findByFirstnameStartingWith ... where x.firstname like ?1
(파라미터 앞에 % 추가)
EndingWith findByFirstnameEndingWith ... wherer x.firstname like ?1
(파라미터 앞에 % 추가)
Containing findByFirstnameContaining ... where x.firstname like ?1
(파라미터 뒤에 % 추가)
OrderBy findByAgeOrderByLastnameDesc ... where x.age = ?1 order by x.lastname desc
Not findByLastnameNot ... where x.lastname <> ?1
In findByAgeIn
(Collection<Age> ages)
... where x.age in ?1
NotIn findByAgeNotIn
(Collection<Age> ages)
... where x.age not int ?1
TRUE findByActiveTrue() ... where x.active = true
FALSE findByActiveFalse() ... where x.active = false
IgnoreCase findByFirstnameIgnoreCase ... where UPPER(x.firstname) = UPPER(?1)

 

@Query 사용하기

메소드 이름이 복잡하거나 쿼리 메소드로 표현하기 힘들다면, @Query 어노테이션으로 쿼리를 직접 작성할 수 있습니다.

// @Query 어노테이션 사용한 JPQL 예시
@Query("SELECT file FROM BoardFileEntity file WHERE board_idx = ?1 AND idx = ?2")
BoardFileEntity findBoardFile(int boardIdx, int idx);

@Query("SELECT file FROM BoardFileEntity file WHERE board_idx =:boardIdx AND idx = :idx")
BoardFileEntity findBoardFile(@Param("boardIdx") int boardIdx, @Param("idx") int idx);
  • 위 예시는 findBoardFile(첨부파일 정보 조회) 메소드입니다. 두 메소드는 파라미터 표시하는 방식에서 차이를 보이고 있습니다.
  • [?숫자]: 메소드의 파라미터 순서대로 각각 ?1, ?2에 할당됩니다.
  • :[변수이름]: 변수이름은 메소드의 @Param 어노테이션에 대응됩니다. 
    • 일반적으로 많이 사용하는 방식입니다. [?숫자] 방식의 경우 파라미터가 한 두개일 때는 상관없지만, 파라미터 개수가 많아지거나 쿼리가 길어지면 파라미터간 비교가 어려워 예상치 못한 오류가 발생할 수 있기 때문에 :[변수이름] 방식을 주로 사용합니다.
  • JPQL 작성 시, 쿼리의 FROM 절에는 데이터베이스의 테이블명이 아닌 검색하려는 엔티티의 이름을 사용해야 한다는 것입니다.
Named Query 사용하기
어플리케이션을 개발할 때 쿼리 메소드만으로 부족한 경우가 발생합니다. 이럴 때, @Query 어노테이션을 이용해 쿼리를 작성하게 되는데 쿼리문이 길어지거나 많아지면 관리하기 어렵습니다. 이럴 때에는 쿼리문을 XML 파일에 작성하면 됩니다. XML에서는 <named-query> 태그<named-native-query> 태그를 사용합니다.

두 태그의 차이는 <name-query> 태그는 JPQL을 사용하고 <named-native-query>는 데이터베이스의 SQL을 사용(JPA의 장점 없어짐)한다는 점입니다. <named-query>나 <named-native-query>는 보통 통계와 같이 복잡한 쿼리를 작성할 때 사용합니다.

 

뷰 템플릿 작성하기

뷰 템플릿은 REST 게시판 만들 때 사용한 뷰 템플릿과 거의 동일합니다. 따라서, 기존에 만든 restBoardDetail.html, restBoardList.html, restBoardWrite.html 템플릿을 복사하여 jpaBoardDetail.html, jpaBoardList.html, jpaBoardWrite.html을 생성해줍니다.

// jpaBoardList.html
...
	        <tbody>
	        	<tr th:if="${#lists.size(list)} > 0" th:each="list : ${list}">
	        		<td th:text="${list.boardIdx}"></td>
	        		<td class="title">
	        			<a href="/jpa/board/" 
	        				th:attrappend="href=${list.boardIdx}"
	        				th:text="${list.title}"></a></td>
	        		<td th:text="${list.hitCnt}"></td>
	        		<td th:text="${#temporals.format(list.createdDatetime, 'yyyy-MM-dd HH:mm:ss')}"></td>
	        	</tr> 
	        	<tr th:unless="${#lists.size(list)} > 0">
	        		<td colspan="4">조회된 결과가 없습니다.</td>
	        	</tr>
	        </tbody>
	    </table>
	    <a href="/jpa/board/write/" class="btn">글 쓰기</a>
...

// jpaBoardWrite.html
...
		<form id="frm" name="frm" method="post" action="/jpa/board/write" enctype="multipart/form-data">
...
  • 게시글 목록 및 작성 화면에서는 호출하는 주소만 변경하면 됩니다.
// jpaBoardDetail.html
...
		<div class="file_list" th:each="list : ${board.fileList}">
			<a style="float:left" th:href="@{/jpa/board/file(idx=${list.idx}, boardIdx=${board.boardIdx})}" th:text="|${list.originalFileName} (${#numbers.formatInteger(list.fileSize/1000, 1, 'DEFAULT')} kb)|"></a>
			<form th:method="delete" th:action="@{/jpa/board/file(idx=${list.idx}, boardIdx=${board.boardIdx})}">
				<input type="submit" id="delete_file" th:value="파일삭제">
			</form>
		</div>
...
		<script type="text/javascript">
			$(document).ready(function(){
				$("#list").on("click", function(){
					location.href = "/jpa/board/";
				});
				
				$("#edit").on("click", function(){
					$("input:hidden[name=_method]").val("put");
					
					var frm = $("#frm")[0];
					frm.action = "/jpa/board/"+$("input:hidden[name=boardIdx]").val();
					frm.submit();
				});
				
				$("#delete").on("click", function(){
					$("input:hidden[name=_method]").val("delete");
					
					var frm = $("#frm")[0];
					frm.action = "/jpa/board/"+$("input:hidden[name=boardIdx]").val();
					frm.submit();
				});
			})
		</script>
...
  • th:href="@{/jpa/board/file(idx=${list.idx}, boardIdx=${board.boardIdx})}" th:text:"|${list.originalFileName} ($(#numbers.formatInteger(list.fileSize/1000, 1, 'DEFAULT')} kb)|"
    • list.fileSize/1000: 바이트 단위로 저장된 파일 크기를 킬로바이트로 표시하기 위해 1000으로 나누었습니다.
    • 1: 최소한으로 표시할 숫자 길이를 의미합니다. 1로 지정했으므로 최소 한 자리 숫자가 출력됩니다.
      • 예를 들어 3으로 지정하고 45를 출력하려 한다면 045로 출력합니다.
    • DEFAULT: 천 단위로 쉼표를 보여줍니다.

 

JPA 게시판 결과 확인하기

먼저 쿼리를 작성하지 않고 데이터베이스 작업이 제대로 이루어졌는지 확인할 것입니다. 

 

JPA 설정(application.properties)에서 spring.jpa.generate-ddl 옵션을 이용하면 엔티티에 해당하는 데이터베이스의 테이블이 자동으로 생성 또는 변경된다고 설명했습니다. BoardEntity와 BoardFileEntity에 해당하는 t_jpa_board, t_jpa_board_file 테이블이 정상적으로 생성되는지 확인해보도록 하겠습니다.

 

어플리케이션을 실행하면 아래와 같은 로그가 출력됩니다.

2021-01-03 14:35:50,442  INFO [jdbc.sqlonly] create table t_jpa_board (board_idx integer not null auto_increment, contents varchar(255) not null, created_datetime datetime not null, creator_id varchar(255) not null, hit_cnt integer not null, title varchar(255) not null, updated_datetime datetime, updater_id varchar(255), primary key (board_idx)) engine=InnoDB

2021-01-03 14:35:50,452  INFO [jdbc.sqlonly] create table t_jpa_file (idx integer not null auto_increment, created_datetime datetime not null, creator_id varchar(255) not null, file_size bigint not null, original_file_name varchar(255) not null, stored_file_path varchar(255) not null, updated_datetime datetime, updater_id varchar(255), board_idx integer, primary key (idx)) engine=InnoDB

2021-01-03 14:35:50,458  INFO [jdbc.sqlonly] alter table t_jpa_file add constraint FK2nbe74xrl4gfj0wnqo1d6dk3l foreign key (board_idx) references t_jpa_board (board_idx)

첫 실행 이후, 재시작을 하면 엔티티에 변화가 없으면 테이블 생성 또는 변경 쿼리가 수행되지 않고 바로 어플리케이션이 실행됩니다.

엔티티를 통해 테이블 생성된 모습

쿼리 작성하지 않고 데이터베이스 작업이 이루어진 것을 확인하였으니, 화면이 제대로 출력되는지 확인해보겠습니다.

 

게시글이 없는 상태에서 localhost:8080/jpa/board를 호출하면 아래와 같이 게시글 목록이 비어있음을 확인할 수 있습니다. 그 후, 글쓰기 버튼을 눌러 게시글을 등록하면 게시글 목록에 신규 게시글이 생성됨을 확인할 수 있습니다.

게시글 목록 조회 및 게시글 작성

작성한 게시글을 클릭하면 아래와 같이 게시글 상세 화면을 확인할 수 있습니다. 첨부파일을 클릭하면 첨부파일 다운로드가 가능하고, 파일 삭제를 클릭할 경우 파일을 삭제할 수 있습니다.

게시글 보기 및 첨부파일 다운로드, 삭제

게시글 수정의 경우 위에서 코드 작성 시 설명했던 것처럼, 기존과 다르게 hitCnt 내용을 추가했습니다. 해당 내용이 없으면 조회수가 0으로 초기화되는 점 참고하시면 될 것 같습니다.

게시글 수정

게시글 삭제의 경우는 아래와 같습니다. 필자의 경우 테스트를 두 번하느라, 아래 캡쳐사진에서 삭제할 게시글 번호가 3입니다.

게시글 삭제

반응형