공모전을 진행중 팀원으로부터 이메일 인증하기 버튼을 클릭할때 이메일이 3회 발송된다는 이야기를 듣게 되었다. 문제는 2회라면 React 18 이후 Strict Mode 에 의해 첫 렌더링 전 Effect, cleanup 을 실행후 Effect 가 실행되면서 useEffect 내에 API 를 사용했을 경우 이해가 되는데 왜 3회였을까?
더불어 이메일 입력란에 이메일을 입력하는 한글자 한글자마다 이메일 인증 API 가 요청된다는 문제도 있었다.
그럼 해결해볼까
해결한 지금 3회 발송되는 문제는 재현할 수 없었다. 상황을 좀 더 구체적으로 물어봤어야 했을까
나는 일단 코드를 보기 전 useEffect 를 사용했고 Strict Mode 때문에 2회는 발송이 되었으리라 생각했고 useEffect 에 대해 먼저 알아봤었다. 이는 코드를 봐도 아직 useEffect 를 잘 모르는 상태라 봐도 이해가 될 것 같지 않아서였다.
먼저, useEffect 는 외부 시스템과의 동기화를 목적으로 사용하는 것이며 유저의 버튼 클릭에 의한 명확한 이벤트 발생조건이 있을때 useEffect 는 사용하지 않는 것이 좋다.
그래서 이런 이메일 인증같은 이벤트의 경우 이벤트 핸들러를 통해 처리해야 한다는 사실을 알게 되었다.
그러고 코드를 보았는데 이미 코드는 이벤트 핸들러로 처리되도록 되어 있던 상황이었다.
???
이상했다. useEffect 문제도 아니고 버튼을 클릭하면 API 가 요청되는데 왜 3회가 전송된다고 했을까
잘 모르겠고 코드가 복잡할때 나는 모두 주석처리하고 앞에서부터 하나씩 코드의 동작을 확인해본다. 그러다 보니 인증 코드를 전송하는 부분에서 문제가 있었음을 알게 되었다.
구체적으로는 버튼 컴포넌트에 onClick 을 통해 클릭 이벤트를 핸들링 해줘야 하는데 버튼 컴포넌트에서 onClick 을 props 로 처리하고 있던 게 원인이었다.
이렇게 코드가 짜여있어서 버튼 컴포넌트에서
위와 같이 onClick 이벤트를 다루려고 했지만 이벤트 핸들러가 아닌 props 로 전달이 되어버린 것 같고
이메일 입력 버튼과 이메일 인증하기 버튼이 있는데 이메일 입력 버튼에서 이메일을 입력할때 입력한 값을 state 로 받고 한글자 입력할 때마다 setState 로 입력 이메일 값 상태를 업데이트 하다보니 매번 렌더링이 되고 있다. 이건 문제가 아니지만 매번 렌더링 될때마다 onClick 이 props 로 전달되는 탓에 전송하기 버튼을 클릭한 적이 없지만 API 가 계속 호출되고 있던 것이다.
바로 제거해줬다.
비동기 처리를 위해 async await 으로 api 를 호출해 줬으나 프론트 단에서 2회 연속 호출을 막고 싶었다.
그래서 인증 코드가 전송되었는지 여부를 확인하는
const [isCodeSent, setIsCodeSent] = useState(false);
를 추가해주고
인증 코드가 전송되지 않았다면 API 를 호출하도록 안전장치를 추가했다.
Rate Limiter ( API Throttling ) 이 필요하다.
이전에 NestJS
를 이용해서 BE 작업을 할때 Rate Limiting 기능을 활용한 적이 있다.
@Throttle(3, 60)
@Get()
findAll() {
return "List users works with custom rate limiting.";
}
이 기능은 brute-force attack 으로부터 애플리케이션을 방어하기 위한 기술이다. 예를 들어 이메일 인증, 문자 인증의 경우 실제 서비스에서 대부분 제공하고 있는 기능이지만 이메일 인증은 비교적 싼 가격이지만 문자 인증은 이메일보다 훨씬 비싸다. 그런데 해커집단이 프로그램을 돌려 문자인증만 IP 를 돌려가며 수없이 많이 요청해버리면 기업에서 수백만원이상 손해만 보게 될 수 있다.
무분별한 API 호출을 방지하는데 이 쓰로틀링 기술이 사용된다.
이번에 이메일 인증메일 중복 요청도 비슷한 상황이다.
유저 입장에선 버튼을 한번 클릭하려고 해도 순간적으로 두번 이상 클릭할 수도 있다.
그런데 이메일을 확인하는데 2회 이상 인증코드가 와있다면 사용자 경험 측면에서 좋지 않을 것이다.
// RateLimitConfig.java
@Configuration
public class RateLimitConfig {
@Bean
public Bucket createBucket(CacheManager cacheManager) {
Cache<String, Bucket> cache = cacheManager.getCache("rate-limit-cache");
return Bucket4j.builder()
.addLimit(Bandwidth.simple(1, Duration.ofSeconds(5))) // 1 request per 5 seconds
.withJpaSupport()
.buildCache(cache);
}
}
// EmailController.java
@RestController
public class EmailController {
private final Bucket rateLimiter;
public EmailController(Bucket rateLimiter) {
this.rateLimiter = rateLimiter;
}
@PostMapping("/email")
public String sendMail(@RequestBody EmailRequest request, HttpSession session) throws MessagingException, UnsupportedEncodingException {
ConsumptionProbe probe = rateLimiter.tryConsumeAndReturnRemaining(1);
if (!probe.isConsumed()) {
throw new RuntimeException("Rate limit exceeded. Please wait for " + Duration.ofNanos(probe.getNanosToWaitForRefill()));
}
String verificationEPw = mailService.sendMessage(request.getEmail());
session.setMaxInactiveInterval(3 * 60); // session expires in 3 minutes
session.setAttribute("verificationEPw", verificationEPw);
return verificationEPw;
}
}
BE 에선 현재 스프링부트를 사용중이라 BE 팀원에게 이 방법에 대해 상의해봐야겠다.