본문 바로가기

3.구현/Java or Kotlin

[kotlin] JPA 3: Spring Boot에서 Entity 매핑 확장

들어가기

이번에는 엔티티 정의위한 확장된 어노테이션에 대해서 다룰려고 한다. 더 다양한 타입과 알아두면 좀더 도움이 되는 어노테이션에 대해서 다룰 예정이다.

작성자: http://ospace.tistory.com/ (ospace114@empal.com)

엔티티 정의

엔티티 정의를 위한 추가적인 어노테이션이다.

  • @IdClass, @EmbeddedId: 복합키 정의
  • @Enumerated: enum 타입 매핑
  • @Temporal: 날짜 타입 매핑
  • @Transient: 매핑 무시
  • @Lob: BLOB CLOB 타입 매핑
  • @CreationTimestamp: insert 시간 자동 저장
  • @UpdateTimestamp: update 시간 자동 저장
  • @CreateDate: 엔티티 생성시간
  • @LastModifiedDate: 엔티티 변경시간
  • @CreatedBy: 엔티티 생성 사용자
  • @LastModifiedBy: 엔티티 수정 사용자
  • @Access: 엔티티 필드 접근 방식

@IdClass와 @EmbeddedId 어노테이션

두 어노테이션은 복합키를 정의하는데 사용한다. 이전에는 속성 하나로 기본키를 사용했지만, 복합키는 두개 이상의 속성으로 기본키로 사용된다. 먼저 @IdClass를 살펴보자.

@IdClass로 복합키로 사용할 클래스를 지정한다. 이 클래스에 복합키로 사용할 속성이 포함되어 있다. 그리고 해당 클래스는 Serializable 인터페이스를 구현해야 하고 equals()와 hashCode()을 구현해야 한다.

data class HobbyId(
    var memberId: Long? = null,
    var hobbyId: Long? = null,
) : Serializable

Serializable가 상속하고 자동으로 equals()와 hashCode()가 구현되는 데이터 클래스로 정의했다. 추가로 기본 생성자가 있어야 한다. 복합키를 Member 엔티티에 적용해보자.

@Entity
@IdClass(HobbyId::class)
open class Hobby(
  @Id
  private var id: Long? = null,
  @Id
  private var memberId: Long? = null,
  private var name: String = "",
) {
  //...
}

@IdClass을 사용해 엔티티 상단에 복합키를 정의하고 있다. Repository을 정의할 때 사용할 키 타입으로 복합키를 사용한다.

interface HobbyRepository : JpaRepository<Hobby, HobbyId>

리포지토리에서 ID 타입파라미터가 HobbyId 클래스를 사용해서 정의하고 있다. 이를 가지고 조회하는 예를 보자.

val res = hobbyRepository.findById(HobbyId(1,0))

findById()로 조회할 때에 복합키 형태로 객체를 넘겨준다.

다음으로 @EmbeddedId를 살펴보자. 이때 복합키용 클래스를 정의할 때 추가 작업이 필요하다. @EmbeddedId에서 사용하기 위해서 복합키로 사용할 클래스에 @Embeddable을 선언해야 한다.

@Embeddable
data class HobbyId(
  val id: Long? = null,
  val memberId: Long? = null
) : Serializable

@IdClass는 클래스 위에 정의했지만 @EmbeddedId는 내부 속성으로 추가된다. 나머지는 앞의 복합키 정의할 때와 동일하다.

@Entity
open class Hobby(
  @EmbeddedId
  private var id: HobbyId? = null,
  private var name: String = "",
) {
  //...
}

Repository을 정의하는 부분도 이전과 동일하다. 엔티티를 추가할 때에는 id 속성은 HobbyId 객체를 생성해서 설정해줘야한다. 조회할 때에는 이전과 동일하게 식별자가 HobbyId 이므로 객체를 생성하고 필요한 식별자 값을 설정해서 조회한다. @IdClass와는 다른 점은 엔티티 내에 직접 복합키 클래스를 내장해서 정의하고 있다는 점이다. 이는 좀더 직관적으로 인지할 수 있다는 장점이 있다.

@IdClass와 @EmbeddedId를 사용해서 식별관계와 비식별관계를 매핑하는데 사용한다. 다른 엔티티에서 ToOne 매핑관계와 @JoinColumns을 사용해 복합키와 매핑 설정을 통해 관계를 지정할 수 있다. 지정하는 방법은 간단한다. 부모 엔티티가 Member이고 자식 엔티티가 Hobby라고 하고 @IdClass인 경우를 보자.

// 식별관계 예
@Entity
@IdClass(HobbyId::class)
open class Hobby(
  @Id
  private var id: Long? = null,
  @Id
  @ManyToOne
  @JoinColumn(name = "member_id")
  private var member: Member? = null,
  //...
) {
  //...
}

// 비식별관계 예
@Entity
open class Hobby(
  @Id
  private var id: Long? = null,
  @ManyToOne
  @JoinColumn(name = "member_id")
  private var member: Member? = null,
  //...
) {
  //...
}

식별관계와 비식별관계 차이는 자식 엔티티에서 부모 엔티티와 연관관계에 대해 @Id로 지정해주는데 있다. 지정하면 식별관계, 없으면 비식별관계가 된다. 또한 자식 엔티티에는 자신 식별자와 부모 식별자로 복합키를 사용하고 있다.

@Enumerated 어노테이션

열거형인 enum 타입 매핑하기 위한 어노테이션이다. 설정할 수 있는 값은 EnumType 열거형으로 ORDINAL과 STRING을 지정할 수 있다. 기본은 ORDINAL이다. 즉, 열거형인 필드는 자동으로 @Enumerated을 사용하며 ORDINAL로 설정된다. 아래와 같이 MemberType라는 열거형이 있다.

enum class MemberType {
  ADMIN, GENERAL, GUEST,
}

이를 type이라는 필드에 설정해보자.

@Enumerated
private var type: MemberType = MemberType.GUEST

DDL에서 type필드는 다음과 같게 된다. 가장 기본적인 매핑 형태이다.

type tinyint check (type between 0 and 2)

EnumType.STRING으로 매핑정보를 설정해보자.

@Enumerated(EnumType.STRING)
private var type: MemberType = MemberType.GUEST

DDL에서 type필드는 다음처럼 정의된다.

type varchar(255) check (type in ('ADMIN','GENERAL','GUEST')),

만약 EnumType.ORDINAL로 이미 사용할 경우 열거형 추가는 가능하지만 변경은 불가하다. 물론 변경은 가능하지만 문제가 발생할 가능성이 높다. STRING인 경우는 변경에도 안전하다. 단지 데이터 크기가 늘어날 뿐이다.

@Temporal 어노테이션

java.util.Date, java.util.Calendar, 그리고 java.time.LocalDateTime인 날짜 타입을 매핑하는 어노테이션이다. 열거형인 TemporalType으로 값을 지정할 수 있다. 지정할 수 있는 값은 DATE, TIME, TIMESTAMP가 있다. 기본값은 없기에 반드시 값을 설정해줘야 한다. H2기준으로 DATE는 date 타입으로, TIME, time 타입으로 TIMESTAMP는 timestamp 타입으로 매핑된다. MySQL 기준으로는 datetime으로 매핑된다.

java.util.Date와 java.util.Calendar은 모두 가능하지만, java.time.LocalDateTime을 사용할 경우 TIMESTAMP만 가능하다.

@Temporal(TemporalType.TIMESTAMP)
private var signedAt: LocalDateTime = LocalDateTime.now()

@Transient 어노테이션

특정 객체 필드를 데이터베이스 매핑하지 않도록 하는 어노테이션이다. 즉, 내부적으로만 사용 객체 필드이다. DDL에도 포함되지 않는다. 추가로 설정하는 값은 없다.

@Transient
private val cache: Int = 0

@Lob 어노테이션

객체 필드를 BLOB CLOB 타입으로 매핑하는 어노테이션이다. 이 어노테이션도 추가 설정할 값은 없다. 필트 타입이 문자형이면 CLOB이고 나머지는 BLOB형태로 매핑된다.

@Lob
private val extra: String? = null

@Lob
private val extra: Array<Byte>? = null

@CreationTimestamp 어노테이션

insert 쿼리 실행할때 시간이 자동 저장된다. 즉, 데이터베이스에 데이터 추가시간으로 갱신된다. 열거형 SourceType으로 DB 또는 VM으로 설정한다. 기본은 VM으로 Java VM에서 값 설정할 때에 시간을 가지고 설정한다. DB는 데이터베이스에 추가할때 데이터베이스 시간을 사용한다.

@CreationTimestamp(source = SourceType.DB)
private var createdAt: LocalDateTime = LocalDateTime.now()

SourceType.DB는 데이터 추가시 데이터베이스 시간 정보가 추가해서 실행된다. 주의할 부분은 SourceType.VM인 경우 Java VM에서 시간정보가 갱신되기 때문에 update 쿼리 실행에서도 createdAt이 변경될 수 있다. 그래서 변경시에는 반영 안되도록 @Column을 이용해서 updatable을 false로 설정한다.

@CreationTimestamp
@Column(updatable = false)
private var createdAt: LocalDateTime? = null

그러면 update 쿼리 실행에서 createdAt이 업데이트에서 제외된다.

@UpdateTimestamp 어노테이션

update 쿼리 실행할때 시간이 자동 저장된다. 즉, 데이터베이스에 데이터 수정시간으로 갱신된다. 열거형 SourceType으로 설정하며 값들도 @CreationTimestamp에 내용과 동일하다.

@UpdateTimestamp(source = SourceType.DB)
private var updatedAt: LocalDateTime = LocalDateTime.now()

getter와 setter은 반드시 별도 정의해야 한다. setter가 없다면 제대로 값이 저장되지 않는다. SourceType.VM은 데이터 수정 후에 별도로 update 쿼리가 실행된다.

@CreateDate 어노테이션

엔티티 생성시간이 자동 저장된다. 추가 설정하는 값은 없다. 이 어노테이션이 Spring의 Auditing으로 이를 활성화하기 위해서 아래 “Spring Auditing 활성화”를 참고하시기 바란다. @CreateDate을 사용하기 위해 몇가지 작업이 필요하다.

@CreatedDate
@Column(updatable = false)
private var createdDate: LocalDateTime? = null

@CreateDate에 의해서 Java VM의 시간이 설정된다. @Column에서 updatable이 false을 설정함으로써 엔티티 수정할 때에 같이 변경되지 않도록 해야 한다. insert 쿼리에서 해당 값이 같이 저장된다.

@LastModifiedDate 어노테이션

엔티티 변경시간이 자동 저장된다. 추가 설정하는 값은 없다. @CreateDate 처럼 Spring Auditing을 활성화해야 한다. 별다른 추가 작업은 없다.

@LastModifiedDate
private var modifiedDate: LocalDateTime? = null

@LastModifiedDate도 Java VM 시간이 설정된다.

@CreatedBy 어노테이션

엔티티가 생성될 경우 자동으로 생성한 사용자 정보를 저장한다. 수정에 의해 변경될 수 있으므로 @Column(updatable=false)와 같이 사용한다. 이를 사용하기 위해서는 Spring Auditing 활성화가 필요하다.

@CreatedBy
private var creator: Long? = null

@LastmodifiedBy 어노테이션

엔티티가 수정될 경우 자동으로 수정한 사용자 정보를 저장한다. 이를 사용하기 위해서는 Spring Auditing 활성화가 필요하다.

@CreatedBy
private var modifier: Long? = null

@Access 어노테이션

JPA가 엔티티의 매핑정보 엑세스 방식을 설정한다. JPA가 엔티티의 매핑 정보을 어디서 가져올지를 정한다. 열거형인 AccessType으로 설정할 수 있고 설정 가능한 값으로 FIELD와 PROPERTY가 있다.

  • FIELD: 필드로 접근하는 방식으로 필드에 있는 매핑 정보를 접근
  • PROPERTY: 속성으로 접근하는 방식으로 getter 접근자에 매핑 정보를 접근

기본 값은 없기 때문에 한가지 방식을 설정해야 한다. 만약 @Access를 사용하지 않는다 @Id가 설정된 위치에 따라서 결정된다. 즉 필드에 @Id가 되어 있다면 앞으로 모든 액세스 방식이 FIELD가 되고 @Id가 속성인 getter에 있다면 AccessType.PROPERTY가 된다.

AccessType.FIELD로 적용하면 어노테이션이 필드에 위치한다.

@Entity
@Access(AccessType.FIELD)
open class Member {
  @Id
  @GeneratedValue
  private var id:Long? = null
//...
}

AccessType.PROPERTY를 적용하면 어노테이션 위치가 getter에 있다.

@Entity
@Access(AccessType.FIELD)
open class Member {
  @Id
  @GeneratedValue
  private var id:Long? = null
  //...

  @Id
  @GeneratedValue
  open fun getId(): Long? = id
  //...
}

그렇기 때문에 다른 위치에 있다면 제대로 매핑이 되지 않는다. 그렇기에 하나로 통일해서 정의해야한다. 아니면 FIELD나 PROPERTY를 미리 지정해서 사용한다. 만약 객체 필드별로 다르게 지정하고 싶다면 개별 필드 또는 getter의 어노테이션에서 직접 @Access를 지정하면 된다.

@Entity
@Access(AccessType.FIELD)
open class Member {
  @Id
  @GeneratedValue
  private var id:Long?,

  private val extra: String?,
  //...

  @Lob
  @Access(AccessType.PROPERTY)
  open fun getExtra(): String? = extra
  //...
}

AccesType.FIELD에 의해서 id는 제대로 필드에 적용하고 있다. extra인 경우는 @Acces에 의해서 getExtra()라는 getter 접근자에 적용하고 있다.

Spring Auditing 활성화

Spring의 Auditing을 사용하기 위해서 추가적인 환경구성이 필요하다. 먼저, @EnableJpaAuditing을 @SpringBootApplication 선언에 추가해야 한다.

@SpringBootApplication
@EnableJpaAuditing
open class ProjectApplication
//...

다음으로 엔티티에 해당 기능이 활성화하기 위해서 @EntityListeners을 추가해야 한다.

@Entity
@EntityListeners(AuditingEntityListener::class)
open class Member {
//...
}

추가로 Auditing에서 사용자 정보를 얻기 위해서는 AuditorAware를 구현해야 한다.

@Component
class UserAuditorAware(
  private val memberCtrl: MemberController
) : AuditorAware<Long> {
    override fun getCurrentAuditor(): Optional<Long> {
        val obj = SecurityContextHolder.getContext().getAuthentication().getPrincipal()
        return Optional.ofNullable(if (obj is Member) obj.getId() else null)
    }
}

사용자 정보 타입이 Long으로 사용되고 있다. 물론 Member 엔티티를 사용해서 표현할 수 도 있다. 혹은 스프링 시큐리에 User 객체를 사용할 수도 있다. 이는 현재 사용 목적에 맞게 반영하면 된다. 앞에서 CreatedBy와 LastModifiedBy에서 Long을 사용하고 있기에 Long형으로 리턴하고 있다.

이후 부터는 Member 엔티티에서 @CreatedDate와 @CreatedBy 그리고 @LastModifiedDate 와 @LastModifiedBy가 활성화된다.

마무리

엔티티를 정의하는 다양한 어노테이션을 살펴보았다. 기본적으로 매핑이 되지만, 좀더 세부적으로 매핑 설정할려고 할 때 사용한다. 그리고 JPA가 자동으로 데이터베이스 매핑해준다고 하지만, 실제 세부적인 부분에서 설정을 하고 매핑할 경우 원하는데로 잘 되지 않을 경우가 있다. @CreateDate나 @CreationTimestamp인 경우 업데이트할 때에도 같이 변경될 수 있기 때문에 확인이 필요하다. 이런 부분에서 주의가 필요하다.

부족한 글이지만 여러분에게 참고가 되었으면 하네요. 모두 즐거운 코딩생활되세요. ospace.

참고

[1] 최범균, JPA 프로그래밍 입문, 가메출판사, 2017

[2] 김영한, 자바 ORM 표준 JPA 프로그래밍, 에이콘, 2015

[3] Spring Data - Auditing, https://docs.spring.io/spring-data/jpa/reference/auditing.html

[4] Package org.hibernate.annotations, https://docs.jboss.org/hibernate/orm/5.5/javadocs/org/hibernate/annotations/package-summary.html

반응형