Series/실전!

느슨한 결합도의 설계를 위해! (2)

Hyunec 2021. 12. 4. 16:39

요구 사항 정리와 AS-IS 분석은 끝났지만 설계하기 전에 개발 범위 산정과 제약 조건을 먼저 생각해야 합니다.

목표 달성을 위한 리팩토링은 끝이 없기에 일정과 범위를 조율해야 하고, 개인의 이해도는 차이가 있기에 실무의 설계는 범용적인 구성을 해야 합니다. 디자인 패턴 공부의 필요성을 느끼는 요즘입니다. 

 

이런 생각을 가지고 개발한 과정을 기록해봅니다.

 

개발 범위 산정

최소 개발 범위는 심플하지만 앞으로 알림 톡의 종류가 많아질 것을 대비하면 AlarmGqlService의 호출도 interface로 만들고 내부 서비스들도 표준화시키는 것이 좋을 것 같습니다.

하지만 개발 범위를 AlarmGqlService까지 확장하면 입력부에 대한 검증과 interface 설계에 따른 리팩토링도 추가되어야 해서 '알림 톡 호출 주소 변경'이라는 컴팩트한 목표에 맞지 않다고 생각했습니다. 그리고 사실 최소 범위도 이미 강한 결합도를 가졌기에 이 것을 해결하는 것이 우선이라고 생각했고, SysSmsSendService 정도는 가져갈 수 있다고 판단해서 개발 범위를 확정했습니다.

 

제약 조건

  1. 2 영업일 이내의 개발 기간
  2. 환경변수의 관리 주체를 분리한다.
  3. 공통 로직을 분리하며 중복되는 로직을 최소화
  4. 상속보다는 조합을 사용하자.
  5. 최신 컨벤션에 맞는 코드 정리
  6. 테스트 방법에 대한 고려

개발 기간이라는 단어에는 설계와 QA도 포함되어야 한다고 생각한 때가 있었습니다.

하지만 실무를 하다 보니 개발과 배포는 직결되지 않고, QA 후 돌아오거나 기타 이슈로 늘어지는 일정은 예상하기 힘들기에 분리해야 된다는 것을 느꼈습니다. 좀 더 복잡한 업무라면 분석과 설계 기간도 분리했겠지만 이번 업무는 같이 해도 될만한 분량이라고 판단했고, 최소한의 개발 기간으로서 2일을 산정했습니다.

 

설계

AS-IS를 분석하면서 공통부로 분리해야 될 로직이 보였고, 사이드 프로젝트에서 연습한 JWT 적용의 패턴에서의 Provider 가 생각났습니다.

 

 

Spring Security 로 회원 가입을 구현해보자 (2)

Step 3. JWT 적용 1. 준비 // build.gradle dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.2' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11..

hyune-c.tistory.com

 

그래서 설계한 모델입니다. 전송을 위한 책임을 ApiGetwaySender라는 정보 전문가에게 위임함으로써 일련 과정을 모듈화 시켰습니다.

 

1. 환경 변수 관리

// application.yml
aws:
  lambda:
    apiGateway:
      baseUrl: ${API_GATEWAY_BASE_URL}
      username: ${API_GATEWAY_USERNAME}
      password: ${API_GATEWAY_PASSWORD}

민감정보는 환경 변수로 관리합니다.

@Getter
@Configuration
public class ApiGatewayConfiguration {

 @Value("${aws.lambda.apiGateway.baseurl}")
 private String baseUrl;

 @Value("${aws.lambda.apiGateway.username}")
 private String username;

 @Value("${aws.lambda.apiGateway.password}")
 private String password;
}

그것을 주입받는 주체로서 Configuration을 만듭니다.

 

2. 공통 변수, 로직 관리

@Getter
@Component
@RequiredArgsConstructor
public class ApiGatewayProvider {

 // API GATEWAY 통신 오류
 public static final Supplier<CustomGraphQLException> BAD_RESPONSE_EXCEPTION =
   CustomGraphQLException.supply("M9999");

 private String baseUrl;
 private String username;
 private String password;

 public static final String SUCCESS_TEXT = "Success";
 public static final String SENDING_NUMBER = "15000000";

 private final ApiGatewayConfiguration configuration;
 private final RestTemplate restTemplate;

 @PostConstruct
 private void postConstruct() {
  baseUrl = configuration.getBaseUrl();
  username = configuration.getUsername();
  password = configuration.getPassword();
 }

 public String generateJwt() {
  final Request request = Request.of(GET_SESSION.getUrl(), username, password);
  final HttpHeaders headers = GET_SESSION.getHeaderType().getHttpHeadersFunction().apply("");
  final HttpEntity<Request> httpEntity = new HttpEntity<>(request, headers);

  final Response response = restTemplate.exchange(
      baseUrl + GET_SESSION.getUrl(),
      GET_SESSION.getHttpMethod(),
      httpEntity,
      Response.class
    )
    .getBody();

  return Optional.ofNullable(response)
    .map(Response::getToken)
    .orElseThrow(BAD_RESPONSE_EXCEPTION);
 }

 @Data
 @AllArgsConstructor(staticName = "of")
 private static class Request {

  private String url;
  private String username;
  private String password;
 }

 @Data
 @AllArgsConstructor
 @NoArgsConstructor
 private static class Response {

  private String token;
 }
}

Provider는 이 모듈에서 공통으로 사용되는 변수와 로직을 모아놓은 것으로 v2에서는 모든 요청에 jwt 가 필요합니다. 따라서 jwt를 가져올 수 있는 로직을 만들고 필요한 곳에서 호출할 수 있도록 합니다.

 

3. 각 구현 Service 별 발송과 검증을 위한 값을 enum으로 관리

@Getter
@RequiredArgsConstructor
public enum ApiGatewayRequestType {

 GET_SESSION("/sessions", WITHOUT_AUTHORIZATION, POST, "", Integer.MAX_VALUE),
 xxxxx1("/kakao/xxxxx1", WITH_AUTHORIZATION, POST, "xxxxx1", 50),
 xxxxx2("/kakao/xxxxx2", WITH_AUTHORIZATION, POST, "xxxxx2", 10),
 xxxxx3("/kakao/xxxxx3", WITH_AUTHORIZATION, POST, "xxxxx3", 1000),
 RESET_PASSWORD("/kakao/resetpw", WITH_AUTHORIZATION, POST, "reset_pw", 10),
 ;

 private final String url;
 private final ApiGatewayHeaderType headerType;
 private final HttpMethod httpMethod;
 private final String kakaoTemplateCode;
 private final int limit;
}

 

@Getter
@RequiredArgsConstructor
public enum ApiGatewayHeaderType {

 WITH_AUTHORIZATION(jwt -> {
  final HttpHeaders headers = new HttpHeaders();
  headers.setContentType(MediaType.APPLICATION_JSON);
  headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
  headers.setBearerAuth(jwt);
  return headers;
 }),
 WITHOUT_AUTHORIZATION(ignored -> {
  final HttpHeaders headers = new HttpHeaders();
  headers.setContentType(MediaType.APPLICATION_JSON);
  headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
  return headers;
 }),
 ;

 private final Function<String, HttpHeaders> httpHeadersFunction;
}

최초 jwt를 가져올 때는 jwt 가 없는 header 가 필요하며, 혹여 이후에도 jwt 가 필요 없는 요청이 생기는 경우를 위해 enum으로 관리합니다.

 

4. 검증, 후처리 로직과 발송 로직의 분리

@Service
@RequiredArgsConstructor
public class ApiGatewaySender {

 private final ApiGatewayProvider apiGatewayProvider;
 private final RestTemplate restTemplate;

 public ResponseEntity<Response> request(final ApiGatewayRequestType requestType, final Object body) {
  final String jwt = apiGatewayProvider.generateJwt();
  final HttpHeaders headers = requestType.getHeaderType().getHttpHeadersFunction().apply(jwt);
  final HttpEntity<?> httpEntity = new HttpEntity<>(body, headers);
  final String url = apiGatewayProvider.getBaseUrl() + requestType.getUrl();

  return restTemplate.exchange(
    url,
    requestType.getHttpMethod(),
    httpEntity,
    Response.class);
 }

 @Data
 @AllArgsConstructor
 @NoArgsConstructor
 @Builder
 public static class Response {

  private String messageId;
  private String to;
  private String status;
  private String text;

  public boolean isSuccess() {
   return ApiGatewayProvider.SUCCESS_TEXT.equals(text);
  }
 }
}

발송의 책임을 ApiGetwaySender로 모으고 공통 응답에서 성공 여부를 검증하는 메서드도 제공합니다.

 

5. 기존 구현 Service를 검증, 후처리 로직만 남을 수 있도록 개선

@Service
@RequiredArgsConstructor
public class ResetPasswordService {

 // 1일 최대 10개까지 발송하실 수 있습니다.
 private static final Supplier<CustomGraphQLException> RESET_PASSWORD_EXCEPTION =
   CustomGraphQLException.supply("M0001");
 private static final ApiGatewayRequestType REQUEST_TYPE = RESET_PASSWORD;

 private final SysSmsSendService sysSmsSendService;
 private final ApiGatewaySender apiGatewaySender;

 public void send(final String receiveNumber, final String paramsPassword) {
  final Request request = Request.of(receiveNumber, paramsPassword);
  sendValidation(request.getReceiveNumber());
  final ResponseEntity<Response> exchange = apiGatewaySender.request(REQUEST_TYPE, request);

  Optional.ofNullable(exchange)
    .map(HttpEntity::getBody)
    .filter(Response::isSuccess)
    .ifPresentOrElse(
      response -> sysSmsSendService.insertSysSmsSend(
        null,
        response.getMessageId(),
        response.getTo(),
        REQUEST_TYPE.getUrl(),
        response.getStatus()
      ),
      () -> {
       throw BAD_RESPONSE_EXCEPTION.get();
      }
    );
 }

 private void sendValidation(final String receiveNumber) {
  final int sendCount = sysSmsSendService.getResetPasswordLimitCount(receiveNumber);
  if (sendCount >= REQUEST_TYPE.getLimit()) {
   throw RESET_PASSWORD_EXCEPTION.get();
  }
 }

 @Data
 @AllArgsConstructor
 @NoArgsConstructor
 @Builder
 public static class Request {

  private String templateCode;
  private String receiveNumber;
  private String sendingNumber;
  private String paramsPassword;
  private String paramsLoginUrl;

  public static Request of(final String receiveNumber, final String paramsPassword) {
   final String paramsLoginUrl = generateParamsLoginUrl();

   return new Request(
     REQUEST_TYPE.getKakaoTemplateCode(),
     SplitAndJoinUtils.joinCountryContactsNumber(receiveNumber),
     SENDING_NUMBER,
     paramsPassword,
     paramsLoginUrl
   );
  }

  private static String generateParamsLoginUrl() {
   if (isStageServer()) {
    return "https://stage.company.com/login";
   }

   if (isDevServer()) {
    return "https://dev.company.com/login";
   }

   return "https://prod.company.com/login";
  }
 }
}

이제 발송은 ApiGatewaySender에게 위임하고 검증, 후처리, 발송에 필요한 값 역시 ApiGatewayRequestType를 통해 가져와 로직을 단순화 시킬 수 있습니다. 그리고 ApiGatewayRequestType를 클래스 변수로 관리함으로써 성능의 향상도 기대할 수 있습니다.

 

6. 테스트

// 실제 알림톡을 받으려면 @disable 를 풀고 receiveNumber 를 기록합니다.
@SpringBootTest
class ResetPasswordServiceTest {

 @Autowired ResetPasswordService passwordService;

 @DisplayName("알림톡 - 비밀번호 초기화")
 @Test
 void getJwt() {
  // given

  // when
  passwordService.send("010-****-****", "12345");

  // then

 }
}

영향도를 줄이기 위해서는 mock 을 활용하여야겠지만, 이미 mock을 활용한 통합 테스트가 존재하고 있었습니다. 그리고 개발간 매번 postman을 활용하는 것은 힘들었기에 샘플 테스트를 만들고 merge 시에는 @Disabled로 변경했습니다.

 

마치며

앞의 과정들을 통해 목표한 수준의 느슨한 결합도는 달성했지만, 실무라는 제한적인 환경이다 보니 좀 더 만족스럽게 하지 못한 부분이 있습니다. 예를 들면 jwt 캐싱, 인터페이스를 통한 설계, 역할을 분리해서 조합으로 구성, 특히 이 글을 작성하게 영감을 준 로치의 글에서 나온 Validator의 분리입니다.

하지만 그러기 위해서는 목표 범위 이상을 개선해야 했고, 무엇보다 제 디자인 패턴 지식이 부족함을 느꼈습니다. 

 

글을 마치며 제가 생각하는 다음 스텝의 설계를 남겨봅니다.

 

2021.12.03 - [Computer Science/Software Architecture] - 느슨한 결합도의 설계를 위해! (1)