구글 리캡챠를 사용하여 문의 메일을 보내는 사용자 중 봇을 감지하여 스팸문자를 방지하도록 할 것이다.
구글 리캡챠는 구글에서 제공하는 봇, 스팸 방지 API이다.
"나는 로봇이 아닙니다. " 혹은 "~이 있는 타일을 모두 선택하세요" 같은 그림 맞추기 라고 하면 아~ 하고 떠오르는 그게 구글 리캡챠이다.
이번에 회사에서 홈페이지 리뉴얼을 진행하면서 이따금씩 해외에서 보내지는 스팸메일들을 방지하기 위해 구글 리캡챠 기능을 사용하기로 했다. 굉장히 간단하게 생각했다가 며칠을 고생했다..😢
버전선택
구글 리캡챠는 2022년 2월 현재 v2, v3 가 있다. (v1은 예전에 서비스가 종료되었다.)
v2 는 checkbox 와 invisible 2가지 종류가 있는데
checkbox는 "로봇이 아닙니다." 체크 박스를, invisible은 표시되지 않는 reCaptcha badge를 제공한다.
v3는 v2와 다르게 이러한 체크박스를 보여주지 않고 사용자의 행동(action)을 감지하여 로봇인지 사람인지 스스로 점수를 매겨 판단한다.
나는 문의 메일을 보내고자 클릭을 했을 때 사용자 검증이 실행되는 대신, 평상시에는 보이지 않도록 하고자 v2의 invisible을 사용하기로 하였다.
Google reCaptcha v2 - invisible 구현
처음엔 역시 구글 리캡챠 공식 API 문서를 보고 사용하려고 하였으나 script 형태의 코드를 리액트에 맞게 사용하는게 익숙치 않아서 어려움을 겪다가 결국 react-google-recaptcha 라이브러리를 사용하여 구현 하였다.
Invisible reCaptcha 공식문서
import ReCAPTCHA from "react-google-recaptcha";
const recaptchaRef = React.createRef();
ReactDOM.render(
<form onSubmit={() => { recaptchaRef.current.execute(); }}>
<ReCAPTCHA
ref={recaptchaRef}
size="invisible"
sitekey="Your client site key"
onChange={onChange}
/>
</form>,
document.body
);
내가 구현한 Invisible reCaptcha
1. reCaptcha를 사용할 form 태그 하위에 ReCaptcha 컴포넌트 삽입
function Contact() {
...
return(
...
<form onSubmit={handleSubmit(onSubmit)} ref={formRef}>
<ReCAPTCHA
ref={recaptchaRef}
size='invisible'
sitekey={`${process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY}`}
onChange={onReCAPTCHAChange}
className={styles.grecaptchaBadge}
/>
...
)
}
sitekey 는 외부에 노출되면 좋지 않기 때문에 .env 파일에서 별도로 관리하고 process.env 로 호출해서 사용한다.
2. form 태그와 ReCaptcha 컴포넌트에서 사용할 함수 정의
function Contact() {
const recaptchaRef: any = useRef<ReCAPTCHA>(null);
const formRef = useRef<HTMLFormElement>(null);
const [captcha, setCaptcha] = useState(null);
...
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
const res = await axios.post(
`https://new-email.rainbow.co.kr/auth?token=${captcha}`
);
if (captcha !== null && res.data.status === 200) {
sendEmail(data);
recaptchaRef.current.reset();
reset();
}
};
const onReCAPTCHAChange = async (captchaCode: any) => {
if (!captchaCode) {
return;
}
setCaptcha(captchaCode);
};
...
return(
...
)
}
ReCaptcha 컴포넌트에서 captcha 코드를 받아와서 useState를 사용하여 선언한 captcha 를 업데이트하고
form을 submit 하면 해당 captha를 서버단에 함께 보내서 토큰을 검증하고 response 객체에 status 값이 300이면 메일 전송 코드를 실행하고, form 태그의 내용들을 리셋하는 로직으로 되어있다.
겪었던 문제들
위에 구현된 코드를 보면 생각보다 무척 간단하게 구현되어 있다. 나 또한 라이브러리를 사용하여 간단하게 구현할 수 있을 것이라 생각했는데, 테스트를 실행하고 실제 사용자 입장에서 사용해보니 정상적으로 동작하지 않는 부분들이 있어 꽤나 애를 먹었었다.
어떤 문제들을 겪었는지, 어떻게 해결했는지를 기록하고자 한다.
1. 기능 구현완료!! => 그런데 로봇을 구별하지 못한다.
첫번째 겪었던 문제로는 라이브러리 문서를 보고 기능을 구현했으나, 로봇을 구별하지 못하는 문제가 있었다.
구글 리캡챠 v2는 사용자가 로봇으로 의심될 때 그림을 맞추도록 하고, 이를 통해 사용자가 로봇인지 아닌지 구별하도록 한다.
하지만, 기능을 구현했음에도 거의 항상 검사를 실행하지 않고(로봇처럼 보이고자 수십번 새로고침을 하고 반복적으로 메일을 보내도 마찬가지..) 가끔씩 검사가 실행되었음에도 제대로 검사를 수행하지 않아도 메일이 정상적으로 전송되는 문제가 있었다.
로직을 제대로 이해하지 못해 이게 정상인 상태인지 비정상인 상태인지 알 수 없었고, 결국 동료들에게 문제를 공유하고 도움을 요청했다.
백엔드 동료의 도움으로 파이썬으로 자동으로 메일을 전송하는 코드를 만들어 반복적으로 프로그램을 실행해보았는데, 매번 정상적으로 메일이 전송되었다. 결국 겉으로는 구글 리캡챠가 구현되는 것 같아 보이지만 제대로 동작하지 않는 겉만 번지르르한 상태였던 것..(겉도 그닥 번지르르 하지는 않았...😱 )
테스트 코드가 얼마나 중요한지 깨닫게 된 경험.. ✅ Jest 를 공부해야할 이유..
1️⃣ 문제 : 리캡챠 검사가 랜덤하게 실행되어 정상 수행했을때, 그렇지 않을 때 결과값을 확인할 수 없다.
우선 문제를 해결하기 위해 리캡챠 검사결과에 따라 어떤 차이가 있는지 알아야 했다. 그래야만 정상적으로 검사를 수행했을 때만 메일이 전송 되도록 로직을 구현할 수 있었기 때문이다.
하지만 위에서 언급했던 것처럼 검사를 실행시키려면 수십번 페이지를 리프레쉬하고 난리를 쳐야 겨우 한 두번 검사가 실행되었다.
문제를 인식하기 위해서는 우선 리캡챠 검사를 수시로 실행하고 그 결과값을 확인해야만 했다.
1️⃣ -1️⃣ 해결방법 : reCaptcha Admin Console (어드민 콘솔) 보안 환경설정 수정
그동안 대부분의 경우 검사가 실행되지 않았던 것은 보안 환경설정이 사용성 높음으로 설정 되어있어 로봇인지 사람인지 구별하는 허들이 낮았기 때문이었다. 보안 환경설정을 높인 후, 검사를 실행하는 빈도가 급격히 높아졌다.
1️⃣ -2️⃣ 해결방법 : reCaptcha 검사 직접 실행하도록 하기
빈도가 높아지긴 했지만, 위 방법으로도 검사가 랜덤하게 실행되어 디버깅을 하기엔 다소 부족한 부분이 있었다. 이를 해결하기 위해 리캡챠 검사를 실행하는 코드를 직접적으로 onClick 이벤트의 콜백으로 실행되게 하였다.
<div className={styles.agreeWrap}>
<input
type='checkbox'
id='agree'
{...register('agree', {
required:
'개인정보수집 및 이용에 동의해주세요.',
})}
onClick={() => {
recaptchaRef.current.execute();
}}
/>
<a
href='#agreePopup'
data-popup='#agreePopup'
className={styles.layerPopup}
onClick={clickPersonal}
>
개인정보수집
</a>
및 이용에 동의합니다.
</div>
공식문서에서는 submit을 보냄과 동시에 구글 리캡챠가 실행되도록 되어있지만, 이번 프로젝트에서는 submit 버튼을 클릭하면 기존에 입력되어있던 form들이 초기화되도록 로직이 구현되어 있기 때문에 사용자가 로봇으로 의심될 경우 검사를 실행하고 다시 form을 입력해야하는 불편함이 있을 것 같았다. 따라서 form 을 submit 되기 전에 검사결과를 실행하고 그 결과에 따라 메일을 전송할 수 있도록 로직을 구현했다.
개인정보수집 및 이용에 동의해야만 메일이 전송되도록 되어있었으므로 개인정보수집 버튼을 클릭할때마다 리캡챠를 실행하고 검사결과가 존재해야만 메일이 전송되고 form의 input 태그 value들이 초기화되도록 하였다.
2️⃣ 문제 : 개인정보수집에 동의를 할때마다 검사를 실행하지만 검사결과와 무관하게 메일이 전송된다.
검사를 실행하면 onRecaptcha에서 captcha code를 받아오고 state 값이 captcha에 값을 할당한다. 그리고 captcha에 값이 있다면 메일이 전송되도록 하였다. 하지만 검사를 수행하지 않더라도, captcha code가 null 값이 출력되더라도 메일이 정상적으로 전송되었다.
여기에는 2가지 오류가 있었다.
- 구글 리캡챠의 토큰은 2분간 유효하다. ( But, 메일 전송과 같은 실행을 한번 하고 나면 더 이상 유효하지 않음)
- captchaCode가 인증된 토큰이 아니다. ( api로 부터 받아온 임의의 토큰으로, 서버 사이드에서의 인증과정이 필요했다.)
위 2가지 오해와 2가지 사실을 제대로 이해하지 못해 문제조차 인식하지 못하고 있었다.
그동안 인증된 토큰이라고 알고 있엇던 captcha code는 2번 과정에서 전달받은 임의의 토큰일 뿐이었다.
이것을 서버사이드에서 secret key와 함께 다시 api로 전달하여 검증과정을 거치고 그 결과값을 가지고 로직을 구현해야 했었는데, 이것을 간과했던 것이었다.
2️⃣ -1️⃣ 해결방법 : 서버사이드에 토큰을 전달하고 4,5 과정을 통해 결과값을 받아왔다.
구현된 코드에 나와있는 onSubmit 함수에서 해당 해결방법에 대한 로직이 구현되어있다.
...
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
const res = await axios.post(
`https://new-email.rainbow.co.kr/auth?token=${captcha}`
);
if (captcha !== null && res.data.status === 200) {
sendEmail(data);
recaptchaRef.current.reset();
reset();
}
};
...
서버사이드에 captcha 값을 보내서 검증하는 로직을 거친 후에 그 결과값을 res로 받아와서 res에 담긴 status 값이 200, 즉 토큰이 검증되었을때 메일이 전송되도록 하였다.
여기까지 수행하고 나니 파이썬으로 만든 프로그램으로 메일을 전송하면 전송이 되지 않았고, 검사결과를 정상적으로 수행하지 않아도 메일전송이 되지 않도록 리캡챠 기능을 정상적으로 사용할 수 있었다.
무엇을 잘 못 했는지?
시간에 쫓겨 기능구현에 급급하다 보니 api가 동작하는 원리에 대한 이해가 부족했고, 이로 인해 여러가지 힌트들이 있었음에도 제대로 활용하지 못했다.
코드가 제대로 동작하지 않았을 때, 구글 리캡챠 어드민 콘솔에서 사용자에 대한 인증이 제대로 실행되고 있지 않다는 메시지창이 있었다. 뿐만 아니라 공식문서에도 아래와 같이 서버사이드에서 인증 과정이 존재하고 그에 따라 api response가 전달됨을 확인할 수 있었다.
하지만 api 구조를 지레짐작하여 이러한 것들을 자세히 살피지 못했던 것이 원인이었다.
항상 뒤돌아서면 깨닫는 사실이지만 멀리 보고 생각할 줄 알아야하고, 기초에 대한 중요성을 알고 있으면서도 실천하지 못하는 실력과 상황으로 똑같은 실수를 반복하는 것 같아 무척이나 아쉽다. 결국 시간이 있을때 꾸준히 학습하고 고민해보면서 이러한 고민의 시간들이 자연스럽게 줄어들 수 있도록 항상 노력하자.
✅ 리캡챠를 구현할 때 반드시 기억할 것
- captcha code 는 인증된 토큰이 아니다. 반드시 서버사이드에서 검증하는 과정을 거쳐야 한다.
- 리캡챠 토큰은 2분간 유효하다. 하지만 토큰을 사용하여 어떤 동작을 하고 나면 2분이 지나지 않았더라도 유효하지 않다.
'React' 카테고리의 다른 글
React | Redux 사용하기 (feat. 리액트를 다루는 기술) (0) | 2022.03.21 |
---|---|
React | 리덕스 키워드 정리 (0) | 2022.03.18 |
React | 카카오 맵 API 사용하여 지도 띄우기 (0) | 2022.02.23 |
React | do not nest ternary expression (Nextjs , typescript, airbnb style guide) (0) | 2022.02.10 |
React | 동적 라우팅 (0) | 2022.01.26 |