GuestBook 프로젝트 (5) (검색 처리, 검색 조건 추가, 수정 및 삭제 후 redirect)
GuestBook 프로젝트 (5) (검색 처리, 검색 조건 추가, 수정 및 삭제 후 redirect)
GuestBook 프로젝트 (4) (guestbook 등록, 조회, 수정, 삭제) GuestBook 프로젝트 (4) (guestbook 등록, 조회, 수정, 삭제) GuestBook 프로젝트 (3) (목록처리, 페이징, Controller) GuestBook 프로젝트 (3) (목록처리, 페이징
soohykeee.tistory.com
기존에 작성했던 프로젝트에서 약간의 수정을 통해 Member, Board, Relpy가 있는 프로젝트를 만들것이다.
프로젝트를 작성하기 전에 우선적으로 연관관계에 대해 생각하고 설계가 필요하다.
연관관계와 관계형 데이터베이스 설계
관계형 데이터베이스에서 개체간의 관계에 대해 고민해야한다. 관계형 데이터베이스에서는 1:1, 1:N, N:M 의 관계를 이용하여 데이터가 서로 간에 어떻게 구성되었는지를 표현하게 된다. 해당 표현을 위해서 가장 중요한 점은 PK, FK를 어떻게 설정하여 사용할 것인가 이다.
우선적으로 Member와 Board의 관계를 살펴보면, 다음과 같은 명제를 생각해 볼 수 있다.
- 한 명의 Member는 여러 Board를 작성할 수 있다.
- 하나의 Board는 한명의 Member에 의해서 작성이 된다.
위의 두 명제들은 틀린것 없이 작성되었다. 첫번째 문장을 보면 1:N의 연관관계를 가지는 것이 명확하게 느껴질것이다. 하지만 두번째 문장을 보게 되면 1:1 연관관계인 것처럼 보일 수 있다. 해당 문장은 데이터 베이스에서 관계를 해석할 때 PK 쪽에서 해석한 것이 아니라서 발생된 문제이다. 이러한 혼란을 줄이기 위해서는 PK쪽 입장으로 해석해야 하는 능력을 길러야한다.
- 한 명의 Member는 여러개의 Board를 작성할 수 있다. (PK 에서 해석)
- 하나의 Board는 한명의 Member만을 표시할 수 있다.
앞서 있던 문장을 위와 같이 수정하게 된다면, 연관관계에 헷갈리지 않고 1:N인줄 알 수 있다.
위에서의 내용으로 Member, Board, Reply의 연관관계를 살펴보면 아래와 같은 결과가 나오게 된다.
- Board는 Member와 N:1 관계
- Reply는 Board와 N:1 관계
Entity 클래스 추가
해당 entity 디렉토리 하위에 Board, Member, Reply 클래스를 추가합니다.
package com.example.guestbookdemo.entity;
import lombok.*;
import javax.persistence.Entity;
import javax.persistence.Id;
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString
public class Member extends BaseEntity {
@Id
private String email;
private String password;
private String name;
}
----------------------------------
package com.example.guestbookdemo.entity;
import lombok.*;
import javax.persistence.*;
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = "writer")
public class Board extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bno;
private String title;
private String content;
@ManyToOne
private Member writer;
}
---------------------------
package com.example.guestbookdemo.entity;
import lombok.*;
import javax.persistence.*;
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = "board")
public class Reply extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long rno;
private String text;
private String replyer;
@ManyToOne
private Board board;
}
위와 같이 연관관계에 맞춰 @ManyToOne 어노테이션을 서로 연결해준다.
해당 어노테이션은 1쪽의 PK 값을 N쪽에 작성해줘야한다. 해당 entity들을 작성해준후 실행해보면 정상적으로 Table이 생성이 되고 FK로 서로 연결된 것을 확인 할 수 있다.
Repository , Test
해당 repository 디렉토리 하위에 BoardRepository, MemberRepository, ReplyRepository 클래스를 추가합니다.
package com.example.guestbookdemo.repository;
import com.example.guestbookdemo.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, String> {
}
---------------------------------------------------------
package com.example.guestbookdemo.repository;
import com.example.guestbookdemo.entity.Board;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BoardRepository extends JpaRepository<Board, Long> {
}
---------------------------------------------------------
package com.example.guestbookdemo.repository;
import com.example.guestbookdemo.entity.Reply;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ReplyRepository extends JpaRepository<Reply, Long> {
}
Repository interface도 작성했으니, 정상적으로 저장이 되고, 연관관계가 잘 이루어졌는지 확인하기 위해 Test 코드를 통해 확인 해보겠다.
MemberRepositoryTest
Board나 Reply entity에서의 연관관계를 확인하기 위해서는 우선적으로 Member가 존재해야하므 MemberRepositoryTests 클래스를 생성한 후, 아래와 같이 Member insert하는 코드를 작성한다.
package com.example.guestbookdemo.repository;
import com.example.guestbookdemo.entity.Member;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.stream.IntStream;
@SpringBootTest
public class MemberRepositoryTests {
@Autowired
private MemberRepository memberRepository;
@Test
public void insertMember() {
IntStream.rangeClosed(1, 100).forEach(i -> {
Member member = Member.builder()
.email("user" + i + "@aaa.com")
.password("1111")
.name("USER" + i)
.build();
memberRepository.save(member);
});
}
}
BoardRepositoryTest
Board에 Member의 PK 값인 email을 FK 값으로 연결했으므로, board에 builder를 통해 build 해주기 전에 member의 기존에 존재하던 email 값을 가져왔다. 해당 코드를 작성한 후 실행해보면 정상적으로 board에 값이 저장이 되는것을 확인 할 수 있다.
package com.example.guestbookdemo.repository;
import com.example.guestbookdemo.entity.Board;
import com.example.guestbookdemo.entity.Member;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.stream.IntStream;
@SpringBootTest
public class BoardRepositoryTests {
@Autowired
private BoardRepository boardRepository;
@Test
public void insertBoard() {
IntStream.rangeClosed(1, 100).forEach(i -> {
Member member = Member.builder().email("user" + i + "@aaa.com").build();
Board board = Board.builder()
.title("Title..." + i)
.content("Content..." + i)
.writer(member)
.build();
boardRepository.save(board);
});
}
}
ReplyRepositoryTest
Reply에 Board의 PK 값인 bno를 FK 값으로 연결했으므로, Reply에 builder를 통해 build 해주기 전에 Board의 기존에 존재하던 bno값을 가져왔다. 앞서 test코드를 통해 board에 insert 할때 100까지만 줬으므로 1~100 사이의 코드로 random하게 가져와서 bno값을 넘겨줬다. 해당 코드를 작성한 후 실행해보면 정상적으로 Reply에 값이 저장이 되는것을 확인 할 수 있다.
package com.example.guestbookdemo.repository;
import com.example.guestbookdemo.entity.Board;
import com.example.guestbookdemo.entity.Reply;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.stream.IntStream;
@SpringBootTest
public class ReplyRepositoryTests {
@Autowired
ReplyRepository replyRepository;
@Test
public void insertReply() {
IntStream.rangeClosed(1, 300).forEach(i->{
long bno = (long) (Math.random() * 100) + 1;
Board board = Board.builder().bno(bno).build();
Reply reply = Reply.builder()
.text("Reply....." + i)
.board(board)
.replyer("guest")
.build();
replyRepository.save(reply);
});
}
}
즉시 로딩 / 지연 로딩
앞서 1:N로 엔티티간의 연관관계를 맺게 되면 쿼리를 실행하는 데이터베이스 입장에서 봤을 때 고민을 해야할 것이 생긴다. 그것은 엔티티 클래스들이 실제 데이터베이스상에서는 두개 혹은 두개 이상의 테이블로 생성되기 때문에, 연관관계를 맺고 있다는 뜻은 결국 데이터베이스 입장에서 보면 조인이 필요하다는 뜻이다.
실제로 @ManyToOne의 경우 FK 쪽의 entity만 조회하려고 해도 PK 쪽의 entity도 같이 가져오게된다. 그렇게 되면 쓸데없는 쿼리가 실행이 된다는 뜻이다. 만약 지금은 두개의 테이블만 조인이 되어서 한개의 테이블만 조회하려해도 두개의 테이블을 조회하는 쿼리가 실행이 되는데, 실제 실무에서는 여러개의 테이블을 조인하므로, 해당 기능을 수정해주지 않으면 비효율적인 쿼리가 나가게 됩니다.
위와 같이 조인된 모든 테이블이 쿼리로 나가게 되는것이 즉시 로딩 (EAGER) 인 경우이다. @ManyToOne, @OneToOne과 같이 @XXXToOne 어노테이션들은 기본값이 즉시 로딩(EAGER) 이고, @ManyToMany, @OneToMany와 같이 @XXXToMany 어노에티션들은 기본값이 지연 로딩(LAZY)이다.
즉시로딩과 지연로딩을 프로젝트에 적용시키기 위해서는 두 기능부터 알아야 한다.
즉시 로딩 (EAGER)
즉시 로딩은 엔티티 조회 시 연관관계에 있는 데이터까지 한 번에 조회해오는 기능이며, fetch=FetchType.EAGER 옵션으로 지정이 가능하다. 대부분의 연관관계 어노테이션은 default 값으로 즉시로딩이 적용이 된다.
지연 로딩 (LAZY)
지연 로딩은 엔티티 조회 시점이 아닌 엔티티 내 연관관계를 참조할 때 해당 연관관계에 대한 SQL이 질의되는 기능이며, fetch=FetchType.LAZY 옵션으로 지정이 가능하다. 지연로딩되는 연관관계를 참조하기 전까지는 프록시 객체가 초기화되자 않고, 프록시 객체를 참조할 때 프록시 객체가 초기화되고 SQL이 질의됩니다.
쉽게말하면 즉시 로딩과 다르게, 연관관계가 있다고 하더라도 직접적으로 연관관계에 있는 것을 요청하지 않으면 불필요하게 모든 연관관계 테이블을 조회하는 쿼리를 날리지 않는다는 뜻이다.
언제 적용해야 하는가?
만약 Member와 Team 엔티티가 N:1 연관관계를 가진다고 한다면 언제 즉시로딩, 지연로딩을 적용해야하는지 예시를 들어보겠다.
- Member와 Team을 자주 함께 사용한다 -> 즉시 로딩
- Member와 Team을 가끔 함께 사용한다 -> 지연 로딩
대부분 위와 같은 방식으로 지연 로딩, 즉시 로딩을 판별해서 사용한다. 하지만 실무에서는 가급적이면 지연 로딩으로 적용시켜놓는 편이 좋다. 그 이유는 앞서 말했듯이, 실무에서는 여러개의 테이블이 연관관계를 가지므로 즉시 로딩을 하게 되면 불필요한 쿼리가 많이 나가게 되고, 그렇게 되면 서버 비효율적이게 사용이 되버리기 때문이다.
지연로딩을 사용 시에 한가지 주의해야할 점이 있다. getWriter는 Board와 연결된 Member 테이블에서 가져오는 정보이다. Board에서 지연로딩 적용되어있다. 해당 testRead() 메소드에서 findById는 오직 Board 테이블만 조회하는 쿼리를 날리게 된다. 하지만 아래에서 board.getWriter()는 쿼리를 통해 조회하지 않았던 Member 테이블의 정보이기 때문에 'no Session' 에러가 발생하게 된다.
해당 에러가 발생하지 않도록 @Transactional 어노테이션을 추가 해줘야 한다.@Transactional은 해당 메소드를 하나의 트랜잭션으로 처리하라는 의미이다. 트랜잭션으로 처리하면 속성에 따라 다르게 동작하지만, 기본적으로는 필요할 때 다시 데이터베이스와 연결이 생성이 되기 때문에 다시 Member 테이블의 조인이 필요할 경우 에러없이 다시 쿼리를 날려 조회가 가능하다.
// @Transactional 추가 필요
@Test
public void testRead() {
Optional<Board> result = boardRepository.findById(100L);
Board board = result.get();
System.out.println(board);
System.out.println(board.getWriter());
}
앞서 본 내용으로 Board 클래스에 지연 로딩을 추가해주면 된다.
package com.example.guestbookdemo.entity;
import lombok.*;
import javax.persistence.*;
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = "writer")
public class Board extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bno;
private String title;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
private Member writer;
}