요즘IT
위시켓
콘텐츠프로덕트 밸리
요즘 작가들컬렉션물어봐
놀이터
콘텐츠
프로덕트 밸리
요즘 작가들
컬렉션
물어봐
놀이터
새로 나온
인기
개발
AI
IT서비스
기획
디자인
비즈니스
프로덕트
커리어
트렌드
스타트업
서비스 전체보기
위시켓요즘IT
고객 문의
02-6925-4867
10:00-18:00주말·공휴일 제외
yozm_help@wishket.com
요즘IT
요즘IT 소개작가 지원
기타 문의
콘텐츠 제안하기광고 상품 보기
요즘IT 슬랙봇크롬 확장 프로그램
이용약관
개인정보 처리방침
청소년보호정책
㈜위시켓
대표이사 : 박우범
서울특별시 강남구 테헤란로 211 3층 ㈜위시켓
사업자등록번호 : 209-81-57303
통신판매업신고 : 제2018-서울강남-02337 호
직업정보제공사업 신고번호 : J1200020180019
제호 : 요즘IT
발행인 : 박우범
편집인 : 노희선
청소년보호책임자 : 박우범
인터넷신문등록번호 : 서울,아54129
등록일 : 2022년 01월 23일
발행일 : 2021년 01월 10일
© 2013 Wishket Corp.
로그인
요즘IT 소개
콘텐츠 제안하기
광고 상품 보기
개발

이메일 인증은 어떻게 구현하는 걸까?

flamelet
10분
1시간 전
266
에디터가 직접 고른 실무 인사이트 매주 목요일에 만나요.
newsletter_profile0명 뉴스레터 구독 중

익숙하지만 어떻게 구현하는지 궁금한 기능: 이메일 인증

 

메일함을 열어보면 가끔 이런 버튼들이 와 있습니다. "이메일 인증하기", "비밀번호 재설정", "초대 수락하기" 같은 것들이죠. 뭔가 거창한 이름이 붙어있긴 한데, 막상 그냥 파란 버튼 하나가 덩그러니 놓여있을 뿐입니다. 생각해 보면 참 편리합니다. 예전엔 인증 번호 확인하고, 그걸 다시 어디에 입력하고, 여러 단계를 거쳐야 했는데 요즘은 버튼 하나만 누르면 알아서 처리됩니다. 클릭 한 번이면 끝이라 대부분은 별생각 없이 누르고 완료 화면을 보고, 바로 잊어버리게 됩니다.

 

그런데 보이는 건 버튼 하나지만, 그 안에는 꽤 많은 정보가 숨어있습니다. 마우스를 버튼 위에 올려보신 적 있으신가요? 브라우저 하단에 굉장히 긴 URL이 스쳐 지나가는 걸 보실 수 있을 겁니다. 그 긴 주소 안에는 인증에 필요한 여러 정보가 빼곡하게 담겨있죠. 그리고 사실 이 버튼들은 겉모습만 닮았을 뿐, 속사정은 제각각입니다.

 

<출처: 작가, GPT 생성>

 

언젠가 아는 개발자분한테 이런 질문을 받은 적이 있습니다. 채팅 서비스를 만들고 있는데, 사용자를 채팅방에 초대하는 링크를 만들어야 한다고 하셨죠. "그냥 랜덤 문자열 하나 만들어서 URL에 붙이면 되는 거 아닌가요?"하고 물었는데, 저도 예전에 비슷한 고민을 해본 적이 있어서 이런저런 이야기를 나누게 됐습니다.

 

한참 대화를 나누다 보니, 결국 가장 중요한 질문은 하나로 모이더라고요. "그 링크가 만약 유출됐을 때, 얼마나 큰일이 나는가?"생각해 보면 당연한 얘기입니다. 단순히 이메일 주소가 본인 것인지 확인하는 인증 링크가 유출되는 것과, 비밀번호를 재설정할 수 있는 링크가 유출되는 건 결과가 완전히 다릅니다. 하나는 "아, 좀 찝찝하네" 정도지만, 다른 하나는 계정 자체를 잃어버릴 수도 있는 문제니까요. 그렇다면 애초에 이 인증을 만들 때도 같은 방식으로 만들면 안 되겠죠.

 

이번 글에서는 그 "위험의 무게"에 따라, 메일 인증을 어떻게 다르게 설계하면 좋을지, 단계별로 정리해 보려고 합니다.

 

가장 단순한 인증: 숫자 여섯 자리를 직접 입력하기

<출처: 작가, GPT 생성>

 

사실 메일로 누군가를 인증하는 가장 쉬운 방법은, 그 사람만 받을 수 있는 코드를 입력하게 하는 겁니다. "123456" 같은 여섯 자리 숫자를 메일로 보내고, 사용자가 그걸 화면에 직접 입력하면 끝이에요. 보통 인증 화면에 3분, 5분 같은 타이머가 돌아가고 있는 걸 보신 적 있을 거예요. 그 시간 안에만 입력하면 됩니다.

 

구현하는 입장에서도 간단합니다. 랜덤으로 숫자 여섯 자리 만들어서 DB에 저장해두고, 사용자가 입력한 값이랑 같은지 비교하면 되니까요. 유효시간은 저장할 때 생성 시간을 같이 기록해 두고, 검증할 때 현재 시간이랑 비교해서 만료 여부만 체크하면 됩니다. 굳이 복잡하게 암호화할 필요도 없죠. 왜냐하면 이 코드가 유출된다고 해서 딱히 큰일이 나지 않거든요.

 

예를 들어, 커뮤니티 가입할 때 이메일 인증 같은 경우를 생각해 보면, 누군가 이 인증 번호를 가로챘다고 해도 딱히 할 수 있는 게 없습니다. 그 번호를 어디에 입력해야 하는지도 모르고, 설령 안다고 해도 이미 본인이 가입 중인 화면에서만 유효한 번호니까요. 게다가 유효시간도 짧아서, 조금만 시간이 지나면 그냥 만료되어 버립니다. 가로챈 사람 입장에선 그냥 의미 없는 숫자 여섯 자리일 뿐입니다.

 

이처럼 "인증이 털려도 별일 없는" 상황에서는, 복잡하게 만들 이유가 없습니다. 숫자 여섯 자리와 짧은 유효시간이면 충분합니다. 실제 구현도 정말 간단합니다. DB에서 랜덤 인증번호를 생성하고 저장하는 건 이 정도면 끝이죠.

 

-- 6자리 랜덤 인증번호 생성 
DECLARE @CharPool 
VARCHAR(10) = '0123456789' 
DECLARE @Code 
VARCHAR(6) = '' 
DECLARE @i INT = 0 
WHILE @i < 6 
BEGIN 
       SET @Code = @Code + SUBSTRING(@CharPool, CAST(RAND() * 10 + 1 AS INT), 1) 
       SET @i = @i + 1 
END
 -- 생성된 인증번호 저장 
INSERT INTO AuthCodes (UserId, Code, CreatedAt, IsUsed) VALUES (@UserId, @Code, GETDATE(), 0)

 

이게 전부입니다. 사용자가 입력한 값과 DB에 저장된 값을 비교하고, 생성 시간으로부터 유효시간이 지났는지만 확인하면 되죠. 맞으면 인증 완료, 틀리거나 만료됐으면 다시 요청하게 하면 됩니다.

 

 

한 단계 더: 링크 클릭만으로 인증하기

이번엔 친구들끼리 쓰는 가벼운 채팅방에 누군가를 초대하는 상황을 생각해 볼게요. 초대 링크를 만들어서 보내고, 상대방이 그 링크를 클릭하면 바로 채팅방에 들어올 수 있게 하려고 합니다. 이 링크에 들어가는 코드는 한 가지 중요한 조건이 있습니다. 절대로 다른 초대 코드와 겹치면 안 된다는 겁니다.

 

만약 내가 만든 초대 링크의 코드가 다른 채팅방의 초대 코드와 우연히 똑같다면 어떻게 될까요? 친구를 초대했는데 엉뚱한 채팅방에 들어가버리는 상황이 생길 수 있습니다. 그래서 이 코드는 반드시 "유일"해야 합니다.

 

유일한 코드를 만드는 방법은 여러 가지가 있습니다. 랜덤 문자열을 생성하고 DB에 이미 같은 값이 있는지 확인하는 방식도 있고요. 하지만 가장 많이 쓰이는 건 UUID입니다. 550e8400-e29b-41d4-a716-446655440000 이렇게 생긴 긴 문자열인데, 랜덤으로 생성해도 다른 값과 겹칠 확률이 거의 0에 가깝습니다. 굳이 DB에서 중복 체크를 하지 않아도 될 정도로 말이죠.

 

const inviteCode1 = crypto.randomUUID();
const inviteCode2 = crypto.randomUUID();
const inviteCode3 = crypto.randomUUID();

console.log(inviteCode1); // '550e8400-e29b-41d4-a716-446655440000'
console.log(inviteCode2); // '7c9e6679-7425-40de-944b-e07fc1f90ae7'
console.log(inviteCode3); // 'f47ac10b-58cc-4372-a567-0e02b2c3d479'

 

이렇게 호출할 때마다 매번 다른 값이 만들어집니다. 그래서 겹칠 걱정 없이 그냥 생성해서 쓰면 돼요. 그런데 이 초대 링크가 유출되면 어떻게 될까요? 원치 않는 사람이 채팅방에 들어올 수는 있겠죠. 좀 귀찮은 상황이긴 한데, 비밀번호가 털리는 것처럼 치명적이진 않습니다. 채팅방에서 내보내면 그만이니까요. 이처럼 "털리면 좀 곤란하긴 한데, 큰 피해는 아닌" 상황에서는 UUID 정도의 유일성이면 충분합니다. 복잡한 암호화까지는 필요 없습니다.

 

 

민감한 정보를 담아야 할 때: 해시로 감추기

이번엔 조금 다른 상황을 생각해 볼게요. 비밀번호 재설정처럼, 사용자의 개인정보를 기반으로 인증을 해야 하는 경우가 있습니다. 예를 들어, 이름, 핸드폰 번호, 이메일 이 세 가지 정보가 일치하는지 확인해서 본인 인증을 하는 상황이죠. 그런데 이걸 URL에 그대로 담으면 어떻게 될까요?

 

  • https://example.com/verify?name=홍길동&phone=01012345678&email=test@test.com

 

이런 링크가 메일로 나간다고 생각해 보세요. 누군가 이 링크를 훔쳐보기만 해도 개인정보가 그대로 노출됩니다. 당연히 이렇게 하면 안 되겠죠. 이럴 때 쓰는 게 바로 해시(Hash)입니다. 개인정보들을 합쳐서 해시로 변환하면, 원본을 알아볼 수 없는 문자열이 만들어집니다.

 

  • https://example.com/verify?token=a3f2b8c1d4e5f6a7b8c9d0e1f2a3b4c5

이렇게 되면 URL만 봐서는 어떤 정보가 담겨있는지 전혀 알 수 없습니다.

 

잠깐, 양방향 암호화와 단방향 암호화

암호화에는 크게 두 종류가 있습니다.

 

양방향 암호화는 암호화한 걸 다시 원래대로 되돌릴 수 있는 방식입니다. 비밀번호(키)만 있으면 원본을 복원할 수 있어요. 택배 상자에 자물쇠를 채우는 것과 비슷합니다. 열쇠가 있으면 다시 열 수 있죠.

 

  • 단방향 암호화(해시)는 한 번 변환하면 다시 되돌릴 수 없는 방식입니다. 고기를 갈아서 다진 고기로 만드는 것과 비슷한데요. 다진 고기를 다시 원래 고깃덩어리로 되돌릴 수는 없죠. 대신, 같은 고기를 같은 방식으로 갈면 항상 똑같은 결과가 나옵니다.

 

해시의 핵심은 이겁니다.

  • 같은 입력 → 항상 같은 출력
  • 출력 → 입력을 알아낼 수 없음

 

그래서 개인정보처럼 "원본을 숨기고 싶지만, 맞는지 비교는 해야 하는" 상황에 딱 맞습니다.

 

실제로 어떻게 쓰나요?

링크를 만들 때는 개인정보를 해시로 변환해서 토큰을 만듭니다.

 

import crypto from 'crypto';

const createVerificationToken = (name: string, phone: string, email: string): string => {
  const data = `${name}:${phone}:${email}`;
  return crypto.createHash('sha256').update(data).digest('hex');
};

const token = createVerificationToken('홍길동', '01012345678', 'test@test.com'

 

검증할 때는 서버에 저장된 사용자 정보를 똑같이 해시해서 비교합니다.

const verifyToken = (token: string, name: string, phone: string, email: string): boolean => {
  const expectedToken = createVerificationToken(name, phone, email);
  return token === expectedToken;
};

 

사용자가 링크를 클릭하면, URL에 있는 토큰과 서버에서 계산한 해시값이 일치하는지 확인하면 됩니다. 이러면 개인정보는 URL에 노출되지 않으면서도, 본인 확인이 가능해집니다. 이처럼 "URL에 민감한 정보를 담아야 하는데, 그대로 노출하면 안 되는" 상황에서 해시는 아주 유용한 도구입니다.

 

 

가장 강력한 인증: 비밀키로 서명하기

지금까지는 해시로 정보를 숨기는 방식을 봤습니다. 이번엔 한 단계 더 나아가서 비밀키를 이용해, "나만 만들고 나만 검증할 수 있는" 토큰을 만드는 방식을 살펴보겠습니다. 매직 링크 로그인이나 관리자 권한 인증처럼, 정말 중요한 기능에는 단순한 해시만으로는 부족합니다. 토큰 자체를 위조할 수 없도록 서명이 필요합니다.

 

대칭키 방식: 같은 키로 암호화하고 복호화하기

가장 많이 쓰이는 방식은 대칭키 방식입니다. 하나의 비밀키(secretKey)로 암호화하고, 같은 키로 복호화하는 겁니다. 원리는 간단합니다. 자물쇠와 열쇠를 생각해 보세요. 같은 열쇠로 잠그고, 같은 열쇠로 열 수 있죠. 대칭키 방식도 마찬가지입니다. 비밀키를 아는 사람만 토큰을 만들 수 있고, 같은 키를 아는 사람만 토큰을 열어볼 수 있죠.

 

import crypto from 'crypto';

const SECRET_KEY = 'your-secret-key-here';

// 암호화
function encrypt(text: string): string {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(SECRET_KEY), iv);
  const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
  return iv.toString('hex') + ':' + encrypted.toString('hex'}

}

 

실제로 매직 링크 인증에 적용하면 이런 흐름이 됩니다.

 

1. 토큰 생성 및 발송

// 사용자가 로그인 요청
const userId = 12345;
const expireAt = Date.now() + 10 * 60 * 1000; // 10분 후 만료

// 정보를 담아서 암호화
const payload = JSON.stringify({ userId, expireAt });
const token = encrypt(payload);

// 이메일로 발송
const magicLink = `https://example.com/auth?token=${encodeURIComponent(token)}`;
sendEmail(userEmail, magicLink);

 

2. 토큰 검증 및 로그인 처리

// 사용자가 링크 클릭
const token = req.query.token;

// 복호화해서 정보 꺼내기
const payload = JSON.parse(decrypt(token));

// 만료 체크
if (payload.expireAt < Date.now()) {
  return res.status(401).send('링크가 만료되었습니다.');
}

// 로그인 처리
loginUser(payload.userId);

 

비밀키를 모르면 암호화된 토큰을 만들 수도, 복호화할 수도 없습니다. 누군가 토큰을 임의로 조작하더라도 복호화 과정에서 실패하기 때문에, 위조된 토큰으로는 인증을 통과할 수 없죠.

 

공개키 방식도 있습니다 

대칭키 방식은 생성하는 곳과 검증하는 곳이 같은 키를 알아야 합니다. 대부분의 경우엔 이걸로 충분하지만, 여러 서비스에서 토큰을 검증해야 하는 상황이라면, 모든 곳에 비밀키를 배포해야 해서 위험할 수 있습니다. 이럴 때 쓰는 게 공개키/개인키 방식(비대칭키 방식)입니다. 개인키로 암호화하고, 공개키로 복호화하는 방식이죠. 검증하는 쪽에는 공개키만 주면 되니까, 개인키 유출 위험이 줄어듭니다. 다만 토큰 길이가 길어지고 연산도 더 무거워서, 일반적인 매직 링크에서는 대칭키 방식을 더 많이 씁니다.

 

여러 단계를 조합하면 더 안전해집니다

실제로 보안이 중요한 시스템에서는 하나의 기법만 쓰지 않고, 여러 단계를 조합합니다. 앞에서 배운 것들을 전부 섞는 것이죠.

 

<출처: 작가>

 

 각 단계가 하는 역할을 정리하면,

  • ID + 만료 시간: 토큰에 의미를 부여 (누구의 토큰인지, 언제까지 유효한지)
  • 랜덤값 추가: 같은 ID여도 매번 다른 토큰이 생성되도록
  • 해시 적용: 원본 정보를 숨김
  • 비밀키 서명: 위조 방지 (비밀키 없이는 유효한 토큰을 만들 수 없음)
  • Base62 인코딩: URL에 안전하게 쓸 수 있는 문자열로 변환

 

이렇게 여러 겹으로 보안을 쌓아 올리면, 하나가 뚫려도 다른 층에서 막아줄 수 있습니다. 보안에서는 이런 "다층 방어"가 중요합니다.

 

 

결론: 적절한 수준을 찾는 것이 핵심

지금까지 인증 번호 여섯 자리부터, UUID, 해시, 비밀키 서명까지, 메일 인증을 설계하는 여러 방식을 살펴봤습니다. 뒤로 갈수록 보안은 강력해지지만, 그만큼 구현도 복잡해지고 신경 써야 할 것도 많아집니다. 그런데 여기서 중요한 건, 무조건 강력한 게 좋은 건 아니라는 점입니다. 커뮤니티 가입 인증에 비밀키 서명까지 적용할 필요가 있을까요? 아마 과한 투자일 겁니다. 반대로, 비밀번호 재설정에 단순한 숫자 여섯 자리만 쓴다면? 그건 너무 위험하겠죠.

 

결국 보안 설계는 세 가지 사이에서 균형을 찾는 과정입니다.

 

  • 보안: 얼마나 안전해야 하는가?
  • UX: 사용자가 얼마나 편하게 쓸 수 있는가?
  • 개발 비용: 구현하고 유지보수하는 데 얼마나 드는가?

 

보안을 높이면 UX가 불편해지거나, 개발 비용이 올라갑니다. 반대로 너무 편하게 만들면 보안에 구멍이 생기고요. 어디서 타협할지를 정하는 게 설계의 핵심입니다. 그래서 처음에 던졌던 질문으로 돌아가면, "그 링크가 털리면 얼마나 큰일 나는데?"라는 질문이 출발점이 됩니다. 

 

그 대답에 따라 어느 수준의 보안이 적절한지가 달라집니다. 이 글이 여러분이 메일 인증을 설계할 때, "어느 정도까지 해야 하지?"라는 고민에 조금이나마 도움이 되길 바랍니다.

 

©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.

);
console
.log(token);
// 'a3f2b8c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8...'
);
// 복호화
function
decrypt
(
token:
string
):
string
{
const
[ivHex, encryptedHex] = token.split(
':'
);
const
iv = Buffer.from(ivHex,
'hex'
);
const
encrypted = Buffer.from(encryptedHex,
'hex'
);
const
decipher = crypto.createDecipheriv(
'aes-256-cbc'
, Buffer.from(SECRET_KEY), iv);
return
Buffer.concat([decipher.update(encrypted), decipher.final()]).toString();