프로그래밍Front-End

[Vue3&TypeScript] 사용자가 끝까지 스크롤하면 약관동의 체크박스를 자동으로 체크되도록 만드는 예제코드

회원가입 약관동의 페이지가 있을 때, 사용자가 스크롤을 아래쪽까지 다 내려서 약관을 읽었다고 가정하고, 체크박스를 자동으로 체크되도록 하는게 요즘 추세인가? 특히 보험이 그렇긴한데…. 약관 내용을 사용자가 끝까지 스크롤하면 해당 체크박스를 자동으로 체크되도록 만드는 예제코드이다.

<template>
  <div class="mb-10">
    <div class="h-[12rem] overflow-y-auto border p-4" ref="termsBox" @scroll="handleScroll">
      <div v-html="termsContent" />
    </div>
    <div class="mt-4">
      <input
        :id="id"
        type="checkbox"
        :name="name"
        :checked="modelValue"
        @change="$emit('update:modelValue', $event.target.checked)"
      />
      <label :for="id">{{ title }}</label>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{
  id: string;
  name: string;
  title: string;
  modelValue: boolean;
  termsContent: string;
}>();
const emit = defineEmits(['update:modelValue', 'update']);

const termsBox = ref<HTMLDivElement | null>(null);

interface Emits {
  (e: 'update'): void;
  (e: 'update:modelValue', value: boolean): void;
}

const handleScroll = () => {
  const el = termsBox.value;
  if (!el) return;

  // 끝까지 스크롤한 경우
  if (el.scrollTop + el.clientHeight >= el.scrollHeight - 10) {
    // 아직 체크 안 된 경우만 emit
    if (!props.modelValue) {
      emit('update:modelValue', true);
      emit('update'); // 부모에서 전체동의 여부 체크용
    }
  }
};
</script>

효과 설명

  • termsBoxref를 붙여서 DOM 요소 추적
  • @scroll="handleScroll"로 스크롤 이벤트 감지
  • scrollTop + clientHeight >= scrollHeight → 거의 끝까지 스크롤했는지 판단
  • 끝까지 내린 경우 emit('update:modelValue', true)로 체크박스 체크
  • 동시에 부모에 @update 이벤트도 emit하여 updateAllChecked() 실행되게 함

약관 동의는 자동 체크되더라도 사용자에게 표시가 필요하므로, 예를 들어 체크박스에 “읽은 것으로 간주되어 자동 체크됨” 등 안내 텍스트를 보여주는 것도 좋다.


emits에 modelValue 업데이트 추가

interface Emits {
  (e: 'update'): void;
  (e: 'update:modelValue', value: boolean): void;
}

전체 변경된 부분 요약 (템플릿 + 스크립트 핵심만)

<!-- 템플릿 -->
<div
  v-if="props.termsContent"
  ref="termsContentRef"
  class="terms-content"
  v-html="props.termsContent"
  @scroll="handleScroll"
/>


// 스크립트 setup 내부
const termsContentRef = ref<HTMLElement | null>(null);

const handleScroll = () => {
  const el = termsContentRef.value;
  if (!el) return;

  if (el.scrollTop + el.clientHeight >= el.scrollHeight - 10) {
    if (!props.modelValue) {
      emits('update:modelValue', true);
      emits('update');
    }
  }
};

const emits = defineEmits<{
  (e: 'update'): void;
  (e: 'update:modelValue', value: boolean): void;
}>();

스크롤이 발생하지 않는다면, 실제로 스크롤이 생길 만큼 높이가 충분하지 않음으로 max-height가 너무 작거나, v-html로 삽입된 약관 내용이 짧다면 스크롤이 안 생긴다.


해결법: max-height 값을 확실하게 크게 설정

.terms-content {
  max-height: calc(1.6em * 5); /* 5줄 높이 */
  overflow-y: auto;
}


디버깅 팁 : 부모에서 변경 사항 확인하려면 콘솔 찍어보면 됩니다:

watch(() => values.terms1, (val) => {
  console.log('terms1 체크됨:', val);
});

CSS 변경

.terms-content {
  white-space: pre-wrap;
  word-wrap: break-word;
  line-height: 1.6;
  max-height: 300px;        /* ← 스크롤 높이 제한 */
  overflow-y: auto;         /* ← 스크롤을 이 div 안에 강제 */
  padding-right: 0.5rem;    /* ← 스크롤바 가리지 않게 여유 */
}

.terms-scroll-box {
  max-height: 300px;         /* ✅ 고정된 스크롤 높이 */
  overflow-y: auto;          /* ✅ 스크롤 발생 */
  padding-right: 1rem;       /* ✅ 스크롤바와 겹치지 않도록 */
  white-space: pre-wrap;
  word-break: break-word;
  line-height: 1.6;
  border: 1px solid #ccc;    /* ✅ 확인용 */
}
const handleScroll = () => {
  const el = termsContentRef.value;
  if (!el) return;

  console.log('스크롤 위치:', el.scrollTop, '/', el.scrollHeight - el.clientHeight);

  if (el.scrollTop + el.clientHeight >= el.scrollHeight - 10) {
    if (!props.modelValue) {
      emits('update:modelValue', true);
      emits('update');
    }
  }
};


디버깅용으로 간단히 내용 강제로 추가해보기

약관 내용이 정상 삽입되는지 확인을 위해 아래처럼 일시적으로 넣어도 좋아요:

termsContent.value = '약관 내용입니다.\n'.repeat(100);

조치설명
max-height꼭 필요. 없으면 scroll 안 생김
overflow-y: auto있어야 내부에서만 스크롤됨
ref & @scroll자식 컴포넌트에서 이벤트 감지용
v-html 내용이 충분히 긴지짧으면 scroll 이벤트 안 나옴

이런 오류가 발생한다면?

TermsView.vue:21 [Vue warn] Set operation on key "terms1" failed: target is readonly. Proxy(Object) {terms1: false, terms2: false}

Vue의 ref() 또는 computed()가 읽기 전용(readonly)인데 거기에 직접 값을 할당하려 했을 때 발생합니다.

emits('update:modelValue', true);

이 줄이 문제의 핵심이다:

이 코드를 쓰려면 부모에서 v-modelmodelValue로 연결하고 있어야 합니다.
하지만 지금 TermsBox에서는 v-model="values.terms1!" 이렇게 사용 중인데, VeeValidate의 values는 내부적으로 readonly Proxy입니다.

v-model="values.terms1"  // ❌ 직접 수정 불가한 readonly proxy!

그래서 emit('update:modelValue', true) 를 통해 values.terms1 = true가 실행되면 위 에러가 뜹니다.



해결 방법 2가지

방법 1. v-model 대신 수동 props + emit 구조로 사용 (권장)

부모에서는 아래처럼 :checked@check를 수동 처리:

🔧 부모 컴포넌트

<TermsBox
  :checked="values.terms1"
  @check="val => {
    setValues({ terms1: val });
    updateAllChecked();
  }"
  ...
/>

TermsGuide.vue 콤포넌트

const props = defineProps<{
  checked: boolean;
  ...
}>();

const emits = defineEmits<{
  (e: 'check', value: boolean): void;
}>();

스크롤 감지에서:

if (isAtBottom && !props.checked) {
  emits('check', true);
}

이렇게 하면 values를 직접 수정하지 않고, setValues()를 통해 VeeValidate의 공식 API로 값을 변경하기 때문에 에러가 나지 않습니다.



방법 2. v-model 연결을 values.terms1이 아닌 ref 변수로 교체

const terms1Check = ref(false);

watch(terms1Check, (val) => {
  setValues({ terms1: val });
  updateAllChecked();
});

그리고 v-model="terms1Check"로 넘김:

<TermsBox v-model="terms1Check" ... />

이렇게 하면 v-model로 직접 terms1Check를 쓰고, values.terms1watch로 우회해서 set 하므로 에러 안 남

만약 스크롤이 없는 케이스라면?


1. 약관 내용이 짧아서 스크롤이 생기지 않는 경우

즉, 스크롤할 필요 없이 모든 약관이 한눈에 다 보이는 경우

해결 전략:

이 경우는 스크롤 이벤트가 발생할 수 없기 때문에, 초기 렌더링 시 스크롤이 필요 없는지도 판단해서 자동 체크해야 합니다.


실전 대응 코드 (자식 TermsBox.vue에서 처리)

1. 기존 refhandleScroll 유지:

const termsContentRef = ref<HTMLElement | null>(null);


2. checkIfScrollNeeded() 함수 추가:

const checkIfScrollNeeded = () => {
  const el = termsContentRef.value;
  if (!el) return;

  const noScrollNeeded = el.scrollHeight <= el.clientHeight;
  if (noScrollNeeded && !props.modelValue) {
    emits('update:modelValue', true); // ✅ 자동 체크
    emits('update');                  // 전체 동의 갱신
  }
};

3. onMounted + watch에서 실행

import { onMounted, watch, nextTick } from 'vue';

onMounted(() => {
  checkIfScrollNeeded(); // 초기에 약관 내용이 짧을 경우
});

watch(
  () => props.termsContent,
  async () => {
    await nextTick(); // DOM 반영 이후에 계산해야 정확
    checkIfScrollNeeded();
  }
);


완성 흐름 요약

  1. 스크롤 감지 → 스크롤이 끝까지 내려가면 체크
  2. 스크롤 자체가 필요 없는 경우 → 로딩 직후 자동 체크


결과적으로 다음 케이스 모두 대응됩니다

상황대응 방식
약관 내용이 길다 (스크롤 있음)@scroll으로 끝까지 감지 후 자동 체크
약관 내용이 짧다 (스크롤 없음)DOM 렌더 직후 scrollHeight <= clientHeight로 자동 체크
error: Content is protected !!