현재 뮤피에서 소셜 로그인을 OIDC를 사용해서 구현하고 있다.
카카오, 구글, 애플 로그인을 제공하는데, OIDC도 OAuth2.0의 확장 스펙이다 보니 각 로그인의 구현 방식이 서로 같다. 현재 코드가 구현에만 신경을 쓰다 보니 카카오 로그인 먼저 구현하고 나머지는 복붙해서 변경 부분만 바꿔둔 상태였다.
“객체 지향과 디자인 패턴”이라는 책을 읽는 중에 디자인 패턴 중 하나인 “템플릿 메서드 패턴” 파트를 읽다가 소셜 로그인 부분에 적용할 수 있을 것 같아서 미뤄놨던 리팩토링을 해보려고 한다.
OIDC를 어떻게 구현했는지는 아래의 링크에 자세히 설명해두었다.
https://aodtns.tistory.com/124
템플릿 메서드 패턴?
템플릿 메서드 패턴은 여러 클래스에서 공통으로 사용하는 메서드들을 템플릿으로 만들어 상위 클래스에 정의하고, 하위 클래스에서 세부 동작 사항만 다르게 구현하는 패턴이다.
상위 클래스에는 변경되지 않는 사항들을 구현해 두고, 하위 클래스에서 변경 사항들만 다르게 구현하는 방식이라고 생각하면 된다.
위의 DbAuthenticator와 LdapAuthenticator의 구현 방식을 보면, 두 구현체 모두 아래와 같은 절차를 따른다.
- 사용자 정보(id, pw)로 인증 확인
- 인증 실패 시 예외 발생
- 인증 성공 시, 인증 정보(Auth) 제공
이때 우리는 뭔가 코드를 줄일 수 있을 것 같다는 생각이 머릿속에 스칠 것이다.
패턴 적용
public abstract Authenticator {
// 템플릿 메서드
public Auth authenticate(String id, String pw) {
if (!doAuthenticate(id, pw)) {
throw createException();
}
return createAuth(id);
}
protected abstract boolean doAuthenticate(String id, String pw);
private RuntimeException createException() {
throw new AuthException();
}
protected abstract Auth createAuth(String id);
}
추상 클래스를 사용하여 public 접근제어자로 authenticate 메서드를 제공을 한다. 그래야 하위 클래스를 사용하는 클래스에서 해당 메서드를 사용할 수 있다.
이 메서드가 (1) 사용자 정보로 인증을 하고, (2) 인증에 실패하면 예외가 발생하며, (3) 성공한다면 인증 정보를 제공하는 로직을 구현한 템플릿 메서드이다.
이제 DbAuthenticator와 LdapAuthenticator의 구현 방식의 차이점을 보자.
DbAuthenticator와 LdapAuthenticator는 모두 (2) 인증 실패 시 같은 예외가 발생한다. 이는 구현 방식마다 변하지 않기 때문에 private 접근제어자를 사용하여 상위 클래스에 미리 구현해 둔다. (createException)
(1)과 (3)은 각 구현체마다 다르기 때문에 추상 메서드로 어떻게 구현할지 하위 클래스에게 맡긴다.
패턴 구현
public class LdapAuthenticator extends Authenticator {
@Override
protected boolean doAuthenticate(String id, String pw) {
return ldapClient.authenticate(id, pw);
}
@Override
protected Auth createAuth(String id) {
LdapContext ctx = ldapClient.find(id);
return new Auth(id, ctx.getAttribute("name"));
}
}
위에서 만든 Authenticator를 상속받고, 정의한 메서드들을 구현체에 맞게 구현해 주면 된다.
OAuthHelper에 템플릿 메서드 패턴 적용하기
이제 템플릿 메서드 패턴을 사용해서 소셜 로그인을 리팩토링 해보자.
GoogleOAuthHelper와 AppleOAuthHelper
@Component
@RequiredArgsConstructor
public class GoogleOAuthHelper {
private final OAuthOIDCHelper oauthOIDCHelper;
private final GoogleOAuthClient googleOAuthClient;
@Value("${oauth.google.url.auth}")
private String iss;
@Value("${oauth.google.client-id}")
private String aud;
public OIDCDecodePayload getOIDCDecodePayload(String token) {
OIDCPublicKeysResponse oidcPublicKeysResponse = googleOAuthClient.getGoogleOIDCOpenKeys();
return oauthOIDCHelper.getPayloadFromIdToken(
token,
iss,
aud,
oidcPublicKeysResponse
);
}
}
@Component
@RequiredArgsConstructor
public class AppleOAuthHelper {
private final OAuthOIDCHelper oauthOIDCHelper;
private final AppleOAuthClient appleOAuthClient;
@Value("${oauth.apple.url.auth}")
private String iss;
@Value("${oauth.apple.client-id}")
private String aud;
public OIDCDecodePayload getOIDCDecodePayload(String token) {
OIDCPublicKeysResponse oidcPublicKeysResponse = appleOAuthClient.getAppleOIDCOpenKeys();
return oauthOIDCHelper.getPayloadFromIdToken(
token,
iss,
aud,
oidcPublicKeysResponse
);
}
}
민망하지만 복붙으로 구현해 놔서 두 코드는 매우 비슷하다.
각 OAuthHelper는 getOIDCDecodePayload라는 메서드 한 개만 제공하고 있고, (1) 각 IDP에 해당하는 OAuthClient로 키 값을 가져오고, (2) 해당 키로 Payload를 만드는 방식이다.
IDP마다 변경되는 점은 아래와 같다.
- OAuthClient
- @Value를 사용한 프로퍼티 주입 부분
패턴을 적용하기 전에 각 OAuthClient를 인터페이스로 빼서 다형성을 활용할 수 있게 만들었다.
public interface OAuthClient {
OIDCPublicKeysResponse getOIDCOpenKeys();
}
OAuthHelper - 상위 클래스
@RequiredArgsConstructor
public abstract class OAuthHelper {
private final OAuthClient oAuthClient;
public OIDCDecodePayload getOIDCDecodePayload(String token) {
OIDCPublicKeysResponse oidcOpenKeys = getOIDCOpenKeys();
return getPayloadFromIdToken(token, oidcOpenKeys);
}
private OIDCPublicKeysResponse getOIDCOpenKeys() {
return oAuthClient.getOIDCOpenKeys();
}
protected abstract OIDCDecodePayload getPayloadFromIdToken(String token, OIDCPublicKeysResponse oidcPublicKeysResponse);
}
(1) 각 IDP에 해당하는 OAuthClient로 키를 가져오고, (2) 해당 키로 Payload를 만들어 반환하는 getOIDCDecodePayload라는 템플릿 메서드를 만들었다.
(1)은 IDP마다 변하지만 다형성을 활용할 수 있기 때문에, 위와 같이 OAuthClient를 주입하여 구현을 하였다.
(2)는 iss와 aud에 따라 변하기 때문에 하위 클래스에서 구현해줘야 한다.
GoogleOAuthHelper - 하위 클래스
@Component
public class GoogleOAuthHelper extends OAuthHelper {
private final OAuthOIDCHelper oauthOIDCHelper;
@Value("${oauth.google.url.auth}")
private String iss;
@Value("${oauth.google.client-id}")
private String aud;
public GoogleOAuthHelper(GoogleOAuthClient googleOAuthClient, OAuthOIDCHelper oauthOIDCHelper) {
super(googleOAuthClient);
this.oauthOIDCHelper = oauthOIDCHelper;
}
@Override
protected OIDCDecodePayload getPayloadFromIdToken(String token, OIDCPublicKeysResponse oidcPublicKeysResponse) {
return oauthOIDCHelper.getPayloadFromIdToken(token, iss, aud, oidcPublicKeysResponse);
}
}
하위 클래스에서는 getPayloadFromIdToken 메서드만 적절히 구현해 주면 된다.
참고로 부모에서 상속받은 필드들은 자동으로 생성된 생성자에 추가되지 않기 때문에 @RequiredArgsConstructor는 하위 클래스에서 사용하면 안된다. 이 경우에는 직접 생성자를 선언해서 상속 받은 필드들도 super 키워드로 의존성을 주입시켜줘야 한다.
OAuthLoginService에 템플릿 메서드 패턴 적용하기
템플릿 메서드 패턴을 활용하여 OAuthHelper를 리팩토링 해보았다. 이제 OAuthLoginService를 리팩토링 해보자.
GoogleOAuthLoginService와 AppleOAuthLoginService
@Service
@RequiredArgsConstructor
@Transactional
public class GoogleOAuthLoginService {
private final GoogleOAuthHelper googleOAuthHelper;
private final MemberRepository memberRepository;
private final JwtUtil jwtUtil;
private static final String GRANT_TYPE = "Bearer";
public AuthTokens login(OAuthLoginParams oAuthLoginParams) {
String idToken = oAuthLoginParams.getIdToken();
OIDCDecodePayload oidcDecodePayload = googleOAuthHelper.getOIDCDecodePayload(idToken);
OAuthInfo oauthInfo = OAuthInfo.builder()
.provider(OAuthProvider.GOOGLE)
.oid(oidcDecodePayload.getSub())
.build();
MemberDetail memberDetail = MemberDetail.builder()
.email(oidcDecodePayload.getEmail())
.profileImage(oidcDecodePayload.getPicture())
.name(Optional.ofNullable(oidcDecodePayload.getName()).orElse(oidcDecodePayload.getNickname()))
.build();
Member member = findOrCreateMember(oauthInfo, memberDetail);
Long memberId = member.getId();
return generateAuthTokens(memberId);
}
private Member findOrCreateMember(OAuthInfo oauthInfo, MemberDetail memberDetail) {
return memberRepository.findByOauthInfo(oauthInfo)
.orElseGet(() -> createMember(oauthInfo, memberDetail));
}
private Member createMember(OAuthInfo oauthInfo, MemberDetail memberDetail) {
Member member = Member.builder()
.email(memberDetail.getEmail())
.name(memberDetail.getName())
.profileImage(memberDetail.getProfileImage())
.oauthInfo(oauthInfo)
.build();
return memberRepository.save(member);
}
private AuthTokens generateAuthTokens(Long memberId) {
String accessToken = jwtUtil.generateAccessToken(memberId);
String refreshToken = jwtUtil.generateRefreshToken(memberId);
return AuthTokens.of(accessToken, refreshToken, GRANT_TYPE);
}
}
@Service
@RequiredArgsConstructor
@Transactional
public class AppleOAuthLoginService {
private final AppleOAuthHelper appleOAuthHelper;
private final MemberRepository memberRepository;
private final JwtUtil jwtUtil;
private static final String GRANT_TYPE = "Bearer";
public AuthTokens login(OAuthLoginParams oAuthLoginParams) {
String idToken = oAuthLoginParams.getIdToken();
OIDCDecodePayload oidcDecodePayload = appleOAuthHelper.getOIDCDecodePayload(idToken);
OAuthInfo oauthInfo = OAuthInfo.builder()
.provider(OAuthProvider.APPLE)
.oid(oidcDecodePayload.getSub())
.build();
MemberDetail memberDetail = MemberDetail.builder()
.email(oidcDecodePayload.getEmail())
.profileImage(oidcDecodePayload.getPicture())
.name(Optional.ofNullable(oidcDecodePayload.getName()).orElse(oidcDecodePayload.getNickname()))
.build();
Member member = findOrCreateMember(oauthInfo, memberDetail);
Long memberId = member.getId();
return generateAuthTokens(memberId);
}
private Member findOrCreateMember(OAuthInfo oauthInfo, MemberDetail memberDetail) {
return memberRepository.findByOauthInfo(oauthInfo)
.orElseGet(() -> createMember(oauthInfo, memberDetail));
}
private Member createMember(OAuthInfo oauthInfo, MemberDetail memberDetail) {
Member member = Member.builder()
.email(memberDetail.getEmail())
.name("apple_"+ memberDetail.getEmail().substring(0, memberDetail.getEmail().indexOf('@'))) // Apple을 구현할 때 다른 부분
.profileImage(memberDetail.getProfileImage())
.oauthInfo(oauthInfo)
.build();
return memberRepository.save(member);
}
private AuthTokens generateAuthTokens(Long memberId) {
String accessToken = jwtUtil.generateAccessToken(memberId);
String refreshToken = jwtUtil.generateRefreshToken(memberId);
return AuthTokens.of(accessToken, refreshToken, GRANT_TYPE);
}
}
코드가 꽤 길지만 간단하게 요약하면, 두 구현체 모두 아래의 절차를 따른다.
- 클라이언트에서 넘겨준 id 토큰을 가져온다.
- id 토큰으로 OIDC Payload를 만든다.
- OAuthProvider와 만든 Payload로 OAuthInfo를 만든다.
- Payload로 회원 정보들을 가져온다.
- OAuthInfo와 회원 정보로 로그인하거나 회원가입하여 memberId를 가져온다.
- AccessToken과 RefreshToken을 만든다.
Google과 다르게 Apple 로그인의 경우 nickname이나 name을 제공하지 않는 경우가 있어서, 이름을 저장할 때 추가 로직이 들어간다. 이런 경우는 하위 클래스에서 적절하게 구현해야 한다.
그러므로 추가 로직이 들어야 하는 5번을 제외한 단계는 상위 클래스에 만들 수 있다.
OAuthLoginService - 상위 클래스
@RequiredArgsConstructor
public abstract class OAuthLoginService {
private final OAuthHelper oAuthHelper;
private final JwtUtil jwtUtil;
private final OAuthProvider oAuthProvider;
private static final String GRANT_TYPE = "Bearer";
public AuthTokens login(OAuthLoginParams oAuthLoginParams) {
String idToken = oAuthLoginParams.getIdToken();
OIDCDecodePayload oidcDecodePayload = getOIDCDecodePayload(idToken);
OAuthInfo oauthInfo = createOAuthInfo(oidcDecodePayload);
MemberDetail memberDetail = createMemberDetail(oidcDecodePayload);
Member member = findOrCreateMember(oauthInfo, memberDetail);
Long memberId = member.getId();
return generateAuthTokens(memberId);
}
private OIDCDecodePayload getOIDCDecodePayload(String token) {
return oAuthHelper.getOIDCDecodePayload(token);
}
private OAuthInfo createOAuthInfo(OIDCDecodePayload oidcDecodePayload) {
return OAuthInfo.builder()
.provider(oAuthProvider)
.oid(oidcDecodePayload.getSub())
.build();
}
private MemberDetail createMemberDetail(OIDCDecodePayload oidcDecodePayload) {
return MemberDetail.builder()
.email(oidcDecodePayload.getEmail())
.profileImage(oidcDecodePayload.getPicture())
.name(Optional.ofNullable(oidcDecodePayload.getName()).orElse(oidcDecodePayload.getNickname()))
.build();
}
protected abstract Member findOrCreateMember(OAuthInfo oAuthInfo, MemberDetail memberDetail);
protected abstract Member createMember(OAuthInfo oAuthInfo, MemberDetail memberDetail);
private AuthTokens generateAuthTokens(Long memberId) {
String accessToken = jwtUtil.generateAccessToken(memberId);
String refreshToken = jwtUtil.generateRefreshToken(memberId);
return AuthTokens.of(accessToken, refreshToken, GRANT_TYPE);
}
}
OAuthHelper를 템플릿 메서드 패턴으로 구현해서, 다형성을 활용할 수 있기 때문에 상위 클래스에 묶을 수 있었다.
또한 회원 정보와 인증 정보를 만들고, Access, Refresh Token을 만드는 로직을 상위 클래스에 두어 캡슐화를 높였다.
GoogleOAuthLogin - 하위 클래스
@Service
@Transactional
public class GoogleOAuthLoginService extends OAuthLoginService {
private final MemberRepository memberRepository;
public GoogleOAuthLoginService(GoogleOAuthHelper googleOAuthHelper, JwtUtil jwtUtil, MemberRepository memberRepository) {
super(googleOAuthHelper, jwtUtil, OAuthProvider.GOOGLE);
this.memberRepository = memberRepository;
}
@Override
protected Long findOrCreateMember(OAuthInfo oAuthInfo, MemberDetail memberDetail) {
return memberRepository.findByOauthInfo(oAuthInfo)
.map(Member::getId)
.orElseGet(() -> createMember(oAuthInfo, memberDetail));
}
@Override
protected Long createMember(OAuthInfo oAuthInfo, MemberDetail memberDetail) {
Member member = Member.builder()
.email(memberDetail.getEmail())
.name(memberDetail.getName())
.profileImage(memberDetail.getProfileImage())
.oauthInfo(oAuthInfo)
.build();
return memberRepository.save(member).getId();
}
}
Repository에 접근해야 하는 부분(영속성 컨텍스트를 활용해야 하는 부분)을 추상 클래스에 두는 것은 좋지 않아 보여서, 하위 클래스에 구현하였다. (IDP마다 제공 정보가 다른 이유도 있다.)
이렇게 하면 IDP가 늘어나도, 서비스에서 회원가입처리하는 로직만 적절히 구현하면 손쉽게 확장할 수 있다.
마무리
리팩토링 하기 전에는 구현에 의존하고 있는 부분(DIP를 위반하는 부분)이 많았다.
중복되는 코드를 최대한 줄이고, 구현보다는 추상화에 의존하게 만들면서 결합도 낮추려고 애를 많이 썼던 것 같다.
템플릿 메서드 패턴을 사용하다 보니 로직 자체의 변경이 거의 없는 경우 사용하기 좋은 것 같다. 소셜 로그인 같은 경우에 적용하면, OAuth 프로토콜의 절차를 따라하다보니 변경이 거의 없어서, 적용해 두면 추후 IDP가 추가되어도 쉽게 확장할 수 있다는 장점이 있다.
'BE > Spring Boot' 카테고리의 다른 글
@Cacheable로 API 성능 개선하기 (with Redis, Scheduler, AOP) (0) | 2023.11.11 |
---|---|
Open ID Connect 으로 구글 로그인 구현하기 (feat. SpringBoot) (1) | 2023.11.02 |
Jwt와 Interceptor 사용 시 CORS 에러가 발생하는 이유 (0) | 2023.06.09 |
로그인 시 id가 없을 때 status code는 200? 404? (0) | 2023.03.31 |
Spring Boot + Docker로 배포하기 (0) | 2022.07.24 |