Strategy Pattern과 함께하는 개발 일기

TAEYONG KIM·2024년 8월 7일
0

사이드 프로젝트에서 요구사항

간단하게 사이드 프로젝트를 소개하자면, 수제 케이크샵들을 사용자 위치 기반으로 제공해주고 있는 것이 메인 기능이다. 케이크 샵 사장님들은 자신의 케이크 샵에 대해 사장님 인증을 할 수 있는 기능을 제공하고 있다.

애플리케이션 다운로드 또는 살펴보기

기획에 따라, 백엔드 로직은 다음과 같은 Flow를 진행해야 한다.

  1. "유저는 케이크 샵에 대해 사장님 인증을 요청한다"
  2. "케이크샵과 유저 정보를 담는다"
  3. "외부 서비스에 인증 요청을 보낸다"

PHASE 1단계에서 외부 서비스는 Slack이라는 외부 메신저 툴에게 웹훅을 활용하여 요청 메시지가 전송되도록 구현을 해야 했다.

리팩토링을 하기 이전에는 확장 가능성을 고려하지 않고 그냥 구현을 했다. 쉽게 말해서 추상화에 의존하여 구현한 것이 아니라 구체적인 기능에 의존하여 개발하였다.

리팩토링 이전의 코드

import lombok.RequiredArgsConstructor;

import com.cakk.api.annotation.ApplicationEventListener;
import com.cakk.api.service.slack.SlackService;
import com.cakk.domain.mysql.event.shop.CertificationEvent;

@RequiredArgsConstructor
@ApplicationEventListener
public class CertificationEventListener {

	private final SlackService slackService;

	@Async
	@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
	public void sendMessageToSlack(CertificationEvent certificationEvent) {
		slackService.sendSlackForCertification(certificationEvent);
	}
}

객체지향 프로그래밍에 대해 조금이라도 관심이 있다면 책임, 역할, 행동 이라는 키워드에 많이 노출되었을 것이다.
CertificationEventListener에서 보면 slackService라는 구현체 자체에 의존하고 있다.

사이드 프로젝트에서는 개발자가 알고 있는 범위가 작고 변동에도 사실 코드를 그냥 고치면 되지만, 학습을 하면서 전략 패턴을 도입해보고 싶기도 했다.
이게 문제가 될 것인가? 라는 질문에 대해서는 다음과 같은 이유를 설명하고 싶다.

CertificationEventListener는 클라이언트라고 할 수 있다. 클라이언트는 SlackService에게 인증 요청을 보내줘라는 메시지를 보내고 있는데 만약, 구체적인 목적지가 변경된다면 어떻게 되는가? 예를 들어, 어드민 앱에 인증 요청을 보내야 한다던가 또는 이메일로 전송되어야할 수도 있다. 실제로, 다음 기획 논의에서 변경이 확정되었기도 하다.
코드 레벨에서의 변경이 생긴다. 정확히는 추상화된 인터페이스가 아닌 구현체에 의존하기 때문에 sendMessageToSlack 메서드의 네이밍이 변경될 뿐만 아니라, 책임에서의 구현도 변경되어야 한다. 여기서 책임이란? 메서드의 구체적인 내용이라고 정의하겠습니다.
또 변경되는 것이 있습니다. 의존하고 있는 import에서도 SlackService에 대한 의존이 제거되어야 하고, 클라이언트가 구체화를 알아야 한다는 점이 문제될 수 있다고 설명하고 싶습니다.

또다른 문제가 있습니다.
1. 목적지에 메시지를 전송하기 위한 메시지 형태를 만드는 것
2. 만들어진 메시지를 담아 외부 api를 호출하는 것

1번과 2번이 합쳐져서 slackService에서 sendMessageToSlack 메서드가 구현되었습니다.
저는 1번과 2번을 분리하고 외부 api가 무엇인지에 따라 유연하게 변경될 수 있게 Strategy로 활용하여 리팩토링하는 목표를 세웠습니다.

변경 전 코드

public void sendSlackForCertification(CertificationEvent certificationEvent) {
		if (!isEnable) {
			return;
		}

		SlackAttachment slackAttachment = new SlackAttachment();
		slackAttachment.setColor("good");
		slackAttachment.setFallback("OK");
		slackAttachment.setTitle("Request Certification");
		slackAttachment.setFields(List.of(
			new SlackField().setTitle("요청자 PK").setValue(String.valueOf(certificationEvent.userId())),
			new SlackField().setTitle("요청자 이메일").setValue(certificationEvent.userEmail()),
			new SlackField().setTitle("요청자 비상연락망").setValue(certificationEvent.emergencyContact()),
			new SlackField().setTitle("요청자 신분증 이미지").setValue(certificationEvent.idCardImageUrl()),
			new SlackField().setTitle("요청자 사업자등록증 이미지").setValue(certificationEvent.businessRegistrationImageUrl()),
			new SlackField().setTitle("요청 사항").setValue(certificationEvent.message()),
			new SlackField().setTitle("가게 이름").setValue(certificationEvent.shopName()),
			new SlackField().setTitle("가게 위치 위도").setValue(String.valueOf(certificationEvent.location().getY())),
			new SlackField().setTitle("가게 위치 경도").setValue(String.valueOf(certificationEvent.location().getX()))
		));

		SlackMessage slackMessage = new SlackMessage();

		slackMessage.setAttachments(List.of(slackAttachment));
		slackMessage.setChannel("#cs_사장님인증");
		slackMessage.setText("%s 사장님 인증 요청".formatted(profile));

		slackApi.call(slackMessage);
	}

메시지 형태를 만드는 메서드와 호출하는 메서드를 분리

//슬랙으로 전송하기 위한 메시지 추출
public SlackMessage extract(CertificationMessage certificationMessage) {
		SlackAttachment slackAttachment = new SlackAttachment();
		slackAttachment.setColor("good");
		slackAttachment.setFallback("OK");
		slackAttachment.setTitle("Request Certification");

		slackAttachment.setFields(List.of(
			new SlackField().setTitle("요청자 PK").setValue(String.valueOf(certificationMessage.userId())),
			new SlackField().setTitle("요청자 이메일").setValue(certificationMessage.userEmail()),
			new SlackField().setTitle("요청자 비상연락망").setValue(certificationMessage.emergencyContact()),
			new SlackField().setTitle("요청자 신분증 이미지").setValue(certificationMessage.idCardImageUrl()),
			new SlackField().setTitle("요청자 사업자등록증 이미지").setValue(certificationMessage.businessRegistrationImageUrl()),
			new SlackField().setTitle("요청 사항").setValue(certificationMessage.message()),
			new SlackField().setTitle("가게 이름").setValue(certificationMessage.shopName()),
			new SlackField().setTitle("가게 위치 위도").setValue(String.valueOf(certificationMessage.latitude())),
			new SlackField().setTitle("가게 위치 경도").setValue(String.valueOf(certificationMessage.longitude()))
		));

		SlackMessage slackMessage = new SlackMessage();
		slackMessage.setAttachments(List.of(slackAttachment));
		slackMessage.setChannel("#cs_사장님인증");
		slackMessage.setText("사장님 인증 요청");

		return slackMessage;
	}
//api 호출하여 슬랙 메시지를 전송
public void send(SlackMessage message) {
	if (!isEnable) {
		return;
	}

	slackApi.call(message);
}

메서드를 분리했지만, 여전히 남은 숙제

extract 메서드와 send 메서드의 구현이 변경되어야 한다면 SlackService가 아니라 새로운 Service를 만들고 해당 목적지를 위한 extract와 send를 또 구현하여 Class를 생성해야 합니다. Client가 또 새롭게 바뀐 구현체 Class를 알아야 할 의무가 있을까? 저의 생각은 알아야 할 의무가 없고, 인터페이스에 의존하도록 변경하는 게 좋은 코드라고 생각했습니다.

Java에서는 Strategy를 위해 Interface와 Abstract Method를 통해 콜백 패턴을 활용할 수 있습니다. 이렇게 되면, 해당 메서드를 람다로도 처리할 수 있고 구현체를 만들어서 Spring Container를 활용한 의존성 주입을 해줄 수 있습니다.

// 목적지의 형식에 맞게 메시지를 추출하는 Extractor
package com.cakk.external.extractor;

import com.cakk.external.vo.CertificationMessage;

public interface CertificationMessageExtractor<T> {
	T extract(CertificationMessage certificationMessage);
}
// 인증을 위한 ApiExecutor
package com.cakk.external.executor;

public interface CertificationApiExecutor<T> {
	void send(T message);
}

마지막으로, Template을 통한 메서드에서 위 인터페이스를 활용하여 로직을 수행하면 됩니다.

// 사장님 인증 요청을 위한 Template
package com.cakk.api.template;

import lombok.RequiredArgsConstructor;

import com.cakk.external.executor.CertificationApiExecutor;
import com.cakk.external.extractor.CertificationMessageExtractor;
import com.cakk.external.vo.CertificationMessage;

@RequiredArgsConstructor
public class CertificationTemplate {

	private final CertificationApiExecutor certificationApiExecutor;
	private final CertificationMessageExtractor certificationMessageExtractor;

	public void sendMessageForCertification(CertificationMessage certificationMessage) {
		this.sendMessageForCertification(certificationMessage, certificationMessageExtractor, certificationApiExecutor);
	}

	public <T> void sendMessageForCertification(
		CertificationMessage certificationMessage,
		CertificationMessageExtractor certificationMessageExtractor,
		CertificationApiExecutor certificationApiExecutor
	) {
		T extractMessage = (T)certificationMessageExtractor.extract(certificationMessage);
		certificationApiExecutor.send(extractMessage);
	}
}

이렇게 추상화에 의존하게 된다면, 인터페이스인 CertificationMessageExtractor와 CertificationApiExecutor를 통해 메시지를 전달하기만 하면 됩니다.

OCP인 개방 폐쇄 원칙을 지킬 수도 있고 의존 방향에서도 추상화에 의존하고 있는 특징을 살펴볼 수 있습니다.

그렇다면, 기존의 CertificationEventListener에서도 의존 방향이 Template으로 변경되고 메시지를 보내기만 하면 됩니다. 여기서 말하는 메시지란? 템플릿에게 사장님 인증을 위한 메시지를 보내줘를 수행하게 됩니다.

CertificationEventListener에서 변경된 코드

import lombok.RequiredArgsConstructor;

import com.cakk.api.annotation.ApplicationEventListener;
import com.cakk.api.mapper.EventMapper;
import com.cakk.api.template.CertificationTemplate;
import com.cakk.domain.mysql.event.shop.CertificationEvent;
import com.cakk.external.vo.CertificationMessage;

@RequiredArgsConstructor
@ApplicationEventListener
public class CertificationEventListener {

	private final CertificationTemplate certificationTemplate;

	@Async
	@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
	public void sendMessageToSlack(CertificationEvent certificationEvent) {
		CertificationMessage certificationMessage = EventMapper.supplyCertificationMessageBy(certificationEvent);
		certificationTemplate.sendMessageForCertification(certificationMessage);
	}
}

external-module에서 가지고 있는 CertificationMessage로 Mapper를 통해 변환한 후
Template를 통해 메시지를 전달하기만 하면 됩니다.
결과적으로, Template에게 의존하는 코드로 변경되었습니다.

결과, 기획이 변경되더라도 기존 코드는 수정되지 않는 설계

따라서 우리는 Template에서 의존하고 있는 인터페이스에 따라 새로운 구현체를 만들더라도 의존성 주입만 다른 객체로 변경해주면 되고 기존 코드는 수정되지 않게 됩니다.
이를 Configuration에서 관리할 수 있습니다.

package com.cakk.api.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import net.gpedro.integrations.slack.SlackApi;

import com.cakk.api.template.CertificationTemplate;
import com.cakk.external.executor.CertificationApiExecutor;
import com.cakk.external.executor.CertificationSlackApiExecutor;
import com.cakk.external.extractor.CertificationMessageExtractor;
import com.cakk.external.extractor.CertificationSlackMessageExtractor;

@Configuration
public class CertificationTemplateConfig {

	private final SlackApi slackApi;
	private final boolean isEnable;

	public CertificationTemplateConfig(
		SlackApi slackApi,
		@Value("${slack.webhook.is-enable}")
		boolean isEnable) {
		this.slackApi = slackApi;
		this.isEnable = isEnable;
	}

	@Bean
	public CertificationTemplate certificationTemplate() {
		return new CertificationTemplate(certificationApiExecutor(), certificationMessageExtractor());
	}

	@Bean
	public CertificationApiExecutor certificationApiExecutor() {
		return new CertificationSlackApiExecutor(slackApi, isEnable);
	}

	@Bean
	CertificationMessageExtractor certificationMessageExtractor() {
		return new CertificationSlackMessageExtractor();
	}
}

예를 들어, 어드민 앱을 활용하여 사장님 인증 요청을 보낸다고 가정할 수 있습니다.
이때, 어드민 앱을 위한 CertificationMessageExtractor, CertificationApiExecutor를 구현하여 위 클래스에서 CertificationTemplate의 생성자에 주입만 변경해주면 대응할 수 있는 코드가 완성됩니다.

만약, 구현체로 구현하여 클래스를 만들고 싶지 않다면
아래의 두가지 메서드에서 오버로딩된 아래 메서드를 호출하여 람다로 기능을 구현할수도 있습니다.

public void sendMessageForCertification(CertificationMessage certificationMessage) {
		this.sendMessageForCertification(certificationMessage, certificationMessageExtractor, certificationApiExecutor);
	}

public <T> void sendMessageForCertification(
		CertificationMessage certificationMessage,
		CertificationMessageExtractor certificationMessageExtractor,
		CertificationApiExecutor certificationApiExecutor
	) {
		T extractMessage = (T)certificationMessageExtractor.extract(certificationMessage);
		certificationApiExecutor.send(extractMessage);
	}

람다 예시

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendMessageToSlack(CertificationEvent certificationEvent) {
	CertificationMessage certificationMessage = EventMapper.supplyCertificationMessageBy(certificationEvent);
	certificationTemplate.sendMessageForCertification(certificationMessage, message -> {
			// 메시지 추출 로직 수행..
			return message;
		}, message -> {
			// 외부 서비스 호출 로직 수행...
	});
}

마무리

이것이 좋은 설계인가? 에 대해서는 논리적으로 설명할 수 있으나 이론을 학습하고 프로젝트에 도입해본 것은 처음이라 운영을 해보면서 추가적인 문제점이나 한계를 마주칠 수 있을 것 같습니다. 명확한 것은 인터페이스를 활용한 추상화와 템플릿 제공, 전략의 변경에 따라 구현체를 교체할 수 있으며 클라이언트(기능을 호출하는 주체)는 구체화에 의존하지 않고 코드의 수정에는 닫혀 있고 확장에는 열려있는 설계를 했다고 할 수 있을 것 같습니다.

자세한 코드를 보고 싶다면 Github을 방문해주세요!

profile
백엔드 개발자의 성장기

0개의 댓글