안녕하세요! 백엔드 개발자 이수인입니다. 오늘은 passport 없이 객체지향적으로 소셜 로그인을 구현해 보려 합니다.
사실 소셜 로그인은 오래 전에 구현했었습니다. 그땐 정말 괜찮게 설계했다고 생각했거든요. 그런데 시간이 지나고 코드 좀 수정하려 하는데 계속 뭐가 어긋나고, 이상하다는 생각이 자꾸 들더라고요. 그래서 '이 코드 설계는 근본적으로 문제다!' 라고 생각해서 그냥 전부 다 갈아엎어 버렸습니다... 지금부터 기존에는 어떤 방식으로 했고, 어떤 문제에 직면했고, 그 문제를 어떻게 풀어갔는지를 공유해보려 합니다.
passport 라이브러리를 쓰면 쉽게 구현을 할 수는 있어요. 하지만 저는 OAuth 를 자세히 이해하고 자유롭게 커스터마이징할 수 있고 객체지향적 설계를 연습하고자 굳이 passport 없이 제가 구현을 했습니다.
저희 서비스는 카카오 로그인과 애플 로그인을 사용합니다. 먼저 카카오 로그인을 구현하고 나중에 애플 로그인을 구현했습니다. 전략 패턴을 사용하기 때문에 추상화하는 작업이 정말 중요했죠. 근데 문제는 제가 추상화를 한 경험이 없었기 때문에 잘 할 줄 몰랐다는 것입니다. 추상화를 할 때 너무 카카오 로그인에만 초점을 둬서 애플 로그인을 구현할 때 여러 문제들을 직면했습니다. 그럼 문제의 코드 설계를 보시죠.
이번 포스팅을 이해하는 용도로 일부 생략하여 가볍게 표현하겠습니다.
src/auth
├── ...
├── auth.controller.ts
├── services
│ ├── auth.service.ts
│ └── ...
└── strategies // 전략 패턴
└── social-login
├── apple
│ ├── apple.strategy.ts
│ └── dto
├── base // 추상 클래스
│ └── social-auth-base.strategy.ts
└── kakao
├── kakao.strategy.ts
└── dto
전략 패턴을 적용하기 위해 strategies
라는 폴더를 만들고, 소셜 로그인 폴더로 social-login
을 만들었습니다. 그리고 추상화 폴더를 명시적으로 구분하고 싶어서 base
라는 폴더를 만들었습니다. 그럼 다음으로 대망의 문제의 코드들을 보죠.
아래 코드는 수정의 지옥에 빠지다가 망해서 버린 코드입니다. 결론적으로 필요한 기능이 일부 빠진 완성하지 못한 코드입니다. 코드가 길지만 요약 정리가 있으니 가볍게 더러운 코드구나~ 정도만 봐주시면 감사하겠습니다.
// src/auth/strategies/social-login/base/social-auth-base.strategy.ts
/**
* @template TToken 소셜 로그인 제공자가 반환하는 토큰 데이터 타입 (ex: KakaoTokenDto)
* @template TUserInfo 소셜 로그인 제공자가 반환하는 사용자 정보 데이터 타입 (ex: KakaoUserInfo)
*/
export abstract class SocialAuthBaseStrategy<TToken = any, TUserInfo = any> {
/**
* 소셜 로그인 인증을 요청하는 URL
*/
protected abstract authLoginUrl: string;
/**
* social 토큰을 요청하는 URL
* - 사용자가 로그인 후 인가 코드(code)를 이용해 토큰을 발급받는 엔드포인트
*/
protected abstract socialTokenUrl: string;
/**
* social 토큰 요청에 필요한 파라미터를 반환
*
* @param code 소셜 로그인에서 제공하는 인가 코드
*/
protected abstract getSocialTokenParams(code: string): Record<string, string>;
/**
* 소셜 로그인 인증 페이지 URL을 반환
*/
public abstract getAuthLoginUrl(): string;
/**
* 소셜 로그인 응답에서 식별자 토큰을 추출
*
* @param socialToken 소셜 로그인에서 반환된 토큰 객체
*/
public abstract getToken(socialToken: TToken): string;
/**
* 토큰을 이용해 소셜 사용자 정보를 조회
*
* @param token 소셜 로그인에서 발급받은 식별자 토큰
*/
public abstract getUserInfo(token: string): Promise<TUserInfo>;
/**
* 각 소셜 서비스의 사용자 정보를 공통 DTO(SocialUserInfoDto)로 변환
*
* @param userInfo 소셜 로그인에서 제공한 원본 사용자 정보
*/
public abstract extractUserInfo(userInfo: TUserInfo): SocialUserInfoDto;
/**
* 소셜 로그인 제공자로부터 토큰을 요청하는 메서드
*
* @param code 소셜 로그인 제공자가 발급한 인가 코드 (authorization code)
*/
public async getSocialToken(code: string): Promise<TToken> {
const params = this.getSocialTokenParams(code);
const response = await axios.post<TToken>(
this.socialTokenUrl,
new URLSearchParams(params),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
);
if (!response?.data) {
throw new UnauthorizedException('Failed to issue token');
}
return response.data;
}
}
뭔 추상화가 이렇게 복잡하고 더러워? 라고 생각할 수 있습니다. 저도 이때는 몰랐습니다... 아직 끝이 아닙니다.
// src/auth/auth.controller.ts
/**
* 소셜 로그인 요청을 처리하는 엔드포인트
* - 해당 소셜 로그인 페이지로 리다이렉트
*/
@Get(':provider/login')
public socialLogin(
@Provider() provider: AuthProvider | null,
@Res() res: Response,
): void {
if (!provider) {
return res.redirect(
this.configService.get<string>('MAIN_PAGE_URL') || '/',
);
}
const socialAuthService = this.authService.getSocialAuthStrategy(provider);
return res.redirect(socialAuthService.getAuthLoginUrl());
}
/**
* 소셜 로그인 콜백 처리 엔드포인트
*/
@Get(':provider/callback')
public async handleGetCallBack(
@Provider() provider: AuthProvider | null,
@Query('code') code: string,
@Res({ passthrough: true }) res: Response,
) {
return this.login(res, provider, code);
}
// src/auth/services/auth.service.ts
public async generateTokenPairWithSocialAuth(
socialAuthService: SocialAuthBaseStrategy,
token: string,
): Promise<TokenPair> {
const userInfo = await socialAuthService.getUserInfo(token);
const extractedUserInfo = socialAuthService.extractUserInfo(userInfo);
const user = await this.userService.createUser(extractedUserInfo);
const payload = { idx: user.idx };
const jwtAccessToken =
await this.loginTokenService.signAccessToken(payload);
const jwtRefreshToken =
await this.loginTokenService.signRefreshToken(payload);
this.tokenStorage.saveRefreshToken(user.idx, jwtRefreshToken);
return { accessToken: jwtAccessToken, refreshToken: jwtRefreshToken };
}
/**
* 소셜 로그인 인증 코드(code)를 사용하여 사용자 인증 후 토큰 발급
*/
public async login(provider: AuthProvider, code: string): Promise<TokenPair> {
const socialAuthService = this.getSocialAuthStrategy(provider);
const socialToken = await socialAuthService.getSocialToken(code);
const token = socialAuthService.getToken(socialToken);
return this.generateTokenPairWithSocialAuth(socialAuthService, token);
}
진짜 코드 더럽네!! 라고 생각할 수 있습니다. 저도 글 쓰면서 왜 이렇게 코드를 짰나 화가날 정도입니다. 위 코드들을 UML로 보면 아래와 같습니다.
서론이 너무 길었습니다. 바로 무엇이 문제인지 말해보죠. 제 기존 코드들은 객체지향 원칙들을 많이 위반하고 있습니다. 그래서 제가 답이 없다고 생각한 거고요. 좀 더 자세히 보기 전에 이 포스팅은 객체지향 원칙을 설명하는 글이 아니기 때문에 이 점은 생략하겠습니다.
AuthService
가 사용자 인증 이라는 자신의 핵심 책임을 넘어, 각 소셜 로그인 전략의 내부 동작 절차를 직접 조율하는 부가적인 책임까지 지게 되었습니다. getToken, getuserInfo, extractUserInfo, getSocialToken 이렇게 각 전략의 메서드를 순서대로 호출하는 것은 AuthService
가 하위 모듈의 상세 구현에 지나치게 관여하는 것으로, 이는 명백한 SRP 위반입니다.
추상 클래스에 함수들을 쪼개다 보니, 굉장히 유지보수에 안 좋아졌습니다. 확장에 열려있어야 되는데, 그렇지 못하게 됐습니다. 만약에 애플 로그인에선 조금 다르게 처리하고 싶다고 했을 때 조건문으로 처리해야 하죠.
AppleStrategy
에서 어떤 함수를 추가하고 싶었습니다. 그러면 추상 클래스에도 추가해야 하죠. 그런데 이러면 어떤 문제가 발생하나요? 바로 KakaoStrategy
에서도 구현을 해야 한다는 것입니다. 카카오에서는 필요없지만 구현을 해야 하는 어이없는 상황이 생기죠. 그래서 중간 합의점을 찾아야 되나? 라는 생각을 진짜 많이 했습니다. 그래서 함수 이름들도 이상해지고 난리도 아니였습니다. 제가 정말 답 없다고 느낀 부분입니다.
겉보기에는 AuthService
가 SocialAuthBaseStrategy
라는 추상 클래스에 의존하여 DIP를 지키는 것처럼 보입니다. 하지만 실제로는 그 추상 클래스가 노출하는 여러 메서드들의 호출 순서, 즉 내부 구현 방식에 강하게 의존하고 있었습니다. 진정한 DIP는 무엇을 할지만 정의된 순수 추상화에 의존해야 하는데, 제 코드는 어떻게가 담긴 불완전한 추상화에 의존하여 유연성을 잃어버렸고, 결과적으로 수정의 지옥의 빠져버렸습니다.
최대한 간략하게 작성하면서도 원칙을 위반하지 않게 코드를 수정해봤습니다.
export interface ISocialLoginStrategy {
getSocialLoginRedirect(): string;
socialLogin(provider: AuthProvider, req: Request): Promise<OAuthInfo>;
}
일단 class -> interface 로 수정을 해봤습니다. 이거는 어차피 무엇을 해야 하는지 만을 원하는 것이지 어떻게 무엇을 해야 하는지까지 원하지 않기 때문에 interface 로 수정을 했습니다.
기존에 AuthService
에서 login
함수에 정말 많은 책임을 가지고 구현체에 의존을 하고 있었다면 socialLogin
이라는 함수로 합쳐서 책임을 분리하고 추상 인터페이스에 의존하게 했습니다.
// auth.service.ts
public async login(
req: Request,
provider: AuthProvider,
issuedBy: TokenIssuedBy,
) {
const strategy = this.socialAuthProviderMap[provider];
if (!strategy) {
throw new InternalServerErrorException();
}
const oauthInfo = await strategy.socialLogin(provider, req);
const userModel = await this.upsertUserByOauthInfo(oauthInfo);
return await this.loginTokenService.issueTokenSet(
{ idx: userModel.idx },
issuedBy,
);
}
private async upsertUserByOauthInfo(
oauthInfo: OAuthInfo,
): Promise<UserModel> {
const user = await this.userCoreService.getUserBySocialId(
oauthInfo.snsId,
oauthInfo.provider,
);
if (user) {
return user;
}
return await this.userCoreService.createUser({
nickname: '새로운 인후러',
profileImagePath: null,
social: {
provider: oauthInfo.provider,
snsId: oauthInfo.snsId,
},
});
}
그래서 이렇게 auth.service.ts
가 굉장히 깔끔해졌습니다.
그럼 이번엔 카카오와 애플 구현체를 봐볼까요? 그냥 어떻게 작성했는지 정도로만 봐주시면 감사하겠습니다.
// kakao.strategy.ts
@Injectable()
export class KakaoLoginStrategy implements ISocialLoginStrategy {
private readonly KAKAO_CLIENT_ID: string;
private readonly KAKAO_REDIRECT_URI: string;
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService,
) {
this.KAKAO_CLIENT_ID =
this.configService.get<string>('KAKAO_CLIENT_ID') ?? '';
this.KAKAO_REDIRECT_URI =
this.configService.get<string>('KAKAO_REDIRECT_URI') ?? '';
}
public getSocialLoginRedirect(): string {
return (
`https://kauth.kakao.com/oauth/authorize` +
'?response_type=code&' +
`redirect_uri=${this.KAKAO_REDIRECT_URI}&` +
`client_id=${this.KAKAO_CLIENT_ID}`
);
}
public async socialLogin(
provider: AuthProvider,
req: Request,
): Promise<OAuthInfo> {
const kakaoAccessToken = await this.getKakaoAccessToken(req);
const { data } =
await this.httpService.axiosRef.get<GetKakaoUserInfoResponseDto>(
'https://kapi.kakao.com/v2/user/me',
{
headers: {
Authorization: `Bearer ${kakaoAccessToken}`,
'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8',
},
},
);
return {
snsId: data.id.toString(),
provider,
};
}
private async getKakaoAccessToken(request: Request): Promise<string> {
if (request.query.token) {
return request.query.token as string;
}
const code = request.query.code as string;
const result =
await this.httpService.axiosRef.post<GetKakaoTokenResponseDto>(
'https://kauth.kakao.com/oauth/token',
new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.KAKAO_CLIENT_ID,
redirect_uri: this.KAKAO_REDIRECT_URI,
code: encodeURIComponent(code),
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } },
);
return result.data.access_token;
}
}
// apple.strategy.ts
@Injectable()
export class AppleLoginStrategy implements ISocialLoginStrategy {
private readonly APPLE_CLIENT_ID: string;
private readonly APPLE_CLIENT_SECRET: string;
private readonly APPLE_REDIRECT_URI: string;
private readonly jwksClient: JwksClient;
constructor(
private readonly configService: ConfigService,
private readonly httpService: HttpService,
private readonly jwtService: JwtService,
) {
this.APPLE_CLIENT_ID =
this.configService.get<string>('APPLE_CLIENT_ID') ?? '';
this.APPLE_CLIENT_SECRET =
this.configService.get<string>('APPLE_CLIENT_SECRET') ?? '';
this.APPLE_REDIRECT_URI =
this.configService.get<string>('APPLE_REDIRECT_URI') ?? '';
this.jwksClient = new JwksClient({
jwksUri: 'https://appleid.apple.com/auth/keys',
});
}
public getSocialLoginRedirect(): string {
return (
'https://kauth.kakao.com/oauth/authorize' +
`?client_id=${this.APPLE_CLIENT_ID}&` +
`redirect_uri=${encodeURIComponent(this.APPLE_REDIRECT_URI)}&` +
'response_type=code&' +
'scope=name%20email&' +
'response_mode=form_post&' +
'state=a_random_csrf_token_string_12345&' +
'nonce=another_random_string_for_nonce_67890'
);
}
public async socialLogin(
provider: AuthProvider,
req: Request,
): Promise<OAuthInfo> {
const appleIdToken = await this.getAppleIdToken(req);
const decodedToken = await this.decodeIdToken(appleIdToken);
return {
snsId: decodedToken.sub,
provider,
};
}
private async getAppleIdToken(req: Request): Promise<string> {
const code = req.body.code;
const result =
await this.httpService.axiosRef.post<GetAppleTokenResponseDto>(
'https://appleid.apple.com/auth/token',
new URLSearchParams({
client_id: this.APPLE_CLIENT_ID,
client_secret: this.APPLE_CLIENT_SECRET,
code: code,
grant_type: 'authorization_code',
redirect_uri: this.APPLE_REDIRECT_URI,
}),
);
return result.data.id_token;
}
private async decodeIdToken(idToken: string): Promise<GetAppleDecodedDto> {
const decodedToken = this.jwtService.decode(idToken, { complete: true });
const kid = decodedToken.header.kid;
const signingKey = await this.jwksClient.getSigningKey(kid);
const publicKey = signingKey.getPublicKey();
return verify(idToken, publicKey, {
algorithms: ['RS256'],
issuer: 'https://appleid.apple.com',
audience: this.APPLE_CLIENT_ID,
}) as GetAppleDecodedDto;
}
}
이제 카카오와 애플은 socialLogin
만 구현하면 됩니다. 그래서 쓸 데 없는 함수를 구현해야 하는 문제 없이 각각 자유롭게 구현을 할 수 있게 됐습니다.
최종 UML 을 보면 아래와 같습니다.
사실 그냥 passport 쓸 걸... 이라는 생각도 많이 했었는데 문제가 발생하고 해결을 하는 과정에서 많은 것들을 배워갈 수 있었습니다. 특히 객체지향 5원칙!!! 전공 수업 배울 때 '이런 거 왜 쓰는 거야?' 라고 궁시렁대며 공부했었는데... 정말 중요한 개념이었단 걸 뒤늦게 알았습니다.
제 코드가 완벽한 것은 아닙니다. 피드백 적극 환영입니다!
그럼 이상으로 포스팅 마치겠습니다.