Tweaver 프로젝트 OpenFeign 적용기

2023. 12. 4. 21:18

<그림1> 혼자가 아니야, Trip With Buddy - Tweaver


들어가며

안녕하세요 백엔드 개발자 이대영입니다. 이 글에서는 Spring Cloud의 Open Feign 기능을 도입했던 과정을 소개하려 합니다. 본 글은 부족했던 시간 속에서 팀원들과 공동의 목표를 이루어 나가기 위한 과정을 공유합니다. 뿐만 아니라 경험을 기록함으로써 성장의 발판이 되었으면 합니다.

<그림2> 경험농사를 지어봅시다

 

배경 및 목표

Tweaver 서비스에서는 이메일 인증 기능을 통해 서비스 사용자의 최소한의 신원을 확인하려 합니다. 휴대폰 본인 확인, 이메일 인증 정도를 생각해 봤는데요. 솔직히 말하자면 이메일 인증이 조금 더 쉬울 것 같아 이메일 인증을 도입하기로 하였습니다.😅

이메일 인증 도입을 위해서는 2가지를 생각해 볼 수 있었습니다.

1. 직접 SMTP 서버를 구축한다.

    - 구축에 시간이 오래걸릴 수 있다.

    - 자체 서버이기 때문에 이메일 전송, 유지관리 등 제한이 없다.

2. 외부 메일 서비스를 이용한다.

    - 메일 전송에 실제 비용이 청구될 수 있다.

    - 외부 서버와 연결만 관리하면 되기 때문에 편하다.

    - 메일 전송이 안정적이다. (외부 서비스이기 때문에 서비스가 보장되어있음)

개발자로서 직접 SMTP 서버를 구축해보며 학습해보는 것도 좋지 않을까 생각해보았습니다. 하지만 최종 마감일까지 무슨 일이 있을지 모르기 때문에 신경쓸 부분을 최대한 줄이자는 마음으로 외부 서비스를 이용하기로 했습니다. 최종적으로 외부 메일 서비스는 무료로 사용할 수 있는 mailgun 서비스를 이용하기로 했습니다. 해당 서비스를 이용하기 위한 기술 스택으로는 Open Feign을 이용하기로 했습니다.

<그림3> Mailgun 서비스
<그림4> Spring Cloud OpenFeign

 

OpenFeign 적용

1. 필요한 요소 파악하기

OpenFeign을 적용하기 위해 mailgun이 필요로하는 것들을 파악해야 합니다.

import java.io.File;
import com.mashape.unirest.http.HttpResponse;
import com.mashape.unirest.http.JsonNode;
import com.mashape.unirest.http.Unirest;
import com.mashape.unirest.http.exceptions.UnirestException;
public class MGSample {
	 // ...
	public static JsonNode sendSimpleMessage() throws UnirestException {
		HttpResponse<JsonNode> request = Unirest.post("https://api.mailgun.net/v3/" + YOUR_DOMAIN_NAME + "/messages"),
			.basicAuth("api", API_KEY)
			.queryString("from", "Excited User <USER@YOURDOMAIN.COM>")
			.queryString("to", "artemis@example.com")
			.queryString("subject", "hello")
			.queryString("text", "testing")
			.asJson();
		return request.getBody();
	}
}

위 코드는 mailgun에서 제공하는 기본 코드입니다. 코드를 살펴보면 unirest를 이용해 데이터를 주고받습니다. 본인은 OpenFeign을 이용할 것이므로 필요한 요소들만 파악하면 됩니다. 우선은 단순히 from, to, subject, text가 queryString으로 나가는걸 확인할 수 있습니다. 따라서 해당 파라미터들을 담을 객체(SendEmailForm)를 만들어 주고 이를 보낼 수 있도록 합니다.

// SendEmailForm.java

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SendEmailForm {

  /**
   * mailgun에서 요구하는 필드
   * 발신자, 수신자, 제목, 내용
   */
  private String from;
  private String to;
  private String subject;
  private String text;
}

그리고 보안에 유의하며 domain name과 mailgun key를 담아줍니다. 추후 mailgun 관련 설정을 해줄때 Feign에서 제공하는 BasicAuthRequestInterceptor를 이용해줄 것이기 때문에 mailgun key는 Http Webhook signing key로 담아줍니다.

// application-secret.yml

mailgun-key={...}-{...}-{...}  // Http Webhook signing key
mailgun-domain=sandbox{...}.mailgun.org/messages

mailgun을 이용할 준비가 되었습니다. 이제 프로젝트에 OpenFeign을 적용한 후 mailgun API를 호출해보려합니다.

 

2. 프로젝트에 OpenFeign 주입하기

우선 Spring Cloud의 OpenFeign 의존성을 적용해줍니다. Spring Cloud는 Spring Boot의 버전을 타기 때문에 이 링크에서 버전을 매치한 후 적용해줍니다.

// build.gradle

plugins {
  id 'java'
  id 'org.springframework.boot' version '2.7.17'
  id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

repositories {
  mavenCentral()
}

ext {
  set('springCloudVersion', "2021.0.4")
}

dependencyManagement {
  imports {
    mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
  }
}

dependencies {
  implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
}
// TweaverApplication.java

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableFeignClients
public class TweaverApplication {

	public static void main(String[] args) {
		SpringApplication.run(TweaverApplication.class, args);
	}
}

build.gradle과 메인 어플리케이션 클래스(본 프로젝트에선 TweaverApplication)에 EnableFeignClients 어노테이션을 붙여줍니다. 이렇게 되면 사용 준비를 마치게 됩니다.

3. OpenFeign 사용하기

의존성 주입이 완료되었다면, mailgun API를 호출할 클라이언트 인터페이스와 Feign 설정 클래스를 만들어 줍니다.

// FeignConfig.java

import feign.auth.BasicAuthRequestInterceptor;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FeignConfig {

  @Value("${mailgun-key}")
  private String mailgunKey;

  @Bean
  @Qualifier(value = "mailgun")
  public BasicAuthRequestInterceptor basicAuthRequestInterceptor() {
    return new BasicAuthRequestInterceptor("api", mailgunKey);
  }
}
// MailgunClient.interface

import com.valuewith.tweaver.auth.client.mailgun.SendEmailForm;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.cloud.openfeign.SpringQueryMap;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;

@FeignClient(name = "mailgun", url = "https://api.mailgun.net/v3/")
@Qualifier("mailgun")
public interface MailgunClient {

  /**
   * mailgun에서 QueryString을 보내기 때문에 @SpringQueryMap을 사용
   */
  @PostMapping("${mailgun-domain}")
  ResponseEntity<String> sendEmail(@SpringQueryMap SendEmailForm form);
}

FeignConfig 클래스에서는 mailgun(설명)에서 사용할 기본 http 인증을 위한 빈을 생성해줍니다. mailgun에서 사용할 것이므로 Qualify 어노테이션으로 구분해줍니다. mailgun에서는 username을 "api"로 사용한다고 했으나 다른 name을 넣고 테스트해보니 정상적으로 작동했습니다. 

MailgunClient 인터페이스에서는 호출할 url과, mailgun으로 구분해준 basicAuthRequestInterceptor 빈 객체를 적용해줍니다. 이때 Feign에서 제공해주는 SpringQueryMap 어노테이션을 사용해 SendEmailForm을 한 번에 넣어줍니다.

 

4. 이메일 서비스 구현

OpenFeign을 이용해 mailgun API를 호출하는 것까지 완료되었습니다. 이제 서비스단에서 메서드를 호출하는 것으로 로직을 짜면 됩니다. 

// EmailService.java

import static com.valuewith.tweaver.constants.ErrorCode.*;

import com.valuewith.tweaver.auth.client.MailgunClient;
import com.valuewith.tweaver.auth.client.mailgun.SendEmailForm;
import com.valuewith.tweaver.commons.redis.RedisUtilService;
import com.valuewith.tweaver.constants.ErrorCode;
import com.valuewith.tweaver.exception.CustomException;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class EmailService {

  private final MailgunClient mailgunClient;
  private final RedisUtilService redisUtilService;

  public String sendMail(SendEmailForm sendForm) {
    try {
      return mailgunClient.sendEmail(sendForm).getBody();
    } catch (Exception e) {
      throw new CustomException(FAILURE_SENDING_EMAIL);
    }
  }

  public void sendCodeForValid(String memberEmail) {
    String code = createCode();
    String text = createText(code);

    // 유효시간 설정 (key, value, 유효시간(분))
    redisUtilService.setDataTimeout(memberEmail, code, 5L);
    sendMail(SendEmailForm.builder()
        .to(memberEmail)
        .from("manager@value.with")
        .subject("트위버 인증코드입니다.")
        .text(text)
        .build());
  }

  public String createCode() {
    return RandomStringUtils.randomAlphanumeric(6);
  }

  public String createText(String code) {
    StringBuilder sb = new StringBuilder();
    sb.append("인증코드를 입력해주세요.\n").append(code);
    return sb.toString();
  }
}
// AuthController.java

import com.valuewith.tweaver.auth.dto.AuthDto.EmailInput;
import com.valuewith.tweaver.auth.dto.AuthDto.VerificationForm;
import com.valuewith.tweaver.auth.service.AuthService;
import com.valuewith.tweaver.config.SwaggerConfig;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@Api(tags = {SwaggerConfig.AUTH_TAG})
@RestController
@RequiredArgsConstructor
@RequestMapping(value = "/auth")
public class AuthController {

  private final AuthService authService;

  @ApiOperation(
      value = "이메일로 인증번호 요청",
      notes = "이메일 인증번호 유효 시간은 5분입니다."
  )
  @ApiResponses(value = {
      @ApiResponse(code = 502, message = "이메일 전송에 실패하였습니다."),
      @ApiResponse(code = 409, message = "중복된 이메일입니다.")
  })
  @PostMapping("/verify")
  public void sendCode(@Valid @RequestBody EmailInput request) {
    authService.sendEmailVerification(request);
  }

  @ApiOperation(
      value = "이메일 인증 확인 요청",
      notes = "이메일로 보내진 인증번호를 확인합니다."
  )
  @ApiResponses(value = {
      @ApiResponse(code = 401, message = "인증코드에 문제가 있을 경우 발생합니다.")
  })
  @PostMapping("/verify/check")
  public ResponseEntity<Boolean> checkCode(@RequestBody VerificationForm request) {
    return ResponseEntity.ok(authService.isVerified(request));  // true
  }

Redis를 활용해 인증코드, 메일 검증 로직을 구현했습니다. 사실 이부분은 서비스 정책에 맞는 로직을 알맞게 구현하면 되는 부분이라 생략하겠습니다.

 

힘들었던 부분

도입하는 과정에서 삽질은 필수적인 부분이었습니다. 로직, 코드 모두 간단해보이지만 시행착오가 꽤 있었습니다. 그중 기억에 남는 삽질 2가지 정도를 추려봤습니다.

 

1. mailgun-key를 어떤걸 써야하지?

mailgun을 사용하기 위해 mailgun의 API Key를 사용해야한다. mailgun의 빠른시작 가이드에서는 Private API Key를 사용하도록 되어있습니다. 또 mailgun-java 라이브러리(깃헙링크)의 ClientCofiguration 부분에도 Private API Key를 사용하도록 명시되어 있습니다. 그러나 mailgun help 문서를 봐도, 내 API Key를 확인해 봐도 Private API Key가 없었습니다!!

<그림5> 나의 API 키들

할 수 없이 mailgun 튜토리얼에서 사용한 mailgun-java 라이브러리를 의존성에 추가한 뒤 코드를 살펴보았는데요, primary account API key 역시 찾을 수가 없었습니다.

<그림6> primary account API key가 뭔데 뭘써야하는건데

어차피 명시된 키는 3개 뿐이고 일일이 넣어서 확인해보는 수밖에 없었습니다. 우선 튜토리얼에 있는 코드를 실행시켜 본 후에 OpenFeign을 이용해보려고 합니다.

// EmailService.java

import static com.valuewith.tweaver.constants.ErrorCode.*;

import com.mailgun.api.v3.MailgunMessagesApi;
import com.mailgun.client.MailgunClient;
import com.mailgun.model.message.Message;
import com.mailgun.model.message.MessageResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class EmailService {

  public MessageResponse sendSimpleMessage() {
    MailgunMessagesApi mailgunMessagesApi = MailgunClient.config("여기에 일일히 대입")
        .createApi(MailgunMessagesApi.class);

    Message message = Message.builder()
        .from("Excited User <USER@YOURDOMAIN.COM>")
        .to("eod940@gmail.com")
        .subject("Hello")
        .text("Testing out some Mailgun awesomeness!")
        .build();

    return mailgunMessagesApi.sendMessage("도메인.mailgun.org", message);
  }

}
// AuthController.java

...
  @PostMapping("/tutorial")
  public void sendCode() {
    emailService.sendSimpleMessage();
  }
...

일일히 찾아본 결과, HTTP webhook signing key가 동작했습니다. Webhook 관련한 기술은 개발 과정에서 보이지 않았지만 mailgun 내에서 Private account key라던가 Primary account API key 등 모든 역할을 같이 하는 것 같았습니다. 시간이 된다면 mailgun-java 라이브러리(깃헙링크)를 확인해보는게 좋을 것 같습니다.

구글링 결과 다른 곳에서도 저와 아주 조금 비슷한 경험을 한 것 같았습니다. anymail/django-anymail(깃헙이슈링크)에서 한 번 구경해보았습니다.

결과적으로 mailgun-key는 HTTP webhook signing key 라는걸 일일히 대입해 본 뒤에 찾을 수 있었습니다. 코드를 뒤져가며 답을 찾는 멋있는 모습을 상상하며 여러 문서, 코드를 찾아봤지만 어림도 없었습니다. 언젠간 그런 개발자가 되도록 노력해야겠습니다.

 

2. Contract의 사용

OpenFeign 레퍼런스 문서를 보면 아래와 같이 Contract에 관한 이야기가 나옵니다.

<그림7> Spring Cloud OpenFeign 레퍼런스 문서

아무 생각없이 Contract를 빈으로 등록한 후 실행하니 아래와 같은 결과가 나왔습니다.

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'authController' defined in file 
[/tweaver/build/classes/java/main/com/valuewith/tweaver/auth/controller/AuthController.class]: Unsatisfied dependency expressed through constructor parameter 0;
nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'authService' defined in file 
[/tweaver/build/classes/java/main/com/valuewith/tweaver/auth/service/AuthService.class]: Unsatisfied dependency expressed through constructor parameter 3; 
nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'emailService' defined in file 
[/tweaver/build/classes/java/main/com/valuewith/tweaver/auth/service/EmailService.class]: Unsatisfied dependency expressed through constructor parameter 0; 
nested exception is org.springframework.beans.factory.BeanCreationException: 
Error creating bean with name 'com.valuewith.tweaver.auth.client.MailgunClient': Unexpected exception during bean creation; nested exception is java.lang.IllegalStateException: Method MailgunClient#sendEmail(SendEmailForm) not annotated with HTTP method type (ex. GET, POST)
Warnings:
- Class MailgunClient has annotations [FeignClient, Qualifier] that are not used by contract Default
- Method sendEmail has an annotation PostMapping that is not used by contract Default

원인은 SpringMvcContract가 사용되지 않아 PostMapping을 사용하지 못했기 때문입니다. 쉽게 생각해 PostMapping 같은 SpringMvcContract 에서 사용하는 것들을 사용한다면 Feign Default Contract는 사용하지 말아야 합니다.

 

마무리

열심히 탐구하고 학습하려 했지만 제 뇌는 어림도 없는 것 같습니다. 기록하다보면 언젠가는 정신차리겠죠.

 

참조

https://cloud.spring.io/spring-cloud-netflix/multi/multi_spring-cloud-feign.html, 2023. 12. 18 확인

'프로젝트' 카테고리의 다른 글

[SSE] HikariCP Connection is not available 문제 해결 과정  (0) 2023.12.26
spring-cms  (0) 2023.08.09
spring-dividend-service  (0) 2023.08.02

BELATED ARTICLES

more