
스프링으로 회원 기능을 구축하면서 참 귀찮다는 생각이 듭니다.
애플리케이션 단 안에서 회원 테이블을 만들고 관리하면 그나마 단순하지만, Oauth를 사용하는 경우라면 특히나 복잡해집니다..
사용자가 간단하게 SNS 서버의 종류만 알려주고, 알아서 서버의 타입에 따라 처리된다면 얼마나 좋을까요?
"동일한 오퍼레이션을 전달했을 때 객체의 종류에 따라서 서로 다른 응답을 할 수 있는 능력"
여러가지 블로그들과 코드를 보면서 저는 이러한 문제점에 대해 다형적으로 처리하면 좋을 것 같다는 생각이 들었습니다.
이에 따라 선언할 수 있는 퍼블릭 오퍼레이션은 다음과 같습니다.
fetch(): 어떠한 OauthServer인지는 모르나 해당 서버로 부터 사용자 데이터를 가져옴.logout() : 사용자를 로그아웃 시킴supportServer() : 어떤 종류의 OauthServer인지 반환.withdraw() : 해당 어플리케이션과 Oauth간의 연결을 끊고 데이터를 삭제함.해당 퍼블릭 오퍼레이션을 구현하는 각각의 구현체들은 구글, 카카오 등 서버의 타입에 따라 직접 로직을 처리합니다.
public interface OauthUserLogic {
OauthServerType supportServer();
Student fetch(String code);
void logout(String oauthServerUserId);
void withdraw(String oauthServerUserId);
}
결론적으로 제가 사용하는 방법은 전략 패턴의 일종입니다.
전략 패턴이란, 런타임 도중에 알고리즘 전략을 선택하여 객체 동작이 실시간으로 바뀔 수 있도록 하는 행위 디자인 패턴입니다. 이러한 전략 패턴을 사용한다면 클라이언트로부터 알고리즘을 분리하여 독립적으로 사용할 수 있겠죠.
다음은 앞서 구현한 퍼블릭 오퍼레이션을 통해서 전략들을 등록하고 실시간으로 교체할 수 있는 Context입니다.
@Component
public class OauthUser {
private final Map<OauthServerType, OauthUserLogic> clients;
public OauthUser(Set<OauthUserLogic> clients) {
this.clients = clients.stream()
.collect(toMap(OauthUserLogic::supportServer, identity()));
}
public Student fetch(OauthServerType oauthServerType, String authenticationCode) {
return getClient(oauthServerType).fetch(authenticationCode);
}
public void logout(OauthServerType oauthServerType, OauthId oauthId) {
getClient(oauthServerType).logout(oauthId.getOauthServerId());
}
public void withdraw(OauthServerType oauthServerType, OauthId oauthId) {
getClient(oauthServerType).logout(oauthId.getOauthServerId());
}
private OauthUserLogic getClient(OauthServerType oauthServerType) {
return Optional.ofNullable(clients.get(oauthServerType))
.orElseThrow(() -> new LoginException(UNSUPPORTED_OAUTH_TYPE));
}
}
위와 같이 빈으로 Context를 등록할 수 있습니다.
다만, 저는 처음 위와 같은 코드를 접했을 때 "왜 굳이 Map을 사용해야 할까?" 라는 생각이 들었습니다.
해당 빈을 호출하는 시점에 객체를 생성해서 주입하면 어짜피 Context를 실행하는 시점에 알아서 구현체가 선택될 것인데.. 왜 Map을 쓸까 고민이 많이 들었습니다.
이에 저는 두 가지 이유를 유추해보았습니다.
Context밖으로 빠져나옵니다.기본적으로 구현체를 선택하는 로직이 Map으로 등록되어 있지 않다면 결국 이를 선택할 수 있는 로직이 해당 Context밖으로 빠져나가게 됩니다. 다형성이란 결국 하나의 오퍼레이션에 대해 다양한 응답을 할 수 있는 능력이고, 객체의 타입을 결정하는 로직 또한 내부에 들어가 있어야 합니다.
앞서 말한 것과 비슷한 맥락이긴 한데, Context밖으로 선택 로직이 빠져나가게 된다면 빈으로 등록된 구현체 또한 주입이 되어야 합니다. 단순히 내가 원하는 건 서버의 타입 이름만 전달하고 그에 따른 서버 로직이 구현되는 것을 원했지만, 그 밖에 구현체들에 대한 정보들도 알고 있어야 한다는 것입니다. 이는 클라이언트 입장에서 너무 많은 정보를 알고 있다는 단점이 생깁니다.
스프링의 주입 방식에 대해 헷갈린다면 해당 로직을 이해하기 어렵습니다. 스프링 부트는 단일 빈만 주입해주는 것이 아니라 Set, Map, List 등으로 여러 빈들을 함께 주입해 줄 수 있습니다. 당연히 이러한 빈들을 함께 등록하려면 빈들을 상속하고 있는 상위 클래스 혹은 인터페이스여야겠죠.
private final Map<OauthServerType, OauthUserLogic> clients;
public OauthUser(Set<OauthUserLogic> clients) {
this.clients = clients.stream()
.collect(toMap(OauthUserLogic::supportServer, identity()));
}
...
위 코드의 경우 Set으로 받은 것이라 빈의 이름을 직접적으로 받지 않습니다. 저렇게 하는 이유는 OauthServer의 이름을 Enum으로 등록했기 때문에 구현체의 메서드를 통해서 Map의 Key를 설정해줄 수 있기 때문입니다.
Map을 사용한다면 알아서 빈의 이름이 Key로 들어가게 됩니다.
빈의 이름이 마음에 들지 않아요!
만약 빈의 이름이 너무 복잡하거나 바꾸고 싶거나, 한 눈에 관리하고 싶다면 수동빈을 사용하면 됩니다. 수동 빈을 사용하면, Configuration 에 등록한 빈의 이름 그대로 Map의 Key값에 들어갑니다.
전략 패턴을 기반으로 하는 OauthServer를 동적으로 선택할 수 있게 되었습니다. 공부하면서 느낀 건데, 뭐든 다 트레이드 오프인 것 같습니다. 이렇게 확장 가능하게 설계하는 것도 조건적인 논리가 없어져서 좋긴 한데 클래스 수가 정말 늘어납니다. 오히려 가독성 측면에서는 안 좋아질 수 있죠. 다형성을 많이 활용해야 하는 비즈니스 로직이 있다면 사용하면 좋을 것 같네요!