====== Hibernate/JPA Gotcha ====== 하이버네이트/JPA에 관해 항상 기억해둬야 할 사항들을 정리해 둔다. ===== hashCode와 equals를 항상 구현한다 ===== hashCode와 equals를 항상 구현해야 한다. 이때 DB Primary Key(ID)가 아닌 **항상 변치 않고 해당 객체를 대표하는 의미를 가질 수 있는 비즈니스 키(business key)를 hashCode와 equals의 대상으로 삼도록 노력한다.** 이유는 Set등에 신규 객체를 넣을 경우 신규 객체는 아직 프라이머리키가 지정되지 않은 상태이기 때문에 ID가 모두 ''null'' 혹은 ''0''이며 이 경우 Set에 저장할 때 equals가 항상 true 여서 이전의 값을 뒤에 저장한 값이 계속 덮어써버리는 현상이 발생하기 때문이다. 하지만 실제로 해보면 인공키를 hashCode의 기준으로 삼을 수 밖에 없는 경우도 많다. Set/Map 과 함께 사용할 때 주의를 기울여야한다. 따라서 ID를 equals/hashCode의 기준으로 삼을 경우 매우 주의해야 한다. 관련 참조 - [[http://community.jboss.org/wiki/EqualsAndHashCode|Hibernate equals and hashCode]] ===== to-many 관계에 대해서 fetch join은 안하는게 낫다 ===== * to-many 관계에 대해서 fetch join 을 하면 부모측이 자식측(many)의 갯수만큼 중복으로 결과 갯수가 늘어난다. * 이를 해결하려면 ''distinct'' 를 꼭 해야한다. * to-many 관계에 대해서 ''limit'' 을 걸게 되면 그 일반 SQL에서는 자식의 갯수에 대한 limit 이 돼 버린다. 이렇게 되면 실제 조회를 원하는 데이터(parent)의 갯수가 의도대로 나오지 않게 된다. * 때문에 하이버네이트는 SQL에 limit을 걸리 않고 limit 없이 전체 데이터를 조회한 뒤에 메모리에서 parent 데이터를 원하는 limit 갯수만큼 끊어서 반환한다. * 이는 엄청난 성능저하로 이어진다. * 결과적으로 특별한 이유가 없다면 **to-many 관계에 대해서는 fetch join을 하지 말고 항상 ''Hibernate.initialize()'' 등으로 초기화를 하되 batch size 를 지정하는 식으로 한다.** * [[java:hibernate:performance|Hibernate Performance Tuning]] ''hibernate.default_batch_fetch_size=30'' * ''default_batch_fetch_size''를 지정하면 N+1 이 아니라 N 번 조회할 것은 in 쿼리로 batch_size 만큼의 인자를 넣어서 한 방에 조회한다. * [[java:hibernate:configuration|Hibernate Configurations]] - ''hibernate.query.fail_on_pagination_over_collection_fetch=true'' 옵션을 켜서 미리 에러를 내고 확인해서 고쳐두는게 좋다. ===== One-To-One 과 Many-To-One 관계를 조심하라 ===== One-To-One과 Many-To-One 관계에서 One 측은 ''not null''이라고 명시하지 않는이상 **Lazy Loading이 작동하지 않는다.** 이에 관해서는 [[java:jpa:one-to-one|JPA One-To-One]]을 참조한다. ===== 관계 필드에 존재하지 않는 엔티티에 대한 값을 넣지 말라 ===== A -> B 관계가 있을 때 A에 있는 B를 가리키는 컬럼에 존재하지 않는 B에 대한 값(특히 ''0'')을 넣으면 A 를 조회할 때 A 자체가 조회가 안된다(Foreign Key가 안 걸려 있으면 존재하지 않는 값 지정이 가능함). 존재하지 않는 값은 **''null''**로 넣어야지 ''0''이나 ''공백'' 같은 값을 사용하면 안된다. ===== toString 메소드를 조심하라 ===== toString 메소드에서 레이지 로딩으로 지정된 필드를 출력하도록 해서 불필요하게 레이지 로딩 필드가 미리 읽혀지는 문제가 발생할 수 있다. toString 에서 출력값을 주의 깊게 선별해야 한다. 특히 [[http://projectlombok.org/|lombok]]을 사용한다면 매우 주의하라. ===== OpenSessionInView 패턴 사용시 HTML 주석을 사용치 말라 ===== 무엇보다 가급적 **OpenSessionInView 패턴을 사용하지 말라**. 처음엔 편하지만 나중에는 불명확한 쿼리 실행으로 튜닝이 오히려 어렵고 복잡해진다. toString 메소드에서와 같은 현상이 OpenSessionInView 패턴 사용시 뷰에서 발생할 수 있다. 더이상 필요없는 부분 특히 하이버네이트 도메인 객체를 호출하는 부분을 주석처리할 때 **HTML 주석을 사용하지 말라.** HTML주석은 서버사이드는 그대로 동작한다. 따라서 레이지 로딩으로 지정한 값을 HTML 주석 부분에서 호출하면 그대로 값이 로딩되어 출력된다. 항상 **템플릿 엔진의 주석(JSP의 경우 <%-- --%>)을 사용하라.** ===== 개발 환경에서는 쿼리 로그를 남겨라 ===== hibernate.show_sql=true hibernate.format_sql=true 위 프라퍼티 옵션으로 쿼리 로그를 남길 수 있다. 그러나 ''hibernate.show_sql=false''로 두고 되도록 Log4j 옵션을 사용하는 것이 좋다. [[java:hibernate:log|Hibernate Log 남기기]]를 참조한다. 단, 실 운영환경에서는 로그를 남기는 순간 매우 느려지므로 로그 레벨을 높여야 한다. ===== 쿼리에 진짜로 ":"로 들어간다면? ===== Native SQL에 '':''를 넣어야 할 필요가 있다면 Escape을 해줘야 한다. [[http://stackoverflow.com/questions/9460018/how-can-i-use-mysql-assign-operator-in-hibernate-native-query|java - How can I use MySQL assign operator(:=) in hibernate native query?]] ''\\:'' 형태로 Escape 한다. SELECT k.`news_master_id` AS id, @row \\:= @row + 1 AS rownum FROM keyword_news_list k JOIN (SELECT @row \\:= 0) r WHERE k.`keyword_news_id` = :kid ORDER BY k.`news_master_id` ASC ===== Lazy 자식 컬렉션 2중 insert 문제(children collection insert twice) ===== * [[https://hibernate.atlassian.net/browse/HHH-6776|Hibernate inserts duplicates into @OneToMany collection]] * [[https://hibernate.atlassian.net/browse/HHH-5855|The method "merge" from the EntityManager causes a duplicated "insert" of a child entity.]] * Lazy ''@OneToMany'' 컬렉션에 ''add(자식Entity)''를 하면서 ''entityManger.merge(부모Entity)''를 호출 할 경우 두번 insert 되는 문제가 있다. * 이 현상은 ''@OneToMany'' 컬렉션이 Lazy Loading일 경우 컬렉션의 내용이 로딩이 안된 상태에서 add를 하면 발생하는 것 같다. * 재현 * 부모 엔티티를 읽어온다. ''(A a = em.find(A.class, id))'' * 부모측 컬렉션에 자식을 추가한다.''(a.getBs.add(new B()))'' * 명시적으로 merge를 호출하여 저장한다. ''(em.merge(a))'' * 따라서 자식 엔티티를 부모에 추가할 때는 * 미리 Lazy 컬렉션을 로딩하거나, ''(Hibernate.initialize(a.getBs())'' * 아니면 부모 엔티티를 수정하지 말고, 자식 엔티티에 부모 엔티티의 관계를 맺고, 따로 insert 하거나 * 아니면 ''entityManager.merge(entity)''를 수행하지 말고 트랜잭션 종료시에 자동 저장되도록 처리한다. ===== JPQL Positional Parameter Bug ===== * JPQL 방식의 Positional Parameter(''?1, ?2, ..'') 형태를 사용할 경우 각종 버그가 발생한다. 사용하지 말 것. * [[https://hibernate.atlassian.net/browse/HHH-9871|[HHH-9871] JPA positional parameter mapping bug with" same java type and samve value but different custom type" - Hibernate JIRA]] * [[https://hibernate.atlassian.net/browse/HHH-9900|[HHH-9900] Different JPQL queries might be mapped to the same QueryKey]] ===== @Where 에서 true, false 등에 대해 잘못된 반응 ===== * ''@OneToMany''에 ''@org.hibernate.annotations.Where(clause = "deleted = false")''와 같은 조건을 주었을 때, SQL이 잘못 생성된다. select ... from address address0_ where ( address0_.deleted=address0_.FALSE) -- 이 부분 and address0_.contact_id=? * [[http://stackoverflow.com/questions/10730640/is-there-a-way-to-stop-hibernate-from-corrupting-boolean-literals-in-where-anno|java - Is there a way to stop Hibernate from corrupting boolean literals in @Where annotations?]] * ''true'', ''false'' 등이 Dialect에 keyword로 등록이 되어있지 않아서 생기는 현상이다. * 조건을 ''delete = 0'' 형태로 주거나 DB 종류에 따라 문자열 '''true/false'''를 ''true/false''로 인식하면 문자열로 사용하는 방식으로 회피 가능하다. * 혹은 사용중인 Dialect 에 ''true'', ''false''를 추가한다. public class ImprovedHSQLDialect extends HSQLDialect { public ImprovedHSQLDialect() { super(); registerKeyword("true"); registerKeyword("false"); // registerKeyword("unknown"); } } * [[https://hibernate.atlassian.net/browse/HHH-2775|[HHH-2775] true and false are not escaped in where attributes (@Where) - Hibernate JIRA]] ===== SQL / JPQL / HQL Keyword "in", "by" 같은 Keyword 가 JPA Entity package 에 들어가면 파싱 오류 발생 ===== * [[https://stackoverflow.com/questions/29230286/spring-jpa-in-word-in-package-name-of-entity-class-results-in-jpql-error|java - Spring JPA - "in" word in package name of Entity Class - Results in JPQL Error]] * [[https://hibernate.atlassian.net/browse/HHH-11784|[HHH-11784] HQL query fails if entity is inside a package which starts with "in" or "by" - Hibernate JIRA]] 6.0 에서 해결 될 수도 있음. * Entity 의 package name 에 ''in''이 들어 있을 경우, 이를 가지고 full package name으로 JPQL을 생성하면 ''in''을 JPQL Keyword로 인식해서 동작하지 않는 현상이 발생했다. 보통, join 같은 복잡한 쿼리에서 발생했다. * **패키지 이름에서 ''in'' 같은 JPQL, SQL Keyword 단어가 안들어가게** 할 것. * [[https://www.mkyong.com/hibernate/how-to-use-database-reserved-keyword-in-hibernate/|How to use database reserved keyword in Hibernate?]] : ''@Column(name="`desc`")'' 처럼 DB에 적합한 escape 문자 써 줄것. ===== null id in entry ===== > HHH000099: an assertion failure occurred (this may indicate a bug in Hibernate, but is more likely due to unsafe use of the session): org.hibernate.AssertionFailure: null id in ''com.mycompany.XXX'' entry (don't flush the Session after an exception occurs) * [[https://stackoverflow.com/questions/10855542/org-hibernate-assertionfailure-null-id-in-entry-dont-flush-the-session-after|mysql - org.hibernate.AssertionFailure: null id in entry (don't flush the Session after an exception occurs) - Stack Overflow]] * 핵심은 **"동일 트랜잭션 안에 저장이 실패한, 혹은 나도 모르게 직접 쿼리를 통해 DB에서 삭제된 Entity가 EntityManager 1차 캐시에 남아있는 상황"은 존재해서는 안된다.** * 하나의 트랜잭션에서 * 쓰기 작업시 에러가 발생해서 ''XXX'' 엔티티가 저장이 안됐는데, 이를 ''try/catch'' 블록에서 잡아서 그 후속 작업을 진행했거나, * 혹은, entity 삭제가 아니 직접 쿼리로 엔티티를 삭제하거나 한 상황에서 * ''Session/EntityManager'' 입장에서는 잘못된 Entity 가 여전히 1차 캐시에 존재하는 상태이기 때문에 * 나중에 flush 가 일어날 때 잘못된 Entity 혹은 DB상에 존재하지도 않지만 기존에 존재했던 것처럼 표시된 Entity 때문에 오류가 발생하게 된다. * 쓰기 작업과 그 후속 읽기 작업 트랜잭션을 분리해서 ''Session/EntityManager''가 쓰레기 엔티티를 갖지 않게 하거나 * 혹은 직접 쿼리를 통한 삭제 작업의 경우에는 ''session.clear()'' 혹은 ''entityManager.clear()''를 통해 쓰레기 Entity 를 1차 캐시에서 모두 제거해야 한다. ===== 참조 ===== * [[http://www.devinprogress.info/2011/08/hibernate-gotchas.html|Development in progress...: Hibernate Gotchas!]] * 기타 직접 겪은 것들을 정리한다.