개발/spring boot

전략 패턴과 팩토리 메소드 패턴 리팩토링

냥덕_ 2025. 1. 3. 18:15

요구사항

  • 회원가입 시 이메일 인증
  • 비밀번호 찾기 시 이메일 인증

문제점

  • 기존 코드의 경우 각 상황별로 API들이 존재하면서 그에 맞게 서비스 로직도 구현되어 있음 즉 똑같은 로직들이 반복 될 수 밖에 없는 구조

Before Code

AuthController

@Operation(summary = "이메일 인증번호 생성 API")
@Parameter(name = "email", example = "teamluckyvickyblurrr@gmail.com")
@GetMapping("/email/{email}")
public ResponseEntity<Boolean> createEmailAuth(@PathVariable("email") String email) {
    return ResponseEntity.ok(memberService.createEmailAuthCode(email));
}

@Operation(summary = "이메일 인증번호 확인")
@PostMapping("/email")
public ResponseEntity<Boolean> validEmailAuth(@Valid @RequestBody EmailAuth emailAuth) {
    return ResponseEntity.ok(memberService.validEmailAuth(emailAuth));
}

@Operation(summary = "비밀번호 찾기 이메일 인증 요청")
@GetMapping("/passwrod/email/{email}")
public ResponseEntity<Boolean> createPaaswordChangeAuthcode(@PathVariable String email){
    return ResponseEntity.ok(memberService.createPasswordAuthCode(email));
}

@Operation(summary = "비밀번호 찾기 이메일 인증 코드 확인")
@PostMapping("/password/email")
public ResponseEntity<Boolean> validPasswordAuthCode(@Valid @RequestBody EmailAuth emailAuth) {
    return ResponseEntity.ok(memberService.validPasswordAuthCode(emailAuth));
}

MemberService


    private final PasswordAuthStrategy passwordAuthStrategy;
    private final SingInAuthStrategy singInAuthStrategy;
    //회원가입 이메일 인증 코드 확인
    @Transactional
    @Override
    public boolean createEmailAuthCode(String email) {
        if (memberRepository.existsByEmail(email)) {
            throw new DuplicateEmailException();
        }

        String authCode = singInAuthStrategy.saveAuthCode(email);

        sendAuthCodeEmail(email, authCode);

        return true;
    }

        //회원가입 인증 코드 확인
    @Override
    public boolean validEmailAuth(EmailAuth emailAuth) {
        if (!checkAuthCode(singInAuthStrategy.generateKey(emailAuth.email()), emailAuth.authCode())) {
           return false;
        }
        singInAuthStrategy.pushAvailableEmail(emailAuth.email());
        return true;
    }

        //비밀번호 찾기 인증 코드 생성
    @Override
    public boolean createPasswordAuthCode(String email) {
        if (!memberRepository.existsByEmail(email)) {
            throw new NotExistMemberException();
        }

        String authCode = passwordAuthStrategy.saveAuthCode(email);
        sendAuthCodeEmail(email, authCode);
        return true;
    }

      //비밀번호 찾기 인증 코드 검증
    @Override
    public boolean validPasswordAuthCode(EmailAuth emailAuth) {
        if (!checkAuthCode(passwordAuthStrategy.generateKey(emailAuth.email()), emailAuth.authCode())) {
            return false;
        }
        passwordAuthStrategy.pushAvailableEmail(emailAuth.email());
        return true;
    }

        //redis에서 검증 코드 확인
    private boolean checkAuthCode(String key, String code) {
        String getCode = redisAuthCodeAdapter.getValue(key).orElseThrow(ExpiredEmailAuthException::new);

        if (!getCode.equals(code)) {
            return false;
        }
        return true;
    }

    //메일 전송
    private void sendAuthCodeEmail(String email, String authCode) {
        String htmlContent = resourceUtil.getHtml("classpath:templates/auth_email.html");

        htmlContent = htmlContent.replace("{{authCode}}", authCode);
        mailService.sendEmail(email, "이메일 인증 안내 | blurr", htmlContent, true);
    }

전체적인 구조를 먼저 살펴보면 기본적으로 인증 코드는 redis에 저장하는 상태이며 인증 유효 시간은 5분이다.

인증 코드 타입에 따라서 passwordAuthStrategy, singInAuthStrategy 각각의 key 생성 함수를 통해 redis에 key : value 형태로 저장

sendAuthCodeEmail()을 통해 메일을 보냄(사실 이 메소드도 서비스 로직에 존재하면 안됨)

리팩토링 과정

  1. 이메일 인증 코드 요청시 type 기반으로 회원가입, 비밀번호 찾기 인증 코드 타입 구분
@Schema(name = "이메일 인증 코드 확인")
public record EmailAuth(
        @NotBlank
        String email,
        @NotBlank
        String authCode,
        @ValidEnum(enumClass = AuthCodeType.class, ignoreCase = true)
        String type
) {
}

@Getter
public enum AuthCodeType {
    SIGNUP("회원가입", EmailFormType.SIGNUP_AUTH),
    PASSWORD_CHANGE("비밀번호찾기", EmailFormType.PASSWORD_CHANGE_AUTH);
    private final String type;
    private final EmailFormType emailFormType;

    AuthCodeType(String type, EmailFormType emailFormType) {
        this.type = type;
        this.emailFormType = emailFormType;
    }

    public static AuthCodeType of(String t) {
        return AuthCodeType.valueOf(t.toUpperCase());
    }
}

AuthType은 enum으로 정의하고 각 타입에 따라 EmailForm이 존재하도록 구성(인증 코드 전송시에는 모두 html 이메일 폼을 사용해서 보냄)

  1. 인증 기능을 추상화 하여 AuthStrategy 인터페이스 정의 후 각 케이스에 맞게 로직을 수행하도록 PasswordAuthStrategy, SingInAuthStrategy 상속 후 구현
public interface AuthCodeStrategy {
        //인증 코드 저장
    default String saveAuthCode(String email) {
        String code = createCode();
        save(email, code);
        return code;
    }
        //인증번호 생성
    private String createCode() {
        int length = 6;
        try {
            String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
            SecureRandom random = SecureRandom.getInstanceStrong();
            StringBuilder builder = new StringBuilder();

            for (int i = 0; i < length; i++) {
                int index = random.nextInt(characters.length());
                builder.append(characters.charAt(index));
            }

            return builder.toString();
        }  catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }
    //redis 저장
    void save(String key, String code);
        //인증 코드 값 검증
    boolean validAuthCode(String email, String code);
    //인증 이메일 redis 저장
    void pushAvailableEmail(String email);
    //유효한 이메일인지 확인
    void checkAvailableEmail(String email);
}
public class PasswordAuthStrategy implements AuthCodeStrategy {
    private final RedisAuthCodeAdapter redisAuthCodeAdapter;
    private final MemberRepository memberRepository;

    public PasswordAuthStrategy(RedisAuthCodeAdapter redisAuthCodeAdapter, MemberRepository memberRepository) {
        this.redisAuthCodeAdapter = redisAuthCodeAdapter;
        this.memberRepository = memberRepository;
    }

    public void save(String email, String code) {
        if (!memberRepository.existsByEmail(email)) {
            throw new NotExistMemberException();
        }
        redisAuthCodeAdapter.saveOrUpdate(generateSaveKey(email), code, 5);
    }

    @Override
    public boolean validAuthCode(String email, String code) {
        String getCode = redisAuthCodeAdapter.getValue(generateSaveKey(email)).orElseThrow(ExpiredEmailAuthException::new);
        return getCode.equals(code);
    }

    @Override
    public void pushAvailableEmail(String email) {
        redisAuthCodeAdapter.saveOrUpdate(generateAvailableKey(email), String.valueOf(true), 5);
    }

    @Override
    public void checkAvailableEmail(String email) {
        redisAuthCodeAdapter.getValue(generateAvailableKey(email))
                .orElseThrow(InvalidEmailVerificationException::new);
    }

    private String generateSaveKey(String email) {
        return StringFormat.PASSWORD_AUTH_PREFIX + email;
    }

    private String generateAvailableKey(String email) {
        return StringFormat.PASSWORD_CHANGE_AVAILABLE_PREFIX + email;
    }
}

PasswordAuthStrategy를 보면 redis에 저장(save), 인증 코드 값 검증, 검증 이메일 저장 및 확인, type에 맞게 redis에 저장하기 위한 키 생성 메소드가 존재한다 SingInAuthStrategy도 유사하게 구현되어 있음

해당 인증 타입에 맞게 처리를 하기 위해 각 구현체들을 Map에 저장 후 Bean으로 등록 후 이후에 타입에 맞게 가져와서 사용

@Bean
public Map<AuthCodeType, AuthCodeStrategy> authCodeStrategyMap(
        PasswordAuthStrategy passwordAuthStrategy,
        SingInAuthStrategy singInAuthStrategy
) {
    Map<AuthCodeType, AuthCodeStrategy> authCodeStrategyMap = new HashMap<>();
    authCodeStrategyMap.put(AuthCodeType.SIGNUP, singInAuthStrategy);
    authCodeStrategyMap.put(AuthCodeType.PASSWORD_CHANGE, passwordAuthStrategy);
    return authCodeStrategyMap;

}

@Bean
public SingInAuthStrategy singInAuthStrategy(
        RedisAuthCodeAdapter redisAuthCodeAdapter, MemberRepository memberRepository) {
    return new SingInAuthStrategy(redisAuthCodeAdapter, memberRepository);
}

@Bean
public PasswordAuthStrategy passwordAuthStrategy(
        RedisAuthCodeAdapter redisAuthCodeAdapter,
        MemberRepository memberRepository
) {
    return new PasswordAuthStrategy(redisAuthCodeAdapter, memberRepository);
}
  1. AuthCodeService를 통해 인증 코드와 관련된 기능 수행
@Service
public class AuthCodeService {
        private final Map<EmailFormType, EmailFormFactory> emailFormFactoryMap;

        //인증 코드 생성
        public String createAuthCode(String email, AuthCodeType authCodeType) {
        AuthCodeStrategy strategy =  authCodeStrategyMap.get(authCodeType);
        return strategy.saveAuthCode(email);
    }
        //인증 코드값 전송 시 필요한 EmailForm 생성
    public EmailForm getAuthEmailForm(String email, String code, AuthCodeType authCodeType) {
        AuthEmailFormData emailFormData = (AuthEmailFormData) EmailFormDataFactory.getEmailFormData(
                authCodeType.getEmailFormType());
        emailFormData.setAuthCodeType(authCodeType);
        emailFormData.setCode(code);

        return emailFormFactoryMap.get(authCodeType.getEmailFormType()).createEmailForm(email, true, emailFormData);
    }
        //코드 유효성 검사
    public Boolean checkValidCode(EmailAuth emailAuth, AuthCodeType authCodeType) {
        AuthCodeStrategy strategy = authCodeStrategyMap.get(authCodeType);

        boolean isValid = strategy.validAuthCode(emailAuth.email(), emailAuth.authCode());

        if (!isValid) return false;

        strategy.pushAvailableEmail(emailAuth.email());
        return true;
    }
        //인증된 이메일 확인
    public void checkAvailable(String email, AuthCodeType authCodeType) {
        AuthCodeStrategy strategy = authCodeStrategyMap.get(authCodeType);
        strategy.checkAvailableEmail(email);
    }
}
  1. MemberService에 존재하던 인증 관련 기능들을 AuthService에 정의 → MemberService는 사용자와 관련된 기능들만 수행하는 것이 좋다고 판단
public interface AuthService {
    boolean createEmailAuthCode(String email, AuthCodeType authCodeType);
    boolean validAuthCode(EmailAuth emailAuth, AuthCodeType authCodeType);
}
@Override
public boolean createEmailAuthCode(String email, AuthCodeType authCodeType) {
    String code = authCodeService.createAuthCode(email, authCodeType);

    EmailForm emailForm = authCodeService.getAuthEmailForm(email, code, authCodeType);

    mailService.sendEmail(emailForm.getTo(), emailForm.getSubject(), emailForm.getContent(), emailForm.isHtml());
    return true;
}

@Override
public boolean validAuthCode(EmailAuth emailAuth, AuthCodeType authCodeType) {
    return authCodeService.checkValidCode(emailAuth, authCodeType);
}

결과

@Operation(summary = "이메일 인증 코드 생성", description = "회원가입, 비밀번호 변경 시 등 이메일 인증 시 코드 생성 요청 API")
@GetMapping("/email/code/{email}")
public ResponseEntity<Boolean> createEmailAuthCode(
        @PathVariable(name = "email")
        String email,
        @Schema(description = "인증 코드 타입(password_change, signup)")
        @RequestParam(name = "type")
        @ValidEnum(enumClass = AuthCodeType.class, ignoreCase = true)
        String type
) {
    return ResponseEntity.ok(authService.createEmailAuthCode(email, AuthCodeType.of(type)));
}

@Operation(summary = "이메일 인증 코드 검증", description = "회원가입, 비밀번호 변경 시 등 이메일 인증 시 코드 검증 API")
@PostMapping("/email/code")
public ResponseEntity<Boolean> validEmailAuthCode(@Valid @RequestBody EmailAuth emailAuth) {
    return ResponseEntity.ok(authService.validAuthCode(emailAuth, AuthCodeType.of(emailAuth.type())));
}
  • 중복 로직 제거 및 인터페이스 도입:
    • AuthCodeStrategy 인터페이스를 도입하여, 이메일 인증과 비밀번호 찾기에 필요한 공통 로직을 추상화. 이를 통해 PasswordAuthStrategySingInAuthStrategy가 해당 인터페이스를 구현하도록 하여, 중복된 코드 없이 전략에 따라 인증을 수행.
  • 기존 로직을 AuthCodeServiceAuthService로 분리한 이유
    • 인증 코드와 관련된 기능을 하는 객체, 인증 자체를 담당하는 객체로 분리하는 것이 책임을 분명히 하는것으로 생각했습니다.
  • 확장성 있는 구조 설계:
    • 새로운 인증 방식이 필요할 경우, 기존 구조를 변경할 필요 없이 새로운 AuthCodeStrategy 구현체를 추가하여 authCodeStrategyMap에 등록하면 됩니다. 이를 통해 코드의 확장성을 크게 높였습니다.
  • API를 합친 이유
    • 중복된 API를 하나로 통합하여 관리와 유지보수가 용이하도록 했습니다.
    • 예를 들어, 이메일 인증 코드 두개 방식이 아닌 여러개의 인증이 필요할 경우 인증을 하는 타입 별로 로직을 수행하도록 하여 확장에도 유연하게 구성했습니다.

'개발 > spring boot' 카테고리의 다른 글

동시성(명시적 락)  (1) 2025.02.12
동시성(synchronized)  (3) 2025.01.04
[Spring] Quartz 도입기 2  (0) 2024.11.21
[Spring] Quartz 도입기 1  (1) 2024.10.31
[Spring] IoC, DI  (0) 2024.04.24