개인 프로젝트/프로젝트

JPA 연관 관계 순환참조로 인한 직렬화 에러 해결 방법

Leo.K 2024. 8. 9. 16:48

<이슈 전반 상황 설명>

DBMS에서 각 테이블간의 연관관계를 외래키로 설정하듯이 JPA에서도 두 엔티티 간의 연관관계를 설정해주어야 한다. 
JPA의 연관관계의 경우 단방향이 아닌 양방향으로 관계를 설정할 수 있는데, 이렇듯 양방향으로 설정된 관계에서 한 엔티티를 조회하는 경우 순환참조 에러가 발생한다. 

프로젝트를 진행하던 중 맞이하게 된 순환 참조 예시를 보여주고, 해당 에러를 해결한 방법을 설명하겠다. 
가장 처음 기획 의도는 하나의 자산에 최대 3장의 이미지를 등록할 수 있게 하려고 했고, 자산에서는 이미지 리스트를 조회할 수 있도록 하고, 이미지에서는 해당 이미지가 어떤 자산의 이미지인지 확인할 수 있도록 자산 id를 조회할 수 있도록 하고자 했다. 그래서 아래와 같이 두 엔티티의 관계를 자산 : 이미지 = 1 : N으로 양방향 관계를 설정했었다. 

<실제 사례>

@Entity
@Getter
@NoArgsConstructor
public class Asset {

    @Id
    @Column(name = "asset_id")
    private String assetId;

    @OneToMany(mappedBy="asset", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<SaveFile> assetImgs = new ArrayList<>();

}


@Entity
@AllArgsConstructor
@Data
@NoArgsConstructor
@Builder
@ToString
public class SaveFile extends Auditing {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "fild_id")
    private Long id;

	@ManyToOne
    @JoinColumn(name="asset_id")
    private Asset asset;
}


이렇게 설정한 다음 자산 엔티티를 조회하기 위해 직렬화하는 순간 순환참조가 발생하게 된다. 
여기서 순환참조란 두 엔티티가 서로를 참조하기 때문에 발생하는 무한루프라고 생각하면 되겠다. 

예를 들어, Asset이 SaveFile을 참조하고, SaveFile이 다시 Asset을 참조하는 경우다. 이러한 순환 참조는 특히 JSON 직렬화/역직렬화 과정에서 문제가 될 수 있다. 이러한 문제를 해결하기 위한 방법으로 여러가지가 있지만, 일단 나는 근본적인 문제를 생각해보았다. 

이미지는 자산 데이터를 렌더링 하는 경우 조회하면 되는 부분인데, 이미지를 통해 자산을 찾을 필요가 있을까?
해서 두 엔티티 간의 연관관계를 단방향으로 수정했다. 이 방법이 가장 단순한 방법이 아닐까 싶다.

하지만 연관관계를 양방향으로만 해야하는 경우가 있기 때문에 이 참에 해결방법 몇 가지를 더 소개해보겠다. 

 

1. @JsonIgnore

@JsonIgnore는 Jackson 라이브러리에서 제공하는 어노테이션으로, 객체를 JSON으로 직렬화하거나 JSON을 객체로 역직렬화할 때 특정 필드를 무시하도록 지시한다.
이를 통해 순환 참조 문제를 해결하거나 보안상의 이유로 특정 필드를 JSON 출력에서 제외할 수 있다.
단순하게 생각해서 git을 사용할 때, 원격 저장소가 추적하지 말았으면 하는 DB 접속 정보 같은 데이터는 .gitignore에 추가하지 않는가? 마찬가지로 엔티티를 조회하는 경우 연관관계의 주체가 되는 특정 데이터를 제외시킴으로써 순환참조를 방지하는 것이다.

@Entity
@Getter
@NoArgsConstructor
public class Asset {

    @Id
    @Column(name = "asset_id")
    private String assetId;

    @OneToMany(mappedBy="asset", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<SaveFile> assetImgs = new ArrayList<>();

}


@Entity
@AllArgsConstructor
@Data
@NoArgsConstructor
@Builder
@ToString
public class SaveFile extends Auditing {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "fild_id")
    private Long id;

	@ManyToOne
    @JoinColumn(name="asset_id")
    @JsonIgnore
    private Asset asset;
}

이렇게 하면 asset에서 saveFile을 참조할 수 있지만, saveFile에서 asset을 역참조하는 것을 막을 수 있다.

  • 장점 
    • 간단함 : @JsonIgnore를 사용하여 한쪽 참조를 무시함으로써 순환 참조 문제를 쉽게 해결가능하다.
    • 유연성 : JSON 직렬화 시 특정 필드만 무시할 수 있어 필요한 데이터만 포함시킬 수 있다.
  •  단점
    • 데이터 손실: SaveFile의 JSON 표현에는 Asset에 대한 정보가 포함되지 않는다. 즉, SaveFile 객체를 JSON으로 직렬화할 때 Asset 필드는 무시된다.

 

2. @JsonManagedReference와 @JsonBackReference

두 어노테이션은 한 쌍으로 사용이 되어야 한다. 마찬가지로 Jackson 라이브러리에서 제공하며, 서로 연결된 두 엔티티 간의 순환 참조 문제를 해결하는 데 사용된다. 

@JsonManagedReference

  • 역할 : 부모 객체의 필드(=연관관계의 주인)에 사용
  • 기능 : 직렬화 시 이 필드를 포함하여 JSON을 생성

@JsonBackReference

  • 역할: 자식 객체의 필드에 사용
  • 기능: 직렬화 시 이 필드를 무시하여 순환 참조를 방지. 역직렬화 시에는 JSON 데이터로부터 객체를 생성할 때 참조 관계를 복원.
@Entity
@Getter
@NoArgsConstructor
public class Asset {

    @Id
    @Column(name = "asset_id")
    private String assetId;

    @JsonManagedReference
    @OneToMany(mappedBy = "boardAsset")
    private List<Board> boards;

}


@Entity
@Builder
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Board {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_id")
    Long boardId;

    @JsonBackReference
    @ManyToOne
    @JoinColumn(name = "asset_id")
    private Asset boardAsset;
}

...
<TestCase>
public static void main(String[] args) throws Exception {
	ObjectMapper mapper = new ObjectMapper();

    Asset asset = new Asset();
    asset.setId("asset1");

    Board b1 = new Board();
    b1.setId("board1");
    b1.setBoardAsset(asset);

    Board b2 = new Board();
    b2.setId("board2");
    b2.setBoardAsset(asset);

    asset.setBoards(List.of(b1, b2));

    // Asset 객체를 JSON으로 직렬화
    String json = mapper.writeValueAsString(asset);
    System.out.println(json);  // 출력: {"assetId":"asset1","boards":[{"boardId":"board1"},{"boardId":"board2"}]}

    // JSON 문자열을 EntityA 객체로 역직렬화
    Asset deserializedAsset = mapper.readValue(json, Asset.class);
    System.out.println(deserializedAsset);
}

나는 1번의 방식과 많이 유사하다고 생각했고, 참조 관계의 복원이라는 말이 잘 이해가 되지 않아서 조금 더 파보았다. 1번과 2번 방식의 차이점을 살펴보자.

위의 테스트 케이스를 기준으로 작동원리를 먼저 알아보자. 

  1. 직렬화 
    1. Asset 객체를 Json으로 직렬화할 때, @JsonManagedReference가 적용된 boards필드는 Json에 포함 된다.
    2. Board 객체에서 @JsonBackReference가 적용된 boardAsset필드는 Json에 포함되지 않는다. 
  2. 역직렬화
    1. Json을 Asset객체로 역직렬화할 때, Jackson은 boards리스트에 포함된 각 Board 객체의 Asset필드를 올바르게 설정한다. 즉, Board 객체의 boardAsset 필드가 Asset객체를 참조하도록 참조관계를 복원한다.

참조 관계의 복원은 이런 것이다. 보안이 필요한 데이터의 경우 직렬화 시에 잠시 배제해두었다가, 역직렬화시에 다시 복원하여 원래의 관계를 회복시키는 것이다. @JsonIgnore를 사용하는 경우는 해당 필드가 완전히 무시되기 때문에 참조관계가 복원되지 않는다.

특성 @JsonManagedReference & @JsonBackReference @JsonIgnore
사용 위치 부모 - 자식 관계 양쪽에 적용 한 쪽 (주로 자식)
직렬화 시 부모 필드만 포함됨. (자식 필드는 무시) 해당 필드 무시
역직렬화 시 관계 자동 복원 관계 복원 x
복잡성 관리 양쪽 엔티티 모두 적용해야 해서 관리가 복잡할 수 있음 간단하게 사용 가능
주요 용도 부모-자식 관계를 명확히 구분하고 참조 관계 복원이 필요한 경우 특정 필드 완전 무시

 

3. DTO 사용

순환 참조를 피하기 위해 엔티티를 데이터 전송 객체(DTO : Data Transfer Object)로 변환하여 사용하는 방법
필요한 데이터만 포함하고, 엔티티 간의 관계를 포함하지 않거나, 필요한 경우 일부 관계만 포함하여 순환 참조 문제를 회피할 수 있다.
아래의 경우 asset을 조회할 때, board의 id값만 넘어가기 때문에, board를 조회하면서 같은 asset을 순환참조 하는 것을 피할 수 있다.

@Entity
@Getter
@NoArgsConstructor
public class Asset {

    @Id
    @Column(name = "asset_id")
    private String assetId;

    @OneToMany(mappedBy = "boardAsset")
    private List<Board> boards; 

}


@Entity
@Builder
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Board {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_id")
    Long boardId;

    @ManyToOne
    @JoinColumn(name = "asset_id")
    private Asset boardAsset;
}

public class AssetDTO {
    private String id;
    private List<Board> boards;
}

public class BoardDTO {
    private String id;
    private Long boardId;
}

<AssetService>
public AssetDTO convertToDTO(Asset asset){
	AssetDTO dto = new AssetDTO();
    dto.setId(asset.getAssetId());
    List<Board> boards = asset.getBoards().stream()
    							.map(board -> {
                                	BoardDTO bto = new BoardDTO();
                                	bto.setId(board.getBoardId());
                                    return bto;
                                })
                                .collect(Collectors.toList());
    dto.setBoards(boards); //순환 참조 방지를 위해 ID만 포함
    return dto;
}

DTO를 사용하는 방법의 핵심 원리는 객체 그래프를 단절 시킴으로써 직렬화 시 순환 참조를 피하는 것이다.
엔티티는 서로 참조 관계를 맺고 있어 에러가 발생할 수 있지만, DTO는 말 그대로 데이터 전송을 위한 객체이기에 엔티티 간의 참조관계를 포함하지 않고 있다. 따라서 상황에 맞게 커스텀하여 데이터를 전달하며 순환참조를 피할 수 있다.

* 객체 그래프 단절이 무엇이죠?

- 객체 그래프는 객체들이 서로 참조하고 있는 관계를 시각화한 개념이다. 
- 객체 그래프를 단절함으로써 객체들 간의 참조 관계를 끊어, 한 객체에서 다른 객체로의 연쇄적인 접근을 막을 수 있다. 이를 통해 객체 간의 직접적인 참조를 제거하거나 최소화함으로써, 복잡한 객체 간의 관계에서 발생할 수 있는 순환 참조 문제나 성능 저하문제를 피할 수 있습니다.

* 객체 그래프 단절이 필요한 이유가 뭔가요?
1. 순환 참조 문제 해결. (해당 내용은 게시글에 상세히 설명되어 있으니 SKIP)
2. 성능 문제
- 객체 그래프가 깊고 복잡하게 구성된 경우, 한 객체를 로드할 때, 관련된 모든 객체가 함께 로드될 수 있다. 이렇게 하면 시스템 성능에 악영향을 줄 수 있음. 성능 최적화를 위해서 JPA의 지연 로딩 전량을 사용하더라도, 무분별한 접근으로 인해 불필요한 조회 쿼리가 다수 발생할 수 있다.

 

4. @JsonIdentityInfo

@JsonIdentityInfo는 Jackson 라이브러리에서 제공하는 어노테이션으로, 객체의 식별자를 기준으로 JSON을 직렬화하고, 역직렬화 시 동일한 식별자를 가진 객체는 다시 생성하지 않고 기존 객체를 참조하도록 한다. 이를 통해 객체 간의 순환 참조를 방지할 수 있다. 말로만 들으면 어려우니 예시를 통해 함께 살펴보도록 하자.

더보기
@Entity
@Getter
@NoArgsConstructor
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "assetId")
public class Asset {

    @Id
    @Column(name = "asset_id")
    private String assetId;

    @OneToMany(mappedBy = "boardAsset")
    private List<Board> boards;

}


@Entity
@Builder
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@JsonIdentityInfo(generator = ObjectIdGenerators.PropertyGenerator.class, property = "boardId")
public class Board {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_id")
    Long boardId;

    @ManyToOne
    @JoinColumn(name = "asset_id")
    private Asset boardAsset;
}

먼저, 어노테이션 자체를 엔티티 클래스에 추가해준다. 주고 식별자 생성 방식(generator)과 객체의 식별자로 사용할 필드명(property)를 정의해준다. 

위처럼 설정해준뒤에 직렬화를 하게 되면 어떻게 되는지 살펴보자. 
어노테이션이 적용된 객체는 JSON 직렬화 과정에서 객체의 식별자를 사용하여 객체를 표현한다. 따라서, 객체가 순환참조를 포함하더라도 두 번째부터는 객체 자체를 직렬화하지 않고, 이미 직렬화된 객체의 식별자만 JSON에 포함한다.

{
    "assetId": "asset1",
    "boards": [
    			{
        			"boardId": "board1",
        			"boardAsset": "asset1"  // 순환 참조를 ID로 처리
    			},
                {
                	"boardId": "board2",
        			"boardAsset": "asset1"  // 순환 참조를 ID로 처리
                }
			]
}

반대로 역직렬화를 하는 경우도 마찬가지로, 객체의 식별자를 기준으로 이미 생성된 객체를 참조한다. 즉, Board객체를 생성하는 경우 이미 역직렬화된 Asset 객체가 있다면, 해당 객체를 다시 생성하지 않고, 이미 역직렬화된 객체를 참조한다.