Google reCAPTCHA v3 적용

이동영·2025년 11월 16일

웹개발

목록 보기
24/36

이번 프로젝트에서 "문의하기" 기능은 로그인을 하지 않더라도 누구나 접근이 가능하다. 하지만 이렇게 두면, 만약 악의적인 사용자가 스크립트를 만들어 1초에 1,000번 씩 '문의하기' API를 호출하면, 우리 DB는 스팸으로 가득 차고 서버가 다운이 될 수도 있다.
이 문제를 해결하기 위해, reCAPTCHA를 적용하기를 선택했다. 그런데 여기에서 두 가지 선택지가 있었다.

reCAPTCHA v2 vs v3

- reCAPTCHA v2

인터넷 웹서핑을 하다 보면, 많이들 접하게 될 "나는 로봇이 아닙니다" 체크박스. reCAPTCHA v2가 바로 그것이다. 이 체크박스를 사용자가 직접 누르게 해서 봇인지 아닌지 확인하는 것이다. 하지만 여기에는 단점이 있는데, 바로 다들 접했을 문제이다.

이 reCAPTCHA v2 방식의 단점은 UX(사용자 경험)를 해친다는 것이다. 우선은 체크박스를 선택해야하는 동작을 사용자에게 하도록 강제하며, 만약 이 단계에서 사람인지 확신할 수 없는 경우에 맞는 그림 찾기가 실행된다.

이 맞는 그림 찾기에서도 타일이 애매하게 걸쳐 있어서 그것도 선택해야 할지 말지 고민도 생기기도 하고, 또 틀리면 또 다른 문제를 풀어야하는 번거로움이 생긴다. 만약 마지막까지 틀리게 된다면 일정시간동안 서비스 이용이 불가능하게 된다.

- reCAPTCHA v3

반면에 reCAPTCHA v3는 다르다. v3는 점수 기반으로 한다. 사용자가 페이지에 머무르는 동안 마우스 움직임, 키보드 타이핑 속도 등을 분석하여 봇 점수를 매긴다. 점수가 높을수록 사람으로 확인하며, 낮으면 대부분 봇으로 식별한다. 물론 정확히 어떻게 작동하는지는 알려지지 않았지만, 가장 큰 장점은 백그라운드에서 조용히 작동한다는 것이다. 그래서 사용자는 reCAPTCHA가 동작하는지조차 모른다. 이렇게 해서 UX를 전혀 해치지 않는다.

reCAPTCHA v3의 작동 원리 (백엔드 중심)

  1. (프론트) 사용자가 '문의하기' 페이지에 접속하면, Google reCAPTCHA 스크립트가 로드된다.
  2. (프론트) 사용자가 "제출" 버튼을 누르면, reCAPTCHA 스크립트가 Google 서버로부터 일회용 recaptchaToken을 발급받는다.
  3. (프론트 -> 백엔드) 프론트엔드는 이 토큰을 InquiryCreateRequestDto에 담아서 내가 만든 API(POST /api/inquiry)로 보낸다.
  4. (백엔드 -> 구글) POST /api/inquiry API 속 InquiryService는 이 토큰과 비밀 키를 Google siteverify API로 전송한다.
  5. (구글 -> 백엔드) 구글이 응답(JSON)을 준다. { "success": true, "score": 0.9 }
  6. (백엔드) InquiryServicesuccesstrue이고, score가 일정 점수 이상(코드로 직접 조정할 수 있음)일 때만 "사람"으로 판정하고 inquiryRepository.save()를 실행한다.

구현

1단계 : reCAPTCHA 비밀 키 설정

Google reCAPTCHA Admin에서 비밀 키를 발급받아서 application-oauth.properties에 추가한다.

# ======== Google reCAPTCHA v3 설정 ========
recaptcha.secret-key=[여기에_발급받은_비밀_키를_넣습니다]

2단계 : DTO에 필드 추가

DTO에 프론트가 보낼 토큰을 받을 필드를 추가한다.

// InquiryCreateRequestDto.java
@Getter
public class InquiryCreateRequestDto {
    // ... (name, email, content) ...

    @NotBlank(message = "reCAPTCHA 토큰이 필요합니다.")
    private String recaptchaToken; // 토큰 필드 추가
}

3단계 : RecaptchaService (토큰 검증기) 생성

Google siteverify API를 호출하고 응답을 파싱하는 전용 서비스를 만든다.

  1. 먼저, 구글의 응답을 받을 DTO를 만든다.
// dto/RecaptchaResponseDto.java
@Getter
@JsonIgnoreProperties(ignoreUnknown = true) // 모르는 필드는 무시
public class RecaptchaResponseDto {
    private boolean success;
    private double score; // v3 점수
    // ... (hostname, error-codes 등) ...
}
  1. RestTemplate(또는 WebClient)을 사용하여 API를 호출하는 서비스를 만든다.
// service/RecaptchaService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class RecaptchaService {

    @Value("${recaptcha.secret-key}")
    private String recaptchaSecretKey;

    private static final String RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify";

    public boolean validateToken(String recaptchaToken) {
        if (recaptchaToken == null || recaptchaToken.isEmpty()) {
            return false;
        }
        try {
            RestTemplate restTemplate = new RestTemplate();
            MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
            params.add("secret", recaptchaSecretKey);
            params.add("response", recaptchaToken);

            RecaptchaResponseDto response = restTemplate.postForObject(RECAPTCHA_VERIFY_URL, params, RecaptchaResponseDto.class);

            if (response == null || !response.isSuccess()) {
                return false;
            }
            
            // 봇 점수가 0.5점 이상일 때만 '사람'으로 인정 (조절 가능)
            return response.getScore() >= 0.5;

        } catch (Exception e) {
            log.error("reCAPTCHA 검증 중 예외 발생: {}", e.getMessage());
            return false;
        }
    }
}

4단계 : Service 수정

reCAPTHCA를 적용시킬 Service가 DB에 저장하기 전에 RecaptchaService를 호출하도록 한다.

// service/InquiryService.java
@Service
@RequiredArgsConstructor
@Transactional
public class InquiryService {

    private final InquiryRepository inquiryRepository;
    private final RecaptchaService recaptchaService; // 주입

    public InquiryResponseDto createInquiry(InquiryCreateRequestDto requestDto) {
        
        // 선 검증
        boolean isValidRecaptcha = recaptchaService.validateToken(requestDto.getRecaptchaToken());
        if (!isValidRecaptcha) {
            // 봇(스팸)이면 403 에러 발생
            throw new AccessDeniedException("유효하지 않은 reCAPTCHA 토큰입니다. (스팸으로 의심됨)");
        }

        // 후 저장 (검증 통과 시에만 실행)
        Inquiry newInquiry = requestDto.toEntity();
        Inquiry savedInquiry = inquiryRepository.save(newInquiry);

        return new InquiryResponseDto(savedInquiry);
    }
    // ...
}

이렇게 하면, 백엔드에서의 reCAPTCHA 설정은 끝이 난다.
테스트해보기 위해서는 프론트엔드에서도 '사이트 키'를 추가하고 요청을 보내야하기 때문에, 이 상태에서는 테스트할 수 없다.
현재 백엔드 -> 프론트엔드 순으로 개발중이기 때문에, 나중에 프론트엔드에서까지 구현하면 추가적인 포스팅을 하겠다.

0개의 댓글