Java를 공부하며 추상화에 대해 고민을 했다.
현실세계에서는 같은 행위를 하더라도 주체가 누구인지에 따라서 작동 방식이 달라진다는것은 누구나 들어보았을 말인데, 말로만 들으면 굉장히 와닿지 않는다.
모두 Car, Animal 등의 예시를 들지만 사실 이 예제를 실무에 적용하기가 쉽지는 않다.
그래서 실전에서 가장 유용한 케이스가 어떤것이 있을지 고민을 해 보았다.
같은 로그인을 하더라도 일반 유저와 관리자가 로그인할때 로그인 방식이 달라질 수 있다.
일반 유저는 일반적인 로그인 로직으로 otp 인증없이 로그인을 해도 괜찮으나, 관리자의 경우 계정이 유출되었을 때 무작정 로그인이 되면 서비스를 함부로 헤집을 수 잇기에 otp를 이용한 2차 인증이 필요하다.
이 과정을 추상화할 수 없을까? 라는 고민에서 시작했다.
단순히 이 과정을 if-else로 나누는걸 생각하는데, 이렇게 했을때 아래와같은 문제가 생긴다.
OCP는 개방-폐쇠의 원칙으로, 확장은 열려있어야 하지만 변경에는 닫혀있어야 한다.
즉, 기능을 확장할 수 있어야 하지만 기존에 있는 코드를 수정하지 않고 확장할 수 있어야 한다는것이다.
if(user.getCase() === CASE.OTP) {
}else if(user.getCase() === CASE.NO_OTP){
}else{
}
새로운 인증방식이나 CASE가 추가될 때 마다 조건 분기를 위하여 login() 메서드를 수정해야 하므로, 기조노 코드에 대한 의존도와 변경 범위가 커진다. 이는 유지보수성과 확장성을 모두 저해한다.
SRP는 단일 책임 원칙으로, 클래스는 하나의 책임만을 가져야 하며 책임 변경 이유가 하나뿐이어야한다.
즉, 클래스나 메서드는 하나의 역할만 수행하고, 그 역할의 변경으로만 해당 클래스가 수정되도록 설계되어야 한다는것이다.
public class AuthenticationService {
public AuthResponseDto login(User user, String password) {
if (user.getCase() == Case.OTP) {
// OTP 인증 로직
} else if (user.getCase() == Case.NO_OTP) {
// 일반 로그인 로직
} else if (user.getCase() == Case.ADMIN_APPROVAL) {
// 관리자 승인 로직
}
}
}
서비스 클래스가 인증 전략 선택과 인증 수행이라는 두 가지 책임을 가지고 있어, 하나의 이유만으로 변경되지 않는다. 이렇게 한개의 책임을 넘어가기에 SRP 위반이다.
enum과 구현체, factory를 이용해서 매핑-분기 해주는 방식이 좋다.
말로는 쉽지만, 정확히 어떻게 구현해야하는지 감이 오지 않는분들도 있을것이기에, Map을 이용한 Factory 클래스를 만들어 매핑해주는 간단한 예제를 알아보겠다.
public enum Case {
OTP, NO_OTP;
//....
}
우선, 역할에 다라 케이스를 구분해야한다.
이렇게 enum으로 명확하게 분리함으로서 비즈니스 로직과 선택 기준을 분리할 수 있다.
public interface AuthenticationInterface {
AuthResponseDto login(String userName, String password);
}
모든 로그인 방식은 login 이라는 공통 메서드 시그니처로 행위를 명세해둔다.
실제 구현은 구현체에게 위임함으로써 OCP 원칙과 유연한 확장성을 보장한다.
케이스에 따라 전략을 분리한다.
// OTP가 필요한 로그인일 경우
public class OtpAuthentication implements AuthenticationInterface {
public AuthResponseDto login(String userName, String password) {
// Otp 발송 로직....
return new AuthResposneDto(false, null);
}
}
// OTP가 필요 없는 경우
public class NoOtpAuthentication implements AuthenticationInterface {
public AuthResponseDto login(String userName, String password) {
return new AuthResposneDto(true, "token");
}
}
각 케이스마다 동작방식이 달라지기에 SRP, 유지보수 및 테스트 용이성, 로직충돌 방지라는 많은 장점을 가지게 된다.
CASE에 따라 구현체를 연결(매핑)하여 책임을 분리한다.
public class AuthenticationFactory{
private final Map<Case, AuthenticationInterface> authenticator;
public AuthenticationFactory(
OtpAuthentication otpAuthentication,
NoOtpAuthentication noOtpAuthentication,
) {
authenticator = Map.of(
Case.OTP, otpAuthentication,
Case.NO_OTP, noOtpAuthentication
)
}
public AuthenticationInterface get(Case case) {
return authenticator.getOrDefault(case, authenticator.get(Case.NO_OTP));
}
}
전략 선택 로직이 한 군데에 집중되어있어 새로운 케이스 추가 시 Map에 CASE에 맞춰 추가만 해주면 된다.
서비스 레이어에서 직접적인 로직을 다루지 않고 단순히 로그인이라는 행위만 수행하기에 한결 간결해진다.
public class AuthenticationService {
private final UserRepository userRepository;
private final AuthenticationFactory authenticationFactory;
public AuthenticationService(
UserRepository userRepository,
AuthenticationFactory authenticationFactory
){
this.userRepository = userRepository;
this.authenticationFactory = authenticationFactory;
}
public AuthResponse login(String userName, String password) {
User user = //.... 로그인로직...
AuthenticationInterface auth = authenticationFactory.get(user.getCase());
return auth.login(userName, password);
}
}
여태 단순히 개발을 할 때 여러 케이스를 각각 if-else, switch-case를 이용해서 분기처리를 했었다. 하지만, 이는 유지보수가 힘들다는것을 몸소 느껴 더 나은 방법은 없을까 하며 많은 고민을 했었다.
그 해답을 추상화와 전략패턴에서 찾았고, 이를 적용한 결과 구조적 개선을 채감할 수 있었다.
이렇게 직접 겪으며, 직접 적용해보며 느낀게 있다.
"이 로직은 역할이나 케이스에 따라 분기될 가능성이 있나?"라는 고민이 들었다는것은 인터페이스와 전략패턴이 필요하다는 신호일 수 있을것 같다.