[Spring] JPA 중복 Foreign Key 적용하기 + Transactional

정환우·2022년 10월 25일
1

스프링

목록 보기
9/9
post-custom-banner

오랜만에 쓰는 블로그다.

오늘은 DB Table 구조를 고민 끝에 바꾸게 되었는데, JPA 중복 FK뿐만 아니라 바꾸게 되면서 고민했던 점들, 마주했던 에러들에 대해서 조금이나마 정리해보려고 한다.

DB 수정

변경하게 된 이유

DB Table을 수정하게 된 이유는 다음과 같다.

현재는 축구 팀 사이트를 제작하고 있다.
축구에서 한 경기에 선수들이 득점을 올리면 해당 득점과 득점에 대한 도움이 기록이 된다.
나는 단순히 이 부분을 경기에 종속시키려했는데, 그렇게 설계를 하다보니 누가 득점을 했고 '그 득점을 누가 도왔는지'를 알 수가 없었다.

누가 득점을 했는지 모르는 것 정도야 어떻게 고칠 수 있을 것 같았는데, 해당 득점에 대한 도움까지 같이 기록이 되어야 한다는 것을 내가 간과했었다.

그래서 원래는 경기-선수가 출전한 경기-선수 연관관계로만 존재하던 테이블에서, 컬렉션 테이블이 하나 더 생겨야 한다고 생각이 되었다.

그래서 경기-선수가 출전한 경기-선수 테이블과, 경기-득점자,어시스트-선수 테이블 이렇게 두개로 나누게 되었다.

물론 이렇게 나누는 것이 정답이 아니고 더 효율적으로 나눌 수 있는 방법이 있을 순 있지만, 내가 생각하기에 최선의 방법은 이것인 것 같다.

정확히 말하면 득점자,어시스트 테이블은 컬렉션보단 경기와 선수를 외래키로 참조하는 테이블이라 하는 것이 맞겠다.

봉착한 문제점

이렇게 설계해도 되는 걸까?

첫 번째 고민은 다음과 같았다.

결국에 득점한 사람과 어시스트를 한 사람은 선수에 속해있다. 그렇다면 해당 테이블은 Player Table을 두번 참조하는 테이블이 되어 버리는 것인데,

  1. 이게 가능한가?
  2. 이렇게 설계해도 되는 것인가?

이러한 문제에 봉착하게 되었다.

첫 번째 문제에 대한 답은 너무나 쉽게도 가능하다였고, 핵심 고민은 2번째가 되겠다.

두 번째 문제에 대한 답도 YES였다. double fk나 foreign key twice 이런식으로 검색해도 스택오버플로우나 기타 다른 게시글에 수두룩하게 정보들이 나온다. 어차피 player_id를 저장해야 하므로, fk로 두는 것이 맞다.

JPA에서 중복 FK적용하기

JPA에서 적용하는 방법은 컬렉션을 만드는 것과 별 차이가 없다. 다만, fk 이름을 구분해서 삽입하는 것만 주의해서 적용하면 되는데, 이 때 @Column 어노테이션이 사용 불가능하므로 @JoinColumn 어노테이션 안에서 다른 옵션들을 적용해주면 된다.

@Entity
@Getter
@NoArgsConstructor
public class MatchStat {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "match_id", nullable = false)
    private MatchDay matchDay;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(referencedColumnName = "player_id", name = "goal_id", nullable = false)
    private Player goal_player;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(referencedColumnName = "player_id", name = "assist_id")
    private Player assist_player;

    @Builder
    public MatchStat(MatchDay matchDay, Player goal_player, Player assist_player) {
        this.matchDay = matchDay;
        this.goal_player = goal_player;
        this.assist_player = assist_player;
    }

    public void setMatchDay(MatchDay matchDay) {
        this.matchDay = matchDay;
    }
}

기존에는 name 옵션만 사용했는데, name 옵션을 원하는 col 이름으로 바꿔주고 referenecedColumnName 을 참조하는 테이블 pk 이름으로 설정해주면 된다. 나는 하나는 null이어도 되고 하나는 null이면 안되므로 nullable 설정까지 해주었다.

Builder 사용시 주의해야 할 것

객체 생성 시 안전하고 가독성 좋게 생성하기 위해 Builder를 즐겨쓰고 있다. 헌데, 이 Builder를 사용할 때 주의해야 할 점이 있었다.

바로 List 였다. 이게 무엇이냐 하면, 평소에 Builder를 쓸 때 ColumnDefault나 기타 등등을 사용해도 딱히 문제가 발생하지 않았다. 아니 왜 문제가 발생안하는지도 몰랐다는 게 맞는 표현인 것 같다.

하지만 Entity 구조를 바꾸면서 기본 생성자에 Builder 를 사용하다가 자꾸 builder에서 NullPointerException 이 발생하는 것이었다.

이 문제가 발생한 부분은 다음과 같다.

@Getter
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class MatchDay {

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

    @Column(unique = true, nullable = false)
    private LocalDateTime matchDate;

    private String awayName;

    // 경기장 이름
    private String stadium;

    @Enumerated(EnumType.STRING)
    private MatchState state;

    // 우리 팀 득점 수
    @ColumnDefault("0")
    private int goals;

    // 상대 팀 득점 수
    @ColumnDefault("0")
    private int awayGoals;

    // 다 대 다 관계를 분산
    @OneToMany(mappedBy = "matchDay")
    private List<PlayMatch> playMatches = new ArrayList<>();

    @OneToMany(mappedBy = "matchDay")
    private List<MatchStat> matchStats = new ArrayList<>();

이 Entity에서 자꾸 발생을 하였는데, 이 발생한 문제는 다음과 같다.

Builder는 기본 생성자에 사용할 수 있는데, 이 경우에 Default 값을 집어 넣지 않게 된다. 고로, new ArrayList<>()와 같이 기본값을 별도로 지정해주어도 그 값이 Builder에 들어가지 않게 된다는 것이다.

그러니까 기본 생성자 + Builder의 경우 int는 null값이 들어갈 수 없으니 0이들어간다. 이 경우에는 내가 ColumnDefault를 0으로 했듯이 기본값이 0이기를 원했으므로 운좋게 맞아떨어진 것이고, List의 경우에는 null값이 들어가 자꾸 에러가 발생하는 것.

해결하는 방법은 다음과 같다.

1. Builder Default 적용하기

@Builder.Default를 이용하여 기본값을 설정할 수 있다.

lombok의 Builder 설명을 보면 다음과 같이 나와있다.

If a certain field/parameter is never set during a build session, then it always gets 0 / null / false. If you've put @Builder on a class (and not a method or constructor) you can instead specify the default directly on the field, and annotate the field with @Builder.Default: @Builder.Default private final long created = System.currentTimeMillis();

Build 과정동안 설정되지 않는 값들은 0, null, false 값 중 세 값을 갖게 된다.

이게 왜 이렇게 되냐하면, Builder 문서를 찾아보면 builder는FooBuilder라는 똑같은 내부 객체 타입을 갖는 inner static class가 생성되어 거기에 객체가 할당되고, 이 값들이 전달 되는 방식이라고 한다. 그러므로 당연히 class내에서 기본값들을 할당해봐야 Builder에서 할당될리가 없는 것이다.

Builder 공식 문서 - project lombok

2. 기본 값 넣고 싶은 부분은 Builder에서 제외 하기

Builder는 parameter에 존재하는 것들만 건드리게 되므로, Builder에 내 예시에 존재하는 List들이나 이런 것들이 존재하지 않으면 당연히 기본 값으로 넣게 된다.

고로 생성자와 Builder는 분리하는 것이 결론적으로 좋다고 생각한다. 분리하지 않더라도 왜 사용하는지, 어떻게 동작하는지는 알고 사용해야 이러한 오류를 막을 수 있다고 생각한다.

@Transactional

영속성 컨텍스트에 해당하는 부분이라고 할 수 있겠다.

DB 수정 및 repository, service 등 모든 코드를 수정하고, 테스트를 해보기 위해 테스트코드를 작성 후, 실행을 하였는데,

어!? 쿼리가 제대로 안나간다!?

이게 뭐가 문제인지 한참 코드를 들여다보고 문제점을 찾고 있을 무렵 갑자기 어디선가 들었던 이 문장이 생각났다.

'Transactional은 더 작은 단위로 쪼개지지 않는다...?' (맞는 문장은 아니다.)

생각해보니 Test Rollback을 위해 Transactional Annotation을 붙였고, 내 Service에 붙은 Transactional들은 제대로 동작하지 않을 것이다! 라는 생각이 들었다.

그래서 Test에 DB도 초기화 한 겸 그냥 DB를 날려버리는 작업을 하는 AfterEach에 repository.deleteAll()을 붙여버리는 임시방편을 사용해보았더니 바로 테스트케이스가 통과해버리더라.

나중에 알았지만 이 Transactional에 문제가 있는 것이 아니라 내 코드에 문제가 있었던 것이었다.

그리고 나는 이 Transactional과 영속성 컨텍스트에 대해서 더 공부를 해봐야겠다는 생각이 들었다. 눈 앞에서 LAZY Fetch된 객체가 불러와지지 않는 광경을 봐버리니 공부를 안할 수가 없더라.

바로 공식문서를 들어가보았다. Spring Transactional 공식문서


    @Test
    public void Test2() {
        PlayerDto p = PlayerDto.builder()
                .height(178)
                .weight(69)
                .imageUrl("")
                .backNum(62)
                .playerName("김길동")
                .birthDate(LocalDate.now())
                .description("테스트용 선수")
                .position(Position.FW)
                .build();

        PlayerDto pp = PlayerDto.builder()
                .height(192)
                .weight(90)
                .imageUrl("")
                .backNum(7)
                .playerName("홀란드")
                .birthDate(LocalDate.now())
                .description("테스트 2")
                .position(Position.MF)
                .build();

        MatchDayDto m = MatchDayDto.builder()
                .awayName("테스트2")
                .stadium("테스트 구장")
                .matchDate(LocalDateTime.now())
                .state(MatchState.BEFORE)
                .build();

        PlayerDto p1 = playerService.createPlayer(p);
        PlayerDto p2 = playerService.createPlayer(pp);
        MatchDayDto saveMatch = matchDayService.createMatchDay(m);

        System.out.println("CREATE 완료");
        playMatchService.addPlayerToMatch(p1.getPlayerId(), saveMatch.getMatchId());
        playMatchService.addPlayerToMatch(p2.getPlayerId(), saveMatch.getMatchId());

        System.out.println("ADD PLAYER TO MATCH");
        matchStatService.createMatchStatWithoutAssist(p1.getPlayerId(), saveMatch.getMatchId());
        matchStatService.createMatchStat(p2.getPlayerId(), p1.getPlayerId(), saveMatch.getMatchId());

        System.out.println("CREATE MATCH STAT");
        MatchDayDetailDto matchDetail = matchDayService.findMatchDetail(saveMatch.getMatchId());

        System.out.println("FIND MATCH DETAIL");
        for (MatchStatDto matchStatDto : matchDetail.getMatchStatDtoList()) {
            System.out.println("matchStatDto.getGoalPlayerName() = " + matchStatDto.getGoalPlayerName());
            System.out.println("matchStatDto.getAssistPlayerName() = " + matchStatDto.getAssistPlayerName());
        }

        PlayerDto player1 = playerService.findPlayerDtoById(p1.getPlayerId());
        PlayerDto player2 = playerService.findPlayerDtoById(p2.getPlayerId());
        assertThat(player1.getGoals()).isEqualTo(1);
        assertThat(player1.getAssists()).isEqualTo(1);
        assertThat(player2.getGoals()).isEqualTo(1);
        assertThat(player2.getAssists()).isEqualTo(0);
        assertThat(matchDetail.getPlayMatchDtoList().size()).isEqualTo(2);
    }

Test 코드는 다음과 같다.
문제가 되는 부분은 딱 부분 이었다.

  1. addPlayerToMatch() Method가 동작하면, player와 MatchDay모두 수정이 이루어저야 하는데, playMatch만 변경이 이루어진다는 점.

  2. findMatchDetail 함수에서 오류가 난다. (MatchDay 객체에 연관되어 있는 리스트들을 탐색하는 데 , 이 때 fetch가 제대로 되지 않는다.)

해당 문제를 해결하기 위해, Transactional Annotation을 집중 탐색해보았다.

먼저 영속성 엔티티를 Transaction Annotation에 따라 어떻게 공유하는 지 확인하기 위해, 어거지로 테스트용 코드를 만들어 동일한 엔티티를 조회해보도록 하였다.

Transactional Service만 존재할 때

player = com.acroriver.server.team.entity.Player@439b0198
MatchStatService player = com.acroriver.server.team.entity.Player@60510791

Transactional Test 안에서 Transactional Service를 호출했을 때

player = com.acroriver.server.team.entity.Player@2db82155
MatchStatService player = com.acroriver.server.team.entity.Player@2db82155

놀랍게도 후자의 경우 해시값이 같다. 즉, 영속성 컨텍스트가 공유된다는 것을 알 수 있다.

해당 부분은 공식문서에 자세히 나와있는데,

REQUIRED

REQUIRED_NEW

REQUIRED인 경우와 REQUIRES_NEW인 경우 Transaction이 어떻게 동작하는지에 대한 그림이다.
@Transactional의 Default는 REQUIRED이므로, Existing Transaction안에서 실행되므로 동일한 영속성 컨텍스트 안에서 동작하게 된다.

하지만 내 코드를 자세히 뜯어보니, 동일한 영속성 컨텍스트에서 실행되어도 전혀 상관이 없는 로직이었는데, 왜 대체 문제가 발생하는 것이지? 라고 생각할 때 쯤, 우연히 코드 하나가 눈에 들어왔다.

// PlayMatchService

    @Transactional
    @Override
    public void addPlayerToMatch(Long playerId, Long matchId) {
        Player player = playerRepository.findById(playerId).get();
        MatchDay matchDay = matchDayRepository.findById(matchId).get();
        PlayMatch playMatch = PlayMatch.builder()
                .player(player)
                .matchDay(matchDay)
                .build();
                
        matchDay.addPlayMatch(playMatch);
        player.addPlayMatch(playMatch);
        matchDayRepository.save(matchDay);
        playerRepository.save(player);
        playMatchRepository.save(playMatch);
    }

바로 이부분인데, 여기서 playMatch가 builder로 생성이 되어도, Id는 sql에 insert되는 순간 생성이 되는 것이므로 현재는 null 상태이다. 그래서 해당 playMatch를 저장하면서 그 객체를 다른 객체에 추가해줘야하는데, 그대로 추가를 해버렸다.

만약 이대로 진행하게 된다면, 영속성 컨텍스트 안에서 id = null인 상태로 계속 진행하게 되고, 영속성 컨텍스트 내에서 join이 불가능해져버리는 것이다.

실행 되는 코드를 보아도, join이 안되니까 값이 없어 sql에 update랑 등등 query문이 전부 실행이 된다. 그리고 playMatch id가 null인데 null인 객체에서 또 뭘 조사하려고 하니까 당연히 NPE가 나버리는 것.

실제에서는 addPlayerToMatch Transaction이 끝나면 sql에 insert가 될 테니, id가 null이어도 새로운 값이든 뭐든 generated해서 들어갈 테니 id값이 부여되어 join이 될 것이고 오류가 안났던 것이다.

이 부분을 수정하니 영속성 컨텍스트 안에서도 전혀 오류없이 동작을 했었다.

영속성 컨텍스트, Transactional 문제인 줄 알고 깊이 따져보려 했지만 끝은 내 실수 였던 것..

profile
Hongik CE
post-custom-banner

0개의 댓글