[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>
효과 설명
termsBox
에ref
를 붙여서 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-model
을 modelValue
로 연결하고 있어야 합니다.
하지만 지금 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.terms1
은 watch
로 우회해서 set 하므로 에러 안 남
만약 스크롤이 없는 케이스라면?
1. 약관 내용이 짧아서 스크롤이 생기지 않는 경우
즉, 스크롤할 필요 없이 모든 약관이 한눈에 다 보이는 경우
해결 전략:
이 경우는 스크롤 이벤트가 발생할 수 없기 때문에, 초기 렌더링 시 스크롤이 필요 없는지도 판단해서 자동 체크해야 합니다.
실전 대응 코드 (자식 TermsBox.vue에서 처리)
1. 기존 ref
와 handleScroll
유지:
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();
}
);
완성 흐름 요약
- 스크롤 감지 → 스크롤이 끝까지 내려가면 체크
- 스크롤 자체가 필요 없는 경우 → 로딩 직후 자동 체크
결과적으로 다음 케이스 모두 대응됩니다
상황 | 대응 방식 |
---|---|
약관 내용이 길다 (스크롤 있음) | @scroll 으로 끝까지 감지 후 자동 체크 |
약관 내용이 짧다 (스크롤 없음) | DOM 렌더 직후 scrollHeight <= clientHeight 로 자동 체크 |