티스토리 뷰

이번엔 특별히 무언가 주제가 있다기보다 실무에서도 쉽게 접하는 상황에서 어떻게 인터페이스를 설계하는게 올바른지 예제를 통해 알아가보려 한다. 지난 포스팅의 후속작정도 될 것 같다.

 

# 요구사항

소셜 로그인 로직을 구현해야한다. 소셜 로그인은 네이버, 카카오, 구글을 지원할 예정이다. 소셜 로그인을 할때 각 플랫폼들이 필요로 하는 정보는 아래에 적어놨다. 실제 플랫폼들의 소셜 로그인을 구현하는건 아니어서 아래 필요정보들은 실제 플랫폼 소셜 로그인과는 상관없이 가상의 필요정보다.

 

네이버: 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 를 인터페이스에 남겨둘지, 각 구현체의 인스턴스로 뺄지는 상황마다의 설계 관점에 따라 달라진다.

 

# 정리

모든 경우에 이런식으로 추상화를 하라는 이야기는 아니다. 모든 경우에 할 수도 없고, 모든 경우에 어울리지도 않는다. 하지만 실무에서 추상화를 시도하다가 오히려 안하니만 못한 인터페이스 설계가 나오는 경우를 종종 목격했다. 그래서 "인터페이스가 지저분해지는 이유는 무엇일까, 추상화는 허구에 불과한 것인가" 에 대한 고민을 많이 했고, 상당수의 경우 이 방식에 대해선 잘 고민하지 못하는 것 같아 포스팅으로 남겨놓는다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/01   »
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
글 보관함