Redis로 구현한 인증 코드 생성 과정 정리

후라이드·2025년 11월 19일

회고록

목록 보기
19/28
post-thumbnail

KLÜME 프로젝트는 왜 Redis를 사용했는가?

https://github.com/HoodRyan/be19-4th-0xAM-Klume

📌 개요

지난 KLÜME 프로젝트에서 나는 회원가입과 로그인 파트를 맡아서 개발했고,

Local 회원가입을 할 때에는 인증코드를 통해 해당 이메일의 인증을 거쳐야 했었다.

그리고 다른 팀원분이 개발한 기능에도 조직 초대 코드 생성 기능이 있었다.

이 기능들은 공통적으로

  • 짧은 시간 동안만 유효한 데이터
  • 빠른 읽기/쓰기가 필요한 데이터
  • 자동으로 만료되어야 하는 데이터

라는 특징을 갖고 있었다.

이 문제를 해결하기 위해 선택한 기술이 바로 Redis였다.


⭐ Redis란?

  • Redis(Remote Dictionary Server)는 메모리 기반의 오픈소스 Key-Value 저장소다.

주요 특징

  • 인메모리 데이터베이스 - 모든 데이터를 메모리에 저장해 초고속 처리
  • Key-Value 구조 - 단순하고 직관적인 NoSQL
  • TTL 지원 - 데이터에 만료 시간을 설정하면 자동 삭제
  • 다양한 자료구조 - String, List, Set, Hash 등
  • 싱글 스레드 - 모든 명령을 순차 처리해 Race Condition 방지

⭐ Redis를 사용했던 이유

1. 메모리 기반이라 빠르다

이메일 인증 과정은 사용자가 직접 입력하면서 대기하는 흐름이라

속도가 느려지면 사용자 경험이 망가지게 된다.

Redis는 메모리 기반이기 때문에 DB 대비 수십~수백 배 빠르다.

실제로 Redis는 초당 10만 건 이상의 읽기/쓰기를 처리할 수 있다.


2. TTL(Time to Live, 만료시간) 지원

이메일 인증코드는 보통 3~5분 정도만 유효하면 된다.

인증코드를 MariaDB에 저장하면

  • 별도의 만료 시간 컬럼 추가
  • 주기적으로 만료된 데이터를 삭제하는 스케줄러 구현
  • DELETE 쿼리 실행으로 인한 DB 부하

등의 추가 작업이 필요하다.

반면 Redis는 key에 TTL을 지정하기만 하면 끝이다.

// 3분 뒤 자동 삭제
redisService.setDataExpire(email, code, 3, TimeUnit.MINUTES);

지정한 시간(180초)이 지나면 자동 삭제되고, Cleanup 배치 작업도 필요 없다.


3. 단순한 Key-Value 구조

임시 인증 데이터는 복잡한 관계형 구조가 필요 없다.

이메일 → 인증코드
초대코드 → 조직ID

이런 단순한 매핑만 있으면 되는데, 이를 위해 테이블을 만들고 ORM을 설정하는 건 오버엔지니어링이다.

Redis의 Key-Value 구조가 딱 맞는 유스케이스였다.


4. 분산 환경에서도 안전함

만약 서버가 여러 대로 확장된다면?

애플리케이션 메모리에 저장하면 각 서버마다 다른 데이터를 가지게 된다.

하지만 Redis는 중앙 저장소 역할을 하기 때문에

여러 서버에서 접근해도 동일한 데이터를 공유할 수 있다.

[서버1] ──┐
[서버2] ──┼─→ [Redis] ← 모든 서버가 같은 데이터 참조
[서버3] ──┘

Redis는 싱글스레드인데, 동시 요청 시 문제는?

개발하면서 redis를 알아볼 때 나도 궁금했던 부분이었는데,

정답은 동시 요청에도 문제가 없고, 오히려 정상적으로 작동을 잘 했다.

Redis 싱글스레드 구조의 장점

모든 명령은 큐(Queue)로 들어가 순차적으로 처리됨

즉, 내부적으로는 다음과 같은 흐름이다

[A 요청 도착] --> [명령 큐] --> A 처리 완료
[B 요청 도착] --> [명령 큐] --> B 처리 완료

두 요청이 동시에 도착하더라도 Redis는 한 번에 하나씩만 처리한다.

그래서 Race Condition 자체가 발생하지 않는다.

Race Condition이란?
누가 먼저 실행되느냐(=race)에 따라 결과가 달라져서 버그가 생기는 상황

멀티스레드 DB와의 차이

MariaDB 같은 RDBMS는 멀티스레드로 동작하기 때문에

-- 두 사용자가 동시에 실행하면?
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1;

이런 경우 Lock이나 트랜잭션 격리 수준 설정이 필요하다.

하지만 Redis는 싱글스레드라 Lock이 필요 없다.


💻 실제 KLÜME에서의 구현

1. Redis 서비스 구현

먼저 Redis 작업을 추상화한 공통 서비스를 만들었다.

@RequiredArgsConstructor
@Component
public class RedisServiceImpl implements RedisService {
    private final StringRedisTemplate redisTemplate;

    @Override
    public void setDataExpire(final String key, final String value,
                              final long duration, final TimeUnit timeUnit) {
        redisTemplate.opsForValue().set(key, value, duration, timeUnit);
    }

    @Override
    public String getData(final String key) {
        return redisTemplate.opsForValue().get(key);
    }

    @Override
    public void deleteData(final String key) {
        redisTemplate.delete(key);
    }
}

설계 포인트:

  • StringRedisTemplate 사용 - 모든 데이터를 String으로 처리 (간단명료)
  • 인터페이스로 추상화 - 나중에 Redis 외 다른 저장소로 교체 가능
  • TTL 지정 필수 - 모든 저장 메서드에 만료 시간 파라미터 포함

2. 이메일 인증 구현

인증 코드 발송

@Service
public class MailServiceImpl implements MailService {
    private static final long TTL_MINUTES = 3;  // 인증 코드 유효시간

    @Override
    public void sendVerificationCode(final String email) {
        // 1. 6자리 랜덤 코드 생성
        final String code = generateRandomCode(6);

        // 2. Redis에 저장 (3분 TTL)
        redisService.setDataExpire(email, code, TTL_MINUTES, TimeUnit.MINUTES);

        // 3. 이메일 발송
        sendEmail(email, "인증 코드: " + code);
    }

    private String generateRandomCode(final int length) {
        return RandomStringUtils.randomNumeric(length);
    }
}

동작 과정:

  1. 사용자가 이메일 입력 → POST /auth/mail/send
  2. 6자리 랜덤 숫자 생성 (예: 123456)
  3. Redis에 저장: user@example.com → "123456" (3분 TTL)
  4. 이메일 발송

3분 후 자동 삭제:

0분: Redis에 저장
1분: 아직 유효
2분: 아직 유효
3분: 자동 삭제 (Redis가 알아서 처리)

인증 코드 검증

@Override
public void verifyCode(final String email, final String inputCode) {
    // 1. Redis에서 저장된 코드 조회
    final String storedCode = redisService.getData(email);

    // 2. 코드가 없거나 만료됨
    if (storedCode == null) {
        throw new VerificationCodeNotFoundException();
    }

    // 3. 코드 불일치
    if (!storedCode.equals(inputCode)) {
        throw new VerificationCodeMismatchException();
    }

    // 4. 인증 성공 - 기존 코드 삭제
    redisService.deleteData(email);

    // 5. 인증 완료 상태 저장 (10분 TTL - 회원가입 완료까지 유예)
    redisService.setDataExpire(
        email + ":verified",
        "true",
        10,
        TimeUnit.MINUTES
    );
}

검증 로직:

  • getData(email) → 코드가 없으면 null 반환 (만료됨)
  • 코드 일치 여부 확인
  • 인증 성공 시 email:verified 키로 상태 저장 (10분간 유효)

회원가입 시 인증 상태 확인

@Override
public void signUp(final SignUpRequestDTO requestDTO) {
    final String email = requestDTO.getEmail();

    // 1. 이메일 인증 여부 확인
    final String verified = redisService.getData(email + ":verified");
    if (verified == null) {
        throw new EmailNotVerifiedException();
    }

    // 2. 회원가입 처리
    memberRepository.save(new Member(email, password));

    // 3. 인증 상태 삭제 (일회용)
    redisService.deleteData(email + ":verified");
}

2단계 TTL 전략:

[인증 코드 발송]
email → "123456" (3분 TTL)

[인증 성공]
email:verified → "true" (10분 TTL)

[회원가입 완료]
email:verified 삭제

왜 2단계로 나눴나?

  • 1단계 (3분): 인증 코드는 짧게 - 보안상 빠른 만료 필요
  • 2단계 (10분): 인증 완료 후 회원가입까지 여유 시간 제공

3. 조직 초대 코드 구현

팀원분이 개발한 조직 초대 기능도 Redis를 활용했다.

@Service
public class OrganizationServiceImpl implements OrganizationService {
    private static final String INVITATION_CODE_PREFIX = "inviteCode:";
    private static final String ORGANIZATION_PREFIX = "organization:";

    @Override
    public String createInvitationCode(final int organizationId) {
        // 1. 6자리 랜덤 코드 생성
        final String invitationCode = generateRandomCode(6);

        // 2. Redis에 양방향 매핑 저장
        saveInvitationCodeToRedis(organizationId, invitationCode);

        return invitationCode;
    }

    private void saveInvitationCodeToRedis(final int organizationId,
                                            final String invitationCode) {
        // 기존 초대 코드가 있다면 삭제 (조직당 하나의 코드만 유지)
        final String oldCode = redisService.getData(ORGANIZATION_PREFIX + organizationId);
        if (oldCode != null) {
            redisService.deleteData(INVITATION_CODE_PREFIX + oldCode);
        }

        // 양방향 매핑 저장 (30분 TTL)
        redisService.set(
            INVITATION_CODE_PREFIX + invitationCode,  // inviteCode:ABC123 → 1
            String.valueOf(organizationId),
            Duration.ofMinutes(30)
        );
        redisService.set(
            ORGANIZATION_PREFIX + organizationId,     // organization:1 → ABC123
            invitationCode,
            Duration.ofMinutes(30)
        );
    }
}

양방향 매핑 구조:

초대 코드로 조직 찾기:
inviteCode:ABC123 → "1" (조직 ID)

조직 ID로 초대 코드 찾기:
organization:1 → "ABC123"

왜 양방향 매핑?

  1. 초대 검증 시inviteCode:ABC123으로 빠르게 조직 ID 조회
  2. 새 코드 생성 시organization:1으로 기존 코드 찾아서 무효화
    • 조직당 하나의 유효한 초대 코드만 유지

초대 코드 검증

@Override
public void joinOrganization(final int memberId, final String invitationCode) {
    // 1. Redis에서 조직 ID 조회
    final String organizationId = redisService.getData(
        INVITATION_CODE_PREFIX + invitationCode
    );

    // 2. 코드가 없거나 만료됨
    if (organizationId == null) {
        throw new OrganizationInvitationCodeInvalidException();
    }

    // 3. 조직 가입 처리
    final Organization organization = organizationRepository.findById(
        Integer.parseInt(organizationId)
    ).orElseThrow();

    organizationMemberRepository.save(
        new OrganizationMember(memberId, organization)
    );
}

Redis 키 설계 전략

기능Redis 키TTL목적
이메일 인증 코드{email}"123456"3분인증 코드 저장
이메일 인증 완료{email}:verified"true"10분회원가입까지 유예
초대 코드→조직inviteCode:{code}"1" (조직ID)30분빠른 조회
조직→초대 코드organization:{id}"ABC123"30분중복 방지

Redis를 쓰지 않았다면?

시나리오 1: MariaDB 저장

CREATE TABLE email_verifications (
    email VARCHAR(255) PRIMARY KEY,
    code VARCHAR(6) NOT NULL,
    created_at DATETIME NOT NULL,
    expires_at DATETIME NOT NULL
);

-- 주기적으로 만료된 데이터 삭제 필요
DELETE FROM email_verifications WHERE expires_at < NOW();

문제점:

  • TTL 관리 직접 구현 필요 (스케줄러 + DELETE 쿼리)
  • DB I/O 부하 증가 (인증 시마다 SELECT/DELETE)
  • 인덱스 관리 필요 (expires_at 인덱스)
  • 성능 저하 (디스크 기반)

시나리오 2: 애플리케이션 메모리 저장

// ConcurrentHashMap 사용
private final Map<String, VerificationCode> codeMap = new ConcurrentHashMap<>();

public void saveCode(String email, String code) {
    codeMap.put(email, new VerificationCode(code, LocalDateTime.now()));
}

문제점:

  • 서버 재시작 시 데이터 소멸
  • 서버 여러 대 사용 시 데이터 불일치
  • TTL 직접 구현 필요 (스케줄러)
  • 메모리 누수 위험

비교 표

방법성능TTL 자동화분산 환경구현 복잡도
Redis⭐⭐⭐⭐⭐✅ 자동✅ 지원⭐ 간단
MariaDB⭐⭐❌ 수동✅ 지원⭐⭐⭐ 복잡
메모리⭐⭐⭐⭐❌ 수동❌ 불가능⭐⭐ 보통

그럼 Redis는 만능인가?

아니다. Redis도 단점이 있다.

Redis의 한계

1. 메모리 제약

  • 모든 데이터를 메모리에 저장 → 비용 문제
  • 대용량 데이터 저장에는 부적합

2. 데이터 영속성

  • 기본적으로 휘발성 (서버 다운 시 데이터 소멸)
  • RDB/AOF로 백업 가능하지만 완벽하지 않음

3. 복잡한 쿼리 불가

  • Key-Value만 지원
  • JOIN, GROUP BY 같은 복잡한 쿼리 불가

그래서 Redis는 언제 쓰나?

✅ 지향해야 하는 경우

 캐싱 - 자주 조회되는 데이터

 세션 관리 - 로그인 세션

 임시 데이터 - 인증 코드, 초대 코드 ← 우리 프로젝트

 실시간 데이터 - 좋아요 카운트, 조회수

 분산 락 - 중복 요청 방지

❌ 지양해야 하는 경우

 영구 저장 데이터 - 회원 정보, 게시글 (DB 사용)

 복잡한 관계형 데이터 - 다중 JOIN 필요 (DB 사용)

 대용량 파일 - 이미지, 동영상 (S3 사용)


📌 최종 결론

KLÜME 프로젝트에서 Redis는 단순 캐시가 아니라

TTL 기반 임시 데이터 관리 + 빠른 성능 + 분산 환경 지원

을 동시에 해결해주는 핵심 기술이었다.

특히

  • 이메일 인증 코드 (3분 → 10분 2단계 TTL)
  • 조직 초대 코드 (30분 TTL + 양방향 매핑)

같은 기능은 Redis의 자동 만료 + 싱글스레드 구조 + Key-Value 단순성의 조합으로

안전하고 효율적으로 처리할 수 있었다.


💡 추가 개선 사항

현재 프로젝트에서는 기본 명령만 사용했지만, 추후 다음 기능을 추가한다면

1. Rate Limiting (요청 제한)

// INCR 명령으로 요청 횟수 카운팅
public boolean checkRateLimit(String userId) {
    String key = "rate_limit:" + userId;
    Long count = redisTemplate.opsForValue().increment(key);

    if (count == 1) {
        redisTemplate.expire(key, 1, TimeUnit.MINUTES);
    }

    return count <= 100; // 분당 100회 제한
}

2. 분산 락 (중복 예약 방지)

// SETNX 명령으로 동시성 제어
public boolean acquireLock(String reservationKey) {
    return redisTemplate.opsForValue()
        .setIfAbsent(reservationKey, "locked", Duration.ofSeconds(10));
}

이런 식으로 Redis의 원자적 명령어를 활용하면 더 강력한 기능을 구현할 수 있을것 같다.


마치며

사실 프로젝트를 개발할 때는 이정도로 생각해서 개발하진 않았고, 그냥 해당 기능을
개발하기 위해선 Redis를 사용해야 한다 정도로만 알고 진행했었다.
그런데, 이 글을 작성하면서 추가 조사하고 정리하면서
Redis에 대해 좀 더 자세히 알게 되었고, 다음에 개발할 때는 좀 더 디테일하게
사용해보고 싶어졌다. 😎

profile
라이언을 좋아하는 초보 개발자의 보금자리

0개의 댓글