SI 에서 항상 ~Batis
류의 쿼리 매퍼를 사용하다가 좋은 기회가 있어 JPA를 실무에서 계속 사용중입니다. JPA는 아무래도 ORM이다보니 쿼리 매퍼와 다른 점도 많고 조심해서 다루지 않으면 오류가 발생하는 개소가 많아 ~Batis
류의 쿼리매퍼의 경력이 많은 개발자는 꺼리지만 아무래도 쿼리 결과를 매핑하는 과정을 JPA가 대신 해 준다는 점이 큰 매력이라 개인적으로 JPA를 훨씬 더 선호합니다.
실무에서 JPA를 사용하며 고려해야 했던 점을 기록하려고 합니다.
JPA는 Java Persistence API
의 약어로 자바 진영의 ORM을 정의하기 위한 인터페이스 집합입니다. 가장 유명하고 많이 사용되는 구현체는 Hibernate ORM 이라 할 수 있습니다. 스프링 부트를 사용하면 spring-data-jpa
모듈에서 채택하고 있는 구현체라 접근하기도 쉽습니다.
JPA 에서는 쿼리를 작성하지 않고, 전용 API를 사용해서 결과적으로 JPQL을 생성하고, 이를 Dialect
에 맞는 네이티브 쿼리로 변환한 후, 실행하게 됩니다.
JPA 규약에 맞추어 쿼리를 작성하면 매우 복잡하기 때문에 SQL 과 유사하게 쿼리를 작성할 수 있는 QueryDSL 또는 jOOQ 과 같은 플러그인을 사용합니다. 개인적으로는 QueryDSL
이 사용하기 쉬워서 사용하고 있습니다.
build.gradle.kts
에 querydsl
관련 의존성을 추가해 주면 사용이 가능합니다.
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
val kotlin = "1.6.21"
id("org.springframework.boot") version "2.7.1"
id("io.spring.dependency-management") version "1.0.11.RELEASE"
kotlin("jvm") version kotlin
kotlin("kapt") version kotlin
...
}
group = "com.dierslair"
version = "1.0.0"
java.sourceCompatibility = JavaVersion.VERSION_1_8
repositories {
mavenCentral()
}
dependencies {
// kotlin dependencies
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
...
// spring framework dependencies
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// QueryDSL
api("com.querydsl:querydsl-jpa")
kapt(group = "com.querydsl", name = "querydsl-apt", classifier = "jpa")
...
}
...
엔티티의 공통 칼럼을 가지고 있는 공통 엔티티를 작성합니다.
특이사항으로는
Boolean
타입의 삭제 플래그를 선언하여 삭제되었는지 쿼리 및 getter
에서 필터링할 수 있도록 합니다.PK
는 UUID v1
타입을 사용합니다. auto_increment
를 사용한 bigint
타입의 PK와 다른 점은 다음과 같습니다.UUID v4
에 비해 시간순으로 증가하는 UUID v1
은 행 삽입시 인덱싱에 악영향을 끼치지 않습니다.auto_increment
를 사용한 bigint
타입의 PK 는 추측하기 쉬워 Public API 에서 무차별 공격에 취약합니다. UUID
는 사람이 추측하기 힘든 장점이 있습니다.auto_increment
를 사용한 bigint
타입의 PK는 해당 테이블에 대해서만 unique 한 특성이 있으나, UUID
는 global unique 한 특성이 있습니다.hibernate_sequence
테이블을 사용하여 증가하는 값을 채번하는 방식입니다. PK 채번을 위해 select ~ for update
구문을 사용하게 되며 DB 커넥션이 필요합니다. 반면 UUID
를 사용하게 되면 별도의 DB 커넥션이 필요없습니다.@Id
선언시 @GenericGenerator
를 사용하면 String
타입의 UUID
를 생성할 수 있지만 문자열 타입은 DB 성능에 악영향을 줍니다. v1
타입은 생성 시간순으로 증가하는 특성을 가지므로 insert
시 인덱싱에도 성능 저하가 없으며, String
이 아닌 binary(16)
을 사용하면 bigint
타입의 PK에 비해서 성능 저하가 미미합니다.(PK로써의 UUID).AbstractEntity
에서 equals
, hashCode
를 PK
를 기준으로 오버라이딩합니다. 엔티티와 DTO에서는 data class
를 사용하지 않습니다. 이는 엔티티를 다룰 때 더욱 OOP에 부합하도록 사용할 수 있게 합니다.@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class AbstractEntity {
@Id
@Column(length = 16)
var id: UUID? = null
protected set
@Column(nullable = false)
@ColumnDefault(DatabaseIndependent.FALSE)
var deleted: Boolean? = null // 논리 삭제 플래그
protected set(value) {
// 삭제 복구는 지원하지 않습니다
if (field == true && value == false) {
throw UnsupportedOperationException("Recovery operation is not supported")
}
field = value
}
@Column(nullable = false)
@ColumnDefault(DatabaseIndependent.NOW)
@CreatedDate
var createdDate: LocalDateTime? = null
protected set
@Column(nullable = false)
@ColumnDefault(DatabaseIndependent.NOW)
@LastModifiedDate
var updatedDate: LocalDateTime? = null
protected set
var deletedDate: LocalDateTime? = null
protected set
val isDeleted: Boolean
get() = this.deleted ?: false
// 삭제 메서드. 삭제 일시를 기록합니다.
fun delete() {
this.deleted = true
this.deletedDate = LocalDateTime.now()
}
// JPA Hook 은 공통에서 선언하며
// 하위 엔티티에서 필요한 경우 doPre~, doPost~ 를 오버라이딩할 수 있도록 합니다.
@PrePersist
private fun prePersist() {
this.id = Generators.timeBasedGenerator().generate()
doPrePersist()
}
@PostPersist
private fun postPersist() {
doPostPersist()
}
@PreUpdate
private fun preUpdate() {
doPreUpdate()
}
@PostUpdate
private fun postUpdate() {
doPostUpdate()
}
@PreRemove
private fun preRemove() {
throw UnsupportedOperationException()
}
@PostRemove
private fun postRemove() {
throw UnsupportedOperationException()
}
@PostLoad
private fun postLoad() {
doPostLoad()
}
protected fun doPrePersist() {
}
protected fun doPostPersist() {
}
protected fun doPreUpdate() {
}
protected fun doPostUpdate() {
}
protected fun doPostLoad() {
}
// PK 를 기준으로 동일성 비교를 할 수 있도록 equals & hashCode 를 오버라이딩합니다.
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
}
if (this.javaClass != other?.javaClass) {
return false
}
if (other !is AbstractEntity) {
return false
}
if (this.id != other.id) {
return false
}
return true
}
override fun hashCode(): Int {
return this.id?.hashCode() ?: 0
}
// toString() 호출시 getter에 의한 lazy loading 무효화를 위해 간소화합니다.
override fun toString(): String =
"Entity(id=${this.id})"
// 논리 삭제 guard 메서드입니다.
fun <T : AbstractEntity> T.filterNotDeleted(): T? {
return this.takeIf { it.isDeleted.not() }
}
fun <T : AbstractEntity> Set<T>.filterNotDeleted(): MutableSet<T> {
return filter { it.isDeleted.not() }.toMutableSet()
}
}
리파지토리는 spring-data-jpa
에서 메서드명으로 구현하도록 하는 일반 repository 와 QueryDSL
을 사용하여 쿼리를 직접 작성하는 repository로 구분하였습니다.
// spring-data-jpa 에 의해 구현되는 인터페이스
@RepositoryDefinition(domainClass = User::class, idClass = UUID::class)
interface UserRepository : UserQuerydslRepository {
fun save(entity: User): User
}
// Querydsl 사용하여 직접 쿼리를 작성하는 리파지토리
interface UserQuerydslRepository {..}
@Repository
class UserQuerydslRepositoryImpl : UserQuerydslRepository {..}
엔티티는 Repository
, Service
레이어 안에서만 생성되고 사용되어야 하며, 밖으로 벗어날 때에는 HibernateProxy
로 래핑된 엔티티가 아닌 온전한 POJO
로 변환되어야 합니다.
DTO 는 엔티티와 1:1
대응되는 공통 DTO와, 화면/API 별로 다른 결과를 보여주기 위한 Specific DTO 로 구분합니다.
공통 DTO 는 다음과 같은 규칙을 가집니다.
User
엔티티의 공통 DTO UserDto
를 구성하기에 앞서 AbstractDto
를 작성합니다. 역할은 AbstractEntity
와 유사합니다.
// AbstractDto
abstract class AbstractDto(
entity: AbstractEntity,
) : Serializable {
val id = entity.id
@get:JsonIgnore
val createdDate = entity.createdDate
@get:JsonIgnore
val updatedDate = entity.updatedDate
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
}
if (this.javaClass != other?.javaClass) {
return false
}
other as AbstractDto
if (this.id != other.id) {
return false
}
return true
}
override fun hashCode(): Int =
this.id?.hashCode() ?: 0
}
UserDto
는 다음과 같습니다.
class UserDto(
entity: User,
) : AbstractDto(user) {
// own properties
val username = entity.username
val password = entity.password
val fullName = entity.fullName
val nickName = entity.nickName
// foreign properties
var profileImage: ImageDto? = null
var roles: List<RoleDto> = emptyList()
}
스스로의 프로퍼티는 생성자에서 매핑 처리되고, 연관 엔티티는 스스로 처리할 수 없습니다. 연관 엔티티는 다른 레이어에서 처리되어야 합니다.
이렇게 하는 이유는
User
를 select 하는 쿼리라도 관리자가 사용자를 select 하는 쿼리와 마이페이지에서 select 하는 쿼리는 다릅니다.User
엔티티의 값만 필요한 경우 이를 다르게 처리할 수 있습니다.getter
를 호출하여 연관 엔티티를 매핑하게 되면 아직 로딩되지 않은 연관 엔티티가 lazy loading
되며, N+1 문제
가 발생합니다. 쿼리에서 직접 명시하여 eager loading
된 연관 엔티티만 매핑하기 위해 로딩되었는지 판단하는 로직이 필요한데, 이는 DTO 에서 처리할 수 없습니다.엔티티를 DTO 로 변환하는 책임은 EntityMapper
라는 인터페이스 및 구현체를 정의해서 수행하도록 합니다.
// 엔티티를 공통 DTO 로 변환하는 책임을 가진 인터페이스입니다.
interface EntityMapper {
fun map(entity: User): UserDto
fun map(entity: Image): ImageDto
fun map(entity: Role): RoleDto
}
@Component
class EntityMapperImp(
private val persistenceUnitUtil: PersistenceUnitUtil,
) : EntityMapper {
override fun map(entity: User): UserDto =
UserDto(entity) // 자신의 프로퍼티는 생성자에서 매핑됩니다.
.also { dto ->
// 여기서는 연관 엔티티에 대해 `fetch join` 된 것만 매핑하여 주입합니다.
entity.getIfLoaded(entity::profileImage)
?.let { dto.profileImage = map(it) } // 프로필 사진이 fetch join 된 경우에만
entity.getIfLoaded(entity::roles)
?.let { roles ->
dto.roles = roles.map { map(it) }
} // 권한 목록이 fetch join 된 경우에만
}
..
// 로딩된 프로퍼티만 가져오기 위한 확장 메서드
private fun <T : AbstractEntity, U> T.getIfLoaded(
property: KProperty0<U?>,
): U? {
val loaded = isLoaded(this, property.name)
return if (loaded) {
property.get() // getter invoke
} else {
null
}
}
// 주어진 엔티티의 프로퍼티가 로딩되었는지 판단
private fun isLoaded(
entity: Any,
propertyName: String,
): Boolean =
this.persistenceUnitUtil.isLoaded(entity, propertyName)
}
이렇게 구성하게 되면 다음과 같은 두 쿼리에 대해 같은 DTO 로 다른 결과를 얻을 수 있습니다.
interface UserQuerydslRepository {
// 사용자가 마이페이지에서 단건 조회
fun findOne(key: UserKey.MyPage): User?
// 관리자가 관리 페이지에서 단건 조회
fun findOne(key: UserKey.AdminPage): User?
}
@Repository
class UserQuerydslRepositoryImpl(
private val jpaQueryFactory: JPAQueryFactory,
) : UserQuerydslRepoisitory {
override fun findOne(key: UserKey.MyPage): User? {
val user = QUser("user")
val userId = key.id
return this.jpaQueryFactory
.selectFrom(user)
.leftJoin(user.profileImage).fetchJoin() // 프로필 사진만 필요
.where(
user.id.eq(userId),
user.deleted.isFalse, // 논리 삭제 guard
)
.fetchOne()
}
override fun findOne(key: UserKey.AdminPage): User? {
val user = QUser("user")
val userId = key.id
return this.jpaQueryFactory
.selectFrom(user)
.leftJoin(user.profileImage).fetchJoin() // 프로필 사진 필요
.leftJoin(user.roles).fetchJoin() // 권한 필요
.leftJoin()
.where(
user.id.eq(userId),
user.deleted.isFalse, // 논리 삭제 guard
)
.fetchOne()
}
}
UserKey.MyPage
식별자로 사용자 하나를 가져오는 경우, 프로필 사진만 조인해서 가져오고, UserKey.AdminPage
식별자로 사용자를 가져오는 경우 프로필 사진 및 권한을 조인해서 가져올 수 있습니다.
서비스 레이어는 비교적 단순해집니다. 다른 쿼리를 사용하기 위해서 오버로딩을 사용하므로 대부분의 메서드가 비슷한 형태를 가지게 됩니다. 주의할 사항으로는 EntityMapper
를 사용해서 DTO 로 변환한다는 점입니다.
// 마이페이지에서 사용하는 사용자 서비스
interface UserMyPageService {
fun findById(id: UUID): UserDto?
}
@Service
class UserMyPageServiceImpl(
private val entityMapper: EntityMapper,
private val userRepository: UserRepository,
) : UserMyPageService {
override fun findById(id: UUID): UserDto? =
UserKey.MyPage(id)
.let { this.userRepository.findOne(it) }
?.let { this.entityMapper.map(it) }
}
// 관리자페이지에서 사용하는 사용자 서비스
interface UserAdminPageService {
fun findById(id: UUID): UserDto?
}
@Service
class UserAdminPageServiceImpl(
private val entityMapper: EntityMapper,
private val userRepository: UserRepository,
) : UserMyPageService {
override fun findById(id: UUID): UserDto? =
UserKey.AdminPage(id)
.let { this.userRepository.findOne(it) }
?.let { this.entityMapper.map(it) }
}
다른 컨트롤러에서 다른 쿼리를 사용하여 가져온 공통 DTO 를 다르게 표현하기 위해서 Specific DTO 를 사용하게 됩니다.
특별한 처리는 아니고, 델리게이트 패턴을 사용한 래퍼 DTO 를 위와 같이 지칭하도록 합니다.
예를 들어, 사용자를 조회하는 API 에서 이메일 등과 같은 민감 정보를 마스킹 처리하여 보여주고 싶을 때에는 공통 DTO 인 UserDto
를 래핑한 후 email
프로퍼티의 getter
를 오버라이딩하여 제공하는 방식입니다.
class MyPageUserDto(
private val delegate: UserDto, // 공통 DTO 를 제공받습니다.
) {
// 노출되어도 무관한 프로퍼티들
val username = entity.username
// 민감 프로퍼티들
val email: String
get() {
val original = this.delegate.email
return mask(original, 4) // 앞 네자리 외에는 '*'로 마스킹
}
}
// 마이페이지 API
@RestController
@RequestMapping("/api/mypage")
class MyPageApi(
private val userService: UserService,
) {
@GetMapping(
path = ["/{id}"],
produces = [MediaType.APPLICATION_JSON_VALUE],
)
fun findUser(
@PathVariable
id: UUID,
): Map<String, Any?> =
this.userService.findById(id)
?.let { MyPageUserDto(it) } // Specific DTO 로 래핑
.let { mapOf("user" to it) }
}
위와 같이 EntityMapper
에서 엔티티 변환을 잘 사용하고 있다가 문제가 발생합니다. QueryDSL
이 업데이트됨에 따라 부모-자식 관계의 엔티티를 조회할 때, 자식 엔티티를 fetch join
하는 경우 자식 엔티티 측에는 별도 페치 조인을 명시하지 않더라도 부모 엔티티를 포함해서 주기 시작했는데(release note 에서 보이지 않았음 ㅠㅠ) 이런 경우 부모와 자식을 상호 참조하는 상황이기 때문에 Stack Overflow 가 필연적으로 발생합니다.
계층형 엔티티 Category
를 예를 들면..
// Category 엔티티
@Entity
@Table
@DynamicInsert
@DynamicUpdate
class Category : AbstractEntity() {
@Column(length = 16)
var parentId: UUID? = null // 부모가 있는 경우 식별자
@Column(length = 64, nullable = false)
var name: String? = null // 카테고리명
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(
name = "parentId",
foreignKey = ForeignKey(name = "fk_category_parent"),
insertable = false,
updatable = false,
)
var parent: Category? = null // 부모 카테고리 연관
protected set
@OneToMany(mappedBy = "parent")
var children: MutableSet<Category> = mutableSetOf() // 자식 연관
protected set
}
카테고리 엔티티는 위와 같습니다.
// Category 공통 DTO
class CategoryDto(
entity: Category,
): AbstractDto(entity) {
// own properties
val parentId = entity.parentId
val name = entity.name
// foreign properties
var parent: CategoryDto? = null
var children: List<CategoryDto> = emptyList()
}
공통 DTO는 위와 같습니다.
// EntityMapper
class EntityMapperImpl : EntityMapper {
..
override fun map(entity: Category): CategoryDto =
CategoryDto(entity)
.also { dto ->
// 부모 카테고리가 로딩된 경우
entity.getIdLoaded(entity::parent) // #1
?.let { dto.parent = map(it) /* #3 */ }
// 자식 카테고리가 로딩된 경우
entity.getIfLoaded(entity::children)
?.let { children ->
dto.children = children.map { map(it) /* #2 */ }
}
}
}
엔티티 매핑 로직은 위와 같습니다.
문제는 엔티티 매핑 과정에서
1. 최상위 카테고리 매핑을 시도합니다.
2. 최상위 카테고리이므로 부모가 없습니다. 부모 스킵 (#1)
3. 최상위 카테고리이고, 자식이 있으니 자식을 매핑합니다. (#2)
4. 자식 엔티티 매핑을 위해 다시 map 메서드로 진입합니다(재귀)
5. 자식 카테고리이니, 부모 엔티티가 있습니다. (#1)
6. 부모 엔티티 매핑을 위해 다시 map 메서드로 진입합니다. (#3)
7. 부모 엔티티가 1번의 최상위 카테고리와 같은 엔티티입니다. 따라서 1번부터 다시 반복됩니다.
따라서 Stack 이 넘칠 때 까지 1 ~ 7 이 반복됩니다.
해결하기 위해서 MappingContext
라는 매핑 컨텍스트를 만들어 상호 참조를 감지하는 로직을 짜볼까 생각도 해 보았지만 너무 복잡한 로직이 만들어져 유지보수가 불가능에 가깝다는 결론으로 사용하지 않고 있습니다.
실제로 계층 쿼리를 사용할 때 부모와 자식이 모두 필요한 경우가 거의 없기 때문에 map 메서드를 수정하기로 했습니다.
기존 map
메서드에 두 파라미터를 추가합니다.
fun map(
entity: Category,
parent: Boolean = false, // 부모 방향으로만 recursive 매핑을 실시합니다.
children: Boolean = false, // 자식 방향으로만 recursive 매핑을 실시합니다.
): CategoryDto
두 파라미터를 사용하여, 부모 방향 또는 자식 방향으로만 매핑이 전파되도록 합니다.
fun map(
entity: Category,
parent: Boolean, // 부모 방향으로만 recursive 매핑을 실시합니다.
children: Boolean, // 자식 방향으로만 recursive 매핑을 실시합니다. (기본)
): CategoryDto =
CategoryDto(entity)
.also { dto ->
if (parent) {
entity.getIfLoaded(entity::parent)
?.let { dto.parent = map(it, parent = true) } // 방향 유지
} else if (children) {
entity.getIfLoaded(entity::children)
?.let { children ->
dto.children = children.map { map(it, children = true) } // 방향 유지
}
}
}
연관관계가 복잡할 수록 물리 삭제시 FK 제약으로 오류가 발생할 확률이 커지며, cascading 을 사용할 경우 연관관계를 잘못 생각하면 삭제되어선 안되는 데이터가 삭제되는 불상사가 발생할 수 있어 논리 삭제를 사용하는 게 안전합니다.
이 때 Unique 제약이 있는 엔티티는 삭제시 Unique 키를 고려하여 삭제할 필요가 있습니다. 예를 들어 사용자 엔티티는 사용자 아이디가 유일키여야 합니다. 논리 삭제를 사용해서 사용자를 삭제하는 경우 사용자 아이디에 별도 처리를 하지 않으면 그 사용자의 아이디는 영원히 재사용할 수 없게 됩니다. 따라서 유일키는 논리 삭제 시 삭제 메서드를 오버라이딩해서 유일키 제약을 회피할 필요가 있습니다.
@Entity
@Table(
uniqueConstraints = [
UniqueConstraint(name = "uk_user_username", columnNames = ["username"]),
]
)
@DynamicInsert
@DynamicUpdate
class User : AbstractEntity() {
@Column(length = 512, nullable = false)
var username: String? = null // 사용자 아이디
override fun delete() {
super.delete()
// 되도록이면 충돌하지 않도록 UUID 나 System.currentTimeMillis 등을 사용합니다.
this.username = "[${UUID.randomUUID()}]${this.username}"
}
}
그 다음으로는 QueryDSL 사용시 where 절에 반드시 deleted
칼럼을 확인하는 조건을 추가해야 한다는 점인데, 조인을 사용하는 경우 연관된 엔티티까지 deleted
칼럼을 확인하는경우 select쿼리의 주인까지 조회되지 않는 경우가 있기 때문에 주의해야 합니다.
// 단순 사용자 목록을 조회
override fun findAll(): List<User> {
val user = QUser("user")
val image = QImage("image")
return this.jpaQueryFactory
.selectFrom(user)
.leftJoin(user.profileImage, image).fetchJoin() // 프로필 사진을 조인
.where(
user.deleted.isFalse,
image.deleted.isFalse, // image 가 삭제된 상태면 user 도 조회되지 않음
)
.fetch()
}
이를 방지하기 위해 쿼리에는 주인의 삭제 여부만 확인하고, 연관 엔티티의 삭제 여부는 getter 로 처리하는 것이 자연스럽습니다.
@Entity
@Table(..)
@DynamicInsert
@DynamicUpdate
class User : AbstractEntity() {
...
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(..)
var profileImage: Image? = null
get() field?.filterNotDeleted() // AbstractEntity 에서 정의된 확장 메서드 사용
}
다만 연관 엔티티가 논리 삭제되더라도 profileImageId
칼럼은 여전히 남아 있기 때문에 FK 가 있더라도 연관 엔티티는 null
이 될 수 있음에 주의해야 합니다.
val user = this.userRepository.findById(id)
?: throw UserNotFoundException()
if (user.profileImageId != null) { // FK 가 있으니 연관 엔티티가 있겠지..?
val profileImage = user.profileImage!! // NPE
}
JPA 를 사용하면서 고려해야 했던 점을 작성했습니다.
일부는 더 나은 방법이 있을 수도 있겠네요. JPA 는 러닝 커브는 확실히 높지만 Reuslt Mapping 을 직접 할 필요가 없고 좀 더 OOP 에 가깝게 코드를 작성할 수 있어 너무 좋습니다.
감사합니다.