[Spring Data] JPA (3) - 관계 구성 (1:N,N:1,N:M, 양방향 맵핑, 단방향 맵핑, 연관관계의 주인)

k·2024년 2월 8일
0

spring data

목록 보기
3/5

이전글 - [Spring Boot] JPA (1) - 환경셋팅 / Entity 클래스 만들기
이전글 - [Spring Boot] JPA (2) - CRUD 기능을 위한 JPA Repository 만들기

앞서 올린 게시글로 엔티티 구성과 레파지토리를 통한 DB 접근을 간단하게 해보았다.

이번에는 엔티티에 대한 관계를 SpringBoot에서는 어떻게 나타낼 수 있는지 한번 알아보자.

관계를 표현하는 어노테이션 및 옵션

  • @OneToMany - 1:N관계

    • mappedBy
      연관관계의 주인을 설정한다.
    • cascade
      연관된 엔티티에 대한 연쇄작업을 지정
    • fetch
      LAZY와 EAGER로 즉시 로딩과 지연 로딩을 구분
  • @ManyToOne - N:1관계

    • fetch
      LAZY와 EAGER로 즉시 로딩과 지연 로딩을 구분
    • optional
      true면 연관된 엔티티가 필수가 아님, false면 항상 연관된 엔티티가 필수
    • targetEntity
      연관된 엔티티의 타입을 명시적으로 지정할 때 사용 (굳이 사용안하더라도 JPA가 판단해서 적용)
  • @ManyToMany - N:N관계

    • mappedBy
      연관관계의 주인을 설정한다.
    • cascade
      연관된 엔티티에 대한 연쇄작업을 지정
    • fetch
      LAZY와 EAGER로 즉시 로딩과 지연 로딩을 구분
  • @OneToOne - 1:1관계

    • mappedBy
      연관관계의 주인을 설정한다.
    • cascade
      연관된 엔티티에 대한 연쇄작업을 지정
    • fetch
      LAZY와 EAGER로 즉시 로딩과 지연 로딩을 구분
    • optional
      true면 연관된 엔티티가 필수가 아님, false면 항상 연관된 엔티티가 필수
  • @JoinColumn - 외래키 설정
    @Column과 거의 동일하다고 생각하면된다.

    • name
      데이터베이스 내에서 표시되는 컬럼 이름 설정
    • unique
      해당 컬럼을 고유인덱스로써 설정할지에 대한 것(true, false로 구분)
      주인이 될 엔티티의 컬럼이름 작성(클래스 필드명 아님)
    • referencedColumnName
      참조할 엔티티의 컬럼이름을 명시적으로 작성(클래스 필드명 아님)

      일반적으로는 참조x, JPA가 알아서 찾아줌

  • @JoinTable - 관계(연결) 테이블을 만들어준다.(N:M에서 주로 쓰임)

    • name
      관계(연결) 테이블 이름
    • joinColumns - 정방향
      관계 테이블에서 현재 엔티티의 Primary Key를 나타내는 컬럼이다.
    • inverseJoinColumns - 역방향
      관계 테이블에서 다른 엔터티의 Primary Key를 나타내는 컬럼이다.

관계 구성

1:N 과 N:1 관계

User과 Post로 관계 구성을 해보겠다.

User.java

@Entity @Getter @Setter @NoArgsConstructor @Table(name="USERS")
public class User {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") Integer id;
    @Column(name = "u_id",length = 255) String userId;
    @Column(name="u_pw", length = 255) String userPassword;
    @Column(name="u_name", length = 255) String userName;
    @OneToMany(mappedBy = "author", cascade = CascadeType.ALL) private List<Post> posts =new ArrayList<>();
    public User(String userId, String userPassword, String userName) {
        this.userId = userId;
        this.userPassword = userPassword;
        this.userName = userName;
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id);
    }
    @Override
    public int hashCode() {return Objects.hash(id);}
}

Post.java

@Table(name="POSTS") @Entity @Getter @Setter @NoArgsConstructor
public class Post {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "p_id") Long id;
    @Column(name="p_title") String title;
    @Column(name="p_contents") String contents;
    @ManyToOne @JoinColumn(name="u_id",referencedColumnName = "u_id") User author;
    public Post(String title, String contents, User author) {
        this.title = title;
        this.contents = contents;
        this.author = author;
        author.getPosts().add(this);
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Post post = (Post) o;
        return Objects.equals(id, post.id);
    }
    @Override
    public int hashCode() {return Objects.hash(id);}
}

위처럼 구성하면 된다. 제대로 완성했는지 궁금하다면, 아래의 조건에 따라 테스트를 진행해보면 된다.

User 관점으로 Post를 보면 1:N 관계이다.
그리고, Post 관점으로 User를 보면 N:1 관계이다.

그렇기 때문에, 위의 예제를 통해서 1:N과 N:1에 대해서 표기가 가능하다.

1:1 관계

User와 Profile로 구성해보겠다.

User.java

@Entity @Getter @Setter @NoArgsConstructor @Table(name="USERS")
public class User {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "id") Integer id;
    @Column(name = "u_id",length = 255) String userId;
    @Column(name="u_pw", length = 255) String userPassword;
    @Column(name="u_name", length = 255) String userName;
    @OneToOne(mappedBy = "user", cascade = CascadeType.ALL) Profile profile;
    public User(String userId, String userPassword, String userName) {
        this.userId = userId;
        this.userPassword = userPassword;
        this.userName = userName;
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id);
    }
    @Override
    public int hashCode() {return Objects.hash(id);}
}

Profile.java

@Entity
@Getter
@Setter
@Table(name="PROFILES")
@NoArgsConstructor
public class Profile {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name="p_id") Integer id;
    String introduction;
    @OneToOne @JoinColumn(name="u_id") User user;
    public Profile(String introduction, User user) {
        this.introduction = introduction;
        this.user = user;
        user.setProfile(this);
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Profile profile = (Profile) o;
        return Objects.equals(id, profile.id);
    }
    @Override
    public int hashCode() {return Objects.hash(id);}
}

위처럼 구성하면 된다. 제대로 완성했는지 궁금하다면, 아래의 조건에 따라 테스트를 진행해보면 된다.

  • User 생성에 문제가 없나?
  • Profile 생성에 문제가 없나?
  • Profile 생성 이후 User에도 Profile이 추가 되었나?

여기서 중요한 포인트가 있다.

먼저 양쪽 전부 @OneToOne을 사용하고 있다. 그러는 중에도 mappedBy를 사용하여 Owner 객체를 가르킨다.

즉, 소유자와 피소유자가 나누어지는 구조를 볼 수 있다.

N:M 관계

student와 course로 구성해보겠다.

N:M 관계는 다른 관계들과 다르게 표기하는 방식이 2가지가 있다.

다른 관계들과 차이가 있는 이유는 실제 관계형 데이터베이스에는 N:M 이라는 개념이 존재하지않는다.

이는 전체 코드 보다는 관계가 일어나는 대상 위주로 코드를 작성하겠다.

@ManyToMany 어노테이션 사용

@Entity
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @ManyToMany
    @JoinTable(
        name = "student_course",
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id")
    )
    private List<Course> courses;

    // constructors, getters, setters
}

@Entity
public class Course {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @ManyToMany(mappedBy = "courses")
    private List<Student> students;

    // constructors, getters, setters
}

일단 @ManyToMany를 사용해서 간결하게 n:m관계를 나타냈다.

여기서 @JoinTable이라는 어노테이션이 등장한다. 이는 말그대로 조인 테이블을 만드는 것이다.

조금 더 쉽게 설명하자면 아래에서 만드는 관계(연결) 테이블을 자동으로 만들어주는 것이다.

@JoinTable(
        name = "student_course",
        joinColumns = @JoinColumn(name = "student_id"),
        inverseJoinColumns = @JoinColumn(name = "course_id")
)

" student_course라는 이름의 연결 테이블을 만들 건데, 현재 student_id 랑 연결이 되고, 역방향으로는 course_id 와 연결된다. "
라는 의미이다.

@OneToMany@ManyToOne 으로 재구성

@Entity
public class Student {
    // ...

    @OneToMany(mappedBy = "student")
    private List<StudentCourse> studentCourses;

    // ...
}

@Entity
public class Course {
    // ...

    @OneToMany(mappedBy = "course")
    private List<StudentCourse> studentCourses;

    // ...
}

@Entity
public class StudentCourse {
    // ...

    @ManyToOne
    @JoinColumn(name = "student_id")
    private Student student;

    @ManyToOne
    @JoinColumn(name = "course_id")
    private Course course;

    // ...
}

@ManyToMany 사용을 일부러 피하기 위해, 관계 테이블을 두어서 @OneToMany@ManyToOne를 사용할 수 있게 구성했다.

주변에서 @ManyToMany는 무조건 나쁘다던데..?

내가 이 말을 듣고 알 수 없는 호기심이 들었고, 무조건 나쁘다면 이런 어노테이션은 왜 존재할까? 라는 생각으로 공부를 했다.

그 결과, 나는 무조건 나쁘지는 않다 라는 결론이 나왔다.

@ManyToMany 장점

  1. 간편한 N:M 표현을 할 수 있다.
  2. 코드의 가독성 자체가 증가한다. (별도의 클래스가 필요없기 때문이다)

@ManyToMany 단점

  1. 조인 테이블의 컬럼에 추가 정보를 저장하기 어렵다.
  2. JPA 구현체마다 생성되는 조인 테이블이 다를 수도 있다.

1:N/N:1로 재구성시 장점

  1. 추가 정보 저장에 용이하다. (연결 엔티티를 사용해서 훨씬 간편한 접근이 가능하다.)
  2. 조인 테이블의 엔티티를 통해 조인 테이블에 대한 명시적 제어가 가능하다.

1:N/N:1로 재구성시 단점

  1. 복잡성이 증가한다. (엔티티 구조가 복잡해짐)
  2. 추가 엔티티를 필요로 하기 때문에 가독성이 떨어진다.

그럼 언제 무엇을 선택하는게 좋을지 생각할 것이다.

내가 내린 결론은 간단한 프로젝트에서는 오히려 @ManyToMany를 사용하는 것이 훨씬 더 직관적이고, 여러 동작들에 유연함을 목표로 한다면 @ManyToOne@OneToMany를 사용하는 것이 더 좋다고 생각했다.

공부를 하면서 느낀건, 수동과 자동이라는 차이점이 있는 것이고, 이는 당연하게도 자동이면 유연성에 한계점이 존재할 수밖에 없고, 수동으로 하면 프로젝트에 맞게 유연한 설계가 가능하고 저장또한 수월한게 당연한 이야기이다.

단방향 / 양방향 맵핑

단방향과 양방향을 간단하게 알고 가면 편할거 같아 설명하겠다.

지금까지 적어온 코드 전부 양방향 맵핑이다. 부모 엔티티와 자식 엔티티 모두 맵핑이 된 경우이다.

User와 Post가 있고, 이를 클래스로 관계를 구성했다.

User객체에서 Post에 참조를 가지지만, Post객체에서 User객체에 참조를 가지지않는다면..?

이는 양방향으로 맵핑 되어야할까? 단방향 맵핑이 되어야할까?

이는 간단하게 한쪽으로의 참조만 일어난다면, 당연하게도 양방향보다는 단방향이 효율적이다. ( 코스트가 낮아지기 때문에)

양방향은 지금까지 해왔으니 간단한 개념적인 포인트만 알아보겠다.

연관관계의 주인이란?

JPA에서 양방향 매핑 시, 실제로 데이터베이스에 영향을 주는 주체 엔터티를 가리키며, 외래 키를 가지고 있고 변경을 관리하는 역할을 한다.

mappedBy라고 하면 아마 기억날 것이다.
mappedBy를 사용하지않는 곳이 연관관계의 주인이다.

사실 이렇게 말하면 엄청 복잡하고 어려운 개념 같은데, DB를 배운 사람이라면 아래의 특성이 기억 날것이다.

외래키는 항상 primary key를 참조해야한다. 이 때, primary key가 연관관계의 주인이다. 말 그대로 관계에서의 Owner라는 것이다.

그래서 단방향을 어떻게 하는건데..

단방향 맵핑을 어디에 지정해야할지 고민인가?

간단하게 생각해야한다. 말 그대로 참조가 일어나는 곳에만 맵핑을 하면 된다.

User와 Post 관계에서 위에 설명한 상황이 일어난다면, User 쪽에만 단방향 맵핑을 해주면 된다. 그러나, User Post 관계에서도 다같이 사용하는 공용 게시판인 경우에는 양방향 맵핑이 더 좋을 수 도 있다는 소리이다.

한 관계에서 단방향과 양방향에 대한 정답은 없다. 이 것은 프로젝트의 규모, 확장성 등을 고려하여 선택되어야할 요소이다.

profile
You must do the things you think you cannot do

0개의 댓글