Spring Boot와 Vue.js로 구현하는 안전한 캡차(CAPTCHA) 시스템

나근민·2025년 4월 2일
0

Spring Boot와 Vue.js로 구현하는 캡차(CAPTCHA) 시스템 완벽 가이드

안녕하세요! 오늘은 보안에 관심 있는 개발자분들을 위해 Spring Boot와 Vue.js를 활용한 캡차(CAPTCHA) 시스템 구현에 대해 상세히 알아보려고 합니다. 실무에서 바로 적용할 수 있는 코드와 함께 전체 흐름을 쉽게 이해할 수 있도록 설명해드릴게요! 🔒

목차

  1. 캡차(CAPTCHA)란 무엇인가?
  2. 왜 캡차가 필요할까?
  3. 전체 시스템 구조
  4. 백엔드 구현하기 (Spring Boot)
  5. 프론트엔드 구현하기 (Vue.js)
  6. 동작 흐름 살펴보기
  7. 보안 고려사항
  8. 심화: 캡차 이미지 처리와 새로고침 메커니즘
  9. 정리 및 마무리

1. 캡차(CAPTCHA)란 무엇인가?

캡차(CAPTCHA)는 "Completely Automated Public Turing test to tell Computers and Humans Apart"의 약자로, 한마디로 사람과 컴퓨터(봇)를 구분하기 위한 자동화된 테스트입니다. 주로 웹사이트에서 자동화된 봇 공격을 방지하기 위해 사용되죠.

네이버, 구글 등의 서비스에서도 로그인 시도가 여러 번 실패하면 캡차 인증을 요구하는 것을 본 적 있으실 거예요. 이번 글에서는 이런 시스템을 직접 구현해 볼 거예요!

2. 왜 캡차가 필요할까?

캡차가 없다면 어떤 일이 벌어질까요? 해커가 자동화된 스크립트로 무차별 대입 공격(브루트 포스)을 시도해 계정을 탈취할 수 있습니다. 통계에 따르면 암호화되지 않은 개인정보 유출 사고의 상당수가 이런 브루트 포스 공격에서 시작된다고 해요.

우리 시스템에서 캡차가 제공하는 주요 이점:

  • 3회 이상 로그인 실패 시 자동 캡차 표시로 봇 방어
  • 실제 사람의 개입 요구로 자동화된 공격 무력화
  • 보안 사고 추적을 위한 로깅 시스템

3. 전체 시스템 구조

백엔드 (Spring Boot)

  • 캡차 이미지 생성 및 검증 로직
  • 로그인 실패 횟수 관리 (세션 기반)
  • JWT 토큰 기반 인증 서비스

프론트엔드 (Vue.js)

  • 사용자 로그인 UI
  • 조건부 캡차 표시
  • 캡차 새로고침 및 유효성 검사

백엔드와 프론트엔드가 어떻게 상호작용하는지, 어떻게 보안을 유지하는지 이제부터 자세히 살펴볼게요!

4. 백엔드 구현하기 (Spring Boot)

4.1 필요한 의존성

먼저 build.gradle에 다음 의존성을 추가할게요.

// 캡차 관련 의존성
implementation 'com.github.penggle:kaptcha:2.3.2'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'

4.2 캡차 구성 설정하기

캡차 이미지 생성을 위한 설정 클래스를 만들어 볼게요.

DefaultKaptcha kaptcha = new DefaultKaptcha(); 
Properties properties = new Properties(); 

// 캡차 이미지에 테두리를 표시할지 여부 (yes 또는 no)
properties.setProperty("kaptcha.border", "yes"); 

// 테두리 색상 설정 (RGB 값으로 지정, 여기서는 연한 녹색)
properties.setProperty("kaptcha.border.color", "105,179,90"); 

// 캡차 텍스트의 글자 색상 (black, blue, red 등 색상명 또는 RGB 값)
properties.setProperty("kaptcha.textproducer.font.color", "black"); 

// 캡차 문자 간 간격 픽셀 수 (숫자가 클수록 문자 사이 간격이 넓어짐)
properties.setProperty("kaptcha.textproducer.char.space", "5"); 

// 캡차 문자열의 길이 (생성될 캡차 문자 개수)
properties.setProperty("kaptcha.textproducer.char.length", "6"); 

// 캡차 이미지의 너비 (픽셀 단위)
properties.setProperty("kaptcha.image.width", "400"); 

// 캡차 이미지의 높이 (픽셀 단위)
properties.setProperty("kaptcha.image.height", "200"); 

Config config = new Config(properties); 
kaptcha.setConfig(config); 
return kaptcha;

이 설정으로 테두리가 있는 6자리 캡차 이미지를 생성할 수 있게 됩니다. 속성을 변경하여 이미지 스타일을 커스터마이징할 수 있어요.

4.3 캡차 컨트롤러 구현하기

이제 캡차 이미지를 생성하고 검증하는 REST API를 만들어 볼게요.

@RestController
@RequestMapping("/api/captcha")
public class CaptchaController {

    @Autowired
    private DefaultKaptcha captchaProducer;

    @GetMapping("/image")
    public void getCaptchaImage(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 캡차 텍스트 생성
        String captchaText = captchaProducer.createText();

        // 세션에 캡차 텍스트 저장 (검증용)
        request.getSession().setAttribute("captchaText", captchaText);

        // 캡차 이미지 생성
        BufferedImage image = captchaProducer.createImage(captchaText);

        // 이미지 출력
        response.setContentType("image/jpeg");
        ImageIO.write(image, "jpg", response.getOutputStream());
    }

    @PostMapping("/verify")
    public ResponseEntity<?> verifyCaptcha(HttpServletRequest request, @RequestBody CaptchaVerifyDto dto) {
        String sessionCaptcha = (String) request.getSession().getAttribute("captchaText");

        if (sessionCaptcha == null) {
            return ResponseEntity.badRequest().body("캡차가 만료되었습니다.");
        }

        if (sessionCaptcha.equals(dto.getUserInput())) {
            // 검증 성공 후 세션의 캡차 정보 삭제 (재사용 방지)
            request.getSession().removeAttribute("captchaText");
            return ResponseEntity.ok().body("캡차 검증 성공");
        } else {
            return ResponseEntity.badRequest().body("캡차 검증 실패");
        }
    }
}

여기서 가장 중요한 점은:
1. 캡차 텍스트는 서버 세션에 저장하고 클라이언트에게는 이미지만 전송
2. 검증 성공 시 세션에서 캡차 정보 삭제하여 재사용 방지

4.4 로그인 서비스에 캡차 검증 로직 통합하기

이제 로그인 처리 로직에 캡차 검증을 통합해 볼게요.

@Service
@RequiredArgsConstructor
public class LoginService {

    @Transactional
    public LoginResponseDto login(LoginRequestDto loginRequest, HttpServletRequest request,
                    HttpServletResponse response) {
        // 로그인 실패 횟수 관리
        Integer failCount = (Integer) request.getSession().getAttribute("loginFailCount");
        if (failCount == null) {
                failCount = 0;
        }

        // 실패 횟수가 3회 이상이면 캡차 검증
        if (failCount >= 3) {
                String captchaInput = loginRequest.getCaptcha();
                String sessionCaptcha = (String) request.getSession().getAttribute("captchaText");

                if (captchaInput == null || sessionCaptcha == null || !sessionCaptcha.equals(captchaInput)) {
                        // 캡차 오류 로그 기록
                        saveLog(null, "CAPTCHA_FAIL", "캡차 검증 실패: " + loginRequest.getUsername(),
                                        loginRequest.getIpAddress(), request.getHeader("User-Agent"));

                        throw new BadCredentialsException("자동입력 방지 문자가 일치하지 않습니다.");
                }

                // 성공시 캡차 세션 정보 삭제
                request.getSession().removeAttribute("captchaText");
        }

        // 로그인 처리 로직...

        // 로그인 성공 시 실패 횟수 초기화
        request.getSession().removeAttribute("loginFailCount");

        // ... 로그인 성공 후 로직 ...
        return loginResponse;
    }
}

위 코드에서 로그인 실패 횟수를 세션에 저장하고, 3회 이상 실패 시 캡차 검증을 진행하는 것을 볼 수 있습니다. 로그인 성공 시에는 실패 카운트를 초기화하고요.

5. 프론트엔드 구현하기 (Vue.js)

이제 사용자에게 보여줄 프론트엔드를 Vue.js로 구현해 볼게요.

5.1 로그인 폼 템플릿

<template>
  <!-- 상단 오류 메시지 -->
  <div v-if="showCaptcha && loginFailCount >= 3" class="top-error-message">
      아이디(로그인 전화번호, 로그인 전용 아이디), 비밀번호 또는
      자동입력 방지 문자를 잘못 입력했습니다.
      입력하신 내용을 다시 확인해주세요.
  </div>

  <form @submit.prevent="handleSubmit">
      <!-- 아이디/비밀번호 입력 필드 -->
      <div class="input-container">
          <!-- 아이디/비밀번호 입력 필드 (생략) -->
      </div>

      <!-- 캡차 영역 (조건부 표시) -->
      <div v-if="showCaptcha" class="captcha-wrapper">
          <div class="captcha-content">
              <div class="captcha-image-container">
                  <img :src="captchaImageUrl" alt="Captcha 이미지" class="captcha-image" />
              </div>
          </div>

          <div class="captcha-input-container">
              <div class="captcha-input-group">
                  <input
                      type="text"
                      v-model="loginform.captcha"
                      @input="vaildForm"
                      placeholder="정답을 입력해주세요."
                      class="captcha-input"
                  />
                  <div class="captcha-buttons">
                      <button type="button" @click="refreshCaptcha" class="captcha-button" aria-label="새로고침">
                          <span class="refresh-icon"></span>
                      </button>
                      <button type="button" class="captcha-button" aria-label="음성으로 듣기">
                          <span class="audio-icon"></span>
                      </button>
                  </div>
              </div>
          </div>

          <div class="captcha-info">위 문자를 입력해 주세요</div>
      </div>

      <button type="submit" :class="{'active': isFormVaild && !isLoading}">
          {{ isLoading ? '로그인 중...' :'로그인' }}
      </button>

      <!-- 오류 메시지 표시 영역 -->
      <div v-if="usernameError" class="error-message">{{ usernameError }}</div>
      <div v-else-if="passwordError" class="error-message">{{ passwordError }}</div>
      <div v-else-if="captchaError" class="error-message">{{ captchaError }}</div>
      <div v-else-if="errormessage" class="error-message">{{ errormessage }}</div>
      <div v-if="successMessage" class="success-message">{{ successMessage }}</div>
  </form>
</template>

캡차 영역은 v-if="showCaptcha"를 통해 조건부로 표시됩니다. 로그인 실패 횟수가 3회 이상일 때만 캡차가 표시되도록 설계했어요.

5.2 로그인 폼 스크립트

export default {
  name: 'LoginForm',
  data() {
    return {
        loginform:{
            username:'',
            password:'',
            captcha: '' // 캡차 입력값
        },
        errormessage: '',
        successMessage: '',
        usernameError: '',
        passwordError: '',
        captchaError: '',
        isLoading: false,
        isFormVaild: false,
        usernameFocused: false,
        passwordFocused: false,
        showCaptcha: false, // 기본값은 false로 설정 (로그인 실패 시에만 표시)
        captchaImageUrl: '/api/captcha/image', // 캡차 이미지 URL
        loginFailCount: 0 // 로그인 실패 횟수
    }
  },
  methods: {
    // 캡차 검증 메서드
    validateCaptcha() {
      if (this.showCaptcha && !this.loginform.captcha) {
        this.captchaError = "자동입력 방지 문자를 입력해 주세요.";
        return false;
      } else {
        this.captchaError = "";
        return true;
      }
    },

    // 캡차 새로고침 메서드
    refreshCaptcha() {
      // 캡차 이미지 갱신을 위해 타임스탬프 추가
      this.captchaImageUrl = `/api/captcha/image?timestamp=${new Date().getTime()}`;
      this.loginform.captcha = ''; // 캡차 입력값 초기화
    },

    // 로그인 폼 제출 처리
    async handleSubmit() {
      try {
        // 유효성 검사 먼저 실행
        const isUsernameValid = this.validateUsername();
        const isPasswordValid = this.validatePassword();
        const isCaptchaValid = !this.showCaptcha || this.validateCaptcha();

        // 하나라도 유효하지 않으면 제출 중단
        if (!isUsernameValid || !isPasswordValid || !isCaptchaValid) {
          return;
        }

        this.isLoading = true;
        
        const logindata = {
            username: this.loginform.username,
            password: this.loginform.password
        };

        // 캡차가 표시되어 있으면 캡차 값 추가
        if (this.showCaptcha) {
          logindata.captcha = this.loginform.captcha;
        }

        const response = await axios.post('/api/auth/login', logindata, {
            timeout: 10000,
            withCredentials: true,
            headers: {
                'Content-Type': 'application/json',
                'Accept': 'application/json'
            }
        });

        // 로그인 성공 처리
        if (response.data && response.data.token) {
            localStorage.setItem('userToken', response.data.token);
            this.successMessage = "로그인 성공!";

            const userData = {
                userId: response.data.userId,
                username: response.data.username
            };

            localStorage.setItem('userData', JSON.stringify(userData));

            // 로그인 성공 시 캡차 초기화
            this.showCaptcha = false;
            this.loginFailCount = 0;
            this.loginform.captcha = '';
        } else {
            this.errormessage = '로그인은 성공했으나 토큰이 없습니다.';
        }
      } catch (error) {
        console.error('로그인 실패', error);

        // 로그인 실패 시 아이디와 비밀번호 필드 초기화
        this.loginform.username = '';
        this.loginform.password = '';

        // 로그인 실패 횟수 증가
        this.loginFailCount++;

        // 3번 이상 실패하면 캡차 표시
        if (this.loginFailCount >= 3) {
          this.showCaptcha = true;
          this.refreshCaptcha();
        }

        // 오류 처리 로직
        this.errormessage = error.response?.data?.message || '로그인에 실패했습니다.';
      } finally {
        this.isLoading = false;
      }
    }
    
    // 폼 유효성 검사 등 기타 메서드들...
  }
}

핵심 포인트!
1. 로그인 실패 시 loginFailCount 증가
2. 실패 횟수가 3회 이상일 때 캡차 표시 (showCaptcha = true)
3. 캡차가 표시될 때만 요청에 캡차 값 포함
4. 새로고침 버튼 클릭 시 타임스탬프를 이용한 캐시 방지

6. 동작 흐름 살펴보기

이제 전체 시스템이 어떻게 동작하는지 순서대로 살펴봅시다:

6.1 정상 로그인 흐름

  1. 사용자가 아이디/비밀번호 입력 후 로그인 버튼 클릭
  2. 프론트엔드가 백엔드로 로그인 요청 전송
  3. 백엔드에서 인증 정보 검증
  4. 성공 시 JWT 토큰 생성하여 반환
  5. 프론트엔드가 토큰을 로컬 스토리지에 저장

6.2 로그인 실패 및 캡차 표시 흐름

  1. 첫 번째 로그인 실패:

    • 백엔드: 세션에 loginFailCount = 1 저장
    • 프론트엔드: 로컬 loginFailCount 증가 (1)
  2. 세 번째 로그인 실패 (캡차 표시):

    • 백엔드: 세션에 loginFailCount = 3 저장
    • 프론트엔드: showCaptcha = true로 설정하여 캡차 UI 표시
    • 프론트엔드: <img src="/api/captcha/image"> 태그가 브라우저에서 요청 발생
    • 백엔드: 캡차 텍스트 생성 및 세션에 저장, 이미지 반환
  3. 캡차 포함 로그인 시도

    • 사용자가 아이디, 비밀번호, 캡차 입력
    • 프론트엔드: 캡차 값을 포함한 로그인 요청 전송
    • 백엔드: 세션의 캡차 값과 사용자 입력 비교
    • 성공 시: 로그인 진행, 실패 카운트 초기화
    • 실패 시: 에러 응답 반환

이 과정을 통해 자동화된 봇은 캡차를 해독하지 못해 로그인을 계속 시도할 수 없게 되요!!

7. 보안 고려사항

캡차 시스템을 구현할 때 반드시 고려해야 할 보안 사항들을 알아볼게요.

7.1 세션 기반 보안

캡차 텍스트는 절대 클라이언트에 전송하지 않고 서버 세션에만 저장합니다. 이렇게 하면 JavaScript를 통한 캡차 우회를 방지할 수 있어요.

7.2 재사용 방지

// 검증 성공 후 세션에서 캡차 정보 삭제
request.getSession().removeAttribute("captchaText");

검증이 성공하면 세션에서 캡차 텍스트를 제거하여 동일한 캡차 값으로 재시도하는 것을 방지해요.

7.3 로그 기록

보안 시스템의 핵심은 철저한 로깅입니다. 캡차 검증 실패를 포함한 모든 로그인 시도를 기록하면 이후 공격 패턴 분석이나 법적 증거로 활용할 수 있습니다.

saveLog(null, "CAPTCHA_FAIL", "캡차 검증 실패: " + loginRequest.getUsername(),
        loginRequest.getIpAddress(), request.getHeader("User-Agent"));

8. 심화: 캡차 이미지 처리와 새로고침 메커니즘

8.1 이미지 요청 메커니즘

캡차 이미지는 일반적인 HTTP 요청과 다르게 처리되요.

  1. HTML의 <img> 태그가 렌더링될 때 브라우저는 자동으로 src 속성의 URL로 HTTP GET 요청을 보냅니다.
  2. 서버는 이 요청을 받아 캡차 텍스트를 생성하고 세션에 저장합니다.
  3. 생성된 이미지는 HTTP 응답 본문에 직접 쓰여집니다 (JSON이 아닌 이미지 바이너리).
@GetMapping("/image")
public void getCaptchaImage(HttpServletRequest request, HttpServletResponse response) throws IOException {
    // 캡차 텍스트 생성
    String captchaText = captchaProducer.createText();

    // 세션에 캡차 텍스트 저장
    request.getSession().setAttribute("captchaText", captchaText);

    // 캡차 이미지 생성
    BufferedImage image = captchaProducer.createImage(captchaText);

  // 이미지 출력 - 일반적인 JSON 응답이 아닌 이미지 바이너리 전송
  response.setContentType("image/jpeg");  // HTTP 응답의 Content-Type 헤더를 이미지 형식으로 설정
  ImageIO.write(image, "jpg", response.getOutputStream());  // 이미지를 응답 스트림에 직접 쓰기
}

8.2 새로고침 메커니즘과 브라우저 캐시 방지

캡차 이미지 새로고침 시 브라우저 캐시를 우회하기 위해 타임스탬프를 사용되요.

refreshCaptcha() {
  // 타임스탬프를 쿼리 파라미터로 추가하여 브라우저 캐시 방지
  this.captchaImageUrl = `/api/captcha/image?timestamp=${new Date().getTime()}`;
  this.loginform.captcha = '';
}

여기서 타임스탬프의 역할 !
1. 매번 다른 URL을 만들어 브라우저가 캐시된 이미지를 사용하지 않도록 함
2. 서버는 이 타임스탬프 값을 실제로 사용하지 않음 (단순히 URL을 unique하게 만들기 위함)

9. 정리 및 마무리

지금까지 Spring Boot와 Vue.js를 이용한 캡차 시스템 구현 방법을 살펴봤습니다. 핵심 개념을 요약해볼게요.

  1. 캡차의 필요성: 자동화된 봇 공격으로부터 시스템 보호
  2. 백엔드 구현: Spring Boot에서 캡차 이미지 생성 및 검증 로직 구현
  3. 프론트엔드 구현: Vue.js에서 조건부 캡차 표시 및 사용자 입력 처리
  4. 세션 기반 보안: 서버 세션을 활용한 안전한 검증 프로세스
  5. 타임스탬프 활용: 브라우저 캐시 방지 및 매번 새로운 캡차 제공

이 글이 여러분의 웹 애플리케이션 보안을 강화하는 데 도움이 되었으면 좋겠습니다. 캡차는 완벽한 보안 솔루션은 아니지만, 다중 방어 전략(Defense in Depth)의 중요한 한 축을 담당합니다.

더 나아가 reCAPTCHA v3나 hCaptcha 같은 서비스를 활용하거나, 소리 기반 캡차나 퍼즐 기반 캡차 등 다양한 방식을 고려해볼 수도 있습니다.

여러분은 어떤 보안 전략을 사용하고 계신가요? 댓글로 공유해주세요! 🔒


참고자료

profile
개발 공부중인 학생입니다~

0개의 댓글