티스토리 뷰
이번엔 특별히 무언가 주제가 있다기보다 실무에서도 쉽게 접하는 상황에서 어떻게 인터페이스를 설계하는게 올바른지 예제를 통해 알아가보려 한다. 지난 포스팅의 후속작정도 될 것 같다.
# 요구사항
소셜 로그인 로직을 구현해야한다. 소셜 로그인은 네이버, 카카오, 구글을 지원할 예정이다. 소셜 로그인을 할때 각 플랫폼들이 필요로 하는 정보는 아래에 적어놨다. 실제 플랫폼들의 소셜 로그인을 구현하는건 아니어서 아래 필요정보들은 실제 플랫폼 소셜 로그인과는 상관없이 가상의 필요정보다.
네이버: client-key, 사용자 id, 사용자 동의여부
카카오: client-key, 사용자 id
구글: client-key, 사용자 토큰
# 인터페이스 정의
소셜 로그인과 각 플랫폼들의 관계는 딱봐도 개발자로 하여금 뭔가 추상화를 할 수 있을거라는 생각이 들게한다. 그래서 소셜 로그인이라는 인터페이스와 각 구현체를 정의하기로 한다.
interface SocialLogin {
void login();
}
class NaverLogin implements SocialLogin {
@Override
public void login() {
}
}
class KakaoLogin implements SocialLogin {
@Override
public void login() {
}
}
class GoogleLogin implements SocialLogin {
@Override
public void login() {
}
}
인터페이스를 정의했으니 이제 파라미터를 전달해줘야한다. 인터페이스에 파라미터는 어떻게 정의해야할까?
interface SocialLogin {
void login(String clientKey, String userId, String userToken, boolean isAgreed);
}
일단 필요한 정보들을 파라미터로 받게했는데 여기부터 이게 추상화를 제대로 한건지 의심이 들 수 있다. 각 플랫폼이 똑같은 내용을 전달받으면 참 좋을련만 그게 안되니 파라미터가 마구 나열되고, 각 구현체별로 사용하지 않는 파라미터가 생긴다. 이 방식은 직관적으로도 이상하다는 느낌을 주지만 몇가지 문제가 있다.
- 파라미터가 계속 나열된다. 4개는 그래도 견딜만해서 괜찮다고? 만약 특정 플랫폼 한군데서 2개의 파라미터를 더 요구한다면 어떻게 될까? 6개를 메서드에 나열해야할까?
- userId 와 userToken 은 동일한 String 타입이니 하나로 퉁칠수도 있을 것이다. 예제 요구사항에서 토큰을 요구하는건 구글뿐이고, 구글은 id 를 요구하지 않으니 하나로 퉁치면 파라미터가 3개가 될 수 있다. 지금 예제에서는 일리있는 얘기지만 만약 구글이 id 와 토큰을 모두 요구한다면 문제는 여전하다.
- 객체지향과 추상화는 구현체인 저수준 모듈이 고수준 모듈에 의존하게 하기 위한 기법이다. 위는 가볍게보면 그걸 만족하는 것 같지만 사실은 인터페이스가 실제 구현체들에 의존하게 된다. 만약 네이버 id 가 String 에서 Long 으로 변경된다고 가정해보자. 카카오나 구글은 전혀 변경사항이 없지만 네이버의 변경으로 인해 인터페이스에 변경이 발생하고, 이는 전혀 상관없는 카카오나 구글의 코드까지 변경을 전파한다.
# 파라미터 객체
리팩토링에 대해 어느정도 고민해봤다면 이럴때 파라미터 객체를 떠올릴 수 있다. 파라미터 객체를 적용해서 리팩토링하면 위에서 얘기한 문제가 대부분 해결된다.
interface SocialLogin {
void login(SocialLoginParam socialLoginParam);
}
class SocialLoginParam {
private String clientKey;
private String userId;
private String userToken;
private boolean isAgreed;
}
이제 파라미터가 늘어나거나 변경되면 SocialLoginParam 클래스가 변경될 뿐 인터페이스 자체는 변경되지 않는다. 하지만 필드가 대부분 옵셔널 필드가 되어 각 구현체에서 어떤 필드들을 써야하는지를 구분해야하고, 이 정보가 잘 전달되지 않을시 버그를 유발할 수 있다. 파라미터 객체를 초기화할때도 다른 필드를 초기화하는 실수를 유발할 수 있다.
# 구현체별 파라미터 객체
class NaverLoginParam {
private String clientKey;
private String userId;
private boolean isAgreed;
}
class KakaoLoginParam {
// ...
}
class GoogleLoginParam {
// ...
}
각 구현체별로 파라미터 객체를 분리했다. 이 결과 파라미터 객체의 응집도가 올라가고 해당 구현체가 필요로하는 정보만 전달할 수 있게 됐다. 하지만 이렇게 만들면 인터페이스를 정의하는게 곤란한다.
# 제네릭 타입파라미터
interface SocialLogin<T> {
void login(T param);
}
class NaverLoginParam {
private String clientKey;
private String userId;
private boolean isAgreed;
}
class NaverLogin implements SocialLogin<NaverLogin> {
@Override
public void login(NaverLogin param) {
}
}
제네릭을 이용해서 각 구현체별 파라미터 객체를 받게해서 처리했다. 그 동안의 추상화가 마음에 들지 않고, 고민을 한다면 보통 여기까지 오는 것 같다. 하지만 보통 추상화의 결과가 이렇게 나온다면 한번 고민해봐야한다. 이 방식은 인터페이스를 사용하는 클라이언트와 구현체를 결합하게 된다.
class SocialLoginClient {
private SocialLogin<NaverLoginParam> login;
public SocialLoginClient(SocialLogin<NaverLoginParam> login) {
this.login = login;
}
}
// 의존성 주입
new SocialLoginClient(new NaverLogin());
// 주입할 수 없음
new SocialLoginClient(new KakaoLogin());
SocialLogin<NaverLoginParam> 과 SocialLogin<KakaoLoginParam> 은 다른 타입이기 때문에 주입할 수 없다. 이를 해결하기 위해 결국 제네릭이 외부로 전파되며 외부 코드에 영향을 준다.
class SocialLoginClient<T> {
private SocialLogin<T> login;
public SocialLoginClient(SocialLogin<T> login) {
this.login = login;
}
}
// 의존성 주입
new SocialLoginClient<NaverLoginParam>(new NaverLogin());
new SocialLoginClient<KakaoLoginParam>(new KakaoLogin());
# 그럼 어떡해
뭔가 공부한 내용들을 복기해보면 분명히 추상화를 도입할 수 있을 것 같은데 하나씩 나사가 빠진 해결법 같은 느낌이다. 이게 최선일까? 이 고민의 원천적인 문제는 각 구현체의 모든 정보를 인터페이스 레벨에서 해결하려고하기 때문에 발생한다. 인터페이스는 각 구현체의 내용에 대해 몰라야한다. 그리고 의존관계는 메서드 파라미터로만 전달되는게 아니다. 바로 인스턴스필드로 전달할 수 있다.
interface SocialLogin {
void login(String clientKey);
}
class NaverLogin implements SocialLogin {
private String userId;
private boolean isAgreed;
@Override
public void login(String clientKey) {
}
}
class SocialLoginClient {
private SocialLogin login;
public SocialLoginClient(SocialLogin login) {
this.login = login;
}
}
new SocialLoginClient(new NaverLogin("userId", true));
new SocialLoginClient(new KakaoLogin("userId"));
new SocialLoginClient(new GoogleLogin("token"));
추상화 과정에서 clientKey 만 필수사항으로 인지하고 인터페이스에 정의했다. 그리고 구현체별로 달라지는 내용들은 각 구현체가 직접 담게했다. 이런식으로 추상화를 진행하면 인터페이스 수준에서 모든 구현체에 대한 정보를 넣으려고 고민할 필요가 없다. clientKey 를 인터페이스에 남겨둘지, 각 구현체의 인스턴스로 뺄지는 상황마다의 설계 관점에 따라 달라진다.
# 정리
모든 경우에 이런식으로 추상화를 하라는 이야기는 아니다. 모든 경우에 할 수도 없고, 모든 경우에 어울리지도 않는다. 하지만 실무에서 추상화를 시도하다가 오히려 안하니만 못한 인터페이스 설계가 나오는 경우를 종종 목격했다. 그래서 "인터페이스가 지저분해지는 이유는 무엇일까, 추상화는 허구에 불과한 것인가" 에 대한 고민을 많이 했고, 상당수의 경우 이 방식에 대해선 잘 고민하지 못하는 것 같아 포스팅으로 남겨놓는다.
'Java' 카테고리의 다른 글
예외처리는 어떻게 하는게 좋을까 (3) | 2025.01.05 |
---|---|
Jackson 에서 ObjectWriter 와 ObjectReader (0) | 2023.11.05 |
~ByParameter naming 에 대한 고찰 (1) | 2022.11.03 |
builder pattern 에 대한 고찰 (3) | 2022.04.23 |
ThreadPoolExecutor 사용시 maximumPoolSize 동작방식 (5) | 2021.02.02 |
- Total
- Today
- Yesterday
- javascript
- JPA
- backend개발환경
- toby
- DesignPattern
- EffectiveJava
- frontend개발환경
- OOP
- MySQL
- servlet
- 정규표현식
- java8
- Kotlin
- frontcode
- java
- go-core
- http
- code
- clean code
- Spring
- Jackson
- spring cloud
- generics
- Design Pattern
- db
- programming
- TEST
- mariadb
- JavaScript Core
- Git
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |