Nodejs

디자인 패턴을 적용한 oauth login 구현 Part 3

Sila 2023. 12. 10. 21:59

마지막으로 서비스 단에서 가독성이 더 좋고, 코드를 조금 더 재활용할 수 있도록 디자인 패턴을 적용해보자.

 

1. Strategy (전략 패턴)

전략 패턴은 중간에 추상화된 인터페이스를 하나 만들고, 

 

필요에 따라 적절한 클래스에 속하는 인스턴스를 껴서 사용하는 방식이다.

 

덕 타이핑이라는 용어가 있는데,

 

"만약 어떤 동물이 오리처럼 꽥꽥거리고, 뒤뚱거리면서 걷고, 수영하고 등등

 

오리가 하는걸 다 한다면 난 그걸 오리라고 취급하겠다"

 

라는 뜻에서 용어를 그렇게 정했다고 한다.

 

상황에 완전히 들어맞지는 않지만 나는 이 패턴을 이해하는데 도움이 되었다.

 

코드에 대입하자면 몇가지 함수가 정의된 인터페이스가 있고 (class와 비슷하다고 보면 된다.),

 

다른 클래스가 이 인터페이스에 정의된 함수를 다 가지고 있다면,  그 인터페이스처럼 취급해도 괜찮다는 것이다.

 

 interface duck {
        walk()
        swim()
        quack()
}

 

이런 인터페이스가 있다면, duck이 가진 함수 walk(), swim(), quack()을 모두 가진 클래스는 duck처럼 사용될 수 있다.

 

class animal {
        walk() {
                ...
        }
        
        swim() {
                ...
        }
        
        quack() {
                ...
        }
}

 

이렇게 되면 duck 타입 변수를 받는 함수에 animal class의 인스턴스를 넣어줘도 된다.

 

이제 이걸 기반으로 서비스단부터 코드를 수정해보자.

 

1-1. interface

우선 카카오 로그인 클래스, 구글 로그인 클래스가 모두 될 수 있는 인터페이스를 하나 만들것이다.

 

정확히 말하면 두 클래스가 가진 oauth 로그인에 관련된 함수를 전부 가진 인터페이스를 하나 정의한다.

 

이미 앞에서 Abstract factory 패턴을 적용해 두 클래스를 동일한 패턴으로 만들었으므로 어렵지 않게 만들 수 있다.

 

interface IOAuthProvider {
        redirectToProvider(clientId: string, redirectUrl: string): string;
        getToken(code: string, reqTokenDTO: reqTokenDTO);
        getUserInfo(token: any): Promise<userInfoDTO> | null;
}

 

이제 다른 class들 중 이 인터페이스가 포함하고 있는 세 개의 함수를 모두 가진 클래스들은

 

이 인터페이스 타입을 받는 자리에 올 수 있다.

 

즉, 함수의 매개 변수 타입을 IOAuthProvider로 지정한다면

 

IOAuthProvider 인터페이스가 포함한 세 개의 함수를 전부 가진 인스턴스를 이 매개 변수 자리에 넣어도 된다. 

 

1-2. class

인터페이스만 선언하면 끝난 것은 아니고, 이 인터페이스를 구현한다는 표시를 각 class들에 추가해주어야 한다.

 

IOAuthProvider 인터페이스를 구현하는 class는 GoogleOAuth와 KakaoOAuth 두 개가 있다.

 

이 두 클래스가 IOAuthProvider를 구현한다는 것을 프로그램이 알 수 있도록 class에 표기를 해준다고 생각하면 된다.

 

`implements` 키워드를 사용해 이를 알려줄 수 있다. class 선언을 다음과 같이 수정한다.

 

export class GoogleOAuth extends OAuthService implements IOAuthProvider {...}
 
export class KakaoOAuth extends OAuthService implements IOAuthProvider {...}

 

 

이로서 IOAuthProvider 타입이 들어갈 자리에는 GoogleOAuth, KakaoOAuth 타입의 인스턴스가 들어갈 수 있게 되었다.

 

2. Service

2-1. 생성자 함수

서비스 단을 수정해보자. Map을 이용해 로그인 방식(string)과 OAuth login 관련 인스턴스를 묶어주겠다.

 

export class LoginService {
        private providers: Map<string, IOAuthProvider>;

        constructor(private configService: ConfigService) {
                this.providers = new Map<string, IOAuthProvider>();
                this.providers.set("google", new GoogleOAuth());
                this.providers.set("kakao", new KakaoOAuth());
        }
}

 

새 Map을 생성했고, 이는 써드 파티 이름과 인스턴스를 매핑해준다. (e.g. google > GoogleOAuth 인스턴스)

 

만약 페이스 북 로그인을 추가하고 싶다면 추상 클래스에 맞춰서 클래스를 구성하고,

 

여기 매핑을 하나만 더 추가해주면 된다.

 

export class LoginService {
        private providers: Map<string, IOAuthProvider>;

        constructor(private configService: ConfigService) {
                this.providers = new Map<string, IOAuthProvider>();
                this.providers.set("google", new GoogleOAuth());
                this.providers.set("kakao", new KakaoOAuth());
                this.providers.set("facebook", new FacebookOAuth())
        }
}

 

 

2-2. 리다이렉트 함수

이제 써드 파티의 로그인 페이지로 사용자를 리다이렉트 시켜주는 함수를 변경한다.

 

써드 파티 종류에 따라 사용하는 인스턴스가 달라져야 하므로 이를 매개 변수로 받는다.

 

async toOauthLoginPage(party: string) {
        const provider = this.providers.get(party);

        if (!provider) return this.configService.get("FRONT_ADDRESS");

        return provider.redirectToProvider(
                this.configService.get(`${party}_clientId`),
                this.configService.get(`${party}_redirect_url`)
        );
}

 

1. 함수가 호출되면 우선 매개변수 party를 읽어 대응하는 provider를 가져온다.

 

party 변수의 값이 "google"이라면 "google" 문자열에 매핑된 GoogleOAuth 타입 인스턴스를 가져올 것이다.

 

이제 provider 변수에는 GoogleOAuth 타입 인스턴스가 담겨있다.

 

2. provider의 redirectToProvider 함수를 호출한다.

 

provider의 타입이 이미 정해져 있으므로 알맞은 환경 변수만 전달해주면 된다.

 

이런 코드 구조의 장점은 provider가 매개 변수에 의해 정해지고 나면 그 다음엔 알아서 맞는 클래스의 함수를 가져오므로 

 

일일히 경우를 나눠줄 필요없이 코드를 간략하게 나누지 않아도 되고,

 

그에 따라 나중에 다른 써드 파티 로그인 기능이 추가되도 이 부분을 변경할 필요가 없다는 점이다.

 

전술한 것처럼 생성자 함수에만 하나 추가하면 된다.

 

2-3. 사용자 정보 수신 함수

token을 받고, 그 token을 다시 사용자 정보와 교환하는 기능은 하나의 함수에 묶을 수 있다.

 

이 전 글에서 봤던 비슷한 기능의 함수를 아깝게 재활용 못하는 것도 인터페이스를 통해 해결되었다.

 

async getOAuthToken(oAuthcode) {
        const { party, code } = oAuthcode;

        const provider = this.providers.get(party);

        const args: reqTokenDTO = {
                client_id: this.configService.get(`${party}_clientId`),
                client_secret: this.configService.get(
                        `${party}_client_secret`
                ),
                redirect_uri: this.configService.get(
                        `${party}_redirect_url`
                ),
                grant_type: "authorization_code",
        };

        try {
                const accessToken = await provider.getToken(code, args);

                const userInfo = await provider.getUserInfo(
                        accessToken
                );

                const s = encrypter(
                        JSON.stringify(userInfo),
                        this.configService.get("encrypt_code")
                );

                return { s, ...userInfo };
        } catch (e) {
                console.log(e);

                if (e instanceof ErrorMessage) {
                        makeResponseObj(
                                1,
                                `google login failed : ${e.message}`
                        );
                }
        }
}

 

이해를 돕기 위해 설명하자면 oAuthCode는 party (google | kakao) 와 거기서 받은 코드를 담고 있는 객체로 주게끔

 

환경변수를 작성했다.

 

각 redirect url에 다음과 같이 쿼리를 추가했다.

 

kakao_redirect_url= <backend_server_url>/login/getToken?party=kakao
google_redirect_url=<backend_server_url>/login/getToken?party=google

 

더 자세한 내용은 Controller 단에서 마저 언급하겠다.

 

리다이렉트 함수와 마찬가지로 LoginService 내에 있는 providers에서 적절한 인스턴스를 가져오고,

 

알맞은 환경변수를 넣어 provider 변수가 가진 함수를 호출하면 된다.

 

 

3. Controller

마지막으로 컨트롤러 단을 수정해보자.

 

마찬가지로 어떤 써드 파티를 이용하는지만 잘 전달해주면 나머지는 코드가 다 알아서 해줄 것이다.

 

@Controller("login")
export class LoginController {
        constructor(
                private readonly loginService: LoginService,
                private readonly configService: ConfigService
        ) {}

        @Get("OauthLogin")
        async tokakaoLoginPage(@Res() res, @Query() s) {
                const url = await this.loginService.toOauthLoginPage(s.s);
                res.status(200).redirect(url);
        }

        @Get("getToken")
        async getToken(@Res() res: Response, @Query() oAuthCode) {
                const cookieString = await this.loginService.getOAuthToken(
                        oAuthCode
                );

                const token = jwt.sign(
                        { data: cookieString },
                        this.configService.get("encode_salt"),
                        {
                                expiresIn: "24h",
                        }
                );

                res.cookie("userInfo", token, {
                        secure: process.env.NODE_ENV === "production",
                        maxAge: 1000 * 60 * 60 * 24,
                });

                res.redirect(this.configService.get("FRONTEND_ADDRESS"));
        }
}

 

처음 코드와 비교해보면 훨씬 코드 가독성도 좋아졌고,

 

서비스와 컨트롤러에 있는 함수들이 뭘 하는 함수들인지 함수명만 보고 한 눈에 알 수 있게 되었다.

 

코드 재활용성도 높아지고 길이도 짧아졌고..

 

전체 코드는 여기를 참조하면 된다.