개요
JPA는 아래와 같이 다양한 쿼리 방법을 지원합니다.
- JPQL
- JPA Criteria
- QueryDSL
- Native SQL
- JDBC API 직접 적용
이번 게시글에서는 간단하게 위 쿼리 방법들을 설명한 후 JPQL 기본 문법과 기능에 대해 알아보겠습니다.
1. 다양한 쿼리 방법 소개
1.1 JPQL
- JPA가 테이블이 아닌 엔티티 객체를 중심으로 개발하는데, JPQL 역시 엔티티 객체를 대상으로 검색하는 쿼리 방법 (객체지향적인 것이 핵심)
- JPQL은 SQL을 추상화한 객체 지향 쿼리 언어
- 실제로 SQL 문법과 유사하여 ANSI 표준 키워드 전부 지원 (SELECT, FROM, WHERE, GROUP BY, HAVING, JOIN)
- 앞서 언급한 대로 JPQL은 엔티티 객체를 대상으로 쿼리
- 반면, SQL은 DB 테이블을 대상으로 쿼리
- 모든 DB 데이터를 객체로 변환해서 검색하는 것은 실질적으로 불가능
- 따라서, 애플리케이션이 필요한 최소한의 데이터만 불러와야 함
1.2 Criteria
- JPQL로 Dynamic Query 작성하기 어렵기 때문에 등장한 개념
- 장점
- 문자가 아닌 자바 코드로 JPQL을 작성하므로 컴파일 시점에서 에러를 잡아줌
- 단점
- 아래 예시를 보면 알 수 있다시피 SQL스럽지 않고 복잡하며 실용성이 없음
- Criteria 대신 QueryDSL을 통해 동적 쿼리 작성하는 것을 추천
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static void main(String[] args) { | |
EntityManagerFactory entityManagerFactory | |
= Persistence.createEntityManagerFactory("hello"); | |
EntityManager entityManager = entityManagerFactory.createEntityManager(); | |
EntityTransaction transaction = entityManager.getTransaction(); | |
transaction.begin(); | |
try { | |
// Criteria 사용 준비 | |
CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder(); | |
CriteriaQuery<Member> query = criteriaBuilder.createQuery(Member.class); | |
Root<Member> member = query.from(Member.class); | |
CriteriaQuery<Member> criteriaQuery = query.select(member); | |
String username = "kim"; | |
// 장점: 컴파일 시점에 에러 잡아줌 | |
// 단점: sql 스럽지 않음 | |
if (username != null) { | |
criteriaQuery = criteriaQuery.where(criteriaBuilder.equal(member.get("username"), "kim")); | |
} | |
List<Member> members = entityManager.createQuery(criteriaQuery) | |
.getResultList(); | |
transaction.commit(); | |
} catch (Exception e) { | |
transaction.rollback(); | |
e.printStackTrace(); | |
} finally { | |
entityManager.close(); | |
} | |
entityManagerFactory.close(); | |
} |
1.3 QueryDSL
- Criteria와 마찬가지로 문자가 아닌 자바코드로 JPQL을 작성하여 컴파일 단계에서 에러를 잡을 수 있음
- 동적 쿼리를 작성하기 쉬운 쿼리 언어
- 단순하고 난이도가 상대적으로 쉽기 때문에 실무 사용 권장
- 이후 별도로 공부하고 정리할 예정
1.4 Native SQL
- SQL을 직접 작성해서 사용하는 기능
- JPQL로 해결할 수 없는 특정 DB에 의존적인 기능
- ex) ORACLE의 CONNECT BY와 같이 특정 DB만 사용하는 SQL 힌트
- 컴파일 단계에서 에러를 잡을 수 없으므로 에러에 취약함
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static void main(String[] args) { | |
EntityManagerFactory entityManagerFactory | |
= Persistence.createEntityManagerFactory("hello"); | |
EntityManager entityManager = entityManagerFactory.createEntityManager(); | |
EntityTransaction transaction = entityManager.getTransaction(); | |
transaction.begin(); | |
try { | |
entityManager.createNativeQuery("SELECT MEMBER_ID, USERNAME FROM MEMBER") | |
.getResultList(); | |
transaction.commit(); | |
} catch (Exception e) { | |
transaction.rollback(); | |
e.printStackTrace(); | |
} finally { | |
entityManager.close(); | |
} | |
entityManagerFactory.close(); | |
} |
1.5 JDBC API 직접 적용
- JDBC 커넥션을 직접 사용하거나 스프링 JdbcTemplate, MyBatis 등을 함께 사용
- 단, 위와 같은 기능은 JPA와 연관이 없으므로 PersistenceContext를 강제로 플러쉬 해야 함
- 앞서 정리한 게시글 https://jaimemin.tistory.com/1898을 보면 데이터는 persist 메서드가 아닌 commit 메서드를 호출해야 DB에 들어감 (persist를 할 경우 영속성 컨텍스트에서 관리하지만 이는 JPA를 사용할 때만 접근 가능)
- 따라서, JPA와 연관 없는 쿼리를 호출할 때는 수동으로 flush를 진행해서 데이터를 실제 DB에 넣어줘야 오류 방지 가능
2. JPQL 기본 문법과 기능
2.1 JPQL 문법
- JPQL은 SQL을 추상화한 객체지향 언어이므로 기본적인 문법은 비슷
- ex) SELECT e FROM Employee AS e WHERE e.age > 30
- 엔티티와 속성은 위 예시처럼 대소문자 구분
- 엔티티: Employee
- 속성: age
- JPQL 키워드는 대소문자 구분을 하지 않지만 키워드는 대문자로 하는 것이 가독성 측면에서 유리
- 엔티티 중심으로 검색하므로 테이블명이 아닌 엔티티명을 사용
- @Entity(name = "XXX")라고 가정하면 From XXX
- 위 예시처럼 Alias는 필수 (Employee AS e)
- AS는 생략 가능
- 집합과 정렬을 위한 COUNT, SUM, AVG, MAX, MIN, GROUP BY, HAVING 그리고 ORDER BY 키워드 모두 지원
2.2 TypeQuery vs Query
- TypeQuery는 반환 타입이 명확할 때 사용
- Query의 경우 반환 타입이 불명확할 경우 사용
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static void main(String[] args) { | |
EntityManagerFactory entityManagerFactory | |
= Persistence.createEntityManagerFactory("hello"); | |
EntityManager entityManager = entityManagerFactory.createEntityManager(); | |
EntityTransaction transaction = entityManager.getTransaction(); | |
transaction.begin(); | |
try { | |
Member member = new Member(); | |
member.setUsername("member1"); | |
member.setAge(10); | |
entityManager.persist(member); | |
// TypeQuery: 반환 타입 명확 | |
TypedQuery<Member> query | |
= entityManager.createQuery("SELECT m FROM Member m", Member.class); | |
TypedQuery<String> query2 | |
= entityManager.createQuery("SELECT m.username from Member m", String.class); | |
// Query: 반환 타입이 명확하지 않을 때 | |
Query query3 | |
= entityManager.createQuery("SELECT m.username, m.age from Member m"); | |
transaction.commit(); | |
} catch (Exception e) { | |
transaction.rollback(); | |
e.printStackTrace(); | |
} finally { | |
entityManager.close(); | |
} | |
entityManagerFactory.close(); | |
} |
2.3 getResultList() vs getSingleResult()
- 두 메서드 모두 2,2에서 소개한 TypeQuery 혹은 Query의 메서드
- 결과가 하나 이상일 때는 리스트 형태로 반환하는 getResultList() 메서드 사용
- 결과가 없을 경우 빈 리스트 반환하므로 exception 발생 X
- 결과가 정확히 하나일 경우에는 단일 객체를 반환하는 getSingleResult() 메서드 사용
- 주의할 점은, 결과가 없을 경우나 결과가 둘 이상일 때 exception이 발생한다는 점
- 결과가 없을 경우 -> NoResultException 예외 발생
- 결과가 둘 이상일 경우 -> NonUniqueResultException 발생
- Hibernate의 경우 위와 같은 NoResultException 예외가 발생할 경우 catch 해서 결과가 없더라도 예외가 발생하지 않도록 처리
- 안전하게 처리하기 위해서는 getResultList() 메서드를 사용하는 것을 추천
2.4 파라미터 바인딩
- 이름 기준 혹은 위치 기준으로 파라미터를 바인딩해주는 기능
- 위치는 수시로 바뀔 수 있으므로 확실한 이름 기준으로 바인딩해주는 것을 추천
- 동적 쿼리를 작성할 때 사용하는 기능
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static void main(String[] args) { | |
EntityManagerFactory entityManagerFactory | |
= Persistence.createEntityManagerFactory("hello"); | |
EntityManager entityManager = entityManagerFactory.createEntityManager(); | |
EntityTransaction transaction = entityManager.getTransaction(); | |
transaction.begin(); | |
try { | |
Member member = new Member(); | |
member.setUsername("member1"); | |
member.setAge(10); | |
entityManager.persist(member); | |
// 파라미터 바인딩 | |
Member result | |
= entityManager.createQuery("SELECT m FROM Member m WHERE m.username = :username", Member.class) | |
.setParameter("username", "member1") | |
.getSingleResult(); | |
System.out.println("singleResult = " + result); | |
transaction.commit(); | |
} catch (Exception e) { | |
transaction.rollback(); | |
e.printStackTrace(); | |
} finally { | |
entityManager.close(); | |
} | |
entityManagerFactory.close(); | |
} |
2.5 프로젝션
- SELECT 절에 조회할 대상을 지정하는 것을 프로젝션이라고 함
- 프로젝션은 크게 3가지로 나눌 수 있음
- 엔티티 프로젝션
- 임베디드 타입 프로젝션
- 스칼라 타입 프로젝션
2.5.1 엔티티 프로젝션
- 이름에서도 유추할 수 있다시피 엔티티를 반환받는 SELECT 쿼리문
- 엔티티 프로젝션 시 조회된 모든 엔티티들은 PersistenceContext로 인해 관리가 됨
- PersistenceContext에 대한 자세한 설명은 https://jaimemin.tistory.com/1898 참고
2.5.2 임베디드 타입 프로젝션
- 앞서 https://jaimemin.tistory.com/1953에서 정리한 임베디드 값 타입을 조회하는 SELECT 쿼리문
- 아래 예시처럼 주소를 조회할 때 유용하게 쓰임
2.5.3 스칼라 타입 프로젝션
- 여러 타입의 값을 조회할 때 사용하는 SELECT 쿼리문
- 타입이 여러 개이므로 Object 배열을 반환받는 방법이 있고
- 혹은 스칼라 타입 프로젝션을 위한 별도 DTO를 생성하여 new 명령어를 통해 조회하는 방법이 있음
- Object 배열로 반환받는 방법보다 가독성 좋음
- 단, 패키지명을 포함한 전체 클래스명을 입력해야 하는 불편함이 존재하며 순서와 타입이 일치하는 생성자가 필요하다는 단점도 있음 => QueryDSL 사용 시 해결 가능
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static void main(String[] args) { | |
EntityManagerFactory entityManagerFactory | |
= Persistence.createEntityManagerFactory("hello"); | |
EntityManager entityManager = entityManagerFactory.createEntityManager(); | |
EntityTransaction transaction = entityManager.getTransaction(); | |
transaction.begin(); | |
try { | |
Member member = new Member(); | |
member.setUsername("member1"); | |
member.setAge(10); | |
entityManager.persist(member); | |
entityManager.flush(); | |
entityManager.clear(); | |
// 엔티티 프로젝션 시 모든 엔티티들이 영속성 컨텍스트로 관리됨 | |
List<Member> members | |
= entityManager.createQuery("SELECT m FROM Member m", Member.class) | |
.getResultList(); | |
List<Team> teams = entityManager.createQuery("SELECT m.team FROM Member m") | |
.getResultList(); | |
// update 되는 것을 확인할 수 있음 | |
Member foundMember = members.get(0); | |
foundMember.setAge(20); | |
// 임베디드 타입 프로젝션 | |
List<Address> addresses | |
= entityManager.createQuery("SELECT o.address FROM Order o", Address.class) | |
.getResultList(); | |
// 스칼라 타입 프로젝션 | |
List<Object[]> resultList = entityManager.createQuery("SELECT m.username, m.age FROM Member m") | |
.getResultList(); | |
// 1. Object[] 타입으로 조회 | |
Object[] result = resultList.get(0); | |
System.out.println("result[0] = " + result[0]); | |
System.out.println("result[1] = " + result[1]); | |
// 2. new 명령어로 조회 | |
// 패키지명 길어지면 한계가 있는 것이 문제 -> QueryDSL로 해결 가능 | |
List<MemberDTO> memberDTOs | |
= entityManager.createQuery("SELECT new com.tistory.jaimemin.jpql.MemberDTO(m.username, m.age) FROM Member m", MemberDTO.class) | |
.getResultList(); | |
MemberDTO memberDTO = memberDTOs.get(0); | |
System.out.println("username = " + memberDTO.getUsername()); | |
System.out.println("age = " + memberDTO.getAge()); | |
transaction.commit(); | |
} catch (Exception e) { | |
transaction.rollback(); | |
e.printStackTrace(); | |
} finally { | |
entityManager.close(); | |
} | |
entityManagerFactory.close(); | |
} | |
@Getter | |
@Setter | |
@Embeddable | |
public class Address { | |
private String city; | |
private String street; | |
private String zipcode; | |
} | |
@Entity | |
@Getter | |
@Setter | |
public class Member { | |
@Id | |
@GeneratedValue | |
private Long id; | |
private String username; | |
private int age; | |
@ManyToOne | |
@JoinColumn(name = "TEAM_ID") | |
private Team team; | |
} | |
@Getter | |
@Setter | |
public class MemberDTO { | |
private String username; | |
private int age; | |
public MemberDTO(String username, int age) { | |
this.username = username; | |
this.age = age; | |
} | |
} | |
@Entity | |
@Getter | |
@Setter | |
public class Team { | |
@Id | |
@GeneratedValue | |
private Long id; | |
private String name; | |
@OneToMany(mappedBy = "team") | |
private List<Member> members = new ArrayList<>(); | |
} | |
@Entity | |
@Getter | |
@Setter | |
@Table(name = "ORDERS") | |
public class Order { | |
@Id | |
@GeneratedValue | |
private Long id; | |
private int orderAmount; | |
@Embedded | |
private Address address; | |
} |
2.6 페이징 API
- JPA는 페이징을 아래의 두 API로 추상화
- setFirstResult(int startPosition): 조회 시작 위치
- setMaxResults(int maxResult): 조회할 데이터 수
- ORACLE의 페이징 처리에 비해 훨씬 간단함
- MySQL 혹은 PostgreSQL의 offset, limit과 유사
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static void main(String[] args) { | |
EntityManagerFactory entityManagerFactory | |
= Persistence.createEntityManagerFactory("hello"); | |
EntityManager entityManager = entityManagerFactory.createEntityManager(); | |
EntityTransaction transaction = entityManager.getTransaction(); | |
transaction.begin(); | |
try { | |
for (int i = 0; i < 100; i++) { | |
Member member = new Member(); | |
member.setUsername("member" + i); | |
member.setAge(i); | |
entityManager.persist(member); | |
} | |
entityManager.flush(); | |
entityManager.clear(); | |
/** | |
* SELECT | |
* m | |
* FROM | |
* Member m | |
* ORDER BY | |
* m.age DESC | |
* | |
* select | |
* member0_.id as id1_0_, | |
* *member0_.age as age2_0_, | |
* *member0_.TEAM_ID as team_id4_0_, | |
* *member0_.username as username3_0_ | |
* from | |
* Member member0_ | |
* order by | |
* member0_.age DESC limit ?offset ? | |
*/ | |
List<Member> members | |
= entityManager.createQuery("SELECT m FROM Member m ORDER BY m.age DESC", Member.class) | |
.setFirstResult(0) | |
.setMaxResults(10) | |
.getResultList(); | |
System.out.println("members.size = " + members.size()); | |
for (Member m : members) { | |
System.out.println("m.getUsername() = " + m.getUsername()); | |
System.out.println("m.getAge() = " + m.getAge()); | |
System.out.println(); | |
} | |
transaction.commit(); | |
} catch (Exception e) { | |
transaction.rollback(); | |
e.printStackTrace(); | |
} finally { | |
entityManager.close(); | |
} | |
entityManagerFactory.close(); | |
} |
2.7 Join
- INNER JOIN, OUTER JOIN, CROSS JOIN 모두 지원
- JPA 2.1부터 아래의 ON 절을 활용한 JOIN 또한 지원
- 조인 대상 필터링
- 연관관계없는 엔티티 OUTER JOIN (Hibernate 5.1 ~)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static void main(String[] args) { | |
EntityManagerFactory entityManagerFactory | |
= Persistence.createEntityManagerFactory("hello"); | |
EntityManager entityManager = entityManagerFactory.createEntityManager(); | |
EntityTransaction transaction = entityManager.getTransaction(); | |
transaction.begin(); | |
try { | |
Team team = new Team(); | |
team.setName("team"); | |
entityManager.persist(team); | |
Member member = new Member(); | |
member.setUsername("member"); | |
member.setAge(10); | |
member.setTeam(team); | |
entityManager.persist(member); | |
entityManager.flush(); | |
entityManager.clear(); | |
String query = "SELECT m FROM Member m INNER JOIN m.team t"; | |
List<Member> members = entityManager.createQuery(query, Member.class) | |
.getResultList(); | |
String query2 = "SELECT m FROM Member m LEFT OUTER JOIN m.team t"; | |
List<Member> leftMembers = entityManager.createQuery(query2, Member.class) | |
.getResultList(); | |
// cross join | |
String query3 = "SELECT m FROM Member m, Team t WHERE m.username = t.name"; | |
List<Member> thetaMembers = entityManager.createQuery(query3, Member.class) | |
.getResultList(); | |
// 조인 대상 필터링 | |
String query4 = "SELECT m FROM Member m LEFT JOIN m.team t on t.name = 'teamA'"; | |
List<Member> filteringMembers = entityManager.createQuery(query4, Member.class) | |
.getResultList(); | |
// 연관관계가 없는 엔티티 외부 조인 | |
String query5 = "SELECT m FROM Member m LEFT JOIN Team t on m.username = t.name"; | |
List<Member> notRelatedMembers = entityManager.createQuery(query5, Member.class) | |
.getResultList(); | |
transaction.commit(); | |
} catch (Exception e) { | |
transaction.rollback(); | |
e.printStackTrace(); | |
} finally { | |
entityManager.close(); | |
} | |
entityManagerFactory.close(); | |
} |
2.8 SubQuery
- JPA 또한 SQL처럼 서브 쿼리를 지원하며 아래의 키워드를 제공
- [NOT] EXISTS (subquery)
- {ALL | ANY | SOME} (subquery)
- [NOT] IN (subquery)
- 하지만, SQL과 달리 JPQL은 서브 쿼리 한계가 존재
- JPA는 WHERE, HAVING 절에서만 서브 쿼리를 사용 가능
- Hibernate를 이용할 경우 SELECT 절에서도 서브 쿼리 사용 가능
- 하지만, FROM 절의 서브 쿼리는 현재 JPQL에서 사용 불가능
- Native Query를 사용하거나
- 쿼리를 두 번 호출하거나
- 혹은 쿼리를 호출한 뒤 Application 단에서 제어를 하는 방식으로 우회해야 함
2.9 JPQL Type
- 엔티티를 제외하고도 문자, 숫자, Boolean 타입 지원
- Enum 타입도 지원하지만 이를 위해서는 스칼라 타입 프로젝션처럼 전체 패키지명을 명시해야 하는 불편한 점이 있음
- 파라미터 바인딩을 통해 위 문제점을 해결 가능
- 엔티티 타입도 지원하는데 이는 @Discriminator 어노테이션을 통해 명시한 DTYPE을 의미
- 즉, 상속관계 매핑에서 사용되는 타입으로 자세한 내용은 https://jaimemin.tistory.com/1909 참고
2.10 사용자 정의 함수
- 각 DB 방언(Dialect)마다 사용하는 함수가 다름
- 따라서 특정 함수를 사용하기 위해서는 사용하는 DB 방언을 상속받은 뒤, 아래와 같이 사용자 정의 함수를 등록해야 함
- 아래의 예시는 H2Dialect에 group_concat을 추가한 예시
- CustomH2Dialect 클래스를 선언하고 H2Dialect를 상속받은 뒤 group_concat 함수를 등록
- persistence.xml에 CustomH2Dialect 등록
- 실제로 group_concat 함수를 호출
CustomH2Dialect
public class CustomH2Dialect extends H2Dialect {
public CustomH2Dialect() {
registerFunction("group_concat", new StandardSQLFunction("group_concat", StandardBasicTypes.STRING));
}
}
persistence.xml
<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.2"
xmlns="http://xmlns.jcp.org/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd">
<persistence-unit name="hello">
<properties>
<!-- 필수 속성 -->
<property name="javax.persistence.jdbc.driver" value="org.h2.Driver"/>
<property name="javax.persistence.jdbc.user" value="sa"/>
<property name="javax.persistence.jdbc.password" value=""/>
<property name="javax.persistence.jdbc.url" value="jdbc:h2:tcp://localhost/~/test"/>
<property name="hibernate.dialect" value="com.tistory.jaimemin.dialect.CustomH2Dialect"/>
<!-- 옵션 -->
<property name="hibernate.show_sql" value="true"/>
<property name="hibernate.format_sql" value="true"/>
<property name="hibernate.use_sql_comments" value="true"/>
<property name="hibernate.hbm2ddl.auto" value="create" />
</properties>
</persistence-unit>
</persistence>
group_concat 함수 호출하는 에시
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public static void main(String[] args) { | |
EntityManagerFactory entityManagerFactory | |
= Persistence.createEntityManagerFactory("hello"); | |
EntityManager entityManager = entityManagerFactory.createEntityManager(); | |
EntityTransaction transaction = entityManager.getTransaction(); | |
transaction.begin(); | |
try { | |
Employee employee = new Employee(); | |
member1.setPosition("사원"); | |
entityManager.persist(employee); | |
Employee employee2 = new Employee(); | |
member2.setPosition("과장"); | |
entityManager.persist(employee2); | |
entityManager.flush(); | |
entityManager.clear(); | |
String query = "SELECT group_concat(e.position) FROM Employee e"; | |
List<String> result = entityManager.createQuery(query, String.class) | |
.getResultList(); | |
// position = 사원, 과장 | |
for (String position : result) { | |
System.out.println("position = " + poistion); | |
} | |
transaction.commit(); | |
} catch (Exception e) { | |
transaction.rollback(); | |
e.printStackTrace(); | |
} finally { | |
entityManager.close(); | |
} | |
entityManagerFactory.close(); | |
} |
출처
자바 ORM 표준 JPA 프로그래밍 - 기본편 (김영한 강사님)
반응형
'DB > JPA' 카테고리의 다른 글
[JPA] 준영속(Detached) 상태 엔티티 수정하는 방법 (0) | 2023.05.07 |
---|---|
[JPA] JPQL 추가 정리 (0) | 2021.10.18 |
[JPA] 값 타입 정리 (0) | 2021.09.29 |
[JPA] 프록시와 연관관계 관리 정리 (0) | 2021.09.14 |
[JPA] @MappedSuperclass (0) | 2021.09.07 |