본문 바로가기

리눅스 인프라/Kafka

Kafka를 활용한 이메일 인증 기능 구현하기

관련 내용

해당 프로젝트 깃허브

[리눅스 인프라/Kafka] - Virtualbox 포트 포워딩 Kafka 호스트 PC 연결 오류 해결

[리눅스 인프라/Kafka] - Kafka란, Kafka의 구성 요소와 특징

개요와 목적

나의 깃 허브에 있는 Ignorant English 프로젝트는 개인 프로젝트 이기 때문에, 큰 트래픽으로 인한 회원 가입 요청 시 이메일 인증 처리의 부하 문제를 신경 쓸 필요가 없다. 하지만, 트래픽이 많아지는 것을 가정하고, Kafka를 사용하여, 이메일 인증 처리를 PLUS API로 위임하려고 한다.

문제상황

클라이언트 회원 가입 요청

→ 본 API - 회원 가입 로직, 회원 이메일 인증 확인 이메일 보내기 / 다른 클라이언트 이메일 인증 확인 처리로 인해 부하 증가

→ 그래서 단순 회원 가입을 시도한 클라이언트가 오래 기다리는 문제가 발생

이 문제를 해결하기 위한 다이어 그램은 아래와 같다.

먼저 이메일 인증을 처리하는 인증API 서버를 추가로 개설한다.

본API에서 회원 가입 요청이 들어왔을 때, email을 kafka로 보내고 단순 회원 가입 로직만 처리 후에 종료된다.

kafka의 정보를 수시로 확인하고 있는 인증 API는 email 정보가 들어왔을 때, 이메일 인증 확인 메일 보내기 로직을 실행한다.

클라이언트가 본인에게 온 이메일 링크를 클릭하게 된다면, 인증 API를 거쳐서 이메일 인증이 완료된다.

개발 환경

  • SpringBoot(gradle) : 2.7.8
  • Querydsl : 5.0.0
  • Ubuntu : 20.04
  • Kafka - 2.13-3.2.0

본 API 서버와 Kafka 서버 연결하기

우분투 서버에 Kafka 설치하고 설정하기

설치

wget <https://archive.apache.org/dist/kafka/3.2.0/kafka_2.13-3.2.0.tgz>

targz zxf [kafka_2.13-3.2.0.tgz](<https://archive.apache.org/dist/kafka/3.2.0/kafka_2.13-3.2.0.tgz.sha512>)

설정 파일

kafka/config/server.properties

해당 설정에 대한 자세한 설명은 아래 블로그 글에서 확인할 수 있다.

[리눅스 인프라/Kafka] - Virtualbox 포트 포워딩 Kafka 호스트 PC 연결 오류 해결

실행

주키퍼 먼저 실행
/kafka/bin/zookeeper-server-start.sh /usr/local/kafka/config/zookeeper.properties

카프카 서버 실행
/kafka/bin/kafka-server-start.sh /usr/local/kafka/config/server.properties

본 API 서버 Kafka와 연결 설정

build.gradle

implementation 'org.springframework.kafka:spring-kafka'

application.yml producer yml 분리 설정

IgnorantEnglish\API\src\main\resources\application.yml

spring:
  config:
    import: kafka.yml

Kafka 설정은 profile마다 설정이 복잡해질 수 있어서, 따로 분리했다.

IgnorantEnglish\API\src\main\resources\kafka.yml

spring:
  config:
    activate:
      on-profile: local
  kafka:
    producer:
      bootstrap-servers: 127.0.0.7:9093
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
---
spring:
  config:
    activate:
      on-profile: server
  kafka:
    producer:
      bootstrap-servers: 10.0.2.7:9092
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer

이메일 정보를 담은 객체를 JsonSerializer로 Kafka에 보낼 수 있게 설정했다.

Local 환경일 때는, 우분투 카프카서버의 포트 포워딩 주소인 127.0.0.7:9093를 설정했고,

Server 환경일 때는, Spring Server도 같은 VirtualBox 안에서 돌아가기 때문에, 고정 IP주소 10.0.2.7:9092를 설정했다.

본 API 회원 가입 요청 시, kafka로 메일 정보 보내기

회원 가입 요청 시 controller

IgnorantEnglish\API\src\main\java\hello\api\controller\UserManageController.java

@RequiredArgsConstructor
@RestController
@Slf4j
@RequestMapping("/api/user/manage")
public class UserManageController {

    private final UserManageService userManageService;
    private final KafkaEmailService kafkaEmailService;
    private final EmailAuthService emailAuthService;

    @PostMapping("/signup")
    public ResponseEntity signup(
        @Valid @RequestBody UserSignupRequest userSignupRequest) {
        userManageService.signup(userSignupRequest);
        emailAuthService.save(userSignupRequest.getUsername());
				kafkaEmailService.sendMessage(userSignupRequest.getUsername());
        return new ResponseEntity(HttpStatus.CREATED);
    }
}

클라이언트의 회원 가입 요청이 들어오면, 유저 정보를 DB에 저장하고, emailAuthService에서 EmailAuth 정보를 저장한다. 그리고 kafkaEmailService.sendMessage에서 Kafka로 인증이 필요한 이메일 정보를 Kafka로 넘기고 로직이 종료된다.

emailAuth Entity

IgnorantEnglish\API\src\main\java\hello\api\entity\EmailAuth.java

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class EmailAuth {

    private static final Long MAX_EXPIRE_TIME = 5L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String email;
    private String authToken;
    private Boolean expired;
    private Timestamp expireDate;
    @Builder
    public EmailAuth(String email, String authToken, Boolean expired) {
        this.email = email;
        this.authToken = authToken;
        this.expired = expired;
        this.expireDate = Timestamp.valueOf(LocalDateTime.now().plusMinutes(MAX_EXPIRE_TIME));
    }
    public void useToken() {
        this.expired = true;
    }
}

회원 가입이 들어온 시간부터, 제한 시간을 5분으로 주고, EmaillAuth 정보를 DB에 저장한다. 이메일 인증 방식은 해당 이메일 이름과 회원 가입 시점에 같이 생성한 UUID가 일치하는 지 확인하는 것이다.

Kafka로 이메일 정보 보내기

IgnorantEnglish\API\src\main\java\hello\api\dto\KafkaEmailDto.java

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class KafkaEmailDto {
    private String email;
}

IgnorantEnglish\API\src\main\java\hello\api\service\KafkaEmailService.java

@Service
@RequiredArgsConstructor
@Slf4j
public class KafkaEmailService {

    private static final String TOPIC = "email";
    private final KafkaTemplate<String, KafkaEmailDto> kafkaTemplate;

    public void sendMessage(String email) {
        KafkaEmailDto kafkaEmailDto = new KafkaEmailDto(email);
        kafkaTemplate.send(TOPIC, kafkaEmailDto);
    }
}

kafkaTemplate에서, TOPIC을 설정하고, email 정보를 kafka로 보내게 된다.

인증 API 서버 Kafka 정보 받아서 이메일 인증 이메일 보내기

인증 API 서버 consumer Kafka와 연결하기

IgnorantEnglish\PlusAPI\build.gradle

implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.kafka:spring-kafka'

IgnorantEnglish\PlusAPI\src\main\resources\application.yml

spring:
  config:
    import: kafka.yml

IgnorantEnglish\PlusAPI\src\main\resources\kafka.yml

spring:
  config:
    activate:
      on-profile: local
  kafka:
    consumer:
      bootstrap-servers: 127.0.0.7:9093
      group-id: foo
      auto-offset-reset: earliest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: "*"
        spring.json.use.type.headers: false
---
spring:
  config:
    activate:
      on-profile: server
  kafka:
    consumer:
      bootstrap-servers: 10.0.2.7:9092
      group-id: foo
      auto-offset-reset: earliest
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: "*"
        spring.json.use.type.headers: false

인증 API에서 Kafka에서 오는 이메일 정보를 consume할 때, 매핑 문제가 발생했다. 들어오는 정보 헤더에 본API dto package 정보가 있었기 때문이다. 변환하려고 하는 dto 경로가 달랐다. 그래서 consume 하는 데이터 헤더를 지우고 다음에 나오는 type 설정을 통해 문제를 해결했다.

→멘토님이 조언 하시길, 실제 업무 프로젝트에서도 패키지 정보를 제거한다. 정보를 받을 부서가 스프링을 사용 안 할 수 도 있기 때문이다.

@KafkaListener(topics = TOPIC, groupId = "foo", properties = {
        "spring.json.value.default.type:hello.plusapi.dto.KafkaEmailDto"})

(핵심) kafka에서 이메일 정보 consume 후에 이메일 보내기

spring 구글 SMTP 통해 메일 보내기 설정

spring에서는 구글 SMTP사용하려면, 2차 앱 비밀번호를 설정 해야 한다.

나는 이 글을https://jaimemin.tistory.com/2088 참고하여 진행했다.

아래 password 부분에 자신이 설정한 2차 비밀번호를 입력하면 된다.

IgnorantEnglish\PlusAPI\src\main\resources\application.yml

spring:
  mail:
    host: smtp.gmail.com
    port: 587
    username: ~~~~~~~~
    password: ~~~~~~~~
    properties:
      mail:
        smtp:
          starttls:
            enable: true
          auth: true
          timeout: 5000

이메일 보내기

IgnorantEnglish\PlusAPI\src\main\java\hello\plusapi\service\EmailAuthService.java

@Service
@RequiredArgsConstructor
@Slf4j
public class EmailAuthService {

    @Value("${server.port}")
    private String port;
    @Value("${api.ip}")
    private String ip;
    private static final String TOPIC = "email";
    private final JavaMailSender javaMailSender;
    private final EmailAuthRepository emailAuthRepository;
    private final UserRepository userRepository;

    @KafkaListener(topics = TOPIC, groupId = "foo", properties = {
        "spring.json.value.default.type:hello.plusapi.dto.KafkaEmailDto"})
    public void sendEmailAuth(KafkaEmailDto kafkaEmailDto) throws UnknownHostException {
        EmailAuth emailAuth = emailAuthRepository.findByEmail(kafkaEmailDto.getEmail())
            .orElseThrow();

        SimpleMailMessage smm = new SimpleMailMessage();
        smm.setTo(kafkaEmailDto.getEmail());
        smm.setSubject("회원 가입 이메일 인증");
        String linkText = makeLinkText(emailAuth.getAuthToken());
        smm.setText(linkText);
        javaMailSender.send(smm);
    }

    private String makeLinkText(String authToken) {
        return "http://" + ip + ":" + port + "/api/email/auth" + "?authToken=" + authToken;
    }
}
    @KafkaListener(topics = TOPIC, groupId = "foo", properties = {
        "spring.json.value.default.type:hello.plusapi.dto.KafkaEmailDto"})

Kafka에서 consume하는 즉시, EmailAuth에 저장되어 있는 UUID 정보를 담은 링크를 해당 이메일로 보내게 된다.

 

이메일 인증 검사

이메일 인증 검사 Controller

IgnorantEnglish\PlusAPI\src\main\java\hello\plusapi\controller\EmailAuthController.java

@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("api/email")
public class EmailAuthController {

    private final EmailAuthService emailAuthService;

    @GetMapping("/auth")
    public ResponseEntity<String> authEmail(@RequestParam("authToken") String authToken) {
        emailAuthService.confirmEmail(authToken);
        return new ResponseEntity("success", HttpStatus.OK);
    }
}

이메일로 온 링크를 클릭하면 해당 컨트롤러로 인증을 요청하게 된다.

이메일 인증 검사 Service

IgnorantEnglish\PlusAPI\src\main\java\hello\plusapi\service\EmailAuthService.java

@Service
@RequiredArgsConstructor
@Slf4j
public class EmailAuthService {

    private final EmailAuthRepository emailAuthRepository;
    private final UserRepository userRepository;

    @Transactional(noRollbackFor = {EmailAuthException.class})
    public void confirmEmail(String authToken) {
        Optional<EmailAuth> findOptionalExpiredAuth = emailAuthRepository.findExpiredAuth(authToken);
        if (findOptionalExpiredAuth.isPresent()) {
            EmailAuth emailAuth = findOptionalExpiredAuth.get();
            emailAuth.useToken();
            throw new EmailAuthException(
                EmailAuthExceptionEnum.EXPIRED_EMAILAUTHTOKEN.getErrormessage());
        } else {
            EmailAuth emailAuth = emailAuthRepository.findValidAuthByEmail(authToken)
                .orElseThrow(() -> new EmailAuthException(
                    EmailAuthExceptionEnum.NOEXIST_EMAILAUTHTOKEN.getErrormessage()));
            Users users = userRepository.findByUsername(emailAuth.getEmail()).orElseThrow();
            users.emailVerifiedSuccess();
            emailAuth.useToken();
        }
    }
}

emailAuthRepository.findExpiredAuth 메소드를 통해서 5분 제한 기간을 넘긴 EmilAuth 정보인지 확인한다.

if (findOptionalExpiredAuth.isPresent()) 만약 존재한다면, 해당 emailAuth를 사용 표시하고, 만료된 인증 정보 예외를 발생한다. 그리고 @Transactional(noRollbackFor = {EmailAuthException.class}) 를 넣어서, 예외가 발생하더라도 emailAuth.useToken()가 롤백되지 않고 커밋 되도록 설정한다.

else 5분 제한을 넘기지 않았다면, 해당 emailAuthEnity를 사용 표시하고, users의 emailAuth=true로 업데이트한다. 이렇게 되면, 이메일 인증 절차가 완료 된다.

이메일 인증 검사 Repository

IgnorantEnglish\PlusAPI\src\main\java\hello\plusapi\entity\Users.java

@Entity
public class Users {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String password;
    private String name;
    private String roles;
    private Boolean emailAuth;

    public void emailVerifiedSuccess() {
        this.emailAuth = true;
    }
}

IgnorantEnglish\PlusAPI\src\main\java\hello\plusapi\entity\EmailAuth.java

@Entity
public class EmailAuth {

    private static final Long MAX_EXPIRE_TIME = 5L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String email;
    private String authToken;
    private Boolean expired;
    private Timestamp expireDate;

    @Builder
    public EmailAuth(String email, String authToken, Boolean expired) {
        this.email = email;
        this.authToken = authToken;
        this.expired = expired;
        this.expireDate = Timestamp.valueOf(LocalDateTime.now().plusMinutes(MAX_EXPIRE_TIME));
    }
    public void useToken() {
        this.expired = true;
    }
}

IgnorantEnglish\PlusAPI\src\main\java\hello\plusapi\repository\EmailAuthCustomRepositoryImpl.java

@Slf4j
public class EmailAuthCustomRepositoryImpl implements EmailAuthCustomRepository{

    JPAQueryFactory jpaQueryFactory;
    public EmailAuthCustomRepositoryImpl(EntityManager em) {
        this.jpaQueryFactory = new JPAQueryFactory(em);
    }

    public Optional<EmailAuth> findValidAuthByEmail(String authToken) {
        EmailAuth emailAuth = jpaQueryFactory
            .selectFrom(QEmailAuth.emailAuth)
            .where(QEmailAuth.emailAuth.authToken.eq(authToken),
                QEmailAuth.emailAuth.expireDate.goe(Timestamp.valueOf(LocalDateTime.now())),
                QEmailAuth.emailAuth.expired.eq(false))
            .fetchFirst();

        return Optional.ofNullable(emailAuth);
    }

    @Override
    public Optional<EmailAuth> findExpiredAuth(String authToken) {
        EmailAuth emailAuth = jpaQueryFactory
            .selectFrom(QEmailAuth.emailAuth)
            .where(QEmailAuth.emailAuth.authToken.eq(authToken),
                QEmailAuth.emailAuth.expireDate.loe(Timestamp.valueOf(LocalDateTime.now())),
                QEmailAuth.emailAuth.expired.eq(false))
            .fetchFirst();
        return Optional.ofNullable(emailAuth);
    }
}

Querydsl를 사용하여, findExpiredAuth 인증 제한 시간이 만료된, Auth를 찾거나, findValidAuthByEmail 제한 시간이 지나지 않은 유효한 Auth 정보를 찾는 처리를 진행했다.

이메일 인증 검사 확인 UI

이메일 인증 검사 성공

IgnorantEnglish\UI\src\main\resources\templates\user\account.html

<script th:inline="javascript">
  const apiUrl = [[${@environment.getProperty('url.api')}]];
  const uiUrl = [[${@environment.getProperty('url.ui')}]];

  //JWT 토큰
  let jwtToken = localStorage.getItem("Authorization")

	window.onload =function () {
    //타이틀에 이름 사용자 이름, 이메일 인증 정보 가져오기
    axios({
      url: "http://"+apiUrl+"/api/user/manage/information",
      method: "get",
      headers: {
        "Authorization": jwtToken
      }
    })
    .then(function (response) {
      console.log(response)
      document.getElementById("title-div").textContent = response.data.name+"님의 페이지";
      const emailAuthBoolean = response.data.emailAuth;
      if (emailAuthBoolean) {
        document.getElementById("email-auth-check").textContent = "이메일 인증 완료";
      } else {
        document.getElementById("email-auth-check").textContent = "이메일 인증이 필요합니다";
        document.getElementById("email-auth-check").setAttribute("class", "no-email-auth")
      }

    })
    .catch(function (error) {
    });
  }
</script>

이메일 인증 검사 실패 (시간 만료)

http://127.0.0.1:6610/api/email/auth?authToken=be107fdc-c76e-4b72-b0d5-44f27c794108

만료되 인증 토큰으로 인증 하려고 하면, 해당 에러 메세지를 받는다.