외부와 의존성 분리하기

@dallog · July 24, 2022 · 9 min read

이 글은 우테코 달록팀 크루 '매트'가 작성했습니다.

외부와 의존성 분리하기

도메인 로직은 우리가 지켜야할 매우 소중한 비즈니스 로직들이 담겨있다. 이러한 도메인 로직들은 변경이 최소화되어야 한다. 그렇기 때문에 외부와의 의존성을 최소화 해야 한다.

인터페이스 활용하기

우선 우리가 지금까지 학습한 것 중 객체 간의 의존성을 약하게 만들어 줄 수 있는 수단으로 인터페이스를 활용할 수 있다. 간단한 예시로 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);
        ...
    }
    ...
}

References.

Separated Interface

@dallog
우아한테크코스 4기 달록팀 기술 블로그입니다.