들어가기 전에
추상 팩토리 패턴이란??
추상 팩토리 패턴(Abstract Factory Pattern)이란, 서로 관련된 객체들의 집합을 생성할 수 있는 인터페이스를 제공하면서 구체적인 클래스는 지정하지 않는 생성 패턴 중 하나이다. 즉, 클라이언트는 구체적인 클래스에 의존하지 않고, 인터페이스를 통해 객체를 생성하고 사용하게 된다. 이 패턴을 사용하면 객체 생성 로직을 클라이언트 코드에서 분리하고, 다양한 구체적인 클래스들을 교체할 수 있는 유연성을 제공할 수 있다.
소셜 로그인을 구현할 때 Provider 마다 CLIENT_ID, REDIRECT_URI, CLIENT_SECRET이 다르고 사용자 정보도 다르게 넘어온다. if문으로 분기처리하여 구현할 수 있지만 추상 팩토리 패턴을 사용하면 서비스 코드에서 매번 분기처리할 필요없이 Provider 별로 제공된 팩토리 객체의 메소드를 호출하여 관련 객체를 생성함으로써 유지보수가 쉽고 깔끔한 코드를 작성할 수 있게 된다.
구현하기
추상 팩토리 패턴은 객체 생성 코드를 인터페이스로 추상화하고 상황별로 구현 클래스를 작성한 후 타입을 추상화한 인터페이스로 선언하여 사용한다.
OAuthRequestBodyFactory
package com.plcok.common.oauth;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import java.util.Map;
public interface OAuthRequestBodyFactory {
MultiValueMap<String, String> createRequestBody(String token);
default MultiValueMap<String, String> createDefaultRequestBody(
String clientId,
String redirectUri,
String createSecret,
String code
) {
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "authorization_code");
body.add("client_id", clientId);
body.add("redirect_uri", redirectUri);
body.add("client_secret", createSecret);
body.add("code", code);
return body;
}
String getRequestUrl();
String getUserInfoRequestUrl();
OAuth2Attributes createOauthAttribute(Map<String, Object> map);
}
객체 생성 코드를 추상화한 인터페이스이다.
- createRequestBody : AccessToken을 받아오기 위해 작성해야 할 RequestBody 생성 메소드
- createDefaultRequestBody : CLIENT_ID, REDIRECT_URI, CLIENT_SECRET, Code를 파라미터로 받아 RequestBody를 생성. 템플릿은 동일하기 때문에 디폴트 메소드로 선언, createRequestBody를 호출했을 때 구현부에서 호출한다.
- getRequestUrl : AccessToken을 받아오기 위해 요청해야 할 URL
- getUserInfoRequestUrl : AccessToken으로 사용자 정보를 받아오기 위해 요청해야 할 URL
- createOauthAttribute : 조회한 사용자 정보를 담아둘 객체를 생성하는 메소드
OAuthRequestBodyFactory
package com.plcok.common.oauth;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import java.util.Map;
@Component
public class KakaoRequestBodyFactory implements OAuthRequestBodyFactory {
@Value("${spring.security.oauth2.client.registration.kakao.client-id}")
private String CLIENT_ID;
@Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}")
private String REDIRECT_URI;
@Value("${spring.security.oauth2.client.registration.kakao.client-secret}")
private String CLIENT_SECRET;
@Override
public MultiValueMap<String, String> createRequestBody(String code) {
return createDefaultRequestBody(CLIENT_ID, REDIRECT_URI, CLIENT_SECRET, code);
}
@Override
public String getRequestUrl() {
return "https://kauth.kakao.com/oauth/token";
}
@Override
public String getUserInfoRequestUrl() {
return "https://kapi.kakao.com/v2/user/me";
}
@Override
public OAuth2Attributes createOauthAttribute(Map<String, Object> map) {
return OAuth2Attributes.ofKakao(map);
}
}
Provider가 카카오일 때 OAuthRequestBodyFactory 인터페이스에서 추상화한 메소드를 구현한 클래스이다. createRequestBody 메소드에서는 인터페이스에서 선언한 디폴트 메소드를 호출하고 있으며 createOauthAttribute 메소드에서는 OAuth2Attributes 클래스에서 선언한 카카오 사용자 정보를 생성하는 정적 팩토리 메소드를 호출하고 있다.
application.yml 파일에서 선언한 CLIENT_ID, REDIRECT_URI, CLIENT_SECRET 정보를 가져오기 위해 @Component 어노테이션을 선언하고 컴포넌트 스캔의 대상이 되도록 하여 빈 객체로 등록했다.
Provider가 구글인 경우 팩토리 객체도 구현하자
GoogleRequestBodyFactory
package com.plcok.common.oauth;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import java.util.Map;
@Component
public class GoogleRequestBodyFactory implements OAuthRequestBodyFactory {
@Value("${spring.security.oauth2.client.registration.google.client-id}")
private String CLIENT_ID;
@Value("${spring.security.oauth2.client.registration.google.redirect-uri}")
private String REDIRECT_URI;
@Value("${spring.security.oauth2.client.registration.google.client-secret}")
private String CLIENT_SECRET;
@Override
public MultiValueMap<String, String> createRequestBody(String code) {
return createDefaultRequestBody(CLIENT_ID, REDIRECT_URI, CLIENT_SECRET, code);
}
@Override
public String getRequestUrl() {
return "https://www.googleapis.com/oauth2/v4/token";
}
@Override
public String getUserInfoRequestUrl() {
return "https://www.googleapis.com/oauth2/v2/userinfo";
}
@Override
public OAuth2Attributes createOauthAttribute(Map<String, Object> map) {
return OAuth2Attributes.ofGoogle(map);
}
}
OAuth2Attributes
package com.plcok.common.oauth;
import com.plcok.user.entity.enumerated.ProviderType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.Map;
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class OAuth2Attributes {
private String email;
private ProviderType providerType;
private String providerUserId;
public static OAuth2Attributes ofKakao(Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
String email = String.valueOf(kakaoAccount.get("email"));
String providerUserId = String.valueOf(attributes.get("id"));
return builder()
.email(email)
.providerType(ProviderType.KAKAO)
.providerUserId(providerUserId)
.build();
}
public static OAuth2Attributes ofGoogle(Map<String, Object> attributes) {
String email = String.valueOf(attributes.get("email"));
String providerUserId = String.valueOf(attributes.get("id"));
return builder()
.email(email)
.providerType(ProviderType.GOOGLE)
.providerUserId(providerUserId)
.build();
}
}
처음에 언급했듯이 인증 서버로부터 넘어오는 사용자 정보도 Provider 별로 다르다. 인증 서버로부터 넘어온 사용자 정보를 담아둘 객체이다. Provider 별로 정적 팩토리 메서드를 선언했다.
AuthUserController
package com.plcok.user.controller;
import com.plcok.user.dto.response.SocialLoginResponse;
import com.plcok.user.service.AuthUserService;
import com.plcok.common.oauth.GoogleRequestBodyFactory;
import com.plcok.common.oauth.KakaoRequestBodyFactory;
import com.fasterxml.jackson.core.JsonProcessingException;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
@Tag(name = "인증 관련 API")
public class AuthUserController {
private final AuthUserService authUserService;
private final GoogleRequestBodyFactory googleRequestBodyFactory;
private final KakaoRequestBodyFactory kakaoRequestBodyFactory;
@GetMapping("/user/kakao/callback")
public ResponseEntity<SocialLoginResponse> kakaoLogin(@RequestParam("code") String code) throws JsonProcessingException {
String accessToken = authUserService.getToken(code, kakaoRequestBodyFactory);
return ResponseEntity.status(HttpStatus.OK)
.body(authUserService.login(accessToken, kakaoRequestBodyFactory));
}
@GetMapping("/user/google/callback")
public ResponseEntity<SocialLoginResponse> googleLogin(@RequestParam("code") String code) throws JsonProcessingException {
String accessToken = authUserService.getToken(code, googleRequestBodyFactory);
return ResponseEntity.status(HttpStatus.OK)
.body(authUserService.login(accessToken, googleRequestBodyFactory));
}
}
소셜 로그인 요청을 받는 RestController이다. Provider 별로 API를 생성하고 구현 팩토리를 파라미터로 넘겼다.
getToken
@Override
@Transactional(readOnly = true)
public String getToken(String code, OAuthRequestBodyFactory factory) throws JsonProcessingException {
// HTTP Header 생성
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
// HTTP Body 생성
MultiValueMap<String, String> body = factory.createRequestBody(code);
// HTTP 요청 보내기
HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(body, headers);
RestTemplate rt = new RestTemplate();
ResponseEntity<String> response = rt.exchange(
factory.getRequestUrl(),
HttpMethod.POST,
tokenRequest,
String.class
);
// HTTP 응답 (JSON) -> 액세스 토큰 파싱
String responseBody = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(responseBody);
return jsonNode.get("access_token").asText();
}
인증 서버로부터 AccessToken을 받아오는 getToken 메소드이다. HTTP Body를 생성하는 코드를 보면 factory.createRequestBody 메소드를 호출하고 있는데 OAuthRequestBodyFactory 인터페이스를 파라미터로 받기 때문에 호출 클래스에서 어떤 구현 팩토리를 넘기느냐에 따라 다른 RequestBody를 생성한다. 아래는 전체 서비스 코드이다.
AuthUserService
package com.plcok.user.service;
import com.plcok.common.oauth.OAuth2Attributes;
import com.plcok.common.oauth.OAuthRequestBodyFactory;
import com.plcok.user.entity.UserProvider;
import com.plcok.common.security.JwtUtil;
import com.plcok.user.entity.User;
import com.plcok.user.dto.response.SocialLoginResponse;
import com.plcok.user.repository.UserProviderRepository;
import com.plcok.user.repository.UserRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthUserServiceImpl implements AuthUserService {
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
private final UserProviderRepository userProviderRepository;
@Override
@Transactional
public SocialLoginResponse login(String token, OAuthRequestBodyFactory factory) {
Map<String, Object> map = getUserAttributes(factory, token);
OAuth2Attributes attributes = factory.createOauthAttribute(map);
User user = findOrCreateUser(attributes);
return SocialLoginResponse
.from(jwtUtil.createToken(user.getId(), user.getRole(), user.getCreatedAt()));
}
private User findOrCreateUser(OAuth2Attributes attributes) {
return userProviderRepository.findByProviderTypeAndProviderUserId(attributes.getProviderType(), attributes.getProviderUserId())
.map(UserProvider::getUser)
.orElseGet(() -> createUser(attributes));
}
private User createUser(OAuth2Attributes attributes) {
User user = new User();
userRepository.save(user);
UserProvider userProvider = UserProvider.of(attributes.getProviderType(), user, attributes.getProviderUserId());
userProviderRepository.save(userProvider);
return user;
}
private Map<String, Object> getUserAttributes(OAuthRequestBodyFactory factory, String token) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(headers);
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> response = restTemplate
.exchange(factory.getUserInfoRequestUrl(), HttpMethod.GET, request, String.class);
return parseResponseBody(response.getBody());
}
private Map<String, Object> parseResponseBody(String responseBody) {
try {
return new ObjectMapper().readValue(responseBody, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
throw new RuntimeException("Failed to parse user attributes", e);
}
}
@Override
@Transactional(readOnly = true)
public String getToken(String code, OAuthRequestBodyFactory factory) throws JsonProcessingException {
// HTTP Header 생성
HttpHeaders headers = new HttpHeaders();
headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8");
// HTTP Body 생성
MultiValueMap<String, String> body = factory.createRequestBody(code);
// HTTP 요청 보내기
HttpEntity<MultiValueMap<String, String>> tokenRequest = new HttpEntity<>(body, headers);
RestTemplate rt = new RestTemplate();
ResponseEntity<String> response = rt.exchange(
factory.getRequestUrl(),
HttpMethod.POST,
tokenRequest,
String.class
);
// HTTP 응답 (JSON) -> 액세스 토큰 파싱
String responseBody = response.getBody();
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(responseBody);
return jsonNode.get("access_token").asText();
}
}
추상 팩토리 패턴을 사용한 이 구조는 Provider 별로 다른 요청 처리를 깔끔하게 분리해준다. 새로운 Provider가 추가될 때도 기존 코드를 크게 수정하지 않고 팩토리 객체만 구현하면 되므로, 유지보수가 용이하고 코드의 복잡도를 줄일 수 있다.
'Spring' 카테고리의 다른 글
[Spring] 상속 구조와 Static Inner Class로 Dto 클래스 관리하기 (0) | 2024.09.21 |
---|---|
[Spring] Spring AOP를 사용하여 사용자 권한 체크하기 (0) | 2024.09.17 |
헥사고날 아키텍처(Hexagonal Architecture) (0) | 2024.01.19 |
[Spring] Master, Slave 데이터베이스 구조로 쓰기, 읽기 연산 나누기 (0) | 2023.07.28 |
[Spring] 디자인 패턴 - 전략 패턴(Strategy Pattern) (0) | 2023.07.01 |