이름궁합이란, 각 글자의 자음과 모음 획수를 더해서 궁합 점수를 계산하는 놀이(?)이다
갑자기 문득 이 테스트를 만들고 싶었달까..?
+간단해 보이지만, 이런저런 기술도 배우고 경험해 볼 겸 개발을 해 보았다.
개발 환경 : React, TypeScript, NextJs
이름 궁합 테스트를 설계하면서 가장 중요한 첫 단계는 한글 글자 하나를 구성하는 초성(자음), 중성(모음), 종성(받침)의 획수를 계산하고 이를 변수화하는 작업이었다.
일일이 한글의 자음과 모음의 획수를 계산하는 건.. 무리수이기 때문에 GPT에게 도움을 받았다
const strokeCount: StrokeCount[] = [
{ text: 'ㄱ', value: 2 },
{ text: 'ㄲ', value: 4 },
{ text: 'ㄴ', value: 2 },
];
이런식으로 자음과 모음, 받침 하나하나의 획수를 개별적으로 정의한 객체와 배열을 생성하고
또 초성, 중성, 종성 각각의 글자를 배열로 분리했다. (추후 사용이 됩니다요 ..)
이름을 작성하는 인풋은 2~4글자의 한글만 입력이 가능하도록 제한하였다.
입력받은 인풋은 searchParams에 저장하여 결과 페이지에서 활용할 수 있도록 하였다.
처음에는 입력된 이름 데이터를 store에 저장하는 방식으로 구현했다.
근데 .. 결과물 링크를 공유할때, 상태 관리에 저장된 데이터는 유지되지 않는다는 문제가 있었다.
다른 사용자와 링크를 통해 결과를 공유할 방법이 없을까? 고민하다가? 문득 난 바본가? 라는 생각이 들었다. 입력된 이름을 URL의 searchParams에 저장하면 되잖아!? 이름이니까 보안상 크게 문제될 부분도 없다고 생각이 들어서 이 방법으로 구현하였다.
const handleSubmit = () => {
router.push(`/result?name1=${name1}&name2=${name2}`);
};
네 .. 그렇게 저장된 이름을 store 없이도 쉽게 불러올 수 있었고 링크 공유도 가능해졌다 .!
이름 궁합 점수를 내려면 둘의 이름 순서를 섞어야 한다
김철수 + 개발자 = 김 개 철 발 수 자
이름의 길이가 다를 경우도 고려하여 함수를 작성하였다!
예를 들어 두글자, 네글자의 이름 조합이 있을 수 있으니? 두 이름의 길이가 다르더라도 길이에 따라 유연하게 긴 이름은 남은 글자만 추가되도록 하였다.
const [nameBox, setNameBox] = useState<{ char: string; source: string }[]>(
[]
);
useEffect(() => {
function mixStrings(name1: string, name2: string) {
let mixedArr = [];
const nameArr = name1.split('');
const name2Arr = name2.split('');
const maxLength = Math.max(nameArr.length, name2Arr.length);
for (let i = 0; i < maxLength; i++) {
if (nameArr[i]) mixedArr.push({ char: nameArr[i], source: 'name1' });
if (name2Arr[i]) mixedArr.push({ char: name2Arr[i], source: 'name2' });
}
return mixedArr;
}
if (name1 && name2) {
const mixed = mixStrings(name1, name2);
setNameBox(mixed);
}
}, [name1, name2]);
입력받은 이름은 이제 분리작업이 필요하다.
김 -> ㄱ, ㅣ, ㅁ
철 -> ㅊ, ㅓ, ㄹ
이런식으로 분리를 해야 한다.
어떻게..? 분리하지????
는 유니코드 값을 이용해 초성, 중성, 종성을 분리할 수 있었다!
한글은 유니코드 체계에서 음절 단위로 정의되어 있어서 이 값을 활용할 수 있었다
이 부분은 GPT 의 힘을 좀 빌렸는데 아래와 같은 원리로 음절의 인덱스 값을 얻을 수 있다고 한다.
유니코드 값으로 음절을 분리하는 원리
1. 한글 음절의 유니코드 값에서 기준 값 U+AC00을 빼면 음절의 인덱스를 얻을 수 있습니다.
2. 이 인덱스를 통해 초성, 중성, 종성을 계산할 수 있습니다
- 초성의 인덱스 계산: 인덱스 / (21 * 28)
- 중성의 인덱스 계산: (인덱스 % (21 * 28)) / 28
- 종성의 인덱스 계산: 인덱스 % 28
- 예시:
'강'
의 유니코드 분리
'강'의 유니코드 값:U+AC15
U+AC00(가)로부터의 차이값: 0xAC15 - 0xAC00 = 21
즉, baseCode = 21
초성 계산
Math.floor(21 / (21 * 28)) = Math.floor(21 / 588) = 0
초성 인덱스: 0 → 초성 배열의 첫 번째 값: 'ㄱ'
중성 계산
(21 % 588) / 28 = 21 / 28 = 0.75 → Math.floor(0.75) = 0
중성 인덱스: 0 → 중성 배열의 첫 번째 값: 'ㅏ'
종성 계산
21 % 28 = 21
종성 인덱스: 21 → 종성 배열의 21번째 값: 'ㅇ'
이 방법을 활용하여 splitHangulAndCount
함수를 작성했다.
//하나의 글자 획수 계산 함수
function splitHangulAndCount(name: string) {
const char = name.trim();
const charCode = char.charCodeAt(0);
if (charCode >= 0xac00 && charCode <= 0xd7a3) { //한글 유니코드인지 확인 아닐경우 에러처리
const baseCode = charCode - 0xac00;// 한글 유니코드 계산
const con = Math.floor(baseCode / (21 * 28)); //초성
const vow = Math.floor((baseCode % (21 * 28)) / 28); //중성
const cod = baseCode % 28; //종성
//초성, 중성, 종성 분리 후
//만들어둔 변수에서 각 인덱스를 찾아 저장. 결과물 : [ㄱ,ㅣ,ㅁ]
const hangulArr = [consonant[con], vowel[vow], coda[cod]];
//각 획수를 계산하여 더한값 return
const countHangul: number[] = [];
//글자와 글자수배열 매칭
hangulArr.forEach((hangul) => {
const matchedHangul = strokeCount.find(
(value) => value.text === hangul
);
if (matchedHangul && matchedHangul.value !== undefined) {
//strokeCount 배열에서 해당 요소와 매칭되는 획수를 찾아 결과 배열 countHangul에 추가
countHangul.push(+matchedHangul.value);
}
});
//총 획수 합산
const count = countHangul.reduce((a, c) => a + c, 0);
return count;
} else {
setIsError(true);
return 0;
}
}
이름의 획수를 계산하여 더하는 함수까지 만들었으니..!
이제 입력된 두 이름의 조합(nameBox)을 이용해, 각 글자의 획수를 계산하고 이를 반복적으로 합산하여 최종 궁합 점수를 내는 일만 남았다!!
const [countedLines, setCountedLines] = useState<number[][]>([[]]);
useEffect(() => {
//두 숫자를 더하고 일의 자리만 반환
const sumNames = (a: number, b: number) => (a + b) % 10;
if (!nameBox || nameBox.length === 0) return;
// 첫 번째 배열 : 이름 조합(nameBox)을 기반으로 획수 계산
const firstArray = nameBox.map((name) => splitHangulAndCount(name.char));
const allResults = [firstArray]; // 중간 배열 결과를 저장할 배열
let currentArray = firstArray; //현재 계산 중인 배열
while (currentArray.length > 2) {
const newArray = [];
for (let i = 0; i < currentArray.length - 1; i++) {
newArray.push(sumNames(currentArray[i], currentArray[i + 1]));
}
allResults.push(newArray); // 새로운 배열 추가
currentArray = newArray; // 현재 배열 갱신
}
setCountedLines(allResults); // 모든 결과를 상태에 저장
}, [nameBox]);
이렇게 계산된 결과값을 콘솔에 찍어보면 이렇게 나온다
마지막 배열이 점수이겠지요~?
김철수씨와 개발자 궁합은 16점 이네요 ,, 하핫
점수 계산 로직은 완성!
이제 이미지 저장하기, 공유하기, 링크 복사하기 기능을 구현해 볼까요!
이런 웹서비스에서 이미지 저장하기 기능은 필수잖아요?
이미지를 저장하려면 캔버스로 만들어줘야 한다.
나는 html2canvas
라이브러리를 사용하였다.
html2canvas
는 브라우저에서 DOM 요소를 캡처하여 캔버스 이미지로 변환해주는 아이이다. 가볍고 커스터마이징이 쉬워서 이 라이브러리를 선택했다.
엄청 많은 시련과 고난이 있었는데 가장 큰 시련은
스타일이 망가진다는 것이었다 ..
UI 대로 나오지 않고 글씨가 조금 위로 올라가는 문제가 있었다..
찾아보니 이런 현상이 일어나는 경우가 엄청 많았다 라이브러리 자체의 문제인가 봄 ㅜ
다행히 커스터마이징이 가능해서onclone
을 통해 문제를 해결했다
const createCanvas = async (): Promise<HTMLCanvasElement | null> => {
const contentImage = resultRef.current;
const onlyContent = contentsRef.current;
if (!contentImage || !onlyContent) {
return null;
}
try {
const { height } = onlyContent.getBoundingClientRect();
const canvas = await html2canvas(contentImage, {
useCORS: true, //외부 URL에서 이미지를 가져올 때 오류 방지
scale: 2, //해상도 향상
height: Math.ceil(height + 100), //캡처 영역 조정
ignoreElements: (element) => element.id === 'ignore-download', //광고 부분 제외하고 저장되도록
onclone: (el) => { //스타일 조정
const boxText = el.querySelectorAll('#box');
const boxStyle = (element: Element) => {
if (element instanceof HTMLElement) {
element.style.paddingBottom = '20px';
element.style.display = 'inline-block';
}
};
boxText.forEach(boxStyle);
},
});
return canvas;
} catch (error) {
console.error('캔버스 생성 중 오류:', error);
return null;
}
};
캔버스를 이미지로 저장하려면 blob으로 변환해야 한다.
Blob이란?
Blob(Binary Large Object)은 파일 객체의 데이터 형식으로, 이미지 데이터를 바이너리 형태로 표현한다. 브라우저에서 파일로 저장하거나 서버에 업로드하는 데 사용된다.
const generateBlob = async (
canvas: HTMLCanvasElement
): Promise<Blob | null> => {
return new Promise((resolve) => {
canvas.toBlob((blob) => {
if (!blob) {
console.error('Blob 생성 실패');
resolve(null);
} else {
resolve(blob);
}
}, 'image/png');
});
};
file-saver
라이브러리를 사용하여 저장 기능 구현
이 라이브러리를 통해 간단하게 mobile, desktop 저장 기능을 구현할 수 있다!
왠만한 브라우저에서 다 동작한다고 하여 이 라이브러리를 사용했다.
주의할 점은 카카오 인앱 브라우저에서의 저장이다.
카카오 브라우저에서는 다운로드 링크 클릭 방식을 사용해야 하므로 별도 로직 작성이 필요하다!
window.navigator.userAgent
를 활용하여 어떤 환경에서 접속했는지 확인이 가능하다! 이 기능을 통해 카카오인지 확인함
const handleDownload = async () => {
setIsDownloading(true);
const canvas = await createCanvas(); //캔버스 생성
if (!canvas) {
setIsDownloading(false);
errorToast();
return;
}
const blob = await generateBlob(canvas); //캔버스 Blob 변환
if (!blob) {
setIsDownloading(false);
errorToast();
return;
}
//카카오 브라우저인지 체크
const isKakaoBrowser = /kakao/i.test(
window.navigator.userAgent.toLowerCase()
);
if (isKakaoBrowser) {
const dataUrl = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.href = dataUrl;
link.download = `${fileName}.png`;
link.setAttribute('target', '_blank'); //이게 필요함!!! 새 창을 띄워야 한다
link.click();
URL.revokeObjectURL(dataUrl); //메모리 해제!
} else {
saveAs(blob, `${fileName}.png`);
}
setIsDownloading(false);
};
처음에는 공유하기 기능을 이미지를 공유하는 기능을 생각하고 구현했다.
그런데 이 기능이 동작하지 않는 브라우저가 너무 많은 것임.. (크롬, 인스타 인앱, 데스크탑..)
그래서 그냥 navigator.share
을 통해 링크를 공유하는 방법으로 기능을 수정했다..
(인스타에서 오류나는 것은 용납이 안돼요 .. 왜냐면 인스타를 통해 많이 유입될 것이기 때문..)
const handleShare = async () => {
setIsSharing(true);
const url = `${siteUrl}/result?name1=${name1}&name2=${name2}`;
if (!navigator.share) {
if (window.navigator.clipboard) {
handleCopyUrl(url);
} else {
toast('공유하기 기능을 사용할 수 없어요 🥲');
}
setIsSharing(false);
return;
}
try {
await navigator.share({
title: fileName,
url,
});
} catch (error) {
console.error(error);
}
setIsSharing(false);
};
navigator.share
이 되지 않는 환경도 있기에 그런 경우에는 공유가 아닌 링크를 복사할 수 있도록 처리하였다. 근데 만약? window.navigator.clipboard
도 안된다면..? toast로 알림을 띄워주도록 했다 .. ㅠ 근데 거의 그런 경우는 없을 것으로 보임
광고는 카카오 애드핏으로 붙였다!
사실 광고 붙인 서비스를 하나 만들어 보고 싶어서 이 프로젝트를 한 것이기도 해서 ㅎ..ㅎㅎ
카카오 애드핏이 수익은 적지만 승인도 빠르고 UI적으로 깔끔해 보여서 선택했다
진짜 승인이 하루만에 났다죠?
링크 공유시 저장되는 주소
나의 기대 : https://name-compatibility-test.vercel.app/result?name1=김철수&name2=개발자
현실 : https://name-compatibility-test.vercel.app/result?name1=%EA%B9%80%EC%B2%A0%EC%88%98&name2=%EA%B0%9C%EB%B0%9C%EC%9E%90
죽여줘 ..
한글이 포함된 쿼리 파라미터가 URL 인코딩되어 브라우저에 저장된다는 것을 미처 생각하지 못한 나란 인간
하지만 문제 해결은 간단하게 했다 .
디코딩~!
name1, name2의 searchParams 값이 인코딩 된 값인지 확인 후
인코딩 된 값이면 다시 디코딩 한 값으로 변환해주는 것이다..?
꽤나 간단한 해결 방법이죠..? 많은 리서치를 해봤는데 내가 생각해낸 방법이 제일.,. 최적의 방법으로 보여서 이렇게 해결했다!
useEffect(() => {
const isEncoded = (str: string) => {
try {
return str !== decodeURIComponent(str); // 디코딩 후 값이 다르면 인코딩된 상태
} catch (e) {
return false; // decodeURIComponent가 실패하면 인코딩된 상태가 아님
}
};
if (isEncoded(name1) || isEncoded(name2)) {
const decodedUrl = `/result?name1=${decodeURIComponent(name1!)}&name2=${decodeURIComponent(name2!)}`;
router.push(decodedUrl);
}
}, [name1, name2, router]);
이 프로젝트는 크롬, 사파리, 카카오인앱, 인스타인앱, 안드로이드 뷰
모든 브라우저를 대응하였다 ..
기술적인 건 당연하고, 스타일적으로 다른 부분은 용납이 안되어서 ..
이모지도 이미지로 넣어버렸다,, (안드로이드 이모지 너무 못생김)
특히 캔버스로 이미지 캡처할 때!!
글씨 위치가 달라지는 것 때문에 넘......... 스트레스 받았는데
파일을 수십번 수백번 저장하고 테스트 해가면서 해결했을때으ㅣ 희열 .. 잊지 못한다ㅠ
직접 만든 디자인 시스템 컴포넌트(버튼, 팝오버)도 사용하면서 버그도 찾고 ... ㅠ 살려줘
꽤나 만족스러운 결과물을 내서 좀 뿌듯하다~!
+) GA 달아서 유저 데이터도 확인하는 중 .. 후후 많이 써봐주세요~!