요구사항
- 회원가입 시 이메일 인증
- 비밀번호 찾기 시 이메일 인증
문제점
- 기존 코드의 경우 각 상황별로 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()을 통해 메일을 보냄(사실 이 메소드도 서비스 로직에 존재하면 안됨)
리팩토링 과정
- 이메일 인증 코드 요청시 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 이메일 폼을 사용해서 보냄)
- 인증 기능을 추상화 하여 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);
}
- 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);
}
}
- 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
인터페이스를 도입하여, 이메일 인증과 비밀번호 찾기에 필요한 공통 로직을 추상화. 이를 통해PasswordAuthStrategy
와SingInAuthStrategy
가 해당 인터페이스를 구현하도록 하여, 중복된 코드 없이 전략에 따라 인증을 수행.
- 기존 로직을
AuthCodeService
와AuthService
로 분리한 이유- 인증 코드와 관련된 기능을 하는 객체, 인증 자체를 담당하는 객체로 분리하는 것이 책임을 분명히 하는것으로 생각했습니다.
- 확장성 있는 구조 설계:
- 새로운 인증 방식이 필요할 경우, 기존 구조를 변경할 필요 없이 새로운
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 |