JPA를 사용하면서 고려해야 했던 점

Dierslair·2022년 7월 2일
13

jpa

목록 보기
2/4

SI 에서 항상 ~Batis 류의 쿼리 매퍼를 사용하다가 좋은 기회가 있어 JPA를 실무에서 계속 사용중입니다. JPA는 아무래도 ORM이다보니 쿼리 매퍼와 다른 점도 많고 조심해서 다루지 않으면 오류가 발생하는 개소가 많아 ~Batis 류의 쿼리매퍼의 경력이 많은 개발자는 꺼리지만 아무래도 쿼리 결과를 매핑하는 과정을 JPA가 대신 해 준다는 점이 큰 매력이라 개인적으로 JPA를 훨씬 더 선호합니다.

실무에서 JPA를 사용하며 고려해야 했던 점을 기록하려고 합니다.

Data Access - QueryDSL

JPA는 Java Persistence API 의 약어로 자바 진영의 ORM을 정의하기 위한 인터페이스 집합입니다. 가장 유명하고 많이 사용되는 구현체는 Hibernate ORM 이라 할 수 있습니다. 스프링 부트를 사용하면 spring-data-jpa 모듈에서 채택하고 있는 구현체라 접근하기도 쉽습니다.

JPA 에서는 쿼리를 작성하지 않고, 전용 API를 사용해서 결과적으로 JPQL을 생성하고, 이를 Dialect 에 맞는 네이티브 쿼리로 변환한 후, 실행하게 됩니다.

JPA 규약에 맞추어 쿼리를 작성하면 매우 복잡하기 때문에 SQL 과 유사하게 쿼리를 작성할 수 있는 QueryDSL 또는 jOOQ 과 같은 플러그인을 사용합니다. 개인적으로는 QueryDSL 이 사용하기 쉬워서 사용하고 있습니다.

QueryDSL 셋업

build.gradle.ktsquerydsl 관련 의존성을 추가해 주면 사용이 가능합니다.

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")
    ...
}
...

공통 엔티티, 공통 DTO 선언

엔티티의 공통 칼럼을 가지고 있는 공통 엔티티를 작성합니다.
특이사항으로는

  • 논리 삭제를 사용합니다. 연관이 복잡할 수록 물리 삭제시 FK 제약으로 오류가 발생할 확률이 높아지므로 Boolean 타입의 삭제 플래그를 선언하여 삭제되었는지 쿼리 및 getter에서 필터링할 수 있도록 합니다.
  • PKUUID 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 , hashCodePK 를 기준으로 오버라이딩합니다. 엔티티와 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()
    }
}

QueryDSL 사용을 위한 Repository 작성

리파지토리는 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 레이어 구성

공통 DTO 와 Specific DTO

엔티티는 Repository , Service 레이어 안에서만 생성되고 사용되어야 하며, 밖으로 벗어날 때에는 HibernateProxy 로 래핑된 엔티티가 아닌 온전한 POJO 로 변환되어야 합니다.

DTO 는 엔티티와 1:1 대응되는 공통 DTO와, 화면/API 별로 다른 결과를 보여주기 위한 Specific DTO 로 구분합니다.

공통 DTO 는 다음과 같은 규칙을 가집니다.

  • 엔티티 자신의 프로퍼티는 생성자에서 매핑합니다.
  • 연관 엔티티(FK 관계)는 외부에서 주입됩니다.

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 엔티티의 값만 필요한 경우 이를 다르게 처리할 수 있습니다.
  • DTO 생성자에서 getter 를 호출하여 연관 엔티티를 매핑하게 되면 아직 로딩되지 않은 연관 엔티티가 lazy loading 되며, N+1 문제 가 발생합니다. 쿼리에서 직접 명시하여 eager loading 된 연관 엔티티만 매핑하기 위해 로딩되었는지 판단하는 로직이 필요한데, 이는 DTO 에서 처리할 수 없습니다.

EntityMapper 인터페이스

엔티티를 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)
}

Querydsl 리파지토리

이렇게 구성하게 되면 다음과 같은 두 쿼리에 대해 같은 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) }
}

Specific DTO

다른 컨트롤러에서 다른 쿼리를 사용하여 가져온 공통 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) }
}

계층형 쿼리 사용시 주의점

부모 엔티티는 강제 fetch join

위와 같이 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}"
  }
}

select 시 논리 삭제 칼럼 제외

그 다음으로는 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 에 가깝게 코드를 작성할 수 있어 너무 좋습니다.

감사합니다.

profile
Java/Kotlin Backend Developer

0개의 댓글