daniel@blog:~$ctx: infra-first
~/posts / pod-rolling-update-email-code

파드는 교체됐는데, 이메일 인증번호는 어디로 갔을까?

LAYERSserverinfradb
DATE · 10 min read
STANCEreplicas: 1은 ‘파드는 하나’라는 보장이 아니다. 상태를 메모리에 두는 순간 배포 방식이 버그가 된다.
#kubernetes #k3s #rolling-update #prisma #postgresql

TL;DR

  • 혼자 운영하는 MLB 데이터 서비스에서 회원가입 이메일 인증번호를 서버 메모리의 Map에 두었다. 일정에 쫓겨 가장 단순한 방법을 골랐다.
  • k3s로 옮긴 뒤 무중단 배포를 하고 싶어서 API 파드를 2개로 늘리려다가, 이 Map이 “파드는 하나”라는 전제 위에 있다는 걸 다시 봤다.
  • 그런데 그 전제는 파드를 2개로 늘릴 때 처음 깨지는 게 아니었다. replicas가 1이어도 롤링 업데이트 중에는 옛 파드와 새 파드가 겹친다. 이번에 가장 새로 와닿은 부분이다.
  • 인증 상태를 DB 테이블로 옮기고, 재발송 제한과 검증을 각각 조건부 쿼리 한 번으로 묶었다. 옮기는 것보다 두 파드에 동시에 들어오는 요청을 어떻게 막을지가 더 큰 고민이었다.
  • 이 글은 파드를 “늘릴 수 있게 된” 이야기다. 배포 중 실패율은 아직 재 보지 않았고, 언제 늘려야 하는지는 다음 글에서 다룬다.

처음엔 Map이었다

이 서비스는 지인 의뢰로 혼자 기획부터 운영까지 맡고 있는 MLB 데이터 사이트다. 이메일로 가입하면 6자리 인증번호를 보내고, 3분 안에 입력하면 통과다. 같은 이메일로는 1분에 한 번만 다시 보낼 수 있다.

처음 구현은 이랬다.

// 이메일 → 인증번호 상태
private emailVerificationCodes: Map<
  string,
  { code: string; expiresAt: Date; sentAt: Date }
> = new Map();

private generateVerificationCode(email: string): string {
  const code = Math.floor(100000 + Math.random() * 900000).toString();
  const sentAt = new Date();
  const expiresAt = new Date(sentAt.getTime() + 3 * 60 * 1000);
  this.emailVerificationCodes.set(email, { code, expiresAt, sentAt });
  return code;
}

왜 메모리였냐고 물으면 답은 짧다.

간단하게 메모리에 둔거고, 그때는 일정에 쫓겨서 가장 단순하게 짜는게 좋았었어.

메모리가 늘어날 수 있다는 걸 몰랐던 건 아니다.

처음 만들때도 걱정했는데, 사실 트래픽이 엄청 몰린다고해도 뭐 만명이상 동시에 가입할것도 아니고 이생각은 하긴했음. 현실적인 문제점은 아니다 라는 느낌.

만 명을 버틴다고 재 본 건 아니다. 당시 예상한 가입 규모로는 메모리 증가가 먼저 풀어야 할 위험이 아니라고 판단했을 뿐이다. 나중에 확인해 보니 성공한 인증과 만료된 항목은 지우고, 1분마다 만료분을 쓸어내는 타이머도 붙어 있었다. 무한히 쌓이는 구조는 아니었다.

돌아보면 이 판단에서 놓친 건 메모리 크기가 아니었다. “이 서버 프로세스는 하나다”라는 전제였다.

다시 보게 된 이유

서비스를 연 무렵, 대표가 유튜브 라이브에서 사이트를 소개했을 때 접속이 몰렸다. 가입자가 1,000명 정도인 서비스지만, 그 1,000명이 한꺼번에 들어오는 순간이 생길 수 있다는 뜻이었다. 그때 일부 요청이 실패했다. 인증 API는 아니었고, 원인도 끝내 확인하지 못했다.

사실 그때 app runner를 써서 제대로 대응을 못했어. 내가 신입때 무작정 맡은 하나의 덩어리였구.

올해 9월, 흩어져 있던 운영을 EC2 한 대 위의 k3s로 옮겼다. 노드가 한 대라 고가용성이라고 부르기엔 아쉽지만, 쿠버네티스로 옮긴 김에 챙길 수 있는 가용성은 챙기고 싶었다. 가장 먼저 떠오른 건 배포였다.

배포할때 무중단 배포를 하고싶어서 두개로 늘리면 좋겠다고 생각했거든.

App Runner를 쓸 때는 무중단 배포를 플랫폼이 해 줬다. k3s에서는 파드가 재시작되는 동안 요청이 실패할 거라고 생각했다. 솔직히 재 보지는 않았다. 당시엔 “확인 안 해도 뻔하지 않나” 싶었다.

그렇게 API 파드를 2개로 늘리려는 순간, Map이 걸렸다.

파드가 둘이면 생기는 일

파드가 둘이면 메모리도 둘이다. 인증 흐름은 요청 두 번으로 이어진다. 번호를 발급하는 요청과, 번호를 검증하는 요청이다.

상황 A: 파드 1에서 번호 발급 → 검증 요청은 파드 2로 → 파드 2의 Map에는 없음

요청이 어느 파드로 갈지는 정해져 있지 않으니, 발급과 검증이 다른 파드로 가면 맞는 번호를 넣어도 “인증번호가 유효하지 않음”이 된다. 1분 재발송 제한도 파드마다 따로 걸린다.

여기까지는 예상한 범위였다. 그다음이 예상 밖이었다.

평소에 하나여도, 배포 중에는 둘이다

Deployment의 기본 배포 방식인 RollingUpdate는 새 파드를 먼저 띄우고, 준비되면 옛 파드를 내린다. 기본값은 maxSurge: 25%, maxUnavailable: 25%다. replicas가 1이면 각각 올림 1개, 내림 0개로 계산된다. 하나를 더 띄울 수 있고, 하나도 먼저 내리지 않는다는 뜻이다.

그러니까 파드 수를 1로 두고 있어도, 배포하는 동안에는 이런 흐름이 생길 수 있다.

평소: 파드 1개
배포: 옛 파드 + 새 파드 (잠깐 공존)
완료: 새 파드가 준비되면 옛 파드 종료

이 순간 Map에는 두 가지 일이 생긴다.

상황 A: 옛 파드에서 번호 발급 → 새 파드에서 검증 → 새 Map에는 없음
상황 B: 옛 파드에서 번호 발급 → 옛 파드 종료 → 새 파드에서 검증 → 번호 자체가 사라짐

A는 두 파드가 동시에 요청을 받을 때 생긴다. B는 파드 두 개가 동시에 요청을 받지 않아도 생긴다. 번호 유효시간 3분이 남아 있어도, 프로세스가 끝나면 메모리와 함께 사라진다.

나는 “파드를 2개로 늘리면 Map이 문제가 된다”고 생각하고 있었다. 실제로는 파드를 늘리기 전부터, 배포할 때마다 이미 문제가 될 수 있는 구조였다. 이 서비스는 배포를 자주 하는 편이 아니고, 이 때문에 운영에서 인증이 실패했다는 제보나 기록도 없다. 그래도 전제가 어디서 깨지는지는 분명히 알게 됐다.

같은 사용자를 늘 같은 파드로 보내는(세션 고정) 방법도 생각해 볼 수 있다. 하지만 그건 A만 줄인다. 파드가 종료되면 B는 그대로 남는다.

같은 “로그인 관련 상태”라도 성격이 다르다는 것도 정리가 됐다. JWT는 공통 서명 키만 있으면 어느 파드든 검증할 수 있다. 반면 인증번호처럼 서버가 기억하고 있어야 하는 상태는 프로세스 밖에 있어야 한다.

DB로 옮기면서 고민한 것: 옮기는 것보다 동시 요청

옮길 곳은 이미 쓰고 있는 PostgreSQL로 했다. 테이블은 단순하다.

model EmailVerification {
  email     String   @id @db.VarChar
  code      String   @db.VarChar(6)
  sentAt    DateTime @db.Timestamptz(6)
  expiresAt DateTime @db.Timestamptz(6)

  @@index([expiresAt])
}

저장소를 바꾸는 것 자체는 어렵지 않았다. 고민은 다른 데 있었다. Map 시절에는 한 프로세스 안에서 순서대로 처리되니 “1분 안에 보낸 적 있나 확인하고, 없으면 저장”을 두 줄로 써도 괜찮았다. 파드가 둘이면 같은 이메일 요청 두 개가 두 파드에 동시에 들어올 수 있다. 둘 다 “보낸 적 없음”을 보고 둘 다 저장하면 재발송 제한이 뚫린다.

그래서 확인과 저장을 쿼리 한 번으로 묶었다.

// 1분이 지난 행만 갱신된다. 확인과 저장이 한 쿼리다.
const { count } = await this.prisma.emailVerification.updateMany({
  where: {
    email,
    sentAt: { lt: new Date(sentAt.getTime() - 60 * 1000) },
  },
  data: { code, sentAt, expiresAt },
});
if (count === 1) return code;

try {
  // 행이 아예 없으면 새로 만든다. 처음 동시 요청은 email 기본키가 막는다.
  await this.prisma.emailVerification.create({
    data: { email, code, sentAt, expiresAt },
  });
  return code;
} catch (e) {
  // 행이 있는데 update 조건에 안 걸렸다 = 1분 안에 보낸 적이 있다.
  if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
    throw new ApiException(
      AuthErrorCode.EMAIL_VERIFICATION_TOO_FREQUENT,
      '인증번호는 1분에 한 번만 요청할 수 있습니다.',
    );
  }
  throw e;
}
  • 1분이 지난 행이 있으면 갱신된다(count === 1).
  • 행이 없으면 만든다. 두 파드가 동시에 만들려고 하면 이메일 기본키 때문에 한쪽만 성공한다.
  • 행이 있는데 갱신이 안 됐다면 1분 안에 보낸 적이 있다는 뜻이다. 기본키 충돌(P2002)을 재발송 제한으로 돌려준다.

검증도 같은 생각으로 했다.

// 일치·미만료 확인과 삭제를 한 쿼리로 해서 같은 코드가 두 번 통과하지 못하게 한다.
const { count } = await this.prisma.emailVerification.deleteMany({
  where: { email, code, expiresAt: { gt: new Date() } },
});
return count === 1;

“맞는지 확인”과 “써 버리기”가 한 번에 일어나니, 같은 번호로 동시에 두 번 검증해도 한 번만 통과한다. 1분마다 만료분을 쓸어내던 타이머는 없앴다. 파드마다 타이머가 돌 이유가 없어서, 번호를 발급할 때 만료된 행을 지우는 쪽으로 바꿨다.

배포 순서도 신경 썼다. 이번 마이그레이션은 테이블을 추가하기만 한다. 새 테이블이 먼저 생겨도 옛 버전 코드는 그 테이블을 모르니 영향이 없다. 테스트에는 앱 인스턴스 두 개를 띄워, 한쪽에서 발급하고 다른 쪽에서 검증하는 경우와 동시 요청 경우를 추가했다.

이 변경은 AI와 이야기하면서 함께 만들었다. 커밋도 공동 작성이다. 구체적인 방식을 누가 먼저 꺼냈는지는 솔직히 정확히 기억나지 않는다. Redis 같은 별도 저장소를 따로 비교한 기록도 없다. 지금 돌아보면 운영할 대상을 하나 더 늘리지 않는다는 점이 이유가 될 수 있겠지만, 그건 당시 판단이 아니라 지금의 해석이다.

아직 재 보지 않은 것

이 글에서 확인한 건 “구조상 이렇게 깨질 수 있고, 그걸 막는 코드로 바꿨다”까지다. 아래는 아직 모른다.

  • 배포 중 요청이 실제로 얼마나 실패하는지 재 보지 않았다. 운영 매니페스트에는 API 파드 2개와 “최소 1개는 유지”(PodDisruptionBudget)를 적어 두었지만, 무중단이 됐다고 말하려면 배포하면서 실패율을 재야 한다.
  • 노드는 여전히 한 대다. 파드를 늘리는 건 파드 하나가 재시작되는 동안을 버티는 용도이지, 노드 장애에 대한 대비가 아니다.
  • 코드에서 더 따져 볼 곳도 있다. 인증에 성공하면 행을 지우는데, 그러면 재발송 제한 기록도 같이 사라진다. 메일 발송이 실패했을 때 저장해 둔 번호와 재발송 제한을 어떻게 할지, 틀린 번호를 계속 넣을 때 시도 횟수를 제한해야 할지도 아직 정하지 않았다. 결함이라고 확정한 건 아니지만, 다음에 손볼 목록에 올려 두었다.

다음 글: 이제 늘릴 수는 있다. 언제 늘려야 할까?

인증 상태를 밖으로 꺼냈으니 API 파드는 늘릴 수 있는 상태가 됐다. 다음 질문은 이거다.

측정한 부하에 따라 운영 파드수를 늘린다는 판단을 어떻게 함? cpu 사용률인지 아니면 단순 요청수인지 등

평소 2개, 최대 3개로 두고 트래픽이 늘면 늘리는 설정은 매니페스트로 줄 수 있다. 하지만 무엇을 보고 늘릴지는 설정이 정해 주지 않는다. 2편에서는 파드 2개와 3개를 같은 부하로 비교해 보고, 늘린 만큼 처리량이 실제로 느는지, 아니면 EC2 한 대나 DB가 먼저 막히는지를 확인해 볼 생각이다.

댓글

GitHub 계정으로 남길 수 있어요. 누르기 전에는 외부 스크립트를 불러오지 않아요.