주의점
무한 루프(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파일 - 더미데이터 추가]

[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