팩토리 메서드 패턴
올해 초 팩토리 메서드 패턴에 대한 글을 쓴 적이 있다. 다시 설명하자면, 객체 생성을 캡슐화하여 서브클래스에서 어떤 클래스의 인스턴스를 생성할지 결정권을 넘겨주는 패턴이다. 객체 생성을 추상화하여 서브클래스마다 다른 구상 클래스의 인스턴스를 생성하기 위해 사용한다.
@Setter
@Getter
@MappedSuperclass
public abstract class BaseEntity {
private String email;
private LocalDateTime createdAt;
}
@Entity
@Getter
@Setter
public class Member extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "member_seq")
@SequenceGenerator(name = "member_seq", sequenceName = "member_sequence", allocationSize = 1)
private Long id;
private String name;
private String password;
@Enumerated(value = EnumType.STRING)
private Platform platform;
}
public enum Platform {
NAVER, KAKAO, GOOGLE;
}
BaseEntity를 상속받는 Member Entity가 있다. @Setter를 열어두어 여기저기서 변동되게 하는 것은 좋지 않지만 편의를 위해 접근 가능하도록 열어두었다.
@Repository
@RequiredArgsConstructor
public class MemberRepository {
private final EntityManager em;
public void save(Member member) {
em.persist(member);
}
public Member find(Long id) {
return em.find(Member.class, id);
}
}
Member를 디비에 저장하기 위한 Repository도 있다.
public interface MemberService {
Long createMember(Member member);
Member findMemberById(Long memberId);
}
Member Entity를 보면 Platform이라는 enum 클래스를 필드로 가지고 있다. 이 서비스는 네이버, 카카오 그리고 구글을 이용하여 회원 활동을 할 수 있다. 회원 생성, 찾기라는 큰 틀은 같지만 내부의 로직이 조금씩 다르다면 각 플랫폼 별로 처리할 Service가 필요하다.
@Service
@RequiredArgsConstructor
public class KakaoMemberService implements MemberService {
private final MemberRepository memberRepository;
@Override
public Long createMember(Member member) {
member.setPlatform(Platform.KAKAO);
memberRepository.save(member);
return member.getId();
}
@Override
public Member findMemberById(Long memberId) {
return memberRepository.find(memberId);
}
}
@Service
@RequiredArgsConstructor
public class GoogleMemberService implements MemberService {
private final MemberRepository memberRepository;
@Override
public Long createMember(Member member) {
member.setPlatform(Platform.GOOGLE);
memberRepository.save(member);
return member.getId();
}
@Override
public Member findMemberById(Long memberId) {
return memberRepository.find(memberId);
}
}
@Service
@RequiredArgsConstructor
public class NaverMemberService implements MemberService {
private final MemberRepository memberRepository;
@Override
public Long createMember(Member member) {
member.setPlatform(Platform.NAVER);
memberRepository.save(member);
return member.getId();
}
@Override
public Member findMemberById(Long memberId) {
return memberRepository.find(memberId);
}
}
MemberService 인터페이스를 상속받은 각 플랫폼 별 Service들이 있다. 회원 생성이라는 목적과 결과는 같지만 내부의 로직이 조금 다르다. 회원이 플랫폼을 입력하지 않고 각 Service에서 회원 생성 시에 플랫폼 정보를 지정해 준다. 사실 회원이 입력하지 않으면 어떤 플랫폼인지 구분하기 위한 인자는 어떻게 처리하냐고 물어볼 수도 있는데 이 서비스에서는 그걸 판단하는 전처리 로직이 있다고 치자. 보통 웹 서비스에서는 이 Service들을 이용하여 Controller에서 로직을 처리한다. 이때 이 Service들을 특정 기준에 따라 구분하여 인스턴스를 생성해 줄 팩토리가 필요하다.
@Service
@RequiredArgsConstructor
public class MemberServiceFactory {
private final KakaoMemberService kakaoMemberService;
private final NaverMemberService naverMemberService;
private final GoogleMemberService googleMemberService;
public MemberService createMemberService(Platform platform) {
switch (platform) {
case KAKAO:
return kakaoMemberService;
case NAVER:
return naverMemberService;
case GOOGLE:
return googleMemberService;
default:
return null;
}
}
}
MemberService 인스턴스를 반환하는 팩토리(공장)이다. Platform을 인자로 받아 KAKAO면 KakaoMemberService를, NAVE R면 NaverMemberService의 인스턴스를 반환한다. 이 MemberServiceFactory 클래스를 사용하여 Controller에서 로직을 처리하는 것이 일반적인 팩토리 메서드 패턴 이용 예시이다.
이렇게 되면 Controller는 각 플랫폼 별 Service들의 구체적인 구현체를 알 필요 없이 그냥 MemberService라는 것 하나만 알고 있으면 된다. 어떤 인스턴스를 이용하든 간에 그건 팩토리가 주는 대로 사용하면 될 일이다. 이렇게 되면 추후 다른 플랫폼이 추가되어도 간단하게 코드를 수정할 수 있다.
정말 그런지 테스트 해보자.
@ExtendWith(SpringExtension.class)
@SpringBootTest
class MemberServiceFactoryTest {
@Autowired
MemberServiceFactory memberServiceFactory;
@Autowired
KakaoMemberService kakaoMemberService;
@Autowired
NaverMemberService naverMemberService;
@Test
public void 회원_서비스_인스턴스_생성_팩토리_테스트() throws Exception {
//given
Platform kakao = Platform.KAKAO;
//when
MemberService memberService = memberServiceFactory.createMemberService(kakao);
//then
assertThat(memberService).isEqualTo(kakaoMemberService);
}
@Test
@Transactional
public void 회원_생성() throws Exception {
//given
Platform kakao = Platform.KAKAO;
Member member = new Member();
member.setName("louis");
member.setPassword("pwd123!");
member.setEmail("abc@email.com");
member.setCreatedAt(LocalDateTime.now());
//when
MemberService memberService = memberServiceFactory.createMemberService(kakao);
Long memberId = memberService.createMember(member);
Member findMember = memberService.findMemberById(memberId);
//then
assertThat(memberId).isEqualTo(1);
assertThat(findMember.getPlatform()).isEqualTo(Platform.KAKAO);
}
}
KAKAO 플랫폼을 넘겼을 때 정말 KakaoMemberService의 인스턴스를 반환하는지 점검하는 테스트 하나, 정말로 KakaoMemberService 인스턴스를 사용하여 Member를 생성하고 있는 건지 점검하는 테스트 하나를 작성했다.
결과는 당연하게도 성공이다. 역설적으로 테스트 실패를 의도하여 한번 더 점검해 보겠다. 회원_생성 테스트에서 예상 플랫폼을 GOOGLE로 변경했다.
정말로 KakaoMemberService를 이용하고 있다.
여기까지가 자바 코드를 이용한 팩토리 메서드 패턴의 예시와 사용 이유이다. 이미 추상화를 잘했지만 여전히 문제가 되는 부분이 있다. 다른 플랫폼을 추가할 때마다 MemberServiceFactory의 switch-case 문의 수정이 계속 일어난다. 물론 아주 복잡한 수정은 아니지만 수십, 수백 개 혹은 아주 유사한 이름의 플랫폼 등 개발자도 사람이기에 헷갈리는 등 실수가 발생한다.
ServiceLocatorFactoryBean
이제 스프링 프레임워크의 강력한 편의성을 이용해 보자. 바로 ServiceLocatorFactoryBean이다. 스프링 프레임워크에서 제공하는 클래스 중 하나로 스프링 Context 내에서 Bean을 검색하는 데 사용되는 FactoryBean이다. 이를 통해 Runtime에 다른 Bean들을 찾아서 사용할 수 있다.
일반적으로는 컨테이너가 미리 지정해 둔 정의에 따라 Bean을 생성 및 관리하지만, Runtime 시 필요한 Bean을 검색해야 하는 경우도 있다. 바로 위 같은 상황이다. MemberController를 사용하지만 어떤 플랫폼의 Service를 사용할지는 유저가 요청을 보내야만 알 수 있다. 이때 ServiceLocatorFactoryBean를 사용하면 특정 인터페이스를 구현한 Bean들을 찾아 가져올 수 있게 해 준다.
말로 설명하는 것보다 기존 코드를 리팩토링 해서 보여주는 게 이해가 빠를 것 같다.
public interface MemberServiceFactory {
MemberService getMemberService(Platform platform);
}
기존에 switch-case 문을 사용하던 형태에서 더욱 추상화되어 MemberService 인터페이스를 반환하도록 변경했다. 또 클래스에서 인터페이스로 변경됐다.
@Service("KAKAO")
@RequiredArgsConstructor
public class KakaoMemberService implements MemberService {
private final MemberRepository memberRepository;
@Override
public Long createMember(Member member) {
member.setPlatform(Platform.KAKAO);
memberRepository.save(member);
return member.getId();
}
@Override
public Member findMemberById(Long memberId) {
return memberRepository.find(memberId);
}
}
각 플랫폼 별 구체 Service들에는 Bean 이름을 지정해 주는 수정만 간단하게 진행했다. 이유는 뒤에서 설명할 예정이다. 나머지 Service들도 동일하게 GOOGLE, NAVER로 이름을 붙였다.
import example.member.service.MemberServiceFactory;
import org.springframework.beans.factory.config.ServiceLocatorFactoryBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MemberServiceFactoryConfig {
@Bean
public ServiceLocatorFactoryBean serviceLocatorFactoryBean() {
ServiceLocatorFactoryBean factoryBean = new ServiceLocatorFactoryBean();
factoryBean.setServiceLocatorInterface(MemberServiceFactory.class);
return factoryBean;
}
}
이제 @Configuration를 사용하여 스프링 컨테이너가 스캔하고, Bean으로 등록하도록 Config 클래스를 생성하자. @Bean 어노테이션을 달면 Bean 객체를 생성, 반환하는 역할을 한다. 이제 이러한 메서드들은 스프링 Spring IoC 컨테이너에 의해 호출되어 해당 Bean 객체가 컨테이너에 등록되고 필요한 곳에 주입된다.
내용을 살펴보면 ServiceLocatorFactoryBean 인스턴스를 생성하고, setServiceLocatorInterface 메서드를 사용하여 해당 인스턴스에 MemberServiceFactory 인터페이스를 지정했다. 이제 ServiceLocatorFactoryBean이 해당 인터페이스의 구현체를 검색한다. 마지막으로 반환을 하면 스프링 컨테이너가 이 메서드가 생성한 Bean을 관리한다. 이제 기존의 테스트 코드에 위 MemberServiceFactory 인터페이스를 주입 후 검증하면 결과는 동일하다.
앞서 말했던 @Service에 지정해 줬던 Bean name이 없다면 어떤 현상이 일어날지 보여주겠다. 현재 Test에서 KakaoMemberService의 인스턴스를 이용하고 있으니 해당 클래스의 Bean name을 지우고, 다시 검증했다.
No bean named 'KAKAO' available
org.springframework.beans.factory.NoSuchBeanDefinitionException: No bean named 'KAKAO' available
이용 가능한 KAKAO라는 Bean name이 없다고 나온다. "KAKAO" 문자열을 지웠기 때문에 기본 설정인 클래스명으로 생성이 된다. 하지만 MemberServiceFactory.getMemberService가 전달받은 인자는 Platform enum 정보이기 때문에 해당 인자를 통해 MemberService의 구현체들을 탐색하려면 따로 Bean name을 지정해줘야 한다.
이제 스프링 프레임워크를 이용한 팩토리 메서드 패턴 구현을 완료했다. 이전처럼 switch-case문을 남발하지 않아도 된다.
더 자세한 원리에 대해 공부하고, 글을 써보고 싶은데 조만간 해보도록 하겠다.
'Java & Kotlin & Spring' 카테고리의 다른 글
i18n을 도입하여 다국어(국제화) 서비스 제공 (1) | 2023.10.04 |
---|---|
반복되는 필드를 모아 Entity를 만드는 @MappedSuperClass (1) | 2023.08.16 |
flatMap과 map 구분해서 사용하기 (0) | 2023.03.23 |
Mac에서 JDK 제거하기 (0) | 2022.12.17 |
댓글