SpringBoot #5 다양한 연관관계 처리 #3 단방향 처리2

2021. 10. 11. 17:01JAVA/Spring

이전글

https://yonghwankim-dev.tistory.com/140

 

SpringBoot #5 다양한 연관관계 처리 #2 단방향 처리1

이전글 https://yonghwankim-dev.tistory.com/139 SpringBoot #5 다양한 연관관계 처리 #1 연관관계 처리 순서와 사전 설계 본 글은 스타트 스프링 부트 도서의 내용을 복습하기 위해 작성된 글입니다. 개요 관계

yonghwankim-dev.tistory.com

 

본 글은 스타트 스프링 부트 도서의 내용을 복습하기 위해 작성된 글입니다.

 

개요

'단방향'으로 연관관계를 처리하는 경우에는 한쪽만 참조를 하기 때문에 일대다, 다대일에서는 어느 쪽에 참조에 대한 설정을 두는지를 세심하게 결정해야 합니다. 이전 글에서는 Member 클래스에는 Profile에 대한 참조가 없었고, Profile에서는 Member를 참조하는 형태로 작성되었습니다.

 

본 글에서는 @JointTable 애노테이션을 활용하여 자료실의 '자료'와 '첨부 파일'의 관계를 실습합니다.

  • 객체 간 연관관계 설정
  • 단방향, 양방향 관계의 이해 (현재)
  • JPQL을 이용한 @Query 처리와 Fetch JOIN(스프링 부트 2.0.0)

 

1. 엔티티 클래스 작성

org.zerock.domain 패키지에 PDSBoard, PDSFile 클래스 생성

 

org.zerock.domain.PDSBoard

@Getter
@Setter
@ToString
@Entity
@Table(name="tbl_pds")
@EqualsAndHashCode(of="pid")
public class PDSBoard {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long pid;
	private String pname;
	private String pwriter;
	
}

org.zerock.domain.PDSFile

@Getter
@Setter
@ToString
@Entity
@Table(name="tbl_pdsfiles")
@EqualsAndHashCode(of="fno")
public class PDSFile {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long fno;
	private String pdsfile;
}

 

1.1 연관관계 설정

자료실의 자료(PDSBoard)와 첨부파일(PDSFile)의 관계는 '일대다', '다대일'의 관계이고 예제에서는 PDSBoard 쪽에서 단방향으로 연관관계를 설정합니다.

 

org.zerock.domain.PDSBoard

@Getter
@Setter
@ToString(exclude="files")
@Entity
@Table(name="tbl_pds")
@EqualsAndHashCode(of="pid")
public class PDSBoard {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long pid;
	private String pname;
	private String pwriter;
	
	@OneToMany
	@JoinColumn(name="pdsno")
	private List<PDSFile> files;
}

@JoinTable은 자동으로 생성되는 테이블 대신에 별도의 이름을 가진 테이블을 생성하고자 할때 사용하고, @JoinColumn은 이미 존재하는 테이블에 칼럼을 추가할 때 사용합니다.

 

boot04Application 실행하여 테이블 생성 확인

Output 

Hibernate: create table tbl_pds (pid bigint not null auto_increment, pname varchar(255), pwriter varchar(255), primary key (pid)) engine=InnoDB
Hibernate: create table tbl_pdsfiles (fno bigint not null auto_increment, pdsfile varchar(255), pdsno bigint, primary key (fno)) engine=InnoDB

위의 그림과 같이 tbl_pds, tbl_pdsfiles 테이블 생성을 확인합니다.

 

1.2 연관관계에 따른 Repository

현재 PDSFile 클래스의 경우 PDSBoard에 대한 참조가 없기 때문에 문제가 발생합니다. PDSFile이 저장되는 tbl_pdsfiles 테이블에는 tbl_pds의 pid를 참조하기 때문에 값이 들어가야 합니다. 문제는 PDSFile 클래스에는 PDSBoard에 대한 참조가 없기 때문에 단독으로 처리할 수 없다는 것입니다.

 

반면 PDSBoard는 모든 PDSFile 객체들의 참조를 보관할 수 있으므로,  원하는 모든 데이터에 대한 처리가 가능합니다.

 

위와 같은 문제를 해결하기 위해서는 각각 Repository를 생성하는 대신에 'One(일)'쪽에 해당되는 인티티 객체에 대한 Repository(PDSBoard)만을 이용하는 것이 좋습니다.

 

org.zerock.persistence.PDSBoardRepository 생성

public interface PDSBoardRepository extends CrudRepository<PDSBoard, Long>{

}

 

2. 등록과 Cascading 처리

새로운 자료가 등록될때 자료와 첨부된 파일을 동시에 등록하는 경우를 테스트합니다.

 

org.zerock.PDBBoardTests

@SpringBootTest
@Log
@Commit
class PDSBoardTests {
	@Autowired
	PDSBoardRepository repo;
    
}

org.zerock.PDBBoardTests 일부

	// 1개의 자료와 2개의 첨부파일을 저장하려고 시도
	@Test
	void testInsertPDS() {
		PDSBoard pds = new PDSBoard();
		pds.setPname("DOCUMENT 1 - 2");
		
		PDSFile file1 = new PDSFile();
		file1.setPdsfile("file1.doc");
		
		PDSFile file2 = new PDSFile();
		file2.setPdsfile("file2.doc");
		
		List<PDSFile> list = new ArrayList<PDSFile>();
		list.add(file1);
		list.add(file2);
		
		pds.setFiles(list);
		
		log.info("trye to save pds");
		repo.save(pds);
	}

 

testInsertPDS 테스트 코드를 실행한 결과는 아래와 같습니다.

org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.TransientObjectException: object references an unsaved transient instance - save the transient instance before flushing: org.zerock.domain.PDSFile; nested exception is java.lang.IllegalStateException: org.hibernate.TransientObjectException: object references an unsaved transient instance - save the transient instance before flushing: org.zerock.domain.PDSFile
	at org.springframework.orm.jpa.EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible(EntityManagerFactoryUtils.java:371)
	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:235)
	at org.springframework.orm.jpa.JpaTransactionManager.doCommit(JpaTransactionManager.java:566)
	at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:743)
	... 생략

testInsertPDS()를 실행하면 두 개의 테이블(tbl_pds, tbl_pdsfiles)에 데이터를 저장하는 것을 기대하지만, 위와 같은 에러가 발생합니다. 위와 같은 문제가 발생한 이유는 JPA에서 한번에 여러 엔티티 객체들의 상태를 변경해주어야 하기 때문입니다. 즉, 한번에 PDSBoard 객체로 보관해야 하고, PDSFile의 상태도 보관해야 하기 때문입니다.

 

영속성 전이란 무엇인가?

JPA에서는 처리하려는 엔티티 객체의 상태에 따라서 종속적인 객체들의 영속성도 같이 처리되는 것을 영속성 전이라고 합니다. 영속성 전이의 좋은 예시는 날짜(부모 엔티티)와 일정(자식 엔티티)을 생각했을 때 특정한 날짜가 데이터베이스에서 사라지게 되면 거기에 해당하는 일정들 역시 같이 삭제되어야 합니다. JPA에서는 엔티티들이 기본적으로 메모리상의 관계이므로, 날짜 객체가 사라질때 일정 객체 역시 같이 삭제될 필요가 있습니다. 이처럼 영속성 전이는 부모 엔티티나 자식 엔티티의 상태 변호가 자신과 관련 있는 엔티티에 영향을 주는 것을 의미합니다.

 

종속적인 엔티티의 영속성 전이 설정

  • ALL : 모든 변경에 대해 전이
  • PERSIST : 저장시에만 전이
  • MERGE : 병합시에만 전이
  • REMOVE : 삭제 될 시에만 전이
  • REFRESH : 엔티티 매니저의 refresh() 호출 시 전이
  • DETACH : 부모 엔티티가 detach 되면 자식 엔티티 역시 detach

PDSBoard에서 @OneToMany 속성에 cascade 속성을 저장

@Getter
@Setter
@ToString(exclude="files")
@Entity
@Table(name="tbl_pds")
@EqualsAndHashCode(of="pid")
public class PDSBoard {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long pid;
	private String pname;
	private String pwriter;
	
	@OneToMany(cascade = CascadeType.ALL)
	@JoinColumn(name="pdsno")
	private List<PDSFile> files;
}

변경된 코드를 저장하고 다시 testInsertPDS() 테스트 수행 결과 확인

 

3. 첨부 파일 수정과 @Modifying, @Transactional

데이터베이스 처리를 위해서는 Repository의 존재가 필수적인데, 앞서 살펴본 예제와 같이 PDSBoardRepository만이 존재하여 첨부 파일의 이름만 수정해야 하는 상황에서 PDSFile에 대한 Repository가 존재하지 않습니다. 이러한 경우에 @Query를 이용해서 처리하면 편합니다.

 

org.zerock.persistence.PDSBoardRepository

public interface PDSBoardRepository extends CrudRepository<PDSBoard, Long>{
	
	// 파일번호에 따른 첨부파일명을 변경
	@Modifying
	@Query("UPDATE FROM PDSFile f set f.pdsfile = ?2 WHERE f.fno= ?1")
	public int updatePDSFile(Long fno, String newFileName);
	
}

@Query는 기본적으로 'select' 구문만을 지원하지만 @Modifying을 이용해서 DML(insert, update, delete) 작업을 처리할 수 잇습니다.

 

tbl_pdsfiles 테이블의 데이터 상황

 

 

org.zerock.PDSBoardTests 일부

	// 1번파일번호의 첨부파일명을 변경
	@Transactional
	@Test
	public void testUpdateFileName1() {
		Long fno = 1L;
		String newName = "updatedFile1.doc";
		
		int count = repo.updatePDSFile(fno, newName);
		// @Log 설정된 이후 사용 가능
		log.info("update count: " + count);
	}

'delete'나 'update'을 사용하는 경우에는 반드시 @Transactional 처리를 필요로 합니다. @Transactional 어노테이션은 스프링에서 트랜잭션 처리를 지원하는 어노테이션입니다.

 

testUpdateFileName1() 테스트 수행 결과

위와 같이 file1.doc -> updatedFile1.doc로 변경된 것을 확인할 수 있습니다. 단, @Transactional 어노테이션을 사용 시 주의할 점은 테스트를 수행하는 클래스에 @Commit 어노테이션을 설정하지 않으면 롤백될 수 있습니다. @Transactional 어노테이션 설정시 @Commit 어노테이션을 설정해주어야 합니다.

@SpringBootTest
@Log
@Commit
class PDSBoardTests {
,,,
}

 

3.1 순수한 객체를 통한 파일 수정

@Query와 @Modifying을 이용하는 것이 편리하긴 하지만, 필요한 경우에 전통적인 접근 방식을 이용해서 첨부파일 이름을 수정할 수 있습니다.

 

전통적인 방식은 PDSBoardRepository에서 PDSBoard를 얻어온 후 내용물인 PDSFile을 수정하고 save()를 이용해서 업데이트를 진행하는 것입니다.

 

이번에는 전통적인 방식을 사용하여 첨부파일명을 변경하는 것을 실습해봅니다.

tbl_pdsfiles 테이블 데이터 상황

다음 테스트 코드는 fno가 2인 첨부파일 명을 수정하는 테스트입니다.

org.zerock.PDSBoardTests 일부

	// pid가 2인 PDSBoard 객체를 얻고 fno가 2인 첨부파일명을 변경한다.
	@Transactional
	@Test
	public void testUpdateFileName2() {
		String newName = "updatedFile2.doc";
		
		// 반드시 번호가 존재하는지 확인할 것
		Optional<PDSBoard> result = repo.findById(2L);
		
		result.ifPresent(pds->{
			log.info("데이터가 존재하므로 update 시도");
			PDSFile target = new PDSFile();
			target.setFno(2L);
			target.setPdsfile(newName);
			
			int idx = pds.getFiles().indexOf(target);
			
			if(idx > -1) {
				List<PDSFile> list = pds.getFiles();
				list.remove(idx);
				list.add(target);
				pds.setFiles(list);
			}
			
			repo.save(pds);
		});
	}

 

  • repo.findById(2L) : tbl_pds 테이블의 pid 컬럼의 값이 2L인 레코드를 탐색합니다. 반환 타입은 Optional<PDSBoard>로 인하여 Optional 타입의 ifPresent() 메서드를 통하여 탐색된 레코드가 존재하는 경우 메서드 안에 있는 내용을 수행합니다.

위의 테스트 실행 결과를 보면 첨부파일명이 file2.dock -> updatedFile2.doc로 변경된 것을 확인할 수 있습니다. 위의 콘솔 출력 결과에서 tbl_pds 테이블에서 데이터를 조회하는 부분과 tbl_pdsfiles 테이블에서 조회하는 부분이 별개로 실행되는 것을 볼 수 있습니다. 이것은 JPA에서 연관관계 테이블을 조회할때 지연 로딩(Lazy Loading)이라는 것을 하기 때문입니다.

 

지연 로딩(Lazy Loading)이란 무엇인가?

지연로딩은 말 그대로 '게으른'이라는 의미로, 정보가 필요하기 전까지는 최대한 테이블에 접근하지 않는 방식을 의미합니다. 지연로딩을 사용하는 가장 큰 이유는 성능 때문입니다. 하나의 엔티티가 여러 엔티티들과 종속적인 관계를 맺고 있다면, SQL에서는 조인을 이용하는데, 조인이 복잡해질수록 성능이 저하되기 때문입니다. 따라서 JPA에서는 연관관계의 Collection 타입을 처리할 때 '지연 로딩'을 기본으로 사용합니다.

 

4. 첨부 파일 삭제

수정과 마찬가지로 삭제 작업 역시 @Query를 이용하거나 전통적인 방식인 객체를 통해서 접근하는 방식을 사용할 수 있습니다.

 

@Query를 이용한 첨부파일 삭제

org.zerock.persistence.PDSBoardRepsitory

public interface PDSBoardRepository extends CrudRepository<PDSBoard, Long>{
	
	// 파일번호에 따른 첨부파일 삭제
	@Modifying
	@Query("DELETE FROM PDSFile f where f.fno = ?1")
	public int deletePDSFile(Long fno);
	
}

org.zerock.PDSBoardTests 일부

	// 파일번호에 따른 첨부파일을 삭제
	@Transactional
	@Test
	public void deletePDSFile() {
		// 첨부 파일 번호
		Long fno = 2L;
		
		int count = repo.deletePDSFile(fno);
		
		log.info("DELETE PDSFILE : " + count);
	}

위 테스트 결과와 같이 fno=2인 첨부파일이 제거된 것을 확인할 수 있습니다.

5. 조인 처리

실제 화면에서는 '특정 자료의 번호와 자료의 제목, 첨부 파일 수'를 같이 보여줘야 하는 상황에 놓일 수 있습니다. 이러한 상황에서는 @Query 애노테이션을 이용하여 조인을 처리하여 해결이 가능합니다.

 

tbl_pds와 tbl_pdsfiles에 샘플 데이터 추가 (org.zerock.PDSBoardTests 일부)

	// 샘플 데이터 추가
	@Test
	public void insertDummies() {
		List<PDSBoard> list = new ArrayList<PDSBoard>();
		
		IntStream.range(1, 100).forEach(i->{
			PDSBoard pds = new PDSBoard();
			pds.setPname("자료 " + i);
			
			PDSFile file1 = new PDSFile();
			file1.setPdsfile("file1.doc");
			
			PDSFile file2 = new PDSFile();
			file2.setPdsfile("file2.doc");
			
			List<PDSFile> pdsfile_list = new ArrayList<>();
			pdsfile_list.add(file1);
			pdsfile_list.add(file2);
			pds.setFiles(pdsfile_list);
			
			log.info("try to save pds");
			
			list.add(pds);
			
		});
		
		repo.saveAll(list);
	}

위의 isnertDummies() 테스트 코드를 수행하여 100개의 자료와 200개의 첨부파일 데이터를 추가합니다.

 

@Query 조인 활용 예제

아래의 쿼리는 자료와 첨부 파일의 수를 자료번호의 역순으로 출력하는 쿼리입니다.

org.zerock.persistence.PDSBoardRepository

public interface PDSBoardRepository extends CrudRepository<PDSBoard, Long>{
	
	// 자료와 첨부 파일의 수를 자료 번호의 역순으로 출력
	@Query("SELECT p, count(f) FROM PDSBoard p LEFT OUTER JOIN p.files f "
			+ " ON p.pid = f WHERE p.pid > 0 GROUP BY p ORDER BY p.pid DESC ")
	public List<Object[]> getSummary();
}

org.zerock.PDSBoardTests

	// 자료와 첨부 파일의 개수를 자료번호의 역순으로 출력
	@Test
	public void viewSummary() {
		repo.getSummary().forEach(arr -> log.info(Arrays.toString(arr)));
	}

 

지금까지 두 테이블의 단방향 처리를 실습하였습니다. 앞의 예제들과 같이 단방향의 경우 한쪽의 객체만을 이용한다는 점은 편리하게 다가올 수 있지만, 조인이 필요한 경우에는 좀 더 신중이 고려할 필요가 있습니다.

 

References

스타트 스프링 부트, 구멍가게코딩단 지음