본문 바로가기
Spring/Spring Data JPA

Spring Data JPA - 상속(Inheritance) + 다대다(N:M) 관계

by WangTak 2022. 1. 9.
반응형

엔티티를 설계하면서 Parent 클래스를 상속 받은 Child 클래스들 간의 다대다(N:M) 관계를 맺어야 하는 요구사항이 있어서 어떻게 해결했는지 정리해보려고 합니다.

 

어떤 상황인지 이해하실 수 있도록 예시를 들겠습니다.

 

- 개발자 A, B, C, D가 있습니다.

- 개발자는 회사에 재직 중이며, [대기업은 삼성전자, SK, etc..], [IT 서비스업은 네이버, 카카오, etc..]가 있습니다.

- 서로 다른 업종을 가진 기업은 서로 협약을 맺을 수 있습니다. (ex. 삼성전자 <-> 네이버)

- 같은 업종끼리의 협약은 고려하지 않도록 하겠습니다.

 

위와 같은 유사한 예시의 요구사항을 토대로 프로젝트를 진행하고 있었는데 이 문제를 어떻게 해결해야 할 까?를 생각했을 때 다음과 같이 엔티티를 설계 할 필요가 있다고 생각했습니다.

 

- 개발자 엔티티는 필수적이다.

- 또한, 대기업IT 서비스업 각각의 엔티티가 필요할 거 같다.

- 그러나 개발자 엔티티에 대기업IT 서비스업구분 없이 하나의 엔티티로 연관 관계를 가지고 있어야 될 거 같다.

- 근데 각 회사끼리 연관 관계는 회사 차원에서의 재량이기 때문에 N:M 관계가 맺어지겠구나.

 

이러한 요구사항을 토대로 어떻게 설계를 하면 될까라는 생각을 했을 때 다음과 같은 의식의 흐름으로 전개를 했습니다. 개발자 엔티티는 Developer로 만들었습니다. 테스트 코드를 작성하여 정상적으로 작동하는지 확인하도록 하겠습니다.

 

먼저 개발에 사용 될 엔티티 클래스와 테스트 코드는 다음과 같습니다. 그냥 하나의 테스트 코드에서 모든 내용을 테스트해보도록 하겠습니다.

 

본 글에서 사용될 클래스들

 

[상속 - V1] 

1. Company라는 부모 엔티티를 만들고 MajorCompany, ITCompany 엔티티를 만들어서 Company 엔티티를 상속하자

2. MajorCompany, ITCompany는 서로 협력을 할 수 있고, N:M 관계가 맺어질 거니깐 매핑 테이블을 엔티티로 승격시켜서 처리하면 되겠지?

3. 오케이, 그럼 Collaborator 엔티티를 하나 만들어서 Collaborator에서 MajorCompany, ITCompanyManyToOne 관계로, MajorCompany, ITCompanyCollaboratorOneToMany로 양방향 연관 관계로 맺으면 되겠구나~

 

라고 생각했습니다. 그래서 신나게 개발했죠, JPA 강의도 듣고 공부한 성과가 드디어 결실을 맺는구나... 실제 엔티티를 설계할 때 어떻게 해야 할지 생각이 나니깐 너무 좋았죠.. 이때까지만 해도..

 

그럼 처음 짠 코드를 확인해보시죠.

잘못 짠 코드기 때문에 해당 부분은 토글로 해놓도록 하겠습니다. 코드는 위 패키지 구조의 사진대로 위에서 부터 아래로 올리도록 하겠습니다.

 

더보기

Collaborator

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Collaborator {

    @Id
    @GeneratedValue
    @Column(name = "collaborator_id")
    private Long id;

    @JoinColumn(name = "company_id")
    @ManyToOne(fetch = FetchType.LAZY)
    private ITCompany itCompany;
    
    @JoinColumn(name = "company_id")
    @ManyToOne(fetch = FetchType.LAZY)
    private MajorCompany majorCompany;
}

 

 Company

@Entity
@Getter
@DiscriminatorColumn
@Inheritance(strategy = InheritanceType.JOINED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Company {

    @Id
    @Column(name = "company_id")
    @GeneratedValue
    private Long id;

    private String name;
    private String address;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "majorCompany")
    private List<Collaborator> majorCompanyList = new ArrayList<>();

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "itCompany")
    private List<Collaborator> itCompanyList = new ArrayList<>();
}

 

ITCompany

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ITCompany extends Company {

    private String comment;
}

 

MajorCompany

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MajorCompany extends Company {

    private String message;
}

 

Developer

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Developer {

    @Id
    @GeneratedValue
    @Column(name = "developer_id")
    private Long id;

    private String email;
    private String password;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "company_id")
    private Company company;
}

 

일단 위 코드에 대해서는 테스트 코드는 작성하지 않도록 하겠습니다. 네, 사실 작성도 못합니다. 왜냐하면 저렇게 설계하면 애플리케이션이 정상적으로 안 띄워지거든요.. 다음과 같은 에러가 발생합니다.

 

V1 Test Failed

 

일단은 column: company_id가 중복되었다. 라는 정도의 에러를 내뱉는 것을 알 수 있습니다. DB에 생성된 테이블의 PK가 모두 company_id로 동일했습니다. 그래서 Collaborator의 JoinColumn의 이름을 동일하게 company_id로 줬었습니다. 그래서 처음에 JPA의 상속받은 자식(Child) 클래스가 가지는 Id 값을 변경하지 못하는구나..라고 생각했었습니다. 

물론 Company에 연관 관계를 참조하는 이상한 코드기도 하고요..

 

그럼 일단 "상속은 둘째치고 N:M 관계1:N, N:1 관계로 해결해보자"라고 생각했습니다. 테스트 코드가 Fail 뜨니깐 파란불이 보고 싶었나 봐요.

 

[그럼 N:M부터 해결해보자 - V2]

1. 오케이, 그럼 N:M 관계부터 해결해보자.

2. MajorCompany, ITCompanyCompany 상속을 받지 말고 각 엔티티가 독자적인 Id(PK)를 가지도록 하자.

 

그래서 MajorCompany, ITCompany에 extends Company 코드를 빼고 각 엔티티에 @Id를 추가하여 전형적인 매핑 테이블을 엔티티로 승격시켜 N:M 관계1:N, N:1 관계(@ManyToMany -> @OneToMany, @ManyToOne)로 해결했습니다.

 

3. 기업들 간에 협력을 만들 수 있는 구조를 만들었지만

4. Developer가 어떤 회사(ex. 삼성 전자)에 속해있는지 알 수가 없는 구조가 되었습니다.

5. 왜냐하면 DeveloperMajorCompanyITCompany를 한 몸에 담을 수 있는 클래스가 없으니깐요.

 

물론 여기서도 오만가지 생각을 했습니다. Company에서 MajorCompany, ITCompany를 참조하는 그런 설계를 한 후에, Developer에는 Company의 Id 값을 넣고, 하나의 회사에만 속해야 되니깐 Company에서 참조되는 MajorCompany, ITCompany를 하나는 NULL로 박아버릴까 라는 이상한 생각도 했습니다. 그러나 결국에는 상속 관계를 사용하지 않으면 Developer에 회사라는 공통분모를 잡을 수가 없겠더라고요.

 

이 코드도 상속을 이용하지 않은 N:M 관계를 해결하기 위한 코드기 때문에 토클로 처리하도록 하겠습니다.

더보기

Collaborator

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Collaborator {

    @Id
    @GeneratedValue
    @Column(name = "collaborator_id")
    private Long id;

    @JoinColumn(name = "it_company_id")
    @ManyToOne(fetch = FetchType.LAZY)
    private ITCompany itCompany;

    @JoinColumn(name = "major_company_id")
    @ManyToOne(fetch = FetchType.LAZY)
    private MajorCompany majorCompany;

    public Collaborator(ITCompany itCompany, MajorCompany majorCompany) {
        this.itCompany = itCompany;
        this.majorCompany = majorCompany;
    }
}

 

ITCompany

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ITCompany {

    @Id
    @Column(name = "it_company_id")
    @GeneratedValue
    private Long id;

    private String name;
    private String address;

    private String comment;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "itCompany")
    private List<Collaborator> itCompanyList = new ArrayList<>();

    public ITCompany(String name, String address, String comment) {
        this.name = name;
        this.address = address;
        this.comment = comment;
    }
}

 

MajorCompany

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MajorCompany {

    @Id
    @Column(name = "major_company_id")
    @GeneratedValue
    private Long id;

    private String name;
    private String address;

    private String message;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "majorCompany")
    private List<Collaborator> majorCompanyList = new ArrayList<>();

    public MajorCompany(String name, String address, String message) {
        this.name = name;
        this.address = address;
        this.message = message;
    }
}

 

DeveloperTest

@Slf4j
@Transactional
@SpringBootTest
@Rollback(value = false)
class DeveloperTest {

    @PersistenceContext
    private EntityManager em;

    @Test
    public void InsertCompanyAndDeveloperV2() throws Exception {
        ITCompany naver = new ITCompany("네이버", "경기도 성남시", "네이버 최고");
        ITCompany kakao = new ITCompany("카카오", "제주특별자치도 제주시",  "카카오 최고");

        em.persist(naver);
        em.persist(kakao);

        MajorCompany samsung = new MajorCompany("삼성", "경기도 수원시", "삼성 최고");
        MajorCompany sk = new MajorCompany("SK", "서울특별시 종로구", "SK 최고");

        em.persist(samsung);
        em.persist(sk);

        Collaborator collaborator = new Collaborator(naver, samsung);

        em.persist(collaborator);

        em.flush();
        em.clear();
    }
}

 

[결국 다시 상속으로 회귀 - V3]

1. Company를 상속받는 MajorCompany, ITCompany로 다시 회귀했습니다.

2. 그 이후에 Collaborator를 사용하여 오류가 있는 V1 버전의 N:M 관계를 1:N, N:1 관계로 가져갔습니다.

3. 좀 달라진 점은 Company에 있던 연관 관계 관련 코드를 ITCompany, MajorCompany로 내렸습니다.

4. 그러나 똑같이 Duplicate ERROR가 발생했고

5. 이때, JPA 상속에 대한 검색을 했습니다. 다음과 같은 키워드로 말이죠. [jpa inheritance pk join column]

6. 구글링을 통해 못 보던 어노테이션을 확인했습니다. [@PrimaryKeyJoinColumn] 

 

그래서 뭔가 어노테이션의 이름을 딱 보니깐 상속받은 자식 엔티티가 가지는 (PK, FK)의 이름을 바꿀 수 있을 거 같은 느낌이 들었습니다. 그래서 바로 적용해봤습니다.

 

작성한 코드, 테스트 코드는 다음과 같으며 테스트에 사용된 역할, 책임, 협력은 다음과 같습니다. 

 

[개발자의 회사]

개발자 A - 삼성전자 [대기업]

개발자 B - SK [대기업]

개발자 C - 네이버 [IT 서비스업]

개발자 D - 카카오 [IT 서비스업]

 

[협력 관계]

삼성 전자 <-> 네이버

삼성 전자 <-> 카카오

SK <-> 카카오

 

역할, 책임, 협력이 설정되어 있을 때, 개발자A로 서비스에 접근(로그인)했을 때 자신이 속한 회사와 협력되어 있는 기업을 확인해보도록 하겠습니다. 

 

Collaborator

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Collaborator {

    @Id
    @GeneratedValue
    @Column(name = "collaborator_id")
    private Long id;

    @JoinColumn(name = "major_company_id")
    @ManyToOne(fetch = FetchType.LAZY)
    private MajorCompany majorCompany;

    @JoinColumn(name = "it_company_id")
    @ManyToOne(fetch = FetchType.LAZY)
    private ITCompany itCompany;

    public Collaborator(MajorCompany majorCompany, ITCompany itCompany) {
        this.majorCompany = majorCompany;
        this.itCompany = itCompany;
    }
}

 

Company

@Entity
@Getter
@DiscriminatorColumn
@Inheritance(strategy = InheritanceType.JOINED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Company {

    @Id
    @Column(name = "company_id")
    @GeneratedValue
    private Long id;

    private String name;
    private String address;

    public Company(String name, String address) {
        this.name = name;
        this.address = address;
    }
}

 

ITCompany

@Entity
@Getter
@PrimaryKeyJoinColumn(name = "it_company_id") // 추가된 어노테이션
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ITCompany extends Company {

    private String comment;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "itCompany")
    private List<Collaborator> itCompanyList = new ArrayList<>();

    public ITCompany(String name, String address, String comment) {
        super(name, address);
        this.comment = comment;
    }
}

 

MajorCompany

@Entity
@Getter
@PrimaryKeyJoinColumn(name = "major_company_id") // 추가된 어노테이션
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MajorCompany extends Company {

    private String message;

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "majorCompany")
    private List<Collaborator> majorCompanyList = new ArrayList<>();

    public MajorCompany(String name, String address, String message) {
        super(name, address);
        this.message = message;
    }
}

 

 Developer

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Developer {

    @Id
    @GeneratedValue
    @Column(name = "developer_id")
    private Long id;

    private String email;
    private String password;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "company_id")
    private Company company;

    public Developer(String email, String password, Company company) {
        this.email = email;
        this.password = password;
        this.company = company;
    }
}

 

DeveloperTest [데이터 추가]

@Slf4j
@Transactional
@SpringBootTest
@Rollback(value = false)
class DeveloperTest {

    @PersistenceContext
    private EntityManager em;

    @Test
    public void InitDataV3() {
        // 회사 등록
        ITCompany naver = new ITCompany("네이버", "경기도 성남시", "네이버 최고");
        ITCompany kakao = new ITCompany("카카오", "제주특별자치도 제주시",  "카카오 최고");

        MajorCompany samsung = new MajorCompany("삼성", "경기도 수원시", "삼성 최고");
        MajorCompany sk = new MajorCompany("SK", "서울특별시 종로구", "SK 최고");

        em.persist(naver);
        em.persist(kakao);
        em.persist(samsung);
        em.persist(sk);

        // 회사 간의 협력 관계 설정
        Collaborator collaboratorA = new Collaborator(samsung, naver); // 삼성 - 네이버
        Collaborator collaboratorB = new Collaborator(samsung, kakao); // 삼성 - 카카오
        Collaborator collaboratorC = new Collaborator(sk, kakao); // sk - 카카오
        
        em.persist(collaboratorA);
        em.persist(collaboratorB);
        em.persist(collaboratorC);

        // 개발자 등록
        Developer developerA = new Developer("wangtak1@gmail.com", "1234", samsung);
        Developer developerB = new Developer("wangtak2@gmail.com", "1234", sk);
        Developer developerC = new Developer("wangtak3@gmail.com", "1234", naver);
        Developer developerD = new Developer("wangtak4@gmail.com", "1234", kakao);

        em.persist(developerA);
        em.persist(developerB);
        em.persist(developerC);
        em.persist(developerD);

    }
}

 

 

DeveloperTest [테스트]

@Test
public void checkCollaborator() {
    Developer findDeveloper = em.createQuery("select d from Developer d " +
                    "join fetch d.company " +
                    "where d.email = :email", Developer.class)
            .setParameter("email", "wangtak1@gmail.com")
            .getSingleResult();

    Assertions.assertThat(findDeveloper.getEmail()).isEqualTo("wangtak1@gmail.com");
    // wangtak1@gmail.com 의 Company 는 majorCompany 임
    Assertions.assertThat(findDeveloper.getCompany()).isExactlyInstanceOf(MajorCompany.class);

    if (findDeveloper.getCompany() instanceof ITCompany) {
        List<Collaborator> findCollaborators = em.createQuery("select c from Collaborator c " +
                        "where c.itCompany = :itCompany", Collaborator.class)
                .setParameter("itCompany", findDeveloper.getCompany())
                .getResultList();

        findCollaborators.forEach(collaborator -> {
            log.info("findDeveloper associated with : {}", collaborator.getMajorCompany());
        });

    } else if (findDeveloper.getCompany() instanceof MajorCompany) {
        List<Collaborator> findCollaborators = em.createQuery("select c from Collaborator c " +
                        "where c.majorCompany = :majorCompany", Collaborator.class)
                .setParameter("majorCompany", findDeveloper.getCompany())
                .getResultList();

        findCollaborators.forEach(collaborator -> {
            log.info("findDeveloper associated with : {}", collaborator.getItCompany());
        });
    }
}

 

이메일이 wangtak1@gmail.com인 개발자로 서비스에 접근 했을 때 자신이 속한 회사와 협약되어 있는 회사를 찾는 테스트를 했습니다. 개발자가 대기업인지, IT 서비스업인지 알 수 없기 때문에 instanceof 명령어를 통해서 분기 처리를 했지만 이 부분은 Querydsl을 사용하면 동적 쿼리를 좀 더 수월하게 처리할 수 있지 않을 까 생각됩니다.

 

결론: @PrimaryKeyJoinColumn을 사용하여 자식 클래스가 가지는 (PK, FK)의 컬럼 이름을 지정할 수 있다. 각각의 컬럼을 통해서 매핑 테이블을 엔티티로 승격시켜서 @JoinColumn에 @PrimaryKeyJoinColumn로 지정한 컬럼의 이름을 넣어주면 된다.

 

 

반응형