본문 바로가기

Spring

양방향 연관관계와 진행하면서 만난 문제점

이번에 프로젝트를 진행하면서 몇 가지 에러 사항에 부딪혔습니다. 오늘은 그 중 한 가지에 대해 작성하려고 하는데요.

모두들 양방향 연관관계에 대해 잘 아시나요? 저는 이번에 양방향 연관관계를 사용해야하는 상황을 만났는데요. 처음 사용해보고 시간이 부족해서 인터넷에서 여러 블로그를 훑고 따라 사용해봤습니다. 그래서 사실 제대로 된 이해가 되지 않았는데요. 프로젝트가 끝난 지금 양방향 연관관계에 대해 제대로 공부해봤습니다! 그리고 저가 부딪혔던 에러 사항에 대해서도 다른 해결방안들을 조사해봤습니다. 이번 주는 양방향 연관관계에 대해 작성해보겠습니다!

양방향 연관관계

먼저 양방향 연관관계, 단방향 연관관계는 객체 단위에서 적용됩니다. 즉 데이터베이스의 관계와는 상관이 없습니다. 왜냐하면 데이터베이스 테이블은 외래 키 하나로 양방향으로 조회할 수 있기 때문입니다. 예를 들어 MEMBER와 TEAM과의 관계가 아래와 같을 때, TEAM_ID 외래 키를 사용해서 MEMBER JOIN TEAM이 가능하고 반대로 TEAM JOIN MEMBER도 가능합니다. 처음부터 양방향 관계인 것입니다. 또한 엄밀히 말하자면 객체에는 양방향 연관관계라는 것이 없습니다. 서로 다른 단방향 연관관계 2개를 양방향인 것처럼 보이게 할뿐입니다. 이 부분에 대해서 아래에서 더 자세히 설명하겠습니다.

양방향 연관관계에 대해 이해하기 편하게 예시로 설명하겠습니다. MEMBER와 TEAM이 있고 두 관계가 다대일 관계라고 하겠습니다. 그러면 JPA 사용 시 다음과 같이 연관관계를 매핑해주면 간단하게 큰 틀은 형성됩니다.

@Entity
public class Member {
	@Id
	@Column (name = "MEMBER_ID")
	private String id;

	private String username;
	
	@ManyToOne
	@JoinColumn(name = "TEAM_ID")
	private Team team;

	public void setTeam(Team team) {
		this.team = team;
	}
}
@Entity
public class Team{
	@Id
	@Column (name = "TEAM_ID")
	private String id;

	private String name;
	
	**@OneToMany(mappedBy = "team)
	private List<Member> members = new ArrayList<Member>();

	//Getter, Setter**
}

Team과 Member는 일대다 관계라서 Team entity에 컬렉션인 List<Member> members를 추가했고 일 쪽인 Team에 @OneToMany 매핑 정보를 사용했습니다. mappedBy 속성은 양방향 매핑일 때 사용하는데 연관관계의 주인을 결정하는 속성입니다. 반대쪽 매핑의 필드 이름을 값으로 주면 됩니다. Member는 기존의 단방향 관계이기 때문에 @ManyToOne 을 사용했습니다.

연관관계의 주인

@OneTomany는 직관적으로 이해가 될 것이라 생각합니다. 그러면 mappedBy가 필요한 이유는 무엇일까요? 아까 위에서 객체에는 양방향 연관관계라는 것이 없고 테이블은 양방향 연관관계를 맺는다고 했습니다. 이러한 차이를 없애기 위해서 연관관계의 주인을 설정합니다.

위의 예시에 대한 객체 연관관계는 다음과 같습니다.

  • 회의 → 팀 연관관계 1개(단방향)
  • 팀 → 회원 연관관계 1개(단방향)

테이블의 연관관계는 다음과 같습니다.

  • 회원 ↔ 팀의 연관관계 1개(양방향)

객체 연관관계 예시를 보면 엔티티를 양방향 연관관계로 설정하면 객체의 참조는 둘인데 외래 키는 하나입니다. 따라서 둘 사이에 차이가 발생합니다. 이런 차이로 인해 JPA에서는 두 객체의 연관관계 중 하나를 정해서 테이블의 외래키를 관리해야 하는데 이것을 연관관계의 주인이라고 합니다.

연관관계 매핑 시 지켜야 할 규칙이 있는데 두 연관관계 중 하나를 연관관계의 주인으로 정해야 합니다. 연관관계의 주인만이 데이터베이스 연관관계와 매핑되고 외래 키를 관리(등록, 수정, 삭제)할 수 있습니다. 반면에 주인이 아닌 쪽은 읽기만 할 수 있습니다. 이런 연관관계 주인을 정하는 방법은 mappedBy 속성을 사용하면 됩니다. 주인은 mappedBy를 사용하지 않고 주인이 아닌 쪽은 mappedBy 속성을 사용해서 속성의 값으로 연관관계의 주인을 지정해야 합니다. 즉, 연관관계의 주인을 정한다는 것은 외래 키 관리자를 선택하는 것이라고 생각하시면 됩니다.

연관관계의 주인 선택 방법

연관관계의 주인이 존재해야 한다는 것은 이해가 되셨으리라 생각됩니다. 외래 키 관리자를 선택하라는 말이 좀 모호하실 수 있다 생각되는데요. 간단하게 말하면 외래 키가 있는 곳이 연관관계의 주인이 되야 합니다. 다르게 말하면 다대일 관계에서 “다”쪽이 주인이어야 하고, FK가 있는 쪽이 연관관계의 주인이어야 합니다.

하지만 일대다를 연관관계의 주인으로 선택하는 것이 불가능한 것만은 아닙니다. 예를 들어 Team.members를 연관관계의 주인으로 선택할 수 있는 것이죠. 하지만 성능과 관리 측면에서 권장하지 않습니다. 될 수 있으면 외래 키가 있는 곳을 연관관계의 주인으로 설정하는 것이 좋습니다.

순수한 객체까지 고려한 양방향 연관관계

양방향 연관관계에서 연관관계의 주인에만 값을 저장하면 주인이 아닌 방향은 값을 설정하지 않아도 데이터베이스에 외래 키 값이 정상 입력됩니다. 예를 들어 아래의 코드가 추가로 있어야 할 것 같지만

team.getMembers.add(member1); //무시 (연관관계의 주인이 아님)

주인이 아니기 때문에 입력된 값은 외래 키에 영향을 주지 않습니다. 따라서 데이터베이스에 저장할 때 위의 코드는 무시됩니다. 저장하고 싶다면 아래와 같이 연관관계의 주인에만 값을 저장해주면 됩니다.

member1.setTeam(team1); //연관관계 설정

그렇다면 정말 연관관계의 주인에만 값을 저장하고 주인이 아닌 곳에는 값을 저장하지 않아도 될까요? 아래의 예시로 문제점을 말씀드리겠습니다.

예를 들어 JPA를 사용하지 않고 엔티티에 대한 테스트 코드를 작성한다고 가정했을 때,

public void test() {
	Team team1 = new Team();
	Member member1 = new Member();

	member1.setTeam(team1);

	List<Member> members = team1.getMembers();
	System.out.println(members.size());
}

출력되는 값은 0이 나올 것 입니다. JPA를 사용하지 않은 순수한 객체 상태만을 사용했기 때문에 member1에서 team을 조회하면 team1이 나오겠지만 team1에서 조회한 members는 JPA를 사용하지 않아 아무것도 없게 되는 것이죠. 그렇기 때문에 객체까지 고려한다면 아래와 같이 양쪽 다 관계를 맺는 것이 좋습니다.

public void test() {
	Team team1 = new Team();
	Member member1 = new Member();

	member1.setTeam(team1);
	**team1.getMembers().add(member1); //추가된 코드**

	List<Member> members = team1.getMembers();
	System.out.println(members.size());
}

하지만 이렇게 양쪽 다 신경 써야 한다면, 호출 시 실수로 둘 중 하나만 호출해서 양방향이 깨질 수 있습니다.

member1.setTeam(team1);
**team1.getMembers().add(member1)**

그렇기 때문에 두 코드는 하나인 것처럼 사용하는 것이 안전한데요. 다음과 같이 setTeam method를 수정하면 간단하게 사용할 수 있습니다.

@Entity
public class Member {
	@Id
	@Column (name = "MEMBER_ID")
	private String id;

	private String username;
	
	**@ManyToOne
	@JoinColumn(name = "TEAM_ID")
	private Team team;**

	public void setTeam(Team team) {
		this.team = team;
		**team.getMembers().add(this); //추가된 코드**
	}
}

이렇게 한 번에 양방향 관계를 설정하는 메소드를 연관관계 편의 메소드라고 합니다. 하지만 이렇게 작성한 setTeam method도 버그가 존재합니다🤔

연관관계 편의 메소드 주의사항

member1.setTeam(teamA);
member2.setTema(teamB);
Member findMember = teamA.getMember(); //member1이 여전히 조회된다.

이 시나리오를 그림으로 분석해보겠습니다. 먼저 member1.setTeam(teamA)를 호출한 직후는 다음과 같습니다.

다음으론 member2.setTeam(teamB)를 호출한 직후의 상태는 다음과 같습니다.

무엇이 문제인지 보이시나요? 연관관계를 변경할 때는 기존 팀이 있으면 기존 팀과 회원의 연관관계를 삭제하는 코드를 추가해야 합니다. 따라서 리팩토링해보면 다음과 같습니다.

public void setTeam(Team team) {
		**if (this.team != null) {
			this.team.getMembers().remove(this);
		}**
		
		this.team = team;
		team.getMembers().add(this);
	}

참고로 삭제되지 않은 관계에서 teamA → member1 관계가 제거되지 않아도 데이터베이스 외래 키를 변경하는 데는 문제가 없습니다. 왜냐하면 teamA → member1 관계를 설정한 Team.members는 연관관계의 주인이 아니기 때문입니다. 연관관계의 주인인 Member.team의 참조를 member1 → teamB로 변경했으므로 데이터베이스에 외래 키는 teamB를 참조하도록 정상 반영이 됩니다. 그리고 이후에 새로운 영속성 컨텍스트에서 teamA를 조회해서 teamA.getMembers()를 호출하면 데이터베이스 외래 키에는 관계가 끊어져 있으므로 아무것도 조회되지 않습니다. 여기까지만 보면 굳이 왜 저런 로직을 넣어야할까 라는 생각이 들 수 있습니다.

문제는 관계를 변경하고 영속성 컨텍스트가 아직 살아있는 상태에서 teamA의 getMembers()를 호출하면 member1이 반환된다는 점입니다. 따라서 변경된 연관관계는 앞서 설명한 것 처럼 관계를 제거하는 것이 안전합니다.

프로젝트를 진행하면서 부딪힌 문제점

프로젝트를 진행하면서 양방향 연관관계를 사용하는 상황을 만났습니다. 문제 상황에 대해 설명하자면, 기본적으로 1500줄 정도되는 json파일을 RDB에 저장해야하는 상황이었습니다. 기획 시에 해당 상황에 대해 생각하고 NoSQL을 사용하면 좋았겠지만 주니어들만 있는 프로젝트에서 이러한 부분을 고려하기엔 힘들었습니다. 또한 DB를 변경하기엔 프로젝트 마감 기한까지 일주일 정도 남았기 때문에 변경하기엔 다소 어려움이 있었습니다. 따라서 RDB에 1500줄 정도 되는 json 파일을 저장해야 했습니다. 그래서 고민한 결과 이미지 데이터를 제외한 부분을 문자열로 통째로 저장하고 조회 시 그대로 반환하는 방법을 생각했습니다. 왜냐하면 해당 데이터는 가공할 필요가 없는 “값” 자체가 필요한 데이터였기 때문입니다. 이미지 데이터는 따로 필드를 나눠 저장한 이유는 S3에 저장해야했기 때문입니다.

이러한 상황에서 저가 앨범 페이지 조회 시 생각했던 방법은 해당 앨범 페이지를 조회할 때 문자열로 저장된 값들과 해당 앨범 페이지 ID를 가지고 있는 이미지를 모두 보내주는 방법이었습니다. 그렇기 때문에 앨범 페이지와 이미지 간의 일대다 관계를 형성했고 앨범 페이지 조회 시에 해당 페이지ID를 가지고 있는 이미지를 가져와야 하기 때문에 양방향 연관관계를 설정했습니다.

@Entity
@Getter
@ToString
@NoArgsConstructor
@Table(name = "album_pages")
public class AlbumPage extends BaseEntity {
    @ManyToOne
    @JoinColumn(name = "album_id")
    private Album album;

    @Column(columnDefinition = "longtext")
    private String shapes;

    @Column(columnDefinition = "longtext")
    private String bindings;

    **@OneToMany(mappedBy = "albumPage")
    private List<AlbumPageImage> albumPageImages = new ArrayList<>();**

    @Column(name = "capture_page_url", length = 512)
    private String capturePageUrl;

    @Builder
    public AlbumPage(Album album) {
        this.album = album;
    }

    void updateAlbumPage(String shapes, String bindings, String capturePageUrl) {
        this.shapes = shapes;
        this.bindings = bindings;
        this.capturePageUrl = capturePageUrl;
    }
}
@Entity
@Getter
@ToString
@NoArgsConstructor
@Table(name = "album_images", indexes = {@Index(name = "idx_album_page_id_and_asset_id", columnList = "album_page_id, asset_id", unique = true)})
public class AlbumPageImage extends BaseEntity {
    **@ManyToOne()
    @JoinColumn(name = "album_page_id")
    private AlbumPage albumPage;**

    @Column(name = "asset_id", length = 128, nullable = false)
    private String assetId;

    @Column(name = "file_name", length = 128, nullable = false)
    private String fileName;

    @Column(length = 2056)
    private String url;

    @Column(length = 16, nullable = false)
    private String type;

    @Column(name = "x_size", nullable = false)
    private double xSize;

    @Column(name = "y_size", nullable = false)
    private double ySize;

    @Builder
    public AlbumPageImage(AlbumPage albumPage, String assetId, String fileName, String type, double xSize, double ySize, String url) {
        this.albumPage = albumPage;
        this.assetId = assetId;
        this.fileName = fileName;
        this.type = type;
        this.xSize = xSize;
        this.ySize = ySize;
        this.url = url;
    }

    public void setAlbumPage() {
        if (this.albumPage != null) {
            this.albumPage.getAlbumPageImages().remove(this);
        }
        this.albumPage = albumPage;
        albumPage.getAlbumPageImages().add(this);
    }
}

위와 같이 설정한 뒤에 조회하기 위해 Controller를 설정하고 Service에 findPage method도 구현하고 실행을 했습니다.

@GetMapping
    public ResponseEntity<ApiUtils.ApiResult> findPage(@PageableDefault(size = 4) Pageable pageable, @PathVariable Long albumId, @AuthenticationPrincipal CustomUserDetails userDetails) {
        Long userId = userDetails.getUser().getId();
        albumMemberService.checkMembership(userId, albumId);

        return ResponseEntity.ok(ApiUtils.success(albumPageService.findPage(pageable, albumId)));
    }
@Transactional
    public Page<AlbumPage> findPage(Pageable pageable, Long albumId) {
        Page<AlbumPage> albumPages = albumPageJPARepository.findAll(pageable);

        return albumPages;
    }

그런데 아래와 같은 오류가 발생했습니다.

@OneToMany의 default fetchType은 Lazy라서 albumPage를 조회할 때 albumPageImage는 직접 사용하기 전엔 값이 들어오지 않습니다. 그래서 다음과 같은 오류가 발생하는 것을 발견했습니다. 이러한 부분을 해결하기 위해선 DTO를 만들고 해당 DTO에서 albumPageImage에 대한 getter method를 사용하거나 FetchType.EAGER로 변경하는 방법으로 해결할 수 있었습니다. 하지만 FetchType.EAGER로 변경하는 방법은 실무에서는 사용하지 않습니다.

호기심이 들었던 저는 아래와 같이 FetchType.EAGER로 변경해서 실행해보았습니다. 그랬더니..

다음과 같이

org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; nested exception is org.hibernate.exception.DataException: could not execute statement

와 이로 인한

org.apache.catalina.connector.ClientAbortException: java.io.IOException: 현재 연결은 사용자의 호스트 시스템의 소프트웨어의 의해 중단되었습니다

다음의 오류를 발견했습니다.

이 문제에 대해서 오래 고민을 했습니다. DataIntegrityViolationException은 주로 데이터베이스 제약 조건 위반 등과 같은 데이터 무결성 위반이 발생했을 때 발생하는 예외이거나 또는 해당 컬럼의 용량에 넘어가는 데이터가 들어가려고 하면 발생하는 오류였습니다. 한동안 에러를 만지다가 아래의 다음과 같은 에러 구문을 발견했습니다.

Caused by: org.h2.jdbc.JdbcSQLDataException: Value too long for column "MESSAGE CHARACTER VARYING(1024)": "'Could not write JSON: Infinite recursion (StackOverflowError); nested exception... (60536)"; SQL statement:

이걸 발견하고

“MESSAGE column의 용량을 넘어가는 데이터가 들어가려고 했구나, 근데 StackOverflowError가 발생했네? 보통 해당 에러는 무한 참조 때문에 발생하는데 그러면 양방향 연관관계에서 무한 참조로 인해 발생한 에러를 담으려다가 용량이 넘어가서 DataIntegrityViolationException가 발생했구나!”

라고 생각을 정리했습니다. 그래서 무한 참조를 해결할 수 있는 방안에 대해 찾기 시작했고 JSON 라이브러리에서 무한루프에 빠지지 않도록 하는 어노테이션인 @JsonIgnoreProperties 을 사용해서 해결할 수 있었습니다. 해당 어노테이션은 albumPage에 의해 호출된 albumPageImages에서는 albumPage 자료를 가져오지 말라는 어노테이션입니다.

하지만 해당 어노테이션에서 더 생각해 볼 건, albumPage에서 albumPageImages의 자료를 가져오는 경우에만 해당되기 때문에, 만약 albumPageImages만 따로 불러온다면 albumPageImages의 FK로 albumPage의 자료를 가져올 것입니다. 이러한 문제점을 해결하기 위해 @JsonIgnoreProperties을 또 사용해도 되겠지만 가장 좋은 방법은 Entity 자체가 아닌 DTO를 만들어서 리턴해주는 것이 가장 좋은 방법입니다. DTO를 만들어서 응답한다면 위의 두 문제가 모두 해결될 수 있습니다.

실제 프로젝트도 AlbumPageFindResponseDTO 를 만들어서 리턴하는 것으로 구현했습니다.

Resource Links

자바 ORM 표준 JPA 프로그래밍 - 김영한 저자

'Spring' 카테고리의 다른 글

Spring Batch란?  (0) 2026.01.18
Swagger UI란?  (0) 2026.01.18
QueryDSL이란?  (0) 2026.01.18
feign 적용기  (0) 2026.01.18
JPA란?  (0) 2026.01.18