본문 바로가기

프로젝트/나만의명언집

[나만의 명언집] 이메일 본인인증 기능 구현(With Redis 클라우드 & NextJS 서버리스) -> 핵심만

반응형

AWS SES 셋팅 포스트

 

NextJS 를 AWS EC2 에 배포하는 경우 비밀번호 찾기 기능이 안 되었던 이유와 해결 방법( with AWS SES +

SMTP 의 기본 포트는 25 이다. 내가 만든 프로젝트에서 비밀번호 찾기 기능을 구현하고, EC2 에 배포했을 때 비밀번호 찾기 기능이 동작하지 않았다. 그 이유로 짐작되었던 것을 오늘 확인하였다.

duklook.tistory.com

 

오늘의 명언

 

 

이번에 구현해볼 기능은 이메일 본인인증 기능이다. 참고로 언어는 NextJS(^14.04) 를 사용하므로 별도의 백엔드 언어를 따로 두지 않는다. 즉, 서버리스이다.

 

해당 기능은 사실 한참 이전에 구현했어야 했는데, 이메일 인증을 실제 프로덕션에서 적용하려니 생각보다 많은 에러 상황에 직면했고, 오늘 드디어 이를 구현할 기회를 얻었다.

 

이번에 이메일 인증번호의 경우에는 보안성과 확장성을 위해서 처음으로 redis 를 적용해보기로 하였다. redis 는 인메모리 데이터베이스로 임시 메모리 공간에 데이터를 키 값 형태로 저장하여 관리하는 친구이다. 이 친구의 경우 TTL( 타임 투 리브는 컴퓨터나 네트워크에서 데이터의 유효 기간을 나타내기 위한 방법 ) 적용이 가능해서 일정 시간 이후에는 메모리에서 해당 데이터가 제거되도록 로직을 구성할 수 있다. 이를 이용하면 보안상 탈취문제도 최대한 예방할 수 있다.

 

이메일 본인인증을 하는 이유

이메일 본인인증은 보통 이메일 로그인 시 해당 사용자가 실제 해당 이메일의 주인인지 확인하기 위해 실시한다. 

 

만일 해당 이메일의 주인도 아니고, 이메일 형식만 갖춘 가짜 이메일인 경우 해당 사용자가 사이트에서 악의적인 행위를 취하는 경우 제재하기가 참으로 난감하다. 해당 이메일을 차단한다고 해도, 다른 가짜 이메일로 가입 후 같은 행동을 반복하게 되면, 아이피를 차단하면 되는데, 아이피의 경우도 바꿀 수 있기 때문에 완전 차단이 어렵다.

 

그러면 해당 이메일의 도메인이 실제 존재하는 경우라면 앞서 가짜 이메일이 판을 치는 상황 보다는 수월하게 조치가 가능하다. 해당 도메인의 유저를 차단해두면, 그 유저의 입장에서는 악의적으로 행동하려고 해도, 가입 가능한 도메인이 한정되어 있으니 말이다.

 

그 외에도 관리자의 입장에서 편리함을 갖추고, 유저의 입장에서는 악의적인 유저로 부터 받을 수 있는 피해를 최대한 방지할 수 있으니 보안적인 부분에서 윈윈 하는 전략이 아닐까 싶다.

 

우선 Redis 셋팅 부터

이메일 본인인증 시 인증번호를 별도의 장소에 보관해 두어야 한다. 다양한 정보를 찾아보니 인증번호의 경우에는 보안상 문제 및 향후 확장성을 염두해 둘 때 쿠키를 이용하는 것은 좋지 못한 방식이라고 한다.

 

또한, 세션 형태로 관리하는 법도 있다고 하는데, 이 경우 일반적으로 사용하고 있는 데이터베이스에 저장하거나 아니면 인메모리 기반의 Redis 와 같은 데이터베이스에 저장하여 활용하는 법이 있다고 한다.

 

제일 편한 방법은 데이터베이스에 저장하는 것이겠지만, 향후 확장성을 고려한다면 Redis 를 이용해 인증번호를 관리하는게 좋다고 판단했고, 이를 이용하기로 결정했다.

 

그리고 Redis 를 구성하는 방법은 직접 로컬에 패키지를 설치해서 사용하는 방법, 두 번째는 Redis 클라우드를 사용해서 관리하는 방법이 있다. 향후 어떻게 될지 모르기 때문에 범용성과 안정성이 뛰어난 Redis 클라우드를 사용하여 연결하기로 결정하였다.

 

Redis 클라우드 등록 및 필요 정보 확인

Redis 클라우드 등록에 대한 부분은 이 블로그 (https://inpa.tistory.com/entry/REDIS-%F0%9F%93%9A-Redis%EB%A5%BC-%ED%81%B4%EB%9D%BC%EC%9A%B0%EB%93%9C%EB%A1%9C-%EC%82%AC%EC%9A%A9%ED%95%98%EC%9E%90-Redislabs) 를 참고 했다. 자세하게 나와 있으므로 별도 설명없이 넘아갈 것이다.

 

참고로 등록하고 나서 바로 데이터베이스가 생기는데 이는 무료로 제공된다. 한도는 다음과 같이 나와 있다.

 

 

Redis CLI 설치

Redis 클라우드와 연동하기 위해서는 로컬에서 Redis-cli 를 설치해야 한다. 윈도우에서는 아래와 같이 설치하면 되는데,

> npm install -g redis-cli

 

현재 AWS EC2 에서 아마존 리눅스 환경에서도 사용하므로 이에 대한 처리는 배포 후 다시 설정해주어야 한다. 다만, 로컬에서 직접 클라우드로 접근할 생각이라면 굳이 설치할 필요는 없다. 본인이 본 튜토리얼 게시글에서 사용해보길래 체험상 설치 후 적용해보았다. 아래 CLI 로 접속하기 부분도 마찬가지이다. 로컬 설정으로 바로 가려면 [Redis 로컬 설정] 파트로 바로 넘어가길 바란다.

 

Redis CLI 로 접속하기

아래와 같은 형식으로 입력하면 레디스 커맨드에 접속할 수 있다.

rdcli -h [endpoint] -p [port] -a [password]

 

password 의 경우  Security 에서 확인할 수 있으며

 

 

엔드포인트와 포트의 경우 General 부분의 하단에서 확인할 수 있다. 참고로 : 클론을 기준으로 좌측이 호스트, 우측이 포트이다.

 

리눅스에서(참고용)

$ apt-get install redis-tools # redis cli 설치

$ redis-cli -v # 설치 버젼 확인

$ redis-cli -h <endpoint> -p <port> -a <password> # redis cloud 접속

 

Redis 로컬 설정

환경변수에 HOST, USER, PORT 정보 저장 및 redis 설치하기

이들 정보는 중요하므로  .env 환경변수에 저장하여 AWS 환경에서도 사용할 수 있도록 저장해둔다.

 

그 다음 redis 를 npm 등을 이용하여 설치한다. 참고로 redis 는 현재 4버전대이다. 3버전대 와의 차이점은 콜백에서 프로미스 기반으로 변경되었고, 이에 따른 일부 명령어의 사용이 변경되었다는 점이다.

npm install redis

 

Redis 클라우드와  NextJS 서버 연결 및 클라이언트 내보내기

Redis 연결을 위한 파일을 별도로 만들고 아래 로직을 작성한다. 그 후 레디스를 사용하기 위해 client 인스턴스를 내보낸다.

// 레디스
export const client = createClient({
  url: `redis://${process.env.REDIS_USER}:${process.env.REDIS_PW}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}/0`
});

client.on('connect', success=> console.log("레디스 연결 성공", success))
client.on('error', err => console.error('레디스 연결 중 문제 발생:',err))

await client.connect();

 

여기 까지만 해두면 redis 사용 환경설정은 끝이다.

 

이메일 본인인증 로직 개요

로직을 전체적으로 살펴보면 다음 8 가지로 나뉘는 것 같다.

 

1.  사용자가 이메일 도메인 입력하고 [확인] 버튼을 클릭한다.

2. 해당 이메일이 실제 존재하는 도메인인지 서버에서 1차적으로 검증하고,  검증이 실패하면 존재하지 않는 도메인이라고 유저에게 알린다.

3. 만일 존재하는 도메인이라면 1차 검증을 성공시키고, 2차 검증으로 데이터베이스에 해당 이메일이 있는지 확인한다(중복체크)

4. 2차 검증이 성공했다면, 해당 이메일의 도메인으로 인증번호를 발송한다.

5. 사용자는 자신의 이메일 도메인에서 발송받은 인증번호를 확인한다.

6. 회원가입 창에 나타난 인증번호 입력 값에 사용자는 자신의 인증번호를 입력한 후 확인 버튼을 클릭한다.

7. 인증번호가 일치하면 "인증되었습니다." 메시지를 토이스트로 보여주고, 실패하였다면 "인증번호가 일치하지 않습니다" 메시지를 띄운다.

 

이메일 유효성 검증(MX 레코드 활용)

그럼 지금 부터 앞서 정리한 알고리즘을 실제 코드로 구현해볼 것이다. 다만, 모든 로직을 공유하지는 않는다. 흐름만 알아도 누구나 구현할 수 있기 때문에 주요 로직과 흐름만 살펴보고 직접 구현해보기를 바란다.

사용자가 전송한 이메일 도메인이 유효한 이메일 형식인지 검증하기

우선 프론트에서는 이메일 정규식을 통해서 엄격하게 1차 형식 체크를 한다. 그후 클라이언트에서 인증 요청을 보내면, 백엔드 환경에서도 2차적으로  유효성 검증을 한 번 더 실시한다. 이 때는 최소한의 이메일 형식을 갖추고 있는지만 체크 한다. MX 레코드 검증에서 어차피 유효하지 않은 경우 에러를 띄우기 때문이다. 이는 불필요한 MX 레코드 검증 요청을 피하기 위한 최소 조치이다.

 

Joi 를 사용한 1차 유효성 검사

나의 경우에는 객체 유효성 검사 라이브러리로 npm 에 등록된  Joi 를 사용했으며, 사용 예시는 해당 공식 문서(https://joi.dev/api/?v=17.12.3) 에서 자세하게 알아볼 수 있다.

 

아래 로직에  tlds 를 false 로 지정했는데, 이렇게 되면 뒤에 .com, .net, .al 등등 어떤 도메인이 들어와도 통과시킨다. 다만, 최소 길이(minDomainSegments) 를 2로 지정해서 그 밑으로 작성한 경우는 실패처리한다. 

export const emailShema = Joi.object({
  email: Joi.string().email({ minDomainSegments: 2, tlds: { allow: false } }),
})

 

MxDomin 을 활용한 2차 유효성 검사

1차적으로 간단한 유효성으로 거르고, 2차적으로 해당 이메일의 도메인이 실제로 존재하는 도메인인지 검사한다. 이 때 사용하는 방식은 존재하는 도메인이라면 MX 레코드가 존재할텐데, 이것의 존재유무를 바탕으로 검증이 이루어진다.

 

NodeJS 에서는 다양한 빌드인 객체나 함수들을 지원한다. 그 중에서 dnsPropmises 도 있는데, 해당 객체는 내부적으로 DNS 처리와 관련한 다양한 내장 메서드를 가지고 있다. 이 중에서도 resolveMx 라는 메서드를 사용하면 비동기적으로 인자로 전달받은 도메인에 대한 MX 레코드를 반환하는데, 이를 mx 변수에 할당한다. 만약에 mx 변수에 아무런 값이 담기지 않는다면, 해당 도메인은 실제 도메인이 아니므로 에러를 뛰우고 이를 본인의 경우 false 로 처리해서 반환하게 로직을 작성했다.

// MX레코드 검증
export async function emailMxValidator(userEmail: string) {
  const domain = userEmail.split('@')[1] || ''
  if (!domain.includes('.')) return false
  try {
    const mx = await dnsPropmises.resolveMx(domain)
    console.log(mx)
    return true
  } catch (error) {
    console.error('도메인 조회 실패:', error)
    return false
  }
}

 

 

위에서 error 가 발생할 때, 로그를 출력해보면 test.com 이라는 도메인의 Mx 레코드가 존재하지 않는다고 뜨고 있으며, 존재하는 경우에는 mx 레코드가 반환된 mx 변수의 로그를 출력해보면 해당 이메일의 도메인과 연결된 Mx 레코드가 반환되는 것을 볼 수 있다.

 

 

유효한 도메인이 아니라면 거절 메시지를 반환 아니면 다음 절차로

그 후 사용자의 도메인이 유효한 도메인이라면 다음 절차로 넘어가고, 그렇지 않은 경우에는 유효한 이메일 도메인이 아님을 Response 객체에 담아서 클라이언트로 응답한다.

 

나의 경우에는 다음과 같이 JSON 객체에 메시지를 담아서 보내주었다. 

 // 도메인 유효성 검사(MX 레코드 )
   const isValid = emailMxValidator(validEmail)
   if(!isValid) return NextResponse.json({...HTTP_CODE.BAD_REQUEST, meg:"존재하지 않는 도메인입니다. 유효한 도메인 형식으로 다시 요청해주세요."})

 

해당 로직의 분기처리를 통해 test.com 이라는 유효한 도메인이 아니라면 아래와 같이 meg 를 보여준다.

 

 

 

반대로 유효한 도메인 형식을 입력하는 경우에는 존재하는 도메인 이므로 다음 절차로 넘어가라는 메시지를 띄운다. 

 

 

 

여기 까지가 이메일 본인인증 및 redis를 적용하지 않았던 과거 로직의 끝이다. 

 

이메일 본인인증 로직 구현

이번 챕터부터는 이메일 본인인증 구현을 위해 AWS SES 를 연동하여 사용한다. 해당 로직의 구현 부분은 해당 링크( https://duklook.tistory.com/481 ) 에서 구구절절하게 설명하며 정리하였기에 바로 구현되어 있는 로직을 사용하여 이어가도록 할 것이다.

 

다시 상기하고 가는 본인인증 로직

우선 로직 작성 이전에 다시 로직을 상기하고 갈 것이다.

 

1. 유효성 검증을 통과한 사용자에게 이메일 본인인증을 요구하는 인풋 창이 활성화하고, 인증번호가 유저에게 발송된다.

2. 사용자는 해당 이메일 도메인으로 찾아가서 인증번호를 확인하고, 인풋창에 입력 후 서버에 응답을 보낸다.

3. 서버에서는 해당 인증번호의 일치유무를 판단하고, 일치하면 인증했다는 메시지를, 실패하면 실패했다는 메시지를 응답한다.

 

이메일 유효성 검사 통과 시 redis 에 인증번호 저장하기

앞서 서버에서 이메일의 MX 레코드를 검증해서 해당 이메일이 실제 도메인인지를 검증했었다.  여기서 끝나면 안 되고, 그 다음에는 인증번호를 랜덤으로 생성하고, 이를 redis에 TTL 을 지정한 상태로 저장한다. 이 때 redis 에는 키-값 형태로 인증번호가 저장되는데, 유저의 식별을 위해서 이메일 주소를 키로 지정하고 그 값으로 인증번호를 넣을 것이다.

 


 

 

앞서 이메일 중복확인을 시도한 api 경로로 들어가서 Mx 레코드 검사 및 이메일 중복 검사 로직 이후에 redis 관련 로직을 추가로 작성하였다. 여기서 getRandomAuthNum 이라는 함수를 구현하여 1000 부터 9999 까지 랜덤한 숫자를 반환하는 함수를 구현하였고, 이를 인증번호로 사용한다. 그 후 회원가입을 요청한 유저의 이메일 주소를 키로, 랜덤 인증번호를 값으로 설정하여  redis 에 저장하는데, 이 때 저장을 수행하는 메서드가 client.set() 이라는 함수이다.

 

   // 랜덤 인증 번호 생성(1000 ~9999 사이)
    function getRandomAuthNum() {
      const authNum = Math.floor((Math.random() * 9000)) + 1000;
      return authNum
    }
    const emailAuthNum = getRandomAuthNum()
    const userInfo = { authNum: emailAuthNum, email: email }

    // 이메일 주소로 인증번호 발송
    sendMailWithAwsSes(email, "가입자", String(emailAuthNum), 'signin' )
    
    // redis 연결 및 데이터 저장
    const client = await redisClient()
    client.set(userInfo.email, userInfo.authNum)

 

여기서 중요한 점은 캐싱 만료 기간을 설정해두는 것이다. 메모리의 공간은 SSD 에 비해 크기가 작기 때문에, 사용하지 않는 데이터가 오래 저장되어 있는 것은 비효율적이기 때문이다.

 

redis 에서는 만료기간을 설정하는 메서드로  .expire(키, 값)  를 제공하는데 다음과 같이 사용하면된다. 나의 경우에는 7분 뒤에 인증번호가 소멸하도록 지정하였다. 즉, 사용하는 7분 뒤에 인증을 다시 해야 한다(초 단위).

await client.expire(userInfo.email, 420); // 7분 뒤 email 키 제거

 

 

저장한 인증번호 다시 불러오기(인증번호 일치 유무 확인)

앞서 redis 에 저장된 데이터는 메모리에 살아 있다. 즉, 사용자가 서버에서 이메일 도메인서버로 전송한 인증번호를 확인하고, 해당 인증번호를 특정 api 서버 경로로 전달하더라도 우리는 redis 에 저장된 인증번호를 꺼내와서 사용자가 요청한 인증번호와 비교하는 작업을 수행할 수 있다.

 

아래는 서버 코드의 일부인데, redis 클라이언트 인스턴스가 가지고 있는 .get(키) 메소드를 사용하여 유저의 email 을 키로 저장된 값을 읽어오면 redisAuthValue 이라는 변수에는 앞서 랜덤으로 생성한 인증번호가 그대로 들어 있다.

 

여기서 authValue 은 사용자가 클라이언트단에서 input 에 입력 후 전송한 값이 들어 있는데, 만일 1234 를 입력 후 fetch 요청을 통해 보내왔다면 1234가 담겨 있다. 이 값과 redis 에 저장 되어 있는 값을 서로 비교해서 일치하면 인증이 되었다는 메시지를 일치하지 않으면 일치하지 않는다는 메시지를 보내준다.

       const redisAuthValue = await redisClient.get(email)
        
        if (authValue === redisAuthValue) {
            redisClient.del(email)
            return NextResponse.json({ ...HTTP_CODE.NO_CONTENT, meg: "인증 되었습니다." })
        } else {
            return NextResponse.json({ ...HTTP_CODE.BAD_REQUEST, meg: "인증번호가 일치하지 않습니다." })
        }

 

참고로  .del(email) 을 사용하면 redis 데이터베이스에서 해당 이메일을 키로 가지고 있는 키-값 쌍을 제거한다. 인증이 성공한 이후에는 굳이 인증만료 시간 까지 둘 필요가 없으므로 제거했다.

 

이후에는 어떻게 처리하였나?

이 다음 부터는 클라이언트 측에서 인증이 되었다는 응답을 받으면, 해당 인증  유무에 대한 상태를 담고 있는 변수를 업데이트 해준다. 예를 들어, isAuth 이라는 state 가 있다면, 기존에는 false 로 되어 있었겠지만 인증 만료 후에는 true 로 바꿔준다.

 

즉, 인증이 완료되었다면 해당 값이 true 이기 때문에 다음 단계로 넘어갈 수 있으며, 인증을 받지못하는 경우에는 해당 값이 false 이므로 회원가입 요청 자체를 하지 못하게 막아두었다.

 

아래 첨부 이미지를 보면 모든 조건을 일치시켰으나 확인을 하지 않았기에 버튼이 활성화 되지 않고 있다.

 

마무리

이번에 비밀번호 찾기 기능 구현을 위해 AWS SES 를 구축했었는데, 이를 회원가입 시 본인인증에도 활용하기 위해 오늘 시간을 마련했다. 인 메모리 데이터베이스라고 불리는 Redis 에 대해서 많이 들어는 봤지만, 프론트엔드와는 상관없지라는 생각으로 넘어 갔었는데, NextJS 를 사용하게 되면서 자연스레 필요성을 느끼고 찾게 되었다. 

 

Redis 를 사용하면서 느낀 거지만 사용법은 단순한데도, 해당 데이터베이스의 활용성은 가히 무궁무진하다는 생각이 들었다. 

 

이번 포스트에 기능 구현은 사실상 흐름만 가져가면 좋겠다는 생각이 든다. 모든 과정을 상세하게 적는 것은 아무래도 힘들긴하다. 별겨 아닌 기능이라 생각했지만, 그 내부적으로 보안 문제 부터, 서버의 확장성, 데이터베이스 성능 이슈 등등 고려할게 너무 많다. 그냥 기능 구현만 하고 끝낼 것이라면 그저 Postgres 를 이용하거나 보안 문제는 옛다 던져두는 쿠키를 사용했을지도 모른다. 이번 계기를 통해서 기능 구현 하나만 생각하고 만들면 안 된다는 사실을 많이 느꼈다(프론트엔드를 준비하는 건지, 백엔드를 준비하는 것인지 그 경계가 모호해진 느낌을 지울 순 없지만..)

 

 


 

참고자료

redis 공식문서( https://redis.js.org/#node-redis-usage-basic-example )

 

 

Node Redis

 

redis.js.org

 

인파 기술 블로그(https://inpa.tistory.com/entry/REDIS-NODE-%F0%9F%93%9A-%EB%85%B8%EB%93%9Cexpress%EC%97%90%EC%84%9C-redis-%EC%82%AC%EC%9A%A9%EB%B2%95-%EC%BA%90%EC%8B%B1-%EC%84%B8%EC%85%98-%EC%8A%A4%ED%86%A0%EC%96%B4)

 

[REDIS] 📚 Node.js 에서 redis 모듈 사용법 (캐싱 & 세션 스토어)

Node 프로젝트에서 pm2로 다중 클러스터 인프라를 구축했다면 세션 불일치 문제가 생기게 마련이다. 만일 서버가 종료되어 메모리가 날라가면 접속자들의 로그인이 모두 풀려버리게 된다. 따라서

inpa.tistory.com

 

Chat GPT(https://chat.openai.com/share/34885b1b-7875-428a-a2d5-d9e842692f25)

 

ChatGPT

A conversational AI system that listens, learns, and challenges

chat.openai.com

 

반응형