SCRUM-13148 | 커밋 수: 11개 | 기간: 2025.03
문제상황
공유하기 로직이 각 컴포넌트에 분산되어 있어 중복 코드가 발생하고 있었습니다. 로직이 컴포넌트 내부에 직접 구현되어 있었기 때문에 단위 테스트를 작성하기도 어려운 구조였습니다.
주요 개선사항
공유하기 로직을 훅으로 추출하여 재사용 가능한 단일 모듈로 통합했습니다. 공유 방법에 상관없이 단축 URL 또는 앱링크로 URL 처리 방식을 통일했고, @vue/test-utils를 추가하여 테스트 인프라를 갖추었습니다.
정량적 성과
공유하기 url 재정의 함수에 대한 단위 테스트를 추가했습니다. 기존에 흩어져 있던 단위 테스트 모듈을 tests/unit 하위로 이동하여 디렉토리 구조를 정리했습니다.
URL 처리 통일
단축 URL·앱링크 자동 판별
단위 테스트
useShare 훅 테스트 도입
로직 일원화
컴포넌트 분산 → 단일 훅
비즈니스 임팩트
공유 기능의 일관성을 확보하여 어떤 화면에서든 동일한 방식으로 공유가 동작하게 되었습니다. 로직이 훅으로 분리됨에 따라 테스트 가능성이 향상되어 안정적인 기능 유지보수가 가능해졌습니다.
기술적 구현 상세
AS-IS (컴포넌트 분산)
장점
- 각 컴포넌트에서 독립적 구현
고려할 점
- 공유 로직 중복
- URL 처리 방식 불일치
- 단위 테스트 불가
TO-BE (훅 추출)
장점
- 로직 일원화
- URL 처리 통일
- 단위 테스트 가능
고려할 점
- 훅 의존성 추가
AS-IS
기존에는 공유하기 로직이 컴포넌트에 직접 구현되어 있었습니다.
<!-- 기존 구조 (더미 예시) -->
<script setup lang="ts">
const shareToKakao = () => {
// 카카오 공유 로직
const url = window.location.href;
// ...
};
const shareToSms = () => {
// SMS 공유 로직
const url = window.location.href;
// ...
};
</script>
문제점:
공유 로직이 컴포넌트마다 중복되어 있었고, URL 처리 방식도 컴포넌트별로 달랐습니다. 로직이 컴포넌트에 결합되어 있어 단위 테스트를 작성할 수 없었습니다.
TO-BE
공유하기 로직을 훅으로 추출하여 재사용성과 테스트 가능성을 확보했습니다.
- URL 입력
- 단축URL 판별
- 앱링크 판별
- 필요시 단축URL 생성
- 공유 실행
핵심 구현
1. 공유하기 훅
// hooks/common.ts
interface ShareOptions {
title: string;
description: string;
imageUrl: string;
shareUrl: string;
}
export const useShare = () => {
const commonApi = useCommonApi();
// URL 재정의 함수
const refineShareUrl = async (url: string): Promise<string> => {
// 이미 단축 URL인 경우 그대로 반환
if (isShortUrl(url)) {
return url;
}
// 앱링크인 경우 그대로 반환
if (isAppLink(url)) {
return url;
}
// 단축 URL 생성
try {
const shortUrl = await commonApi.createShortUrl(url);
return shortUrl;
} catch {
return url;
}
};
// 단축 URL 여부 확인
const isShortUrl = (url: string): boolean => {
const shortUrlPattern = /^https?:\/\/[a-z]+\.link\/[a-zA-Z0-9]+$/;
return shortUrlPattern.test(url);
};
// 앱링크 여부 확인
const isAppLink = (url: string): boolean => {
return url.startsWith('emartapp://');
};
// 카카오 공유
const shareToKakao = async (options: ShareOptions) => {
const url = await refineShareUrl(options.shareUrl);
window.Kakao?.Link?.sendDefault({
objectType: 'feed',
content: {
title: options.title,
description: options.description,
imageUrl: options.imageUrl,
link: {
mobileWebUrl: url,
webUrl: url,
},
},
});
};
// SMS 공유
const shareToSms = async (options: ShareOptions) => {
const url = await refineShareUrl(options.shareUrl);
const text = `${options.title}\n${url}`;
window.location.href = `sms:?body=${encodeURIComponent(text)}`;
};
// 클립보드 복사
const copyToClipboard = async (options: ShareOptions) => {
const url = await refineShareUrl(options.shareUrl);
try {
await navigator.clipboard.writeText(url);
return true;
} catch {
return false;
}
};
return {
refineShareUrl,
isShortUrl,
isAppLink,
shareToKakao,
shareToSms,
copyToClipboard,
};
};
2. 단위 테스트
// tests/unit/hooks/share.test.ts
import { useShare } from '@/hooks/common';
describe('useShare', () => {
describe('isShortUrl', () => {
it('단축 URL을 올바르게 판별한다', () => {
// Given
const { isShortUrl } = useShare();
// When & Then
expect(isShortUrl('<https://emart.link/abc123>')).toBe(true);
expect(isShortUrl('<https://example.com/long/path>')).toBe(false);
});
});
describe('isAppLink', () => {
it('앱링크를 올바르게 판별한다', () => {
// Given
const { isAppLink } = useShare();
// When & Then
expect(isAppLink('emartapp://product/123')).toBe(true);
expect(isAppLink('<https://emart.com/product/123>')).toBe(false);
});
});
describe('refineShareUrl', () => {
it('이미 단축 URL인 경우 그대로 반환한다', async () => {
// Given
const { refineShareUrl } = useShare();
const shortUrl = '<https://emart.link/abc123>';
// When
const result = await refineShareUrl(shortUrl);
// Then
expect(result).toBe(shortUrl);
});
it('앱링크인 경우 그대로 반환한다', async () => {
// Given
const { refineShareUrl } = useShare();
const appLink = 'emartapp://product/123';
// When
const result = await refineShareUrl(appLink);
// Then
expect(result).toBe(appLink);
});
});
});
3. 컴포넌트에서 사용
<!-- components/popup/SharePopup.vue -->
<script setup lang="ts">
import { useShare } from '@/hooks/common';
const props = defineProps<{
shareInfo: ShareOptions;
}>();
const { shareToKakao, shareToSms, copyToClipboard } = useShare();
const handleShare = async (type: 'kakao' | 'sms' | 'copy') => {
switch (type) {
case 'kakao':
await shareToKakao(props.shareInfo);
break;
case 'sms':
await shareToSms(props.shareInfo);
break;
case 'copy':
const success = await copyToClipboard(props.shareInfo);
if (success) {
showToast('URL이 복사되었습니다');
}
break;
}
};
</script>
<template>
<div class="share-popup">
<button @click="handleShare('kakao')">카카오톡</button>
<button @click="handleShare('sms')">문자</button>
<button @click="handleShare('copy')">URL 복사</button>
</div>
</template>
4. 테스트 디렉토리 구조 정리
root: tests/
children:
- name: unit/
children:
- name: hooks/
children:
- name: share.test.ts
- name: utils/
children:
- name: index.test.ts
- name: integration/
children:
- name: components/
- name: e2e/
회고
잘한 점
공유 로직을 훅으로 추출하여 재사용성을 확보했습니다. 단위 테스트를 도입하여 로직을 독립적으로 검증할 수 있게 되었고, URL 처리 방식을 통일하여 일관된 동작을 보장하게 되었습니다.
개선할 점
트위터, 페이스북 등 더 많은 공유 채널을 지원하면 활용 범위를 넓힐 수 있을 것입니다.
배운 점
비즈니스 로직을 훅으로 추출하는 패턴의 효과를 체감했습니다. 단위 테스트 작성의 중요성을 인식하게 되었고, URL 처리 시 단축 URL, 앱링크 등 다양한 케이스를 고려해야 한다는 점을 배웠습니다.