JPA 노트
JPA로 커머스 개발을 앞두고 혼자 정리해보는 나만의 가이드라인. 일단 JPA로 시작하기는 하지만 어떤 식으로 정리될지는 두고봐야할듯.. 계속 작성하는 페이지.
엔티티 구현
equals()와 hashcode() 구현하기 - 엔티티 동일성 확인
업무 로직에 맞춰서 동일한 정보인지를 판단하는 로직을 보통 서비스 등에 개발을 하게 되는데, 엔티티의 동일성을 확인하는 코드는 이렇게 매번 필요할 때마다 if를 넣지 않고 엔티티 클래스의 equals()와 hashcode()를 구현해 비교하도록 하자.
언제 사용하는가?
- 엔티티가 동일한지 직접 비교 하거나 (if)
- Set에 저장할 때 (Set은 중복을 허용하지 않기 때문에)
- SET에 직접 엔티티를 저장하거나
- 또 다른 엔티티의 SET 프로퍼티로 등록되어 있는 경우
- 세션의 1차, 2차 캐시에 저장하거나
- 세션의 오퍼레이션을 수행할 때 (예를 들어, detach() 등)
- find()한 객체와 Query/Criteria로 로딩한 객체의 동일성 확인할 때
어떻게 구현해야 하나?
- 구현하지 않는다 (equals()와 hashcode()를 override하지 않음)
- 세션에서 동일한 ID를 같는 객체가 이미 로딩된 경우에 이 객체를 그대로 반환하기 때문에 동일한 객체로 인식함
- ID 프로퍼티만 사용해 비교하도록 equals()와 hashcode()를 구현한다 (Member의 id 필드만 사용해 비교)
- @Id가 선언된 DB 주키와 매핑된 필드만 비교한다
- 단, 객체가 동일한지 먼저 비교한 후에 ID 비교를 한다
- 비즈니스키에 해당하는 프로퍼티를 사용해 equals()와 hashcode()를 구현한다 (Member의 id와 email 필드를 함께 사용해 비교)
- 비즈니스키를 사용해 비교하는 e/s 함수를 구현
- 비교하고 hash를 생성하는 로직은 Apache commons 라이브러리를 사용하자 : EqualsBuilder와 hashcodeBuilder 구현 참조
- 위 클래스를 사용하려면 commons-lang을 POM에 추가해야 한다
- 여기서 말하는 비즈니스키에는 DB 주키(ID)를 포함할 수 도 있고 때에 따라서는 포함하지 않고 정확히 식별이 가능하다면 포함하지 않아도 좋다
- 왜냐면 DB 주키(ID)가 할당되어 있지 않지만 비즈니스 키는 동일할 수 있기 때문이다. DB와 동기화가 되지 않은 엔티티라 ID가 없지만 다른 필드 값은 동일하게 가지고 있을 수 있기 때문이다. 물론, 비즈니스 키가 최소한 컬럼의 unique로 선언되어 있을 것이니 로직에서 걸러지지 않아도 저장 시에 걸러지는 케이스가 많을 걸로 생각된다..
- 비즈니스 키 비교시에는 DB 주키(ID)를 포함하지 않는다. ID로 비교가 필요하거나 ID로만 식별이 충분하다면 비즈니스 키 비교(3번)이 아닌 ID 비교 방법(2번)을 사용한다.
참조
- https://vladmihalcea.com/2013/10/23/hibernate-facts-equals-and-hashcode/
Lombok과 함께 사용하기
항상 만들어야 하는 getter/setter와 생성자는 Lombok을 사용하고 코드를 작성하지말자. 기본 포맷은 다음처럼 (필요 시 더 추가..)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
Lombok을 사용하려면 다음 의존성을 추가해야 한다. 로컬에서 개발할 때는 인텔리j와 이클립스 플러그인 설치해 사용해야 한다.
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${version.lombok}</version>
</dependency>
Lombok 사용 시 주의해야 할 점
- @ToString() 사용하면 엔티티의 연관관계까지 출력이 되면서 무한 반복 관계가 된다. 문제 없다라고 볼수 있으나 로그를 찍다가 StackOverFlow가 만날 수 있다…
엔티티 매핑
엔티티 ID 매핑
- DB에서 생성하는 값 사용 (Sequence나 Identity)
- 사용자에게 받는 값 사용
- 로직으로 생성하는 값 사용
DB에서 생성하는 값을 사용하고 싶을 때는 @GeneratedValue 를 사용한다.
@Id
@GeneratedValue
private long id;
시퀀스(특히, 오라클)를 사용할 때는 @SequenceGenerator 와 함께 사용. @SequenceGenerator 는 시퀀스에 대한 상세한 속성을 설정한다. 특별히 테스트 목적이 아니면 반드시 선언해서 사용하자. @SequenceGenerator 를 선언하지 않으면 모든 ID 값이 “hibernate_sequence”라는 이름의 1개 Generator를 사용한다.
주의! Hibernate5부터는 Identity로 정확히 명시하지 않으면 사용하는 DB와 관계 없이 무조건 시퀀스 방식을 사용한다. @GeneratedValue(strategy = GenerationType.IDENTITY) 로 반드시 지정하자. AUTO로 지정하고 MySQL(Maris)에서 실행하면 hibernate_sequence라는 테이블이 생성된다.
참조
- https://github.com/spring-projects/spring-boot/commit/f3c311993a9a4f1cb5ec46bfb885d7d52e47480a
- http://docs.jboss.org/hibernate/orm/5.0/userguide/html_single/Hibernate_User_Guide.html#identifiers-generators-auto
복합키(composite id) 사용하기
두 개 이상의 ID를 사용하고 싶은 경우에 다음 순서대로 작업한다.
@Entity
@IdClass(CatalogProductId.class)
@Data
public class CatalogSectionProduct {
@Id
private long catalogId;
@Id
private long productId;
// ...
}
@Data
public class CatalogProductId implements Serializable {
private long catalogId;
private long productId;
}
Enum 타입 매핑
enum 문자를 DB 값으로 사용하고 싶을 때는 다음과 같이 매핑한다.
@Column(nullable = false, length = 1)
@Enumerated(EnumType.STRING)
private ProductType productType = ProductType.P;
기본은 숫자타입(EnumType.ORDINAL)으로 매핑된다.
Index 매핑
@Index로 매핑 가능 (자동 생성도 지원). columnList는 ‘,’로 구분해 적는다. (배열 아님 주의)
@Entity
@Table(indexes = {@Index(name = "INDEX_PRD_NAME", columnList = "productName")})
public class Product {
private String productName;
}
시간 및 날짜 매핑 (Date)
JDK8 이전에는 @Temporal 를 함께 병행해서 사용.
JDK8부터는 @Temporal 을 사용하지 않는다. 사용하면 에러
Caused by: org.hibernate.AnnotationException: @Temporal should only be set on a java.util.Date or java.util.Calendar property: commerce.app.entity.Category.closeDate …
JDK8의 LocalDateTime을 매핑해 돌려보면 DDL 생성된 코드를 보면 ‘TINYBLOB’로 생성됨. (mariadb)
@Converter(autoApply = true)
public class LocalDateTimeToTimestampAttributeConverter implements AttributeConverter<LocalDateTime, Timestamp> {
@Override
public Timestamp convertToDatabaseColumn(LocalDateTime locDateTime) {
return (locDateTime == null ? null : Timestamp.valueOf(locDateTime));
}
@Override
public LocalDateTime convertToEntityAttribute(Timestamp sqlTimestamp) {
return (sqlTimestamp == null ? null : sqlTimestamp.toLocalDateTime());
}
}
위와 같이 매핑하면 mariadb 기준으로는 DATETIME 타입으로 매핑됨.
2016-12-08 수정 JPAConverter 기능이 추가되어 위에 설명한 클래스를 추가하지 않더라도 더 쉽게 사용할 수 있다! 스프링 문서를 보면 아래처럼 @EntityScan 을 사용해 등록 가능
@EntityScan(
basePackageClasses = { Application.class, Jsr310JpaConverters.class }
)
@SpringBootApplication
class Application { … }
이렇게 Jsr310JpaConverters를 등록하면 별도로 Converter 구현이 필요 없음.
자바 기본 타입 사용 시 primitive 타입을 사용할 것인가 Wrapper 타입을 사용할 것인가?
아래와 같은 경우이다.
public class Asset {
// primitive type
@Colum
private long currentAmount;
// wrapper class type
@Colum
private Long currentAmount;
}
두 방식의 가장 큰 차이점은 ‘null’ 값을 받을 수 있냐 없냐다. long은 null이 될수 없고, Long은 null을 참조할 수 있기 때문에 해당 필드가 null 값이 올수 있다면 long이 아니라 Long을 사용해야 한다.
그런데 애시당초 개발 시작 시점에 long으로 컬럼을 선언해 사용했다면 null 값이 들어갈 수가 없었기 때문에 null이 올일이 없어서 문제가 안된다. 물론, DB에 직접 들어가 강제로 null 값을 넣어주면 되지만 이런 무모한 행동(?)을 하지는 않으니 현실적인 이야기가 아니고..
오히려 현실적인 이야기면서 프로젝트하면서 격었던 점은 운영 중에 필드를 추가하는 경우다. 운영 중에 ‘totalAmount’ 컬럼을 long 타입으로 추가했다고 보자.
public class Asset {
@Column
private long totalAmount;
}
운영DB이니 JPA의 스키마 수정 기능을 사용하지 않고 직접 컬럼을 추가하면 테이블의 기존 row의 해당 컬럼에는 ‘null’로 값이 설정되게 된다. (notnull 컬럼이 아니면)
그 이후 조회하면 기존 테이블 row 데이터에는 totalAmount가 null로 설정되어 있고, 엔티티의 totalAmount 타입이 long이기 때문에 다음과 같은 에러가 발생한다.
org.springframework.orm.jpa.JpaSystemException: Null value was assigned to a property [class io.chanwook.jpa.entity.Asset.totalAmount] of primitive type setter of io.chanwook.jpa.entity.Asset.totalAmount; nested exception is org.hibernate.PropertyAccessException: Null value was assigned to a property [class io.chanwook.jpa.entity.Asset.totalAmount] of primitive type setter of io.chanwook.jpa.entity.Asset.totalAmount
...
Caused by: org.hibernate.PropertyAccessException: Null value was assigned to a property [class io.chanwook.jpa.entity.Asset.totalAmount] of primitive type setter of io.chanwook.jpa.entity.Asset.totalAmount
...
Caused by: java.lang.IllegalArgumentException: Can not set long field io.chanwook.jpa.entity.Asset.totalAmount to null value
...
이런 경우에는
1) 해당 컬럼 생성 시에 기본값(default value)를 지정하거나 2) 엔티티 컬럼 매핑 시에 기본값을 지정하거나 3) 엔티티 컬럼의 타입을 Long(Wrapper 클래스 타입)으로 선언한다
notnull 컬럼이면 이런 문제는 발생하지 않는다.
컬럼 추가 시 에러가 발생하니 단순히 long이 아니라 Long을 쓰자라기 보다는 차근히 생각해보면 운영 중에 테이블에 컬럼을 추가할 때는 당연히 기존 데이터에는 어떤 값을 가져야 하는지 생각해보는 것이 먼저이다.
EntityLister 활용하기 (ex.전체 테이블에 공통으로 들어가는 생성일/수정일 기록하기)
JPA의 EntityListener를 사용하면 엔티티 상태가 바뀌는 이벤트를 잡아서 로직을 실행할 수 있다.
구현 방식은
- 이벤트 메서드를 엔티티 클래스 안에 만들거나 상위 클래스로 만들어서 사용하거나
- 별도 이벤트 리스너 클래스를 만들어서 @EventListener로 엔티티 클래스에 등록해주는 방법이 있다.
생각을 해보면 상위 클래스로 만들어 두면 이벤트 메서드로 초기화한 필드를 사용할 수 있기 때문에 1번 방법이 적절한 상황이 더 많을 듯하다. 물론, 엔티티에 해당 필드의 직접적인 노출을 막아야 하는 경우라면 2번도 괜찮을듯하다.
아래 예제어서 UpdateAuditListener는 1번으로 만든 클래스이고, CreateAuditListener는 2번 방식으로 만든 클래스다.
@Entity
//...
@EntityListeners(CreateAuditListener.class)
public class Order extends UpdateAuditListener {
}
@MappedSuperclass
@Data
public abstract class UpdateAuditListener {
@Convert(converter = Jsr310JpaConverters.LocalDateTimeConverter.class)
@Column(name = "UPDAT_DAT")
protected LocalDateTime updateDateTime;
@PreUpdate
protected void onUpdate() {
this.updateDateTime = LocalDateTime.now();
}
}
public class CreateAuditListener {
@Convert(converter = Jsr310JpaConverters.LocalDateTimeConverter.class)
@Column(name = "CRT_DAT")
protected LocalDateTime createDateTime;
@PrePersist
public void onPersist(Object entity) {
createDateTime = LocalDateTime.now();
}
}
이벤트 리스닝 방식은 전체 테이블에 공통으로 들어가는 생성일/수정일을 처리할 때 사용하면 좋다.
연관 관계 매핑
엔티티 간의 연관 관계 매핑은 DB의 외래키로 관계를 맺어주게 된다. (Fetch 타입에 따라 달라지긴 하지만) 엔티티 간의 연관 관계는 SQL JOIN을 발생시키기 때문에 주의해서 사용이 필요하다.
엔티티 연관은 다음과 같은 다중성으로 표현이 가능하다.
- 일 대 다 (1:N)
- 다 대 일 (N:1)
- 일 대 일 (1:1)
- 다 대 다 (N:N)
보통은 1:N과 N:1을 양방향 관계로 많이 사용하기 때문에 실제로는 3가지 연관관계가 있다고 보면 된다.
1:N과 N:1 처리
DB 외래키가 생성되는 건 N:1 연관을 선언한 엔티티가 된다.
@Entity
@Getter
@Setter
@AllArgsConstructor
public class Product {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "DISPLAY_CATEGORY_ID")
private Category displayCategory;
}
Product에 매핑된 테이블에는 진연카테고리의 ID(Category.categoryId)의 이름으로 컬럼이 생성되고 외래키로 지정된다.
반대로 카테고리에 매핑된 상품 목록을 표현하기 위해서는 카테고리에 상품 컬렉션을 1:N으로 연결한다.
@Entity
@Getter
@Setter
public class Category {
@OneToMany(mappedBy = "displayCategory")
private List<Product> productList = new ArrayList<>();
}
이렇게 양방향 관계로 사용할 때는 양쪽 참조가 누락되지 않도록 지정해야 한다는 점이다. 이럴 때는 보통 Product나 Category 중 한 쪽에 편의함수를 만들어 사용한다.
public class Category {
public void addProduct(Product product) {
this.productList.add(product);
product.setDisplayCategory(this);
}
}
이러한 양방향 관계일 때는 외래키가 생성되는 테이블에 매핑된 엔티티에서 연관 관계 처리의 책임을 저야 한다. 위 예로 들면 Product 엔티티를 통해서 Category와의 연관 관계에 대한 처리를 해야 한다는 뜻이고, Cascade 처리 또한 Product -> Category 뱡향으로 처리하는 것이 좋다.
또한, 1의 측면(위 예에서는 Category)에서 mappedBy 매핑을 해주지 않으면 1대N 관계 처리를 위한 매핑 테이블을 생성해버리게 된다. 정말 매핑 테이블이 필요하다면 모르겠지만 그렇지 않다면 반드시 mappedBy를 붙여주어 클래스 생성이 안 되도록 해야 한다. (주의요망!)
TODO 오너쉽을 갖는 측의 입장에서 처리하라는 설명 추가
참고로 일대다(1:N) 단방향 매핑은 가능하면 사용하지 않돋록 한다. (엔티티와 DB 매핑이 복잡해지기 때문)
OneToMany에서 단방향(Uni-directional) 처리
객체 관계를 사용하는 입장에서 보면 ‘1(Product)’ 측에서 ‘다(ProductImage)’ 측의 변수에 대한 콜렉션에 붙이지는 @OneToMany 에 @JoinColumn 을 붙여 단방향 처리가 가능하다.
2.0과 2.1 스펙에 보면 @JoinColumn 설명 마지막 절에 간단히 언급된다 (예제로만..). (11.1.25절 참조) 위키북스에도 잘 설명되어 있다.
개인적인 생각으로는 객체와 DB 매핑이 연결 고리가 없고 양쪽의 접근 방향성이 정반대이기 때문에 사용하지 않는 것이 좋겠다 생각한다.
일대일(1:1)
일 대 일 @OneToOne 을 사용해 매핑한다. 일대일 관계에서는 양쪽 테이블에 외래키가 생성된다. 그러므로 양쪽에 관계가 누락되지 않도록 정확하게 연관 관계 설정을 해주는 것이 필요하다.
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Member {
@OneToOne
@JoinColumn(name = "ACTVIE_CART_ID", referencedColumnName = "cartId", nullable = true)
private Cart cart;
}
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Cart {
@OneToOne
@JoinColumn(name = "OWNER_MEMBER_NUMBER", referencedColumnName = "memberNumber", nullable = false)
private Member owner;
}
다대다(N:N)
다대다 관계는 매핑 시에 다대다로 매핑해 사용할 수도 있고, 아니면 매핑 테이블을 엔티티로 매핑해 일대다/다대일 관계로 만들어 사용할 수도 있다. 두 엔티티간의 단순한 다대다 관계라면 아래처럼 @JoinTable 을 사용해 매핑하면 된다.
@Entity
public class Sku {
@ManyToMany(cascade = CascadeType.ALL)
@JoinTable(name = "SKU_PRD_ATTR_VALUE",
joinColumns = {@JoinColumn(name = "skuId")},
inverseJoinColumns = {@JoinColumn(name = "valueId")})
private List<ProductOptionValue> optionValueList = new ArrayList<>();
}
@Entity
public class ProductOptionValue {
@ManyToMany(mappedBy = "optionValueList")
private List<Sku> skuList = new ArrayList<>();
}
하지만 연관 테이블이 단순히 연관 엔티티의 ID로만 매핑 된 것이 아니라 별도의 속성이 필요하다면 이를 역시나 엔티티로 매핑해 사용해야 한다. 엔티티 매핑은 앞서 설명한 일대다/다대일 매핑과 동일하다. 단, 다대다 매핑 테이블의 엔티티의 ID를 연관 엔티티의 ID를 그대로 사용하고 싶은 경우에는 @IdClass 를 사용해야 한다.
TODO @IdClass 사용하는 케이스 추가
Query
JPA에서 데이터를 조회할 때는 다음 순서로 접근하자.
- 첫 번째 단계
- findOne(), findAll() 등 Repository의 기본 함수로 엔티티를 조회 후 객체 Graph를 통해 데이터 처리
- Repository 쿼리 메서드를 정의해 별도 조회 로직을 작성하지 않고 데이터 처리 (findByUserName(userName) 이런식으로..)
- 복잡한 조건식을 메서드에 모두 적을 수 없다. 당연히. 그렇게 해서도 안 된다
- 기본적인 조건으로 엔티티를 일단 로딩하고 나서 로직을 수행해도 되는 경우에 적절함
- 두 번째 단계
- QueryDSL으로 구현
- 쿼리 클래스를 사용해 조회 조건을 구현
- JPAQL을 사용해 구현
- Spring Data의 @Query 를 사용해 리파지토리 인터페이스에 선언
- 세 번째 단계
- Native SQL을 실행 ..
Query 메서드
따로 설정을 필요 없고 리파지토리 인터페이스에 엔티티의 속성명을 기준으로 쿼리 메서드를 추가하면 된다.
http://docs.spring.io/spring-data/jpa/docs/1.10.2.RELEASE/reference/html/#jpa.query-methods
QueryDSL
환경설정
POM에 의존성과 빌드 플러그인을 추가하면 된다.
https://spring.io/blog/2011/04/26/advanced-spring-data-jpa-specifications-and-querydsl/
Repository 인터페이스 추가
Repository 인터페이스에 QueryDslPredicateExecutor<?> 인터페이스를 상속하도록 한다.
http://docs.spring.io/spring-data/jpa/docs/1.10.2.RELEASE/reference/html/#core.extensions.querydsl
사용하기
Q로 시작하는 쿼리 클래스를 사용해서 쿼리문을 생성해서 리파지토리에 전달한다.
final QMember member = QMember.member;
// =
final BooleanExpression eq = member.memberName.eq("아이언맨");
List<Member> list = (List<Member>) mr.findAll(eq);
// like
final BooleanExpression like = member.memberNumber.like("1%");
list = (List<Member>) mr.findAll(like);
// and
final BooleanExpression and = member.memberType.eq(MemberType.P)
.and(member.memberStatus.eq(MemberStatus.A));
list = (List<Member>) mr.findAll(and);
TODO 쿼리문을 만드는 로직의 위치에 대한 고민 : 서비스냐 도메인이냐
JPQL
Spring Data Jpa에서 제공하는 @Query 를 사용해 리파지토리 인터페이스에 JPQL을 정의해 사용한다.
public interface SkuJpaRepository extends JpaRepository<Sku, Long> {
@Query("SELECT s FROM commerce.entity.Sku s WHERE s.product.productId = ?1 and s.stock > 0")
List<Sku> findByStockedProduct(String productId);
@Query("SELECT s FROM commerce.entity.Sku s WHERE s.displayName like ?1%")
List<Sku> findByDisplayNameLike(String displayName);
}
Native SQL
Spring Data Jpa에서 제공하는 @Query 를 사용해 리파지토리 인터페이스에 Native SQL을 직접 작성할 수 있다. 단, nativeQuery = true로 선언해줘야 한다.
Spring과 JPA 함께 사용하기
기본 환경설정
일단 설정에 spring Boot의 JPA 모듈과 사용하는 DB에 맞는 JDBC를 POM에 추가해야 한다.
<!-- 기타 Spring boot 설정도 필요 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- H2를 사용. 이 정보는 사용하는 DB에 맞춰 변경하자. -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
데이터소스가 1개라면 EntityManagerFactory 등을 위한 설정은 필요 없다.
TODO 데이터소스가 여러 개 일 때는?
별도로 리파지토리 관련 설정을 하지 않으면 스프링 부트의 APP 메인 클래스부터 엔티티 검색을 한다. 리파지토리 및 엔티티 검색 관련 설정을 하고 싶다면 아래처럼 하도록하자.
@Configuration
@EnableJpaRepositories(basePackages = "applestore",
includeFilters = @ComponentScan.Filter(type = FilterType.REGEX, pattern = "com.jpasample..\*JpaRepository"))
@EntityScan(basePackages = "com.jpasample.domain")
public class JpaConfig {
}
그외 JPA 프로퍼티 설정 역시 부트 프로퍼티 파일에 기록하면 된다. 프로퍼티 명은 ‘spring.jpa.*’ 형식이다.
Config 클래스 만들기
기본 설정으로 부족할 경우에는 JavaConfig 클래스를 만들어 JPA 관련 설정을 정리하자
자세한 내용은 스프링 데이터 JPA 가이드 참조.
Naming strategy
Spring Boot 이전 버전(확인 필요) + Hibernate 4에서는 spring.jpa.hibernate.naming.strategy 을 사용하지만, Spring Boot 최신 버전(확인 필요) + Hibernate 5에서는 physical-strategy와 implicit-strategy를 사용해야 한다.
Hibernate5.x에서는 NamingStrategy가 두 개 인터페이스로 바꼈다. Hibernate5.x를 사용하는 스프링 부트 버전 레퍼런스에서도 아직 설명을 안 하고 있는 것 같다.(스프링 코어 레퍼런스에는 있나?)
왜 자꾸 컬럼명이 카멜케이스로 만들어지냐 한참을 뒤줘봤더니 기존에 사용하던 naming strategy는 deprecated 됐고 이걸 사용해야 한다.
그래도 스프링 부트지라에는 엄청난 얘기가 있구만.. 이건 내일 읽어보자 @.@
application.property에서는
spring.jpa.hibernate.naming.physical-strategy=org.springframework.boot.orm.jpa.hibernate.SpringPhysicalNamingStrategy
spring.jpa.hibernate.naming.implicit-strategy=org.springframework.boot.orm.jpa.hibernate.SpringImplicitNamingStrategy
이렇게 사용해야 하고, JavaConfig로 지정할 때는 hibernate.physical_naming_strategy와 hibernate.implicit_naming_strategy란 이름으로 지정해줘야 한다.
끝~
참조 : https://github.com/spring-projects/spring-boot/issues/2763
개발 모델과 설계
고려사항
- 엔티티를 화면단까지 직접 노출할것인가 아닌가?
- 엔티티와 Repository, Aggregate 정의
- 패키지 구성
TODO DDD, 최범균님 책 보고 한 번 더 뽑아보자
개발 절차와 운영 관련 고민
개발 DB와 운영 DB에서 엔티티 매핑 변경에 따른 스키마 반영은 어떻게 해야할까?
고민할 때 적은 내용
혼자 사용하는 로컬DB라면 JPA의 스키마를 계속 재생성하겠지만 여러 개발자가 함께 사용하는 개발DB나 운영DB에서는 그럴수가 없다.
이럴 때는 변경된 매핑 정보에 대한 DDL 스크립트를 별도로 준비하고 우선 개발환경에서 변경된 스키마 정보를 반영 후 역시 함께 변경한 매핑 정보가 반영된 애플리케이션을 다시 배포해 확인한다. 검증이 되면 그 때 운영에 역시 스키마를 변경하고 애플리케이션을 배포하는 순으로 작업한다.
이말은 JPA라고 특별한 방법을 사용하는 것이 아니라 일반적인 RDB 운영 방식을 그대로 사용하는 편이 좋을 수도 있다는 측면으로 생각해볼 수 있다.
물론, 여기서 변경되는 엔티티 매핑에 대한 DDL 스크립트를 구하기 위해 변경한 엔티티를 한 번 실행해서 로그로 떨어지는 DDL 스크립트를 따낼 수도 있다. (그런데 이렇게 한 번 실행하면 이미 DB에는 반영이 되버린건데.. 로컬에 개발DB를 구성해 돌리기는 무리일테니.. 아니다 JPA를 사용하니 어쨌든 핵심 모델에 대한 스키마는 로컬에서도 구성하지 못할 이유가 없다! 데이터가 없다면 모를까..;;;)
프로젝트든 서비스 운영든 DB 스키마를 관리하는 사람(또는 팀)이 있기 마련인데, 최종 DB 변경의 주체는 이 사람(또는 팀)이 될테니 엔티티 매핑을 관리하는 개발자가 변경하고자 하는 정보를 JPA 스키마 생성으로 떨어지는 정보를 코드와 함께 DB 담당자에게 전달하고, 이를 개발, 운영에 순차적으로 반영하는 방법이 좋겠다. 전달한 DDL을 DB 담당자가 현재 스키마에 맞춰 한 번 더 정제를 해주겠지..
환경 별 DB에 반영하는 작업을 사람이 손으로 하냐 어떤 자동화된 프로세스를 따르냐는 별개 문제.
프로젝트를 앞두고 지금 생각
- 하이버네이트가 생성해주는 스키마를 기본으로 사용해 SQL 파일을 생성
- DB 담당자가 해당 SQL을 검토해 마이그레이션 스크립트 생성
- 개발 환경에서 마이그레이션 스크립트를 실행해 반영 후 검증
- 개발 환경에서 애플리케이션의 하이버네이트는 validate 모드로 실행
- 개발 환경에서 검증된 스크립트를 테스트/QA/스테이징/운영 등에 순차적으로 반영 (환경과 절차에 따라 조정)
- 운영 환경에서 애플리케이션의 하이버네이트는 validate 모드로 실행
hibernate.hbm2ddl.auto에 대한 고찰
하이버네이트의 update 소스코드(SchemaUpdate 클래스 등)를 확인해 봤으나 구체적으로 update로 반영되는 부분과 그렇지 않은 부분의 기준을 찾아내기는 어려웠다.
현재까지 경험한 바로는 필드의 길이 등 필드 수준의 변경 사항은 제대로 반영이 안되는 걸로 파악됨.
TODO 확인되는 대로 계속 반영하자
Hibernate Tools를 사용해 DDL/DML 추출하기
Hibernate Tools에서 제공하는 Ant 태스크를 사용해 현재 정의한 엔티티의 DDL/DML 추출이 가능하다. 여기서는 CREATE, DROP, UPDATE(ALTER) 정보를 얻어낼 수 있다. 하지만 컬럼의 길이 변경 등과 같은 세세한 변경사항까지 정확하게 생성이 된다면 좋겠는데 그렇게 동작하지 않기 때문에 여기서 생성되는 파일의 정보를 바로 자동화된 기능을 가지고 적용할 수는 없다.
오히려 여기서 생성된 파일을 개발자/DBA에게 변경 사항을 정확히 알 수 있게 공유하고 현재 운영의 스키마와 현재 최신 스키마를 비교(diff)하면서 마이그레이션 SQL을 작성해 내는 편이 좋다고 생각한다. 이렇게 만든 파일은 flyway와 같은 마이그레이션 툴을 함께 사용하면 베스트!
개발 방법은 아래처럼하자.
- 우선 메이븐 폼에 ant 플러그인 설정을 하고 소스참조
- 이어서 Hibernate Tools Ant 파일을 만들고 소스참조
- 마지막으로 Ant 태스크 용 persistence.xml을 만들면 끝 소스참조
- 해당 환경과 시점에 맞춰 위 내용을 수정해 사용하자!
Test
JPA 테스트 클래스 생성
- 엔티티 및 리파지토리 빈 설정 로딩
- 트랜잭션/캐시 등 테스트 정보를 클리어하는 기능과 TestEntityManager클래스등과 같은 JPA 테스트 편의 기능/설정 지원
- 임베디드DB(H2 등)가 아닌 설치형 DB(오라클,마이SQL등)를 사용 지원
- Spring+DBUnit으로 테스트 데이터 입력 지원
@RunWith(SpringRunner.class)
@SpringBootTest(classes = App.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestExecutionListeners(mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS,
listeners = {DbUnitTestExecutionListener.class})
@DatabaseSetup("equals-hashcode-sample-data.xml")
public class EqualsAndHashcodeTest {
@Autowired
TestEntityManager em;
@Autowired
XxxJpaRepository r;
}
데이터 셋업
DBUnit을 사용한다. XML로 테스트 데이터를 만들고 이를 테스트 클래스와 동일한 경로로 /test/resources 하위에 두면 됨.
https://github.com/springtestdbunit/spring-test-dbunit 사용
TODO
…