본문 바로가기

FastCampus - 한번에 끝내는 Java|Spring 웹 개발/04 JPA

JPA Ch 6 릴레이션, ERD, 1:1 - 패스트캠퍼스 챌린지 26일차

DB 하면 릴레이션을 빼놓을 수 없습니다. 이번 섹션에서는 ERD를 먼저 그리고, 거기에 맞춰 Spring Data JPA / Jakarta Persistence 가 제공해주는 릴레이션 기능을 사용해봅니다.

Entity-Relation Diagram

대학의 DB 시간에 배웠던 내용들입니다. 지금은 책이 본가에 있어서 정확한 그리기 방식을 다시 확인해보기가 어렵네요...

강의에서는 아무래도 간이 형식으로 진행한 것 같습니다.

  • ---+[엔티티] : 1개
  • ---|<- [엔티티] : n개
  • ---o-[엔티티] : 옵셔널 (이 경우에는 1개겠네요)

정확히는 까마귀발 형식이라고 부르는 모양이네요. 뭐 지금으로서는 대충 이해할 정도만 알면 되는 것 같습니다. ORM을 쓰고있기도 하고, 중요한 건 P.Key 랑 F.Key 이려나요?

옛날에 국비지원교육에 아무것도 모르고 체험하러 들어갔을 때 우리과(컴퓨터공학과)에서는 안 배웠던 ERD 툴 eXERD 를 타과(컴퓨터과학과) 학생들이 쓰고 있는 모습을 보고 문화 충격을 받았던 기억이 나네요... 이거 지금 기록을 보니까 제가 국비지원교육을 3학년때 체험삼아 들어가서 그렇지 저희 학과도 4학년때 해당 툴이랑 ERD를 배우는 과정이 있었습니다. (아래 링크) 국비지원교육은 원래 발등에 불이 떨어진 불쌍한 4학년들이 신청하는 거니까...

강의에서는 기존의 User와 Book 에 이어서 Author, Publisher, Review 등이 엔티티 다이어그램으로 그려졌습니다. 그리는 툴은 단순하게 draw.io 정도로. 이걸로 첫 번째 영상이 종료했습니다. 하긴 뭐 만들지 정하는게 제일 큰 일이 아니겠어요?

ERD는 대학 때 노트북을 가져가서 필기를 해서 깃헙에 올렸었는데 (지금은 아카이브 해뒀지만), 그게 남아있습니다. 퀵 리뷰가 필요한 시점이 왔네요.
https://github.com/sftblw/note-2015-01/tree/master/dbmake

리팩터링

이전 강의에서 작업했던 소스들을 깔끔하게 정리합니다.

강의에서는 부모 클래스인 BaseAuditingEntity 로 IAuditable 을 이동하는데, 어차피 Spring 의 내장 AuditingEntityListener 를 쓸거라면 굳이 인터페이스가 필요하지는 않겠죠. 사라져라!
(*저는 강의와 클래스 이름을 조금 다르게 지었습니다. BaseAuditingEntity 가 아니라 BaseEntity 였다던지 하는 방식으로요.)

그 외에도 ERD에 맞추어 필드들도 대충 정리합니다.

1:1 관계를 실습하자: BookReviewInfo

ERD를 그리고보니 1:1 관계가 없습니다. 실전(현업)에는 두 가지 케이스로 종종 볼 수 있다고 하는데요.

  • 도메인이 분리되어서 1:1이 되어버린 경우
  • 트래픽을 많이 받아서 정보를 다른 테이블로 추가한 경우 (성능 개선 목적인듯)

라고 하네요. BookReviewInfo 도 두 번째 케이스에 해당합니다. 약간 억지스럽기는 한데... Book 과 Review 가 있다면 Book 하나의 Review 정보들의 통계를 낸 값들을 보여줍니다. 실제로 구현한다면 백그라운드 워커에서 접속이 많지 않은 시간대에 갱신을 한다던가 할 수 있겠네요 (요건 제 생각). 그래서... BookReviewInfo 테이블은 이름만 봐선 Review 테이블과 관계가 있을 것 같아보이지만 엔티티 관계 상으로는 전혀 관계가 없습니다.

[ Book ] +---+ [ BookReviewInfo ]    관계없음    [ Review ]

이게 소유하는 쪽이 +던가? 에라 모르겠다 그냥 그러려니 해주세요

BookReviewInfo 와 대응하는 Repo 를 만들어줍니다. 테스트도요. 지금 보니까 이름을 BookReviewStat 이라고 정할걸 그랬다 싶네요

생략
public class BookReviewInfo extends BaseAuditingEntity {
생략
// 이렇게 구현하지 않습니다. 다음 단락에서 설명
//    private Long bookId;
평점 등 생략
}
interface BookReviewInfoRepo: JpaRepository<BookReviewInfo, Long>
@SpringBootTest
internal class BookReviewInfoRepoTest {
    @Autowired private lateinit var reviewRepo: BookReviewInfoRepo
    @Autowired private lateinit var bookRepo: BookRepo

    private fun givenBook(): Book = bookRepo.saveAndFlush(Book().apply {
            category = "문학"
    })

    private fun createReviewInfo(book: Book) = BookReviewInfo().apply {
        this.book = book; 더미 평점 적용 생략
    }

    @Test
    fun crudTest() {
        val reviewInfo = createReviewInfo(givenBook())
        val createdReviewInfo = reviewRepo.saveAndFlush(reviewInfo)
        println(reviewRepo.findById(createdReviewInfo.id))
    }
}

테스트 코드에서는 괜시리 하나를 넣어서 그것만으로 다시 조회합니다. 이렇게 했을 때 다음 단락에서 배우는 @OneToOne 에 의해 만들어지는 쿼리의 차이점들을 보기 위해서입니다.

@OneToOne (* jakarta persistence)

JPA에서 다른 테이블을 1:1로 매핑할 때에는 @OneToOne 을 필드에 장식합니다. 알아서 Book 이 들어오게 됩니다. 물론 우리가 수동으로 만들었어야 했을 DB상의 book_id 칼럼도 추가가 되구요. 이런 게 ORM의 편의성 아니겠어요?

public class BookReviewInfo 생략{
생략
    @OneToOne(optional = true) // 기본값이 true 입니다.
    private Book book;
생략
}

@OneToOne 의 optional 은 기본이 true 인데, not null 여부를 결정하기도 하고, 일부 데이터 작업에서 SQL이 달라지게 됩니다. 테스트에서 이걸 확인하려고 ID로 조회하는 코드를 만들었었죠.

println(reviewRepo.findById(createdReviewInfo.id))

optional 이 true (기본값) 이면, n개를 구하면 SQL에는 left outer join 으로 나옵니다. from 구문의 왼쪽 테이블을 기준으로, 오른쪽 테이블의 값은 없으면 null 일 수 있겠다는 의미겠죠? SQL 참 오랜만이야 ㅋㅋ

아, 쿼리에서 "AS"가 없이 테이블의 이름을 alias로 잡고 있으므로 저처럼 헷갈리지는 않으시길 바랍니다. pgsql 을 쓴다면 AS 는 쓰는 게 좋다네요.
https://stackoverflow.com/a/4164675
https://stackoverflow.com/a/32846600

select
    생략
from
    book_review_info bookreview0_ 
left outer join  # <<<<<
    book book1_
        on bookreview0_.book_id = book1_.id
where
    bookreview0_.id=?

optional 이 false (수동 설정) 이면, inner join 으로 나옵니다. 어차피 둘 다 있을거니까 굳이 힘들게 한 쪽이 비어있을 수 있는 outer join 을 할 필요가 없죠.

select
    생략
from
    book_review_info bookreview0_ 
inner join # <<<<<
    book book1_ 
        on bookreview0_.book_id=book1_.id 
where
    bookreview0_.id=?

강의에서는 진행하지 않았었는데, 저는 findAll() 쿼리도 실행해봤습니다. 이렇게 실행하면,

println(reviewRepo.findAll())

재미있는 일이 일어납니다.

Hibernate: 
    select
        bookreview0_.id as id1_1_,
        생략
        bookreview0_.book_id as book_id6_1_, # <<<<<<
        생략
    from
        book_review_info bookreview0_
Hibernate: 
    select
        book0_.id as id1_0_0_,
        생략
    from
        book book0_ 
    where
        book0_.id=?

아예 Join 이 일어나지 않습니다. 앞쪽 쿼리에서 book_id 를 조회하는 걸로 봐서, 뒤의 쿼리에서 해당 ID에 맞는 것들만 조회해서 소프트웨어적으로 합치는 것 같습니다.

반대쪽 테이블에서도 매핑
: @OneToOne(mappedBy=프로퍼티이름)

관계를 소유하고 있는 테이블이 아닌 반대쪽 테이블에서도 연관된 엔티티를 확인해보고 싶을 수 있습니다. 이 때 @OneToOne() 의 속성 중 mappedBy 를 사용합니다.

생략
public class BookReviewInfo 생략 {
생략
    @OneToOne(optional = false, mappedBy = "뭘 넣으면 될지 맞춰보세요")
    private Book book;
생략
}

이 상태로 돌리면 아래와 같은 에러가 나옵니다.

Unknown mappedBy in:
	패키지명.BookReviewInfo.book,
referenced property unknown:
	패키지명.Book.(위의 칸에 넣은 이름)

장식하고 있는 Book 의 필드 이름이네요.

생략
public class Book 생략 {
생략
	@OneToOne
    private BookReviewInfo bookReviewInfo;
}

필드 이름은 bookReviewInfo 니까 저걸 넣어주면 되겠습니다.

생략
public class BookReviewInfo 생략 {
생략
    @OneToOne(optional = false, mappedBy = "bookReviewInfo")
    private Book book;
생략
}

mappedBy 속성이 있으면 DDL 에서 아예 foreign key 가 사라지게 됩니다. 관계 정보를 그 쪽 테이블에서 가져가는 거죠.

Hibernate: 
    
    create table book (
       id bigint generated by default as identity,
        생략
        book_review_info_id bigint,
        생략
    )
Hibernate: 
    
    create table book_review_info (
       id bigint generated by default as identity,
        생략
        # << book_id 가 없습니다.
    )

양쪽 다 mappedBy 가 없으면 당연하지만 오류가 납... 은 아니고 거기에 둘 다 not null (optional=false) 이면 insert 에서 터집니다.

not-null property references a null or transient value

아, 그리고 이렇게 한 경우 잊지 말고 한 쪽에는 @ToString.Exclude 를 넣어주세요. 상호 참조다보니까 toString 으로 직렬화하려다 stack overflow 가 발생합니다.

java.lang.StackOverflowError
생략
public class Book 생략 {
    @OneToOne(mappedBy = "book")
    @ToString.Exclude
    private BookReviewInfo bookReviewInfo;
}

강의에서 빼먹은 거 없지....??

IntelliJ 에서 확인해보기는 실패

SQL 문을 보면서 한 오해 때문에 (위에서 언급) @OneToOne 이 별도의 관계 테이블을 만든다고 착각했고, 그래서 IntelliJ 의 데이터베이스 탭으로 DB를 뜯어보려고 했습니다. 결론부터 말하자면 실패했습니다.

H2 데이터베이스를 인메모리 모드로 실행하면 해당 JVM에서밖에 접근할수가 없습니다. 테스트 코드를 실행하면 /h2-console 이 열리지 않는 거 같구요. 그렇다면 아래처럼 프로퍼티를 추가해서 하이브리드 모드 혹은 파일 모드로 DB를 서버를 실행해야 하는데...

spring:
  생략
  jpa:
    생략
    database-platform: org.hibernate.dialect.H2Dialect
  datasource:
    url: jdbc:h2:file:./test;AUTO_SERVER=TRUE
    username: sa
    password: password
    driver-class-name: org.h2.Driver

데이터베이스의 초기화에서 막혀서 안 되더군요. 신뢰할 수 있는 출처를 찾는다고 하다가 오기가 생겨서 이렇게 되었습니다. 뭐 다음에 필요할 때 다시...

찾아본 링크들

"DB 설정이 제일 번거로웠어요" (국영수 잘하는 풍)

본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성되었습니다.
패스트캠퍼스: https://bit.ly/37BpXiC

#패스트캠퍼스 #패캠챌린지 #직장인인강 #직장인자기계발 #패스트캠퍼스후기 #한번에끝내는JavaSpring웹개발마스터초격차패키지Online

늦었다 zara

내일 주말이라구 아주 ㅋㅋ