메일 기반 MMS 전화번호 인증

이프·2025년 7월 9일

back-end

목록 보기
10/16

도입 배경

GreenWinit에서 상품 배송을 하기 위해 휴대전화 인증이 필요한 상황

현재 MVP 기능 구현 단계였고, 서비스가 출시되면 테스트 진행 후 상황에 따라 계속해서 사업을 진행할 계획이라고 한다.

그래서 SMS 인증에 대해 지원금도 없고 SMS 인증 비용에 한계가 있었다.
어떻게 비용을 아낄 수 있을까? 주구 장창 검색

MMS를 활용한 인증을 하는 방법이 있었다.
메일을 활용하는 만큼 가격은 거의 무료였다.


MMS 인증 처리

통신사는 문자로 메일을 보낼 수 있다. 이 때, MMS로 자동으로 바뀌게 된다.

MMS로 메일을 보내게 되면, 사진과 같이 전화번호@통신사로 문자가 오게 된다.
우리는 이 정보로 메일 기반 전화번호 인증을 할 수 있게 된다.

처리 방식 도식화

<시스템 아키텍처>

시퀀스 다이어그램

다이어그램을 통해 전체 프로세스를 이해해보자.

  • 그린 위닛은 상품 교환 신청 전, 배송지 정보를 필요로 한다.
  • 배송지 정보가 없다면 저장해야 되는데, 이 때 전화번호 인증을 필요로 한다.
  • 전화번호 인증 내역이 없다면 전화 번호 인증을 요청한다.
  • 전화번호 인증 시 MMS 기반으로 메일을 보낸다.
  • 인증 확인 요청 시 서버에서 Mail 수신함을 뒤져보고 전화번호로 수신된 메일을 확인한다.
  • 토큰을 가져와서 DB에 저장된 토큰과 비교한다.
  • 성공 시, 사용자는 배송지 저장 요청을 할 수 있다.
  • 배송지 저장 요청 시 DB에서 Verified 상태인지 확인한다.

장/단점

장점

해당 방식의 장점은 비용이 거의 무료에 가깝다는 것이다.
물론, 메일의 크레딧을 다하면 조금의 비용은 발생하지만 여전히 실제 SMS보다는 훨씬 저렴하다.

사실 메일의 크레딧이 다 될 정도면 서비스가 나름 원활하게 돌아가고 있으므로 SMS 인증 방식으로 교체해도된다.

단점

생각보다 이 방식의 단점은 많다. 하지만 현재는 MVP 단계이므로 QA 과정 혹은 문제가 발생했을 때, 처리하도록 잠시 배제해두고 예측만 하고 있는다. YAGNI

  1. 메시지 인증 동기화 처리
    -> 클라이언트가 인증 확인을 눌렀을 때, 서버에서 메일을 확인하는 과정이 발생하는데 이 시점에 과연 메일이 도착했을까?

    정답은 알 수 없다.
    근래 통신 속도를 생각하면 통신사에서 전송하는 메일 속도도 매우 빠르다. 하지만, 네트워크 상 혹은 통신사의 혼잡도에 따라서 메일이 생각보다 늦게 전송되는 경우도 있을 것이다.

    직접 확인해 본 결과 메세지 전송 후 메세지 확인을 누르는 시점에 무조건 도착해있었다. 하지만, 안전을 위해 @Retryable을 활용할 수 있다. (이것또한 근본적인 해결책은 안되지만 대부분 해결된다.)

  2. 악의적인 메일 발신
    -> 누군가가 의도적으로 서버 메일에 계속해서 메일을 전송할 수 있다.

    Gmail의 필터링 기능을 활용할 수 있다.
    1) 직접 메일 모니터링을 통해 악의적인 사용자 식별 -> 필터링
    2) 스케줄링을 통해 특정 시간동안 비정상적인 메세지 수 발견 -> 필터링
    그 외에도 여러 방법이 떠오르지만, 우선은 고려만 해두자.

  3. UX 경험 감소
    휴대 기기에서 메세지 창에 들어가 일일이 직접 문자를 작성하는 것은 번거롭다.
    -> 우리는 웹앱이고 해당 문제점은 front의 영역이지만 해결 방법은 명확히 있다.
    IETF RFC 5724 표준화 방법인 sms: scheme 을 활용한다.
    sms:<메일주소>?body=<내용> 으로 hyperlink를 걸면 sms 앱으로 자동으로 열린다.

    pc에서도 마찬가지다 sms:jihwangim128@gmail.com?body=테스트 메세지입니다.로 연결하면 Macbook에도 아래 사진과 같이 sms 앱을 실행시키고 주소 대상, 메시지까지 포함해서 정상적으로 처리된다.

    이렇게 UX 경험을 개선할 수 있다.


주요 구현 포인트 In Spring Boot

우선, 인터넷의 대부분은 SMTP에 관한 내용뿐이라 기본적인 Imap의 지식은 알고 있는게 좋다.

그 다음 구글 계정에서 앱 비밀번호를 발급받는다.

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

mail 인증을 위해 디펜던시를 추가하자.

spring:
  mail:
    username: ${RECEIVER_EMAIL_ADDRESS}
    password: ${RECEIVER_EMAIL_PASSWORD}
    properties:
      mail:
        imap:
          host: imap.gmail.com
          port: 993
          ssl:
            enable: true
          connectiontimeout: 10000
          timeout: 10000

application.yml에 해당 정보를 추가하자.
RECEIVER_EMAIL_ADDRESS: 메일 수신 이메일 주소
RECEIVER_EMAIL_PASSWORD: 앱 키 비밀번호

spring:
  mail:
    host: ...
    port: ...
    username: ${RECEIVER_EMAIL_ADDRESS}
    password: ${RECEIVER_EMAIL_PASSWORD}

이런식으로도 할 수 있지만, 비추한다. 스프링 메일은 기본적으로 smtp만 지원한다.
나중에 smtp 도입 시 처음부터 이 작업을 다시 해야 될 수 있으니 참고 바람.

이제 기본적인 설정은 끝났고 핵심 구현 정보를 알아보자!

Imap Bean 등록

Spring mail은 앞서 말했듯 JavaMailSender만 지원하고 있었다.. 왜 수신받는건 구현을 안해줬을까 ㅠ

package org.springframework.boot.autoconfigure.mail;

import ...

@ConfigurationProperties(
    prefix = "spring.mail"
)
public class MailProperties {
    private String protocol = "smtp";
    private final Map<String, String> properties;
    ...
}

MailProperties 내부에는 앞서 yml에서 작성한 정보들을 획득 할 수 있다.

이 정보로 Imap 전용 Bean을 등록한다.


@Configuration
@EnableConfigurationProperties(MailProperties.class)
public class ImapConfig {

	@Bean
	public Properties imapConnectionProperties(MailProperties mailProperties) {
		Properties props = new Properties();
		props.put("mail.store.protocol", "imaps");

		Map<String, String> mailProps = mailProperties.getProperties();
		props.put("mail.imaps.host", mailProps.get("mail.imap.host"));
		props.put("mail.imaps.port", mailProps.get("mail.imap.port"));
		props.put("mail.imaps.ssl.enable", mailProps.get("mail.imap.ssl.enable"));
		props.put("mail.imaps.connectiontimeout", mailProps.get("mail.imap.connectiontimeout"));
		props.put("mail.imaps.timeout", mailProps.get("mail.imap.timeout"));

		return props;
	}

	@Bean
	public ImapCredentials imapCredentials(MailProperties mailProperties) {
		return new ImapCredentials(mailProperties.getUsername(), mailProperties.getPassword());
	}
}

토큰 생성 및 관리

@Entity
@Table(name = "phone_verifications")
public class PhoneVerification {
    private static final int TOKEN_LENGTH = 16;
    private static final int EXPIRATION_MINUTES = 10;
    
    // 16자리 랜덤 토큰 생성
    public static PhoneVerification of(PhoneNumber phoneNumber, 
                                     TokenGenerator tokenGenerator, 
                                     LocalDateTime now) {
        return new PhoneVerification(phoneNumber, 
                                   tokenGenerator.generate(TOKEN_LENGTH), 
                                   now);
    }
}

Gmail Imap 연결 및 검색

private final Properties imapConnectionProperties;
private final ImapCredentials imapCredentials;

@Override
public String getServerEmail() {
	return imapCredentials.userName();
}
    
@Override
public Optional<String> extractTokenByPhoneNumber(PhoneNumber phoneNumber, LocalDateTime since) {
	try {
		Store store = connectToEmailStore();
		Folder inbox = openInboxFolder(store);
		Optional<String> result = Optional.ofNullable(searchTokenInEmails(inbox, phoneNumber, since));
		closeConnections(inbox, store);
		return result;
	} catch (Exception e) {
		log.error("메일 확인 중 오류 발생: {}", e.getMessage());
		return Optional.empty();
	}
}
    
private String searchTokenInEmails(Folder inbox, PhoneNumber phoneNumber, LocalDateTime since) {
    // 시간 필터링
    Date sinceDate = Date.from(since.atZone(ZoneId.systemDefault()).toInstant());
    SearchTerm timeTerm = new ReceivedDateTerm(ComparisonTerm.GE, sinceDate);
    Message[] messages = inbox.search(timeTerm);
    
    // 최신순 정렬
    Arrays.sort(messages, (a, b) -> dateB.compareTo(dateA));
    
    // 해당 휴대폰 번호에서 발신된 메일 찾기
    for (Message message : messages) {
        if (isFromPhoneNumber(message, phoneNumber)) {
            return extractTokenFromMessageContent(message);
        }
    }
    return null;
}

다양한 통신사별 메시지 형태 처리

private String extractTokenFromMessageContent(Message message) {
    // LG U+: 단순 텍스트 본문
    if (message.isMimeType("text/plain")) {
        String content = (String)message.getContent();
        return extractToken(content);
    }
    
    // KT: multipart (본문 + 첨부파일)
    if (message.isMimeType("multipart/*")) {
        return extractFromMultipart(message);
    }
    
    // SKT: 추후 처리 예정
    return null;
}
private String extractFromMultipart(Message message) {
    Multipart multipart = (Multipart)message.getContent();
    
    for (int i = 0; i < multipart.getCount(); i++) {
        BodyPart bodyPart = multipart.getBodyPart(i);
        
        // 1. 텍스트 본문에서 토큰 찾기
        if (bodyPart.isMimeType("text/plain")) {
            // 본문에서 토큰 추출
        }
        
        // 2. 텍스트 파일 첨부에서 토큰 찾기 (KT)
        if (isTextFile(bodyPart)) {
            InputStream inputStream = bodyPart.getInputStream();
            String content = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
            return extractToken(content);
        }
    }
    return null;
}

KT의 경우 txt 파일로 첨부되는 경우가 있어 별도 처리가 필요했다.
또, 아이폰과 안드로이드 또한 발신 방식이 달랐지만 구현에는 문제가 없었다.

SKT는 최근 일때문인지.. 주변에 보이질 않아서 확인을 못했다 ㅜㅜ
나중에 확인하면 추가할 예정이다.


보안 고려 사항

여러가지 보안 문제 사항이 있겠지만, 앞서 작성한 특별한 케이스가 아닌 정말 자주 발생할 수 있는 사항들을 기준으로만 우선 처리했다.

1. 시도 횟수 제한

@Embeddable
public class Attempt {
    private static final int MAX_TRY_COUNT = 5;
    
    public Attempt increaseCount() {
        if (MAX_TRY_COUNT < attempts + 1) {
            throw new AuthException(PhoneExceptionMessage.OVER_MAX_TRY);
        }
        return new Attempt(this.attempts + 1);
    }
}

2. 토큰 만료 시간 설정

private static final int EXPIRATION_MINUTES = 10;

public void verifyExpiration(LocalDateTime now) {
    if (now.isAfter(expiresAt)) {
        throw new AuthException(PhoneExceptionMessage.VERIFICATION_EXPIRED);
    }
}

3. 토큰 재발급 상태 관리

public void markAsReissue() {
    this.status = VerificationStatus.REISSUED;
}

마치며

MVP 단계에서 비용을 절약하면서도 효과적인 인증 시스템을 구현할 수 있었다. 비록 사용자가 직접 MMS를 전송해야 하는 불편함이 있지만, 서비스 초기 단계에서는 충분히 활용할 수 있는 방법이라고 생각한다.

또 사용자가 화면에 직접 인증코드를 작성하는것과 별반 차이가 없다고도 생각한다.

추후 서비스가 안정화되면 SMS API로 전환하되, 현재로서는 창의적인 해결책으로 비용 효율성을 달성할 수 있었다!

추후 작업 예정

현재 SKT 처리 방식은 아직 확인 중입니다. 확인이 마치는대로 현재 코드와 차이점이 있다면 코드와 포스트 모두 수정할 예정입니다.

참고자료

https://obtuse.kr/dev/free-phone-verification/

profile
if (이런 시나리오는 어떨까?) then(테스트로 검증하고 해결) else(다음 시나리오 고민)

0개의 댓글