SOLID 하게 리팩토링 (feat.템플릿 콜백 패턴)

komment·2024년 8월 31일
10

2024 개발 일지

목록 보기
6/6
post-thumbnail

서론

  사이드 프로젝트를 하다보면 온전히 설계나 개발에 집중하기 힘들다. 소수의 인원이 팀을 이루기 때문에 누구는 개발을 하며 디자인을 하고, 누구는 개발을 하며 프로젝트 관리를 한다. 그러면서 회사 업무까지 병행 해야하니 주어진 시간은 정말 한정적이라고 생각한다. 이런 핑계로 설계와 개발에 큰 힘을 쏟지 않았고, 어느 순간 보니 생각보다 더 난잡한 코드가 되어 있었다.

  태용님과 개발에 앞서 도메인 모델 패턴을 활용하여 유지보수가 용이한 코드를 작성해보자 했는데, 서비스 런칭 후 내 코드를 살펴보니 그저 트랜잭션 스크립트 패턴 그 자체의 코드였다. 나중에 여유 있을 때 리팩토링 하자! 라고 생각하다가 현재 다니는 회사의 개발팀 팀장님께서 하신 말씀이 떠올랐다.

르블랑의 법칙 (Leblance’s Law)

Later equals never 나중은 결코 오지 않는다.

  그렇게 전체적인 리팩토링이 시작됐다. 우리가 수립한 리팩토링 리스트는 다음과 같다.

  • 도메인 모듈을 제외한 나머지 모듈에 Kotlin을 도입하자.
  • Commend 목적의 코드부터 Query 목적의 코드 순으로 도메인 모델 패턴을 점차 적용하자.
  • Service 레이어와 Repository 레이어 사이에서 사용되던 Reader와 Writer를 없애도 Facade 패턴을 적용하자.
  • 인터페이스와 클래스에 대하여 SOLID를 준수하는지 검토하자.

  리팩토링은 전체적으로 동시에 진행 중이고, 이번 포스팅에서는 리팩토링 한 코드들 중 Slack, Email 등 메시지를 추출하고 전송하는 로직에 대한 리팩토링에 대해 다룰 예정이다.

(해당 포스팅에서 다루는 리팩토링 이전에 태용님께서 1차 리팩토링을 진행하셨고, 관련 내용이 개인 블로그 포스트에 올라와 있다.)

SOLID 지키기

  케이크크는 회원가입 시 이메일을 저장한다. 소셜 로그인 중 카카오 로그인에서 이메일을 가져올 수 없기 때문에 이메일 인증을 위해 인증 코드 발급 및 인증 프로세스를 밟는다. 기존의 구현 상태는 대략적으로 다음과 같고, 외부 의존성이라 판단돼 external 모듈에 구현했다.

@Service
class VerificationEmailService(
	. . .
) {

	fun send(reciever: String, message: String) {
    	val message = extractMessage(reciever, message)
        
        mailSender.send(message)
    }
    
    private fun extractMessage(reciever: String, message: String) {
    	/*
         *  생략
         */
        
        return mimeMessage
    }
}

  일단 겉으로 보이는 문제점을 먼저 살펴보자.

1. SRP 위반

  메시지에 대한 동작은 메시지를 만드는 동작과 메시지를 전송하는 동작, 두 동작으로 이루어진다. 하지만 Service라고 이름 지은 클래스에서 두 가지 동작에 대한 책임을 모두 짊어지고 있다. 이는 하나의 클래스는 하나의 책임을 가져야 한다는 SRP를 위반하고 있다. (추가적으로 해당 로직은 Service 비즈니스라고 하기 조금 애매한 것 같다.)

@Component
class VerificationEmailMessageExtractor(
	// 생략
) {

	override fun extract(message: VerificationMessage): MimeMessage {
		val mimeMessage = mailSender.createMimeMessage()

		// 생략

		return mimeMessage
	}
}

@Component
class VerificationEmailMessageSender(
	// 생략
) : MessageSender<MimeMessage> {

	override fun send(message: MimeMessage) {
		try {
			mailSender.send(message)
		} catch (e: RuntimeException) {
			throw CakkException(ReturnCode.SEND_EMAIL_ERROR)
		}
	}
}

  두 클래스가 각각 하나의 동작에 대한 책임을 가지면서 SRP를 준수 할 수 있게 되었다.

2. DIP 위반

  DIP. 의존관계 역전의 원칙으로, 간단하게 두 가지만 지키면 된다.

  • 상위 모듈은 하위 모듈의 구현에 의존하면 안된다.
  • 하위 모듈은 상위 모듈에 정해놓은 추상 타입에 의존해야 한다.

  다음은 위에서 구현한 구현 클래스를 활용하는 API 모듈 내 코드다.

@ApplicationEventListener
class EmailSendEventListener(
    private val messageExtractor: VerificationEmailMessageExtractor,
	private val messageSender: VerificationEmailMessageSender
) {

	@Async
	@EventListener
	fun sendEmailIncludeVerificationCode(event: EmailWithVerificationCodeSendEvent) {
		val verificationMessage = EventMapper.supplyVerificationMessageBy(event)
        val message = messageExtractor.extract(verificationMessage)
        
		messageSender.send(message);
	}
}

  일단 가장 먼저 보이는 문제는 인터페이스가 아닌 구현 클래스를 주입 받아 활용하고 있다는 점이다. 따라서 MessageExtractor와 MessageSender에 대해 추상화를 진행해보려 한다. 구현에 앞서, 현재 활용하고 있는 메시지 관련 플랫폼은 Email과 Slack이 있다. 또, 나중에 어떻게 확장될지 모르는 상태다. 따라서 추상화의 기준을 세워보았다.

  • MessageExtractor
    • 기준1: 플랫폼
    • 기준2: 메시지 추출 목적
  • MessageSender
    • 기준: 플랫폼

  기준에 따라 설계한 클래스 다이어그램은 다음과 같다.

  자, 이렇게 추상화 한 구조를 적용해보자.

@ApplicationEventListener
class EmailSendEventListener(
	@Qualifier("verificationCodeMimeMessageExtractor")
    private val messageExtractor: MessageExtractor,
    @Qualifier("emailMessageSender")
	private val messageSender: MessageSender
) {

	@Async
	@EventListener
	fun sendEmailIncludeVerificationCode(event: EmailWithVerificationCodeSendEvent) {
		val verificationMessage = EventMapper.supplyVerificationMessageBy(event)
        val message = messageExtractor.extract(verificationMessage)
        
		messageSender.send(message);
	}
}

  여러 Bean이 존재하기 때문에 @Qualifier를 통해 어떤 Bean인지 지정해주었다. 하지만 아직 한 가지 문제점이 남았다. 아직 상위 모듈인 API 모듈이 하위 모듈인 External 모듈에 의존하고 있다. 이를 해결하기 위해 두 가지 방법을 강구했다.

1. 인터페이스를 상위 모듈로 이동시키기

  External 모듈에 있는 추상 타입들을 Api 모듈에 이동시켜보았다. 이제 하위 모듈이 상위 모듈에게 의존하게 되었다. 하지만 문제가 있다. 이렇게 되면 External 모듈에서 Api 모듈을 implementation 해야 한다.

  그렇게 되면 runtimeClassPath 부분이 살쪄버린 External 모듈을 만날 수 있게 된다. 이 밖에도, 다른 모듈에서 External 모듈을 활용할 때 곤란한 상황이 발생한다.

2. 객체 생성 및 주입의 책임을 상위 모듈에게 넘기기

  Spring Boot에서 Bean을 생성하는 전략은 두 가지다. 하나는 클래스 상단에 @Component 어노테이션을 붙이는 방법이고, 다른 하나는 Config 파일에 직접 등록하는 방법이다. 기존엔 @Compeont를 활용하여 External 모듈에게 빈 생성 및 주입의 책임을 주었지만, Api 모듈에 Config 클래스를 구현하여 상위 모듈에서 직접 Bean 생성 및 주입을 책임지면 상위 모듈이 하위 모듈에 의존하는 DIP 위반 문제가 해결되지 않을까 생각이 들었다.

  이 방법 또한 트레이드 오프는 있었다. 바로 spring-boot-starter-mail 의존성과 slack-webhook 의존성을 Api 모듈에 추가해야 한다는 것이다. 하지만 고민 끝에 2번 방법을 선택하기로 결정했다.

@Configuration
class MessageConfig(
	// 생략
) {

	@Bean
	fun certificationMessageExtractor(): MessageExtractor {
		return CertificationSlackMessageExtractor();
	}

	@Bean
	fun errorAlertMessageExtractor(): MessageExtractor {
		return ErrorAlertSlackMessageExtractor();
	}

	@Bean
	fun verificationCodeMimeMessageExtractor(): MessageExtractor {
		return VerificationCodeMimeMessageExtractor(javaMailSender, senderEmail);
	}

	@Bean
	fun emailMessageSender(): MessageSender {
		return EmailMessageSender(javaMailSender);
	}

	@Bean
	fun slackMessageSender(): MessageSender {
		return SlackMessageSender(slackApi, isEnable);
	}
}

  또, 해당 문제를 해결하면서 모듈 설계에 문제가 있음을 깨달았다. 모듈 설계에 대한 리팩토링은 추후에 태용님과 이야기 하며 진행 할 예정이다.

템플릿 콜백 패턴 적용하기

  지금까지 리팩토링 한 메시지 추출 및 전송 기능에 대하여 코드 재사용성을 높이고, 특정 동작을 유연하게 변경할 수 있는 구조로 한번 더 리팩토링 하려 한다. 리팩토링에 앞서 템플릿 콜백 패턴에 대해 살짝 알아보자.

템플릿 콜백 패턴 (Template Callback)

  템플릿 콜백 패턴에 앞서 전략 패턴에 대해 먼저 알아야 한다. 전략 패턴이란, 객체들이 할 수 있는 행위 각각에 대하여 전략 클래스를 생성하고, 유사한 행위들을 인터페이스로 캡슐화 하여 정의하는 패턴이다. 객체의 행위를 동적으로 변경하고 싶은 경우, 전략을 바꿔주기만 함으로써 행위를 유연하게 확장할 수 있다.

  템플릿 콜백 패턴은 전략 패턴과 다르게, 전략 클래스를 필드에 가지고 있지 않고, 메서드 파라미터로 넘겨받는 방식이다. 따라서 Context 를 실행할 때마다 Strategy 를 변경할 수 있다. 즉, 일반적인 전략 패턴보다 유연하게 전략 변경이 가능하다.

  RestTemplate, JdbcTemplate 등 많은 라이브러리에서 이 패턴을 활용하고 있다.

Template 구성하기

  위에서 정의한 MessageExtractor 인터페이스와 MessageSender 인터페이스를 바탕으로 Template을 쉽게 구현할 수 있다.

class MessageTemplate {

	fun <T, U> sendMessage(
		message: T,
		messageExtractor: MessageExtractor<T, U>,
		messageSender: MessageSender<U>,
	) {
		val extractMessage: U = messageExtractor.extract(message)
		messageSender.send(extractMessage)
	}
}

  sendMessage 메서드는 고정된 템플릿 로직을 수행한다. 파라미터로 받는 MessageExtractorMessageSender을 통해 각각 메시지를 추출하고 전송하는 로직을 외부에서 정의할 수 있다. 이들은 sendMessage 메서드가 호출하는 콜백 역할을 하게 된다.

  다시 앞으로 돌아와 EmailSendEventListener에 적용하는 코드를 확인하자.

@ApplicationEventListener
class EmailSendEventListener(
	private val messageTemplate: MessageTemplate,
	@Qualifier("verificationCodeMimeMessageExtractor") 
    private val messageExtractor: MessageExtractor,
	@Qualifier("emailMessageSender") 
    private val messageSender: MessageSender
) {

	@Async
	@EventListener
	fun sendEmailIncludeVerificationCode(event: EmailWithVerificationCodeSendEvent) {
		val verificationMessage = supplyVerificationMessageBy(event);
		messageTemplate.sendMessage(verificationMessage, messageExtractor, messageSender);
	}
}

  여기까지 간단하게(?) 추상화와 디자인 패턴을 적용해보았다.



포스팅과 관련된 코드는 케이크크 서버 Github에 저장돼 있습니다.

profile
안녕하세요. 서버 개발자 komment 입니다.

0개의 댓글