본문 바로가기
ETC

[Refactoring] 여러 역할을 갖는 엔티티 리팩터링하기

by devson 2022. 11. 16.

레거시 시스템을 살펴보다보면 하나의 엔티티(테이블)에 서로 다른 역할의 데이터 필드(컬럼)가 모여있도록 모델링 된 경우가 있다.

초기에는 하나의 역할의 데이터만 있었지만, 서비스가 확장되면서 기존 시스템을 재활용하다보니 기존의 엔티티(테이블)에 다른 역할의 데이터 필드(컬럼)를 추가한 경우이다.

 

이번 포스팅에서는 여러 역할을 갖으면서 SRP를 위반하는 엔티티와 그 문제점을 알아보고

이를 어떻게 리팩터링하면 좋을지에 대한 방법을 설명하도록 하겠다. 

 

(예제 코드는 여기에서 살펴볼 수 있다)

 

SRP를 위반하는 엔티티와 그 문제점

예를 들어 처음에는 물리적인 굿즈를 팔기 위한 상품을 담기 위한 상품 엔티티를 만들었다고 하자,

class Product(
    id: ProductId,
    name: String,
    price: BigDecimal,
    forSale: Boolean,
) {
    // 상품 id
    val id: ProductId = id

    // 상품명
    val name: String = name

    // 금액
    val price: BigDecimal = price

    // 판매 여부
    val forSale: Boolean = forSale
}

 

처음에는 아무런 문제가 없지만 서비스가 성장하면서 서비스에 구독 기능이 추가된다고 하자.

기존 시스템을 재활용하기 위해 이 상품 엔티티에 구독 상품과 관련된 데이터 필드를 추가하면 아래와 같이 될 것이다.

class Product(
    id: ProductId,
    type: ProductType,
    name: String,
    price: BigDecimal,
    forSale: Boolean,
    subscriptionPeriodInMonth: Int?,
) {
    // 상품 id
    val id: ProductId = id

    // 상품 타입
    val type: ProductType = type
    
    // 상품명
    val name: String = name

    // 금액
    val price: BigDecimal = price

    // 판매 여부
    val forSale: Boolean = forSale

    // 구독 기간 (월 단위) - 구독 상품인 경우에만 not null
    val subscriptionPeriodInMonth: Int? = subscriptionPeriodInMonth
}

enum class ProductType {
    GOODS,
    SUBSCRIPTION,
}

(상품의 종류를 구분하기 위해 ProductType enum을 추가하였다)

 

시간이 지나 이번에는 판매 시작 일자와 판매 종료 일자가 정해진 이벤트 상품이 추가된다고 하자.

class Product(
    id: ProductId,
    type: ProductType,
    name: String,
    price: BigDecimal,
    forSale: Boolean,
    subscriptionPeriodInMonth: Int?,
    salesStartsDate: LocalDate?,
    salesEndsDate: LocalDate?,
) {
    // 상품 id
    val id: ProductId = id

    // 상품 타입
    val type: ProductType = type

    // 상품명
    val name: String = name

    // 금액
    val price: BigDecimal = price

    // 판매 여부
    val forSale: Boolean = forSale

    // 구독 기간 (월 단위) - 구독 상품인 경우에만 not null
    val subscriptionPeriodInMonth: Int? = subscriptionPeriodInMonth

    // 판매 시작 일자 - 이벤트 상품인 경우에만 not null
    val salesStartsDate: LocalDate? = salesStartsDate

    // 판매 종료 일자 - 이벤트 상품인 경우에만 not null
    val salesEndsDate: LocalDate? = salesEndsDate
}

enum class ProductType {
    GOODS,
    SUBSCRIPTION,
    EVENT,
}

 

이처럼 하나의 엔티티에 다른 역할에 데이터를 추가하면서 계속해서 확장시키다보면

먼저 특정 역할의 데이터에만 필요한 필드가 생기기 때문에 nullable 필드(컬럼)가 계속 추가된다.

이는 Kotlin, TypeScript와 같이 non-null, null type을 컴파일 레벨에서 구분하는 언어를 사용하는 경우 엔티티 내부 필드 사용 시 코드가 지저분해지는 경향이 있다.

둘째로는 해당 엔티티가 어떤 역할일 때 어떤 데이터를 필요로하고 사용하는지 파악하기가 어려워진다.

(이는 특히 새로운 개발자가 기존의 레거시 코드를 살펴볼 때 주로 겪는 문제이다)

 

엔티티의 각 역할을 좀 더 드러내기 위해서 다음과 같이 생성자를 factory method로 분리하는 방법이 있다.

class Product private constructor(
    id: ProductId,
    type: ProductType,
    name: String,
    price: BigDecimal,
    forSale: Boolean,
    subscriptionPeriodInMonth: Int?,
    salesStartsDate: LocalDate?,
    salesEndsDate: LocalDate?,
) {
    // 상품 id
    val id: ProductId = id

    // 상품 타입
    val type: ProductType = type

    // 상품명
    val name: String = name

    // 금액
    val price: BigDecimal = price

    // 판매 여부
    val forSale: Boolean = forSale

    // 구독 기간 (월 단위) - 구독 상품인 경우에만 not null
    val subscriptionPeriodInMonth: Int? = subscriptionPeriodInMonth

    // 판매 시작 일자 - 이벤트 상품인 경우에만 not null
    val salesStartsDate: LocalDate? = salesStartsDate

    // 판매 종료 일자 - 이벤트 상품인 경우에만 not null
    val salesEndsDate: LocalDate? = salesEndsDate

    companion object {
        // 굿즈 상품 생성
        fun createGoods(
            id: ProductId,
            name: String,
            price: BigDecimal,
            forSale: Boolean,
        ): Product {
            return Product(
                id = id,
                type = ProductType.GOODS,
                name = name,
                price = price,
                forSale = forSale,
                subscriptionPeriodInMonth = null,
                salesStartsDate = null,
                salesEndsDate = null,
            )
        }

        // 구독 상품 생성
        fun createSubscription(
            id: ProductId,
            name: String,
            price: BigDecimal,
            forSale: Boolean,
            subscriptionPeriodInMonth: Int,
        ): Product {
            return Product(
                id = id,
                type = ProductType.SUBSCRIPTION,
                name = name,
                price = price,
                forSale = forSale,
                subscriptionPeriodInMonth = subscriptionPeriodInMonth,
                salesStartsDate = null,
                salesEndsDate = null,
            )
        }

        // 이벤트 상품 생성
        fun createEvent(
            id: ProductId,
            name: String,
            price: BigDecimal,
            forSale: Boolean,
            salesStartsDate: LocalDate,
            salesEndsDate: LocalDate,
        ): Product {
            return Product(
                id = id,
                type = ProductType.EVENT,
                name = name,
                price = price,
                forSale = forSale,
                subscriptionPeriodInMonth = null,
                salesStartsDate = salesStartsDate,
                salesEndsDate = salesEndsDate,
            )
        }
    }
}

enum class ProductType {
    GOODS,
    SUBSCRIPTION,
    EVENT,
}

위와 같이 factory method를 각 역할에 맞게 생성함으로써 각 역할의 인스턴스 사용 시 필요한 데이터에 대한 정합성은 어느정도 지킬 수 있다.

하지만 근본적으로 하나의 엔티티에 여러 역할을 갖기 때문에 갖는 문제는 여전히 풀리지 않는다.

 

아래 상품 코드를 보면 상품의 각 역할 별로 필요한 메서드가 있는데

class Product private constructor(
    // parameters
) {
    // fields

    fun methodForGoods1() { /** 굿즈 상품 전용 메서드 1 **/ }
    fun methodForGoods2() { /** 굿즈 상품 전용 메서드 2 **/ }

    fun methodForSubscription() { /** 구독 상품 전용 메서드 **/ }

    fun methodForEvent1() { /** 이벤트 상품 전용 메서드 1 **/ }
    fun methodForEvent2() { /** 이벤트 상품 전용 메서드 2 **/ }
    fun methodForEvent3() { /** 이벤트 상품 전용 메서드 3 **/ }
    
    // factory method
}

이렇듯 역할이 다르지만 모든 데이터가 하나의 엔티티 내에 있기 때문에 데이터 필드가 계속해서 많아지듯

역할 별로 필요한 메서드의 개수도 많아지게되어 클래스가 점점 커지고 복잡도 또한 높아지게 된다.

 

클래스 분리를 통한 리팩터링

그러면 어떻게 하나의 엔티티가 여러 역할을 갖음으로써 인해 커지는 복잡도를 낮출 수 있을까?

 

나는 이러한 문제를 역할에 따라 클래스를 분리하되 기존 클래스를 데이터 홀더로써 사용하는 방식을 제안하고싶다.

class GoodsProduct(
    internal val product: Product
) {
    init {
        require(product.type == ProductType.GOODS)
    }

    val id: ProductId = this.product.id
    val type: ProductType = this.product.type
    val name: String = this.product.name
    val price: BigDecimal = this.product.price
    val forSale: Boolean = this.product.forSale

    fun methodForGoods1() { /** 굿즈 상품 전용 메서드 1 **/ }
    fun methodForGoods2() { /** 굿즈 상품 전용 메서드 2 **/ }

    companion object {
        fun create(
            id: ProductId,
            name: String,
            price: BigDecimal,
            forSale: Boolean,
        ): GoodsProduct {
            return GoodsProduct(
                Product.createGoods(
                    id = id,
                    name = name,
                    price = price,
                    forSale = forSale,
                )
            )
        }
    }
}

class EventProduct(
    internal val product: Product
) {
    init {
        require(product.type == ProductType.EVENT)
    }
    
    val id: ProductId = this.product.id
    val type: ProductType = this.product.type
    val name: String = this.product.name
    val price: BigDecimal = this.product.price
    val forSale: Boolean = this.product.forSale
    val salesStartsDate: LocalDate = this.product.salesStartsDate 
        ?: throw IllegalStateException("salesStartsDate is not defiend")
    val salesEndsDate: LocalDate = this.product.salesEndsDate 
        ?: throw IllegalStateException("salesEndsDate is not defiend")

    fun methodForEvent1() { /** 이벤트 상품 전용 메서드 1 **/ }
    fun methodForEvent2() { /** 이벤트 상품 전용 메서드 2 **/ }
    fun methodForEvent3() { /** 이벤트 상품 전용 메서드 3 **/ }

    companion object {
        fun create(
            id: ProductId,
            name: String,
            price: BigDecimal,
            forSale: Boolean,
            salesStartsDate: LocalDate,
            salesEndsDate: LocalDate,
        ): EventProduct {
            return EventProduct(
                Product.createEvent(
                    id = id,
                    name = name,
                    price = price,
                    forSale = forSale,
                    salesStartsDate = salesStartsDate,
                    salesEndsDate = salesEndsDate,
                )
            )
        }
    }
}

class SubscriptionProduct(
    internal val product: Product
) {
    init {
        require(product.type == ProductType.SUBSCRIPTION)
    }

    val id: ProductId = this.product.id
    val type: ProductType = this.product.type
    val name: String = this.product.name
    val price: BigDecimal = this.product.price
    val forSale: Boolean = this.product.forSale
    val subscriptionPeriodInMonth: Int = this.product.subscriptionPeriodInMonth
        ?: throw IllegalStateException("subscriptionPeriodInMonth is not defiend")

    fun methodForSubscription() { /** 구독 상품 전용 메서드 **/ }

    companion object {
        fun create(
            id: ProductId,
            name: String,
            price: BigDecimal,
            forSale: Boolean,
            subscriptionPeriodInMonth: Int,
        ): SubscriptionProduct {
            return SubscriptionProduct(
                Product.createSubscription(
                    id = id,
                    name = name,
                    price = price,
                    forSale = forSale,
                    subscriptionPeriodInMonth = subscriptionPeriodInMonth,
                )
            )
        }
    }
}

 

코드가 장황하지만 각 클래스를 살펴보면 단순하다.

- 먼저 각 상품의 역할에 따라 클래스를 분리하고, 특정 상품 객체는 해당 상품의 엔티티를 데이터로써 갖는다.

- 그리고 각 역할에 있어 필요한 인터페이스(필드, 메서드)만을 외부에 노출한다.

 

이렇게 함으로써 거대한 엔티티가 각 역할에 따라 분리가 되고 그로인해

- 각 역할에 어떤 데이터가 필수로 필요로하고

- 각 역할이 어떤 기능을 사용하는지

를 명확하게 알 수 있게되어 각 역할에 대한 기능의 응집성을 가질 수 있다.

(그러나 이게 완벽한 해답은 아니라고 생각한다 이에 대해서는 마지막에 다시 언급하도록 하겠다)


그리고 각 상품에 대한 영속성을 다루기 위한 Repository도 위와 유사하게 처리한다.

interface ProductRepository {
    fun save(entity: Product): Product

    fun findByIdAndType(id: ProductId, type: ProductType): Product?
}

class GoodsProductRepository(
    private val productRepository: ProductRepository,
) {
    fun save(entity: GoodsProduct): GoodsProduct {
        this.productRepository.save(entity.product)
        return entity
    }

    fun findById(id: ProductId): GoodsProduct? {
        return this.productRepository.findByIdAndType(id = id, type = ProductType.GOODS)
            ?.let(::GoodsProduct)
    }
}

class SubscriptionProductRepository(
    private val productRepository: ProductRepository,
) {
    fun save(entity: SubscriptionProduct): SubscriptionProduct {
        this.productRepository.save(entity.product)
        return entity
    }

    fun findById(id: ProductId): SubscriptionProduct? {
        return this.productRepository.findByIdAndType(id = id, type = ProductType.SUBSCRIPTION)
            ?.let(::SubscriptionProduct)
    }
}

class EventProductRepository(
    private val productRepository: ProductRepository,
) {
    fun save(entity: EventProduct): EventProduct {
        this.productRepository.save(entity.product)
        return entity
    }

    fun findById(id: ProductId): EventProduct? {
        return this.productRepository.findByIdAndType(id = id, type = ProductType.EVENT)
            ?.let(::EventProduct)
    }
}

위 코드에서처럼 특정 역할에 특화된 영속성 인터페이스를 제공할 수 있게 Repository 클래스를 생성한다.

delegate pattern과 같이 실질적인 영속성 관련 작업은 상품 엔티티의 Repository를 통해 이뤄지지만,

이렇게 함으로써 해당 역할에 필요한 영속성 기능에 더 초점을 줄 수 있다.

 


JPA를 사용하고 코드를 변경할 수 있는 여유가 좀 더 있다면 JPA Single Table Strategy를 사용하는 것도 고려할 수 있겠다.

(사실 위 방법은 JPA Single Table Strategy로부터 아이디어를 따왔다)


 

결론

나는 업무를 하면서 위와 유사한 레거시 코드에 대해 앞서 살펴본 솔루션을 적용하여

비대한 엔티티에 대한 복잡도는 줄이면서 특정 역할에 대한 응집도를 높이도록 하였다.

 

사실 하나에 테이블에 여러 역할에 데이터를 넣어서 사용하다보면 테이블이 비대해지는 것은 물론이고,

그로인해 해당 테이블을 통해 데이터를 조회할 때는 데이터의 히스토리를 모두 알아야 적절하게 데이터를 분석할 수 있다. 

또한 데이터의 역할이 여러 개이기 때문에 테이블에 index 수가 많아질 수 있고 테이블 사용 시 lock과 관련된 문제도 생길 수 있다.

(이는 JPA Single Table Strategy를 사용할 때도 동일한 문제이다)

 

그렇기에 엔티티가 점점 커지게 된다면 이를 명백히 분리하고 DB 테이블 또한 나누고 데이터를 이전하는 것이 프로그래밍적으로는 가장 최선의 방법이지만,

리팩터링해야하는 코드의 규모와 분리 및 이전해야하는 데이터의 양, 또한 데이터의 추가/수정이 자주 이뤄지고 있다면 물리적으로 코드와 데이터를 분리하는 것은 손이 많이 드는 작업이다.

(또한 한창 다른 업무를 할 때 이러한 작업을 하기가 쉽지않다)

 

그렇기 때문에 현재 상황에서 적절한 레벨에서 대처를 하는 것이 중요하다.

앞서 본문에서 살펴본 내용은 어떤 측면에서 보았을 때 애플리케이션 코딩을 하는 입장에서만 고려한 반쪽짜리 방법일 수 있다.

하지만 어떠한 방법을 선택함에 있어서 현재의 상황과 내가 갖고 있는 리소스를 판단하고 트레이드 오프를 잘 저울질하여 적절한 솔루션을 선택하는 것이 현명할 것이다.

댓글