단방향 설계의 JPA 프로젝트, 양방향 연관관계 도입 심사기

Libienz·2024년 9월 9일

자바 ORM 표준 JPA 프로그래밍책에서는 다음과 같이 말하고 있습니다.

  • 단방향 매핑만으로 테이블과 객체의 연관관계 매핑은 이미 완료되었다.
  • 양방향 매핑은 복잡하기에 우선적으로 단방향 매핑을 사용하여 구현하라.
  • 다만 반대 방향으로의 객체 그래프 탐색 기능이 필요할 때 양방향 코드를 사용하는 것을 검토하라.

저희 팀은 양방향 연관관계를 도입하는 것을 고려하고 있습니다.
이 글은 저희 도메인의 양방향 연관관계 도입 심사를 위한 글임을 먼저 말씀드립니다.

진행중인 프로젝트의 도메인 연관관계

저희 프로젝트의 핵심 도메인은 여행기와 여행 계획입니다. 해당 도메인들은 생명주기를 공유하는 여러 단방향 연관관계를 가지고 있죠. 예를 들어, 여행기는 여러 개의 여행 날짜를 포함하며, 각 여행 날짜는 여러 여행 장소를 가집니다. 여행 날짜는 여행기가 없으면 존재할 수 없으며, 여행 장소 역시 여행 날짜가 없으면 존재할 수 없습니다. 이 외에도 연관된 추가 도메인들이 있지만, 여기서는 이 정도로 약술하겠습니다.

우리 팀의 초기 도메인 설계

프로젝트 초기에 저희 팀은 엔티티간 양방향 연관관계를 가지지 않기로 합의하였습니다. 양방향 연관관계는 순환 참조와 참조 그래프의 복잡성을 초래할 수 있으며, 하나의 수정이 여러 엔티티에 영향을 미칠 수 있음을 고려한 것입니다.

초기에는 단방향으로만 설계해라라는 원칙에 철저했던 것이라고도 볼 수 있겠네요.

따라서 현재 JPA와 함께 작성된 엔티티 코드는 자식엔티티만이 부모엔티티를 알고 있는 단방향 연관관계만을 포함하고 있습니다.

프로젝트 초기 설계

단방향 설계에서 발생하는 여러 불편 사항

그런데 단방향 설계로 개발을 이어나가고 있던 저희 팀은 여러 불편사항을 마주하게 되었습니다. 조회와 삭제에서 반대 방향으로 참조를 하지 못하는 것이 가장 큰 불편 사항이었죠.

여행기를 조회하고 삭제하는 경우를 이어지는 글에서 예시로 보여드리겠습니다.

여행기 조회

이 글을 읽는 분들은 여행기를 조회하는 로직이 다음처럼 생겼으리라 기대하실지도 모르겠습니다.

	public Travelogue findById(Long travelogueId) {
    	return travelogueRepository.findById(travelogueId);
    }

하지만 단방향 설계로만 이루어진 저희 프로젝트에서 여행기를 조회하는 일에는 위보다 번거로운 과정이 필요합니다.
단방향에서의 코드는 다음과 같이 구현되었습니다.


    @Transactional(readOnly = true)
    public TravelogueResponse findTravelogueById(Long id) {
        Travelogue travelogue = travelogueService.getTravelogueById(id);
        return getTravelogueResponse(travelogue);
    }
    

    private TravelogueResponse getTravelogueResponse(Travelogue travelogue) {
        List<TagResponse> tagResponses = travelogueTagService.readTagByTravelogue(travelogue);
        TravelogueLikeResponse likeResponse = travelogueLikeService.findLikeByTravelogue(travelogue);
        return TravelogueResponse.of(travelogue, findDaysOfTravelogue(travelogue), tagResponses, likeResponse);
    }

    private List<TravelogueDayResponse> findDaysOfTravelogue(Travelogue travelogue) {
        List<TravelogueDay> travelogueDays = travelogueDayService.findDaysByTravelogue(travelogue);

        return travelogueDays.stream()
                .sorted(Comparator.comparing(TravelogueDay::getOrder))
                .map(day -> TravelogueDayResponse.of(day, findPlacesOfTravelogueDay(day)))
                .toList();
    }

    private List<TraveloguePlaceResponse> findPlacesOfTravelogueDay(TravelogueDay travelogueDay) {
        List<TraveloguePlace> places = traveloguePlaceService.findTraveloguePlacesByDay(travelogueDay);

        return places.stream()
                .sorted(Comparator.comparing(TraveloguePlace::getOrder))
                .map(place -> TraveloguePlaceResponse.of(place, findPhotoUrlsOfTraveloguePlace(place)))
                .toList();
    }

    private List<String> findPhotoUrlsOfTraveloguePlace(TraveloguePlace place) {
        return traveloguePhotoService.findPhotoUrlsByPlace(place);
    }

여행기에 해당하는 여행날짜들 조회하고 여행 날짜들에 대한 여행 장소들을 순차적으로 조회하고 있음을 확인하실 수 있습니다.

단방향 설계에서는 여행기(1)가 여행 날짜들(N)을 모르고 여행날짜(1)가 여행 장소(N)을 모르기 때문에 이러한 구현으로 이어질 수 밖에 없었죠.

그렇기에 단방향 설계에서 하나의 여행기를 조회하기 위한 로직은 자신의 하위 도메인을 순차적으로 조회하는 복잡하고 거대한 코드로 이어졌습니다.

여행기 삭제

조회와 마찬가지로 글을 읽는 분들은 여행기를 삭제하는 로직이 다음처럼 생겼으리라 기대하실지도 모르겠습니다.

	public void deleteTravelogue(Long travelogueId) {
    	travelogueRepository.deleteById(travelogueId);
    }

하지만 삭제 역시 조회와 비슷한 문제점을 가집니다.
단방향 설계에서의 삭제는 조회처럼 자신의 하위 도메인에 대한 모든 정보를 아는 상태로 순차적으로 처리해야 합니다.
삭제는 또 조회와는 다르게 외래키 제약 조건을 의식하고 말단 도메인인부터 순서대로 삭제되어야 하죠.

하나의 여행기를 삭제하기 위한 로직 역시 하위 도메인을 자세히 알고 순차적으로 삭제하는 코드로 이어졌습니다.

    private void clearTravelogueContents(Travelogue travelogue) {
        traveloguePhotoService.deleteAllByTravelogue(travelogue);
        traveloguePlaceService.deleteAllByTravelogue(travelogue);
        travelogueDayService.deleteAllByTravelogue(travelogue);
    }

점점 깊어지는 도메인

요구사항이 추가되면서 도메인의 연관관계는 점차 깊어져 갔습니다. 여행 장소 뿐만 아니라 여행 장소의 사진들도 추가되었고 여행 계획에서는 여행 장소마다 Todo 도메인이 추가되기도 했죠.

이렇게 단방향 연관관계가 깊어질수록 앞서 말씀드린 단방향 설계의 문제점은 더욱 커져갔습니다. 여행기, 여행계획을 조회, 삭제할 때 알아야 하는 정보가 너무 많아졌기에 코드 양과 알아야 하는 하위 도메인 지식이 점점 비대해진 것이죠.

양방향 연관관계 도입을 통한 득(得)

저희 팀은 적절한 수준의 추상화를 바라게 되었습니다.
저희 프로젝트에서는 여행기를 삭제한다. 여행기를 조회한다와 같은 구현이 편해질 수 있기를 바랬죠.

지금 이러한 구현이 안되는 이유는 역방향(일쪽에서 다쪽으로) 참조를 이용할 수 없고 JPA 수준에서의 추상화를 이용할 수 없기 때문입니다.

따라서 양방향 연관관계를 적절히 도입하면 조회와 삭제 로직은 앞서 보여드린 형태로 단순화 될 수 있습니다.

개선된 조회

	public Travelogue findById(Long travelogueId) {
    	return travelogueRepository.findById(travelogueId);
    }

개선된 삭제

	public void deleteTravelogue(Long travelogueId) {
    	travelogueRepository.deleteById(travelogueId);
    }

개선된 버전에서의 조회와 삭제는 핵심 로직과 거리가 있는 코드들을 추상화하여 가독성을 높입니다. 또한 객체 그래프 참조를 용이하게 하죠. 뿐만 아니라 도메인 지식이 부족한 개발자도 코드를 쉽게 이해 할 수 있도록 합니다.

이는 양방향 연관관계를 도입했을 때 얻을 수 있는 점이라고 볼 수 있습니다.

양방향 연관관계 도입을 통한 실(失)

하지만 양방향 설계의 단점도 존재합니다. 다음과 같은 것들이죠.

1. 수정 지점의 확산

양방향 연관관계는 수정 지점을 두 개 이상으로 만들 수 있습니다. 예를 들어, 유저와 게시글 간의 양방향 관계에서는 게시글 생성 시 유저의 게시글 리스트도 수정해야 합니다. 이는 작업을 진행하는 데 수정해야 할 엔티티가 두 개가 되는 것을 의미합니다.

2. 순환 참조 가능성

양방향 연관관계는 무한 루프를 초래할 수 있습니다. 예를 들어, 객체를 JSON으로 직렬화할 때 양방향 참조로 인해 무한 루프에 빠질 수 있습니다. 이러한 문제를 피하기 위해 단방향 참조로 제한하거나 문서화 및 교육을 통해 예방할 수 있지만, 실수의 여지는 남아 있습니다.

3. 성능 이슈

양방향 연관관계는 성능에 영향을 미칠 수 있습니다. JPA에서 추가 쿼리가 발생할 수 있으며, 특히 ToMany 관계에서는 주의가 필요합니다. 예를 들어, 페이징 처리 불가능한 상황에서 인메모리에 모든 데이터를 올리는 경우가 있습니다.

고려해야 할 트레이드 오프 지점들입니다.

결론

저희 팀은 다음을 근거로 양방향 연관관계로 얻을 득이 실보다 크다고 결론 내렸습니다.

  • 수정 지점의 확산: JPA가 많은 부분을 도와주기 때문에 (연관된 엔티티를 조회해주는 기능 등) 단점의 의미가 크지 않다.
  • 순환 참조 가능성: 확실한 위계를 가진 현재 도메인 구조에서 양방향 참조가 일어날 가능성이 적다. 직렬화, toString 등에서의 주의점만 의식하면 훨씬 편하게 조립할 수 있음으로 득이 실보다 크다.
  • 양방향 연관관계는 성능이 좋지 않다: @BatchFetchSize등을 활용할 수 있음으로 단점으로써의 의미가 희석된다.
  • 양방향 연관관계를 도입하면 연관된 엔티티 관련 동작들을 높은 수준으로 추상화할 수 있다.
  • 코드에서 핵심로직이 더 잘 드러나 가독성이 높아지고 훨씬 안정적인 유지보수를 가능하게 한다.

양방향 연관관계의 단점들이 관리될 수 있고 그 관리 난이도가 높지 않으며, 장점이 매우 크다고 판단한 것입니다.

사실 저는 양방향 연관관계를 금기시 되는 안티 패턴 정도로 이해하고 있었습니다. 그래서 쉽사리 단방향에서 양방향으로 리팩터링 하자는 아이디어를 낼 수 없었어요. 모든 기술은 tradeOff가 있을 뿐이지 절대라는 개념은 프로그래밍에 없는 듯 합니다.

다음 글에서는 양방향 연관관계 도입으로 저희 팀이 실제로 원하는 효과를 얻을 수 있었는지, 복잡성으로 인해 여러 문제점을 만날 수 있었는지 설명드리는 것도 좋겠네요.

더 깨닫게 되는 점이 있으면 공유드리겠습니다. 이번 글은 여기서 마치겠습니다.

감사합니다.

profile
추상보다 상세에 집착하는 개발자 리비(리비엔즈)입니다 🤗

0개의 댓글