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

2024. 9. 8. 14:23Technology/Others

Event 페이지나 Lounge 페이지에서 일어나는 행위를 Log로 남겨야 하는 요구사항이 있었다. 여러 가지 방법이 있었겠지만, 계속 뒤바뀌는 상황에서 일단 RDB에 적당히 일반화된 포맷으로 저장하도록 결정했다. 하지만, EventLog와 LoungeLog는 굉장히 닮아있었고 심지어는 이들을 기록하는 비즈니스 로직마저 닮은 점이 많았다. 우리 팀은 Event와 Lounge를 Page라는 추상적인 개념으로 인식할 수 있었고, PageLog와 PageLogService 라는 추상화를 적용해 보기로 했다.

다음 예제는 위 상황을 재현할 수 있도록 가정한 간단한 케이스이다.

 


 

도메인 지식이 있어야 하는 Page 개념 말고, 간단하게 Email과 Sms를 발송해야 한다는 경우로 간소화 해보자. 발송할 주소와 메시지를 저장해 두고, 이를 하나씩 차례로 발송하는 로직이 있다고 가정했다.

간단하게 Email부터 EmailDispatch 라는 도메인을 만들어서 발송하는 로직까지 만들었다.

EmailDispatch.kt

@Entity
class EmailDispatch(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    @Embedded
    var account: EmailAddress,

    var message: String,

    var purpose: EmailPurpose,
) {
    companion object {
        fun create(account: EmailAddress, message: String, purpose: EmailPurpose) =
            EmailDispatch(account = account, message = message, purpose = purpose)
    }

    fun buildRequest() = "Sending email to ${account.account} with message: $message"
}

EmailAddress.kt

@Embeddable
data class EmailAddress(
    val account: String,
)

EmailPurpose.kt

enum class EmailPurpose {
    NEWSLETTER,
}

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

 

같은 방법으로 Sms에 대한 SmsDispatch와 SmsService도 구현한다.

SmsDispatch.kt

@Entity
class SmsDispatch(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    @Embedded
    var account: PhoneNumber,

    var message: String,

    var purpose: SmsPurpose,
) {
    companion object {
        fun create(account: PhoneNumber, message: String, purpose: SmsPurpose) =
            SmsDispatch(account = account, message = message, purpose = purpose)
    }

    fun buildRequest() = "Sending sms to ${account.account} with message: $message"
}

PhoneNumber.kt

@Embeddable
data class PhoneNumber(
    val account: String,
)

SmsPurpose.kt

enum class SmsPurpose {
    PASSWORD_RESET,
    ACCOUNT_ACTIVATION,
}

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

 

위 로직들을 테스트하는 코드까지 만들면 요구사항 충족 완료.

EmailDispatchServiceTest.kt

@SpringBootTest
@Transactional
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class EmailDispatchServiceTest(
    private val emailService: EmailService,
) {
    @Test
    fun `register and send email`() {
        val account = "eunzin.park@gmail.com"

        // register
        val emailDispatch: EmailDispatch = emailService.registerEmail(account, "Hello, World!", EmailPurpose.NEWSLETTER)

        emailDispatch.id shouldNotBe null

        // send
        val result: String = emailService.sendEmail(account)

        result shouldBe "Sending to $account with message: Hello, World!"
    }
}

SmsDispatchServiceTest.kt

@SpringBootTest
@Transactional
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
class SmsDispatchServiceTest(
    private val smsService: SmsService,
    private val smsRepository: SmsRepository,
) {
    @Test
    fun `sendEmail()`() {
        val account = "01012345678"

        // register
        val smsDispatch = SmsDispatch.create(PhoneNumber(account), "Hello, World!", SmsPurpose.ACCOUNT_ACTIVATION)
            .also { smsRepository.save(it) }

        smsDispatch.id shouldNotBe null

        // send
        val result = smsService.sendSms(account)

        result shouldBe "Sending to $account with message: Hello, World!"
    }
}

 

 


 

개발자들이 가장 없애고 싶어하는 중복이 많이 보인다. 애초에 EmailDispatch, SmsDispatch 부터가 너무나 닮아있지 않은가. Dispatch라는 추상 class를 구성해 보자.

1단계. EmailDispatch와 SmsDispatch의 닮아있는 필드 추상화

01

EmailDispatch의 account와 SmsDispatch의 account는 우리에게 익숙한 주소 값처럼 핵심 값과 함께 부가 정보들이 들어갈 수 있다. 그래서 Account라는 Embeddable한 abstract class로 뽑아내어 중복을 최소화 했다.

Account.kt

@Embeddable
abstract class Account(
    open var value: String,
)

EmailAddress.kt

class EmailAddress(
    @Column(name = "_value")
    override var value: String,
) : Account(value)

PhoneNumber.kt

class PhoneNumber(
    @Column(name = "_value")
    override var value: String,
) : Account(value)

 

또한 EmailDispatch의 purpose와 SmsDispatch의 purpose도 동일한 목적일 때도 있고, 서로 다른 목적일 때도 있을 것이다. 그래서 EmailPurpose와 SmsPurpose enum class들이 Purpose라는 interface로 하나의 타입으로 정의되도록 뽑아냈다.

Purpose.kt

interface Purpose

EmailPurpose.kt

enum class EmailPurpose : Purpose {
    NEWSLETTER,
}

SmsPurpose.kt

enum class SmsPurpose : Purpose {
    PASSWORD_RESET,
    ACCOUNT_ACTIVATION,
}

 

2단계. EmailDispatch와 SmsDispatch를 추상화하는 Dispatch 구성

동일한 필드부터 닮아있는 필드까지 Dispatch 라는 추상 클래스로 그려볼 수 있다.

Dispatch.kt

@MappedSuperclass
abstract class Dispatch<A: Account, P: Purpose>(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    @Embedded
    open var account: A,

    var message: String,

    @Enumerated(EnumType.STRING)
    var purpose: P,
) {
    override fun equals(other: Any?): Boolean {
        if (this === other) return true

        if (other !is Dispatch<*,*>) return false

        if (id == null || other.id == null) return false

        return id == other.id
    }

    override fun hashCode(): Int = id?.hashCode() ?: 0
}

 

이렇게 추상화하면 EmailDispatch와 SmsDispatch가 아래와 같이 변경된다.

EmailDispatch.kt

@Entity
class EmailDispatch(
    id: Long? = null,
    account: EmailAddress,
    message: String,
    purpose: EmailPurpose,
) : Dispatch<EmailAddress, EmailPurpose>(id, account, message, purpose) {
    companion object {
        fun create(account: EmailAddress, message: String, purpose: EmailPurpose) =
            EmailDispatch(account = account, message = message, purpose = purpose)
    }
    
    fun buildRequest() = "Sending to ${account.account} with message: $message"
}

SmsDispatch.kt

@Entity
class SmsDispatch(
    id: Long? = null,
    account: PhoneNumber,
    message: String,
    purpose: SmsPurpose,
) : Dispatch<PhoneNumber, SmsPurpose>(id, account, message, purpose) {
    companion object {
        fun create(
            account: PhoneNumber,
            message: String,
            purpose: SmsPurpose,
        ) = SmsDispatch(account = account, message = message, purpose = purpose)
    }
}

 

리팩토링을 했으면 문제가 없는지 테스트 코드로 확인을 해야겠지?


깔끔하게 성공했다.

 


 

사실 이 정도 추상화는 적용해 봤을 법하다. 하지만 난 아직도 EmailService와 SmsService의 닮은 점을 뽑아내고 싶었다. 왜냐하면 똑같이 Dispatch를 만들고 register()하며, 똑같이 Dispatch에 대해 조회해 와서 send()하는 부분이 템플릿처럼 구성되면 좋겠다 싶었거든.

위 부분까지 추상화하는 방법을 적용해 보니 코드의 중복도 말끔히 없어지고, Dispatch를 상속받는 다른 개념이 추가되어도 충분히 조립하여 사용할 수 있는 형태가 됐다.

그 방법은 다음 편에 계속! 🤭

 


 

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

 

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

T라 미숙해 (feat. 제네릭, 어디까지 써봤니?) - 1 T라 미숙해 (feat. 제네릭, 어디까지 써봤니?) - 1Event 페이지나 Lounge 페이지에서 일어나는 행위를 Log로 남겨야 하는 요구사항이 있었다. 여러 가지

zins.tistory.com

 

반응형