yeonuel-tech
스프링 AOP가 아닌 데코레이터 패턴을 직접 적용한 이유 본문
일단, 제가 스프링의 AOP와 데코레이터 패턴을 적용하려고 했던 이유는 다음과 같습니다. TDD를 통해서 구현이 완료되고 리팩토링 과정 중에 중복된 코드를 발견했고 추후 변경이 일어난다고 가정 했을 때 어떻게 처리하면 좋을 지 생각하게 되었습니다. 그 결과로 나온 대안점이 스프링의 AOP를 사용하냐 아니면 데코레이터 패턴을 사용하냐였습니다.
밑에는 제가 작성한 코드입니다. 해당 코드에는 서로 다른 관심을 갖은 코드가 보입니다. 첫 번째는 회원 등록 로직이고 두 번째는 DB 상의 네트워크 장애시 재시도를 통해 복구하는 로직입니다. 두 번째 코드는 다른 부분에도 똑같이 적용될 수 있기 때문에 코드의 중복으로 판단했습니다. 또한, 특정 외부환경 문제로 인한 처리 로직으로부터 순수한 비즈니스 로직을 분리하는 것이 좋다고 생각했습니다.
이를 해결하기 위한 방법으로는 스프링의 AOP 기술인 @Retrable을 사용하는 것과 데코레이터 패턴을 적용하는 것이였습니다.
스프링의 @Retrable 어노테이션 공식 문서는 다음과 같습니다.
https://docs.spring.io/spring-retry/docs/api/current/org/springframework/retry/annotation/Retryable.html
Retryable (Spring Retry 1.2.2.RELEASE API)
stateful public abstract boolean stateful Flag to say that the retry is stateful: i.e. exceptions are re-thrown, but the retry policy is applied with the same policy to subsequent invocations with the same arguments. If false then retryable exceptions ar
docs.spring.io
해당 어노테이션에 대해 간단하게 설명하자면, 스프링이 제공하는 기능입니다. 특정 예외가 던져질 때 자동으로 재시도합니다.
해당 어노테이션은 네트워크 오류 등과 같은 외부 환경의 일시적인 문제가 발생했을 때, 재시도를 통해 복구하는 작업에 사용합니다.
런타임 시점에 스프링에 의해 코드가 하나로 합쳐지는 AOP 기술을 사용한 어노테이션입니다.(예를 들어서, 프록시 패턴이 적용되었습니다) 해당 어노테이션에서는 재시도 횟수, 간격, 처리할 유형 등을 설정할 수 있습니다.
@Retryable(value = Exception.class, maxAttempts = 3, backoff = @Backoff(delay = 2000))
public void retryableMethod() {
System.out.println("Trying to execute method...");
// 예외를 던지는 로직
throw new RuntimeException("Temporary exception");
}
스프링의 AOP와 데코레이터 사이에서 어떤걸 쓰면 좋을지 고민을 했습니다. 그리고 원장님의 조언에 따라 사용관점에서 데코레이터 패턴을 적용하기로 선택했습니다.
데코레이터 패턴을 적용하기로 결심했던 가장 큰 요인은 내부 소스 코드를 제가 직접 다루기 편하기 때문입니다. 제가 내부 소스 코드를 직접 다루려고 한 이유는 Repository 계층에서 던지는 예외를 좀 더 명확하게 다루기 위함입니다. 스프링은 Repository 계층에서 발생하는 예외를 DataAccessException으로 추상화해서 던집니다. 이는 DB마다 기술이 다르고 각 기술에 정의된 예외가 다르기 때문에 특정 기술에 종속되지 않게 막기 위해서 예외를 추상화해서 던져줍니다.
하지만, 추상화된 예외로 인해 예외의 원인을 명확히 파악하기 어려워지는 경우도 있습니다. 이를 보완하기 위해서 서비스 계층에서 좀 더 명확한 예외로 전환해서 컨트롤러에 던지려고 했습니다. 또한, 컨트롤러에서 DataAccessException와 관련된 예외를 쓰는 것은 DIP(상위 모듈은 하위 모듈에 의존하면 안된다)에 어긋난다는 생각도 들었습니다.
그래서, 위와 같은 이유로 제가 소스 코드를 직접 다루는 것이 중요하다고 생각되어졌습니다. 그래서, 데코레이터 패턴을 적용하기로 선택했습니다. 밑에는 데코레이터 패턴을 적용한 코드입니다.
0. @Configuration 을 통한 빈 설정
package com.example.shop2.config.member;
import com.example.shop2.repository.MemberRepository;
import com.example.shop2.service.MemberService;
import com.example.shop2.service.base.MemberServiceBase;
import com.example.shop2.service.expand.MemberServiceWithRetry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MemberConfig {
private final MemberRepository memberRepository;
@Autowired
public MemberConfig(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
public MemberService memberServiceBase() {
return new MemberServiceBase(memberRepository);
}
@Bean
public MemberService memberServiceWithRetry() {
return new MemberServiceWithRetry(memberServiceBase());
}
}
1. 인터페이스 정의
package com.example.shop2.service;
import com.example.shop2.dto.MemberFormDto;
import com.example.shop2.entity.Member;
import com.example.shop2.exception.global.EmptyRequiredValuesException;
import com.example.shop2.exception.global.RetryFailedException;
import com.example.shop2.exception.member.DuplicatedEmailException;
public interface MemberService {
Member create(MemberFormDto memberFormDto)
throws DuplicatedEmailException, EmptyRequiredValuesException, RetryFailedException;
boolean isValidUser(MemberFormDto memberFormDto);
}
2. 내용이 되는 객체의 클래스
package com.example.shop2.service.base;
import static com.example.shop2.error.MemberErrorCode.*;
import com.example.shop2.dto.MemberFormDto;
import com.example.shop2.entity.Member;
import com.example.shop2.exception.member.DuplicatedEmailException;
import com.example.shop2.exception.member.MemberNotFoundException;
import com.example.shop2.repository.MemberRepository;
import com.example.shop2.service.MemberService;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class MemberServiceBase implements MemberService {
private MemberRepository memberRepository;
public MemberServiceBase(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
// 회원 등록 처리
@Override
public Member create(MemberFormDto memberFormDto)
{
Member member = Member.createMember(memberFormDto); // dto를 엔티티로 변환합니다.
checkDuplicatedEmail(memberFormDto); // 이메일이 중복되는지 확인합니다.
return memberRepository.save(member); // repository를 통해 회원 등록 시도합니다.
}
// 회원 확인 처리
@Override
public boolean isValidUser(MemberFormDto memberFormDto) {
Member foundMember;
try {
foundMember = findMember(memberFormDto); // 해당 회원을 찾습니다.
} catch (MemberNotFoundException e) { // 존재하지 않는 경우, false를 반환합니다.
log.debug("해당 회원이 존재하지 않습니다.");
return false;
}
// 비밀번호가 일치하는지 확인합니다.
return isPasswordMatched(foundMember.getPassword(), memberFormDto.getPassword());
}
// 회원이 존재하는지 확인
private Member findMember(MemberFormDto memberFormDto) {
Member foundMember = memberRepository.findByEmail(memberFormDto.getEmail());
if (foundMember == null) {
throw new MemberNotFoundException(null, NotFoundMember);
}
return foundMember;
}
// 비밀번호가 일치하는지 확인
private boolean isPasswordMatched(String foundMemberPassword, String memberFormDtoPassword) {
return foundMemberPassword.equals(memberFormDtoPassword);
}
// 이메일이 중복되는지 확인
private void checkDuplicatedEmail(MemberFormDto memberFormDto) {
Member foundDuplicatedEmailMember = memberRepository.findByEmail(memberFormDto.getEmail());
if (foundDuplicatedEmailMember != null) {
throw new DuplicatedEmailException(null, DuplicatedEmail);
}
}
}
2. 부가 기능만을 다루고 있는 객체의 클래스
package com.example.shop2.service.expand;
import static com.example.shop2.error.GlobalErrorCode.RetryFailed;
import static com.example.shop2.error.MemberErrorCode.DuplicatedEmail;
import static com.example.shop2.error.MemberErrorCode.EmptyRequiredValue;
import com.example.shop2.dto.MemberFormDto;
import com.example.shop2.entity.Member;
import com.example.shop2.exception.global.EmptyRequiredValuesException;
import com.example.shop2.exception.global.RetryFailedException;
import com.example.shop2.exception.member.DuplicatedEmailException;
import com.example.shop2.service.MemberService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.TransactionSystemException;
@Slf4j
public class MemberServiceWithRetry implements MemberService {
private final int MAX_RETRIES = 10; // 재시도 횟수 10번
private final int INIT_RETRIES = 1; // 초기 횟수
private final int RETRY_DELAY = 10_000; // 대기 시간 10초
private final MemberService memberService;
public MemberServiceWithRetry(MemberService memberService) {
this.memberService = memberService;
}
@Override
public Member create(MemberFormDto memberFormDto)
throws DuplicatedEmailException, EmptyRequiredValuesException, RetryFailedException {
return retryWhenSomethingWrong(memberFormDto);
}
@Override
public boolean isValidUser(MemberFormDto memberFormDto) {
return this.memberService.isValidUser(memberFormDto);
}
// DB 상의 문제가 있을 경우, 재시도를 통해 복구한다.
private Member retryWhenSomethingWrong(MemberFormDto memberFormDto)
throws DuplicatedEmailException, EmptyRequiredValuesException, RetryFailedException
{
int retries = INIT_RETRIES;
while (retries++ < MAX_RETRIES) {
try {
return this.memberService.create(memberFormDto);
} catch (TransactionSystemException e) { // 해당 예외는 재시도 할 필요 없음
log.debug("회원쪽에서 전달한 데이터에 필수 입력값이 누락되었습니다. 회원쪽에게 재요청하겠습니다.");
log.debug(e.getMessage());
e.printStackTrace();
throw new EmptyRequiredValuesException(e, EmptyRequiredValue);
} catch (DataIntegrityViolationException e) { // 해당 예외는 재시도 할 필요 없음
log.debug("데이터 베이스 제약 조건 위배로 회원 등록에 실패했습니다. 회원쪽에게 재요청하겠습니다.");
log.debug(e.getMessage());
e.printStackTrace();
throw new DuplicatedEmailException(e, DuplicatedEmail);
} catch (DuplicatedEmailException e) { // 해당 예외는 재시도 할 필요 없음
throw e;
}
catch (DataAccessException e) { // 위의 예외 이외의 예외는 재시도를 통해 복구작업을 진행함
log.debug("회원 등록 중 예외 발생, %d 동안 대기했다가 재시도하겠습니다. [재시도 횟수 : %d]", RETRY_DELAY, retries);
log.debug(e.getMessage());
try {
Thread.sleep(RETRY_DELAY);
} catch (InterruptedException ex) {}
}
}
log.debug("재시도를 했지만, 실패했습니다.");
throw new RetryFailedException(null, RetryFailed);
}
}
이 과정을 통해서 배운 점들이 2가지가 있습니다. 내가 직접 디자인 패턴을 적용할 때는 @Autowired 만을 사용해서 DI를 적용하기 어렵다는 것이 였습니다. 데코레이터 패턴을 생각해보면. 내용과 장식을 동일시 취급하기 위해 둘 다 같은 인터페이스로 부터 구현체를 만듭니다. 이렇게 될 경우 스프링 컨테이너에는 같은 타입의 구현체가 2개가 존재하게 되기 때문에 두 구현체를 빈으로 등록할 때 문제가 발생합니다. 그래서, 이를 위해서 빈 설정 파일로 내가 특정 타입에 어떤 구현체를 빈으로 등록할지 설정 파일에 작성해줘야 합니다. 저는 @Configuration을 통해서 설정했습니다.
또한, 단위 테스트의 중요성을 배웠습니다. 단위 테스트는 테스트 대상이 외부 환경에 영향을 받지 않게 만드는 것이 중요합니다. 즉, 테스트 대상이 되는 객체가 의존하고 있는 객체를 목 객체로 설정해서 사용해주어야 합니다. 이를 위해서 Mokito를 사용했습니다.
https://github.com/jongheonleee/study_spring_team3/tree/member
GitHub - jongheonleee/study_spring_team3
Contribute to jongheonleee/study_spring_team3 development by creating an account on GitHub.
github.com
'backend-framework > Spring' 카테고리의 다른 글
단위 테스트를 적용하지 못했을 때의 문제점과 단위 테스트의 중요성 (0) | 2024.09.01 |
---|---|
토비의 스프링 - 오브젝트 (0) | 2024.08.15 |
스프링 DI/IoC - 파트2(DI/IoC의 중요성) (0) | 2024.08.05 |
스프링 DI/IoC 관련 용어 및 개념 정리 (0) | 2024.08.04 |
스프링 MVC 패턴 및 레이어드 아키텍쳐 (0) | 2024.08.04 |