달록 기술 블로그 방문하기 (opens new window)
이 글은 우테코 달록팀 크루 '매트 (opens new window)'가 작성했습니다.
# 외부와 의존성 분리하기
도메인 로직은 우리가 지켜야할 매우 소중한 비즈니스 로직들이 담겨있다. 이러한 도메인 로직들은 변경이 최소화되어야 한다. 그렇기 때문에 외부와의 의존성을 최소화 해야 한다.
# 인터페이스 활용하기
우선 우리가 지금까지 학습한 것 중 객체 간의 의존성을 약하게 만들어 줄 수 있는 수단으로 인터페이스를 활용할 수 있다. 간단한 예시로 JpaRepository
를 살펴보자.
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(final String email);
boolean existsByEmail(final String email);
}
이러한 인터페이스 덕분에 우리는 실제 DB에 접근하는 내부 구현에 의존하지 않고 데이터를 조작할 수 있다. 핵심은 실제 DB에 접근하는 행위
이다.
아래는 Spring Data
가 만든 JpaRepository의 구현체
SimpleJpaRepository
의 일부를 가져온 것이다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
private static final String ID_MUST_NOT_BE_NULL = "The given id must not be null!";
private final JpaEntityInformation<T, ?> entityInformation;
private final EntityManager em;
private final PersistenceProvider provider;
private @Nullable CrudMethodMetadata metadata;
private EscapeCharacter escapeCharacter = EscapeCharacter.DEFAULT;
public SimpleJpaRepository(JpaEntityInformation<T, ?> entityInformation, EntityManager entityManager) {
Assert.notNull(entityInformation, "JpaEntityInformation must not be null!");
Assert.notNull(entityManager, "EntityManager must not be null!");
this.entityInformation = entityInformation;
this.em = entityManager;
this.provider = PersistenceProvider.fromEntityManager(entityManager);
}
...
}
해당 구현체는 entityManger
를 통해 객체를 영속 시키는 행위를 진행하고 있기 때문에 영속 계층
에 가깝다고 판단했다. 즉 도메인의 입장에서 MemberRepository
를 바라볼 때 단순히 JpaRepository
를 상속한 인터페이스를 가지고 있기 때문에 영속 계층
에 대한 직접적인 의존성은 없다고 봐도 무방하다. 정리하면 우리는 인터페이스를 통해 실제 구현체에 의존하지 않고 로직을 수행할 수 있게 된다.
# 관점 변경하기
이러한 사례를 외부 서버와 통신을 담당하는 우리가 직접 만든 인터페이스인 OAuthClient
에 대입해본다. OAuthClient
의 가장 큰 역할은 n의 소셜에서 OAuth 2.0
을 활용한 인증의 행위를 정의한 인터페이스이다. google, github 등 각자에 맞는 요청을 처리하기 위해 OAuthClient
를 구현한 뒤 로직을 처리할 수 있다. 아래는 실제 google의 인가 코드를 기반으로 토큰 정보에서 회원 정보를 조회하는 로직을 담고 있다.
public interface OAuthClient {
OAuthMember getOAuthMember(final String code);
}
@Component
public class GoogleOAuthClient implements OAuthClient {
private static final String JWT_DELIMITER = "\\.";
private final String googleRedirectUri;
private final String googleClientId;
private final String googleClientSecret;
private final String googleTokenUri;
private final RestTemplate restTemplate;
private final ObjectMapper objectMapper;
public GoogleOAuthClient(@Value("${oauth.google.redirect_uri}") final String googleRedirectUri,
@Value("${oauth.google.client_id}") final String googleClientId,
@Value("${oauth.google.client_secret}") final String googleClientSecret,
@Value("${oauth.google.token_uri}") final String googleTokenUri,
final RestTemplate restTemplate, final ObjectMapper objectMapper) {
this.googleRedirectUri = googleRedirectUri;
this.googleClientId = googleClientId;
this.googleClientSecret = googleClientSecret;
this.googleTokenUri = googleTokenUri;
this.restTemplate = restTemplate;
this.objectMapper = objectMapper;
}
@Override
public OAuthMember getOAuthMember(final String code) {
GoogleTokenResponse googleTokenResponse = requestGoogleToken(code);
String payload = getPayloadFrom(googleTokenResponse.getIdToken());
String decodedPayload = decodeJwtPayload(payload);
try {
return generateOAuthMemberBy(decodedPayload);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException();
}
}
private GoogleTokenResponse requestGoogleToken(final String code) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> params = generateRequestParams(code);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
return restTemplate.postForEntity(googleTokenUri, request, GoogleTokenResponse.class).getBody();
}
private MultiValueMap<String, String> generateRequestParams(final String code) {
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("client_id", googleClientId);
params.add("client_secret", googleClientSecret);
params.add("code", code);
params.add("grant_type", "authorization_code");
params.add("redirect_uri", googleRedirectUri);
return params;
}
private String getPayloadFrom(final String jwt) {
return jwt.split(JWT_DELIMITER)[1];
}
private String decodeJwtPayload(final String payload) {
return new String(Base64.getUrlDecoder().decode(payload), StandardCharsets.UTF_8);
}
private OAuthMember generateOAuthMemberBy(final String decodedIdToken) throws JsonProcessingException {
Map<String, String> userInfo = objectMapper.readValue(decodedIdToken, HashMap.class);
String email = userInfo.get("email");
String displayName = userInfo.get("name");
String profileImageUrl = userInfo.get("picture");
return new OAuthMember(email, displayName, profileImageUrl);
}
}
보통의 생각은 인터페이스인 OAuthClient
와 구현체인 GoogleOAuthClient
를 같은 패키지에 두려고 할 것이다. GoogleOAuthClient
는 외부 의존성을 강하게 가지고 있기 때문에 domain
패키지와 별도로 관리하기 위한 infrastructure
패키지가 적합할 것이다. 결국 인터페이스인 OAuthClient
또한 infrastructure
에 위치하게 될 것이다. 우리는 이러한 생각에서 벗어나 새로운 관점에서 살펴봐야 한다.
앞서 언급한 의존성에 대해 생각해보자. 위 OAuthClient
를 사용하는 주체는 누구일까? 우리는 이러한 주체를 domain
내에 인증을 담당하는 auth
패키지 내부의 Authservice
로 결정 했다. 아래는 실제 OAuthClient
를 사용하고 있는 주체인 AuthService
이다.
@Transactional(readOnly = true)
@Service
public class AuthService {
private final OAuthEndpoint oAuthEndpoint;
private final OAuthClient oAuthClient;
private final MemberService memberService;
private final JwtTokenProvider jwtTokenProvider;
public AuthService(final OAuthEndpoint oAuthEndpoint, final OAuthClient oAuthClient,
final MemberService memberService, final JwtTokenProvider jwtTokenProvider) {
this.oAuthEndpoint = oAuthEndpoint;
this.oAuthClient = oAuthClient;
this.memberService = memberService;
this.jwtTokenProvider = jwtTokenProvider;
}
public String generateGoogleLink() {
return oAuthEndpoint.generate();
}
@Transactional
public TokenResponse generateTokenWithCode(final String code) {
OAuthMember oAuthMember = oAuthClient.getOAuthMember(code);
String email = oAuthMember.getEmail();
if (!memberService.existsByEmail(email)) {
memberService.save(generateMemberBy(oAuthMember));
}
Member foundMember = memberService.findByEmail(email);
String accessToken = jwtTokenProvider.createToken(String.valueOf(foundMember.getId()));
return new TokenResponse(accessToken);
}
private Member generateMemberBy(final OAuthMember oAuthMember) {
return new Member(oAuthMember.getEmail(), oAuthMember.getProfileImageUrl(), oAuthMember.getDisplayName(), SocialType.GOOGLE);
}
}
지금 까지 설명한 구조의 패키지 구조는 아래와 같다.
└── src
├── main
│ ├── java
│ │ └── com
│ │ └── allog
│ │ └── dallog
│ │ ├── auth
│ │ │ └── application
│ │ │ └── AuthService.java
│ │ ...
│ │ ├── infrastructure
│ │ │ ├── oauth
│ │ │ │ └── client
│ │ │ │ ├── OAuthClient.java
│ │ │ │ └── GoogleOAuthClient.java
│ │ │ └── dto
│ │ │ └── OAuthMember.java
│ │ └── AllogDallogApplication.java
| |
│ └── resources
│ └── application.yml
결국 이러한 구조는 아래와 같이 domain
패키지에서 infrastructure
에 의존하게 된다.
...
import com.allog.dallog.infrastructure.dto.OAuthMember; // 의존성 발생!
import com.allog.dallog.infrastructure.oauth.client.OAuthClient; // 의존성 발생!
...
@Transactional(readOnly = true)
@Service
public class AuthService {
...
private final OAuthClient oAuthClient;
...
@Transactional
public TokenResponse generateTokenWithCode(final String code) {
OAuthMember oAuthMember = oAuthClient.getOAuthMember(code);
...
}
...
}
# Separated Interface Pattern
분리된 인터페이스
를 활용하자. 즉 인터페이스
와 구현체
를 각각의 패키지로 분리한다. 분리된 인터페이스를 사용하여 domain
패키지에서 인터페이스를 정의하고 infrastructure
패키지에 구현체를 둔다. 이렇게 구성하면 인터페이스에 대한 종속성을 가진 주체가 구현체에 대해 인식하지 못하게 만들 수 있다.
아래와 같은 구조로 인터페이스와 구현체를 분리했다고 가정한다.
└── src
├── main
│ ├── java
│ │ └── com
│ │ └── allog
│ │ └── dallog
│ │ ├── auth
│ │ │ ├── application
│ │ │ │ ├── AuthService.java
│ │ │ │ └── OAuthClient.java
│ │ │ └── dto
│ │ │ └── OAuthMember.java
│ │ ...
│ │ ├── infrastructure
│ │ │ ├── oauth
│ │ │ └── client
│ │ │ └── GoogleOAuthClient.java
│ │ └── AllogDallogApplication.java
| |
│ └── resources
│ └── application.yml
자연스럽게 domain
내에 있던 infrastructure
패키지에 대한 의존성도 제거된다. 즉 외부 서버와의 통신을 위한 의존성이 완전히 분리된 것을 확인할 수 있다.
...
import com.allog.dallog.auth.dto.OAuthMember; // auth 패키지 내부를 의존
...
@Transactional(readOnly = true)
@Service
public class AuthService {
...
private final OAuthClient oAuthClient;
...
@Transactional
public TokenResponse generateTokenWithCode(final String code) {
OAuthMember oAuthMember = oAuthClient.getOAuthMember(code);
...
}
...
}