[스프링부트] 익명 블로그 V6 - 양방향 Mapping

익명 블로그 V6 - 3
이나겸's avatar
Dec 03, 2024
[스프링부트] 익명 블로그 V6 - 양방향 Mapping
💡

양방향 매핑(Bidirectional Mapping)

ORM(객체 관계 매핑)에서 두 엔티티(테이블)가 서로를 참조하는 관계
서로의 데이터를 쉽게 접근하고 관리 가능

주의점

무한 루프(loop) 문제

  • 두 엔티티가 서로 참조하고 이를 JSON으로 직렬화할 때 주로 발생
    • ex) A 엔티티가 B 엔티티를 참조하고 B 엔티티가 A 엔티티를 참조 하는 경우
    • 직렬화 과정에서 계속 참조를 따라가게 되어 끝나지 않는 순환 발생 할 수 있음
    • // A 엔티티 @Entity public class A { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // B 엔티티 참조 // mappedBy : fk의 변수명이 무엇인가? @OneToOne(mappedBy = "a") private B b; // getter setter }
      // B 엔티티 @Entity public class B { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // A 엔티티 참조 @OneToOne @JoinColumn(name = "a_id") // FK 지정 private A a; // getter setter }

무한 루프 방지 방법

  • @JsonIgonre 사용
    • 직렬화 시 특정 필드 무시 할 수 있어서 순환 참조를 피할 수 있음
    • @Entity public class A { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // B 엔티티 참조 // mappedBy : fk의 변수명이 무엇인가? @OneToOne(mappedBy = "a") @JsonIgnore private B b; // getter setter }
  • @JsonManagedReference, @JsonBackReference 사용
    • 부모-자식 관계를 명시적으로 설정 가능
    • // A 엔티티 @Entity public class A { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // B 엔티티 참조 // mappedBy : fk의 변수명이 무엇인가? @OneToOne(mappedBy = "a") @JsonManagedReference private B b; // getter setter }
      // B 엔티티 @Entity public class B { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // A 엔티티 참조 @OneToOne @JoinColumn(name = "a_id") // FK 지정 @JsonBackReference private A a; // getter setter }
  • DTO(Data Transfer Object) 사용
    • 엔티티 대신 DTO 사용하여 순환 참조 피할 수 있음
    • 필요한 데이터만 DTO에 담아서 전송하면 무한루프 방지 가능
 

익명 블로그에 양방향 Mapping활용하기

Board 엔티티와 Reply 엔티티 간의 양방향 매핑 설정

[Reply 클래스 - 엔티티]

  • @NoArgsConstructor(access = AccessLevel.PROTECTED)
    • 객체를 안전하게 초기화하고 불필요한 인스턴스 생성 방지
  • @ManyToOne 어노테이션으로 Board 엔티티와의 관계 설정
    • 보드(게시물) 1 : 댓글 n
  • @JoinColum
    • 외래키(FK) 지정
    • 이 경우 board_id 컬럼이 FK
  • fetch = FetchType.LAZY
    • User와 Board에 대해 지연 로딩 사용해서 해당 엔티티가 실제로 필요할 때까지 로드되지 않도록 함
    • 무한 루프 방지
      • 지연 로딩은 직렬화 중에 불필요한 순환 참조 방지 가능
@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter @Table(name = "reply_tb") @Entity public class Reply { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; private String comment; @CreationTimestamp private Timestamp createdAt; @ManyToOne(fetch = FetchType.LAZY) // 유저1 : 댓글n private User user; // 댓글을 쓴 유저 @ManyToOne(fetch = FetchType.LAZY) // 보드1 : 댓글n @JoinColumn(name = "board_id") // board_id를 FK 지정 private Board board; // 댓글이 달린 게시물 @Builder public Reply(Integer id, String comment, Timestamp createdAt, User user, Board board) { this.id = id; this.comment = comment; this.createdAt = createdAt; this.user = user; this.board = board; } }
 

[Board 클래스 - 엔티티]

  • @NoArgsConstructor(access = AccessLevel.PROTECTED)
    • 객체를 안전하게 초기화하고 불필요한 인스턴스 생성 방지
  • List<Reply> replies 추가
  • mappedBy
    • 양방향 Mapping에서 반대쪽 엔티티의 필드명(FK) 지정
    • 이 경우 Reply 엔티티의 board 필드 참조
  • fetch = FetchType.EAGER
    • 데이터 즉시 로드하도록 설정
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 외부에서 빈 생성자 생성하지 못하게 제한(protected) @Getter @Table(name = "board_tb") // 테이블 명 @Entity // Board 객체를 테이블로 생성하며, 데이터베이스 테이블과 매핑하여 데이터베이스 작업(insert, update, delete 등)을 수행 public class Board { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) // AutoIncrement private Integer id; private String title; private String content; private User user; // mappedBy : fk의 변수명이 무엇인가? @OneToMany(mappedBy = "board", fetch = FetchType.EAGER) private List<Reply> replies = new ArrayList<>(); // 댓글들을 List로 만듦 @CreationTimestamp private Timestamp createdAt; // 풀 생성자 만들고 @Builder 달아서 빌더의 형태 잡음 @Builder public Board(Integer id, String title, String content, User user, Timestamp createdAt) { this.id = id; this.title = title; this.content = content; this.user = user; this.createdAt = createdAt; } // update(글 수정)를 setter로 만들어서 활용하기로 함 // setter는 명확한 의도가 있는것만 해놓는게 좋기 때문에 public void update(String title, String content) { this.title = title; this.content = content; } }
 

[data.sql파일 - 더미데이터 추가]

notion image

[BoardRepository 클래스]

  • left join을 한 이유
    • reply(댓글) 없는 board(게시물)도 조회되어야 하기 때문
@RequiredArgsConstructor @Repository public class BoardRepository { private final EntityManager em; // Board의 User와 Reply 모두 1번에 조회 public Optional<Board> findByIdJoinUserAndReply(int id) { // 조인 결과가 하나의 테이블 / JPQL쿼리 사용 // fetch가 빠지면 user테이블이 null이라 콘솔 안나옴 String sql = """ select b from Board b join fetch b.user left join fetch b.replies r left join fetch r.user where b.id = :id """; Query q = em.createQuery(sql, Board.class); q.setParameter("id", id); try { // 해당 try문은 이상한 구조로 실제 적용X. 참고만 하기 Board board = (Board) q.getSingleResult(); return Optional.ofNullable(board); } catch (RuntimeException e) { return Optional.ofNullable(null); } } }
 

[BoardResponse 클래스 - DetailDTO]

  • private List<ReplyDTO> replies와 ReplyDTO
    • 필드에 컬렉션(List)이 추가됐기 때문에 ReplyDTO 필요
    • ReplyDTO는 DetailDTO의 이너 클래스
      • 이너 클래스는 접근제한자 생략할 경우 private가 디폴트
  • this.replies = board.getReplies().stream().map(r -> new ReplyDTO(r)).toList();
    • Board 객체 안의 Reply 컬렉션(List) 안의 모든 Reply(댓글)를 Stream 활용해서 ReplyDTO로 변경
public class BoardResponse { // 글 상세보기 @Data public static class DetailDTO { private int id; private String title; private String content; private String createdAt; private Integer userId; // 게시글을 작성한 userId private String username; private boolean isOwner = false; private List<ReplyDTO> replies; // 내부 DTO(Class)는 public 붙이지 않기 - 외부에서 모르고 꺼내쓸 수 있기때문 @Data class ReplyDTO { private int id; private String comment; private int userId; // 댓글 작성한 userId private String username; // 댓글 작성한 username(아이디) // 생성자 public ReplyDTO(Reply reply) { this.id = reply.getId(); this.comment = reply.getComment(); this.userId = reply.getUser().getId(); this.username = reply.getUser().getUsername(); } } // 생성자 public DetailDTO(Board board, User sessionUser) { // sessionUser는 권한 확인을 위해 this.id = board.getId(); this.title = board.getTitle(); this.content = board.getContent(); this.createdAt = Encoding.formatToStr(board); this.userId = board.getUser().getId(); this.username = board.getUser().getUsername(); // lazy loading if(sessionUser != null) { this.isOwner = sessionUser.getId() == board.getUser().getId(); } this.replies = board.getReplies().stream().map(r -> new ReplyDTO(r)).toList(); } } }
 

활용에서의 무한 루프 방지 요소

  • FetchType.LAZY (지연 로딩)
    • Reply 클래스에서 User 클래스와 Board 클래스에 대해 지연 로딩 설정
    • 해당 엔티티가 실제로 필요할 때까지 로드되지 않음
    • 직렬화 중에 불필요한 순환 참조 방지 가능
  • 직렬화 라이브러리의 특성
    • Jackson과 같은 JSON 직렬화 라이브러리는 지연 로딩된 프록시 객체를 직렬화 하지 않아서 무한 루프 방지 가능
    • 지연 로딩(FetchType.LAZY)으로 설정된 관계에 대해 직렬화 할 때 실제 객체가 로드되지 않기 때문에 발생하기 때문
  • 접근 수준 제어
    • @NoArgsConstructor(access = AccessLevel.PROTECTED)를 Reply 클래스와 Board 클래스의 생성자에 대해 사용
    • 객체를 안전하게 초기화하고 불필요한 인스턴스 생성 방지
Share article

Nakyeom's Study