Dev Log
수정·

[SNS 개발] 영속성 엔티티와 도메인 엔티티 분리

by Dohoon Kim · 24년 10월 19일 12:43:41

영속성 엔티티와 도메인 엔티티 분리

이 글은 잘못된 지식에서 기반하여 잘못된 결론을 도출했던 글이다. 결론만 짧게 적어두고, 아래 글을 읽을 때 잘못된 점을 기억하고 남긴다.

우선 문제의 원인은 Spring Data JPA의 동작을 잘못 이해하고 있었다. JPARepository 구현체에서 save를 호출하면 신규 엔티티를 다음과 같이 판단한다.(Persistable 구현체의 경우 제외)

  • id 타입이 프리미티브 타입이라면 0일 경우에 신규 엔티티로 판단하여 em.persist()를 호출한다.
  • id 타입이 프리미티브 타입이 아니라면, null 일 경우 신규 엔티티로 판단하여 em.merge()를 호출한다.

그리고 나는 아래와 같은 BaseEntity를 정의하여 사용하고 있었다.

abstract  UUIDV7IdEntity<UUID>(id: UUID? = null): Persistable<UUID> {
   ...
   private val id: UUID = id ?: UUIDV7Generator.generate()
   private val _isNew = true
   
   override fun getId(): UUID {
       return id
   }

   override fun isNew(): Boolean {
       return _isNew
   }
   
   private fun getIdentifier(obj: Any): Serializable {
        return if(obj is HibernateProxy) {
            obj.hibernateLazyInitializer.identifier as Serializable
        } else {
            (obj as UUIDPkEntity).id
        }
    }
    
    @PostPersist
    @PostLoad
    protected fun markNotNew() {
        _isNew = false
    }
}

@Id 어노테이션이 달린 필드에 @GeneratedValue 어노테이션을 이용하여 자동 아이디 할당을 사용하지 않고, 직접 관리할 경우 Persistable 인터페이스를 구현하여 이를 JPA에서 이용할 수 있게 해야하는 것은 맞다. 문제는 persist와 merge의 동작이 다르다는 것이다.

EntityManager의 persist 는 매개변수로 넘어온 객체를 영속화한다. EntityManager의 merge 는 매개변수로 넘어온 객체를 영속화하지 않고 영속화 된 객체를 반환한다.

merge의 동작에 주목해야 한다. JPA는 특정 테이블의 특정 레코드에 대한 세션을 만든다. merge는 기존에 가지고 있는 세션에 대해 변경 사항을 적용하여 기존 세션을 이용하여 쿼리를 발생시킨다. 그렇기 때문에 매개변수로 넘어온 객체를 영속화 하지않고 영속화 된 객체를 반환하는 것이다. 그래서 실제로 SimpleJpaRepository 구현체를 보면 다음과 같이 구현되어있다.

@Transactional
public <S extends T> S save(S entity) {
    Assert.notNull(entity, "Entity must not be null");
    if (this.entityInformation.isNew(entity)) {
        this.entityManager.persist(entity);
        return entity;
    } else {
        return this.entityManager.merge(entity);
    }
}

내가 사용한 UUIDV7IdEntity를 상속받아 사용했다고 가정해보자.

  1. 신규 도메인 엔티티를 생성하여 영속화 요청을 한다.
  2. Mapper가 도메인 엔티티를 영속성 엔티티로 변환한다. 이 때 생성자를 이용하기 때문에 isNew는 true
  3. JpaRepository가 이를 저장할 때, em.persist 를 이용한다. 이 때 세션이 생성된다.
  4. 정상적으로 저장 완료.

문제가 발생하는 경우는 기존 엔티티를 변경 할 때이다.

  1. 도메인 엔티티 조회 요청을 한다.
  2. 영속성 엔티티를 조회하여, Mapper를 통해 도메인 엔티티로 변환한다. 이 때 해당 영속성 엔티티는 isNew false이다.
  3. 도메인 엔티티를 조작한 후, 영속화 요청을 한다.
  4. Mapper를 통해 도메인 엔티티를 영속성 엔티티로 변환한다. 이 때 생성자를 이용하기 때문에 isNew는 true이다.
  5. 영속화 과정에서 save가 호출되고, EntityManager 내에서 동일한 id를 가진 엔티티에 대한 두개의 세션이 발생하게 된다 -> 에러 발생

이를 단순 JPA Entity만 이용해서 재현해보자.

@Entity
class TeamEntity(
    @Id
    val id : Long = 0L,
    @Column
    private var name: String = "",
    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
    private val members: MutableList<TeamMemberEntity> = mutableListOf()
) {
    fun addTeamMember(member: TeamMemberEntity) {
        members.add(member)
        member.setTeam(this)
    }
}

그리고 테스트 코드를 다음과 같이 작성해본다.

@Test
fun `정상 동작`() {
    // Given
    var team = TeamEntity(id = 1L, name = "team 1", members = mutableListOf())
    team = teamRepository.saveAndFlush(team) // merge 가 콜이 됨
    println("is team managed? ${em.contains(team)}") // true가 반환 되어야함
    team.changeTeamName("new name")
    team = teamRepository.saveAndFlush(team)
    println("team = $team")
}
    
//출력
Hibernate: select te1_0.id,te1_0.name from team_entity te1_0 where te1_0.id=?
Hibernate: insert into team_entity (name,id) values (?,?)
is team managed? true
Hibernate: update team_entity set name=? where id=?
team = TeamEntity(id=1, name='new name', members=[])
Hibernate: select te1_0.id,te1_0.name from team_entity te1_0
Hibernate: delete from team_entity where id=?
Hibernate: select tme1_0.id,tme1_0.back_number,tme1_0.name,tme1_0.team_id from team_member_entity tme1_0


보다시피 코드에서 select 쿼리가 먼저 발생하는 것을 확인할 수 있다. merge 를 하려고 하는데 1차 캐시에 없으니 select로 먼저 읽어오는 것이다.

그럼 문제 상황은 확실해졌다. 어떤 특정 테이블의 특정 컬럼에 대해 두개의 세션을 만들 수 있다면 에러가 재현될 것이다.

@Test
fun `에러 재현`() {
    var team = TeamEntity(id = 1L, name = "team 1", members = mutableListOf())
    teamRepository.saveAndFlush(team) // 마찬가지로 merge가 호출된다
    // 이 스코프의 team은 managed 상태가 아니다.
    println("is team managed? ${em.contains(team)}") // false가 반환 되어야함
    team.changeTeamName("new name")
    em.persist(team) // 또하나의 id 1L 을 가진 신규 team의 세션을 형성
    println("team = $team")
}
// 출력 
    
Hibernate: select te1_0.id,te1_0.name from team_entity te1_0 where te1_0.id=?
Hibernate: insert into team_entity (name,id) values (?,?)
is team managed? false
Hibernate: select te1_0.id,te1_0.name from team_entity te1_0
Hibernate: delete from team_entity where id=?
Hibernate: select tme1_0.id,tme1_0.back_number,tme1_0.name,tme1_0.team_id from team_member_entity tme1_0
A different object with the same identifier value was already associated with the session: [com.jpa.example.example.adapter.out.jpa.team.TeamEntity#1]
jakarta.persistence.

이렇게 한 트랜잭션 세션에 동일 엔티티에 대한 두개의 세션이 형성되면서 에러가 발생한다.

그렇다면 내가 겪은 문제 상황을 천천히 다시 생각해보자.

신규 엔티티를 생성하는 요청에는 아무런 문제가 없이 정상적으로 insert query가 나갔었다. 그 이유는 Mapper가 생성자를 통해 JPA Entity를 생성하면, isNew 는 true이므로 em.persist 콜이 호출되고 정상적으로 저장이 된다. 그리고 기존 엔티티를 조회하여 도메인 엔티티로 변환 후 데이터를 조작하고 다시 JPA Entity로 생성자로 매핑하면? isNew의 반환값이 new인 새로운 JPA Entity가 생성된다.

이 때 레포지토리의 save 메서드가 호출된다면 당연하게도 persist 콜이 호출되면서 매개변수로 넘어온 JPA Entity 객체에 세션을 할당하려고 할 것이다. 그런데 이미 세션이 존재하기 때문에 에러가 발생하는 것이다.

문제 해결 방법

가장 간단하게 해결하려면 EntityManager를 직접 사용하는 것이다. 영속화 시 무조건 em.merge 를 호출하여 저장하면 저장되어 있는 세션을 이용할 것이기 때문에 쉽게 해결이 가능하다. 일반적인 조회는 Repository를 이용하되, 영속화 부분에서만 이용하는 것이다. 다만 이 방식은 정말로 신규 객체를 저장할 때 조차 select 쿼리가 한번 나가는 문제가 있다. 또 연관관계 설정을 전부 끊어버리고, 말 그대로 DAO 로서의 역할로 JPA를 사용하는 것이 추가적인 문제를 발생시키지 않는다. 객체가 계속해서 신규 생성되는 로직이다보니 문제가 발생할 여지가 너무 많다.

현재는 EntityManager의 구조를 카피해서 만든 DomainEntityCache 라는 클래스를 만들어 도메인 엔티티의 로드 정보를 직접 관리하고 있다. 좀 번잡스럽기는 하지만 문제는 발생하지 않고 있다.

기존 본문

2021년 전문연구요원 훈련소 입소를 했던 당시 클린 아키텍처 책을 본 기억이 있다.

해당 부분의 예로 영속성 라이브러리에 의존하지 않는 개발의 예시를 들었었다. 훈련소 퇴소 이후, 인공 지능 기반 MRO 공급업체 추천 시스템을 개발할 때

의뢰 기업에서 학습 데이터를 CSV로 제공해줬었고, 이후에 데이터베이스 접근 권한 등을 줄 수 있을지 없을지 모른다고 하여. 레포지토리 자체를 인터페이스화 하고 초기 개발 단계에서는 딕셔너리를 사용하여 동작을 구현한 뒤 사용했었다.

이후 NestJS, Spring Boot로 넘어오는 과정에서는 거의 ORM 프레임워크에 의존하게 되었는데 NestJS의 경우 TypeORM(또는 Prisma), 스프링 MVC에서는 거진 JPA를 이용하고 있었다. 이번 SNS 개발을 하면서 데이터베이스부터 뭘 사용해야할지 정하지 않은 상태로 개발을 시작했고 커맨드 요청 처리에 대한 부분을 DDD 패턴(이 전략이 개발에 필요하진 않았다, 실제 개발을 하면서 장단점을 느끼기 위해서 사용) 을 도입해보기로 한 상태라 도메인 엔티티만 구성하여 유닛테스트를 진행하고, 이후 도메인 엔티티와 영속화 엔티티를 분리를 하기로 했다.

JPA와 같은 ORM은 사실 그 자체로 도메인 엔티티를 다루기 위한 목적이 강한데, 이걸 다시 분리할 필요가 있을까? 라고 생각할 수도 있지만 이러한 방식을 선택한 이유는 일단 MVC를 사용할 지, Webflux를 사용해볼지 고민을 하던 상황이었기 때문이다. MVC 환경에서는 JDBC 기반의 JPA를 사용할 수도 있고 Webflux 환경으로 간다고하면 R2DBC 를 사용하게 될 것인데 도메인 엔티티 자체가 특정 프레임워크에 종속되어버리는 문제를 해결하기 위해 분리를 선택했다.

DDD 패턴과 도메인/영속화 엔티티의 분리를 선택할 때 Kakao의 아래 영상을 참고했다.

위 영상에서 도메인 엔티티와 영속성 엔티티를 분리한 방법은 아래와 같았다.

  • 도메인 엔티티를 별도로 작성한다.
  • 도메인 엔티티에 대응하는 영속성 엔티티(JPA)를 구성한다
  • Mapper 클래스를 별도 작성하여 도메인 엔티티 <-> 영속성 엔티티를 변환한다.

하지만 위 영상을 보다가 든 의문점이 있었다. 영상의 방식처럼 JPA 엔티티를 도메인 엔티티로 변환하는 과정에는 별도로 고려할 사항이 없지만 도메인 엔티티를 JPA 엔티티로 변환할 때는 문제가 발생한다.

기본적으로 JPA는 한번 로드된 엔티티를 1차 캐시에 넣고 관리하게 된다. 이 과정에서 인스턴스화된 엔티티 별로 세션이 생성되고 이미 1차 캐시에 로드된 엔티티를 변경할 때는, 레포지토리에서 해당 엔티티를 로드하고 저장을 해야한다. 그렇지 않으면 에러가 발생하게 된다.

예시를 들면 아래와 같다.


// JpaMemberEntity
class JpaMemberEntity(
    @Id
    val id: Long = 0L,
    @Column
    val name: String = "Dohoon-kim"
) {
}
// Member
class Member(
    id: Long = 0L,
    name: String = "Dohoon-kim"
) {
    val id: Long = id
    val name: String = name
}

// MemberMapper
class MemberMapper {
    
    fun toDomainEntity(member: JpaMemberEntity): Member {
       ...
    }

    fun toJpaEntity(member: Member): JpaMemberEntity {
        // 신규 JPA Entity 객체 생성
        return JpaMemberEntity(
            id = member.id,
            name = member.name
        )
    }
}

이렇게 Mapper를 작성한다면 신규 생성 시에는 문제가 없지만, 이미 영속화 된 엔티티를 로드하는 경우엔 문제가 발생한다.

가령 아래와 같은 상황을 살펴보자

@Component
class JpaMemberLoadAdapter(
    private val jpaMemberRepository: MemberRepository,
    private val memberMapper: MemberMapper
): MemberLoadPort {
    
    override fun findById(memberId: Long): Member? {
        return jpaMemberRepository.findByIdOrNull(memberId)
    }
}

@Component
class MemberSaveAdapter(
    private val jpaMemberRepository: MemberRepository
): MemberSavePort {
    override fun save(member: Member): Member {
        val jpaMember = jpaMemberMapper.toJpaEntity(member)
        val savedMember = jpaMemberRepository.save(jpaMember)
        return memberMapper.toDomainEntity(savedMember)
    }
}

---

class MemberCommandService(
    private val memberLoadPort: MemberLoadPort,
    private val memberSavePort: MemberSavePort
): MemberUseCase {
    override fun changeMemberName(memberId: Long, newName: String): MemberDto {
        val member = memberLoadPort.findById(memberId) // 1. 여기서 memberId에 해당하는 JPA Entity가 1차 캐시에 저장
            ?: throw NotFoundException() 
        member.changeName(newName) // 2. 도메인 엔티티를 통한 데이터 조작
        val savedMember = memberSavePort.save(member) // 3 도메인 엔티티를 JPA 엔티티로 변환
        return MemberDto.from(savedMember)
    }
}

위 예시에서 JPA 영속성 컨텍스트 관점으로 살펴보면 아래와 같은 일이 발생한다.

  • MemberLoadAdapter에서 memberId에 해당하는 JPA Entity 로드
  • JpaMemberEntity(id=memberId)가 1차 캐시에 저장됨.
  • MemberMapper에 의해 Member(id=memberId)로 변환됨.
  • 서비스에서 Member(id=memberId) 엔티티 데이터 조작
  • MemberSaveAdapter를 통해 save 요청
  • MemberMapper에 의해 JpaMemberEntity(id=memberId, name=newName) 에 해당하는 신규 엔티티 생성
  • JpaMemberRepository가 해당 인스턴스를 저장하려고 시도
  • 영속성 컨텍스트에서 JpaMemberEntity(id=memberId)에 이미 연결된 세션이 있음에도, 다른 객체를 통한 영속화 요청이 들어온 것을 확인하고 에러 발생

이렇게 문제가 발생하게 된다.

JPA Entity의 상태는 비영속/영속/준영속/삭제가 존재하는데, 위 코드는 비영속 상태에서만 유효한 코드이고, 위와같이 사용하려면 로드한 직후 DETACH를 하거나, 또는 Mapper를 통해 매핑을 하기 전에 JPA Repository에서 이미 존재하는 JPA Entity를 가져와서 값을 변경하는 식으로 적용을 해야한다.

첫번째 방식과 두번째 방식을 모두 테스트 해보면서 든 문제점들, 그리고 그에 대한 임시결론은 아래와 같다.

1. Domain Entity -> JPA Entity 로 변환 시, 1차 캐시에 저장된 객체를 찾아 MemberMapper.toJpaEntity의 두번째 인자로 넘겨준다.
    문제점: 이 방식은 MemberMapper에 JPA에 대한 의존성이 생기게 된다.
    해결방안: MemberMapper는 그냥 신규 JPA 엔티티를 반환하고,MemberMapper를 호출하는 쪽에서(이 경우 MemberLoadAdapter, JPA Entity를 1차 캐시에서 불러와 저장하도록 한다.

2. JPA Entity를 로드한 뒤, 곧바로 EntityManager를 사용하여 해당 객체를 준영속 상태로 만든다.
    문제점: JPA에서 제공하는 변경 감지등의 기능을 전혀 이용할 수 없다.
    임시결론: 어차피 영속화만 시키고 데이터 변경은 도메인 엔티티를 이용하는데 그 기능들이 과연 필요한걸까? 락이 필요하다면 레디스 분산락을 이용하는 방법도 있다.

2번의 방식이 개인적으로 좋지않을까? 라고 생각을 했는데, 트랜잭션의 경계나 락등을 위해서 추가적인 작업이 필요할 것 같아 우선 1번을 적용하였다. 예상했던대로 매핑 과정이 굉장히 번거롭게 되었고, 연관 객체의 경우 Proxy 상태인 경우 매핑 과정에서 배제하는 등의 추가 작업이 필요해졌다. 영상에서는 따로 설명이 되어 있지 않아 카카오측에서는 어떻게 적용했는지 모르겠지만

현재 연관된 모든 객체를 Fetch Join을 통해 불러오게 만들어 두었는데, 추후 성능상의 문제가 발생할 가능성이 높아 다른 방법을 찾고 있다.

위 방식을 통해 도메인 엔티티에서 실제 비지니스 로직을 수행하고, 서비스 레이어는 도메인 엔티티를 불러오고 도메인 엔티티의 메소드를 호출하고 저장하는 식으로 서비스 레이어가 얇아지는 효과를 얻을 수 있었다. 다만 앞으로 도메인 엔티티가 비대해질 것으로 예상된다.