[Spring] 연관 관계 주인 지정 시, 주의사항

하원·2024년 7월 31일
post-thumbnail

안녕하세요, 하원입니다.
이번에는 제가 프로젝트를 진행하면서 겪었던 연관 관계 주인 지정 시 주의할 점에 대해 소개해 보겠습니다.

일대일 관계에서 겪게 된 상황이기 때문에 일대일 관계를 주제로 이야기하겠습니다!


@OneToOne 매핑이란?

  • 일대일(1:1) 관계를 가진 객체를 매핑할 때 사용하는 방식입니다.
  • 주 테이블과 대상 테이블 둘 중 한 곳에서 외래 키를 관리합니다.
  • 실무에서는 주 테이블에서 외래 키를 관리한다고 합니다. (김영한 님 강의 참고)

@Entity
@Getter
public class Poster {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "poster_id")
    private Long posterId;

    private String title;
    private String content;

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "event_id")
    private Event event;
}
@Entity
@Getter
public class Event {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "event_id")
    private Long eventId;

    @OneToOne(mappedBy = "event")
    private Poster poster;

    private String artistName;
    private int ticketPrice;
    private LocalDateTime startTime;
    private LocalDateTime endTime;
}

위 코드에서는 PosterEvent일대일 양방향 매핑 관계를 맺고 있습니다.
연관 관계의 주인Poster로 지정되어 있습니다.

보통 일대다(1 : N) 관계에서는 다(N) 쪽에서 외래 키를 관리하기 때문에 연관 관계의 주인을 쉽게 지정해 줄 수 있습니다. 하지만 일대일 관계에서는 특별히 정해진 규칙이 없기 때문에 개발자마다 각기 다른 선택을 하게 됩니다.


	// 포스터 등록 API
    @PostMapping
    public String writePoster(@RequestBody PosterRequest request) {
        Poster poster = Poster.create(request);
        posterRepository.save(poster);

        return "포스터 제목 : " + poster.getTitle()
                + ", 이벤트 아티스트 이름 : " + poster.getEvent().getArtistName();
    }

	// 포스터 수정 API
    @PostMapping("/{posterId}")
    public String modifyPoster(
            @PathVariable Long posterId,
            @RequestBody PosterRequest request) {

        // 수정할 포스터 찾기
        Poster poster = posterRepository.findById(posterId).orElseThrow(
                () -> new IllegalArgumentException("잘못된 포스터 요청입니다.")
        );

        Poster modifiedPoster = Poster.modify(poster, request);
        posterRepository.save(modifiedPoster);

        return "수정된 포스터 제목 : " + modifiedPoster.getTitle()
                + ", 수정된 이벤트 아티스트 이름 : " + modifiedPoster.getEvent().getArtistName();
    }

포스터 등록 API와 포스터 수정 API의 컨트롤러 코드입니다.
API의 Response는 최대한 간단하게 구현하였습니다.
저는 주로 도메인 주도 설계(DDD)를 지향하며 개발하기 때문에 setter를 사용하지 않았습니다.

위 코드의 포스터 수정 API를 간략하게 설명하자면, posterId를 통해 포스터를 불러와 modify라는 수정 메서드를 호출하여 요청에 따라 수정한 후 저장하는 방식입니다.


    // 생성 메서드
    public static Poster create(PosterRequest request) {
        Poster poster = new Poster();
        poster.title = request.getTitle();
        poster.content = request.getContent();
        poster.event = Event.create(request.getEvent(), poster);

        return poster;
    }
    // 생성 메서드
    public static Event create(PosterRequest.EventDto request, Poster poster){
        Event event = new Event();
        event.artistName = request.getArtistName();
        event.ticketPrice = request.getTicketPrice();

        event.poster = poster;

        event.startTime = LocalDateTime.now();
        event.endTime = LocalDateTime.now();
        
        return event;
    }
    // 수정 메서드
    public static Poster modify(Poster poster, PosterRequest request) {
        poster.event = Event.modify(poster.event, request.getEvent());
        poster.title = request.getTitle();
        poster.content = request.getContent();

        return poster;
    }
    // 수정 메서드
    public static Event modify(Event event, PosterRequest.EventDto request) {
        event.artistName = request.getArtistName();
        event.ticketPrice = request.getTicketPrice();
        event.startTime = LocalDateTime.now();
        event.endTime = LocalDateTime.now();
        
        return event;
    }

위 코드는 엔티티별 생성 및 수정 메서드입니다.
Poster를 수정하는 과정에서 Eventmodify 메서드를 호출하여 Event를 수정하고, 나머지 Poster를 수정하여 결과적으로 Poster를 반환하는 방식입니다.

결과는 어떻게 나왔을까요?


image.jpg1image.jpg2

아티스트 이름이 바뀌지 않은 것을 보니 포스터 정보만 수정됐고, 이벤트 정보는 수정되지 않았습니다..!

제가 프로젝트를 진행하면서 이 오류를 마주치게 되었는데요, 바로 @OneToOne 매핑의 연관 관계 주인 때문이었습니다. 왜 이런 현상이 발생한 걸까요?


연관 관계 주인만이 외래 키를 관리

제가 연관 관계 주인을 전혀 고려하지 않았던 것입니다.
연관 관계 주인으로 지정된 엔티티만이 외래 키를 관리할 수 있습니다.
외래 키를 관리한다는 것은 관련 데이터를 등록, 수정, 삭제할 수 있다는 것입니다.

제가 PosterEvent를 일대일 관계로 매핑하면서 Poster연관 관계 주인으로 지정했습니다.
하지만 수정 메서드를 보시면 Event 수정 부분을 Event의 modify 메서드를 호출하여 실행합니다.

이전에 말했듯이 Event연관 관계의 주인이 아닙니다. 그래서 포스터 정보만 수정되고, 이벤트 정보는 수정되지 않았던 것입니다.

저는 양방향 매핑을 했기 때문에 서로 데이터가 실시간으로 동기화될 거라는 아주 큰 착각을 했고, 괜히 다른 곳에 문제가 없는지 확인하며 대략 2시간에 가까운 시간을 날리게 되었습니다...
역시 JPA를 이용해 도메인을 설계할 때는 맑은 정신으로 해야겠습니다..!


@Entity
@Getter
public class Poster {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "poster_id")
    private Long posterId;

    private String title;
    private String content;

    @OneToOne(mappedBy = "poster")
    private Event event;
}
@Entity
@Getter
public class Event {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "event_id")
    private Long eventId;

    @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "poster_id")
    private Poster poster;

    private String artistName;
    private int ticketPrice;
    private LocalDateTime startTime;
    private LocalDateTime endTime;
}

이전과 달리 연관 관계의 주인Event로 지정해 줍니다.
주인만 바꿔주면 해결될까요?

주인만 바꿔주면 object references an unsaved transient instance - save the transient instance before flushing : velog.core.domain.Poster.event -> velog.core.domain.Event 와 같은 오류가 발생합니다.
주인보다 먼저 save 명령을 받았기 때문에 오류가 발생한 것 같습니다.

그러면 어떻게 수정해 줘야 할까요?


수정된 코드

	// 포스터 등록 API
    @PostMapping
    public String writePoster(@RequestBody PosterRequest request) {
        Poster poster = Poster.create(request);
        posterRepository.save(poster);

        Event event = Event.create(request.getEvent(), poster);
        eventRepository.save(event);

        Poster completeEvent = Poster.setEvent(poster, event);
        posterRepository.save(completeEvent);

        poster = posterRepository.findById(poster.getPosterId()).orElseThrow(
                () -> new IllegalArgumentException("잘못된 포스터 요청입니다.")
        );

        return "포스터 제목 : " + poster.getTitle()
                + ", 이벤트 아티스트 이름 : " + poster.getEvent().getArtistName();
    }

	// 포스터 수정 API
    @PostMapping("/{posterId}")
    public String modifyPoster(
            @PathVariable Long posterId,
            @RequestBody PosterRequest request) {

        // 수정할 포스터 찾기
        Poster poster = posterRepository.findById(posterId).orElseThrow(
                () -> new IllegalArgumentException("잘못된 포스터 요청입니다.")
        );

        Poster modifiedPoster = Poster.modify(poster, request);
        posterRepository.save(modifiedPoster);

        return "수정된 포스터 제목 : " + modifiedPoster.getTitle()
                + ", 수정된 이벤트 아티스트 이름 : " + modifiedPoster.getEvent().getArtistName();
    }

포스터 등록 API가 꽤 복잡해졌네요..!

아까 주인보다 먼저 저장되는 바람에 오류가 발생했는데요,
그래서 Poster를 생성할 때 Event 생성 코드를 분리해 주었습니다. PosterEvent를 따로 생성하고, PosterEvent를 지정해 주는 메서드를 하나 추가로 만들었습니다.


    // 생성 메서드
    public static Poster create(PosterRequest request) {
        Poster poster = new Poster();
        poster.title = request.getTitle();
        poster.content = request.getContent();

        return poster;
    }
    
    
    // 생성 메서드
    public static Event create(PosterRequest.EventDto request, Poster poster){
        Event event = new Event();
        event.artistName = request.getArtistName();
        event.ticketPrice = request.getTicketPrice();

        event.poster = poster;

        event.startTime = LocalDateTime.now();
        event.endTime = LocalDateTime.now();
        
        return event;
    }
    
    
    // 수정 메서드
    public static Poster modify(Poster poster, PosterRequest request) {
        poster.event = Event.modify(poster.event, request.getEvent());
        poster.title = request.getTitle();
        poster.content = request.getContent();
        
        return poster;
    }
    
    
    // 수정 메서드
    public static Event modify(Event event, PosterRequest.EventDto request) {
        event.artistName = request.getArtistName();
        event.ticketPrice = request.getTicketPrice();
        event.startTime = LocalDateTime.now();
        event.endTime = LocalDateTime.now();
        
        return event;
    }
    
    
    // 포스터에 이벤트 지정
    public static Poster setEvent(Poster poster, Event event) {
        poster.event = event;
        return poster;
    }

이전과 달라진 점은 Poster를 생성할 때 Event를 생성하지 않고, 포스터에 이벤트를 지정하는 메서드가 따로 추가된 것입니다.


image.jpg1image.jpg2

이제 포스터 정보와 이벤트 정보 둘 다 수정되는 것을 확인할 수 있습니다!

연관 관계의 주인을 누구로 지정하느냐에 따라 객체를 생성하거나 수정하는 코드의 흐름이 달라지게 됩니다. 이제는 연관 관계의 주인을 지정할 때 신중하게 생각해야겠네요..!


마무리

이전에 JPA 강의를 들을 때, 연관 관계의 주인을 잘못 지정하면 고생 고생을 하게 된다는 이야기를 들은 적이 있다.
그 조언을 들으면서 '이 부분은 공부를 많이 했으니 나는 안 그럴 거야'라고 생각했던 것 같은데.. 하하
역시 공부와 실전은 확실히 다른 것 같다. 차라리 이렇게 프로젝트 할 때 여러 상황이 발생하는 게 나을지도 모르겠다..!

profile
호기심 저장소

0개의 댓글