지난 KLÜME 프로젝트에서 나는 회원가입과 로그인 파트를 맡아서 개발했고,
Local 회원가입을 할 때에는 인증코드를 통해 해당 이메일의 인증을 거쳐야 했었다.
그리고 다른 팀원분이 개발한 기능에도 조직 초대 코드 생성 기능이 있었다.
이 기능들은 공통적으로
라는 특징을 갖고 있었다.
이 문제를 해결하기 위해 선택한 기술이 바로 Redis였다.
이메일 인증 과정은 사용자가 직접 입력하면서 대기하는 흐름이라
속도가 느려지면 사용자 경험이 망가지게 된다.
Redis는 메모리 기반이기 때문에 DB 대비 수십~수백 배 빠르다.
실제로 Redis는 초당 10만 건 이상의 읽기/쓰기를 처리할 수 있다.
이메일 인증코드는 보통 3~5분 정도만 유효하면 된다.
인증코드를 MariaDB에 저장하면
등의 추가 작업이 필요하다.
반면 Redis는 key에 TTL을 지정하기만 하면 끝이다.
// 3분 뒤 자동 삭제
redisService.setDataExpire(email, code, 3, TimeUnit.MINUTES);
지정한 시간(180초)이 지나면 자동 삭제되고, Cleanup 배치 작업도 필요 없다.
임시 인증 데이터는 복잡한 관계형 구조가 필요 없다.
이메일 → 인증코드
초대코드 → 조직ID
이런 단순한 매핑만 있으면 되는데, 이를 위해 테이블을 만들고 ORM을 설정하는 건 오버엔지니어링이다.
Redis의 Key-Value 구조가 딱 맞는 유스케이스였다.
만약 서버가 여러 대로 확장된다면?
애플리케이션 메모리에 저장하면 각 서버마다 다른 데이터를 가지게 된다.
하지만 Redis는 중앙 저장소 역할을 하기 때문에
여러 서버에서 접근해도 동일한 데이터를 공유할 수 있다.
[서버1] ──┐
[서버2] ──┼─→ [Redis] ← 모든 서버가 같은 데이터 참조
[서버3] ──┘
개발하면서 redis를 알아볼 때 나도 궁금했던 부분이었는데,
정답은 동시 요청에도 문제가 없고, 오히려 정상적으로 작동을 잘 했다.
즉, 내부적으로는 다음과 같은 흐름이다
[A 요청 도착] --> [명령 큐] --> A 처리 완료
[B 요청 도착] --> [명령 큐] --> B 처리 완료
두 요청이 동시에 도착하더라도 Redis는 한 번에 하나씩만 처리한다.
그래서 Race Condition 자체가 발생하지 않는다.
Race Condition이란?
누가 먼저 실행되느냐(=race)에 따라 결과가 달라져서 버그가 생기는 상황
MariaDB 같은 RDBMS는 멀티스레드로 동작하기 때문에
-- 두 사용자가 동시에 실행하면?
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1;
이런 경우 Lock이나 트랜잭션 격리 수준 설정이 필요하다.
하지만 Redis는 싱글스레드라 Lock이 필요 없다.
먼저 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으로 처리 (간단명료)
@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);
}
}
동작 과정:
POST /auth/mail/send123456)user@example.com → "123456" (3분 TTL)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단계로 나눴나?

팀원분이 개발한 조직 초대 기능도 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"
왜 양방향 매핑?
inviteCode:ABC123으로 빠르게 조직 ID 조회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 키 | 값 | TTL | 목적 |
|---|---|---|---|---|
| 이메일 인증 코드 | {email} | "123456" | 3분 | 인증 코드 저장 |
| 이메일 인증 완료 | {email}:verified | "true" | 10분 | 회원가입까지 유예 |
| 초대 코드→조직 | inviteCode:{code} | "1" (조직ID) | 30분 | 빠른 조회 |
| 조직→초대 코드 | organization:{id} | "ABC123" | 30분 | 중복 방지 |
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();
문제점:
expires_at 인덱스)// 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 자동화 | 분산 환경 | 구현 복잡도 |
|---|---|---|---|---|
| Redis | ⭐⭐⭐⭐⭐ | ✅ 자동 | ✅ 지원 | ⭐ 간단 |
| MariaDB | ⭐⭐ | ❌ 수동 | ✅ 지원 | ⭐⭐⭐ 복잡 |
| 메모리 | ⭐⭐⭐⭐ | ❌ 수동 | ❌ 불가능 | ⭐⭐ 보통 |
아니다. Redis도 단점이 있다.
캐싱 - 자주 조회되는 데이터
세션 관리 - 로그인 세션
임시 데이터 - 인증 코드, 초대 코드 ← 우리 프로젝트
실시간 데이터 - 좋아요 카운트, 조회수
분산 락 - 중복 요청 방지
영구 저장 데이터 - 회원 정보, 게시글 (DB 사용)
복잡한 관계형 데이터 - 다중 JOIN 필요 (DB 사용)
대용량 파일 - 이미지, 동영상 (S3 사용)
KLÜME 프로젝트에서 Redis는 단순 캐시가 아니라
TTL 기반 임시 데이터 관리 + 빠른 성능 + 분산 환경 지원
을 동시에 해결해주는 핵심 기술이었다.
특히
같은 기능은 Redis의 자동 만료 + 싱글스레드 구조 + Key-Value 단순성의 조합으로
안전하고 효율적으로 처리할 수 있었다.
현재 프로젝트에서는 기본 명령만 사용했지만, 추후 다음 기능을 추가한다면
// 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회 제한
}
// SETNX 명령으로 동시성 제어
public boolean acquireLock(String reservationKey) {
return redisTemplate.opsForValue()
.setIfAbsent(reservationKey, "locked", Duration.ofSeconds(10));
}
이런 식으로 Redis의 원자적 명령어를 활용하면 더 강력한 기능을 구현할 수 있을것 같다.
사실 프로젝트를 개발할 때는 이정도로 생각해서 개발하진 않았고, 그냥 해당 기능을
개발하기 위해선 Redis를 사용해야 한다 정도로만 알고 진행했었다.
그런데, 이 글을 작성하면서 추가 조사하고 정리하면서
Redis에 대해 좀 더 자세히 알게 되었고, 다음에 개발할 때는 좀 더 디테일하게
사용해보고 싶어졌다. 😎