Develop/Web

Thrift 뽀개기 | Cracking Thrift

보통 서버 통신을 할 때 http 를 떠올리기 쉽다. 좀 더 가볍고, 다른 언어간의 호환이 편했으면 하는 이슈와, 당시 facebook에서 만들어서 인기가 많았다는 이유로 thrift를 서버간의 통신에서 사용하기도한다. 1. Thrift 정의하는 방법https://thrift.apache.org/ Apache Thrift - HomeThe Apache Thrift software framework, for scalable cross-language services development, combines a software stack with a code generation engine to build services that work efficiently and seamlessly between C++,..

Thrift 뽀개기 | Cracking Thrift

728x90

보통 서버 통신을 할 때 http 를 떠올리기 쉽다.
좀 더 가볍고, 다른 언어간의 호환이 편했으면 하는 이슈와, 당시 facebook에서 만들어서 인기가 많았다는 이유로 
thrift를 서버간의 통신에서 사용하기도한다.

 

1. Thrift 정의하는 방법

https://thrift.apache.org/

 

Apache Thrift - Home

The Apache Thrift software framework, for scalable cross-language services development, combines a software stack with a code generation engine to build services that work efficiently and seamlessly between C++, Java, Python, PHP, Ruby, Erlang, Perl, Haske

thrift.apache.org

먼저 thrift사용을 위해 thrift를 설치한다

$ brew update
$ brew install thrift

thrift 파일을 작성한다 (IDL). 해당 파일에 작성된 대로 해당 서버에서 thrift로 통신할 수 있는 요청 응답 포맷이 정의가 되게 된다.
IDL 작성법과 예시는 공식문서에 나와있는 example이 가장 좋은 것 같다.

https://thrift.apache.org/docs/types

 

Apache Thrift - Thrift Type system

Thrift Types The Thrift type system is intended to allow programmers to use native types as much as possible, no matter what programming language they are working in. This information is based on, and supersedes, the information in the Thrift Whitepaper. T

thrift.apache.org

 

기본 타입리스트

  • 기본타입 : bool, byte, i16, i32, i64, double, string (숫자는 전부 signed 타입이다.)
  • 특별 타입 : binary - 인코딩되지 않은 바이트 시퀀스
  • struct : oop 언어의 클래스와 동일하나 상속은 없다.
  • 컨테이너 : list, set, map<type1, type2>

위에서 말한 타입을 기반으로 https://thrift.apache.org/docs/idl 을 추가로 정의할 수 있다.

서비스

위에서 정의한 type들을 사용하여 정의된다. 인터페이스를 정의하는 것과 유사하고, 이렇게 정의된 service를 thrift 컴파일러로 컴파일을 하게되면, thrift가 정의한 서비스대로 요청 응답을 할 수 있는 클라이언트 코드 및 서버 코드를 작성해준다.

네임스페이스

작성한 thrift IDL 을 컴파일 했을 때 어떤언어로 내보낼지 설정한다. 문서를 보면 내보내기 가능한 언어 리스트가 적혀져있다. 다양한 언어로 thrift가 생성이 가능하기 때문에, 서로 다른 언어로 짜여진 서버간의 통신에 사용할 때 이점이 있다고 보는 것 같다.

 

사용법 예시를 위해 thrift DML을 아래와 같이 작성을 해보았다.

namespace java com.jyami.java.thrift.service
namespace py com.jyami.python.thrift.service

struct CommonResponse {
  1:required i32 status,
  2:optional binary bsonBody
}

struct RequestWithFlag {
  1:required binary bsonData,
  2:required bool flag
}

service PostService {

  void ping()

  CommonResponse read(1:RequestWithFlag request),

  CommonResponse save(1:binary bsonData),

  CommonResponse remove(1:binary bsonData)
}

 

2. thrift 컴파일로 코드 생성

thrift --gen <language> <Thrift filename>

 

위 명령어를 java로 실행했을 때 ( thrift --gen java app.thrift ) 위와 같은 java 파일이 자동생성되었다.

namespace에 python 도 짜두었으니, python 도 마찬가지로 생성이 가능하다.

 

3. Client, Server 코드 생성

https://thrift.apache.org/tutorial/java.html

 

Apache Thrift - Java

Java Tutorial Introduction All Apache Thrift tutorials require that you have: The Apache Thrift Compiler and Libraries, see Download and Building from Source for more details. Generated the tutorial.thrift and shared.thrift files: thrift -r --gen java tuto

thrift.apache.org

사실 공식문서에 너무 잘되어있다. java로 Client, Server 전부 작성해보았다. 
결국은 위에서 thrift gen으로 생성한 java 파일안의 클래스와 메서드들을 사용하는게 포인트이며,
Thrift 통신을 하기위한 기본적인 구현체인 TSocket, Ttransport 등등은 아래와 같이 thrift 라이브러리를 사용하면 받을 수 있다.

implementation("org.apache.thrift:libthrift:0.18.0")

3-0. 세팅

편의를 위해 client와 server를 한 디렉토리에 두었다.

하지만 핵심은 thrift 폴더 아래에 있는 app.thrift에서 생성된 java 코드 안에 있는 각종 클래스나 메서드와 같은 구현체를

비즈니스 로직이 있는 실제 클라이언트 혹은 서버의 main 코드에서 직접 사용하여 thrift로 통신을 할 수 있다는 것이다.

intellij gradle project 기준에서, 위와같이 main폴더와 thrift폴더 사이의 코드들이 서로 import 해서 사용하고, 이를 개발환경에서도 intellij 상에서 indexing을 진행한 후 사용하기 위해, 여러가지 세팅을 해주었다.

## build.gradle.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    kotlin("jvm") version "1.7.10"
}

group = "org.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    testImplementation(kotlin("test"))
    implementation("org.apache.thrift:libthrift:0.18.0")
    implementation("javax.annotation:javax.annotation-api:1.3.2")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.14.1")
    implementation("de.undercouch:bson4jackson:2.13.1")
    implementation("org.slf4j:slf4j-api:1.7.25")
}

tasks.test {
    useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
    kotlinOptions.jvmTarget = "1.8"
}


sourceSets {
    this.getByName("main"){
        this.java.srcDir("src/thrift/gen-java")
    }
}

 

intellij 로 실행할 때 thrift 폴더도 sourceDirectory로 인지하고 해당 폴더 안에 있는 자바 코드를 사용하기 위해 build.gradle에 sourceSets 를 설정해주었다. 또한 intellij 상에서 해당 폴더를 실제 sources로 인식할 수 있게 project structure(command + ;)에서 modules 안에 source로 잘 들어갔는지도 확인해준다.

 

3-1. Client 코드 작성

thrift gen으로 생성된 파일들의 실제 내용을 보면 알겠지만 RequestWithFlag, CommonRespones와 같은 파일은 요청 응답을 위해 정의한 구조체의 클래스만 들어있다. 즉 app.thrift에서 struct로 정의한 객체들의 파일이다.
실제로 서버로 호출을 하기위한 주된 역할을 하는 java파일은 PostService이며, Client와 Server 각각의 구현체 작성을 위한 코드는 전부 여기있다.

가이드에 따라 Client 코드를 짜고, 내 나름대로 리팩토링을 한다.

package com.jyami.sample.client

import com.jyami.java.thrift.service.CommonResponse
import com.jyami.java.thrift.service.PostService
import com.jyami.java.thrift.service.RequestWithFlag
import org.apache.thrift.protocol.TBinaryProtocol
import org.apache.thrift.transport.TSocket
import org.apache.thrift.transport.TTransport
import java.nio.ByteBuffer

fun main() {

    val sampleByte = ByteBuffer.wrap("hello".toByteArray())

    connectWithServer { transport ->
        val client = makeClient(transport)
        client.ping()
        val readResponse: CommonResponse = client.read(RequestWithFlag(sampleByte, false))
        println("read response $readResponse")
        val saveResponse: CommonResponse = client.save(sampleByte)
        println("save response $saveResponse")
        val removeResponse: CommonResponse = client.remove(sampleByte)
        println("remove response $removeResponse")
    }

}

fun connectWithServer(process: (transport: TTransport) -> Unit) {
    val transport = TSocket("localhost", 9090)
    transport.open()
    process.invoke(transport)
    transport.close()
}

fun makeClient(transport: TTransport): PostService.Client = PostService.Client(TBinaryProtocol(transport))

build.gradle에 넣어준 thrift 라이브러리에 들어있는 것들 TBinaryProtocol, TSocket, TTransport 와 같은 객체들은 전부
thrift를 이용한 socket통신을 하기위해 apache에서 제공하는 추상화된 코드들이다. 해당 객체들을 사용하여 서버와 연결지을 커넥션을 생성하고, thrift.gen으로 만들어진 java 파일들 CommonResponse, PostService, RequestWithFlag를 사용하면 쉽게 클라이언트 코드를 작성할 수 있다.

connectWithServer(), makeClient() 메서드들 각각을 각각 thrift에서 실제 socket 커넥션을 맺는 역할, 호출을 위한 client 객체를 만드는 역할로 각각 코드를 작성해주었다. 

connectWithServer로 서버와 connection을 맺고, 해당 서버에 요청을 보낼 내용들은 makeClient로 생성된 PostService.Client객체에 정의된 메서드들을 사용한다. 해당 메서드들은 app.thrift에서 정의한 service들의 메서드임을 알 수 있고, 서버에서 해당 service들에 대한 비즈니스 로직을 채워서 client에 응답을 주면 client는 해당하는 응답에 따라 클라이언트 자체 뷰를 그리기도 하고 print를 하기도하는 등 클라이언트의 요구사항을 채워준다.

 

3-2. Server 코드 작성

package com.jyami.sample.server

import com.jyami.java.thrift.service.PostService
import org.apache.thrift.server.TServer.Args
import org.apache.thrift.server.TSimpleServer
import org.apache.thrift.transport.TServerSocket

fun main(){
    val serverTransport = TServerSocket(9090)
    val postServiceImpl = PostServiceImpl()
    val server = TSimpleServer(Args(serverTransport).processor(PostService.Processor(postServiceImpl)))

    println("Starting the simple server")
    server.serve()
}

 

서버 코드도 클라이언트와 마찬가지로 thrift 라이브러리를 사용하여 ServerSocket을 열어준다. TServerSocket, TSimpleServer(http2와 같은 secure 연결이 되어있지 않은 서버) 를 활용하여 thrift 통신을 하는 서버를 구동시킨다. 

이때 해당 서버에 요청이 들어오면 어떤 처리를 할 것 인지를 지정하는 것을 .processor() 메서드로 지정하는데.
이때 처리할 Processor 구현체는 아까 app.thrift에서 생성한 PostService안에 생성이 되어있다. 

 

 

package com.jyami.sample.server

import com.jyami.java.thrift.service.CommonResponse
import com.jyami.java.thrift.service.PostService
import com.jyami.java.thrift.service.RequestWithFlag
import java.nio.ByteBuffer

class PostServiceImpl : PostService.Iface{

    override fun ping() {
        println("ping")
    }

    override fun read(request: RequestWithFlag): CommonResponse {
        println("server : read request")
        return CommonResponse()
    }

    override fun save(bsonData: ByteBuffer?): CommonResponse {
        println("server : save request")
        return CommonResponse()
    }

    override fun remove(bsonData: ByteBuffer?): CommonResponse {
        println("server : remove request")
        return CommonResponse()
    }

}

 

 

 

실제 해당 서버가 어떤 요청응답을 받는지에 대한 인터페이스는 PostService.Iface 를 이용하면 되기 때문에, 실제 서버에 작성한 프로토콜 명세에 대한 요청을 어떻게 처리할지만 정해주면 된다.
PostService.Iface를 구현한 구현체에서 각각의 프로토콜에 대한 비즈니스 로직을 길게 넣어주고 thrift파일에 정의한 프로토콜 명세에 맞게 요청값 응답값을 잘 넣어준다. 
PostService.Iface의 구현체 즉, 각 프로토콜의 동작이 정의된 비즈니스 로직을 담은 PostServiceImpl을 processor() 메서드 안에 넣어주면, 해당 thrift 서버의 동작을 정의할 수 있는 것이다.

 

3-3.  실행

위 코드대로 실제로 서버를 구동시키고 클라이언트가 2개 있다는 가정하에 Client 코드를 2번 가동시키면

서버 로그

클라이언트로부터 ping, read, save, remove, ping, read, save, remove 요청을 받아 처리하였다.

클라이언트 로그

서버에 read, save, remove 요청을 보내고 해당하는 응답을 받아 해당 응답을 직접 출력하였다.

 

3-4. 추가 잡담 - 코드 의도

thrift.app 파일에 int, long, string과 같은 다양한 타입을 정의할 수 있지만, struct에 굳이 binary를 사용한 이유는 thrift로 생성된 객체를 직접 바로 사용하고 싶지 않아서이다. 

만약 thrift로 생성된 요청 응답 객체를 직접 사용한다면, 요구사항에 따라 request에 param이 하나가 추가가 되야할 경우 그때마다 thrift.app 파일을 변경하고 그에따라 thrift gen을 이용해서 java 파일을 생성하고 생성된 java 파일에서 변경된 클래스명이나 파라이터 명에 따라 main 코드를 고쳐야한다. 

그러나 내가 원하는 요청응답 객체가 있을 때 (Post(title:String, username: String)) 위 객체를 jackson을 이용하여 byinary로 파싱하여 담으면 Post 객체 자체에 대한 요구사항을 훨씬 유연하게 처리할 수 있으면서, 가장 바깥에서 통신하는 thrift 객체는 body가 binary이기 때문에 거의 변화가 없어 위에서 말한 thrift 파일 변경 > thrift gen으로 파일생성 > 이에따른 main 코드 변경이 잦지 않게 되었기 때문에 위와 같은 코드 예시를 선택하였다.

샘플코드라서 오히려 간단해서 구분이 안갈 수 있지만, 해당 샘플 코드를 기반으로 나의 의도를 담아 비즈니스로직을 구현한 예시 소스코드가 보고싶다면 아래 링크에 넣어놨다. thrift 뽀개기 끝 ~_~

https://github.com/jyami-kim/Jyami-Java-Lab/tree/master/thrift-sample

 

GitHub - jyami-kim/Jyami-Java-Lab: 💻 Jyami의 Spring boot 및 Java 실험소 💻

💻 Jyami의 Spring boot 및 Java 실험소 💻. Contribute to jyami-kim/Jyami-Java-Lab development by creating an account on GitHub.

github.com

 

When it comes to server communication, HTTP is usually the first thing that comes to mind.
However, due to the need for something lighter and better cross-language compatibility, and partly because Facebook created it and it gained a lot of popularity, 
Thrift is also used for server-to-server communication.

 

1. How to Define Thrift

https://thrift.apache.org/

 

Apache Thrift - Home

The Apache Thrift software framework, for scalable cross-language services development, combines a software stack with a code generation engine to build services that work efficiently and seamlessly between C++, Java, Python, PHP, Ruby, Erlang, Perl, Haske

thrift.apache.org

First, install Thrift to use it.

$ brew update
$ brew install thrift

Write a Thrift file (IDL). Based on what's written in this file, the request and response formats for Thrift communication on that server will be defined.
For IDL syntax and examples, the official documentation's examples are probably the best resource.

https://thrift.apache.org/docs/types

 

Apache Thrift - Thrift Type system

Thrift Types The Thrift type system is intended to allow programmers to use native types as much as possible, no matter what programming language they are working in. This information is based on, and supersedes, the information in the Thrift Whitepaper. T

thrift.apache.org

 

Basic Type List

  • Basic types: bool, byte, i16, i32, i64, double, string (all numeric types are signed.)
  • Special type: binary - a sequence of unencoded bytes
  • struct: Same as a class in OOP languages, but without inheritance.
  • Containers: list, set, map<type1, type2>

Based on the types mentioned above, you can additionally define elements described at https://thrift.apache.org/docs/idl.

Services

Services are defined using the types described above. It's similar to defining an interface. Once a defined service is compiled with the Thrift compiler, Thrift generates both client and server code that can handle requests and responses according to the defined service.

Namespaces

This specifies which language the compiled Thrift IDL should be exported to. The documentation lists all the supported export languages. Since Thrift can generate code for a wide variety of languages, it seems to be particularly advantageous when used for communication between servers written in different languages.

 

As an example, I wrote a Thrift IDL file like below.

namespace java com.jyami.java.thrift.service
namespace py com.jyami.python.thrift.service

struct CommonResponse {
  1:required i32 status,
  2:optional binary bsonBody
}

struct RequestWithFlag {
  1:required binary bsonData,
  2:required bool flag
}

service PostService {

  void ping()

  CommonResponse read(1:RequestWithFlag request),

  CommonResponse save(1:binary bsonData),

  CommonResponse remove(1:binary bsonData)
}

 

2. Generating Code with Thrift Compiler

thrift --gen <language> <Thrift filename>

 

When running the above command for Java ( thrift --gen java app.thrift ), the Java files shown above were auto-generated.

Since we also set up Python in the namespace, Python files can be generated in the same way.

 

3. Creating Client and Server Code

https://thrift.apache.org/tutorial/java.html

 

Apache Thrift - Java

Java Tutorial Introduction All Apache Thrift tutorials require that you have: The Apache Thrift Compiler and Libraries, see Download and Building from Source for more details. Generated the tutorial.thrift and shared.thrift files: thrift -r --gen java tuto

thrift.apache.org

Honestly, the official documentation is really well done. I wrote both Client and Server in Java. 
The key point is using the classes and methods inside the Java files generated by thrift gen,
and the basic implementations for Thrift communication like TSocket, TTransport, etc. can be obtained by using the Thrift library as shown below.

implementation("org.apache.thrift:libthrift:0.18.0")

3-0. Setup

For convenience, I placed the client and server in the same directory.

But the key takeaway is that the various classes and methods — the implementations generated from app.thrift inside the thrift folder —

can be directly used in the actual client or server main code where the business logic resides, enabling Thrift communication.

In an IntelliJ Gradle project setup, I configured several settings so that the code between the main folder and the thrift folder can import and use each other, and also so that IntelliJ can index and recognize them properly in the development environment. I checked in Project Structure (Command + ;) under Modules to make sure the thrift folder was properly registered as a source.

## build.gradle.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    kotlin("jvm") version "1.7.10"
}

group = "org.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    testImplementation(kotlin("test"))
    implementation("org.apache.thrift:libthrift:0.18.0")
    implementation("javax.annotation:javax.annotation-api:1.3.2")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.14.1")
    implementation("de.undercouch:bson4jackson:2.13.1")
    implementation("org.slf4j:slf4j-api:1.7.25")
}

tasks.test {
    useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
    kotlinOptions.jvmTarget = "1.8"
}


sourceSets {
    this.getByName("main"){
        this.java.srcDir("src/thrift/gen-java")
    }
}

 

I configured sourceSets in build.gradle so that IntelliJ recognizes the thrift folder as a sourceDirectory and can use the Java code inside it when running. I also verified in Project Structure (Command + ;) under Modules that the folder was properly registered as a source.

 

3-1. Writing the Client Code

If you look at the actual contents of the files generated by thrift gen, you'll see that files like RequestWithFlag and CommonResponse only contain the struct classes defined for requests and responses. In other words, they are the files for the objects defined as structs in app.thrift.
The main Java file responsible for making actual server calls is PostService, and all the code needed to write both Client and Server implementations is in there.

I wrote the Client code following the guide, and then refactored it in my own way.

package com.jyami.sample.client

import com.jyami.java.thrift.service.CommonResponse
import com.jyami.java.thrift.service.PostService
import com.jyami.java.thrift.service.RequestWithFlag
import org.apache.thrift.protocol.TBinaryProtocol
import org.apache.thrift.transport.TSocket
import org.apache.thrift.transport.TTransport
import java.nio.ByteBuffer

fun main() {

    val sampleByte = ByteBuffer.wrap("hello".toByteArray())

    connectWithServer { transport ->
        val client = makeClient(transport)
        client.ping()
        val readResponse: CommonResponse = client.read(RequestWithFlag(sampleByte, false))
        println("read response $readResponse")
        val saveResponse: CommonResponse = client.save(sampleByte)
        println("save response $saveResponse")
        val removeResponse: CommonResponse = client.remove(sampleByte)
        println("remove response $removeResponse")
    }

}

fun connectWithServer(process: (transport: TTransport) -> Unit) {
    val transport = TSocket("localhost", 9090)
    transport.open()
    process.invoke(transport)
    transport.close()
}

fun makeClient(transport: TTransport): PostService.Client = PostService.Client(TBinaryProtocol(transport))

Objects like TBinaryProtocol, TSocket, and TTransport that come from the Thrift library added in build.gradle are all
abstracted code provided by Apache for socket communication using Thrift. You create a connection to the server using these objects, and then use the Java files generated by thrift gen — CommonResponse, PostService, RequestWithFlag — to easily write client code.

I wrote the connectWithServer() and makeClient() methods to handle establishing the actual socket connection with Thrift and creating the client object for making calls, respectively. 

connectWithServer establishes the connection with the server, and the actual requests sent to the server use the methods defined on the PostService.Client object created by makeClient. You can see that these methods correspond to the service methods defined in app.thrift. When the server fills in the business logic for those services and sends responses back to the client, the client can then render its own views, print output, or fulfill whatever client-side requirements it has.

 

3-2. Writing the Server Code

package com.jyami.sample.server

import com.jyami.java.thrift.service.PostService
import org.apache.thrift.server.TServer.Args
import org.apache.thrift.server.TSimpleServer
import org.apache.thrift.transport.TServerSocket

fun main(){
    val serverTransport = TServerSocket(9090)
    val postServiceImpl = PostServiceImpl()
    val server = TSimpleServer(Args(serverTransport).processor(PostService.Processor(postServiceImpl)))

    println("Starting the simple server")
    server.serve()
}

 

Just like the client, the server code also uses the Thrift library to open a ServerSocket. It uses TServerSocket and TSimpleServer (a server without secure connections like HTTP/2) to start a server that communicates via Thrift. 

To specify what processing should happen when a request comes into the server, you use the .processor() method.
The Processor implementation used here is already generated inside PostService, which was created from app.thrift. 

 

 

package com.jyami.sample.server

import com.jyami.java.thrift.service.CommonResponse
import com.jyami.java.thrift.service.PostService
import com.jyami.java.thrift.service.RequestWithFlag
import java.nio.ByteBuffer

class PostServiceImpl : PostService.Iface{

    override fun ping() {
        println("ping")
    }

    override fun read(request: RequestWithFlag): CommonResponse {
        println("server : read request")
        return CommonResponse()
    }

    override fun save(bsonData: ByteBuffer?): CommonResponse {
        println("server : save request")
        return CommonResponse()
    }

    override fun remove(bsonData: ByteBuffer?): CommonResponse {
        println("server : remove request")
        return CommonResponse()
    }

}

 

 

 

Since the interface for what requests and responses the server handles can be obtained through PostService.Iface, all you need to do is define how to process the requests for the protocol specs written on the server.
In the implementation of PostService.Iface, you add the business logic for each protocol, and make sure the request and response values match the protocol specs defined in the Thrift file. 
The implementation of PostService.Iface — that is, the business logic where each protocol's behavior is defined — PostServiceImpl, is passed into the processor() method, which defines the behavior of the Thrift server.

 

3-3.  Running It

Following the code above, after actually starting the server and running the Client code twice (assuming there are 2 clients):

Server Log

It received and processed ping, read, save, remove, ping, read, save, remove requests from the clients.

Client Log

It sent read, save, and remove requests to the server, received the corresponding responses, and printed them out.

 

3-4. Extra Thoughts - Code Intent

While you can define various types like int, long, string in the thrift.app file, the reason I deliberately used binary in the struct is that I didn't want to directly use the objects generated by Thrift. 

If you directly use the request/response objects generated by Thrift, every time a new param needs to be added to a request due to changing requirements, you'd have to modify the thrift.app file, regenerate the Java files with thrift gen, and then update the main code to reflect any changed class names or parameter names. 

However, when I have my own desired request/response object (e.g., Post(title:String, username: String)), I can parse it into binary using Jackson and pass it along. This way, I can handle changes to the Post object itself much more flexibly, and since the outermost Thrift object's body is binary, it rarely changes. This means the cycle of modifying the Thrift file → regenerating files with thrift gen → updating main code accordingly happens much less frequently. That's why I chose this code approach.

Since it's sample code, it might actually be too simple to see the distinction clearly, but if you'd like to see the source code where I implemented business logic with my intent based on this sample code, I've put it at the link below. And that's a wrap on cracking Thrift! ~_~

https://github.com/jyami-kim/Jyami-Java-Lab/tree/master/thrift-sample

 

GitHub - jyami-kim/Jyami-Java-Lab: 💻 Jyami의 Spring boot 및 Java 실험소 💻

💻 Jyami의 Spring boot 및 Java 실험소 💻. Contribute to jyami-kim/Jyami-Java-Lab development by creating an account on GitHub.

github.com

 

댓글

Comments

Develop/JAVA

자바의 제네릭 타입 소거, 리스트에 관하여 (Java Generics Type Erasure, List) | Java's Generic Type Erasure, Regarding Lists (Java Generics Type Erasure, List)

1. 자바의 제네릭과 로타입 (Java Generics and Raw Type)public class Example{ private T member;}위와 같이 클래스 및 인터페이스 선언에 타입 매개변수 T 가 사용되면 제네릭 클래스, 제네릭 인터페이스라고 말하는데, 이때 사용된 이 클래스 Example 를 제네릭타입이라고 이야기한다.제네릭을 사용하면 로타입이라는 개념이 나오는데, 로타입은 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않았을 때를 의미한다 즉, 위 제네릭 타입Example를 Example 로만 선언하여 사용했을 경우를 말한다.public class Example { private T member; public Example(T member) { this.membe..

자바의 제네릭 타입 소거, 리스트에 관하여 (Java Generics Type Erasure, List) | Java's Generic Type Erasure, Regarding Lists (Java Generics Type Erasure, List)

728x90

1. 자바의 제네릭과 로타입 (Java Generics and Raw Type)

public class<T> Example{
	private T member;
}

위와 같이 클래스 및 인터페이스 선언에 타입 매개변수 T 가 사용되면 제네릭 클래스, 제네릭 인터페이스라고 말하는데, 이때 사용된 이 클래스 Example<T> 를 제네릭타입이라고 이야기한다.

제네릭을 사용하면 로타입이라는 개념이 나오는데, 로타입은 제네릭 타입에서 타입 매개변수를 전혀 사용하지 않았을 때를 의미한다 즉, 위 제네릭 타입Example<T>Example 로만 선언하여 사용했을 경우를 말한다.

public class Example<T> {
    private T member;

    public Example(T member) {
        this.member = member;
    }

    public static void main(String[] args) {
        Example<Integer> parameterType = new Example<>(1);
        Integer parameterTypeMember = parameterType.member;
        System.out.println(parameterTypeMember);

        Example rawType = new Example(1);
        Object rawTypeMember = rawType.member;
        System.out.println(rawTypeMember);
    }
}

위 코드는 제네릭 파라미터 타입과 로타입을 사용한 경우이다. 하지만 로타입은 사용하지말자.

제네릭의 장점은 컴파일 타임에 타입에 대한 안정성을 보장받을 수 있다는 점이다. 제네릭 타입으로 선언한 변수는 컴파일 타임에 타입 체크를 하기 때문에 런타임에서 ClassCastException과 같은 UncheckedException을 보장 받을 수 있다.

반면 아래와 같이 로타입으로 사용될 경우에는 제네릭을 사용했을 때의 안정성과 표현력이라는 장점을 발휘할 수 없기 때문에, IDE 에서도 "Raw use of parameterized class 'Example' " 라는 경고를 주는 것을 볼 수 있다.

그럼 로타입이 나오게 된 이유는 무엇일까? 로타입이 나오게 된 이유는 제네릭의 특징인 소거와 관련이 있다.

제네릭은 JDK5 에서 도입이되었다. 버그를 줄이기 위한 목적과, 다른 추상화된 타입에 대한 레이어를 추가하기 위해서이다.
이에 따라 제네릭을 도입한 JDK5는 기존의 코드를 모두 수용하면서 제네릭을 사용하는 새로운 코드와의 호환성을 유지 했어야 했다. 따라서 코드의 호환성 때문에 : 로타입의 지원 + 제네릭을 구현할 때 소거(erasure)하는 방식을 이용하였다.

 

2. 제네릭의 타입소거 (Generics Type Erasure)

소거란 원소 타입을 컴파일 타임에만 검사하고 런타임에는 해당 타입 정보를 알 수 없는 것이다. 다른 말로는 컴파일 타임에만 타입에 대한 제약 조건을 적용하고, 런타임에는 타입에 대한 정보를 소거하는 프로세스이다.

List<Object> ol = new ArrayList<Long>(); // 컴파일 에러
ol.add("타입이 달라 넣을 수 없다");

다음과 같은 상황에서 컴파일시에 타입 오류를 바로 알 수 있다 (리스트도 제네릭 타입으로 구현되어있기 때문에)

Java 컴파일러는 타입소거를 아래와 같이 적용한다.

  • 제네릭 타입( Example<T>) 에서는 해당하는 타입 파라미터 (T) 나 Object로 변경해준다. 
    Object로 변경하는 경우는 unbounded 된 경우를 뜻하며, 이는 <E extends Comparable<E>>와 같이 bound를 해주지 않은 경우를 의미한다.
    따라서 이 소거 규칙에 대한 바이트코드는 제네릭을 적용할 수 있는 일반 클래스, 인터페이스, 메서드에만 해당된다.
  • 타입 안정성 보존을 위해 필요하다면 type casting을 넣어준다.
  • 확장된 제네릭 타입에서 다형성을 보존하기 위해 bridege method를 생성한다.
public static <E> boolean containsElement(E[] elements, E element) {
	for (E e : elements) {
		if (e.equals(element)) {
			return true;
		}
	}
	return false;
}

실제로 이렇게 선언되어있는 제네릭 메서드의 경우 선언 방식에 따라 컴파일러가 타입파라미터 E를 변경한다.

public static boolean containsElement(Object[] elements, Object element) {
	for (Object e : elements) {
		if (e.equals(element)) {
			return true;
		}
	}
	return false;
}

컴파일러는 첫번째 규칙에 따라 타입 파라미터 E가 bound하게 선언되어있지 않기 때문에 타입 파라미터 EInteger로 우선적으로 바꾼다.

이때 만약 프로그래머가 continasElement(Integer[], Integer) 형식으로 해당 메서드를  사용했다면, 컴파일러 내부에서 두번재 규칙에 따라 타입 안정성 보존을 위해 Object -> Integer로의 타입 캐스트 코드를 넣어주어 제네릭의 타입 안정성을 보장해주는 것이다. 

반면 로타입일 경우에는, 타입 파라미터가 정해져있지 않아. Object로 변환한 것에서 끝난다.

더보기

반면 타입 파라미터 E를 bound하게 설정한다면 

public static <E extends Comparable<E>> void containsElement(E[] elements) {
	for (E e : elements) {
		System.out.println("%s", e);
	}
}

타입이 소거될때 Object로 바뀌는 것이 아닌 한정시킨 타입인 Comparable로 변환된다.

public static void containsElement(Comparable[] elements) {
	for (Comparable e : elements) {
		System.out.println("%s", e);
	}
}

추가로 세번째 규칙에 대해서 언급하자면 java compiler는 제네릭의 타입안정성을 위해 Bridge Method도 만들어낼 수있다. Bridge Method는 java 컴파일러가 컴파일 할 때 메서드 시그니처가 조금 다르거나 애매할 경우에대비하여 작성된 메서드이다. 이 경우는 파리미터화된 클래스나 인터페이스를 확장한 클래스를 컴파일 할 때 생길 수 있다.

public class IntegerStack extends Stack<Integer> {
    public Integer push(Integer value) {
        super.push(value);
        return value;
    }
}

 

Java 컴파일러는 다형성을 제네릭 타입 소거에서도 지키기 위해, IntegerStackpush(Integer) 메서드와 Stack의 push(Object) 메서드 시그니처 사이에 불일치가 없어야 했다. 따라서 컴파일러는 런타임에 해당 제네릭 타입의 타입소거를 위한 Bridge 메서드를 만드는데 아래와같은 방식으로 만든다.

public class IntegerStack extends Stack {
    // Bridge method generated by the compiler
     
    public Integer push(Object value) {
        return push((Integer)value);
    }
 
    public Integer push(Integer value) {
        return super.push(value);
    }
}

extends Stack<Integer> -> Stack 으로  변경한 것을 볼 수 있으며, push에 parameter를 Object가 아닌 Integer로 맞추기 위한 도우미 메서드가 늘어났다는 것을 알 수 있다. 결과적으로 Stack 클래스의 push method는 타입소거를 진행한 후에, IntegerStack 클래스의 원본 push 방법을 사용하게 한다.

 

3. 제네릭에서는 리스트를 사용하자

실체화 불가 타입(Non-Reifiable Type)에 대한 설명이 있다 runtime에 타입 정보를 갖고있지 않고, compile-time에 타입 소거가 되는 타입을 의미한다고 한다. 이에 반대하는 개념으로는 실체화(reifiable)가 있다. 이는 타입 정보를 런타임에 완벽하게 사용할 수 있는 유형으로, 소거와는 반대 개념이다. 

실체화 불가 타입의 대표적인 예시로는 List<String> List<Number> 와 같은 리스트가 있고, 실체화 타입의 대표적인 예시로는 String[], Number[] 와 같은 배열이 있다.

이펙티브 자바에서는 타입소거라는 특성이 있는 제네릭은 실체화 불가 타입인 List와 함께 사용하기를 권장한다. Array 에서는 런타임에 타입정보를 갖고있는데, 제네릭을 사용하면 타입이 소거되기 때문에 해당 제네릭 변수에 대한 정보를 런타임에 갖고있지 않기 때문이다.

사실 이 부분은 이펙티브 자바를 읽으면서 스터디에서 했던 제네릭과 관련한 이야기를 예시로 이야기 하려한다.

관련 이슈 : https://github.com/Java-Bom/ReadingRecord/issues/88

 

[아이템 32] toArray · Issue #88 · Java-Bom/ReadingRecord

193p 마지막 코드에서 toArray를 바로 String[]배열로 받는건 되는데 왜 pickTwo를 거쳐서 String[]로 받는건 안될까?

github.com

static <T> T[] pickTwo(T a, T b, T c) {
  switch (ThreadLocalRandom.current().nextInt(3)) {
    case 0:
      return toArray(a, b);
    case 1:
      return toArray(b, c);
    case 2:
      return toArray(a, c);
  }
  throw new AssertionError();
}

static <T> T[] toArray(T... args) {
  return args;
}

public static void main(String[] args) {
  String[] strings = pickTwo("좋은", "빠른", "저렴한");  
}

위 코드에서 ClassCastException이 터진다. 반면 아래와 같이 pickTwo 메서드를 사용했을 때는 ClassCastException이 터지지 않는다. 왜그럴까? 

static <T> List<T> pickTwoList(T a, T b, T c) {
  switch (ThreadLocalRandom.current().nextInt(3)) {
    case 0:
      return Arrays.asList(a, b);
    case 1:
      return Arrays.asList(b, c);
    case 2:
      return Arrays.asList(a, c);
  }
  throw new AssertionError();
}

List<String> strings = pickTwoList("좋은", "빠른", "저렴한");

문제를 좀 더 단순히 해보자.

static <T> List<T> pickList(T a) {
        return Arrays.asList(a);
    }

    static <T> T[] toArray(T... args) {
        return args;
    }

    static <T> T[] pickArray(T a) {
        return toArray(a);
    }

    public static void main(String[] args) {
        List<String> stringList = pickList("자바봄");
        String[] stringArray = pickArray("자바봄");
   }
}

위 코드에서 pickList를 지나 pickArray를 실행하면 runtimeException이 터지는 것을 볼 수 있다. 내용은 [Ljava.lang.Object; cannot be cast to [Ljava.lang.String; 이다 무엇이 문제일까?

정답은 Array와 List의 실체화에 있었다. 

코드를 이해해보자. 위에서는 제네릭 타입추론이 2 depth가 들어간다. pickArray에서 타입 매개변수 TString과 대응하여 들어가기 때문에 pickArray(String) toArray(String...) 이 들어갈 것으로 예상한다. 하지만 실제 런타임에 확인해보면 pickArray(String),  toArray(Object...)가 들어간다. 그 이유는 위에서 제네릭 타입추론을 이야기할 때 우선적으로 bounded가 아닌 매개변수일경우 컴파일러가 Object로 대체한다는 이야기와 대응된다. pickArray에서는 main에 있는 String 타입으로 타입추론이 가능했으나 toArray는 제네릭 타입을 바라보고 있으므로 타입추론을 Object로 하여 런타임에 타입정보를 갖고있게 된다.

따라서 pickArrayString[] 으로 타입캐스팅을 준비하였으나, 위에서 말한 것처럼 런타임에 toArray가 갖고있는 타입은 Object[] 이므로 런타임에 (String[]) Object[] 와 같은 형식으로 강제적으로 타입캐스팅을 하다가 ClassCastException이 발생한다. (String 배열은, Object 배열의 하위 타입이 아니기 때문에 Casting이 되지 않는다.)

반면에 pickList를 호출할때는 컴파일이 성공한다. 이 이유는 List가 실체화 불가타입이었기 때문이다. 컴파일 타임에 캐스팅할 정보가 이미 결정이되고, 런타임때에는 제네릭의 소거라는 특성 때문에 Java 컴파일러가 타입에 맞는 캐스팅 방식을 올바르게 추가해줘서 캐스팅 에러가 나지 않는다.

정리하자면, 리스트 + 제네릭은 컴파일 타임에 결정된 캐스팅 정보가 올바르기 때문에 통과가 된다. 제네릭의 장점인 컴파일 타임에 타입이 안맞는 것을 체크해주는 걸 List에서도 수행하기 때문이다. 반면 배열 같은 경우엔 타입 캐스팅을 런타임에 결정하기 때문에 문제가 생긴 것이다. 

 

조금 길었지만 사실 결론은 간단하다. 제네릭은 타입소거라는 특징으로 컴파일러가 컴파일 타임에 타입을 추론할 수 있으며, 이런 타입 추론 기능을 강력하게 사용하기 위해서는 런타임에 타입을 추론하는 Array 대신에 컴파일타임에 타임을 추론하는 List를 함께 사용해야 안정성을 보장 할 수 있다는 것이다.

 

참고 글

https://www.baeldung.com/java-generics

Effecitve Java 3/E - Chapter 5: Generics 

https://docs.oracle.com/javase/tutorial/java/generics/erasure.html

https://www.baeldung.com/java-type-erasure

 

1. Java Generics and Raw Type

public class<T> Example{
	private T member;
}

When a type parameter T is used in a class or interface declaration like above, we call it a generic class or generic interface. The class Example<T> used here is referred to as a generic type.

When using generics, the concept of raw types comes up. A raw type is when you don't use a type parameter at all with a generic type — in other words, when you declare and use the generic type Example<T> as just Example.

public class Example<T> {
    private T member;

    public Example(T member) {
        this.member = member;
    }

    public static void main(String[] args) {
        Example<Integer> parameterType = new Example<>(1);
        Integer parameterTypeMember = parameterType.member;
        System.out.println(parameterTypeMember);

        Example rawType = new Example(1);
        Object rawTypeMember = rawType.member;
        System.out.println(rawTypeMember);
    }
}

The code above shows both a generic parameterized type and a raw type in use. But don't use raw types.

The advantage of generics is that they guarantee type safety at compile time. Since variables declared with a generic type are type-checked at compile time, you're protected from UncheckedException errors like ClassCastException at runtime.

On the other hand, when used as a raw type like below, you lose the benefits of safety and expressiveness that generics provide. That's why you can see the IDE giving you a warning like "Raw use of parameterized class 'Example' ".

So why do raw types exist in the first place? The reason raw types came about is related to erasure, a key characteristic of generics.

Generics were introduced in JDK5. The goals were to reduce bugs and to add a layer of abstraction over types.
Because of this, JDK5 — which introduced generics — had to accommodate all existing code while maintaining compatibility with new code that uses generics. So for the sake of code compatibility, they supported raw types and implemented generics using erasure.

 

2. Generics Type Erasure

Erasure means that element types are only checked at compile time and the type information is not available at runtime. In other words, it's a process where type constraints are enforced only at compile time, and type information is erased at runtime.

List<Object> ol = new ArrayList<Long>(); // 컴파일 에러
ol.add("타입이 달라 넣을 수 없다");

In a situation like this, you can immediately catch the type error at compile time (because List is also implemented as a generic type).

The Java compiler applies type erasure as follows:

  • In a generic type (Example<T>), it replaces the type parameter (T) with the corresponding type or Object
    Replacing with Object happens when the type is unbounded — meaning it hasn't been bounded like <E extends Comparable<E>>.
    Therefore, the bytecode for this erasure rule only applies to regular classes, interfaces, and methods that can use generics.
  • It inserts type casting where necessary to preserve type safety.
  • It generates bridge methods to preserve polymorphism in extended generic types.
public static <E> boolean containsElement(E[] elements, E element) {
	for (E e : elements) {
		if (e.equals(element)) {
			return true;
		}
	}
	return false;
}

For a generic method declared like this, the compiler replaces the type parameter E depending on how it's declared.

public static boolean containsElement(Object[] elements, Object element) {
	for (Object e : elements) {
		if (e.equals(element)) {
			return true;
		}
	}
	return false;
}

Following the first rule, since the type parameter E is not declared as bounded, the compiler first replaces the type parameter E with Integer.

At this point, if the programmer used the method in the form of containsElement(Integer[], Integer), the compiler internally inserts type casting code from Object to Integer according to the second rule to preserve type safety, thus guaranteeing the type safety of generics.

In the case of raw types, however, since no type parameter is specified, it simply ends with the conversion to Object.

더보기

On the other hand, if you set the type parameter E as bounded:

public static <E extends Comparable<E>> void containsElement(E[] elements) {
	for (E e : elements) {
		System.out.println("%s", e);
	}
}

When the type is erased, instead of being replaced with Object, it gets converted to the bounded type Comparable.

public static void containsElement(Comparable[] elements) {
	for (Comparable e : elements) {
		System.out.println("%s", e);
	}
}

Additionally, regarding the third rule, the Java compiler can also generate Bridge Methods for generic type safety. A Bridge Method is a method created by the Java compiler during compilation to handle cases where method signatures are slightly different or ambiguous. This can happen when compiling a class that extends a parameterized class or interface.

public class IntegerStack extends Stack<Integer> {
    public Integer push(Integer value) {
        super.push(value);
        return value;
    }
}

 

The Java compiler needed to ensure there was no mismatch between the push(Integer) method signature of IntegerStack and the push(Object) method signature of Stack, in order to preserve polymorphism even during generic type erasure. So the compiler creates a Bridge method for the type erasure of that generic type at runtime, and it does it like this:

public class IntegerStack extends Stack {
    // Bridge method generated by the compiler
     
    public Integer push(Object value) {
        return push((Integer)value);
    }
 
    public Integer push(Integer value) {
        return super.push(value);
    }
}

You can see that extends Stack<Integer> has been changed to just Stack, and a helper method has been added to match the push parameter to Integer instead of Object. As a result, the Stack class's push method, after type erasure, delegates to the original push method of the IntegerStack class.

 

3. Use Lists with Generics

Non-Reifiable Type refers to a type that doesn't hold type information at runtime and undergoes type erasure at compile time. The opposite concept is reifiable, which refers to types whose type information is fully available at runtime — the opposite of erasure. 

Typical examples of non-reifiable types include lists like List<String> and List<Number>, while typical examples of reifiable types include arrays like String[] and Number[].

Effective Java recommends using generics — which have the characteristic of type erasure — with List, a non-reifiable type. This is because arrays hold type information at runtime, but when you use generics, the type gets erased, so the generic variable's information isn't available at runtime.

This part is actually something I want to illustrate with an example from a study group discussion about generics that we had while reading Effective Java.

Related issue : https://github.com/Java-Bom/ReadingRecord/issues/88

 

[Item 32] toArray · Issue #88 · Java-Bom/ReadingRecord

On p.193, the last code example — receiving toArray directly as a String[] array works, but why doesn't it work when receiving as String[] through pickTwo?

github.com

static <T> T[] pickTwo(T a, T b, T c) {
  switch (ThreadLocalRandom.current().nextInt(3)) {
    case 0:
      return toArray(a, b);
    case 1:
      return toArray(b, c);
    case 2:
      return toArray(a, c);
  }
  throw new AssertionError();
}

static <T> T[] toArray(T... args) {
  return args;
}

public static void main(String[] args) {
  String[] strings = pickTwo("좋은", "빠른", "저렴한");  
}

In the code above, a ClassCastException is thrown. However, when using the pickTwo method like below, no ClassCastException occurs. Why is that?

static <T> List<T> pickTwoList(T a, T b, T c) {
  switch (ThreadLocalRandom.current().nextInt(3)) {
    case 0:
      return Arrays.asList(a, b);
    case 1:
      return Arrays.asList(b, c);
    case 2:
      return Arrays.asList(a, c);
  }
  throw new AssertionError();
}

List<String> strings = pickTwoList("좋은", "빠른", "저렴한");

Let's simplify the problem a bit.

static <T> List<T> pickList(T a) {
        return Arrays.asList(a);
    }

    static <T> T[] toArray(T... args) {
        return args;
    }

    static <T> T[] pickArray(T a) {
        return toArray(a);
    }

    public static void main(String[] args) {
        List<String> stringList = pickList("자바봄");
        String[] stringArray = pickArray("자바봄");
   }
}

In the code above, after pickList passes, executing pickArray throws a runtimeException. The message is [Ljava.lang.Object; cannot be cast to [Ljava.lang.String; — what's the problem?

The answer lies in the reifiability of Array vs. List.

Let's understand the code. Here, generic type inference goes 2 levels deep. Since the type parameter T in pickArray corresponds to String, you'd expect pickArray(String) toArray(String...) to be called. But when you actually check at runtime, it's pickArray(String), toArray(Object...) that gets called. The reason is exactly what we discussed above about generic type inference — when the parameter is unbounded, the compiler replaces it with Object first. While pickArray could infer the String type from main, toArray looks at the generic type, so it infers Object and holds that type information at runtime.

Therefore, pickArray prepares to cast to String[], but as mentioned above, the type that toArray holds at runtime is Object[], so at runtime it tries to forcefully cast like (String[]) Object[], which causes a ClassCastException. (A String array is not a subtype of an Object array, so the cast fails.)

On the other hand, calling pickList compiles successfully. The reason is that List is a non-reifiable type. The casting information is already determined at compile time, and at runtime, thanks to the erasure characteristic of generics, the Java compiler correctly adds the appropriate casting, so no casting error occurs.

To summarize, List + generics works because the casting information determined at compile time is correct. List performs the same compile-time type mismatch checking that is the advantage of generics. Arrays, on the other hand, determine type casting at runtime, which is where the problem arises.

 

That was a bit long, but the conclusion is actually simple. Generics use type erasure, which allows the compiler to infer types at compile time. To fully leverage this type inference capability, you should use List — which infers types at compile time — instead of Array — which infers types at runtime — to guarantee type safety.

 

References

https://www.baeldung.com/java-generics

Effecitve Java 3/E - Chapter 5: Generics 

https://docs.oracle.com/javase/tutorial/java/generics/erasure.html

https://www.baeldung.com/java-type-erasure

 

댓글

Comments

Develop/Springboot

[JPA] 고급매핑 - 상속 관계 , 매핑 정보 상속 | [JPA] Advanced Mapping - Inheritance Relationships, Mapped Superclass

인프런에서 에서 김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본편을 듣고 쓴 정리 글입니다.https://www.inflearn.com/course/ORM-JPA-Basic 자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다. 초급 웹 개발 서버 데이터베이스 프레임워크 및 라이브러리 프로그래밍 언어 서비스 개발 Java JPA 스프링 데이터 JPA 온라인 강의www.inflearn.com평소에 Spring Data JPA 를 썼는데, 김영한님은 JPA 자체를 강의하시더라구요.김영한님 강의 바탕으로 Spring Data ..

[JPA] 고급매핑 - 상속 관계 , 매핑 정보 상속 | [JPA] Advanced Mapping - Inheritance Relationships, Mapped Superclass

728x90

인프런에서 에서 김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본편을 듣고 쓴 정리 글입니다.

https://www.inflearn.com/course/ORM-JPA-Basic

 

자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런

JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다. 초급 웹 개발 서버 데이터베이스 프레임워크 및 라이브러리 프로그래밍 언어 서비스 개발 Java JPA 스프링 데이터 JPA 온라인 강의

www.inflearn.com

평소에 Spring Data JPA 를 썼는데, 김영한님은 JPA 자체를 강의하시더라구요.

김영한님 강의 바탕으로 Spring Data JPA로 강의 소스를 테스트해보고 개념을 기록하기 위해 포스팅을 하게되었습니다.



고급 매핑

1. 상속관계 매핑

  • 관계형 데이터베이스는 상속 관계X
  • 슈퍼타입 서브타입 관계라는 모델링 기법이 객체 상속과 유사
  • 상속관계 매핑: 객체의 상속과 구조와 DB의 슈퍼타입 서브타입 관계를 매핑
  • 슈퍼타입 서브타입 논리 모델을 실제 물리 모델로 구현하는 방법
    1. 각각 테이블로 변환 -> 조인 전략
    2. 통합 테이블로 변환 -> 단일 테이블 전략
    3. 서브타입 테이블로 변환 -> 구현 클래스마다 테이블 전략

테이블은 여러개의 모델링이 나오지만, 객체는 상속관계라는 1개의 개념이다.

객체관계는 같지만 DB설계를 다르게 할 수 있음

  • 관계형 데이터베이스는 상속 관계 X
  • 슈퍼타입 서브타입 관계라는 모델링 기법이 객체 상속과 유사
  • 상속관계 매핑 : 객체의 상속, 구조와 DB의 슈퍼타입 서브타입 관계를 매핑
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DiscriminatorColumn
@Getter
public abstract class Item {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private int price;

    public Item(String name, int price) {
        this.name = name;
        this.price = price;
    }
}

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DiscriminatorValue("Book")
public class Book extends Item {
    private String author;
    private String isbn;

    @Builder
    public Book(String name, int price, String author, String isbn) {
        super(name, price);
        this.author = author;
        this.isbn = isbn;
    }
}

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DiscriminatorValue("Album")
public class Album extends Item{
    private String artist;

    @Builder
    public Album(String name, int price, String artist) {
        super(name, price);
        this.artist = artist;
    }
}

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DiscriminatorValue("Movie")
public class Movie extends Item {
    private String actor;
    private String director;

    @Builder
    public Movie(String name, int price, String actor, String director) {
        super(name, price);
        this.actor = actor;
        this.director = director;
    }
}

[Repository 코드]

public interface ItemRepository<T extends Item> extends JpaRepository<T, Long> {}
public interface BookRepository extends JpaRepository<Book, Long> {}
public interface AlbumRepository extends JpaRepository<Album, Long> {}
public interface MovieRepository extends JpaRepository<Movie, Long> {}

이때 ItemRepository extends 를 꼭 기억하자!! [abstract class jpaRepository 상속법]

ItemRepository만 사용해도 Book, Album, Movie를 모두 가져올 수 있다. (type casting 사용해서)

[테스트 코드]

@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class ItemTest {

    @Autowired
    ItemRepository itemRepository;

    @Autowired
    EntityManager entityManager;

    @Before
    public void setUp() throws Exception {
        Movie movie = Movie.builder()
                .actor("맷데이먼")
                .director("리들리스콧")
                .name("마션")
                .price(10000)
                .build();

        Book book = Book.builder()
                .author("조영호")
                .isbn("isbn")
                .name("객체지향의 사실과 오해")
                .price(10000)
                .build();

        Album album = Album.builder()
                .artist("엔플라잉")
                .name("야호")
                .price(30000)
                .build();

        itemRepository.save(movie);
        itemRepository.save(book);
        itemRepository.save(album);

        entityManager.clear();
    }

    @Test
    public void Item의_서브클래스_객체들_casting으로_가져오기() {
        Movie movie = (Movie) itemRepository.findAll().get(0);
        Book book = (Book) itemRepository.findAll().get(1);
        Album album = (Album) itemRepository.findAll().get(2);

        assertThat(movie.getName()).isEqualTo("마션");
        assertThat(book.getName()).isEqualTo("객체지향의 사실과 오해");
        assertThat(album.getArtist()).isEqualTo("엔플라잉");

    }
}

1-0. 주요 어노테이션

  • @Inheritance(strategy = InheritanceType.XXX) = default: SINGLE_TABLE
    • JOINED : 조인 전략
    • SINGLE_TABLE : 단일 테이블 전략
    • TABLE_PER_CLASS : 구현 클래스마다 테이블 전략
  • @DiscriminatorColumn(name = "DTYPE") = default: DTYPE DTYPE이라는 Column이 super class의 table에 생기고,
    DTYPE의 값은 sub class의 이름으로 지정된다. SingleTable 전략에서 없어도 DTYPE 이생성되기도 하는데, 그래도 운영상 써주자
  • @DiscriminatorValue("XXX") = default: classname

[예시]

@Inheritance(strategy = InheritanceType.JOIN)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item{}

@DiscriminatorValue("ALBUM_TYPE")
public class Album extends Item{}

@DiscriminatorValue("BOOK_TYPE")
public class Book extends Item{}

@DiscriminatorValue("MOVIE_TYPE")
public class Movie extends Item{}

DB 설계를 바꿨는데도 코드를 많이 수정하지 않아도 된다!! : JPA의 큰 장점!!

Join이 성능이 안나오네 -> singletable로 고치자!!
: query를 사용하면 코드를 많이 바꿔야함 근데 JPA사용하면 바꾸는게 엄청 쉽다.

1-1. 조인전략

데이터를 가져올 때 JOIN을 이용해서 가져온다.

insert는 두번 ITEAM ALBUM

select는 PK, FK를 이용해서 JOIN해서 가져온다.

abstract class에는 type을 컬럼을 두어서 구분한다.

@Inheritance(strategy = InheritanceType.JOIN)
@DiscriminatorColumn
public abstract class Item{}

1-1-1. 장점

  • 테이블 정규화
  • 외래 키 참조 무결성 제약조건 활용 가능
  • 저장공간 효율화

1-1-2. 단점

  • 조회시 조인을 많이 사용, 성능 저하
  • 조회 쿼리가 복잡함
  • 데이터 저장시 INSERT SQL 2번 호출

조인 성능이 생각보다 치명적이진 않고, 오히려 저장공간이 더 효율적일 수도 있음

그래도 단일 테이블 전략과 비교했을 때 단점이다!

조인이 정규화도 되고 객체랑도 잘 맞고 설계 입장에서 잘 맞아 떨어진다.

1-2. 단일 테이블 전략 - 기본 전략

subclass 의 모든 멤버변수를 테이블의 컬럼으로 가져온다.

insert도 한번에 되고, select도 한번에 되니까 아무래도 성능이 나오지!

@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn
public abstract class Item{}

1-2-1. 장점

  • 조인이 필요 없으므로 일반적으로 조회 성능이 빠름
  • 조회 쿼리가 단순함

1-2-2. 단점

  • 자식 엔티티가 매핑한 컬럼은 모두 null 허용
  • 단일 테이블에 모든 것을 저장하므로 테이블이 커질 수 있고 상황에 따라서 조회 성능이 오히려 느려질 수 있다. NULL 조건이 데이터 무결성 입장에서 애매하다. ALBUM 저장하면 > Book, Movie 관련 column이 모두 null이 되어야한다. 조회 성능을 문제시 하려면 임계점을 넘어야하는데 보통은 없음

1-3. 구현 클래스마다 테이블 전략

subclass 자체를 테이블로 만든다 + superclass의 멤버변수도 포함해서!

superclass를 아예 없애버리고, table을 subclass 기준으로 만든 후,
superclass의 멤버변수도 같이 포함하게 한다.

Item table 자체가 존재하지 않고, Movie, Book, Album table만 존재한다.

@DiscriminatorColumn의 의미가 없다! (없어도 된다.)

단순하게 값을 넣고 뺄 때는 좋은데, 이외의 경우에는 세 개 테이블을 모두 찾아봐서 쿼리가 복잡하게 나간다.

ex ) Item id가 5번이라고 할 때!

@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Item{}

이 전략은 데이터베이스 설계자와 ORM 전문가 둘 다 싫어하는 전략임!

1-3-1. 장점

  • 서브 타입을 명확하게 구분해서 처리할 때 효과적
  • Not Null 제약조건 사용가능

1-3-2. 단점

  • 여러 자식 테이블을 함께 조회할 때 성능이 느림 (UNION SQL)
  • 자식 테이블을 통합해서 쿼리하기 어려움

2. @MappedSuperclass - 매핑 정보 상속

  • 공통 매핑 정보가 필요할 때 사용한다. (ex : baseTimeEntity 같은 것)

위에서 말한 상속 관계 매핑에서 테이블까지 고민하기 싫음.
DB는 따로 쓰되, 객체입장에서 속성만 상속 받아서 쓰고 싶을때!

@MappedSuperclass
public abstract class BaseEntity {
    private String createdBy;
    private LocalDateTime createdDate;
    private String lastModifedBy;
    private LocalDateTime lastModifiedDate;
}

@Entity
public class Member extends BaseEntity{ ... }

@Entity
public class Team extends BaseEntity{ ... }

매핑 정보만 받는 슈퍼 클래스로 하고싶다면

  1. extends 로 클래스 설정하기
  2. @MappedSuperclass 어노테이션 추가하기.

그냥 속성을 같이 쓰고 싶을 때 사용한다!!

@Column(name = "CREATED_BY") // 이런식으로 column 설정도 충분히 가능하다.
private String createdBy;

JPA의 이벤트 기능으로 아예 어노테이션으로 시간, auth 정보를 편리하게 만들어 버릴 수 있다.

  • 상속관계 매핑 X
  • 엔티티X, 테이블과 매핑X (@Entity 안붙였다.)
  • 부모 클래스를 상속 받는 자식 클래스에 매핑 정보만 제공
  • 조회, 검색 불가(em.find(BaseEntity)불가) em.find(BaseEntity.class, 1L); 불가능
  • 직접 생성해서 사용할 일이 없으므로 추상 클래스 권장
  • 테이블과 관계 없고, 단순히 엔티티가 공통으로 사용하는 매핑 정보를 모으는 역할
  • 주로 등록일, 수정일, 등록자, 수정자 같은 전체 엔티티에서 공통으로 적용하는 정보를 모을 때 사용
  • 참고 : @Entity 클래스는 엔티티나 @MappedSuperclass로 지정한 클래스만 상속가능하다.
@MappedSuperclass //매핑 정보 상속
public abstract class BaseEntity{...}

@Entity //상속 관계 매핑
public abstract class Item extends BaseEntity{...}

@Entity
public class Album extends Item{...}

This is a summary post written after taking Kim Young-han's Java ORM Standard JPA Programming - Basics course on Inflearn.

https://www.inflearn.com/course/ORM-JPA-Basic

 

Java ORM Standard JPA Programming - Basics - Inflearn

For those who are new to JPA or use JPA in practice but lack foundational theory — build a solid understanding of JPA basics so that even beginners can confidently use JPA in real-world projects. Beginner Web Development Server Database Frameworks & Libraries Programming Languages Service Development Java JPA Spring Data JPA Online Course

www.inflearn.com

I've been using Spring Data JPA, but Kim Young-han actually teaches JPA itself.

Based on his lectures, I'm writing this post to document concepts while testing the course source code with Spring Data JPA.



Advanced Mapping

1. Inheritance Mapping

  • Relational databases do NOT have inheritance
  • The supertype-subtype modeling technique is similar to object inheritance
  • Inheritance mapping: Mapping between object inheritance structure and DB supertype-subtype relationships
  • Ways to implement a supertype-subtype logical model into a physical model:
    1. Convert to separate tables -> Joined strategy
    2. Convert to a single table -> Single table strategy
    3. Convert to subtype tables -> Table-per-class strategy

Tables can result in multiple modeling approaches, but on the object side, there's only one concept: inheritance.

The object relationships stay the same, but the DB design can vary.

  • Relational databases do NOT have inheritance
  • The supertype-subtype modeling technique is similar to object inheritance
  • Inheritance mapping: Mapping between object inheritance/structure and DB supertype-subtype relationships
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DiscriminatorColumn
@Getter
public abstract class Item {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private int price;

    public Item(String name, int price) {
        this.name = name;
        this.price = price;
    }
}

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DiscriminatorValue("Book")
public class Book extends Item {
    private String author;
    private String isbn;

    @Builder
    public Book(String name, int price, String author, String isbn) {
        super(name, price);
        this.author = author;
        this.isbn = isbn;
    }
}

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DiscriminatorValue("Album")
public class Album extends Item{
    private String artist;

    @Builder
    public Album(String name, int price, String artist) {
        super(name, price);
        this.artist = artist;
    }
}

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DiscriminatorValue("Movie")
public class Movie extends Item {
    private String actor;
    private String director;

    @Builder
    public Movie(String name, int price, String actor, String director) {
        super(name, price);
        this.actor = actor;
        this.director = director;
    }
}

[Repository Code]

public interface ItemRepository<T extends Item> extends JpaRepository<T, Long> {}
public interface BookRepository extends JpaRepository<Book, Long> {}
public interface AlbumRepository extends JpaRepository<Album, Long> {}
public interface MovieRepository extends JpaRepository<Movie, Long> {}

Make sure to remember the ItemRepository extends part!! [How to extend JpaRepository for an abstract class]

You can retrieve Book, Album, and Movie all through ItemRepository alone. (using type casting)

[Test Code]

@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class ItemTest {

    @Autowired
    ItemRepository itemRepository;

    @Autowired
    EntityManager entityManager;

    @Before
    public void setUp() throws Exception {
        Movie movie = Movie.builder()
                .actor("맷데이먼")
                .director("리들리스콧")
                .name("마션")
                .price(10000)
                .build();

        Book book = Book.builder()
                .author("조영호")
                .isbn("isbn")
                .name("객체지향의 사실과 오해")
                .price(10000)
                .build();

        Album album = Album.builder()
                .artist("엔플라잉")
                .name("야호")
                .price(30000)
                .build();

        itemRepository.save(movie);
        itemRepository.save(book);
        itemRepository.save(album);

        entityManager.clear();
    }

    @Test
    public void Item의_서브클래스_객체들_casting으로_가져오기() {
        Movie movie = (Movie) itemRepository.findAll().get(0);
        Book book = (Book) itemRepository.findAll().get(1);
        Album album = (Album) itemRepository.findAll().get(2);

        assertThat(movie.getName()).isEqualTo("마션");
        assertThat(book.getName()).isEqualTo("객체지향의 사실과 오해");
        assertThat(album.getArtist()).isEqualTo("엔플라잉");

    }
}

1-0. Key Annotations

  • @Inheritance(strategy = InheritanceType.XXX) = default: SINGLE_TABLE
    • JOINED: Joined strategy
    • SINGLE_TABLE: Single table strategy
    • TABLE_PER_CLASS: Table-per-class strategy
  • @DiscriminatorColumn(name = "DTYPE") = default: DTYPE A column called DTYPE is created in the superclass table,
    and the DTYPE value is set to the subclass name. In the SingleTable strategy, DTYPE may be generated even without this annotation, but you should still include it for production use.
  • @DiscriminatorValue("XXX") = default: classname

[Example]

@Inheritance(strategy = InheritanceType.JOIN)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item{}

@DiscriminatorValue("ALBUM_TYPE")
public class Album extends Item{}

@DiscriminatorValue("BOOK_TYPE")
public class Book extends Item{}

@DiscriminatorValue("MOVIE_TYPE")
public class Movie extends Item{}

Even when the DB design changes, you barely need to modify the code!! This is a huge advantage of JPA!!

Join performance isn't cutting it -> Let's switch to single table!!
With raw queries, you'd have to change a lot of code, but with JPA it's super easy to switch.

1-1. Joined Strategy

When fetching data, it uses JOINs to retrieve it.

Insert happens twice — once for ITEM, once for ALBUM.

Select uses PK and FK to JOIN and fetch the data.

The abstract class has a type column to distinguish between subtypes.

@Inheritance(strategy = InheritanceType.JOIN)
@DiscriminatorColumn
public abstract class Item{}

1-1-1. Pros

  • Table normalization
  • Can leverage foreign key referential integrity constraints
  • Efficient storage space

1-1-2. Cons

  • Heavy use of joins during queries, potential performance degradation
  • Complex query statements
  • INSERT SQL is called twice when saving data

Join performance isn't as fatal as you might think, and it can actually be more storage-efficient.

Still, these are disadvantages compared to the single table strategy!

Joins provide normalization, align well with objects, and fit nicely from a design perspective.

1-2. Single Table Strategy - Default Strategy

All member variables of subclasses become columns in a single table.

Insert happens in one shot, select happens in one shot — so naturally the performance is better!

@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn
public abstract class Item{}

1-2-1. Pros

  • No joins needed, so query performance is generally fast
  • Simple query statements

1-2-2. Cons

  • All columns mapped by child entities must allow null
  • Since everything is stored in a single table, the table can get large and in some cases query performance may actually get slower. NULL conditions are awkward from a data integrity standpoint. When you save an ALBUM > all Book and Movie related columns must be null. For query performance to become a real problem, you'd need to cross a threshold, which usually doesn't happen.

1-3. Table-per-Class Strategy

Each subclass becomes its own table — including the superclass's member variables!

The superclass table is completely eliminated, tables are created based on subclasses,
and they include the superclass's member variables as well.

The Item table itself doesn't exist — only Movie, Book, and Album tables exist.

@DiscriminatorColumn has no meaning here! (You don't need it.)

It's fine for simple inserts and retrieves, but for anything else, it has to search all three tables resulting in complex queries.

e.g.) When the Item id is 5!

@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
public abstract class Item{}

This is a strategy that both database designers and ORM experts dislike!

1-3-1. Pros

  • Effective when you need to clearly distinguish and handle subtypes
  • Can use NOT NULL constraints

1-3-2. Cons

  • Slow performance when querying multiple child tables together (UNION SQL)
  • Difficult to write unified queries across child tables

2. @MappedSuperclass - Inheriting Mapping Information

  • Used when common mapping information is needed. (e.g., something like BaseTimeEntity)

You don't want to think about table design like in inheritance mapping above.
You want the DB tables to be separate, but from the object perspective, you just want to inherit the attributes!

@MappedSuperclass
public abstract class BaseEntity {
    private String createdBy;
    private LocalDateTime createdDate;
    private String lastModifedBy;
    private LocalDateTime lastModifiedDate;
}

@Entity
public class Member extends BaseEntity{ ... }

@Entity
public class Team extends BaseEntity{ ... }

If you want a superclass that only provides mapping information:

  1. Set up the class with extends
  2. Add the @MappedSuperclass annotation.

Use it when you simply want to share attributes!!

@Column(name = "CREATED_BY") // 이런식으로 column 설정도 충분히 가능하다.
private String createdBy;

With JPA's event features, you can conveniently create time and auth information using annotations alone.

  • NOT inheritance mapping
  • NOT an entity, NOT mapped to a table (@Entity is not applied.)
  • Only provides mapping information to child classes that inherit from the parent class
  • Cannot be queried or searched (em.find(BaseEntity) is not possible) em.find(BaseEntity.class, 1L); is not possible
  • Since you'll never instantiate it directly, abstract class is recommended
  • Has no relation to tables — it simply gathers mapping information commonly used by entities
  • Mainly used to collect information like created date, modified date, created by, modified by that applies commonly across all entities
  • Note: @Entity classes can only extend entities or classes annotated with @MappedSuperclass.
@MappedSuperclass //매핑 정보 상속
public abstract class BaseEntity{...}

@Entity //상속 관계 매핑
public abstract class Item extends BaseEntity{...}

@Entity
public class Album extends Item{...}

댓글

Comments

Develop/Springboot

[JPA] 프록시와 연관관계 관리 - 프록시, LAZY, EAGER , CASCADE, orphanRemoval | [JPA] Proxy and Association Management - Proxy, LAZY, EAGER, CASCADE, orphanRemoval

인프런에서 에서 김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본편을 듣고 쓴 정리 글입니다.https://www.inflearn.com/course/ORM-JPA-Basic 자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다. 초급 웹 개발 서버 데이터베이스 프레임워크 및 라이브러리 프로그래밍 언어 서비스 개발 Java JPA 스프링 데이터 JPA 온라인 강의www.inflearn.com평소에 Spring Data JPA 를 썼는데, 김영한님은 JPA 자체를 강의하시더라구요.김영한님 강의 바탕으로 Spring Data ..

[JPA] 프록시와 연관관계 관리 - 프록시, LAZY, EAGER , CASCADE, orphanRemoval | [JPA] Proxy and Association Management - Proxy, LAZY, EAGER, CASCADE, orphanRemoval

728x90

인프런에서 에서 김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본편을 듣고 쓴 정리 글입니다.

https://www.inflearn.com/course/ORM-JPA-Basic

 

자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런

JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다. 초급 웹 개발 서버 데이터베이스 프레임워크 및 라이브러리 프로그래밍 언어 서비스 개발 Java JPA 스프링 데이터 JPA 온라인 강의

www.inflearn.com

평소에 Spring Data JPA 를 썼는데, 김영한님은 JPA 자체를 강의하시더라구요.

김영한님 강의 바탕으로 Spring Data JPA로 강의 소스를 테스트해보고 개념을 기록하기 위해 포스팅을 하게되었습니다.



프록시와 연관관계 관리

1. 프록시

Member를 조회할 때 Team도 함께 조회해야 할까?

  • Member 가져올 때 Team도 함께 출력
    • jpa에서 member가져올 때 team도 가져오면 좋다.
  • Member 가져올때 오로지 member만!
    • jpa에서 member가져올 때 team도 가져오면 안좋다!

1-1. 프록시 기초

  • em.find() : DB를 통해서 실제 엔티티 객체조회
  • em.getReference() : 데이터베이스 조회를 미루는 가짜(프록시) 엔티티 객체 조회

DB의 쿼리가 안나가는데 조회가 되는 것

@Test
public void 멤버와조회할때_팀도함께_조회() {
    Member findMember = entityManager.find(Member.class, 1L);
    System.out.println("findMember.id = " + findMember.getId());
    System.out.println("findMember.username = " + findMember.getUsername());
    }
select 
    member0_.member_id as member_i1_0_0_, 
    member0_.team_id as team_id3_0_0_, 
    member0_.username as username2_0_0_, 
    team1_.member_id as member_i1_1_1_, 
    team1_.name as name2_1_1_ 
from 
    member member0_ 
left outer join 
    team team1_ 
        on member0_.team_id=team1_.member_id 
where member0_.member_id=?

자동적으로 Member를 조회하는데 Team도 join이 되서 같이 조회가된다.

@Test
public void 멤버만_조회() {
    Member findMember = entityManager.getReference(Member.class, 1L);
}

이 경우 select 쿼리가 안나간다!!

@Test
public void 멤버만_조회() {
    Member findMember = entityManager.getReference(Member.class, 1L);
    System.out.println("findMember.id = " + findMember.getId());
    System.out.println("findMember.username = " + findMember.getUsername());
}

이 경우에는 select 쿼리가 나간다!

getReference() 를 호출하는 시점에는 DB에 Query를 호출하지 않는다.
이 값이 실제 사용되는 시점 (username)에 DB에 Query를 호출한다.

System.out.println("findMember = " + findMember.getClass());
findMember = class com.jyami.jpalab.domain.Member$HibernateProxy$injSwDL2

이름이 Member가 아니다! HibernateProxy : 강제로 만든 가짜클래스이다 : 프록시 클래스

1-2. 프록시 특징

  • 실제 클래스를 상속 받아서 만들어짐
  • 실제 클래스와 겉 모양이 같다.
  • 사용하는 입장에서는 진짜 객체인지 프록시 객체인지는 구분하지 않고 사용하면 됨 (이론상)
  • 프록시 객체는 실제 객체의 참조(target)를 보관
  • 프록시 객체를 호출하면 프록시 객체는 실제 객체의 메소드 호출
em.getReference(Member.class, 1L); //프록시객체 가져온다.

getName() > Member target에 값이 없다 > 영속성 컨텍스트에 실제 값 가져오라 요청 > db가 그 값을 가져오고, Proxy객체에 진짜 객체를 연결시켜준다. 그래서 target.getName()으로 name을 가져온다.

영속성 컨텍스트에 초기화 요청 : 프록시에 값이 없을 때 DB에서 진짜 값을 달라.

1-3. 프록시 객체 매커니즘

  • 프록시 객체는 처음 사용할 때 한 번만 초기화
@Test
public void 프록시_테스트() {
    Member findMember = entityManager.getReference(Member.class, 1L);
    System.out.println("1st = " + findMember.getUsername());
        //1st에서는 query가 나간다.
    System.out.println("2nd = " + findMember.getUsername());
        //2nd에서는 query가 나가지 않는다.
}
  • 프록시 객체를 초기화 할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아님, 초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능
@Test
public void 프록시_테스트() {
    Member findMember = entityManager.getReference(Member.class, 1L);
    System.out.println("before findMember = " + findMember.getClass());
    System.out.println("findMember.username = " + findMember.getUsername());
    System.out.println("after findMember = " + findMember.getClass());
}
before findMember = class com.jyami.jpalab.domain.Member$HibernateProxy$EYDMo7wU
Hibernate: [select query]
findMember.username = member1
after findMember = class com.jyami.jpalab.domain.Member$HibernateProxy$EYDMo7wU
  • 프록시 객체는 원본 엔티티를 상속 받음, 따라서 타입 체크시 주의해야함 (== 비교 실패, instance of 사용) => 프록시로 넘어올지, 원래 객체 타입으로 넘어올지 모른다
@Test
public void 프록시_엔티티상속_테스트() {
    Member member1 = Member.builder()
        .username("member1")
        .build();
    memberRepository.save(member1);

    Member member2 = Member.builder()
        .username("member2")
        .build();
    memberRepository.save(member1);

    entityManager.clear();

    Member m1 = entityManager.find(Member.class, member1.getId());
    Member m2 = entityManager.getReference(Member.class, member2.getId());

    System.out.println("m1 == m2 : " + (m1.getClass() == m2.getClass()));   // false
    System.out.println("m1 instanceof : " + (m1 instanceof Member));        // true
    System.out.println("m2 instanceof : " + (m2 instanceof Member));        // true
}
  • 영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티 반환
@Test
public void 프록시_영속성_테스트() {
    Member member1 = Member.builder()
        .username("member1")
        .build();
    memberRepository.save(member1);

    entityManager.clear();

    Member m1 = entityManager.find(Member.class, member1.getId()); //영속성 상태
    System.out.println("m1 = " + m1.getClass());

    Member references = entityManager.getReference(Member.class, member1.getId());
    System.out.println("reference = " + references.getClass());
}
m1 = class com.jyami.jpalab.domain.Member
reference = class com.jyami.jpalab.domain.Member

멤버를 이미 1차 캐싱했는데 굳이 proxy로 가져오는게 의미가 없다.

JPA는 한 트랜잭션에서 같은거를 보장해준다.
한 영속성 컨텍스트에서 가져온거면 true.

System.out.println(m==reference) // true로 무조껀 만들어 줘야한다 : proxy가 아닌 실 값 가져옴

Member reference1 = entityManager.getReference(Member.class, member1.getId());
System.out.println("reference1 = " + reference1.getClass());

Member reference2 = entityManager.getReference(Member.class, member1.getId());
System.out.println("reference2 = " + reference2.getClass());

System.out.println("a == a" + (reference1 == reference2)); //true
reference1 = class com.jyami.jpalab.domain.Member$HibernateProxy$Xr6pfd5T
reference2 = class com.jyami.jpalab.domain.Member$HibernateProxy$Xr6pfd5T

같은 프록시 객체를 가져온다. a == a 를 보장해주어야 하기 때문이다.

Member refMember = entityManager.getReference(Member.class, member1.getId()); 
System.out.println("refMember = " + refMember.getClass());

Member findMember = entityManager.find(Member.class, member1.getId());
System.out.println("findMember = " + findMember.getClass());

System.out.println("a == a" + (refMember == findMember));
refMember = class com.jyami.jpalab.domain.Member$HibernateProxy$HbLZp8PQ
Hibernate: [select 쿼리] 
findMember = class com.jyami.jpalab.domain.Member$HibernateProxy$HbLZp8PQ

find() 에서도 proxy가 반환된다!!

proxy를 한번 조회되면 em.find()에서 proxy를 반환해버린다! == 비교를 완료하려고

"프록시든 아니든 개발에 문제가 없게 하는게 중요하다."

  • 영속성 컨텍스트의 도움을 받을 수 있는 준영속 상태일 때, 프록시를 초기화
Member refMember = entityManager.getReference(Member.class, member1.getId()); //영속성 상태
System.out.println("refMember = " + refMember.getClass());

entityManager.detach(refMember); //영속성 컨텍스트 관리 안한다.
entityManager.close();

assertThatThrownBy(() -> {
    refMember.getUsername();
}).isInstanceOf(org.hibernate.LazyInitializationException.class);

에러 : could not initialize proxy - no Session

영속성 컨텍스트의 도움을 받지 못해서 proxy에 연결되었던 객체에 대한 target이 없어지는 듯

그래서 transaction 설정과 proxy 설정을 같게 하려고 한다~

1-4. 프록시 확인

  • 프록시 인스턴스의 초기화 여부 확인 persistenceUnitUtil.isLoaded(Object entity)
    System.out.println("isLoaded = " + entityManagerFactory.getPersistenceUnitUtil().isLoaded(refMember));
  • 프록시 클래스 확인 방법 entity.getClass().getName() 출력 (..javasist.. or HibernateProxy..)
    System.out.println("refMember = " + refMember.getClass()); //클래스 확인
    System.out.println(refMember.getUsername()); //강제 호출
  • 프록시 강제 초기화
    System.out.println("refMember = " + refMember.getClass());
    Hibernate.initialize(refMember); // 강제 초기화
  • 참고: JPA 표준은 강제 초기화 없음
    강제 호출: member.getName()

2. 즉시로딩과 지연로딩

2-1. 지연로딩 LAZY를 사용해서 프록시로 조회

멤버 클래스만 DB에서 조회한다.

@ManyToOne(fetch = FetchType.LAZY)  ///fecth 설정을 해준다.
@JoinColumn(name = "TEAM_ID")
private Team team;
@Test
public void 지연로딩() {
    Member member = memberRepository.findById(1L).get();
    assertThat(member.getUsername()).isEqualTo("MemberDefault");
    System.out.println("m = " + member.getTeam().getClass()); 
    //getTeam()은 프록시 가져오는 것
}
Hibernate: select 
    member0_.member_id as member_i1_0_0_, 
    member0_.team_id as team_id3_0_0_, 
    member0_.username as username2_0_0_ 
from 
    member member0_ 
where 
    member0_.member_id=?
m = class com.jyami.jpalab.domain.Team$HibernateProxy$gs0vf0Qv

멤버만 나가는 걸 알 수 있다!
그리고 Team은 proxy 객체를 가져온다.

System.out.println("team.name = " + member.getTeam().getName());
select 
    team0_.member_id as member_i1_1_0_, 
    team0_.name as name2_1_0_ 
from 
    team team0_ 
where 
    team0_.member_id=?

그래서 위와 같이 영속성 컨텍스트 초기화를 하게 될 때 그때 쿼리가 나간다.

  • Member에서 Team을 가져올 때 Lazy로 설정해두었기 때문에,
    Team 객체 안에는 프록시 객체를 넣어둔다.
    실제 team을 사용하는 시점에 영속성 컨텍스트 초기화를 한다.
  • BM 상에서 Member조회시 Team을 같이 조회하지 않을 때 LAZY를 사용하면!

2-2. 즉시로딩 EAGER를 사용해서 함께 조회

@ManyToOne(fetch = FetchType.EAGER)  ///fecth 설정을 해준다.
@JoinColumn(name = "TEAM_ID")
private Team team;
Hibernate: insert into team (member_id, name) values (null, ?)
Hibernate: insert into member (member_id, team_id, username) values (null, ?, ?)
Hibernate: select 
    member0_.member_id as member_i1_0_0_, 
    member0_.team_id as team_id3_0_0_, 
    member0_.username as username2_0_0_, 
    team1_.member_id as member_i1_1_1_, 
    team1_.name as name2_1_1_ 
from 
    member member0_ 
left outer join 
    team team1_ on member0_.team_id=team1_.member_id 
where 
    member0_.member_id=?
m = class com.jyami.jpalab.domain.Team

즉시 로딩이기 때문에 Proxy를 가져올 필요가 없어서
getClass() 를 했을 때 실제 객체가 나온다!

proxy를 가져오지 않으니까 영속성 컨텍스트 초기화를 해줄 필요가 없다.

BM 상에서 Mebmer를 쓸때 항상 Team도 조회할 경우!

JPA 구현체는 가능하면 조인을 사용해서 SQL 한번에 함께 조회

2-3. 프록시와 즉시로딩 주의

  1. 가급적 지연 로딩만 사용(특히 실무에서)
    만약 관련 링크객체가 N개면 N개만큼 Join이 발생해서 나간다.
  2. 즉시 로딩을 적용하면 예상하지 못한 SQL이 발생
  3. 즉시로딩을 JPQL에서 N+1 문제를 일으킨다.
  4. @ManyToOne, @OneToOne은 기본이 즉시로딩 -> LAZY로 설정 (X To One 시리즈)
  5. @OneToMany, @ManyToMany는 기본이 지연 로딩

2-3-1. JPQL N+1 문제 preview

  @Test
  public void JPQL의_N_플러스_1_문제() {
      List<Member> members = entityManager.createQuery("select m from Member m", Member.class)
          .getResultList();
  }
  Hibernate: select 
      member0_.member_id as member_i1_0_, 
      member0_.team_id as team_id3_0_, 
      member0_.username as username2_0_ 
  from 
      member member0_
  Hibernate: select 
      team0_.member_id as member_i1_1_0_,
      team0_.name as name2_1_0_ 
     from 
     team team0_ 
     where 
         team0_.member_id=?
  • 쿼리가 두번나간다!!
  • JPQL : 1번째 파라미터가 sql query로 그대로 읽힌다. 따라서 쿼리대로 Member를 가져온다. 근데 Team이 즉시로딩이 되어있음! 즉시로딩이라 무조껀 그안에 값이 들어가 있어야 하기 때문에 Team도 가져온다. 따라서 Team 쿼리를 또 따로 보낸다.
  • 쿼리가 N+1 나간다
    • 1 : 처음에 내보낸 쿼리 (N개의 Member 리턴)
    • N : EAGER 설정이 되어있어 참조 객체를 가져오기 위한 추가 쿼리 (N개의 Member 각각의 Team 값을 채우기 위해 각 Team을 찾기위해 N개의 쿼리가 나간다.)
  • 이걸 LAZY로 잡으면 그냥 Member만 가져오고, Team은 proxy 객체라서 쿼리가 1개만 나가게된다.
  • 해결 기본은 fetchJoin : runtime에 동적으로 내가 원하는애들만 선택해서 가져온다. application안에서도 member만 가져올 때 / member + team 가져올때가 구분되기 때문에
    List<Member> members = entityManager.createQuery("select m from Member m join fecth m.team", Member.class).getResultList();
    이 한방 쿼리에 모든게 들어가 있다.

2-4. 지연 로딩 활용

지금은 굉장히 이론적이고, 실무에서는 그냥 다 LAZY로 해야한다.

  • Member와 Team은 자주 함께 사용 : 즉시로딩
  • Member와 Order는 가끔 사용 : 지연로딩
  • Order와 Product는 자주 함께 사용 : 즉시로딩

3. 영속성 전이: CASCADE

  • 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속성 상태로 만들고 싶을 때
  • 예 : 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장
  • 영속성 전이는 연관관계를 매핑하는 것과는 아무 관련이 없음
  • 엔티티를 영속화할 때 연관된 엔티티도 함께 영속화하는 편리함을 제공할 뿐
@Entity
@NoArgsConstructor
@Getter
public class Parent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    //cascade 옵션 : Parent를 저장할 때 child도 같이 저장하고 싶다.
    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
    private List<Child> childList = new ArrayList<>();

    @Builder
    public Parent(String name) {
        this.name = name;
    }
}
@Entity
@Getter
@NoArgsConstructor
public class Child {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn
    private Parent parent;

    @Builder
    public Child(String name, Parent parent) {
        this.name = name;
        this.parent = parent;
        parent.getChildList().add(this); //양방향 위해 추가함!
    }
}

[테스트 코드]

@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class ParentTest {
    @Autowired
    ParentRepository parentRepository;

    @Autowired
    ChildRepository childRepository;

    @Autowired
    EntityManager entityManager;

    @Before
    public void setUp() throws Exception {
        Parent parent = Parent.builder()
                .name("parent")
                .build();

        Child child1 = Child.builder()
                .parent(parent)
                .name("child1")
                .build();

        Child child2 = Child.builder()
                .parent(parent)
                .name("child2")
                .build();

        parentRepository.save(parent);

        entityManager.clear(); //영속성 컨텍스트 제거
    }

    @Test
    public void Parent만_저장해도_Child_저장되는지_확인(){
        Parent parent = parentRepository.findById(1L).get();
        for(Child child: parent.getChildList()){
            assertThat(child.getName()).startsWith("child");
        }
    }
}
Hibernate: insert into parent (id, name) values (null, ?)
Hibernate: insert into child (id, name, parent_id) values (null, ?, ?)
Hibernate: insert into child (id, name, parent_id) values (null, ?, ?)
---
Hibernate: select parent0_.id as id1_2_0_, parent0_.name as name2_2_0_ from parent parent0_ where parent0_.id=?
Hibernate: select childlist0_.parent_id as parent_i3_0_0_, childlist0_.id as id1_0_0_, childlist0_.id as id1_0_1_, childlist0_.name as name2_0_1_, childlist0_.parent_id as parent_i3_0_1_ from child childlist0_ where childlist0_.parent_id=?

심플하게 Parent를 저장할 때, Parent안에 있는 객체인 Child도 같이 저장할 때

3-1. CASCADE의 종류

  • ALL : 모두 적용
  • PERSIST : 영속 - 저장할 때만 lifecycle을 맞출래
  • REMOVE : 삭제
  • MERGE : 병합
  • REFERESH : refresh
  • DETACH : detach

하나의 부모가 자식들을 관리할 때는 의미가 있다.
ex ) 게시판에 댓글, 첨부파일의 경로 등이 들어갈 때 : 의미 있음

그러나 여러 엔티티에서 관리한다면 쓰면 안된다.

소유자가 하나일 때는 써도 된다.

단일 엔티티에 완전히 종속적일 때 사용하자

Child와 Parent의 lifecycle이 완전히 비슷할 때 사용하자

4. 고아객체

  • 고아 객체 제거 : 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제 JPA는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데 이것을 고아 객체 제거라 한다. 이 기능을 사용해서 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제 된다.
  • orphanRemoval = true
    Parent parent1 = em.find(Parent.class, id);
    parent1.getChildren().remove(0);
    // 자식 엔티티를 컬렉션에서 제거
    DELETE FROM CHILD WHERE ID = ?
    연관관계가 끊어져버린 상태 > delete가 나간다.
    public class Parent{
        @OneToMany(mappedBy = "parent", orphanRemoval = true) // orphanRemoval 옵션 추가
        private List<Child> childList = new ArrayList<>();    
    }
  • 참조가 제거된 엔티티는 다른 곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능
  • 참조하는 곳이 하나일 때 사용해야함!!
  • 특정 엔티티가 개인 소유할 때 사용
  • @OneToOne, @OneToMany만 가능
  • 참고 : 개념적으로 부모를 제거하면 자식은 고아가된다.
    따라서 고아 객체 기능을 제거 기능을 활성화하면, 부모를 제거할 때 자식도 함께 제거된다.
    이것은 CascadeType.REMOVE 처럼 동작한다.

흠 근데 왜 난 안되지ㅠㅠ 물어봐야겠다.

5. 영속성 전이 + 고아 객체, 생명주기

public class Parent{
    @OneToMany(mappedBy = "parent", cascade = CascadeType=ALL, orphanRemoval = true)
    private List<Child> childList = new ArrayList<>();    
}
  • CasecadeType.ALL + orphanRemovel = true
  • 스스로 생명주기를 관리하는 엔티티는 em.persist()로 영속화, em.remove()로 제거
  • 두 옵션을 모두 활성화 하면 부모 엔티티를 통해서 자식의 생명주기를 관리할 수 있다. 자식 repository가 필요 없어진다.
  • 도메인 주도 설계(DDD)의 Aggregate Root 개념을 구현할 때 유용

This is a summary post written after taking Kim Young-han's Java ORM Standard JPA Programming - Basics course on Inflearn.

https://www.inflearn.com/course/ORM-JPA-Basic

 

Java ORM Standard JPA Programming - Basics - Inflearn

For those who are new to JPA or use JPA in practice but lack foundational theory — this course helps you build a solid understanding of JPA basics so that even beginners can confidently use JPA in real-world projects. Beginner Web Development Server Database Frameworks & Libraries Programming Languages Service Development Java JPA Spring Data JPA Online Course

www.inflearn.com

I've been using Spring Data JPA normally, but Kim Young-han actually teaches JPA itself.

Based on his lectures, I decided to write this post to test the course material with Spring Data JPA and document the concepts.



Proxy and Relationship Management

1. Proxy

When querying a Member, should we always fetch the Team together?

  • When fetching Member, also print Team together
    • It's good if JPA fetches Team when fetching Member.
  • When fetching Member, only fetch Member!
    • It's not good if JPA fetches Team when fetching Member!

1-1. Proxy Basics

  • em.find() : Retrieves the actual entity object through the DB
  • em.getReference() : Retrieves a fake (proxy) entity object that defers the database lookup

The query doesn't hit the DB, yet you can still retrieve the object.

@Test
public void 멤버와조회할때_팀도함께_조회() {
    Member findMember = entityManager.find(Member.class, 1L);
    System.out.println("findMember.id = " + findMember.getId());
    System.out.println("findMember.username = " + findMember.getUsername());
    }
select 
    member0_.member_id as member_i1_0_0_, 
    member0_.team_id as team_id3_0_0_, 
    member0_.username as username2_0_0_, 
    team1_.member_id as member_i1_1_1_, 
    team1_.name as name2_1_1_ 
from 
    member member0_ 
left outer join 
    team team1_ 
        on member0_.team_id=team1_.member_id 
where member0_.member_id=?

When querying Member, Team is automatically joined and fetched together.

@Test
public void 멤버만_조회() {
    Member findMember = entityManager.getReference(Member.class, 1L);
}

In this case, no select query is executed!!

@Test
public void 멤버만_조회() {
    Member findMember = entityManager.getReference(Member.class, 1L);
    System.out.println("findMember.id = " + findMember.getId());
    System.out.println("findMember.username = " + findMember.getUsername());
}

In this case, the select query is executed!

At the point when getReference() is called, no query is sent to the DB.
The query is sent to the DB when the value is actually used (username).

System.out.println("findMember = " + findMember.getClass());
findMember = class com.jyami.jpalab.domain.Member$HibernateProxy$injSwDL2

The name isn't Member! HibernateProxy: it's a forcibly created fake class — a proxy class

1-2. Proxy Characteristics

  • Created by inheriting the actual class
  • Looks the same as the actual class on the outside
  • From the user's perspective, you don't need to distinguish between the real object and the proxy object (in theory)
  • The proxy object holds a reference (target) to the actual object
  • When you call the proxy object, it delegates the call to the actual object's method
em.getReference(Member.class, 1L); //프록시객체 가져온다.

getName() > The Member target has no value > Requests the persistence context to fetch the actual value > The DB fetches that value and links the real object to the Proxy object. So it gets the name via target.getName().

Initialization request to the persistence context: when the proxy has no value, ask the DB for the real value.

1-3. Proxy Object Mechanism

  • The proxy object is initialized only once, on first use
@Test
public void 프록시_테스트() {
    Member findMember = entityManager.getReference(Member.class, 1L);
    System.out.println("1st = " + findMember.getUsername());
        //1st에서는 query가 나간다.
    System.out.println("2nd = " + findMember.getUsername());
        //2nd에서는 query가 나가지 않는다.
}
  • When a proxy object is initialized, it doesn't turn into the actual entity — once initialized, you can access the actual entity through the proxy object
@Test
public void 프록시_테스트() {
    Member findMember = entityManager.getReference(Member.class, 1L);
    System.out.println("before findMember = " + findMember.getClass());
    System.out.println("findMember.username = " + findMember.getUsername());
    System.out.println("after findMember = " + findMember.getClass());
}
before findMember = class com.jyami.jpalab.domain.Member$HibernateProxy$EYDMo7wU
Hibernate: [select query]
findMember.username = member1
after findMember = class com.jyami.jpalab.domain.Member$HibernateProxy$EYDMo7wU
  • The proxy object inherits the original entity, so be careful with type checking (== comparison fails, use instanceof) => You never know if it'll come as a proxy or the original object type
@Test
public void 프록시_엔티티상속_테스트() {
    Member member1 = Member.builder()
        .username("member1")
        .build();
    memberRepository.save(member1);

    Member member2 = Member.builder()
        .username("member2")
        .build();
    memberRepository.save(member1);

    entityManager.clear();

    Member m1 = entityManager.find(Member.class, member1.getId());
    Member m2 = entityManager.getReference(Member.class, member2.getId());

    System.out.println("m1 == m2 : " + (m1.getClass() == m2.getClass()));   // false
    System.out.println("m1 instanceof : " + (m1 instanceof Member));        // true
    System.out.println("m2 instanceof : " + (m2 instanceof Member));        // true
}
  • If the entity being looked up already exists in the persistence context, calling em.getReference() returns the actual entity
@Test
public void 프록시_영속성_테스트() {
    Member member1 = Member.builder()
        .username("member1")
        .build();
    memberRepository.save(member1);

    entityManager.clear();

    Member m1 = entityManager.find(Member.class, member1.getId()); //영속성 상태
    System.out.println("m1 = " + m1.getClass());

    Member references = entityManager.getReference(Member.class, member1.getId());
    System.out.println("reference = " + references.getClass());
}
m1 = class com.jyami.jpalab.domain.Member
reference = class com.jyami.jpalab.domain.Member

If the member is already in the first-level cache, there's no point in fetching it as a proxy.

JPA guarantees identity within a single transaction.
If fetched from the same persistence context, it returns true.

System.out.println(m==reference) // must always return true: it fetches the real value, not a proxy

Member reference1 = entityManager.getReference(Member.class, member1.getId());
System.out.println("reference1 = " + reference1.getClass());

Member reference2 = entityManager.getReference(Member.class, member1.getId());
System.out.println("reference2 = " + reference2.getClass());

System.out.println("a == a" + (reference1 == reference2)); //true
reference1 = class com.jyami.jpalab.domain.Member$HibernateProxy$Xr6pfd5T
reference2 = class com.jyami.jpalab.domain.Member$HibernateProxy$Xr6pfd5T

It returns the same proxy object because it must guarantee a == a.

Member refMember = entityManager.getReference(Member.class, member1.getId()); 
System.out.println("refMember = " + refMember.getClass());

Member findMember = entityManager.find(Member.class, member1.getId());
System.out.println("findMember = " + findMember.getClass());

System.out.println("a == a" + (refMember == findMember));
refMember = class com.jyami.jpalab.domain.Member$HibernateProxy$HbLZp8PQ
Hibernate: [select 쿼리] 
findMember = class com.jyami.jpalab.domain.Member$HibernateProxy$HbLZp8PQ

Even find() returns a proxy!!

Once a proxy has been retrieved, em.find() returns the proxy too! This is to satisfy the == comparison.

"What matters is that it works correctly regardless of whether it's a proxy or not."

  • Initializing a proxy when in a detached state where the persistence context can no longer help
Member refMember = entityManager.getReference(Member.class, member1.getId()); //영속성 상태
System.out.println("refMember = " + refMember.getClass());

entityManager.detach(refMember); //영속성 컨텍스트 관리 안한다.
entityManager.close();

assertThatThrownBy(() -> {
    refMember.getUsername();
}).isInstanceOf(org.hibernate.LazyInitializationException.class);

Error: could not initialize proxy - no Session

Since the persistence context can no longer help, it seems like the target linked to the proxy object is lost.

That's why you want to align the transaction scope with the proxy scope~

1-4. Proxy Inspection

  • Check whether a proxy instance has been initialized: persistenceUnitUtil.isLoaded(Object entity)
    System.out.println("isLoaded = " + entityManagerFactory.getPersistenceUnitUtil().isLoaded(refMember));
  • How to check the proxy class: print entity.getClass().getName() (..javasist.. or HibernateProxy..)
    System.out.println("refMember = " + refMember.getClass()); //클래스 확인
    System.out.println(refMember.getUsername()); //강제 호출
  • Force initialize a proxy
    System.out.println("refMember = " + refMember.getClass());
    Hibernate.initialize(refMember); // 강제 초기화
  • Note: The JPA standard does not have forced initialization
    Forced invocation: member.getName()

2. Eager Loading and Lazy Loading

2-1. Using Lazy Loading (LAZY) to Fetch via Proxy

Only the Member class is fetched from the DB.

@ManyToOne(fetch = FetchType.LAZY)  ///fecth 설정을 해준다.
@JoinColumn(name = "TEAM_ID")
private Team team;
@Test
public void 지연로딩() {
    Member member = memberRepository.findById(1L).get();
    assertThat(member.getUsername()).isEqualTo("MemberDefault");
    System.out.println("m = " + member.getTeam().getClass()); 
    //getTeam()은 프록시 가져오는 것
}
Hibernate: select 
    member0_.member_id as member_i1_0_0_, 
    member0_.team_id as team_id3_0_0_, 
    member0_.username as username2_0_0_ 
from 
    member member0_ 
where 
    member0_.member_id=?
m = class com.jyami.jpalab.domain.Team$HibernateProxy$gs0vf0Qv

You can see that only Member is queried!
And Team returns a proxy object.

System.out.println("team.name = " + member.getTeam().getName());
select 
    team0_.member_id as member_i1_1_0_, 
    team0_.name as name2_1_0_ 
from 
    team team0_ 
where 
    team0_.member_id=?

So the query is only fired when the persistence context initialization happens, like above.

  • Since fetching Team from Member is set to Lazy,
    a proxy object is placed inside the Team object.
    The persistence context initialization happens at the point when Team is actually used.
  • When you don't need to fetch Team together when querying Member in your business model, use LAZY!

2-2. Using Eager Loading (EAGER) to Fetch Together

@ManyToOne(fetch = FetchType.EAGER)  ///fecth 설정을 해준다.
@JoinColumn(name = "TEAM_ID")
private Team team;
Hibernate: insert into team (member_id, name) values (null, ?)
Hibernate: insert into member (member_id, team_id, username) values (null, ?, ?)
Hibernate: select 
    member0_.member_id as member_i1_0_0_, 
    member0_.team_id as team_id3_0_0_, 
    member0_.username as username2_0_0_, 
    team1_.member_id as member_i1_1_1_, 
    team1_.name as name2_1_1_ 
from 
    member member0_ 
left outer join 
    team team1_ on member0_.team_id=team1_.member_id 
where 
    member0_.member_id=?
m = class com.jyami.jpalab.domain.Team

Since it's eager loading, there's no need to fetch a Proxy, so
when you call getClass(), the actual object is returned!

Since it doesn't fetch a proxy, there's no need for persistence context initialization.

Use this when you always need to fetch Team whenever you use Member in your business model!

The JPA implementation tries to use joins to fetch everything in a single SQL query

2-3. Cautions with Proxy and Eager Loading

  1. Use lazy loading as much as possible (especially in production)
    If there are N related linked objects, N joins will be executed.
  2. Applying eager loading can cause unexpected SQL queries
  3. Eager loading causes the N+1 problem in JPQL
  4. @ManyToOne and @OneToOne default to eager loading -> Set them to LAZY (the X-To-One series)
  5. @OneToMany and @ManyToMany default to lazy loading

2-3-1. JPQL N+1 Problem Preview

  @Test
  public void JPQL의_N_플러스_1_문제() {
      List<Member> members = entityManager.createQuery("select m from Member m", Member.class)
          .getResultList();
  }
  Hibernate: select 
      member0_.member_id as member_i1_0_, 
      member0_.team_id as team_id3_0_, 
      member0_.username as username2_0_ 
  from 
      member member0_
  Hibernate: select 
      team0_.member_id as member_i1_1_0_,
      team0_.name as name2_1_0_ 
     from 
     team team0_ 
     where 
         team0_.member_id=?
  • Two queries are fired!!
  • JPQL: The first parameter is read directly as an SQL query. So it fetches Member as the query specifies. But Team is set to eager loading! Since it's eager, the values must always be populated, so it fetches Team too. Therefore, it sends a separate query for Team.
  • N+1 queries are fired
    • 1: The initial query (returns N Members)
    • N: Additional queries to fetch referenced objects due to EAGER setting (N queries are fired to fill in each Team value for each of the N Members)
  • If you set this to LAZY, it just fetches Member, and since Team is a proxy object, only 1 query is fired.
  • The basic solution is fetch join: it dynamically selects and fetches only what you want at runtime. Since within the application there are times when you only need Member vs. times when you need Member + Team:
    List<Member> members = entityManager.createQuery("select m from Member m join fecth m.team", Member.class).getResultList();
    Everything is included in this single query.

2-4. Lazy Loading in Practice

This is all very theoretical for now — in practice, you should just use LAZY for everything.

  • Member and Team are frequently used together: Eager loading
  • Member and Order are occasionally used together: Lazy loading
  • Order and Product are frequently used together: Eager loading

3. Cascade (Persistence Propagation)

  • When making a specific entity persistent, you may want to make its associated entities persistent as well
  • Example: Saving child entities together when saving a parent entity
  • Cascade has nothing to do with mapping relationships
  • It simply provides the convenience of persisting associated entities together when persisting an entity
@Entity
@NoArgsConstructor
@Getter
public class Parent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    //cascade 옵션 : Parent를 저장할 때 child도 같이 저장하고 싶다.
    @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
    private List<Child> childList = new ArrayList<>();

    @Builder
    public Parent(String name) {
        this.name = name;
    }
}
@Entity
@Getter
@NoArgsConstructor
public class Child {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn
    private Parent parent;

    @Builder
    public Child(String name, Parent parent) {
        this.name = name;
        this.parent = parent;
        parent.getChildList().add(this); //양방향 위해 추가함!
    }
}

[Test Code]

@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class ParentTest {
    @Autowired
    ParentRepository parentRepository;

    @Autowired
    ChildRepository childRepository;

    @Autowired
    EntityManager entityManager;

    @Before
    public void setUp() throws Exception {
        Parent parent = Parent.builder()
                .name("parent")
                .build();

        Child child1 = Child.builder()
                .parent(parent)
                .name("child1")
                .build();

        Child child2 = Child.builder()
                .parent(parent)
                .name("child2")
                .build();

        parentRepository.save(parent);

        entityManager.clear(); //영속성 컨텍스트 제거
    }

    @Test
    public void Parent만_저장해도_Child_저장되는지_확인(){
        Parent parent = parentRepository.findById(1L).get();
        for(Child child: parent.getChildList()){
            assertThat(child.getName()).startsWith("child");
        }
    }
}
Hibernate: insert into parent (id, name) values (null, ?)
Hibernate: insert into child (id, name, parent_id) values (null, ?, ?)
Hibernate: insert into child (id, name, parent_id) values (null, ?, ?)
---
Hibernate: select parent0_.id as id1_2_0_, parent0_.name as name2_2_0_ from parent parent0_ where parent0_.id=?
Hibernate: select childlist0_.parent_id as parent_i3_0_0_, childlist0_.id as id1_0_0_, childlist0_.id as id1_0_1_, childlist0_.name as name2_0_1_, childlist0_.parent_id as parent_i3_0_1_ from child childlist0_ where childlist0_.parent_id=?

Simply put, when saving a Parent, you also want to save the Child objects inside the Parent.

3-1. Types of CASCADE

  • ALL: Apply all
  • PERSIST: Persistence — only sync the lifecycle when saving
  • REMOVE: Delete
  • MERGE: Merge
  • REFERESH: Refresh
  • DETACH: Detach

It's meaningful when a single parent manages its children.
e.g.) When a board post has comments, attachment file paths, etc.: meaningful

However, you should NOT use it when multiple entities manage the same thing.

It's fine to use when there's a single owner.

Use it when something is completely dependent on a single entity.

Use it when the lifecycles of Child and Parent are completely aligned.

4. Orphan Objects

  • Orphan removal: Automatically deletes child entities whose relationship with the parent entity is severed. JPA provides a feature that automatically deletes child entities that are disconnected from their parent entity — this is called orphan removal. Using this feature, if you simply remove the reference to a child entity from the parent entity's collection, the child entity is automatically deleted.
  • orphanRemoval = true
    Parent parent1 = em.find(Parent.class, id);
    parent1.getChildren().remove(0);
    // 자식 엔티티를 컬렉션에서 제거
    DELETE FROM CHILD WHERE ID = ?
    The relationship is severed > a DELETE is executed.
    public class Parent{
        @OneToMany(mappedBy = "parent", orphanRemoval = true) // orphanRemoval 옵션 추가
        private List<Child> childList = new ArrayList<>();    
    }
  • It treats entities whose references have been removed as orphan objects that are not referenced anywhere else, and deletes them
  • Should only be used when there is exactly one place referencing it!!
  • Use when a specific entity has sole ownership
  • Only available for @OneToOne and @OneToMany
  • Note: Conceptually, when a parent is removed, the children become orphans.
    Therefore, when orphan removal is enabled, removing the parent also removes the children.
    This behaves like CascadeType.REMOVE.

Hmm, but why isn't it working for me 😭 I should ask about this.

5. Cascade + Orphan Removal, Lifecycle

public class Parent{
    @OneToMany(mappedBy = "parent", cascade = CascadeType=ALL, orphanRemoval = true)
    private List<Child> childList = new ArrayList<>();    
}
  • CascadeType.ALL + orphanRemoval = true
  • Entities that manage their own lifecycle use em.persist() to persist and em.remove() to remove
  • When both options are enabled, you can manage the child's lifecycle through the parent entity. The child repository becomes unnecessary.
  • Useful when implementing the Aggregate Root concept from Domain-Driven Design (DDD)

댓글

Comments

Develop/Springboot

[JPA] 다양한 연관관계 매핑 - @OneToMany @ManyToOne @OneToOne @ManyToOne | [JPA] Various Association Mapping - @OneToMany @ManyToOne @OneToOne @ManyToOne

인프런에서 에서 김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본편을 듣고 쓴 정리 글입니다.https://www.inflearn.com/course/ORM-JPA-Basic 자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다. 초급 웹 개발 서버 데이터베이스 프레임워크 및 라이브러리 프로그래밍 언어 서비스 개발 Java JPA 스프링 데이터 JPA 온라인 강의www.inflearn.com평소에 Spring Data JPA 를 썼는데, 김영한님은 JPA 자체를 강의하시더라구요.김영한님 강의 바탕으로 Spring Data ..

[JPA] 다양한 연관관계 매핑 - @OneToMany @ManyToOne @OneToOne @ManyToOne | [JPA] Various Association Mapping - @OneToMany @ManyToOne @OneToOne @ManyToOne

728x90

인프런에서 에서 김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본편을 듣고 쓴 정리 글입니다.

https://www.inflearn.com/course/ORM-JPA-Basic

 

자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런

JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다. 초급 웹 개발 서버 데이터베이스 프레임워크 및 라이브러리 프로그래밍 언어 서비스 개발 Java JPA 스프링 데이터 JPA 온라인 강의

www.inflearn.com

평소에 Spring Data JPA 를 썼는데, 김영한님은 JPA 자체를 강의하시더라구요.

김영한님 강의 바탕으로 Spring Data JPA로 강의 소스를 테스트해보고 개념을 기록하기 위해 포스팅을 하게되었습니다.



다양한 연관관계 매핑

1.연간관계 매핑시 고려사항 3가지

1-1. 다중성

  • 다대일 [N:1] : @ManyToOne
  • 일대다 [1:N] : @OneToMany
  • 일대일 [1:1] : @OneToOne
  • 다대다 [N:M] : @ManyToMany

1-2. 단방향, 양방향

1-3. 연관관계의 주인

  • 테이블은 외래 키 하나로 두 테이블이 연관관계를 맺음
  • 객체 양방향 관계는 A->B, B-> A처럼 참조가 2군데
  • 객체 양방향 관계는 참조가 2군데 있다. 둘중 테이블의 외래 키를 관리하는 곳을 지정해야함
    A를 바꿀때 B도 같이 바꿀지 / B를 바꿀때 A도 같이 바꿀지
  • 연관관계의 주인 : 외래 키를 관리하는 참조
  • 주인의 반대편 : 외래 키에 영향을 주지 않음

2. 다대일 [N:1]

연관관계의 주인 : N이다

2-1. 다대일 단방향

Member : N - Team : 1

Member에서 Team을 참조한다.

@Entity
@Getter
@Setter
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID") // 외래키
    private Team team;

}
@Entity
@Getter
@Setter
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;
    private String name;
}

외래키가 있는 곳에 참조를 걸고 연관관계 매핑을 한다.

DB입장에서 보면 당연히 N에서 FK가 있어야한다.

반대로 Team이라면, list가 들어가니까 설계가 안맞다.

  • 가장 많이 사용한다
  • 다대일의 반대는 일대다 이다.

2-2. 다대일 양방향

Member : N - Team : 1

Member에서 Team을 참조한다. Team에서도 Member를!

연관관계 주인이 FK 관리한다.
반대쪽은 어차피 읽기만 가능하기 때문에 Team에서 List를 추가하기만 하면 된다.

이때 mappedBy로 연관관계의 주인을 읽을 것이라는 것 명시가 중요

// Team 클래스
    @OneToMany(mappedBy = "team") //참조를 당하는 쪽에서 읽기만 가능! 
    private List<Member> members = new ArrayList<>();
  • 외래키가 있는 쪽이 연관관계의 주인
  • 양쪽을 서로 참조하도록 개발

3. 일대다 [1:N]

3-1. 일대다 단방향

권장하지 않는다.

Team을 중심으로! : Team에서 외래키를 관리

Team은 Member를 알고싶은데 Member는 Team을 알고싶지 않음.

DB입장 : Member에 FK 걸어야한다.

Team의 List 바꾸었을 때 DB의 Mebmer중에 어떤 것의 TEAM_ID를 바꿔야한다.

@Entity
@Getter
@Setter
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

}
@Entity
@Getter
@Setter
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;
    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<>();
}

DB에는 잘 들어가는데 UpdateQurey가 나가는 등 query가 많이 나간다.

team에서 Member list를 저장할 때, Member테이블에도 team_id를 update해줘야한다.

Team을 건드렸는데 Member 테이블에 영향이간다 > 이해, 추적에서 조금 어렵다.

  • 일대다 단방향은 일대다(1:N)에서 일(1)이 연관관계의 주인
  • 테이블 일대다 관계는 항상 다(N) 쪽에 외래 키가 있음
  • 객체와 테이블의 차이 때문에 반대편 테이블의 외래 키를 관리하는 특이한 구조
  • @JoinColumn을 꼭 사용해야 함. 그렇지 않으면 조인 테이블 방식을 사용 (중간에 테이블 하나 추가) team_member라는 중간테이블이 생겨버린다 : team_id와 member_id를 갖고있다. 단점 : 테이블이 1개 더들어가서 운영이 어렵다.
  • 일대다 단반향 매핑의 단점
    • 엔티티가 관리하는 외래키가 다른 테이블에 있음
    • 연관관계 관리를 위해 추가로 UPDATE SQL 실행
  • 일대다 단방향 매핑보다는 다대일 양방향 매핑을 사용하자 : 객체관계를 조금 포기!

3-2. 일대다 양방향

약간 야매로 된다ㅋㅋㅋ

// Member 클래스
    @ManyToOne
    @JoinColumn(name="TEAM_ID", insertable = false, updatable = false) //중요!!
    private Team team;

근데 이러면 Team Member모두 @JoinColumn이 붙어서 둘다 연관관계의 주인이된다.

그래서 JoinColumn의 옵션을 사용해서 mapping은 되어있고 값은 다 쓰는데 insertable, updatable을 막아 read 전용으로 만든다.

관리는 Team으로하고 Member는 읽기만한다.

  • 이런 매핑은 공식적으로 존재 X
  • @JoinColumn(insertable=false, updatable=false)
  • 읽기 전용 필드를 사용해서 양방향 처럼 사용하는 방법
  • 다대일 양방향을 사용하자

일대다 일대일 [1:1]

다대다 [N:M]

This is a summary post based on Kim Young-han's Java ORM Standard JPA Programming - Basics course on Inflearn.

https://www.inflearn.com/course/ORM-JPA-Basic

 

Java ORM Standard JPA Programming - Basics - Inflearn

For those who are new to JPA or use JPA in practice but lack the fundamental theory — this course helps you build a solid foundation so that even beginners can confidently use JPA in real-world projects. Beginner Web Development Server Database Frameworks & Libraries Programming Languages Service Development Java JPA Spring Data JPA Online Course

www.inflearn.com

I've been using Spring Data JPA, but Kim Young-han actually teaches JPA itself.

Based on his lectures, I decided to write this post to test the course material with Spring Data JPA and document the concepts.



Various Association Mappings

1. Three Things to Consider When Mapping Associations

1-1. Multiplicity

  • Many-to-One [N:1] : @ManyToOne
  • One-to-Many [1:N] : @OneToMany
  • One-to-One [1:1] : @OneToOne
  • Many-to-Many [N:M] : @ManyToMany

1-2. Unidirectional vs. Bidirectional

1-3. Owner of the Association

  • Tables establish an association between two tables with a single foreign key
  • In a bidirectional object relationship, there are two references: A→B and B→A
  • Since there are two references in a bidirectional object relationship, you need to designate which side manages the foreign key
    When you change A, should B also change? / When you change B, should A also change?
  • Owner of the association: the reference that manages the foreign key
  • Inverse side: does not affect the foreign key

2. Many-to-One [N:1]

Owner of the association: N side

2-1. Many-to-One Unidirectional

Member : N - Team : 1

Member references Team.

@Entity
@Getter
@Setter
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

    @ManyToOne
    @JoinColumn(name = "TEAM_ID") // 외래키
    private Team team;

}
@Entity
@Getter
@Setter
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;
    private String name;
}

You place the reference and map the association where the foreign key exists.

From the DB's perspective, the FK naturally belongs on the N side.

If it were on the Team side instead, you'd need a list, and the design wouldn't make sense.

  • This is the most commonly used mapping
  • The inverse of Many-to-One is One-to-Many

2-2. Many-to-One Bidirectional

Member : N - Team : 1

Member references Team. And Team also references Member!

The owner of the association manages the FK.
Since the inverse side can only read anyway, you just need to add a List to Team.

Here, it's important to specify with mappedBy that this side reads from the association owner

// Team 클래스
    @OneToMany(mappedBy = "team") //참조를 당하는 쪽에서 읽기만 가능! 
    private List<Member> members = new ArrayList<>();
  • The side with the foreign key is the owner of the association
  • Develop so that both sides reference each other

3. One-to-Many [1:N]

3-1. One-to-Many Unidirectional

This is not recommended.

Centered around Team: Team manages the foreign key

Team wants to know about Member, but Member doesn't want to know about Team.

From the DB's perspective: the FK must be on the Member table.

When you modify Team's List, you have to update the TEAM_ID of some row in the Member table in the DB.

@Entity
@Getter
@Setter
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

}
@Entity
@Getter
@Setter
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;
    private String name;

    @OneToMany
    @JoinColumn(name = "TEAM_ID")
    private List<Member> members = new ArrayList<>();
}

The data goes into the DB fine, but extra queries like UPDATE queries are fired.

When saving the Member list from Team, the team_id in the Member table also needs to be updated.

You touched Team, but it affects the Member table — this makes it a bit harder to understand and trace.

  • In One-to-Many unidirectional, the One (1) side is the owner of the association
  • In a One-to-Many table relationship, the foreign key is always on the Many (N) side
  • Due to the mismatch between objects and tables, this results in an unusual structure where you manage the foreign key of the opposite table
  • You must use @JoinColumn. Otherwise, it uses a join table strategy (adds an intermediate table) — a middle table like team_member gets created with team_id and member_id. The downside: having one extra table makes operations harder.
  • Downsides of One-to-Many unidirectional mapping
    • The foreign key managed by the entity is in a different table
    • Additional UPDATE SQL is executed to manage the association
  • Use Many-to-One bidirectional mapping instead of One-to-Many unidirectional — sacrifice a bit on the object relationship side!

3-2. One-to-Many Bidirectional

This kinda works as a hacky workaround lol

// Member 클래스
    @ManyToOne
    @JoinColumn(name="TEAM_ID", insertable = false, updatable = false) //중요!!
    private Team team;

But this way, both Team and Member have @JoinColumn, making both of them owners of the association.

So you use JoinColumn options — the mapping exists and values are all there, but you block insertable and updatable to make it read-only.

Management is done through Team, and Member only reads.

  • This mapping doesn't officially exist
  • @JoinColumn(insertable=false, updatable=false)
  • A way to use it like bidirectional by using a read-only field
  • Just use Many-to-One bidirectional

One-to-Many One-to-One [1:1]

Many-to-Many [N:M]

댓글

Comments

Develop/Springboot

[JPA] 연관관계 매핑 | [JPA] Association Mapping

인프런에서 에서 김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본편을 듣고 쓴 정리 글입니다.https://www.inflearn.com/course/ORM-JPA-Basic 자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다. 초급 웹 개발 서버 데이터베이스 프레임워크 및 라이브러리 프로그래밍 언어 서비스 개발 Java JPA 스프링 데이터 JPA 온라인 강의www.inflearn.com평소에 Spring Data JPA 를 썼는데, 김영한님은 JPA 자체를 강의하시더라구요.김영한님 강의 바탕으로 Spring Data ..

[JPA] 연관관계 매핑 | [JPA] Association Mapping

728x90

인프런에서 에서 김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본편을 듣고 쓴 정리 글입니다.

https://www.inflearn.com/course/ORM-JPA-Basic

 

자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런

JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다. 초급 웹 개발 서버 데이터베이스 프레임워크 및 라이브러리 프로그래밍 언어 서비스 개발 Java JPA 스프링 데이터 JPA 온라인 강의

www.inflearn.com

평소에 Spring Data JPA 를 썼는데, 김영한님은 JPA 자체를 강의하시더라구요.

김영한님 강의 바탕으로 Spring Data JPA로 강의 소스를 테스트해보고 개념을 기록하기 위해 포스팅을 하게되었습니다.


 


연관관계 매핑 기초

1. 단방향 연관관계

<아직 안들음>

2. 양방향 연관관계와 연관관계의 주인 : 기본

양방향 연관관계 -> 양쪽으로 참조한다.

객체 : 참조를 활용
테이블 : FK를 이용한 join

객체-테이블 사이 패러다임 차이를 봐야한다.

2-1. 테이블 연관관계

단방향과 양방향과 차이가 없다.

TEAM->MEMBER 알고싶든, MEMBER -> TEAM알고싶든 Foreign Key로 join해서 알 수 있다.
양방향 단방향 상관없이 FK로 모든 연관관계 알 수 있다.

2-2. 객체 연관관계

Member에서 Team변수를 갖고있으면 Team으로 갈 수 있다.
Team에서는 List를 갖고있어야 Member로 갈 수 있다.

 

멤버변수로 다른 객체를 갖고있어야 서로에게 접근이 가능하다.

 

[참고] : List 멤버변수를 사용할 땐 꼭 new ArrayList<>() 이용해서 초기화를 해주자!
add() 할 때 NullPointError가 안뜨게!

@Entity
@Getter
@NoArgsConstructor
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    @Builder
    private Team(String name) { //여기 그냥 members도 param으로 넣었다가 에러 팡!
        this.name = name;
    }
}
@Entity
@Getter
@NoArgsConstructor
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

    @ManyToOne(cascade = CascadeType.ALL)
    @JoinColumn(name= "TEAM_ID")
    private Team team;

    @Builder
    private Member(String username, Team team) {
        this.username = username;
        this.team = team;
    }
}

궁금한 것

EntitiyTransaction tx = em.getTrasaction();
em.persist(team);
em.flush();
em.clear();

반대 방향으로도 그래프 탐색이 가능해 진다.

@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class MemberTest {
    @Autowired
    MemberRepository memberRepository;

    @Autowired
    TeamRepository teamRepository;

    @Before
    public void setUp() throws Exception {
        Team team = Team.builder()
                .name("TeamA")
                .build();

//        teamRepository.save(team);

        Member member = Member.builder()
                .username("member1")
                .team(team)
                .build();

        memberRepository.save(member);
    }

    @Test
    public void 잘_저장되었는지_불러오기() {
        Member member = memberRepository.findAll().get(0);
        String username = member.getUsername();
        assertThat(username).isEqualTo("member1");

        Team team = member.getTeam();
        assertThat(team.getName()).isEqualTo("TeamA");

        List<Member> members = team.getMembers();
        for (Member m : members) {
            assertThat(m.getUsername()).startsWith("member");
        }

    }

강좌랑 cascade 부분만 달라서 왜 그런가 하고 생각해 봤는데 강좌에서는 save를 두번 했었다.
강좌코드 대로 코딩하고 테스트한 결과는 아래

@Entity
@Getter
@NoArgsConstructor
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

//    @ManyToOne(cascade = CascadeType.ALL)
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    @Builder
    private Member(String username, Team team) {
        this.username = username;
        this.team = team;
    }
}
@Entity
@Getter
@NoArgsConstructor
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    @Builder
    private Team(String name) { //여기 그냥 members도 param으로 넣었다가 에러 팡!
        this.name = name;
    }
}

테스트코드

@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class MemberTest {
    @Autowired
    MemberRepository memberRepository;

    @Autowired
    TeamRepository teamRepository;

    @Before
    public void setUp() throws Exception {
        Team team = Team.builder()
                .name("TeamA")
                .build();

        teamRepository.save(team);

        Member member = Member.builder()
                .username("member1")
                .team(team)
                .build();

        memberRepository.save(member);
    }

    @Test
    public void 잘_저장되었는지_불러오기() {
        Member member = memberRepository.findAll().get(0);
        String username = member.getUsername();
        assertThat(username).isEqualTo("member1");

        Team team = member.getTeam();
        assertThat(team.getName()).isEqualTo("TeamA");

        List<Member> members = team.getMembers();
        for (Member m : members) {
            assertThat(m.getUsername()).startsWith("member");
        }

    }
}

Member 저장할 때 Team을 저장하도록 cascade 설정을 하지 않고,

TestCode 작성시에 Member save , Team save를 각각 해줬다

Team이 이미 save가 된 상태에서 Member를 save할 경우인데,

어차피 DB에서는 Member에 FK가 있기 때문에 매핑이 가능해진다!

객체에서의 매핑은 이미 Team, Member 모두 각자의 참조객체를 갖고있기 때문에 가능하고!

내가 처음에 작성한 코드의 경우에는 member만 저장해서 team도 같이 저장하는 것이었기 때문에 Member를 저장할 때 cascade 옵션을 줘야했다.

따라서 Member repository에 저장하더라도, Team의 Insert를 먼저 실행 후에, Member insert를 진행하여 Member Table의 FK에 Team을 저장해준다.

Hibernate: insert into team (member_id, name) values (null, ?)
Hibernate: insert into member (member_id, team_id, username) values (null, ?, ?)

Hibernate: select member0_.member_id as member_i1_0_, member0_.team_id as team_id3_0_, member0_.username as username2_0_ from member member0_

Q. 양방향 매핑이 좋은가?

A. 객체는 사실 단방향이 좋다! -> 신경쓸게 많음

2-3. 객체와 테이블이 관계를 맺는 차이

2-3-1. 객체의 연관관계 - 2개

​ Member -> Team 연관관계 1개 (단방향) - Team 레퍼런스 객체

​ Team -> Member 연관관계 1개 (단방향) - Member 레퍼런스 객체

  • 객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단방향 관계 2개다
  • 객체를 양방향으로 참조하려면 단방향 연관 관계를 2개 만들어야 한다.
class Member{
    Team team;    // TEAM -> Member (team.getMember())
}
class Team{
    Member member;    // MEMBER -> TEAM (member.getTeam())
}

2-3-2. 테이블의 연관관계 - 1개

​ Team <-> Member 연관관계 1개 (양방향) - FK하나로 양쪽의 연관관계 알 수 있음 (join)

  • 테이블은 외래 키 하나로 두 테이블의 연관관계를 관리
  • MEMBER.TEAM_ID 외래 키 하나로 양방향 연관관계 가짐 (양쪽으로 조인할 수 있다.)
SELECT * 
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID

SELECT * 
FROM TEAM T
JOIN MEMBER M ON T.TEAM_ID = M.TEAM_ID

2-4. 연관관계의 주인

딜레마가 생긴다 > solution : 둘중 하나로 외래키를 관리한다!

  • Team에 있는 List 로 FK를 관리할지
  • Member에 있는 Team으로 FK를 관리할지

2-4-1. 양방향 매핑 규칙

  • 객체의 두 관계중 하나를 연관관계의 주인으로 지정
  • 연관관계의 주인만이 외래 키를 관리 (등록, 수정)
  • 주인이 아닌쪽은 읽기만 가능
  • 주인은 mappedBy 속성 사용X
  • 주인이 아니면 mappedBy 속성으로 주인 지정

mappedBy : 나는 누군가에 의해서 매핑이 되었어! 나는 주인이 아니야!

public class Team {
    @OneToMany(mappedBy = "team") 
    private List<Member> members = new ArrayList<>();
}

public class Member { 
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

mappedBy : 나는 team에 의해서 관리가 된다 : Member 객체의 team 변수에 의해서 관리된다.

@JoinColumn의 Team: 나는 앞으로 Team을 관리할꺼야

2-4-2. 누구를 주인으로?

  • 외래키가 있는 곳을 주인으로 정해라
  • 여기서는 Member.team이 연관관계의 주인!

성능 이슈!

Member의 경우에는 insert 쿼리 하나인데

Team의 경우에는 insert 쿼리 + update 쿼리

DB 입장에서 외래키가 있는 곳이 무조건 N

= N 이 있는 곳이 무조건 주인

= @ManyToOne 이 무조건 주인

3. 양방향 연관관계와 연관관계의 주인 : 주의점, 정리

3-1. 양방향 매핑시 가장 많이 하는 실수

  • 연관관계의 주인에 값을 입력하지 않음
    @RunWith(SpringRunner.class)
    @DataJpaTest
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
    public class FailTest {
        @Autowired
        MemberRepository memberRepository;
    
        @Autowired
        TeamRepository teamRepository;
    
        @Autowired
        EntityManager entityManager;
    
        @Test
        public void 일차캐싱에_따른_저장_테스트() {
    
            Team team = Team.builder()
                    .name("TeamA")
                    .build();
    
            teamRepository.save(team);
    
            Member member = Member.builder()
                    .username("member1")
                    .team(team)
                    .build();
    
    //        team.getMembers().add(member);
    
            memberRepository.save(member);
    
            // 주인(Member)이 연관관계를 설정하지 않음!!
            // 역방향(주인이 아닌 방향)만 연관관계 설정
    //        entityManager.clear();
    
            Team findTeam = teamRepository.findAll().get(0);
            List<Member> members = findTeam.getMembers();
    
            assertThat(members).isEmpty();
        }
    }
  • entityManager.clear(); 을 안했을 경우
    : 1차 캐시를 해서 영속성 컨텍스트가 되어있는 상태 값 세팅 연관관계가 되어있는걸 그냥 가져온다.
    이렇게 실행하면 DB에서 select 쿼리가 안 나간다.
  • Team이 그냥 영속성 컨텍스트에 들어가있어서, team에는 현재 member가 없는상태.
    그러다보니 1차 캐싱으로 인해 아무것도 안들어가 있음!
  • 객체지향적으로 양쪽다 값을 입력해야 한다!

3-2. 양방향 연관관계 주의

  • 순수 객체 상태를 고려해서 항상 양쪽에 값을 성정하자
  • 연관관계 편의 메소드를 생성하자
  • 양방향 매핑시에 무한루프를 조심하자
    예 ) toString(), lombok, JSON 생성 라이브러리
Team team = Team.builder()
    .name("TeamA")
    .build();

teamRepository.save(team);

Member member = Member.builder()
    .username("member1")
    .team(team)
    .build();

team.getMembers().add(member);

이런식으로 Member에 한줄을 넣어주기 보다! 연관관계 편의 메소드를 생성하자

 

Member에서 team을 set 해줄때 설정해버린다. - 하나면 세팅해도 두개가 같이 세팅이 되게!

@Builder
private Member(String username, Team team) {
    this.username = username;
    this.team = team;
    team.getMembers().add(this);
}

편의 메소드는 일에 넣어도 되고, 다에 넣어도 된다 : 상황을 보고 만들기를 추천한다.

 

@ToString / toString() 메소드

//Team 클래스
@Override
public String toString() {
    return "Team{" +
        "id=" + id +
        ", name='" + name + '\'' +
        ", members=" + members +
        '}';
}

//Member 클래스
@Override
public String toString() {
    return "Member{" +
        "id=" + id +
        ", username='" + username + '\'' +
        ", team=" + team +
        '}';
}

JSON 생성 라이브러리 : entity를 바로 Controller에서 바로 response 해버릴때 문제가 생긴다.

Member > Team > Member > Team > Member > Team > ...

  1. lombok에서 toString을 쓰지마라
  2. Controller에는 절대 Entity를 반환하지 마라.

4. 정리

4-1. 양방향 매핑 정리

  • 단방향 매핑만으로도 이미 연관관계 매핑은 완료
  • 양방향 매핑은 반대방향으로 조회(객체 그래프 탐색) 기능이 추가된 것 뿐
  • JPQL에서 역방향으로 탐색할 일이 많음
  • 단방향 매핑을 잘 하고 양방향은 필요할 때 추가해도됨 (테이블에 영향을 주지 않음)

JPA에서의 설계는 단방향만으로도 객체와 테이블의 매핑이 완료되어야한다.

테이블은 한번 만들면 굳어지는 것!

4-2. 연관관계의 주인을 정하는 기준

  • 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안됨
  • 연관관계의 주인은 외래 키의 위치를 기준으로 정해야함

This is a summary post based on Kim Young-han's Java ORM Standard JPA Programming - Basics course on Inflearn.

https://www.inflearn.com/course/ORM-JPA-Basic

 

Java ORM Standard JPA Programming - Basics - Inflearn

For those who are new to JPA or use JPA in practice but lack foundational theory — this course helps you build a solid understanding of JPA basics so that even beginners can confidently use JPA in real-world projects. Beginner Web Development Server Database Frameworks & Libraries Programming Languages Service Development Java JPA Spring Data JPA Online Course

www.inflearn.com

I've been using Spring Data JPA, but Kim Young-han actually teaches JPA itself.

Based on his lectures, I decided to write this post to test the course material using Spring Data JPA and document the concepts.


 


Association Mapping Basics

1. Unidirectional Association

<Haven't watched this part yet>

2. Bidirectional Association and the Owner of the Relationship: Basics

Bidirectional association -> references go both ways.

Object: uses references
Table: uses FK joins

We need to look at the paradigm difference between objects and tables.

2-1. Table Associations

There's no difference between unidirectional and bidirectional.

Whether you want to know TEAM->MEMBER or MEMBER->TEAM, you can find out by joining with the Foreign Key.
Regardless of whether it's bidirectional or unidirectional, you can figure out all associations with just the FK.

2-2. Object Associations

If Member has a Team variable, it can navigate to Team.
Team needs to have a List to navigate to Member.

 

Objects need to hold references to each other as member variables to be able to access one another.

 

[Note]: When using a List member variable, always initialize it with new ArrayList<>()!
This prevents NullPointerError when calling add()!

@Entity
@Getter
@NoArgsConstructor
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    @Builder
    private Team(String name) { //여기 그냥 members도 param으로 넣었다가 에러 팡!
        this.name = name;
    }
}
@Entity
@Getter
@NoArgsConstructor
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

    @ManyToOne(cascade = CascadeType.ALL)
    @JoinColumn(name= "TEAM_ID")
    private Team team;

    @Builder
    private Member(String username, Team team) {
        this.username = username;
        this.team = team;
    }
}

Something I was curious about

EntitiyTransaction tx = em.getTrasaction();
em.persist(team);
em.flush();
em.clear();

Now graph traversal is possible in the reverse direction as well.

@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class MemberTest {
    @Autowired
    MemberRepository memberRepository;

    @Autowired
    TeamRepository teamRepository;

    @Before
    public void setUp() throws Exception {
        Team team = Team.builder()
                .name("TeamA")
                .build();

//        teamRepository.save(team);

        Member member = Member.builder()
                .username("member1")
                .team(team)
                .build();

        memberRepository.save(member);
    }

    @Test
    public void 잘_저장되었는지_불러오기() {
        Member member = memberRepository.findAll().get(0);
        String username = member.getUsername();
        assertThat(username).isEqualTo("member1");

        Team team = member.getTeam();
        assertThat(team.getName()).isEqualTo("TeamA");

        List<Member> members = team.getMembers();
        for (Member m : members) {
            assertThat(m.getUsername()).startsWith("member");
        }

    }

The only difference from the course was the cascade part, and when I thought about why, it's because the course called save twice.
Here's the test result after coding it exactly like the course:

@Entity
@Getter
@NoArgsConstructor
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;

    @Column(name = "USERNAME")
    private String username;

//    @ManyToOne(cascade = CascadeType.ALL)
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;

    @Builder
    private Member(String username, Team team) {
        this.username = username;
        this.team = team;
    }
}
@Entity
@Getter
@NoArgsConstructor
public class Team {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "MEMBER_ID")
    private Long id;
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    @Builder
    private Team(String name) { //여기 그냥 members도 param으로 넣었다가 에러 팡!
        this.name = name;
    }
}

Test code

@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
public class MemberTest {
    @Autowired
    MemberRepository memberRepository;

    @Autowired
    TeamRepository teamRepository;

    @Before
    public void setUp() throws Exception {
        Team team = Team.builder()
                .name("TeamA")
                .build();

        teamRepository.save(team);

        Member member = Member.builder()
                .username("member1")
                .team(team)
                .build();

        memberRepository.save(member);
    }

    @Test
    public void 잘_저장되었는지_불러오기() {
        Member member = memberRepository.findAll().get(0);
        String username = member.getUsername();
        assertThat(username).isEqualTo("member1");

        Team team = member.getTeam();
        assertThat(team.getName()).isEqualTo("TeamA");

        List<Member> members = team.getMembers();
        for (Member m : members) {
            assertThat(m.getUsername()).startsWith("member");
        }

    }
}

Without setting cascade on Member to also save Team,

I called save separately for both Member and Team in the test code.

Since Team is already saved before saving Member,

the mapping works because the FK exists in the Member table in the DB!

On the object side, the mapping works because both Team and Member already hold their own reference objects!

In my original code, I was saving only member and having team saved along with it, so I needed the cascade option on Member.

Therefore, even though we're saving through the Member repository, it executes the Team's INSERT first, then proceeds with the Member INSERT, storing the Team in the Member table's FK.

Hibernate: insert into team (member_id, name) values (null, ?)
Hibernate: insert into member (member_id, team_id, username) values (null, ?, ?)

Hibernate: select member0_.member_id as member_i1_0_, member0_.team_id as team_id3_0_, member0_.username as username2_0_ from member member0_

Q. Is bidirectional mapping better?

A. Actually, unidirectional is better for objects! -> There's a lot more to worry about

2-3. Differences in How Objects and Tables Form Relationships

2-3-1. Object Associations - 2

​ Member -> Team: 1 association (unidirectional) - Team reference object

​ Team -> Member: 1 association (unidirectional) - Member reference object

  • A bidirectional relationship in objects is not truly bidirectional — it's actually two separate unidirectional relationships
  • To reference objects bidirectionally, you need to create two unidirectional associations.
class Member{
    Team team;    // TEAM -> Member (team.getMember())
}
class Team{
    Member member;    // MEMBER -> TEAM (member.getTeam())
}

2-3-2. Table Associations - 1

​ Team <-> Member: 1 association (bidirectional) - A single FK lets you know both sides of the relationship (join)

  • Tables manage the association between two tables with a single foreign key
  • The MEMBER.TEAM_ID foreign key alone provides a bidirectional association (you can join from either side.)
SELECT * 
FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID

SELECT * 
FROM TEAM T
JOIN MEMBER M ON T.TEAM_ID = M.TEAM_ID

2-4. The Owner of the Relationship

A dilemma arises > solution: manage the foreign key from one side!

  • Should we manage the FK through the List in Team?
  • Or through the Team in Member?

2-4-1. Bidirectional Mapping Rules

  • Designate one of the two object relationships as the owner of the relationship
  • Only the owner of the relationship can manage the foreign key (create, update)
  • The non-owner side can only read
  • The owner does NOT use the mappedBy attribute
  • The non-owner uses mappedBy to specify the owner

mappedBy: "I'm mapped by someone else! I'm not the owner!"

public class Team {
    @OneToMany(mappedBy = "team") 
    private List<Member> members = new ArrayList<>();
}

public class Member { 
    @ManyToOne
    @JoinColumn(name = "TEAM_ID")
    private Team team;
}

mappedBy: "I'm managed by team" — meaning it's managed by the team variable in the Member object.

@JoinColumn's Team: "I'm going to manage Team from now on"

2-4-2. Who Should Be the Owner?

  • Make the side where the foreign key exists the owner
  • In this case, Member.team is the owner of the relationship!

Performance issue!

For Member, it's just one INSERT query,

but for Team, it's an INSERT query + an UPDATE query

From the DB's perspective, the side with the foreign key is always the N (many) side

= The N side is always the owner

= @ManyToOne is always the owner

3. Bidirectional Association and the Owner of the Relationship: Pitfalls and Summary

3-1. The Most Common Mistake in Bidirectional Mapping

  • Not setting a value on the owner of the relationship
    @RunWith(SpringRunner.class)
    @DataJpaTest
    @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
    public class FailTest {
        @Autowired
        MemberRepository memberRepository;
    
        @Autowired
        TeamRepository teamRepository;
    
        @Autowired
        EntityManager entityManager;
    
        @Test
        public void 일차캐싱에_따른_저장_테스트() {
    
            Team team = Team.builder()
                    .name("TeamA")
                    .build();
    
            teamRepository.save(team);
    
            Member member = Member.builder()
                    .username("member1")
                    .team(team)
                    .build();
    
    //        team.getMembers().add(member);
    
            memberRepository.save(member);
    
            // 주인(Member)이 연관관계를 설정하지 않음!!
            // 역방향(주인이 아닌 방향)만 연관관계 설정
    //        entityManager.clear();
    
            Team findTeam = teamRepository.findAll().get(0);
            List<Member> members = findTeam.getMembers();
    
            assertThat(members).isEmpty();
        }
    }
  • When entityManager.clear() is not called:
    Since first-level caching is active and the persistence context still holds the values, it just fetches the existing association state.
    When executed this way, no SELECT query is actually sent to the DB.
  • Team is just sitting in the persistence context, and at this point, it has no members.
    Because of first-level caching, nothing is populated!
  • From an object-oriented perspective, you should set values on both sides!

3-2. Bidirectional Association Precautions

  • Always set values on both sides, considering the pure object state
  • Create convenience methods for managing associations
  • Watch out for infinite loops in bidirectional mapping
    e.g.) toString(), Lombok, JSON serialization libraries
Team team = Team.builder()
    .name("TeamA")
    .build();

teamRepository.save(team);

Member member = Member.builder()
    .username("member1")
    .team(team)
    .build();

team.getMembers().add(member);

Rather than adding a separate line for Member like this, create a convenience method for the association!

 

Set it up when setting the team on Member — so that setting one side automatically sets both!

@Builder
private Member(String username, Team team) {
    this.username = username;
    this.team = team;
    team.getMembers().add(this);
}

The convenience method can go on either the One side or the Many side — I recommend deciding based on the situation.

 

@ToString / toString() method

//Team 클래스
@Override
public String toString() {
    return "Team{" +
        "id=" + id +
        ", name='" + name + '\'' +
        ", members=" + members +
        '}';
}

//Member 클래스
@Override
public String toString() {
    return "Member{" +
        "id=" + id +
        ", username='" + username + '\'' +
        ", team=" + team +
        '}';
}

JSON serialization libraries: Problems occur when you return an entity directly from the Controller as a response.

Member > Team > Member > Team > Member > Team > ...

  1. Don't use toString from Lombok
  2. Never return an Entity directly from a Controller.

4. Summary

4-1. Bidirectional Mapping Summary

  • Unidirectional mapping alone already completes the association mapping
  • Bidirectional mapping simply adds the ability to query in the reverse direction (object graph traversal)
  • In JPQL, you often need to traverse in the reverse direction
  • Do unidirectional mapping well first, then add bidirectional when needed (it doesn't affect the table)

In JPA design, object-to-table mapping should be complete with just unidirectional associations.

Once a table is created, it's set in stone!

4-2. Criteria for Choosing the Owner of the Relationship

  • Don't choose the relationship owner based on business logic
  • The relationship owner should be determined based on where the foreign key is located

댓글

Comments

Develop/Springboot

[inflearn] 스프링 부트 개념과 활용 2.스프링 부트 시작하기 | [inflearn] Spring Boot Concepts and Utilization 2. Getting Started with Spring Boot

1. Spring Boot 소개1-1. Spring Boot Start특징토이를 만드는게 아니라 제품수준의 어플리케이션을 만들때 도와주는 툴.opinated view : 스프링 부트가 갖고있는 컨벤션을 의미한다 (널리 사용되는 설정)Spring platform에 대한 기본 설정 뿐만아니라 다른 library에 대한 설정(tomcat)도 기본적으로 해준다목표모든 스프링 개발을 할 때 더 빠르고 더 폭넓은 사용성을 제공한다.일일히 설정하지 않아도 convention으로 정해져있는 설정을 제공한다. 하지만 우리의 요구사항에 맞게 이런 설정을 쉽고 빠르게 바꿀 수 있다.(스프링 부트를 사용하는 이유)non-fucntional 설정도 제공해 준다. 비즈니스로직 구현에 필요한 기능 외에도 non-functional..

[inflearn] 스프링 부트 개념과 활용 2.스프링 부트 시작하기 | [inflearn] Spring Boot Concepts and Utilization 2. Getting Started with Spring Boot

728x90

1. Spring Boot 소개

1-1. Spring Boot Start

특징

토이를 만드는게 아니라 제품수준의 어플리케이션을 만들때 도와주는 툴.

opinated view : 스프링 부트가 갖고있는 컨벤션을 의미한다 (널리 사용되는 설정)

Spring platform에 대한 기본 설정 뿐만아니라 다른 library에 대한 설정(tomcat)도 기본적으로 해준다

목표

  • 모든 스프링 개발을 할 때 더 빠르고 더 폭넓은 사용성을 제공한다.
  • 일일히 설정하지 않아도 convention으로 정해져있는 설정을 제공한다. 하지만 우리의 요구사항에 맞게 이런 설정을 쉽고 빠르게 바꿀 수 있다.(스프링 부트를 사용하는 이유)
  • non-fucntional 설정도 제공해 준다. 비즈니스로직 구현에 필요한 기능 외에도 non-functional feature도!
  • XML 사용하지 않고, code generation도 하지 않는다.

Spring 루 : 독특하게 code generation을 해주는데 지금은 잘 사용되지 않는다. generation을 안해서 더 쉽고 명확하고 커스터마이징하기 쉽다. > spring boot의 bb

System Requirements

Spring boot 는 java 8 이상을 필요로 한다.

지원하는 servletContainer로는 tomcat, jetty Undertow가 있다.

 

2. Spring Boot 시작하기

Intellij ultimate를 사용하면 Spring boot initializer가 있으나, community 버전은 없다. 따라서 자신이 원하는 build tool을 이용해서 만들어 주면된다 Spring boot initializer를 이용하지 않고, 프로젝트 생성하는 법 을 공부 할 것이다.

 

2-1. gradle project에서 시작

 

auto import OK (build.gradle 파일 변경할 때 마다 바로바로 변경 : dependency 추가 등)

spring.io > project > spring boot > Learn > Reference Doc > Gradle Installation

https://docs.spring.io/spring-boot/docs/2.0.3.RELEASE/reference/htmlsingle/#getting-started-gradle-installation

 

Spring Boot Reference Guide

This section dives into the details of Spring Boot. Here you can learn about the key features that you may want to use and customize. If you have not already done so, you might want to read the "Part II, “Getting Started”" and "Part III, “Using Spring Boot

docs.spring.io

 

build.gradle

build.gradle 파일을 입력해준다.

plugins {
    id 'org.springframework.boot' version '2.0.3.RELEASE'
    id 'java'
}

의존성 관리와 매우 관련이 있는 설정이다.

dependencies {
    compile("org.springframework.boot:spring-boot-starter-web")
    testCompile("org.springframework.boot:spring-boot-starter-test")
}

일반적으로 프로젝트는 하나이상의 "starter"에 대한 의존성을 선언한다.
spring boot는 의존성 선언을 간소화했으며, jar를 생성하는데 유용한 Gradle 플러그인을 제공한다.

 

initial build.gradle 파일

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:2.1.4.RELEASE")
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

bootJar {
    baseName = 'spring-boot-getting-started'
    version =  '0.1.0'
}

repositories {
    mavenCentral()
}

sourceCompatibility = 1.8
targetCompatibility = 1.8

dependencies {
    compile("org.springframework.boot:spring-boot-starter-web")
    testCompile("org.springframework.boot:spring-boot-starter-test")
}

 

SpringBootApplication.java

package com.jyami;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String args[]){
        SpringApplication.run(Application.class, args);
    }

}

 

SpringBootApplication 어노테이션을 사용해서, SpringApplication을 run하는 메소드 호출

Spring MVC가 돌아가려면 여러 dependency가 필요한데, 어떻게 하여 수많은 의존성들이 들어왔는가?

mvc앱을 설정해야하는데 (bean, tomcat .. )

: 이게 @SpringBootApplication에 설정되어있다. > InableAutoCompletecation

 

Intellij 설정에서
Build, Execution, Deployment > Compiler > Annotation Processors에 들어가
Enable annotation processing에 체크해줘야 gradle로 build한 annotation들을 사용할 수 있다.

 

Run (실행하기)

Run을 하고 log를 보면 벌서 Tomcat이 8080 port에서 실행되고 있음을 알 수 있고
http://localhost:8080 을 띄어보면, tomcat web application이 동작함을 알 수 있다. (error이긴 하지만)

 

build (빌드하기)

gradle build

이 package를 build한다. java프로젝트이므로 jar파일이 생성되고, 이 jar파일을 생성한다

java -jar build/libs/spring-boot-getting-started-0.1.0.jar

jar 파일을 실행하면, 아까와 같은 spring web application이 동작하게 된다.

 

 

2-2. 웹으로 Spring Boot project 시작

http://start.spring.io

원하는 build 형태의 spring boot project를 생성해준다. (dir 형태로!)

 

 

3. 스프링 프로젝트의 구조

gradle java 기본 프로젝트 구조와 동일하다

저장 파일 파일 경로 설명
소스 코드  src/main/java -
소스 리소스 src/main/resource java application에서 resources 기준으로 아래 것들을 참조 가능 (classpath)
테스트 코드 src/test/java -
테스트 리소스 src/test/resource test 관련 리소스를 만들 수 있다

 

메인 애플리케이션 위치 (@SpringBootApplication) : 기본 패키지  package com.jyami
프로젝트가 쓰고있는 가장 최상위 패키지! > why? 컴포넌트 스캔을 하기 때문

com.jyami에서부터 시작을 해서, 그 아래에 있는 파일들을 스캔해서 bean으로 등록한다.

 

src/main/java 위치에 넣으면 모든 패키지를 스캔하므로

 

만약 java>com.hello 패키지가 있고, 그안에 메인 애플리케이션이 아닌 java파일이 있으면, 그 java파일은 component 스캔이 이루어지지 않는다.

1. Introduction to Spring Boot

1-1. Spring Boot Start

Features

It's a tool that helps you build production-level applications, not just toy projects.

opinated view : This refers to the conventions that Spring Boot has (widely used configurations)

It provides default configurations not only for the Spring platform but also for other libraries (like tomcat) out of the box.

Goals

  • Provides faster and broader usability for all Spring development.
  • Provides convention-based configurations without having to set everything up manually. But you can easily and quickly change these settings to match your requirements. (This is the reason to use Spring Boot)
  • Provides non-functional configurations as well. Beyond features needed for business logic, it also covers non-functional features!
  • Does not use XML and does not do code generation.

Spring Roo : It uniquely does code generation, but it's not widely used anymore. By not doing generation, things are easier, clearer, and simpler to customize. > The predecessor of Spring Boot

System Requirements

Spring Boot requires Java 8 or higher.

Supported servlet containers include Tomcat, Jetty, and Undertow.

 

2. Getting Started with Spring Boot

If you use IntelliJ Ultimate, it has a Spring Boot Initializer built in, but the Community edition does not. So you can create one using whichever build tool you prefer. We'll learn how to create a project without using the Spring Boot Initializer.

 

2-1. Starting from a Gradle Project

 

auto import OK (Applies changes immediately whenever the build.gradle file is modified: adding dependencies, etc.)

spring.io > project > spring boot > Learn > Reference Doc > Gradle Installation

https://docs.spring.io/spring-boot/docs/2.0.3.RELEASE/reference/htmlsingle/#getting-started-gradle-installation

 

Spring Boot Reference Guide

This section dives into the details of Spring Boot. Here you can learn about the key features that you may want to use and customize. If you have not already done so, you might want to read the "Part II, “Getting Started”" and "Part III, “Using Spring Boot

docs.spring.io

 

build.gradle

Enter the build.gradle file content.

plugins {
    id 'org.springframework.boot' version '2.0.3.RELEASE'
    id 'java'
}

This is a configuration closely related to dependency management.

dependencies {
    compile("org.springframework.boot:spring-boot-starter-web")
    testCompile("org.springframework.boot:spring-boot-starter-test")
}

Typically, a project declares dependencies on one or more "starters".
Spring Boot simplifies dependency declarations and provides a useful Gradle plugin for generating jars.

 

initial build.gradle file

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:2.1.4.RELEASE")
    }
}

apply plugin: 'java'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

bootJar {
    baseName = 'spring-boot-getting-started'
    version =  '0.1.0'
}

repositories {
    mavenCentral()
}

sourceCompatibility = 1.8
targetCompatibility = 1.8

dependencies {
    compile("org.springframework.boot:spring-boot-starter-web")
    testCompile("org.springframework.boot:spring-boot-starter-test")
}

 

SpringBootApplication.java

package com.jyami;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String args[]){
        SpringApplication.run(Application.class, args);
    }

}

 

Using the SpringBootApplication annotation to call the method that runs SpringApplication

Spring MVC requires many dependencies to run — so how did all those dependencies get pulled in?

We need to configure the MVC app (bean, tomcat, etc.)

: This is all configured in @SpringBootApplication. > EnableAutoConfiguration

 

In IntelliJ settings, go to
Build, Execution, Deployment > Compiler > Annotation Processors and
check Enable annotation processing to be able to use annotations built with Gradle.

 

Run

When you run the application and check the logs, you can see that Tomcat is already running on port 8080.
If you open http://localhost:8080, you can confirm that the Tomcat web application is working. (Even though it shows an error page)

 

Build

gradle build

This builds the package. Since it's a Java project, a jar file is generated.

java -jar build/libs/spring-boot-getting-started-0.1.0.jar

When you run the jar file, the same Spring web application will start up just like before.

 

 

2-2. Starting a Spring Boot Project from the Web

http://start.spring.io

It generates a Spring Boot project in your desired build format. (As a directory structure!)

 

 

3. Spring Project Structure

It follows the same structure as a standard Gradle Java project.

File Type File Path Description
Source Code  src/main/java -
Source Resources src/main/resource In a Java application, files below the resources directory can be referenced (classpath)
Test Code src/test/java -
Test Resources src/test/resource You can create test-related resources here

 

Main Application Location (@SpringBootApplication) : Default package  package com.jyami
This should be in the topmost package of the project! > Why? Because of component scanning.

Starting from com.jyami, it scans files underneath and registers them as beans.

 

If you place it directly under src/main/java, it would scan all packages.

 

If there's a package java>com.hello with a Java file that isn't the main application, that Java file will not be picked up by component scanning.

댓글

Comments