T라 미숙해 (feat. 제네릭, 어디까지 써봤니?) - 2

2024. 9. 8. 18:54Technology/Others

T라 미숙해 (feat. 제네릭, 어디까지 써봤니?) - 1

 

T라 미숙해 (feat. 제네릭, 어디까지 써봤니?) - 1

Event 페이지나 Lounge 페이지에서 일어나는 행위를 Log로 남겨야 하는 요구사항이 있었다. 여러 가지 방법이 있었겠지만, 계속 뒤바뀌는 상황에서 일단 RDB에 적당히 일반화된 포맷으로 저장하도록

zins.tistory.com

 


 

도메인을 추상화하기 시작한 이유는 이를 활용하는 핵심 비즈니스 로직이 동일했기 때문이다. 도메인 설계만 하고 "어? 비슷하게 생겼네."하고 무턱대고 추상화하는건 불필요한 것 같다. 추상화는 잘못쓰거나 잘 못 쓸 경우, 그러니까 추상화된 대상의 내부 구성과 복잡도를 이해하지 못하면 오히려 비효율적인 코드가 된다.

우리 팀의 상황은 예제를 기준으로 EmailService와 SmsService의 중복을 제거하고 간결하고 확장성 있게 만드는 것이었다. "추상화 짱이야! 적용해!"가 아니라, "이런 것까지 추상화가 되는구나."에 초점을 맞추었다는 사실을 강조해 본다.

 


 

지난 번에 예제로 구성한 핵심 개념인 Dispatch를 추상화하여, EmailDispatch와 SmsDispatch의 중복을 최소화 해보았다. 이제 아래 핵심 비즈니스 로직의 닮은 점을 추상화 해보겠다.

기존 비즈니스 로직은 다음과 같다.

EmailService.kt

@Service
@Transactional
class EmailService(
    private val emailRepository: EmailRepository,
) {
    fun registerEmail(account: String, message: String, purpose: EmailPurpose): EmailDispatch {
        val emailAddress = EmailAddress(account)

        return EmailDispatch.create(emailAddress, message, purpose)
            .also { emailRepository.save(it) }
    }

    fun sendEmail(account: String): String {
        val email = emailRepository.findByAccount(EmailAddress(account))
            ?: throw IllegalArgumentException("Email not found")

        // print하는 것으로 api를 호출하여 실제 발송하는 것을 대체한다.
        return email.buildRequest()
            .also { println(it) }
    }
}

SmsService.kt

@Service
@Transactional
class SmsService(
    private val smsRepository: SmsRepository,
) {
    fun registerSms(account: String, message: String, purpose: SmsPurpose): SmsDispatch {
        val phoneNumber = PhoneNumber(account)

        return SmsDispatch.create(phoneNumber, message, purpose)
            .also { smsRepository.save(it) }
    }

    fun sendSms(account: String): String {
        val sms = smsRepository.findByAccount(PhoneNumber(account))
            ?: throw IllegalArgumentException("Email not found")

        // print하는 것으로 api를 호출하여 실제 발송하는 것을 대체한다.
        return sms.buildRequest()
            .also { println(it) }
    }
}

 

자, 여기서 일단 추상화된 Dispatch와 Account를 활용하는 Repository를 추상화 해보자. 

EmailRepository와 SmsRepository가 구현하는 공통인 JpaRepository를 상속 받으면 save()를 쓸 수 있다.

DispatchRepository.kt

@NoRepositoryBean
interface DispatchRepository<D : Dispatch<A, P>, A : Account, P : Purpose> : JpaRepository<D, Long>

EmailRepository.kt

interface EmailRepository : DispatchRepository<EmailDispatch, EmailAddress, EmailPurpose> {
    fun findByAccount(account: EmailAddress): EmailDispatch?
}

SmsRepository.kt

interface SmsRepository : DispatchRepository<SmsDispatch, PhoneNumber, SmsPurpose> {
    fun findByAccount(account: PhoneNumber): SmsDispatch?
}

 

그리고 추상화된 Account도 동일하게 조회하니 DispatchRepository에 구현한다.

DispatchRepository.kt

@NoRepositoryBean
interface DispatchRepository<D : Dispatch<A, P>, A : Account, P : Purpose> : JpaRepository<D, Long> {
    fun findByAccount(account: A): D?
}

EmailRepository.kt

interface EmailRepository : DispatchRepository<EmailDispatch, EmailAddress, EmailPurpose> {
    override fun findByAccount(account: EmailAddress): EmailDispatch?
}

SmsRepository.kt

interface SmsRepository : DispatchRepository<SmsDispatch, PhoneNumber, SmsPurpose> {
    override fun findByAccount(account: PhoneNumber): SmsDispatch?
}

 

이렇게 한 단계 리팩토링 했으니, 다시 한 번 테스트 코드를 돌려본다.

 


 

처음 만든 EmailService, SmsService는 기능은 비슷해보이지만 타입이 다 달라서 이걸 추상화할 방법이 없었는데, 타입 파라미터를 도입해서 이 두 개의 서비스에 등장하는 모든 타입(Dispatch, DispatchRepository)를 추상화 할 수 있었다. 이제는 EmailService, SmsService 코드도 중복을 제거하고 추상화된 슈퍼 클래스를 만들 수 있게 됐다. 다시 말하면 코드의 구조는 비슷하지만 클래스가 달랐던 상황에서 그 클래스들에 다형성을 적용하여 코드의 구조가 비슷한 Service 쪽까지 추상화할 수 있게된 것이다.

그 클래스 이름을 DispatchService라고 하자.

DispatchService 파일을 만들고, 추상화된 재료들을 명시해준다. DispatchService가 EmailService와 SmsService의 중복을 제거하고 재사용 가능한 클래스로 만드는 것이 목적이다.

abstract class DispatchService<D : Dispatch<A, P>, A : Account, P : Purpose>(
    private val dispatchRepository: DispatchRepository<D, A, P>,
)

그리고 각 EmailService, SmsService에서 공통 로직을 뽑아 정의한다. 이 때, EmailDispatch와 SmsDipatch를 만드는 것은 각 도메인의 companion object에서 구현되어 있기 때문에 EmailService, SmsService에서 각자 EmailDispatch와 SmsDipatch를 만들 수 있도록 createDispatch()이라는 abstract fun으로 뽑아준다.

abstract class DispatchService<D : Dispatch<A, P>, A : Account, P : Purpose>(
    private val dispatchRepository: DispatchRepository<D, A, P>,
) {
    fun register(
        account: A,
        message: String,
        purpose: P,
    ): D {
        val dispatch: D = createDispatch(account, message, purpose)
        return dispatchRepository.save(dispatch)
    }

    fun send(account: A): String {
        val dispatch: D =
            dispatchRepository.findByAccount(account)
                ?: throw IllegalArgumentException("$account not found")

        return dispatch.buildRequest()
            .also { println(it) }
    }

    // EmailService, SmsService에서 각자 구현해야 할 로직
    abstract fun createDispatch(
        account: A,
        message: String,
        purpose: P,
    ): D
}

 

최소한의 구현 부분만 남기고 모두 추상화가 가능해졌다. 이 DispatchService를 EmailService, SmsService가 상속받으면 아래처럼 간결해진다.

EmailService.kt

@Service
@Transactional
class EmailService(
    emailRepository: EmailRepository,
) : DispatchService<EmailDispatch, EmailAddress, EmailPurpose>(emailRepository) {
    override fun createDispatch(
        account: EmailAddress,
        message: String,
        purpose: EmailPurpose,
    ): EmailDispatch {
        return EmailDispatch.create(account, message, purpose)
    }
}

SmsService.kt

@Service
@Transactional
class SmsService(
    smsRepository: SmsRepository,
) : DispatchService<SmsDispatch, PhoneNumber, SmsPurpose>(smsRepository) {
    override fun createDispatch(
        account: PhoneNumber,
        message: String,
        purpose: SmsPurpose,
    ): SmsDispatch {
        return SmsDispatch.create(account, message, purpose)
    }
}

 

EmailService와 SmsService 모두 필요한 @Transactional 애노테이션도 DispatchService로 올렸다.

DispatchService.kt

@Transactional
abstract class DispatchService<D : Dispatch<A, P>, A : Account, P : Purpose>(
    private val dispatchRepository: DispatchRepository<D, A, P>,
) {
    // ..생략
}

EmailService.kt

@Service
class EmailService(
    emailRepository: EmailRepository,
) : DispatchService<EmailDispatch, EmailAddress, EmailPurpose>(emailRepository) {
    // ..생략
}

SmsService.kt

@Service
class SmsService(
    smsRepository: SmsRepository,
) : DispatchService<SmsDispatch, PhoneNumber, SmsPurpose>(smsRepository) {
    // ..생략
}

 

리팩토링 후 테스트 코드까지 성공

 


 

다시 한 번 강조하지만 무조건 중복이 있을 때 추상화를 써야하는 것이 아니다. 우리 팀도 일단 중복이 보여도 구현해뒀고, 이를 어떻게 보완 혹은 개선해 볼 수 있을까 고민한 끝에 진행했던 방법이다. 이런 방법도 있다는 것을 알고, 적절한 상황에 적용하여 활용하는 경험은 워낙 짜릿하니 이를 공유할 수 있길 바라는 마음에 작성해보았다.

이 시리즈는 놀랍게도 3편이다. 다음 편에서는 Reflection을 활용한 EmailDispatch와 SmsDispatch의 companion object에 구현된 create()까지, 그러니까 DispatchService의 createDispatch()까지 추상화를 해볼 예정이다. 진짜 제네릭을 극한까지 사용하면 이것도 가능하다.🤭

 

 

반응형