간접 매핑과 DDD

devty·2024년 3월 5일
0

DB

목록 보기
3/5
post-thumbnail

두개 이상의 Entity간에 같은 라이프 사이클 유무

  • 엔티티 간의 관계 설정은 해당 엔티티들이 같은 라이프 사이클을 공유하는지 여부에 따라 달라질 수 있다.
  • 여기서 라이프 사이클을 공유한다는 것은 엔티티들이 생성, 수정, 삭제 등의 생명주기를 함께한다는 의미다.
  • 반면, 공유하지 않는다면 각 엔티티는 독립적으로 생명주기를 가진다.

같은 라이프 사이클을 공유할 때

  • 엔티티들이 같은 라이프 사이클을 공유한다면, 이들 사이에는 강한 연관관계가 존재한다.
  • 이 경우, 단방향 또는 양방향 매핑을 사용하여 엔티티 간의 관계를 명확히 할 수 있다.
  • 양방향 매핑
    • 양방향 매핑은 서로 다른 두 엔티티가 서로를 참조하는 경우 사용한다.
    • 예를 들어, Order 엔티티와 OrderItem 엔티티가 있을 때, Order는 여러 OrderItem을 가질 수 있고, OrderItem은 하나의 Order에 속한다고 할 때 사용한다.
    • 양방향 매핑은 엔티티 간의 관계를 명확하게 표현할 수 있다.
  • 단방향 매핑
    • 단방향 매핑은 한 엔티티가 다른 엔티티를 참조하지만, 반대 방향으로는 참조하지 않을 때 사용한다.
    • 예를 들어, Product 엔티티가 여러 개의 ProductImage엔티티를 가지고 있지만, ProductImage에서 Product을 직접 참조하지 않는 경우가 이에 해당한다.
    • 단방향 매핑은 관계의 방향이 한쪽으로만 흐른다.

같은 라이프 사이클을 공유하지 않을 때

  • 엔티티들이 서로 다른 라이프 사이클을 가진다면, 이들 사이의 관계는 느슨한 연관관계로 간주된다.
  • 이러한 경우, 엔티티 간 직접적인 연관관계 매핑 대신 ID를 통한 간접 참조를 사용한다.
  • ID로만 참조하기
    • 엔티티 간의 직접적인 연관관계를 설정하지 않고, 관련 엔티티의 ID만을 속성으로 가지고 있어서 필요할 때마다 해당 ID를 사용해 관련 엔티티를 조회하는 방식이다.
    • 이 방식은 엔티티 간의 결합도를 낮추어 유지 보수성과 확장성을 향상시킨다.

단방향과 양방향

  • 단방향 예시 (FoodTruckJpaEntity)

    @Entity
    @Table(name = "food_trucks")
    class FoodTruckJpaEntity(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val id: Long? = null,
    
        @OneToMany(cascade = [CascadeType.ALL])
        @JoinColumn(name = "food_truck_id")
        val menus: List<MenuJpaEntity> = mutableListOf()
    )
    
    @Entity
    @Table(name = "menus")
    class MenuJpaEntity(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val id: Long? = null,
    )
    
    • 위 같이 Food Truck에 중점적인 단방향 매핑은 아래와 같은 장점이 있다.

      1. Food Truck 조회(1개의 Query)시 Menu도 같이 가져올 수 있다.
      2. Food Truck과 Menu의 관계가 비즈니스 로직상 직관적으로 이해하기 쉽다.
      3. Food Truck 삭제시 Menu도 일괄 삭제 되어 관리가 용이하고 일관성을 지키기 쉽다.
    • 단점으로는 아래와 같다.

      1. Food Truck 전체 조회시 메뉴가 불 필요하지만 가져오게 된다.

        • 메뉴가 필요없는 치킨 전체 조회
      2. 다른 Entity들과 연관관계 매핑이 추가 된다면, Food Truck가 책임이 무거워진다.

      3. 또한 지금 JPA Entity와 Domain Entity(비즈니스적 처리를 위함)를 같이 사용하고 있어서 두 Entity간에 변환을 Mapper 클래스로 사용하는데 Food Truck의 Mapper 클래스가 집중화가 된다.

        class FoodTruckMapper(
            private val userMapper: UserMapper,
        		private val menuMapper: MenuMapper,
        		private val reviewMapper: ReviewMapper,
        		private val ...Mapper: ...Mapper, // 연관관계가 있는 모든 Entity Mapper
        ) {
            fun mapToDomainEntity(foodTruckJpaEntity: FoodTruckJpaEntity): FoodTruck = FoodTruck(
                id = foodTruckJpaEntity.id,
                name = foodTruckJpaEntity.name,
                foodType = foodTruckJpaEntity.foodType,
                operatingStatus = foodTruckJpaEntity.operatingStatus,
                starRating = foodTruckJpaEntity.starRating,
                reviewCount = foodTruckJpaEntity.reviewCount,
                user = userMapper.mapToDomainEntity(foodTruckJpaEntity.userJpaEntity),
                menu = menuMapper.mapToDomainEntity(foodTruckJpaEntity.menuJpaEntity),
                review = reviewMapper.mapToDomainEntity(foodTruckJpaEntity.reviewJpaEntity),
                ... = ...Mapper.mapToDomainEntity(foodTruckJpaEntity....JpaEntity),
            )
        
            fun mapToJpaEntity(foodTruck: FoodTruck): FoodTruckJpaEntity = FoodTruckJpaEntity(
                name = foodTruck.name,
                foodType = foodTruck.foodType,
                userJpaEntity = userMapper.mapToJpaEntity(foodTruck.user),
                menuJpaEntity = menuMapper.mapToJpaEntity(foodTruck.menu),
                reviewJpaEntity = reviewMapper.mapToJpaEntity(foodTruck.review),
                ...JpaEntity = ...Mapper.mapToJpaEntity(foodTruck....),
            )
        }
        
  • 단방향 예시 (MenuJpaEntity)

    @Entity
    @Table(name = "food_trucks")
    class FoodTruckJpaEntity(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val id: Long? = null
    )
    
    @Entity
    @Table(name = "menus")
    class MenuJpaEntity(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val id: Long? = null,
    
        @ManyToOne
        @JoinColumn(name = "food_truck_id")
        val foodTruck: FoodTruckJpaEntity
    )
    
    • 위 같이 Food Truck에 중점적인 단방향 매핑은 아래와 같은 장점이 있다.
      1. Food Truck에 대한 책임이 감소하였다. → 책임의 대한 분산

      2. JPA Entity와 Domain Entity의 변환 Mapper 클래스의 집중 분리

        class FoodTruckMapper(
            private val userMapper: UserMapper
        ) {
            fun mapToDomainEntity(foodTruckJpaEntity: FoodTruckJpaEntity): FoodTruck = FoodTruck(
                id = foodTruckJpaEntity.id,
                name = foodTruckJpaEntity.name,
                foodType = foodTruckJpaEntity.foodType,
                operatingStatus = foodTruckJpaEntity.operatingStatus,
                starRating = foodTruckJpaEntity.starRating,
                reviewCount = foodTruckJpaEntity.reviewCount,
                user = userMapper.mapToDomainEntity(foodTruckJpaEntity.userJpaEntity)
            )
        
            fun mapToJpaEntity(foodTruck: FoodTruck): FoodTruckJpaEntity = FoodTruckJpaEntity(
                name = foodTruck.name,
                foodType = foodTruck.foodType,
                userJpaEntity = userMapper.mapToJpaEntity(foodTruck.user)
            )
        }
        
      3. Menu를 추가했을 때 독립적인 관리가 가능하다. → 확장성을 고려했을 때 장점

    • 단점으로는 아래와 같다.
      1. Food Truck에서 해당 Food Truck의 모든 Menu를 조회하고자 할 때, 직접적인 방법이 없다. 이를 위해서는 별도의 조회 로직을 구현해야 하며, 2개 이상의 Query를 요청해야한다.
      2. Food Truck이 삭제시 별도의 Menu 삭제 로직을 구현해야한다. 이것 또한 2개 이상의 Query를 요청해야한다.
  • 양방향 예시

    @Entity
    @Table(name = "food_trucks")
    class FoodTruckJpaEntity(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val id: Long? = null,
    
        @OneToMany(mappedBy = "foodTruck", cascade = [CascadeType.ALL], orphanRemoval = true)
        val menus: List<MenuJpaEntity> = mutableListOf()
    )
    
    @Entity
    @Table(name = "menus")
    class MenuJpaEntity(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val id: Long? = null,
    
        @ManyToOne
        @JoinColumn(name = "food_truck_id")
        val foodTruck: FoodTruckJpaEntity
    )
    • 위 같이 양방향 매핑은 아래와 같은 장점이 있다.
      1. 양방향 매핑을 통해 Food Truck과 Menu 간의 관계가 명확해지며, 서로를 쉽게 참조할 수 있습니다. → 양방향 관계의 명확성
      2. 연관된 엔티티 간의 데이터 일관성 유지가 쉬워집니다. → 데이터 일관성 유지
    • 단점으로 아래와 같다.
      1. 양방향 매핑은 관리 복잡성을 증가시키며, 데이터 일관성을 유지하기 위한 추가적인 주의가 필요합니다. → 관리 복잡성 증가
      2. 양방향 매핑은 성능 문제를 야기할 수 있으며, 특히 대규모 데이터를 다룰 때 주의가 필요합니다. → 성능 문제

ID로만 참조하는 이유

  • 단방향일 경우 안 좋은 점
    • 연관된 엔티티를 업데이트하거나 삭제할 때, 관계가 단방향이기 때문에 해당 작업이 다른 엔티티에 자동으로 반영되지 않는다.
    • 이는 데이터 일관성을 유지하기 위해 추가적인 처리가 필요함을 의미한다.
    • 특정 엔티티에서 시작해서 관계된 다른 엔티티로의 접근이 필요한 경우, 단방향 매핑은 이를 직접적으로 지원하지 않아 별도의 쿼리나 로직을 통해 관계를 탐색해야 한다.
  • 양방향일 경우 안 좋은 점
    • 생성, 삭제가 발생시 연관관계가 되어 있는 모든 곳에서 비즈니스 로직을 처리해줘야한다.
    • 1, 2개일 경우에는 금방 처리하겠지만, n개 이상인 경우 일일이 다 처리하는 것도 문제지만 나중에 유지보수 하는 경우에도 코드가 더러워지는 걸 볼수 있다.
    • 또한, 1:N 관계중 N에 대한 Entity가 삭제될 경우 1에 대한 Entity에서 수동으로 제거를 해줘야한다.
    • 1에 대한 Entity는 CaseCade를 통해 처리가 가능하지만, N에 대한 Entity는 그렇지 못하다.
  • 위 같은 이유로 엔티티 간의 관계를 ID를 통해 간접 참조하는 방식은 다음과 같은 이유로 선호된다.
    1. 엔티티들이 서로를 직접 참조하지 않음으로써, 결합도가 낮아지고 각 엔티티의 독립성이 향상된다. 이는 변경에 대한 유연성을 증가시키며, 시스템의 전체적인 유지보수성을 개선한다. → 결합도 감소
    2. JPA에서 가장 큰 단점으로 꼽히는 N+1 문제를 각각의 쿼리 조회로 인해 해결이 가능하다. 또한, MSA로 마이그레이션 했을 경우 DB 의존관계가 더 줄어들기 때문에 기능 개발이 편리해진다. → 확장성 고려
    3. 서로 다른 라이프 사이클을 가진 엔티티들 사이의 강제적인 생명주기 종속성을 방지한다. 예를 들어, 한 엔티티의 삭제가 다른 엔티티에 영향을 미치지 않도록 할 수 있다. → 라이프 사이클 독립성
    4. 단방향이나 양방향 매핑은 종종 LAZY 로딩과 EAGER 로딩 사이의 성능 고려사항을 야기한다. ID로만 참조하는 방식은 필요할 때에만 관련 엔티티를 조회함으로써, 불필요한 데이터 로딩을 최소화하고 성능을 최적화할 수 있다. → 성능 최적화
    5. 엔티티 간의 관계가 시간이 지나면서 변경되거나 새로운 관계가 추가되는 경우, ID를 통한 간접 참조는 새로운 관계를 통합하기가 더 쉽다. 직접적인 연관관계 매핑보다는 수정이 간단하며, 확장성이 높다. → 확장성과 유연성

실제 설계

  • food truck
    @Entity
    @Table(name = "food_trucks")
    class FoodTruckJpaEntity(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val id: Long? = null, 
    
        var name: String, 
    
        @Enumerated(EnumType.STRING) 
        var foodType: FoodType,
    
        var operatingStatus: Boolean = false, 
    )
  • menu
    @Entity
    @Table(name = "menus")
    class MenuJpaEntity(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        val id: Long? = null, 
    
        var name: String,
        var description: String, 
        var price: Float, 
    
        var foodTruckId: Long
    )
  • 위 두 Entity를 보면 간접적인 단방향 매핑을 하고 있다.
  • 왜 간접적인 매핑인데 양방향으로 하지 않은가에 대해서는 아래에서 설명하겠다.
    1. MenuFoodTruck에 의존적이지만, FoodTruckMenu의 존재를 명시적으로 알 필요가 없어, 양쪽 엔티티 간 결합도를 낮출 수 있습니다. → 의존성 최소화
    2. 간접적인 매핑은 엔티티 간의 관계를 단순화시켜, 시스템의 전반적인 유지보수성을 향상시킨다. → 유지보수 용이성
    3. MenuFoodTruck이 있어야만 존재할 수 있으므로, FoodTruckId를 통해 직접적인 연결을 유지함으로써 데이터의 일관성을 보장할 수 있습니다. → 데이터 일관성

테스트 코드의 차이

  • 직접적인 연관관계가 있는 테스트 코드
    @Test
    fun test() {
        val foodTruckId = 1L
        val foodTruck = FoodTruck(id = foodTruckId, name = "Test Truck")
        val menu = Menu(id = 1L, name = "Deluxe Burger", price = 9.99, foodTruck = foodTruck)
        foodTruck.menus.add(menu)
    
        // 연관관계로 인해 FoodTruck 저장 시 Menu도 함께 저장됨
        when(foodTruckRepository.findById(foodTruckId)).thenReturn(Optional.of(foodTruck))
    
        val foundMenus = foodTruckService.findMenusByFoodTruck(foodTruckId)
    
        verify(foodTruckRepository).findById(foodTruckId)
        assertTrue(foundMenus.isNotEmpty())
        assertEquals("Deluxe Burger", foundMenus.first().name)
    }
  • 간접적인 연관관계가 있는 테스트 코드
    @Test
    fun test() {
        val foodTruckId = 1L
        val menus = listOf(Menu(id = 1L, name = "Deluxe Burger", price = 9.99, foodTruckId = foodTruckId))
    
        when(menuRepository.findByFoodTruckId(foodTruckId)).thenReturn(menus)
    
        val foundMenus = foodTruckService.findMenusByFoodTruck(foodTruckId)
    
        verify(menuRepository).findByFoodTruckId(foodTruckId)
        assertTrue(foundMenus.isNotEmpty())
        assertEquals("Deluxe Burger", foundMenus.first().name)
    }
  • 위 두 테스트 코드의 차이점
    1. 위 테스트 코드는 연관관계가 1개 밖에 없는데 벌써 2줄이상의 코드를 차지하게 된다. 만약 연관관계가 n개 이상이라고 한다면 코드 수는 무수히 늘어날 것이다.
    2. 간접 매핑을 사용하면, FoodTruckMenu의 데이터 준비와 검증이 더 독립적으로 이루어진다. 이는 테스트 간 격리를 쉽게 유지할 수 있게 해준다.
    3. 간접 매핑에서는 Menu 조회를 위해 FoodTruck 엔티티의 실제 인스턴스 생성이 필요 없습니다. 이는 테스트 데이터 준비를 간소화하고, 테스트 실행 속도를 개선할 수 있다.
    4. 간접 매핑은 FoodTruckMenu 사이의 결합도를 낮춘다. 이로 인해 각 엔티티의 변경이 다른 엔티티의 테스트에 미치는 영향을 줄일 수 있으며, 시스템의 전반적인 유연성이 증가한다.
    5. 간접 매핑을 사용하면, 관련 엔티티를 모킹하여 테스트하는 것이 더 간단해진다. MenuRepositoryfindByFoodTruckId 메소드만 모킹하면 되기 때문에, 복잡한 연관관계를 설정할 필요가 없다.
      • 사실 이 부분이 제일 크다. 모듈별 또는 엔티티별 연관관계가 짙다면, 모킹하는데만해도 많은 리소스를 사용하게 될 것이다.
      • 하지만, 간접매핑으로 인해 느슨할 결합이 유지 되기 때문에 테스트 코드 작성에도 유의미한 결과를 도출할 수 있다.

DDD 패턴과 간접 매핑의 연관성

  • 도메인 주도 설계(DDD)는 복잡한 소프트웨어의 설계와 구현 과정에서 도메인의 복잡성을 관리하는데 초점을 맞춘다.
  • DDD에서는 Aggregate라는 개념을 통해 도메인의 복잡성을 캡슐화하고, 이를 Aggregate Root를 통해 관리한다.
  • Aggregate Root는 Aggregate 내의 엔티티와 값 객체를 관리하는 역할을 하며, Aggregate 간의 관계는 주로 Aggregate Root를 통해 정의된다.
  • ID를 통한 간접 참조는 이러한 DDD 접근법과 잘 맞아떨어지는데, 이는 두 가지 주요 이유 때문이다.
    1. 간접 참조를 사용하면 Aggregate Root 간의 직접적인 연관관계 대신 ID를 통해 서로를 참조한다. 이 방식은 엔티티 간의 결합도를 낮추며, 각 Aggregate가 독립적으로 개발, 테스트, 유지보수될 수 있도록 한다. 결과적으로 시스템의 전반적인 유연성과 확장성이 향상된다. → 결합도 감소
    2. DDD에서는 도메인 모델의 명확성과 정확성이 중요하다. 간접 참조를 사용하면, 개발자는 Aggregate 간의 관계를 더 명확하게 표현할 수 있다. 예를 들어, 어떤 Aggregate Root가 다른 Aggregate Root를 ID로 참조하면, 이 두 Aggregate 간의 관계가 느슨하며, 독립적인 라이프사이클을 가진다는 것을 명확히 알 수 있다. 이는 복잡한 도메인 로직을 더 쉽게 이해하고 관리할 수 있도록 도와준다. → 도메인 모델의 명확성
  • 간접 매핑은 DDD에서 추구하는 모듈성과 유연성을 실현하는 데 도움을 준다.
  • 특히 마이크로서비스 아키텍처와 같이 분산 시스템에서는 서비스 간의 결합도를 최소화하는 것이 중요하다. → 확장성 고려
  • 간접 매핑은 서비스 간의 의존성을 줄이고, 도메인 간의 명확한 경계를 정의하는 데 유리합니다.
  • 또한, Aggregate 간의 관계가 변경되거나 확장될 필요가 있을 때, 간접 매핑을 사용하면 기존 코드에 미치는 영향을 최소화하면서 변경사항을 적용할 수 있다.
profile
지나가는 개발자

0개의 댓글

관련 채용 정보