Vue와 Element UI를 활용한 슬라이딩 퍼즐 인증 기능 구현

슬라이딩 퍼즐 인증 구현 개요

사용자가 직접 조작했는지 확인하기 위한 슬라이딩 퍼즐 인증 기능을 Vue와 Element UI를 사용하여 구현하는 방법을 설명합니다. 핵심 로직은 백엔드 API를 통해 배경 이미지와 퍼즐 조각 이미지를 Base64 형식으로 받아와 렌더링하고, 사용자의 드래그 거리를 계산하여 오차 범위(약 10px) 내에서 일치하는지 검증하는 것입니다. 기존 코드에서 발생하던 DOM 직접 조작의 부작용을 방지하고, Vue의 반응형 시스템과 이벤트 생명주기를 활용하여 안정성을 높인 구조로 재설계했습니다.

Vue 컴포넌트 구현

마우스 이벤트 리스너를 전역으로 등록하고 컴포넌트 소멸 시 해제하여 메모리 누수를 방지했습니다. 또한, async/await 구문을 적용하여 비동기 API 호출 로직을 직관적으로 개선했습니다.

<template>
  <div class="slider-captcha-container">
    <el-form :model="form" :rules="rules" ref="captchaForm">
      <el-form-item prop="account">
        <el-input v-model="form.account" placeholder="이메일 또는 휴대폰 번호 입력" />
      </el-form-item>
      
      <el-form-item>
        <el-input 
          v-model="captchaStatus" 
          placeholder="클릭하여 인증 진행" 
          readonly 
          @click="openCaptchaDialog"
        >
          <template slot="prefix">
            <i :class="statusIcon"></i>
          </template>
        </el-input>
      </el-form-item>

      <el-button type="primary" @click="handleSubmit">확인</el-button>
    </el-form>

    <el-dialog :visible.sync="isDialogOpen" title="보안 인증" width="400px">
      <div class="image-wrapper">
        <img :src="backgroundImage" class="bg-image" />
        <img :src="puzzleImage" class="puzzle-image" :style="{ top: puzzleTop, left: puzzleLeft + 'px' }" />
      </div>
      
      <div class="slider-track" @mousedown="startDragging">
        <div class="slider-thumb" :style="{ left: sliderLeft + 'px' }">
          <i class="el-icon-d-arrow-right"></i>
        </div>
      </div>
      
      <div class="actions">
        <el-button icon="el-icon-refresh" circle @click="fetchCaptchaImages"></el-button>
      </div>
    </el-dialog>
  </div>
</template>

<script>
export default {
  data() {
    return {
      form: { account: '' },
      rules: {
        account: [{ required: true, message: '계정을 입력해주세요.', trigger: 'blur' }]
      },
      isDialogOpen: false,
      isVerified: false,
      backgroundImage: '',
      puzzleImage: '',
      puzzleTop: '0px',
      puzzleLeft: 0,
      sliderLeft: 0,
      isDragging: false,
      startX: 0
    };
  },
  computed: {
    captchaStatus() {
      return this.isVerified ? '인증 완료' : '';
    },
    statusIcon() {
      return this.isVerified ? 'el-icon-success' : 'el-icon-lock';
    }
  },
  methods: {
    async openCaptchaDialog() {
      if (this.isVerified) return;
      if (!this.form.account) {
        this.$message.warning('먼저 계정을 입력해주세요.');
        return;
      }
      this.isDialogOpen = true;
      await this.fetchCaptchaImages();
    },
    async fetchCaptchaImages() {
      try {
        const response = await this.$axios.get('/api/captcha/generate');
        const { bgImage, puzzleImage, yCoordinate } = response.data;
        this.backgroundImage = `data:image/jpeg;base64,${bgImage}`;
        this.puzzleImage = `data:image/png;base64,${puzzleImage}`;
        this.puzzleTop = `${yCoordinate}px`;
        this.resetSlider();
      } catch (error) {
        this.$message.error('이미지를 불러오는 데 실패했습니다.');
      }
    },
    startDragging(e) {
      this.isDragging = true;
      this.startX = e.clientX;
      document.addEventListener('mousemove', this.onDragging);
      document.addEventListener('mouseup', this.stopDragging);
    },
    onDragging(e) {
      if (!this.isDragging) return;
      let moveX = e.clientX - this.startX;
      if (moveX < 0) moveX = 0;
      if (moveX > 320) moveX = 320; 
      
      this.sliderLeft = moveX;
      this.puzzleLeft = moveX;
    },
    async stopDragging() {
      if (!this.isDragging) return;
      this.isDragging = false;
      document.removeEventListener('mousemove', this.onDragging);
      document.removeEventListener('mouseup', this.stopDragging);
      
      await this.verifyCaptcha(this.puzzleLeft);
    },
    async verifyCaptcha(distance) {
      try {
        const response = await this.$axios.post('/api/captcha/verify', { distance });
        if (response.data.code === 0) {
          this.isVerified = true;
          this.isDialogOpen = false;
          this.$message.success('인증에 성공했습니다.');
        } else {
          this.$message.error('인증에 실패했습니다. 다시 시도해주세요.');
          this.resetSlider();
        }
      } catch (error) {
        this.$message.error('서버 오류가 발생했습니다.');
        this.resetSlider();
      }
    },
    resetSlider() {
      this.sliderLeft = 0;
      this.puzzleLeft = 0;
    },
    handleSubmit() {
      this.$refs.captchaForm.validate((valid) => {
        if (valid && this.isVerified) {
          this.$emit('submit', this.form.account);
        } else if (!this.isVerified) {
          this.$message.warning('슬라이더 인증을 진행해주세요.');
        }
      });
    }
  },
  beforeDestroy() {
    document.removeEventListener('mousemove', this.onDragging);
    document.removeEventListener('mouseup', this.stopDragging);
  }
};
</script>

<style scoped>
.slider-captcha-container {
  width: 400px;
  margin: 0 auto;
}
.image-wrapper {
  position: relative;
  width: 100%;
  height: 200px;
  overflow: hidden;
  border-radius: 4px;
}
.bg-image {
  width: 100%;
  height: 100%;
}
.puzzle-image {
  position: absolute;
  height: 40px;
  width: 40px;
}
.slider-track {
  position: relative;
  width: 100%;
  height: 40px;
  background: #e4e7ed;
  border-radius: 20px;
  margin-top: 15px;
}
.slider-thumb {
  position: absolute;
  width: 40px;
  height: 40px;
  background: #409eff;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #fff;
  cursor: pointer;
  box-shadow: 0 2px 6px rgba(0,0,0,0.2);
}
.actions {
  text-align: right;
  margin-top: 10px;
}
</style>

Axios 쿠키 전송 설정

캡차 검증 과정에서 세션 유지를 위해 HTTP 요청 시 쿠키를 포함하도록 Axios 인스턴스를 설정해야 합니다. withCredentials 옵션을 활성화하여 크로스 도메인 환경에서도 인증 상태를 유지할 수 있습니다.

<script>
import axios from 'axios';

const apiClient = axios.create({
  baseURL: process.env.VUE_APP_BASE_API,
  timeout: 5000,
  withCredentials: true
});

export default apiClient;
</script>

API 인터페이스 명세

백엔드에서 제공하는 캡차 이미지 생성 및 검증 API의 명세는 다음과 같습니다.

구분 엔드포인트 메서드 파라미터 응답 데이터
이미지 생성 /api/captcha/generate GET 없음 bgImage (Base64), puzzleImage (Base64), yCoordinate (숫자)
거리 검증 /api/captcha/verify POST distance (이동 거리) code (0: 성공, 1: 실패), message

검증 API의 경우, 실제 퍼즐 조각의 위치와 사용자가 드래그한 거리 간의 오차가 10px 이내일 경우 성공으로 처리됩니다. 세션 만료 등으로 인해 검증 토큰이 유효하지 않을 경우 실패 코드를 반환하므로, 프론트엔드에서는 이를 감지하여 이미지를 다시 요청하는 로직을 수행해야 합니다.

태그: vue Element-UI slider-captcha axios Base64

8월 7일 04:06에 게시됨