SCRUM-13748 | 커밋 수: 12개 | 기간: 2025.05
문제상황
댓글 작성 시 플로팅 버튼을 클릭해야 모달이 열리는 UX가 불편했습니다. 또한 이미지 업로드 컴포넌트가 리뷰 전용으로 제한되어 있어 재사용성이 부족한 상황이었습니다.
주요 개선사항
하단 댓글 입력 컴포넌트를 구현하여 즉시 작성이 가능하도록 개선했습니다. ReviewImageUpload를 ImageUpload로 리네임하고 common 디렉토리로 이동시켜 공통화했습니다. 댓글이나 답글 수정 시 하단 입력 박스로 포커스가 자동 이동하도록 구현했습니다.
정량적 성과
댓글 작성 플로팅 버튼을 삭제하고 하단 고정 입력으로 대체했습니다. ImageUpload 컴포넌트를 공통화하여 댓글과 리뷰 모두에서 사용할 수 있게 했으며, 하단 댓글 이미지 첨부 기능을 구현했습니다.
UX 개선
하단 고정 입력으로 즉시 작성
ImageUpload 공통화
review → common 이동
수정 모드
포커스 자동 이동
비즈니스 임팩트
댓글 작성 UX가 크게 개선되었습니다. 3단계(플로팅 버튼 클릭 → 모달 열기 → 작성)였던 플로우가 하단 고정 입력으로 바로 작성할 수 있게 단축되었고, 컴포넌트 재사용성이 향상되었습니다.
기술적 구현 상세
AS-IS (플로팅 버튼)
장점
- 화면 공간 절약
고려할 점
- 클릭→모달→작성 3단계 플로우
- 이미지 업로드 리뷰 전용
TO-BE (하단 고정 입력)
장점
- 즉시 입력 가능
- ImageUpload 공통화로 재사용
- 수정 시 포커스 자동 이동
고려할 점
- 하단 영역 항상 점유
AS-IS
기존에는 플로팅 버튼을 클릭해야 댓글 작성 모달이 열렸습니다.
<!-- 기존 구조 (더미 예시) -->
<template>
<FloatingButton @click="openCommentModal">
댓글 작성
</FloatingButton>
<CommentModal v-if="isModalOpen" @close="closeModal" />
</template>
문제점:
플로팅 버튼 클릭 → 모달 열기 → 작성의 번거로운 3단계 플로우였습니다. 이미지 업로드 컴포넌트가 리뷰 전용으로 제한되어 있어 댓글에서는 사용할 수 없었습니다.
TO-BE
하단 고정 입력 컴포넌트로 즉시 댓글 작성이 가능합니다.
- 하단 입력 포커스
- 텍스트/이미지 입력
- 등록/수정 완료
핵심 구현
1. 하단 댓글 입력 컴포넌트
<!-- components/comment/CommentBox.vue -->
<script setup lang="ts">
import { ImageUpload, ImageUploadButton } from '@/components/common';
import { useCommentStore } from '@/stores/modules/comment';
import { useMediaStore } from '@/stores/modules/media';
const props = defineProps<{
contentType: CommentContentType;
contentId: string;
}>();
const commentStore = useCommentStore();
const mediaStore = useMediaStore();
const commentText = ref('');
const isFocused = ref(false);
const isEditing = ref(false);
const editingCommentId = ref<string | null>(null);
// 포커스 상태에 따른 placeholder 변경
const placeholder = computed(() =>
isFocused.value ? '댓글을 입력하세요' : '댓글 작성'
);
// 댓글 작성
const submitComment = async () => {
if (!commentText.value.trim()) {
return;
}
const attachedImages = await mediaStore.getUploadedImagePaths();
if (isEditing.value && editingCommentId.value) {
// 수정 모드
await commentStore.updateComment({
commentId: editingCommentId.value,
text: commentText.value,
images: attachedImages,
});
} else {
// 신규 작성
await commentStore.createComment({
contentType: props.contentType,
contentId: props.contentId,
text: commentText.value,
images: attachedImages,
});
}
// 초기화
resetForm();
};
// 수정 모드 진입
const enterEditMode = (comment: Comment) => {
isEditing.value = true;
editingCommentId.value = comment.id;
commentText.value = comment.text;
// 하단 입력 박스로 포커스 이동
nextTick(() => {
textareaRef.value?.focus();
});
};
// 폼 초기화
const resetForm = () => {
commentText.value = '';
isEditing.value = false;
editingCommentId.value = null;
mediaStore.clearAttachedImages();
};
// 로그인 체크 후 포커스 처리
const handleFocus = () => {
// 비로그인 시 로그인 페이지로 이동
// 로그인 상태면 포커스 활성화
};
</script>
<template>
<div class="comment-box" :class="{ focused: isFocused }">
<!-- 이미지 업로드 영역 -->
<ImageUpload
v-if="isFocused"
:max-count="3"
/>
<div class="input-area">
<ImageUploadButton
v-if="!isFocused"
type="comment"
/>
<textarea
ref="textareaRef"
v-model="commentText"
:placeholder="placeholder"
@focus="handleFocus"
@blur="isFocused = false"
/>
<button
class="submit-button"
:disabled="!commentText.trim()"
@click="submitComment"
>
{{ isEditing ? '수정' : '등록' }}
</button>
</div>
</div>
</template>
<style scoped lang="scss">
.comment-box {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-top: 1px solid #eee;
padding: 12px 16px;
&.focused {
padding-bottom: env(safe-area-inset-bottom);
}
}
</style>
2. ImageUpload 컴포넌트 공통화
<!-- components/common/ImageUpload.vue -->
<script setup lang="ts">
import { useMediaStore } from '@/stores/modules/media';
import { UploadImageList } from '@/components/common';
const props = withDefaults(defineProps<{
maxCount?: number;
showMainImageSelector?: boolean;
}>(), {
maxCount: 3,
showMainImageSelector: false,
});
const mediaStore = useMediaStore();
const { attachedImages } = storeToRefs(mediaStore);
const handleFileSelect = async (event: Event) => {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files ?? []);
for (const file of files) {
if (attachedImages.value.length >= props.maxCount) {
break;
}
await mediaStore.uploadImage(file);
}
// input 초기화
input.value = '';
};
const removeImage = (index: number) => {
mediaStore.removeImage(index);
};
</script>
<template>
<div class="image-upload">
<UploadImageList
:images="attachedImages"
:show-main-selector="showMainImageSelector"
@remove="removeImage"
/>
<label v-if="attachedImages.length < maxCount" class="upload-button">
<input
type="file"
accept="image/*"
multiple
@change="handleFileSelect"
/>
<span>+ 이미지 추가</span>
</label>
</div>
</template>
3. ImageUploadButton 타입 추가
<!-- components/common/ImageUploadButton.vue -->
<script setup lang="ts">
type ButtonType = 'review' | 'comment' | 'feed';
const props = withDefaults(defineProps<{
type?: ButtonType;
}>(), {
type: 'review',
});
const buttonConfig: Record<ButtonType, { icon: string; label: string }> = {
review: { icon: 'camera', label: '사진 추가' },
comment: { icon: 'image', label: '이미지' },
feed: { icon: 'gallery', label: '갤러리' },
};
const config = computed(() => buttonConfig[props.type]);
</script>
<template>
<button class="image-upload-button" :class="`type-${type}`">
<Icon :name="config.icon" />
<span>{{ config.label }}</span>
</button>
</template>
회고
잘한 점
하단 고정 입력으로 댓글 작성 UX를 대폭 개선했습니다. ImageUpload 컴포넌트를 공통화하여 리뷰와 댓글 모두에서 재사용할 수 있도록 했습니다.
개선할 점
체험단 타입에서는 하단 입력 컴포넌트를 제외해야 하는 케이스가 있어 기획 확인이 필요합니다.
배운 점
모바일 UX에서 하단 고정 입력의 장점을 체감했습니다. 또한 컴포넌트 공통화 시 네이밍이 가져오는 영향력을 인식하게 되었습니다. ReviewImageUpload라는 이름이 재사용을 막는 심리적 장벽이 되었던 경험이 이를 잘 보여줍니다.