posts

이메일 생성 및 발송 프로세스

Oct 1, 2025 updated Oct 1, 2025 computer-sciencecssexpojavascripttypescript

1. RSA 링크 생성

RSA(Rivest-Shamir-Adleman) 암호화를 사용하여 보안 링크를 생성합니다.

String link = RsaUtils.generateVerificationLink(to, privateKey);
  • to: 수신자의 이메일 주소
  • privateKey: RSA 개인 키

generateVerificationLink 메서드는 다음과 같은 과정을 거칩니다:

  1. 이메일 주소와 만료 시간을 포함한 페이로드 생성
  2. RSA 개인 키를 사용하여 페이로드 암호화
  3. 암호화된 데이터를 Base64로 인코딩
  4. 인코딩된 데이터를 URL에 추가하여 완전한 링크 생성

2. Thymeleaf Context 설정

Context context = new Context();
context.setVariable("key", value);
  • Context: Thymeleaf의 변수 컨테이너
  • setVariable(String key, Object value): 컨텍스트에 변수 추가

사용된 변수들:

  • verificationCode: 이메일 인증 코드
  • email: 사용자 이메일 주소
  • propLink: 이메일 찾기 링크
  • resetLink: 비밀번호 재설정 링크

3. Thymeleaf Template 처리

String htmlBody = templateEngine.process("template-name", context);
  • templateEngine: Thymeleaf 템플릿 엔진 인스턴스
  • process(String template, Context context): 템플릿 처리 메서드

처리 과정:

  1. 지정된 이름의 템플릿 파일 로드
  2. 컨텍스트의 변수들을 템플릿의 플레이스홀더에 삽입
  3. 최종 HTML 문자열 생성

사용된 템플릿들:

  • verification-email: 인증 코드 이메일 템플릿
  • email-find-link: 이메일 찾기 링크 템플릿
  • password-reset-link: 비밀번호 재설정 링크 템플릿

4. Amazon SES 이메일 요청 객체 생성

SendEmailRequest request = new SendEmailRequest()
    .withDestination(new Destination().withToAddresses(to))
    .withMessage(new Message()
        .withBody(new Body().withHtml(new Content().withCharset("UTF-8").withData(htmlBody)))
        .withSubject(new Content().withCharset("UTF-8").withData(subject)))
    .withSource(sourceEmail);

SendEmailRequest 객체 구성:

  • Destination: 수신자 이메일 주소 설정
  • Message: 이메일 본문과 제목 설정
    • Body: 이메일 본문 (HTML 형식)
    • Subject: 이메일 제목
  • Source: 발신자 이메일 주소

5. 이메일 발송

sesClient.sendEmail(request);
  • sesClient: Amazon Simple Email Service (SES) 클라이언트
  • sendEmail(SendEmailRequest request): 실제 이메일 발송 메서드

발송 과정:

  1. SES 클라이언트가 AWS 서버에 연결
  2. 요청 객체를 AWS SES로 전송
  3. AWS SES가 이메일을 처리하고 지정된 수신자에게 발송
  4. 발송 성공 시 정상 반환, 실패 시 예외 발생

6. 예외 처리

try {
    sesClient.sendEmail(request);
} catch (AmazonSimpleEmailServiceException e) {
    throw new EmailSendException(e);
}
  • AmazonSimpleEmailServiceException: AWS SES 관련 예외
  • EmailSendException: 애플리케이션의 커스텀 예외

7. 비동기 처리

@Async
protected void sendEmail(String to, String subject, String htmlBody) {
    // 이메일 발송 로직
}

8. 시퀀스

sequenceDiagram
    participant C as 클라이언트
    participant MA as MailAdapter
    participant RSA as RsaUtils
    participant TE as TemplateEngine
    participant SES as Amazon SES

    C->>MA: 이메일 발송 요청
    alt 인증 코드 이메일
        MA->>TE: 인증 코드 템플릿 처리
    else 이메일 찾기/비밀번호 재설정
        MA->>RSA: 보안 링크 생성
        RSA-->>MA: 생성된 링크
        MA->>TE: 링크 포함 템플릿 처리
    end
    TE-->>MA: 처리된 HTML
    MA->>MA: SendEmailRequest 객체 생성
    MA->>SES: 이메일 발송 요청
    alt 발송 성공
        SES-->>MA: 발송 완료
        MA-->>C: 성공 응답
    else 발송 실패
        SES-->>MA: 예외 발생
        MA->>MA: EmailSendException 생성
        MA-->>C: 실패 응답
    end

  • 찾기 요청을 할 때 유저의 상태 업데이트

  • 메일 형식 및 링크 확인

  • 인증번호 발송 메일

    • 인증번호 입력 시 검증
      • 재인증 요청 시 메일 재발송 및 기존 코드 삭제 후 재삽입
  • 인증정보 익셉션 추가

  • 요청이 끝나면 인증 삭제

  • 인증 이메일 요청 시 이전에 있던 인증 정보 삭제

  • 인증 시간 내 제한 - 질문


RSA (Rivest-Shamir-Adleman) 암호화 방식:

RSA는 공개키 암호화 시스템의 하나로, 두 개의 키를 사용합니다:

  1. 공개키: 누구나 알 수 있는 키
  2. 개인키: 오직 소유자만 알고 있는 비밀 키

주요 특징:

  • 큰 소수의 곱셈을 기반으로 한 알고리즘
  • 공개키로 암호화된 메시지는 개인키로만 복호화 가능
  • 개인키로 서명한 메시지는 공개키로 검증 가능

구현 방법:

  1. 키 생성:

    • 서버 측에서 RSA 키 쌍(공개키/개인키) 생성
    • 공개키는 클라이언트에 제공, 개인키는 서버에 안전하게 보관
  2. 링크 생성 (서버):

    • 이메일 주소와 만료 시간을 포함한 페이로드 생성
    • 페이로드를 JSON으로 변환 후 Base64 인코딩
    • 인코딩된 데이터를 개인키로 서명
    • 인코딩된 데이터와 서명을 조합하여 링크 생성
  3. 링크 검증 (클라이언트):

    • 링크에서 인코딩된 데이터와 서명 추출
    • 서버의 공개키를 사용하여 서명 검증
    • 검증 성공 시 Base64 디코딩 및 JSON 파싱
    • 만료 시간 확인
  4. API 구현:

    • 공개키 제공 API 구현 (클라이언트가 서버의 공개키를 받을 수 있도록)
    • 링크 검증 결과를 서버에 전송하는 API 구현 (선택적)
// 키 생성
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
KeyPair keyPair = keyGen.generateKeyPair();
PublicKey publicKey = keyPair.getPublic();
PrivateKey privateKey = keyPair.getPrivate();

// 링크 생성
String payload = "{\"email\":\"user@example.com\",\"expiry\":1234567890}";
byte[] encodedPayload = Base64.getEncoder().encode(payload.getBytes());
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey);
signature.update(encodedPayload);
byte[] signatureBytes = signature.sign();
String signatureBase64 = Base64.getEncoder().encodeToString(signatureBytes);
String link = "https://example.com/verify?data=" + new String(encodedPayload) + "&signature=" + signatureBase64;

// 링크 검증 (클라이언트 측)
String[] parts = link.split("&signature=");
byte[] receivedPayload = parts[0].split("=")[1].getBytes();
byte[] receivedSignature = Base64.getDecoder().decode(parts[1]);

Signature verifier = Signature.getInstance("SHA256withRSA");
verifier.initVerify(publicKey);
verifier.update(receivedPayload);
boolean isValid = verifier.verify(receivedSignature);

if (isValid) {
    String decodedPayload = new String(Base64.getDecoder().decode(receivedPayload));
    // JSON 파싱 및 만료 시간 확인
}
sequenceDiagram
    participant Server
    participant Client

    Note over Server: Generate RSA key pair on startup
    Client->>Server: Request public key
    Server->>Client: Send public key
    Note over Client: Store public key

    Note over Server: User requests password reset
    Server->>Server: Create payload (email, expiry)
    Server->>Server: Encrypt payload with private key
    Server->>Client: Send encrypted link via email

    Note over Client: User clicks link
    Client->>Client: Extract encrypted data from link
    Client->>Client: Decrypt data using stored public key
    Client->>Client: Verify payload (expiry, etc.)

    alt Link is valid
        Client->>Server: Proceed with password reset
    else Link is invalid
        Client->>Client: Show error message
    end
  1. RSA 키 페어 생성 (CLI 사용):

OpenSSL을 사용하여 키 페어를 생성할 수 있습니다. 터미널에서 다음 명령어를 실행합니다:

# 프라이빗 키 생성
openssl genpkey -algorithm RSA -out <deploy-key>.pem -pkeyopt rsa_keygen_bits:2048

# 프라이빗 키에서 퍼블릭 키 추출
openssl rsa -pubout -in <deploy-key>.pem -out <deploy-key>.pem

# 프라이빗 키를 Base64로 인코딩 (환경 변수용)
base64 -w 0 < <deploy-key>.pem > private_key_base64.txt

# 퍼블릭 키를 Base64로 인코딩 (환경 변수용)
base64 -w 0 < <deploy-key>.pem > public_key_base64.txt
  1. 키 설정:
  • 백엔드 (Java): private_key_base64.txt의 내용을 환경 변수로 설정

    RSA_PRIVATE_KEY=<private_key_base64.txt의 내용>
    
  • 프론트엔드 (React): public_key_base64.txt의 내용을 환경 변수로 설정

    REACT_APP_RSA_PUBLIC_KEY=<public_key_base64.txt의 내용>
    
  1. Java 코드 수정:

RsaUtils 클래스를 다음과 같이 수정합니다:

package kr.co.visibleray.prop.util;

import javax.crypto.Cipher;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

public class RsaUtils {
    private static final String INSTANCE_TYPE = "RSA";

    public static String encryptWithPrivateKey(String plainText, String privateKeyString) throws Exception {
        PrivateKey privateKey = convertPrivateKey(privateKeyString);
        Cipher cipher = Cipher.getInstance(INSTANCE_TYPE);
        cipher.init(Cipher.ENCRYPT_MODE, privateKey);
        byte[] encryptedBytes = cipher.doFinal(plainText.getBytes());
        return Base64.getEncoder().encodeToString(encryptedBytes);
    }

    private static PrivateKey convertPrivateKey(String privateKeyString) throws Exception {
        byte[] privateBytes = Base64.getDecoder().decode(privateKeyString);
        PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateBytes);
        KeyFactory keyFactory = KeyFactory.getInstance(INSTANCE_TYPE);
        return keyFactory.generatePrivate(keySpec);
    }
}
  1. 백엔드에서 사용:
@Service
public class EmailService {
    @Value("${RSA_PRIVATE_KEY}")
    private String privateKeyString;

    @Autowired
    private MailAdapter mailAdapter;

    public void sendEmailFindLink(String email) throws Exception {
        long expiryTime = System.currentTimeMillis() + 3600000; // 1 hour
        String payload = String.format("{\"email\":\"%s\",\"expiry\":%d}", email, expiryTime);

        String encryptedPayload = RsaUtils.encryptWithPrivateKey(payload, privateKeyString);
        String link = "https://example.com/verify?data=" + encryptedPayload;

        mailAdapter.sendEmailFindLink(email, link);
    }
}
  1. 프론트엔드 (React) 에서 사용:
import { Buffer } from 'buffer';
import { publicDecrypt } from 'crypto-browserify';

function verifyLink(link) {
  const publicKey = process.env.REACT_APP_RSA_PUBLIC_KEY;

  try {
    const encryptedData = link.split('data=')[1];
    const buffer = Buffer.from(encryptedData, 'base64');

    const decrypted = publicDecrypt(
      { key: publicKey, padding: crypto.constants.RSA_PKCS1_PADDING },
      buffer
    );

    const payload = JSON.parse(decrypted.toString());

    if (Date.now() > payload.expiry) {
      return false; // 링크 만료
    }

    // 추가 검증 로직...

    return true;
  } catch (error) {
    console.error('Link verification failed:', error);
    return false;
  }
}
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import CryptoJS from 'crypto-js';
import JSEncrypt from 'jsencrypt';

const ResetPassword = () => {
  const [email, setEmail] = useState('');
  const [isExpired, setIsExpired] = useState(false);
  const location = useLocation();

  useEffect(() => {
    const decryptData = () => {
      const searchParams = new URLSearchParams(location.search);
      const encryptedData = searchParams.get('data');

      if (encryptedData) {
        const publicKey = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuqCptryI0CGnCDOyN4ro
yd7s9MUvamfeRuYopBznRgGyd6jxnQPXClujGdkhz0dKp445bc9W06OZcxGTCZyu
HuQrlUDgnzAjfFQDMVYcY5B5GjWezU69VOsYB5V5/0aPebXWUQhoJMc0M6B+NlJJ
6bLrR2a0Y5TqB9iiZoYsF4AxKgNeyYQ5kGMs7FakN2T2153pBcjJSVr/mQd+ee2M
psgw8KYQQjHn6+yFiuIaP/cnt0KiYqWrE72+C/lKH7rXIpufOoEAICMMd9hmbojL
V7dZS0JnA7Lt487KAKvfnFhA/QsRn9ShsoWk7OOfQ7psFBkNrja4a8VcrmtcogjfJQ
IDAQAB
-----END PUBLIC KEY-----`;

        const decrypt = new JSEncrypt();
        decrypt.setPublicKey(publicKey);

        const decryptedData = decrypt.decrypt(encryptedData);

        if (decryptedData) {
          const { email, expiry } = JSON.parse(decryptedData);
          setEmail(email);
          setIsExpired(Date.now() > expiry);
        }
      }
    };

    decryptData();
  }, [location]);

  if (isExpired) {
    return <div>링크가 만료되었습니다. 새로운 비밀번호 재설정 링크를 요청해주세요.</div>;
  }

  return (
    <div>
      <h1>비밀번호 재설정</h1>
      <p>{email} 계정의 비밀번호를 재설정합니다.</p>
      {/* 비밀번호 재설정 폼 구현 */}
    </div>
  );
};

export default ResetPassword;
sequenceDiagram
    participant U as 사용자
    participant F as 프론트엔드
    participant B as 백엔드
    participant E as 이메일 서버

    U->>B: 비밀번호 재설정 요청
    B->>B: RSA 키쌍 생성
    B->>B: 이메일과 만료시간으로 페이로드 생성
    B->>B: 개인키로 페이로드 암호화
    B->>E: 암호화된 링크가 포함된 이메일 전송
    E->>U: 비밀번호 재설정 이메일 수신
    U->>F: 이메일의 링크 클릭
    F->>F: URL에서 암호화된 데이터 추출
    F->>F: 공개키로 데이터 복호화
    F->>F: 이메일과 만료시간 확인
    alt 링크가 유효한 경우
        F->>U: 비밀번호 재설정 폼 표시
    else 링크가 만료된 경우
        F->>U: 오류 메시지 표시
    end

Public Key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnQru4l5JTgTo4PCQjlP2OgkrH70RblLpkUCy/0ZN6F+ESE9PXgIosqwEjVLRe60Ua3Ywus6L2FOWgJrZXOxS0sapNvRJGoeR+d/hTuqriQY4WhKhxvT7B2wYL3otwP5DjFnewhEZ9u4N42n01AsUzXFK+EXRyyF9yMNfKCs702IpyQvOuZmthDu9kgquc+6H2D4taH70tpZcCqtaL2o0C9etCWYJ7S7wOZlu07FIQ92UZR5Ceph+nDK1m/TPYTNLXDLM12YvtXGNptxhAy0ISkz7DINM+k/7/5+eoN+J4N5TqSymf2o1DbZVmMMY5RHSBfAmaNnwc6tmBtOYIoefQQIDAQAB

Private Key: