ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • JPA 게시판 엔티티 만들기
    프로젝트/게시판 프로젝트 2022. 6. 23. 16:19

    목표

    회원이 작성하는 게시글의 Entity를 만들자

     

    회원과 게시글의 연관관계

    회원은 게시글을 여러 개 쓸 수 있습니다.

     

    이에 따라 회원과 게시글의 관계는 1 : N으로 볼 수 있습니다.

     

    회원 1명은 게시글 N개를 가질 수 있음

     

    "JPA 1:N 연관관계" 키워드에 대해 검색해보겠습니다.

     

    1:N 연관관계에 대해서 알아보기 전에  JPA의 연관관계에 대해서 먼저 학습하겠습니다.

     

    JPA 연관관계 매핑 정리

    엔티티는 다른 엔티티의 참조를 가지면서 서로 관계를 맺게 됩니다.

     

    회원이 게시글을 작성하는 경우 게시글 엔티티는 회원 엔티티 필드를 가지면서 서로 연관관계를 맺어 해당 게시글을 작성한 회원을 조회할 수 있습니다.

     

    또한 서로 연관관계를 맺으면 회원이 어떤 게시글들을 작성했는지를 알 수 있습니다.

     

    객체와 DB의 방향성의 차이

    회원 -> 게시글

    게시글 -> 회원

     

    DB는 외래 키 하나로 테이블을 조인하여 양쪽으로 쿼리가 가능합니다.

    SELECT * FROM member AS m INNER JOIN board AS b ON m.id = b_id
    # or
    SELECT * FROM board AS b INNER JOIN member AS m ON m.id = b_id

     

    하지만 객체의 경우에는 객체에 참조하는 필드가 존재하면 그 필드를 통하여 연관된 객체를 한쪽으로만 조회가 가능합니다.

     

    이때 서로 각각의 객체를 필드로 가지고 있으면 양방향이 됩니다.

     

    단방향

    - 객체 관계에서 한쪽만 참조하는 경우

    - 회원 -> 게시판

     

    양방향

    - 객체 관계에서 양 쪽 다 참고하는 경우

    - 회원 -> 게시판, 게시판 -> 회원

     

    연관관계의 종류

    1:N

    N:1(가장 많이 사용되는 연관관계)

    1:1

    N:N (실무에서는 거의 사용하지 않고 N:1 , 1:N으로 풀어서 사용)

     

    나올 수 있는 경우는 다음과 같습니다.

     

    JPA 1:N 연관관계

    결론부터 말하자면 1:N 대신에 N:1을 사용하는 것이 좋습니다.

     

    왜 그럴까요?

     

    일대다 관계에서는 일이 연관관계의 주인이 됩니다. (주인??)

     

    주인이라는 의미는 일 쪽에서 외래 키를 관리하겠다는 의미입니다.

     

    아래 그림은  Member와 Team 연관관계의 예시입니다.

    https://ict-nroo.tistory.com/125

    Team은 여러 명의 Member를 가질 수 있습니다.

     

    즉, 팀 1개는 N개의 회원을 가질 수 있음

     

    DB 테이블의 입장에서 보면 무조건 1:N의 N 쪽에 외래 키(FK)가 들어갑니다.

     

    하지만 위에서 말했든이 일대다 관계에서는 TEAM(일) 이 연관관계의 주인이 되며 일쪽에서 외래 키를 관리하기 됩니다.

     

     

    Member 코드 예시

    @Entity
    public class Member {
    	@Id
    	@GeneratedValue
    	@Column(name = "member_id")
    	private Long id;
        
    	private String userName
    }

    Team 코드 예시

    @Entity
    public class Team{
    	@Id
    	@GeneratedValue
    	@Column(name = "team_id")
    	private Long id;
        
    	private String name;
        
    	@OneToMany
    	@JoinColumn(name ="team_id") //Member테이블의 team_id를 뜻합니다 (FK)
    	private List<Member> members = new ArrayList<>();
    }

     

    단점

    매핑한 객체 Team이 관리하는 FK가 다른 테이블에 있다는 점입니다.

     

    이렇게 되면 실제로 DB는 Member가 외래 키를 관리하지만 객체상에서는 TEAM이 외래키를 관리하기 때문에 Team에서 members가 바뀌면 DB의 Member 테이블에 업데이트 쿼리가 나가게 됩니다.

     

    실무에서는 테이블이 수십 개가 엮여서 돌아가고 위와 같은 상황은 혼란을 유발할 수 있습니다. (나는 Team을 건드렸는데 Member 테이블에 쿼리가 나가네?)

     

    또한 본인 테이블에 FK가 있으면 insert 쿼리 한 번으로 끝나지만, 서로 다른 테이블에 있으므로 별도의 update 쿼리를 추가로 실행하게 됩니다.

     

    이러한 문제를 해결하기 위해 N:1 관계를 설정하여 해결하는 방법이 좋습니다.

     

    또한 1:N은 양방향 매핑이 공식적으로 존재하지 않습니다. (물론 구현이 불가능한 것은 아닙니다.)

     

    JPA의 N:1 연관관계

    이번에는 게시글과 댓글을 예시로 들어보겠습니다. (게시글 1, 댓글 N)

    https://blog.advenoh.pe.kr/database/JPA-%EB%8B%A4%EB%8C%80%EC%9D%BC-Many-To-One-%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84/

    단방향 연관관계

    Post Entity 코드

    @Entity
    @Table(name = "post")
    public class Post extends DateAudit {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "post_id")
        private Long postId;
        private String title; 
        private String content;		
    }

    Comment Entity 코드

    @Entity
    @Table(name = "comment")
    public class Comment extends DateAudit {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "comment_id")
        private Long commentId;		
        
        //연관관계 매핑
        @ManyToOne
        @JoinColumn(name = "post_id")
        private Post post;  	
    }

     

    Comment 엔티티에만 Post 필드가 존재하고 @ManyToOne 어노테이션으로 단방향 관계를 맺습니다.

     

     

     

    다대일 양방향 연관관계

    다대일 양방향은 Post와 Commet 엔티티에 서로를 참조하는 필드가 존재합니다.

    양방향 매핑을 하는 이유는 post -> comment 방향으로의 객체 참조를 만들어서 조회를 쉽게 하도록 하기 위함입니다.

    양방향 매핑 시 주의사항이 많으니 논리적 오류가 없도록 조치를 잘해주어야 합니다.

    https://blog.advenoh.pe.kr/database/JPA-%EB%8B%A4%EB%8C%80%EC%9D%BC-Many-To-One-%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84/

    Comment Entity 코드

    @Entity
    @Table(name = "comment")
    public class Comment extends DateAudit {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "comment_id")
        private Long commentId;
    
        //연관관계 매팽
        @ManyToOne
        @JoinColumn(name = "post_id", nullable = false)
        private Post post; //연관관계의 주인이 된다
    }

    Post Entity 코드

    @Entity
    @Table(name = "post")
    public class Post extends DateAudit {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "post_id")
        private Long postId;
    
        //양방향 연관관계 설정
        @JsonIgnore //JSON 변환시 무한 루프 방지용
        @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
        private List<Comment> comments = new ArrayList<>();
    }

     

    게시글(Post)과 댓글(Comment)의 관계는 1: N인 관계로 @OneToMany 어노테이션을 사용하였으며 List <Comment> 컬렉션으로 선언합니다.

     

    객체를 양방향으로 설정하면 외래 키를 관리하는 곳이 2곳이 생깁니다.

     

    관리하는 곳이 2개라는 의미는 만약  Comment Entity의 post가 변경되었고, Post Entity의 List <Commet> commets는 변경되지 않았다면 JPA는 어떤 것을 보고 FK를 업데이트해야 할까요?

     

    외래 키를 한쪽에서만 관리하도록 하기 위해서 연관관계의 주인을 설정할 필요가 있습니다.

     

    다대일에서는 다 쪽이 연관관계의 주인이 되므로 @OneToMany에서 mappedBy 속성의 값으로 연관관계 주인을 지정합니다.

     

    이렇게 되면 Post Entity에서는 외래 키를 읽기만 할 수 있으며, Comment Entity에서는 외래키를 읽기, 등록, 수정, 삭제까지 할 수 있습니다.

     

    즉, 연관관계의 주인을 가지는 쪽을 기준으로 FK 업데이트가 일어납니다.

     

    연관관계 편의 메서드

    연관관계의 주인을 가지는 쪽을 기준으로 FK 업데이트가 일어나기 때문에 연관관계 주인 쪽을 기준으로만 값을 세팅해줘도 DB에는 정상적으로 반영됩니다.

     

    하지만 그렇기 되면 일단 객체지향적이지 못합니다.

    한쪽에만 값이 업데이트되고 다른 한쪽에는 값이 업데이트되지 않습니다.

     

    또한 만약 DB에 반영되고 나서 이후에 조회하는 경우에는 문제가 없지만 만약 DB에 반영되지 않은 상태에서 한쪽에만 값이 업데이트되고 다른 한쪽에는 값이 업데이트되지 않는다면 객체 조회 시에 문제가 발생할 수 있습니다.

     

    따라서 값을 양방향으로 설정하여 DB뿐만이 아니라 객체 저장/조회 시에도 제대로 반영하기 위해서 다음과 같은 코드를 작성해줘야 합니다.

    comment.setPost(post) //코멘트에 포스트 지정
    
    post.getComments().add(commet) //포스트 리스트에 코멘트 추가

    하지만 실수로 post.getComments(). add(comment)를 호출하지 않아 양방향이 깨질 수 있기 때문에 이를 방지하기 위해서 편의 메서드를 작성하는 것이 좋습니다.

     

    다음과 같이 양방향 관계를 설정하는 메서드를 연관관계 편의 메서드라고 합니다.

    public void setPost(Post post) {
        this.post = post;
        post.getComments().add(this);
    }

     

    하지만 다음과 같이 setTeam 메서드를 작성하는 경우 버그가 발생할 수 있습니다.

    comment.setPost(post1);
    comment.setPost(post2);

    예를 들어 연속적으로 setPost을 호출한 이후 post 1에서 댓글을 조회하면 여전히 comment가 조회됩니다.

    post 2로 변경할 때 post 1과의 관계를 제거하지 않았기 때문입니다.

     

     

    다음은 위의 문제를 해결하는 코드입니다.

    public class Comment extends DateAudit {
    	public void setPost(Post post) {
    	if (this.post != null) { //기존 포스트 관계를 제거함
    		this.post.getComments().remove(this);
    	}
    	this.post = post;
    	post.getComments().add(this);
    	}
    }

     

     

    양방향 매핑 연관관계를 저장할 때 무한 루프 주의

    toString()에서도 무한 루프에 걸릴 수 있음

     

    엔티티를 JSON으로 변환하는 경우

     

    실전 적용 예시

    기존 Member Entity 분석

    우선 이미 작성된 Member Entity를 분석해 보겠습니다.

    package anthill.Anthill.domain.member;
    
    import anthill.Anthill.dto.member.MemberResponseDTO;
    import lombok.*;
    import org.hibernate.annotations.DynamicUpdate;
    
    import javax.persistence.*;
    
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @Getter
    @Table(
            name = "member"
            , indexes = {
            @Index(name = "unique_idx_user_id", columnList = "user_id", unique = true),
            @Index(name = "unique_idx_nickname", columnList = "nickname", unique = true),
            @Index(name = "unique_idx_phone_number", columnList = "phone_number", unique = true)
    }
    )
    
    
    @Entity
    @DynamicUpdate
    public class Member {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY) //MySQL의 AUTO_INCREMENT를 사용
        @Column(name = "member_id")
        private Long id;
    
        @Column(name = "user_id", nullable = false, length = 20)
        private String userId;
    
        @Column(name = "password", nullable = false, length = 255)
        private String password;
    
        @Column(name = "nickname", nullable = false, length = 20)
        private String nickName;
    
        @Column(name = "name", nullable = false, length = 40)
        private String name;
    
        @Column(name = "phone_number", nullable = false, length = 40)
        private String phoneNumber;
    
        @Embedded
        private Address address;
    
        @Builder
        public Member(Long id, String userId, String password, String nickName, String name, String phoneNumber, Address address) {
            this.id = id;
            this.userId = userId;
            this.password = password;
            this.nickName = nickName;
            this.name = name;
            this.phoneNumber = phoneNumber;
            this.address = address;
        }
    
        public void changeNickName(String nickName) {
            this.nickName = nickName;
        }
    
        public MemberResponseDTO toMemberResponseDTO() {
            return MemberResponseDTO.builder()
                                    .userId(this.getUserId())
                                    .nickName(this.getNickName())
                                    .name(this.getName())
                                    .phoneNumber(this.getPhoneNumber())
                                    .address(this.getAddress())
                                    .build();
        }
    
    }

    회원 식별자, 아이디, 비밀번호, 닉네임, 이름, 전화번호, 주소로 이루어져 있습니다.

     

    회원과 게시물은 1:N 관계입니다.

    게시물과 회원은 N:1 관계입니다.

     

    위에서 연관관계에 대해서 학습했기 때문에 N:1 관계로 게시물에서 외래 키를 관리할 계획입니다.

     

    이제 게시물 Entity를 어떻게 설계할지 생각해보겠습니다.

     

    게시물 Entity 설계

    DB sequence ID를 위한 id 필드

    게시글 이름을 위한 필드

    게시글 내용을 위한 필드

    작성자가 누구인지 구분하기 위한 필드

    조회수를 담고 있는 필드

     

    게시물 Entity 코드

    package anthill.Anthill.domain.board;
    
    import anthill.Anthill.domain.member.Member;
    import lombok.AccessLevel;
    import lombok.Builder;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    import org.hibernate.annotations.DynamicUpdate;
    
    import javax.persistence.*;
    
    @Table(name = "board")
    @Entity
    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @DynamicUpdate
    public class Board {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "board_id")
        private Long id;
    
        @Column(name = "title" ,nullable = false)
        private String title;
    
        @Column(name = "content",nullable = false)
        private String content;
    
        @Column(name = "writer",nullable = false)
        private String writer;
    
        @Column(name = "hits")
        private Long hits;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "member_id")
        private Member member;
    
        @Builder
        public Board(Long id, String title, String content, String writer, Long hits) {
            this.id = id;
            this.title = title;
            this.content = content;
            this.writer = writer;
            this.hits = hits;
        }
    
        public void setMember(Member member){
            if(this.member != null){
                this.member.getBoards().remove(this);
            }
            this.member = member;
            member.getBoards().add(this);
        }
    
    }

    위에서 설계한 대로 필드는 식별자를 위한 PK, 제목, 내용, 조회수를 가집니다.

    N: 1 연관관계 지정을 위한 @ManyToOne 어노테이션을 사용하여 member_id와 @JoinColumn을 지정해 줍니다.

     

    또한 setMember를 통해 연관관계 편의 메서드를 작성해줍니다.

     

    조금 더 고민해볼 점은 아직 게시물 삭제를 구현하지는 않았지만 게시물 삭제 시에도 양방향 관계를 잘 고려해야 할 것 같습니다.

     

    Member Entity에 추가된 코드

    @JsonIgnore
    @OneToMany(mappedBy = "member", fetch = FetchType.LAZY)
    private List<Board> boards = new ArrayList<>();

    양방향 연관관계를 위해 @OneToMany 어노테이션에 mappedBy 속성을 활용하여 Board 엔티티와 양방향 관계를 성립하고 연관관계의 주인은 N:1에서 N인 Board입니다.

     

    또한 엔티티를 Json으로 변환하는 경우에 주의해야 합니다.

    Board Entity는 Member를 가지고 있기 때문에 Member Entity를 다시 Json으로 변환하려고 할 것입니다.

     

    이때 Member Entity가 가진 List <Board> boards에서 다시 Board Entity를 Json으로 변환하려고 하는 무한루프에 빠질 수 있습니다.

     

    이에 따라 @JsonIgnore을 통해 무한 루프를 방지합니다.

     

    Repository 패지지에 BoardRepository 추가

    package anthill.Anthill.repository;
    
    import anthill.Anthill.domain.board.Board;
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface BoardRepository extends JpaRepository<Board, Long> {
    	List<Board> findByWriter(String writer);
    }

    Board 엔티티와 Board 엔티티의 PK 값을 넣어주어 BoardRepository를 생성합니다.

     

    이렇게 되면 자체적으로 JPA에서 CRUD를 지원합니다.

     

    추가적으로 글쓴이를 기준으로 게시글들을 조회하고 싶어 findBy~~ 메서드를 통해 반환 타입 인자 값을 선언하면 JPA가 알아서 구현해줍니다.

     

    테스트 코드 작성해보기

    package anthill.Anthill.repository;
    
    import anthill.Anthill.domain.board.Board;
    import anthill.Anthill.domain.member.Address;
    import anthill.Anthill.domain.member.Member;
    import org.assertj.core.api.Assertions;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
    
    import java.util.List;
    
    
    @DataJpaTest
    class BoardRepositoryTest {
    
        final private String test = "test";
    
        @Autowired
        BoardRepository boardRepository;
    
        @Test
        public void 게시물생성테스트() {
            //given
            Board board = getBoard(test);
            boardRepository.save(board);
    
            //when
            List<Board> boardsByWriter = boardRepository.findByWriter(test);
    
            //then
            Assertions.assertThat(boardsByWriter.size())
                      .isEqualTo(1);
        }
    
        private Board getBoard(String settingValue) {
    
            Board board = Board.builder()
                               .title(settingValue)
                               .content(settingValue)
                               .writer(settingValue)
                               .hits(0L)
                               .build();
            board.setMember(makeMember(settingValue));
    
            return board;
        }
    
        private Member makeMember(String settingValue) {
            Member member = Member.builder()
                                  .userId(settingValue)
                                  .name(settingValue)
                                  .nickName(settingValue)
                                  .password(settingValue)
                                  .phoneNumber(settingValue)
                                  .address(new Address("a1", "a2", "a3"))
                                  .build();
            return member;
        }
    
    }

    우선 게시물 생성 테스트부터 수행해 보았습니다.

     

    board를 가져와서 board를 저장하는데 getBoard메서드는 board를 생성하고 setMember 메서드를 통해 member를 생성합니다.

     

    이렇게 테스트를 수행하면 다음과 같은 에러 메시지를 만날 수 있습니다.

     

     

    TransientPropertyValueException : object references an unsaved transient instance - save the transient instance before flushing

    해석하면 다음과 같습니다

    개체가 저장되지 않은 임시 인스턴스를 참조합니다.

    flushing 하기 전에 인스턴스를 저장하세요

     

    현재 Member Entity가 DB에 저장되지 않아서 발생한 문제 같습니다.

     

    따라서 다음과 같이 코드를 변경하면 해결할 수 있습니다.

    package anthill.Anthill.repository;
    
    import anthill.Anthill.domain.board.Board;
    import anthill.Anthill.domain.member.Address;
    import anthill.Anthill.domain.member.Member;
    import org.assertj.core.api.Assertions;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
    
    import java.util.List;
    
    
    @DataJpaTest
    class BoardRepositoryTest {
    
        final private String test = "test";
    
        @Autowired
        BoardRepository boardRepository;
    
        @Autowired
        MemberRepository memberRepository;
    
        @Test
        public void 게시물생성테스트() {
            //given
            Member member = makeMember(test);
            memberRepository.save(member);
            Board board = getBoard(test);
            board.setMember(member);
            boardRepository.save(board);
    
            //when
            List<Board> boardsByWriter = boardRepository.findByWriter(test);
    
            //then
            Assertions.assertThat(boardsByWriter.size())
                      .isEqualTo(1);
        }
    
        private Board getBoard(String settingValue) {
    
            Board board = Board.builder()
                               .title(settingValue)
                               .content(settingValue)
                               .writer(settingValue)
                               .hits(0L)
                               .build();
    
            return board;
        }
    
        private Member makeMember(String settingValue) {
            Member member = Member.builder()
                                  .userId(settingValue)
                                  .name(settingValue)
                                  .nickName(settingValue)
                                  .password(settingValue)
                                  .phoneNumber(settingValue)
                                  .address(new Address("a1", "a2", "a3"))
                                  .build();
            return member;
        }
    
    }

    하지만 여기서 boardRepository를 단위 테스트하고 싶은데 memberRepsotiroy를 의존하는 모습이 매우 불편합니다.

     

    기존 위의 코드(memberRepository를 의존하지 않는 코드)에서 Board 엔티티를 다음과 같이 수정하면 됩니다.

    @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST)

    casecade = CascadeType.PERSIST 옵션을 주어 Board 엔티티가 영속되면 Member 엔티티도 영속되도록 합니다.

     

    이렇게 하면 테스트가 정상적으로 잘 수행됩니다.

     

     

     

     

    출처

    https://ict-nroo.tistory.com/125

     

    [JPA] @OneToMany, 일대다[1:N] 관계

    일대다 [1:N] 일대다 관계에서는 일이 연관관계의 주인이다. 일 쪽에서 외래키를 관리하겠다는 의미가 된다. 결론을 먼저 말하자면, 표준스펙에서 지원은 하지만 실무에서 이 모델은 권장하지 않

    ict-nroo.tistory.com

     

    https://blog.advenoh.pe.kr/database/JPA-%EC%97%B0%EA%B4%80%EA%B4%80%EA%B3%84-%EB%A7%A4%ED%95%91-%EC%A0%95%EB%A6%AC/

     

    [JPA-1] JPA 연관관계 매핑 정리

    1. 들어가며 엔티티는 다른 엔티티의 참조(변수)를 가지면서 관계를 서로 맺게 됩니다. 블로그에서 해당 포스트에 댓글을 다는 경우를 예를 들면, 댓글(Comment) 엔티티는 포스트 (Post…

    blog.advenoh.pe.kr

    https://steady-hello.tistory.com/103

     

    [JPA] 연관관계 매핑 - N:1(다대일), 1:N(일대다)

    [스프링/JPA] - [JPA] 연관관계 매핑 - 1:1(일대일), N:N(다대다) JPA는 다양한 연결 관계가 있습니다. 이 연결 관계들에 대해 하나씩 알아보겠습니다. 고려할 점 엔티티의 연관 관계를 매핑할 때는 고려

    steady-hello.tistory.com

    https://blog.advenoh.pe.kr/java/Jackson%EC%97%90%EC%84%9C-Infinite-Recursion-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0%EB%B0%A9%EB%B2%95/

     

    Jackson에서 Infinite Recursion 이슈 해결방법

    1. 들어가며 Jackson에서 양방향 관계 (Bidirectional Relationship…

    blog.advenoh.pe.kr

    https://joanne.tistory.com/220

     

    [Spring Data JPA] 연관관계 편의 메서드

    연관관계 편의 메서드 양방향 연관관계를 맺을 때에는, 양쪽 모두 관계를 맺어주어야한다. 사실 JPA의 입장에서 보았을 때에는 외래키 관리자(연관관계의 주인) 쪽에만 관계를 맺어준다면 정상

    joanne.tistory.com

     

    댓글

Designed by Tistory.