들어가며
현재 서비스중인 AuthService가 387줄의 거대한 God Object로 성장해버렸다. 로그인, 회원가입, 소셜 로그인, 이메일 찾기, 비밀번호 변경 등 인증 관련 모든 기능을 한 클래스에서 처리하다 보니 테스트도 어렵고 수정할 때마다 긴 코드를 찾아봐야 하다보니 유지보수에 있어서 너무 불편했다. 이를 SRP 원칙에 따라 3개 서비스로 분리하고, Facade 패턴으로 재구성한 과정을 기록했다.
문제 상황
God Object가 된 AuthService
AuthService - 387줄
- 의존성: 11개
- 담당 책임: 6가지
1. 로그인/로그아웃 (토큰 관리)
2. 회원가입 (검증 + 외부 API + DB 저장)
3. 소셜 로그인 (카카오/구글/네이버)
4. 이메일/전화번호 찾기
5. 이메일 인증 발송/확인
6. 비밀번호 변경
한 클래스가 6가지 책임을 가지고 있고 명백한 SRP 위반이다.
문제점
// AuthService 테스트를 위해 11개 의존성 모킹 필요
@Mock
MemberRepository memberRepository;
@Mock
PasswordEncoder passwordEncoder;
@Mock
JwtProvider jwtProvider;
@Mock
BannedWordService bannedWordService;
@Mock
RedisTokenService redisTokenService;
@Mock
PortoneService portoneService;
@Mock
RegisterService registerService;
@Mock
FirebaseUidService firebaseUidService;
@Mock
VerificationFacade verificationFacade;
// ... 3개 더
이건 단위 테스트가 아니라 통합 테스트 수준이었다.
387줄의 코드를 스크롤하며 원하는 메서드 찾아야 하고, private 헬퍼 메서드만 15개 이상에 어떤 기능이 어디 있는지 파악 어려웠다.
또한, 변경 영향 범위가 불명확하다는 점이다.
예를 들어, 이메일 찾기 로직을 수정하면 로그인 테스트도 돌려야 한다.
해결 방법
책임 기준으로 3개 서비스 분리
- AuthService (387줄, 11개 의존성)
- LocalAuthService (로컬 인증 전담)
- 로그인/로그아웃
- 회원가입
- 비밀번호 변경
- SocialAuthService (소셜 인증 전담)
- 소셜 로그인 (카카오/구글/네이버)
- MemberDiscoveryService (회원 정보 찾기 전담)
- 이메일 찾기
- 인증 발송/확인
위와 같이 각 서비스는 하나의 명확한 책임만 가지도록 설계했다.
검증 로직 분리 (RegisterValidator)
회원가입 로직 내부에 검증 로직이 여러개가 있어 코드가 길어지고, 재사용성을 위해서 검증 로직을 별도 클래스로 분리하였다.
@Component
@RequiredArgsConstructor
public class RegisterValidator {
private final MemberRepository memberRepository;
private final BannedWordService bannedWordService;
public void validateAll(RegisterRequestDto dto) {
validateExistingMember(dto.email());
validateDeletedMemberReregistration(dto);
validatePasswordMatch(dto);
validateNicknameBannedWords(dto);
}
// 탈퇴하지 않은 사용자 존재 여부 확인
public void validateExistingMember(String email) {
if (memberRepository.findByEmailAndProviderAndDeletedAtIsNull(email, "local").isPresent()) {
throw new DuplicateEntryException("회원", "이메일", email);
}
}
// 탈퇴한 사용자 중 동일 이메일 존재 여부 확인
public void validateDeletedMemberReregistration(RegisterRequestDto dto) {
memberRepository.findByEmailAndProviderAndDeletedAtIsNotNull(dto.email(), "local").ifPresent(member -> {
if (member.getDeletedAt().isAfter(LocalDateTime.now().minusDays(7))) {
throw new UnauthorizedException("탈퇴 후 7일이 지나야 재가입할 수 있습니다.");
}
});
}
// 비밀번호 확인 일치 검증
public void validatePasswordMatch(RegisterRequestDto dto) {
if (!dto.password().equals(dto.passwordConfirm())) {
throw new InvalidInputException("비밀번호", "비밀번호와 비밀번호 확인이 일치하지 않습니다.");
}
}
// 닉네임 비속어 검사
public void validateNicknameBannedWords(RegisterRequestDto dto) {
List<String> badWordsList = bannedWordService.containsBannedWord(dto.nickname());
if (!badWordsList.isEmpty()) {
String badWords = String.join(", ", badWordsList);
throw new InvalidInputException("닉네임", "비속어가 포함되어 있습니다: " + badWords);
}
}
}
Facade 패턴 적용
AuthController (클라이언트)
↓
AuthFacade (조율자)
↓
LocalAuthService
SocialAuthService
MemberDiscoveryService
Controller가 세부 서비스를 직접 의존하지 않도록 AuthFacade로 감쌌다.
구현 과정
LocalAuthService 구현
전체적으로 책임과 역할에 맞게 분리만 해주면 되기에 구조 변경은 간단했다.
local provider 사용자의 인증을 처리한다. (로그인, 로그아웃, 회원가입, 비밀번호 변경)
@Service
@RequiredArgsConstructor
@Slf4j(topic = "LocalAuthService")
public class LocalAuthService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
private final RedisTokenService redisTokenService;
private final RegisterService registerService;
private final PortoneService portoneService;
private final VerificationFacade verificationFacade;
private final RegisterValidator registerValidator; // 검증 로직 재사용
// ==================== 로그인 ====================
public TokenResponseDto login(LoginRequestDto dto) {
//...
}
// ==================== 로그아웃 ====================
@Transactional
public void logout(Long memberId, String accessToken) {
//...
}
// ==================== 회원가입 ====================
public MemberResponseDto register(RegisterRequestDto dto) {
// 1. DTO 검증 (RegisterValidator 사용)
registerValidator.validateAll(dto);
//...
}
// ==================== 비밀번호 변경 ====================
@Transactional
public void updatePassword(Long memberId, UpdatePasswordRequestDto dto) {
//...
}
// ==================== Private 헬퍼 메서드 ====================
private TokenResponseDto generateLoginTokens(Member member) {
//...
}
private TokenResponseDto handleDeletedMember(Member member) {
//...
}
private IdentityVerificationResponseDto validateRealnameVerification(RegisterRequestDto dto) {
//...
}
private void validateLocalProvider(Member member) {
//...
}
private void validateCurrentPassword(UpdatePasswordRequestDto dto, Member member) {
//...
}
}
결과적으로 코드라인이 166줄로 줄게되어, 기존 387줄 대비 57%가 감소했다.
의존성 또한 기존 11개에서 8개로 줄어들었다.
SocialAuthService 구현
소셜 로그인 (카카오, 구글, 네이버) 사용자의 인증을 전담하는 서비스이다.
@Service
@RequiredArgsConstructor
@Slf4j(topic = "SocialAuthService")
public class SocialAuthService {
private final MemberRepository memberRepository;
private final RedisTokenService redisTokenService;
private final JwtProvider jwtProvider;
private final FirebaseUidService firebaseUidService;
@Value("${aws.s3.bucket}")
private String s3Bucket;
@Transactional
public SocialLoginResponseDto socialLogin(SocialLoginRequestDto dto) {
// ...
}
private Member createSocialMember(SocialLoginRequestDto dto) {
// ...
}
private SocialLoginResponseDto generateSocialLoginTokens(Member member) {
// ...
}
private Member processSocialMember(SocialLoginRequestDto dto) {
// ...
}
private Member handleExistingSocialMember(Member existingMember, SocialLoginRequestDto dto) {
// ...
}
}
MemberDiscoveryService 구현
회원 정보 찾기와 관련된 서비스 클래스이다. (이메일 찾기, 인증 발송)
RegisterValidator 내부의 검증메서드를 사용하기때문에 빈을 주입받았다.
@Service
@RequiredArgsConstructor
@Slf4j(topic = "MemberDiscoveryService")
public class MemberDiscoveryService {
private final MemberRepository memberRepository;
private final VerificationFacade verificationFacade;
private final RegisterValidator registerValidator;
@Transactional(readOnly = true)
public FindEmailResponseDto findEmailByNameAndPhoneV2(FindEmailRequestDto dto) {
// ...
}
@Transactional(readOnly = true)
public String findEmailByNameAndPhone(FindEmailRequestDto dto) {
// ...
}
public void sendKakaoAlimTalk(FindEmailRequestDto dto) {
// ...
}
public String sendEmailVerification(String email) {
// ...
}
public boolean confirmEmailVerification(String email, String code) {
// ...
}
public FindEmailResponseDto confirmKakaoVerification(KakaoCodeRequestDto dto) {
// ...
}
private String maskEmail(String email) {
// ...
}
}
AuthFacade 구현
Controller의 클라이언트로 위에서 SRP원칙으로 나눈 세부 서비스들의 통합된 진입점 역할을 하는 Facade 객체이다
@Service
@RequiredArgsConstructor
@Slf4j(topic = "AuthFacade")
public class AuthFacade {
private final LocalAuthService localAuthService;
private final SocialAuthService socialAuthService;
private final MemberDiscoveryService memberDiscoveryService;
// ==================== Local 인증 ====================
public TokenResponseDto login(LoginRequestDto dto) {
return localAuthService.login(dto);
}
public void logout(Long memberId, String accessToken) {
localAuthService.logout(memberId, accessToken);
}
public MemberResponseDto register(RegisterRequestDto dto) {
return localAuthService.register(dto);
}
public void updatePassword(Long memberId, UpdatePasswordRequestDto dto) {
localAuthService.updatePassword(memberId, dto);
}
// ==================== 소셜 인증 ====================
public SocialLoginResponseDto socialLogin(SocialLoginRequestDto dto) {
return socialAuthService.socialLogin(dto);
}
// ==================== 회원 정보 찾기 ====================
public FindEmailResponseDto findEmailByNameAndPhoneV2(FindEmailRequestDto dto) {
return memberDiscoveryService.findEmailByNameAndPhoneV2(dto);
}
public void sendKakaoAlimTalk(FindEmailRequestDto dto) {
memberDiscoveryService.sendKakaoAlimTalk(dto);
}
public FindEmailResponseDto confirmKakaoVerification(KakaoCodeRequestDto dto) {
return memberDiscoveryService.confirmKakaoVerification(dto);
}
// ==================== 이메일 인증 ====================
public String sendEmailVerification(String email) {
return memberDiscoveryService.sendEmailVerification(email);
}
public boolean confirmEmailVerification(String email, String code) {
return memberDiscoveryService.confirmEmailVerification(email, code);
}
}
결과
리팩토링 후 가장 먼저 체감한 건 테스트 작성 시간이었다. 기존에는 AuthService를 테스트하려면 11개 의존성을 전부 모킹해야 했는데, 이제는 LocalAuthService 테스트할 때 8개만 모킹하면 되고, SocialAuthService에서는 4개만 모킹하면 되니까 테스트 코드 작성 시간이 체감이 확실히 될 정도로 줄었다. 각 서비스를 독립적으로 테스트할 수 있기에 디버깅도 훨씬 쉬워졌다.
가장 만족스러운 건 수정할 때 불안감이 없어진 것이다. 소셜 로그인 로직을 고칠 때 SocialAuthService만 보면 되니까, 로그인이나 회원가입 기능이 깨질 걱정할 필요가 없어졌다. 각 서비스가 명확한 단일 책임만 가지고 있어서 수정 시 영향 범위가 확실해졌다.
코드 라인 수를 보면
| 클래스 | 라인 수 | 의존성 |
|---|---|---|
| AuthService (Before) | 387줄 | 11개 |
| LocalAuthService | 166줄 | 8개 |
| SocialAuthService | 124줄 | 4개 |
| MemberDiscoveryService | 147줄 | 3개 |
| RegisterValidator | 62줄 | 2개 |
| AuthFacade | 78줄 | 3개 |
총 라인 수는 577줄로 늘었지만, 각 클래스의 복잡도는 확연히 감소했다. 의존성도 대략적으로
LocalAuthService는 27%, SocialAuthService는 64%, MemberDiscoveryService는 73%나 줄었다.
Controller에서는 AuthFacade만 의존하면 되니까 내부 서비스 구조를 어떻게 바꿔도 Controller는 손댈 필요가 없다.
마치며
이번 리팩토링을 하면서 SRP가 왜 중요한지 뼈저리게 느꼈다. 처음엔 “387줄 정도면 괜찮지 않나?” 싶었는데, 막상 나누고 보니 훨씬 깔끔하고 편리해졌다. 테스트 작성도 쉬워지고, 코드 찾기도 편해지고, 수정할 때 불편함도 없어졌다.
Facade 패턴도 이번에 제대로 활용해봤다. Controller가 세부 구현을 모르게 하니까, 나중에 내부 서비스를 어떻게 바꿔도 Controller는 손댈 필요가 없다. 이게 진짜 유지보수가 쉬운 구조구나 싶었다. 클라이언트를 세부 구현으로부터 보호하는 것의 중요성을 깨닫게되는 지점이었다.
God Object는 초기엔 편하겠지만 장기적으로는 앱의 유지보수, 생산성 측면에서 봤을 때는 재앙적이었다. SRP를 처음부터 지키는 것이 나중에 리팩토링하는 것보다는 훨씬 나을 것이다. 수고스러움을 확실히 덜 수 있으니 말이다.
앞으로는 설계를 할 때 책임과 역할을 제대로 설정하고 분리를 하는 설계를 하도록 더 고민해야겠다.