이메일 인증을 DB에서 Redis로 바꾼 이유

Junyoung·2026년 3월 24일

Virgin Road

목록 보기
2/5

배경

Virgin Road는 신랑/신부가 방을 만들 때 이메일 인증을 거친다. 인증 코드를 이메일로 보내고, 5분 안에 입력하면 방이 활성화되는 구조다.

처음 구현할 때 인증 코드를 어디에 저장할지 고민했다. 가장 빠른 선택은 DB 테이블이었다. 이미 MySQL을 쓰고 있으니까 테이블 하나 추가하면 끝이었다.


DB로 처음 구현했을 때

email_verification 테이블을 만들었다.

CREATE TABLE email_verification (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    room_id BIGINT NOT NULL,
    auth_code VARCHAR(6) NOT NULL,
    attempts INT DEFAULT 0,
    created_at DATETIME NOT NULL
);

인증 코드 발급 시 INSERT, 인증 완료 시 DELETE. 동작은 했다.

근데 만료 처리가 문제였다. DB에는 TTL 개념이 없으니 직접 구현해야 했다. 조회할 때마다 created_at + 5분 < now() 조건을 달거나, 별도 스케줄러로 주기적으로 만료된 레코드를 지워야 했다.

5분짜리 임시 데이터를 영구 저장소에 넣고, 그걸 지우기 위해 스케줄러까지 돌려야 하는 구조가 점점 이상하게 느껴졌다.


Redis로 전환

Redis는 TTL을 네이티브로 지원한다. 키를 저장할 때 만료 시간을 같이 설정하면 알아서 사라진다. 스케줄러도 필요 없고, 만료 로직도 없다.

email_verification 테이블과 엔티티를 아예 날리고 Redis로 전환했다. Flyway 마이그레이션으로 테이블도 DROP했다.

-- V7__drop_email_verification_table.sql
DROP TABLE IF EXISTS email_verification;

Redis 키 구조는 Hash로 잡았다.

key   : verification:{roomCode}
field : authCode  → "382910"
        attempts  → "0"
TTL   : 5분

이메일이 아닌 roomCode를 키로 쓴 이유는, 같은 이메일로 방을 여러 번 만드는 케이스에서 키 충돌이 생기기 때문이다. 방마다 고유한 roomCode가 있으니 이걸 키로 쓰는 게 더 자연스럽다.

public String createVerification(String roomCode) {
    String authCode = generateAuthCode();
    String key = KEY_PREFIX + roomCode;

    redisTemplate.opsForHash().putAll(key, Map.of(
            FIELD_AUTH_CODE, authCode,
            FIELD_ATTEMPTS, "0"
    ));
    redisTemplate.expire(key, EXPIRY); // 5분 TTL

    return authCode;
}

시도 횟수 제한도 자연스럽게

5회 이상 틀리면 더 이상 시도 못하게 막는다. Redis Hash의 increment로 처리한다.

public void verify(String roomCode, String authCode) {
    String key = KEY_PREFIX + roomCode;
    Map<Object, Object> data = redisTemplate.opsForHash().entries(key);

    if (data.isEmpty()) {
        throw new BusinessException(ErrorCode.VERIFICATION_NOT_FOUND); // 만료 또는 미존재
    }

    int attempts = Integer.parseInt((String) data.get(FIELD_ATTEMPTS));
    if (attempts >= MAX_ATTEMPTS) {
        throw new BusinessException(ErrorCode.VERIFICATION_MAX_ATTEMPTS);
    }

    redisTemplate.opsForHash().increment(key, FIELD_ATTEMPTS, 1);

    String storedAuthCode = (String) data.get(FIELD_AUTH_CODE);
    if (!storedAuthCode.equals(authCode)) {
        throw new BusinessException(ErrorCode.VERIFICATION_INVALID_CODE);
    }

    redisTemplate.delete(key); // 인증 성공 시 즉시 삭제
}

TTL이 지나면 키 자체가 사라지기 때문에 data.isEmpty() 하나로 "만료됨"과 "존재하지 않음"을 동시에 처리할 수 있다. DB였으면 조건이 두 개였을 것이다.


정리

DBRedis
TTL 관리직접 구현네이티브 지원
만료 레코드 정리스케줄러 필요자동
적합성영구 데이터임시 데이터

인증 코드처럼 임시적이고, 만료가 핵심인 데이터는 Redis가 맞다. DB로 구현하면 TTL을 흉내 내는 코드가 생기고, 그걸 관리하기 위한 코드가 또 생긴다. 전환하고 나서 EmailVerification 엔티티, 레포지토리, 스케줄러 관련 코드가 전부 사라졌다.

profile
라곰

0개의 댓글