게시판을 예로 들어보자.
아래와 같은 테이블이 존재한다고 가정한다.
DROP TABLE IF EXISTS file_info;
DROP TABLE IF EXISTS board_info;
CREATE TABLE board_info (
`board_id` INT NOT NULL AUTO_INCREMENT,
`board_title` VARCHAR(100) NULL,
`board_content` VARCHAR(2000) NULL,
`writer` VARCHAR(20) NULL,
`crtr_dtm` TIMESTAMP NULL,
PRIMARY KEY (`board_id`)
);
CREATE TABLE file_info (
`file_id` INT NOT NULL AUTO_INCREMENT,
`board_id` INT NOT NULL,
`own_file_nm` VARCHAR(100) NULL,
`saved_file_nm` VARCHAR(100) NULL,
PRIMARY KEY (`file_id`, `board_id`),
CONSTRAINT `FK_file_info` FOREIGN KEY (`board_id`)
REFERENCES `board_info` (`board_id`)
);
게시글에 존재하는 것은 단순 제목/내용 만이 아니다.
각 게시글에는 사용자가 업로드한 파일들이 포함되어 있고, 게시글 등록 시 만약 사용자가 게시글과 함께 파일을 업로드해놓았다면 이 또한 DB에 저장이 되어야 한다.
그렇다면 그 순서는 어떻게 될까?
Request로 게시글 정보와 파일을 함께 전송했다고 가정했을 때 로직은 아래 순서로 이루어질 것이다.
- 게시글 정보를 DB에 저장한다.
- 저장된 게시글의 PK를 외래키로 한 파일 정보를 DB에 저장한다.
- 파일을 서버에 저장한다.
만약 이 기능을 JPA를 사용해서 개발할 경우 별 다른 문제가 존재하지 않을 것이다.
해당 부분은 코드로 설명을 해보자.
설명을 위해 아래와 같은 코드를 작성했다.
우선은 위 테이블을 본따 만든 domain 클래스이다.
BoardInfo.java
package com.mwlee.keyproperty.domain;
import java.util.Date;
import java.util.List;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
@Table(name="board_info")
public class BoardInfo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "board_id")
private Integer boardId;
@Column(name = "board_title", length = 100)
private String boardTitle;
@Column(name = "board_content", length = 2000)
private String boardContent;
@Column(name = "writer", length = 20)
private String writer;
@Column(name = "crtr_dtm")
private Date creationDateTime;
@OneToMany(mappedBy = "boardInfo", cascade = CascadeType.ALL, orphanRemoval = true)
private List<FileInfo> fileInfos;
}
FileInfo.java
package com.mwlee.keyproperty.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
@Table(name = "file_info")
public class FileInfo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "file_id")
private Integer fileId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "board_id", nullable = false)
private BoardInfo boardInfo;
@Column(name = "own_file_nm", length = 100)
private String ownFileName;
@Column(name = "saved_file_nm", length = 100)
private String savedFileName;
}
Respository는 내용이 없어 생략하고, Service는 다음과 같이 save만 생성해놓았다.
package com.mwlee.keyproperty.service;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.mwlee.keyproperty.domain.BoardInfo;
import com.mwlee.keyproperty.domain.FileInfo;
import com.mwlee.keyproperty.repository.BoardRepository;
import com.mwlee.keyproperty.repository.FileRepository;
import jakarta.transaction.Transactional;
@Service
public class JpaService {
@Autowired BoardRepository boardRepo;
@Autowired FileRepository fileRepo;
/**
* 단일 트랜잭션 처리를 위해 한 메소드에서 트랜잭션을 걸어서 사용
* @param boardInfo
* @param fileInfo
* @return
*/
@Transactional
public void save(BoardInfo boardInfo, List<FileInfo> fileInfoList) throws Exception {
// 게시글 저장
boardRepo.save(boardInfo);
// 파일 저장 > 사실 BoardInfo에서 옵션이 CASCADE.ALL이라 게시글 저장 시 함께 저장되어
// 굳이 저장할 필요가 없긴 함.
fileRepo.saveAll(fileInfoList);
}
}
마지막으로 테스트 코드이다.
@Autowired JpaService service;
@Test
void doTestJpa() {
// 게시글 정보 생성
BoardInfo board = new BoardInfo();
board.setBoardTitle("TEST TITLE");
board.setBoardContent("THIS IS TEST CONTENT.");
board.setWriter("lmw");
board.setCreationDateTime(new Date());
// 업로드한 파일 정보 생성
FileInfo fileOne = new FileInfo();
FileInfo fileTwo = new FileInfo();
fileOne.setOwnFileName("FILE1.csv");
fileOne.setSavedFileName("asdws-sadsf-sadsf-gasdz.csv");
fileOne.setBoardInfo(board);
fileTwo.setOwnFileName("FILE2.csv");
fileTwo.setSavedFileName("hfdfb-sddfe-dsfdv-asdwa.csv");
fileTwo.setBoardInfo(board);
List<FileInfo> fileInfoList = new ArrayList<>();
fileInfoList.add(fileOne);
fileInfoList.add(fileTwo);
try {
// 저장
service.save(board, fileInfoList);
}
catch(Exception e) {
log.error("@@ Error Occured : ", e);
}
}
Board의 PK는 AUTO INCREMENT로 자동으로 생성되는 키이다. 고로 코드 단에서 해당 PK는 아직 DB에 저장되지 않아 null 상태일 것이다.
그럼에도 불구하고 File이 정상적으로 저장되었다. 이는 JPA의 특징인 영속성 컨텍스트 (Persistence Context) 덕이다. 굳이 엔티티가 DB에 저장되지 않더라도, 마치 저장된 것처럼 참조할 수 있게 엔티티간 관계가 유지되는 것이다.
영속성 컨텍스트 (Persistence Context)
JPA는 엔티티의 상태를 영속성 컨텍스트라는 메모리 영역에서 관리한다. 일종의 캐시로 볼 수 있는데, 엔티티가 DB에 저장되디 전까지 메모리에 남아있고, 트랜잭션이 끝날 때까지 모든 엔티티의 변경 사항이 영속성 컨텍스트에 저장되며, 트랜잭션이 완료되면 DB에 반영되는 형식이다.
MyBatis
그러면 위 코드를 MyBatis로 옮겨보자.
우선은 간단하게 DTO 클래스를 만들었다.
BoardInfo.java
package com.mwlee.keyproperty.domain;
import java.util.Date;
import lombok.Data;
@Data
public class BoardInfo {
private Integer boardId;
private String boardTitle;
private String boardContent;
private String writer;
private Date creationDateTime;
}
FileInfo.java
package com.mwlee.keyproperty.domain;
import lombok.Data;
@Data
public class FileInfo {
private Integer fileId;
private Integer boardId;
private String ownFileName;
private String savedFileName;
}
그리고 Mapper XML과 인터페이스를 생성했다.
인터페이스는 간단하므로 포스트에는 적지 않을 것이고, xml만 적어놓을까 한다.
BoardMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.mwlee.keyproperty.mapper.BoardMapper">
<!-- 게시글 저장 -->
<insert id="saveBoard" parameterType="com.mwlee.keyproperty.domain.BoardInfo">
INSERT INTO board_info (
board_title,
board_content,
writer,
crtr_dtm
) VALUES (
#{boardTitle},
#{boardContent},
#{writer},
#{creationDateTime}
)
</insert>
<!-- 파일 저장 (일괄 저장) -->
<insert id="saveFiles" parameterType="java.util.List">
INSERT INTO file_info (
board_id,
own_file_nm,
saved_file_nm
) VALUES
<foreach collection="list" item="file" separator=",">
(
#{file.boardId},
#{file.ownFileName},
#{file.savedFileName}
)
</foreach>
</insert>
</mapper>
다음으로 서비스 클래스를 아래와 같이 재작성했다.
MybatisService.java
package com.mwlee.keyproperty.service;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.mwlee.keyproperty.domain.BoardInfo;
import com.mwlee.keyproperty.domain.FileInfo;
import com.mwlee.keyproperty.mapper.BoardMapper;
import jakarta.transaction.Transactional;
@Service
public class MybatisService {
@Autowired BoardMapper boardMapper;
/**
* 단일 트랜잭션 처리를 위해 한 메소드에서 트랜잭션을 걸어서 사용
* @param boardInfo
* @param fileInfo
* @return
*/
@Transactional
public void save(BoardInfo boardInfo, List<FileInfo> fileInfoList) throws Exception {
// 게시글 저장
boardMapper.saveBoard(boardInfo);
// 파일 저장
fileInfoList.forEach(fileInfo -> {
fileInfo.setBoardId(boardInfo.getBoardId());
});
boardMapper.saveFiles(fileInfoList);
}
}
그리고 테스트 코드를 만들어서 한 번 돌려보자.
@Autowired MybatisService service;
@Test
void doTestJpa() {
// 게시글 정보 생성
BoardInfo board = new BoardInfo();
board.setBoardTitle("TEST TITLE");
board.setBoardContent("THIS IS TEST CONTENT.");
board.setWriter("lmw");
board.setCreationDateTime(new Date());
// 업로드한 파일 정보 생성
FileInfo fileOne = new FileInfo();
FileInfo fileTwo = new FileInfo();
fileOne.setOwnFileName("FILE1.csv");
fileOne.setSavedFileName("asdws-sadsf-sadsf-gasdz.csv");
fileTwo.setOwnFileName("FILE2.csv");
fileTwo.setSavedFileName("hfdfb-sddfe-dsfdv-asdwa.csv");
List<FileInfo> fileInfoList = new ArrayList<>();
fileInfoList.add(fileOne);
fileInfoList.add(fileTwo);
try {
// 저장
service.save(board, fileInfoList);
}
catch(Exception e) {
log.error("@@ Error Occured : ", e);
}
}
당연히 에러가 발생할 것이다.
이유라면 너무나도 당연하게, file 데이터의 외래키인 board_id가 null이기 때문이다.
JPA에서는 위에서 설명했듯 영속성 컨텍스트라는 특성 상 사전에 외래키로 지정한 boardInfo의 PK를 별도로 지정하지 않아도 해당 값은 null이 아니었고, 그 덕에 별도의 SELECT 없이 관계를 설정할 수 있었다.
하지만 MyBatis는 영속성 컨텍스트 기반이 아니고, 그에 따라 당연히 코드에서 지정하지 않은 ID값이 DB에서 자동으로 생성이 된다고 그 값이 자바 객체에 곧바로 되돌아오는 일은 없다.
그러면 반드시 전통적인 방법대로 Insert 후 Select를 통해 PK를 가져와 다시 Insert를 수행해야만 하는 것일까? 굳이 그럴 필요는 또 없다.
위에서 표현을 잘못한 것 같다. "DB에서 자동으로 생성된 값이 자바 객체에 곧바로 되돌아오는 일은 없다"가 아니라 "DB에서 자동으로 생성된 값이 별도의 설정 없이 자바 객체에 곧바로 되돌아오는 일은 없다"가 맞는 표현인 것 같다.
위 말에서 알 수 있듯, 별도의 설정을 통한다면 해당 PK가 곧바로 되돌아와 자바 객체에도 적용이 된다는 이야기이다.
그리고 그 방법은 바로 useGeneratedKeys 설정을 사용하는 것이다.
- useGeneratedKeys: 데이터베이스에서 자동 생성된 키 값을 사용하도록 설정한다. MySQL, PostgreSQL 등에서 사용 가능하다.
- keyProperty: 생성된 키 값을 매핑할 객체의 필드를 지정한다.
한 마디로 자동으로 생성된 키값을 자바 객체에 자동으로 할당해준다는 이야기이다.
사용방법은 간단한데, 그냥 MyBatis Mapper에 추가만 해주면 된다.
참고로 이 때 keyProperty는 Java 객체 내의 멤버변수로 설정해주어야 한다. (DB내 컬럼명이 아니다.)
위 설정을 적용한 후 테스트 코드를 다시 돌려보면 코드가 정상적으로 실행되고, 데이터가 정상 삽입됨을 확인할 수 있다.
'실습 > 리눅스 서버 + 스프링 부트' 카테고리의 다른 글
SSE를 사용해 알람 구현 (3) | 2024.10.29 |
---|---|
Redis도 네트워크 딜레이를 고려해야 한다 (0) | 2024.10.28 |
[Thymeleaf] 데이터를 동적으로 바꾸기 - replaceWith (1) | 2024.09.14 |
Mybatis 중복 쿼리 공통화 (<sql>과 <include>) + Interceptor (1) | 2024.09.07 |
[Spring] 자바 이미지 핸들링 (0) | 2024.07.21 |