연관관계 매핑🌺

예지성준·2024년 7월 24일

스프링부트

목록 보기
5/6
post-thumbnail

연관 관계 매핑

  • 엔티티는 대부분 다른 엔티티와 연관 관계를 맺고있다.

  • JPA에서는 엔티티에 연관 관계를 매핑해두고 필요할 때 해당 엔티티와 연관된 엔티티를 사용하여 좀 더 객체지향적으로 프로그래밍 할 수 있도록 도와준다.

  1. 일대일(1:1) : @OneToOne

  2. 일대다(1:N) : @OneToMany (다대일 관계가 있어야 일대다 관계가 성립한다) - ManyToOne에 종속적인 애노테이션이다.

  • 연관관계 주인 설정 -> 관계의 주인의 외래키 쪽
  • mappedBy
  1. 다대일(N:1) : @ManyToOne

  2. 다대다(N:M) : @ManyToMany ex) 하나의 해시태그에 여러개의 게시글

ex) 다대일 관계
한명의 회원이 여러개의 게시글을 쓴다 가정

게시글(자식) - 회원(부모)
(BOARD_DATA테이블 - MEMBER테이블)

공통적인 값(ex. 회원번호)으로 조인 해야한다.

  • FOREIGN KEY: 다른 테이블의 기본키를 참조하는 키, 참조 무결성 제약조건
    부모 레코드에 자식 레코드가 있는 경우 부모 레코드 삭제 불가
  • 외래키 제약조건: 특정 테이블에서 PRIMARY KEY 제약 조건을 지정한 열을 다른 테이블의 특정 열에서 참조하겠다는 의미로 지정

외래키는 [게시글쪽(BOARD_DATA) - Many]에 존재한다.
보통은 많이 있는 쪽[Many]에 외래키가 존재함

관계를 바꿀 수 있는 주체는 자식

Many쪽

  • 외래키를 가지고 있다.
  • 자식 테이블
  • 연관관계의 주인

One쪽

  • 부모 테이블

다대일 예시

🔸BOARD_DATA 엔티티 클래스

@Data
@Builder
@Entity
@NoArgsConstructor @AllArgsConstructor
public class BoardData extends BaseEntity { //자동으로 날짜와 시간 가져옴
    @Id @GeneratedValue
    private Long seq;

    @ManyToOne
    //기본적으로 외래키가 엔티티명_기본키 기준으로 만들어진다 - member_seq
    private Member member; //다대일 관계에서 게시글쪽이 Many라 게시글쪽에 ManyToOne 정의

    @Column(nullable = false) //필수로 Notnull 제약조건 (varchar2 기본값 크기는 255자)
    private String subject;

    @Lob
    private String content;
}

회원 한명은 여러 게시글을 작성할 수 있으므로 @ManyToOne 애노테이션을 이용하여 다대일 관계로 매핑

테이블에 외래키 생성

🔽외래키 부여 sql쿼리문도 실행되었다.🔽

  • 외래키명 바꾸기

@JoinColumn: 조인되는 컬럼의 이름을 변경할때

🔹 JpaRepository

  • QueryDslPredicateExecutor를 함께 상속 -> 기존 Repository 메서드에 Predicate가 매개변수인 메서드가 추가된다.

*One(Member)엔티티가 계속 영속성 상태에 유지 되야하기때문에 Transaction처리를 해줘야한다.

✅ 테스트1

...
@Test
    void test1(){
        BoardData item = boardDataRepository.findById(1L).orElse(null); //조회

        System.out.println(item);
    }

조인 쿼리 생성되었다!

👩‍🏫inner조인이 아닌 left조인을 쓴 이유?
-> inner조인(교집합)일경우 member쪽에 null이면 데이터가 나오지 않기때문에 left조인(왼쪽 데이터 전부, 오른쪽 데이터는 있으면 나오고 없으면 null)을 기본으로 만든다.

  • 회원정보 조회
 @Test
    void test1(){
        BoardData item = boardDataRepository.findById(1L).orElse(null); //조회
        System.out.println(item);

        Member member = item.getMember(); //게시글을 작성한 회원
        System.out.println(member);
    }


일대다(@OneToMany): Member - BoardData
다대일(@ManyToOne): BoardData - Member

일대다: OnetoMany는 ManyToOne을 바탕으로 정의되기 때문에 다대일 관계가 정의되어있어야 한다.

🔸Member 엔티티 클래스

관계의 주인 명시해줘야함 (Many 쪽 - BoardData의 Member(외래키))
BoardData 엔티티 ⬇

외래키가 부여되는 쪽 -> member을 MappedBy에 정의해줘야함

🔽👩‍🏫🔽

  • 회원을 바탕으로 게시글 조회하기
 @Test
    void test2(){
        Member member = memberRepository.findById(1L).orElse(null);
        List<BoardData> items = member.getItems();
        items.forEach(System.out::println);
    }

회원번호로 게시글 조회 쿼리는 수행됨

하지만 값은 나오지 않고 있다... -> 지연로딩
StackOverflowError

🔹 순환 참조 문제

  • lombok의 toString() 함께 사용시 순환 참조 문제 발생 가능성
    • toString()을 구성할때 getter메서드를 사용해서 구성하기 때문

Member

boardData

롬복은 toString을 getter메서드를 통해서 출력한다.

member에서 items를 출력할때(getItems) -> BoardData 엔티티 member을 출력하는데 toString메서드를 출력한다 -> getMember()가 호출 -> 또 toString 출력된다 -> List<BoardData> items 출력 (getItems)-> 또 toString() 호출 -> getMember()호출... 무한반복

  • 해결 방법?
    • toString을 멤버 변수를 직접 출력하는 것으로 직접 정의
    • @ToString.Exclude -> ToString 포함 배제
    • @ToString.Include -> ToString 포함
      한쪽을 연결 배제시키기

회원쪽 데이터에서 게시글 데이터는 항상 필요한 필수데이터는 아님 - 필요할때는 마이페이지 정도?
필요도로 봣을때 member쪽에 items를 배제하는게 좋음

  • Member쪽 ToString 배제시키기

🔽게시글 데이터 조회 가능🔽


일대일(@OneToOne)
회원1 - 프로필1

회원쪽에서 프로필을 확인하는 경우가 많음
회원이 프로필 정보를 참조하도록!
외래키는 회원쪽에 있는것이 적합하다.

🔸Member 엔티티 클래스

memberProfile 테이블 생성됨

1:1
모든 회원이 한개만 가지고 있어야하기 때문에 unique제약조건이 걸려있다.

🔹 JpaRepository

✅ 테스트

  • 회원 조회 후 결과를 가지고 프로필 조회
    @Test
    void test1(){
        Member member = memberRepository.findById(1L).orElse(null);
        System.out.println(member);
    }

    @Test
    void test1(){
        Member member = memberRepository.findById(1L).orElse(null);
        MemberProfile profile = member.getProfile();
       // System.out.println(member);
        System.out.println(profile);
        //프로필 조회
    }
  • 프로필에서 회원 조회

OneToOne에도 한쪽에 관계의 주인이 존재하는데 주인은 외래키가 존재하는쪽 Member쪽임, 프로필 쪽에서도 회원을 조회 하도록 하려면 mappedBy로 관계의 주인을 명시해줘야한다.

외래키가 생성되지는 않음

이것도 양방향 조회시 순환참조가 발생한다.

@ToString.Exclude -> ToString 포함 배제로 stackoverflow 해결해주기

관계의 주인은 회원쪽이기 때문에 프로필쪽에서 배제해주자!


다대다 관계 (@ManytoMany)

  • 중간 테이블 하나 생성
    연결시 중간 테이블이 만들어진다.
  • 외래키가 만들어지지 않는다.

BoardData - HashTag

게시글 1 - 태그1 태그4
게시글 2 - 태그1 태그2
게시글 3 - 태그3 태그4

게시글에도 여러 태그가, 태그 한개에도 여러 게시글이 존재

테이블 이름은 관계의 주인이 테이블명 앞에 온다.

게시글이 더 중요! 게시글을 관계의 주인으로 ~

🔸HashTag엔티티 클래스

  • mappedBy로 관계의 주인을 명시

🔸BoardData엔티티 클래스

중간 테이블 생성됨

각 테이블의 기본키로 테이블이 생성된다.
엔티티명_기본키명

🔹 JpaRepository

✅ 테스트

  • 해시태그 만들고 동작확인

DB에 값 보기 위해 transactional 제거해줌

중간테이블

✅ 테스트2

  • 게시글 바탕으로 태그 조회

Transactional과 em.clear 추가

    @Test
    void test1(){
        BoardData item = boardDataRepository.findById(1L).orElse(null);
        List<HashTag> tags = item.getTags();
        tags.forEach(System.out::println);
    }

  • 태그1개에서 게시글 조회

...
...

ManyToOne

테이블 한번 조회할때 연관되어있는 모든 테이블을 조회하는건 불필요하다. 모든 엔티티의 정보가 필요하지X, 주요 엔티티 정보만 있으면 됨

조인이 많아질수록 성능이 떨어진다.

지연 로딩 전략을 권장함 -> 필요할때 쿼리 쓰도록


엔티티 매핑시 방향성

  • 테이블에서 관계는 항상 양방향이지만, 객체에서는 단방향과 양방향이 존재합니다.
    • 단방향
    • 양방향

지연로딩

1. FetchType.EAGER

즉시로딩 - 각 엔티티를 처음부터 join

2. FetchType.LAZY

지연로딩 - 처음에는 현재 엔티티만 조회, 다른 매핑된 엔티티는 사용할때만 2차 쿼리를 실행한다.

필요할때만 조회 하도록!

  • 글로벌 전략 지연로딩 -> 필요할때만 즉시 로딩 전략으로 사용!!

  • ManyToOne은 기본 전략으로 즉시로딩 전략이 기본값으로 되어있다.

성능을 위해서 지연로딩으로 바꿔주자

  • oneToMany의 기본 전략은 지연로딩!!

  • @Transactional 애노테이션과 함께 많이 사용한다.
    지연로딩에선 영속성이 유지되어야지 문제가 생기지 않기때문임

지연로딩 전략을 사용하면

게시글만 조회하고 회원쪽은 필요할때 2차쿼리 형태로 실행된다

Member엔티티 클래스 OneToOne(기본값: 즉시로딩) 지연로딩 전략 적용

지연로딩 적용 후

  • 게시글 목록 조회

게시글 조회시 쿼리 수행 1번
데이터 조회할때 쿼리 수행 10 번

✅ 테스트 12

쿼리 한번 수행하는데 그 갯수만큼 쿼리가 많이 실행되고 있다..
💥N+1문제💥
성능 떨어짐ㅜㅜ


👨‍💻⭐ Fetch 조인을 통해서 필요한 엔티티만 즉시 로딩 전략을 사용하자

1) JPQL 직접 정의: @Query 애노테이션

🔹 BoardDataRepository

⭐2) @EntityGraph 애노테이션: 쿼리 메서드 사용시 정의 가능, 바로 조회 할 엔티티 명시

✅ 테스트

    @Test
    void test3(){
        List<BoardData> items = boardDataRepository.findBySubjectContaining("제목");
    }

⭐3) QueryDsl의 fetchJoin() 메서드 사용

JPAQueryFactory -> JPAQuery 객체를 만든다, 쿼리 빌딩!
반환값: JPAQuery
생성자 매개변수: EntityManager

✅ 테스트4

@Test
    void test4(){

        QBoardData boardData = QBoardData.boardData;
        //싱글톤 형태로 정적인 객체 만들어져있음

        JPAQueryFactory factory = new JPAQueryFactory(em);
        // 전용 클래스로 쿼리빌딩 할 수 있음 - Querydsl이 자동으로 만든 Q클래스로 쿼리 빌딩
        JPAQuery<BoardData> query = factory
                .selectFrom(boardData)
                .leftJoin(boardData.member) // boardData.member를 처음부터 조인
                .fetchJoin();

        List<BoardData> items = query.fetch(); //목록조회: fetch()
        items.forEach(System.out::println);

    }


📌항상 엔티티 매니저가 필요하면 빈으로 등록해서 조립된 객체 가져오도록 하자!

객체가 조립될 필요가 있는 부분 미리 조립해버리자

👩‍🏫참고)
@Lazy

  • 사용시점에 객체로 생성해주고 싱글톤으로 관리
  • 클래스명 위, 수동 등록 빈 위..

    @Test
    void test4(){

        QBoardData boardData = QBoardData.boardData;
        //싱글톤 형태로 정적인 객체 만들어져있음 - 단점) 처음 로딩될때 너무 많은 객체를 로딩하면 느려진다.

       // JPAQueryFactory factory = new JPAQueryFactory(em);
        // 전용 클래스로 쿼리빌딩 할 수 있음 - Querydsl이 자동으로 만든 Q클래스로 쿼리 빌딩
        JPAQuery<BoardData> query = queryFactory
                .selectFrom(boardData)
                .leftJoin(boardData.member) // boardData.member를 처음부터 조인
                .fetchJoin();

        List<BoardData> items = query.fetch(); //목록조회: fetch()
       // items.forEach(System.out::println);

    }

4) @BatchSize

@BatchSize(size=10) : 최대 추출 개수를 한정함
10개까지가 max사이즈, 10개를 넘어서는 조회가 필요하다면 IN쿼리가 또 실행됨

적용 전
SELECT ... FROM BoardData
SELECT ... FROM MEMBER WHERE seq = 1L
SELECT ... FROM MEMBER WHERE seq = 2L
SELECT ... FROM MEMBER WHERE seq = 3L

적용 후
1차쿼리는 수행
SELECT ... FROM BoardData
개수만큼 in조건으로 쿼리
SELECT ... FROM MEMBER seq IN(1L,2L,3L)


selectfrom 으로 전체 조회시 영속성 상태이지만
개별로 조회시 영속성 상태가 아니다

  • 일부 데이터 조회
    • 영속성 상태 x

게시글 제목과 내용 조회

	@Test
    void test5(){
        QBoardData boardData = QBoardData.boardData;
        //낱개 조회 할때는 해당 데이터의 자료형으로 정의하면 된다.
        //두개 이상일 경우 자료형 -> tuple
//        JPAQuery<String> query = queryFactory.select(boardData.subject);
        JPAQuery<Tuple> query = queryFactory.select(boardData.subject,boardData.content).from(boardData); //낱개 데이터 조회 영속상태와 관계 없음
        List<Tuple> items = query.fetch();
        for(Tuple item:items){
            String subject = item.get(boardData.subject);
            String content = item.get(boardData.content);
            System.out.printf("subject=%s, content=%s\n",subject,content);
        }
    }

통계 데이터

 @Test
    void test6(){
        QBoardData boardData = QBoardData.boardData;
        JPAQuery<Long> query = queryFactory.select(boardData.seq.sum())
                .from(boardData);

        long sum = query.fetchOne();
        //fetchOne(): 하나만 가져옴, fetchFirst(): 여러개중 한개만 가져옴
        System.out.println(sum);
    }
  • fetchOne(): 하나만 가져옴, fetchFirst(): 여러개중 한개만 가져옴

seq 1~10

✅ 테스트

@Test
    void test7(){
        QBoardData boardData = QBoardData.boardData;
        JPAQuery<BoardData> query = queryFactory.selectFrom(boardData)
                .leftJoin(boardData.member)
                .fetchJoin()
                .where(boardData.seq.in(2L,3L,4L));//where절에는 조건식을 넣을 수 있음
                //게시글 번호에서 2, 3, 4 번만 조회시 in 조건 사용
        //booleanExpression(Predicate 구현클래스)형태로 반환값이 나온다. - 상위 인터페이스: Predicate
        
        List<BoardData> items = query.fetch();
        items.forEach(System.out::println);
    }


👩‍🏫참고
Q 클래스
조건식 메서드를 가지고 사용
eq: =(같다)
lt: < (작다)
loe: <=(작거나 같다)
gt: > (크다)
goe: >= (크거나 같다)

✅ 테스트

seq 2,3,4인 동일 출력 결과 나온다!

✅ 테스트

시작 위치, 조회할 개수 추가되어있음

  • 정렬 조건 추가


영속성 전이

  • 부모 엔티티의 영속성 변화 상태를 자식 엔티티에 전달
    • 부모는 One에 해당하고 자식은 Many에 해당
    • 부모의 상태를 자식이 따라감

1. CASCADE 종류

1) PERSIST

2) MERGE

3) REMOVE: 부모 엔티티가 삭제될때 자식 엔티티도 삭제되지만 -> 동작은 자식 엔티티가 삭제되고 부모 엔티티가 삭제된다.(부모에 연관된 자식이 있으면 먼저 삭제가 안되기 때문)

4) REFRESH

5) DETACH

6) ALL

🔸 부모 엔티티 => Member

제약조건 CASCADE ON DELETE는 아님

  • 회원데이터 가져오고 회원 지우기 -> 게시글 삭제되는지!

✅ 테스트

@SpringBootTest
@Transactional
@ActiveProfiles("test")
public class Ex13 {

    @Autowired
    private MemberRepository memberRepository; //회원
    @Autowired
    private BoardDataRepository boardDataRepository;

    @Autowired
    private JPAQueryFactory queryFactory;     //빈으로 등록한 완성된 객체

    @PersistenceContext
    private EntityManager em;

    @BeforeEach
    void init(){
        em.setFlushMode(FlushModeType.AUTO);

        Member member = Member.builder()
                .email("user01@test.org")
                .password("12345612")
                .userName("사용자01")
                .authority(Authority.USER)
                .build();

        memberRepository.saveAndFlush(member);

        //게시글 작성 - member 엔티티를 꼭 추가시켜야함 외래키로 회원 번호가 들어간다
        List<BoardData> items = IntStream.rangeClosed(1,10)
                .mapToObj( i -> BoardData.builder()
                        .subject("제목"+i)
                        .content("내용"+i)
                        .member(member)
                        .build()).toList();

        boardDataRepository.saveAllAndFlush(items);

        em.clear();//테스트 할때는 영속성 컨텍스트의 모든 엔티티를 비워주는게 좋음
    }

    @Test
    void test1(){
        Member member = memberRepository.findById(1L).orElse(null);
        //회원을 지우면 해당되는 게시글도 제거되도록
        memberRepository.delete(member); //사용자01
        memberRepository.flush();
    }
}

자식 데이터 삭제 다 이뤄지고 나서 부모 데이터 삭제가 이루어짐

2. 고아 객체 제거하기

  • @OneToMany 애노테이션에 orphanRemoval=true 옵션을 추가

    • 부모를 잃은 자식을 제거해줌,,
    • CascadeType.PERSIST 설정을 해줘야 orphanRemoval가 동작함

    @Test
    void test2(){
        Member member = memberRepository.findById(1L).orElse(null);
        
        List<BoardData> items = member.getItems();
        //0번과 1번객체를 고아상태로 만들기
        items.remove(0);
        items.remove(1); //db에는 존재하지만 객체로는 존재하지 않는 상태
        
        memberRepository.flush(); //제거됨
    }

profile
꽁꽁 얼어붙은 한강 위로 😺

0개의 댓글