이전 노션 프로젝트에서는, 이모지 기능을 위해 picmo 라는 라이브러리를 사용했다. 아래와 같이 생겼다!
기능이 이것 저것 많아서 편하긴 하지만 디폴트로 작성되어 있는 css 가 노션이랑 맞지 않기도 하고, 이모지 데이터 베이스가 있어서 가져오는 거라면 충분히 직접 구현할 수 있지 않을까? 하는 생각에 api 를 찾아서 구현해보기로 결심했다. (마침 지금은 deprecated 되어서 더이상 업데이트가 되지 않는다고 한다~)
api 를 사용해서 이모지를 불러오는 기능을 중점적으로 기록해보고자 한다!
완전 잘 되어있는 api 가 있었다 👍
https://emoji-api.com/
게다가 api 명세도 친절하게 되어있어서, 이모지 기능을 바로 구상할 수 있었다.
이건 실제 노션에서 볼 수 있는 이모지 창이다. 카테고리 별로 나누어서 이모지들을 볼 수 있으며, 검색 기능과 이모지 제거 기능이 붙어있다.
이제 emoji-api의 명세를 살펴 보자 ! 사용할 수 있는 기능은 다양하다.
노션 이모지 기능을 따라하려면, 굵게 표시한 기능들을 활용하면 된다.
일단 검색은 살짝 뒤로 미뤄두고, 카테고리 목록을 불러온 후 그에 따른 이모지들을 불러오는 것을 목표로 정했다.
get categories (
/categories
)
[
{
"slug": "smileys-emotion",
"subCategories": [
"face-smiling",
"face-affection",
"face-tongue",
"face-hand",
// ...
]
},
{
"slug": "people-body",
"subCategories": [
"hand-fingers-open",
"hand-fingers-partial",
"hand-single-finger",
"hand-fingers-closed",
// ...
]
},
]
get emojis in a category (
/categories/카테고리이름
)
[
{
"slug": "globe-showing-europe-africa",
"character": "\ud83c\udf0d",
"unicodeName": "globe showing Europe-Africa",
"codePoint": "1F30D",
"group": "travel-places",
"subGroup": "place-map"
},
{
"slug": "globe-showing-americas",
"character": "\ud83c\udf0e",
"unicodeName": "globe showing Americas",
"codePoint": "1F30E",
"group": "travel-places",
"subGroup": "place-map"
},
]
/categories
로 카테고리 목록을 요청하고, slug 프로퍼티에 들어 있는 카테고리 이름을 사용하여 /categories/travel-places
와 같이 그 안의 이모지들을 가져오면 될 것이다.
이전에 구현 해놓은 ApiClient 와 makeRequest 를 활용해서 쉽게 구현할 수 있었다. 이모지 전용 apiClient 인스턴스를 하나 더 만들기만 하면 된다!
const emojiApiClient = createApiClient(
EMOJI_BASE_URL,
{
headers: {
"Content-Type": "application/json",
},
},
);
// access_key 넣는 부분은 차후에 apiClient 안으로 숨길 예정!
const emojiApi = {
getCategories: async () =>
await emojiApiClient.get(
`/categories?access_key=${EMOJI_KEY}`
),
getEmojiByCategory: async (category: string) =>
await emojiApiClient.get(
`/categories/${category}?access_key=${EMOJI_KEY}`
),
};
service 레이어에서는, 컴포넌트에서 바로 호출할 수 있는 getEmojiCategory
를 작성했다. 이모지 컴포넌트가 마운트되면 이 친구가 호출되고, 카테고리 불러오기 성공 시 Store 의 이모지 데이터를 갱신해준다.
카테고리 이름은 slug 프로퍼티에 들어있기 때문에, 필요한 데이터인 slug 만 꺼내서 사용할 수 있도록 select 기능을 사용하였다.
[ {slug: ‘people’, … }, {slug: ‘place’, …} ]
이러한 친구를
[’people’, ‘place’, …]
이렇게 사용하기 위함이다.
// store.ts
export const emojiData = store.addData<EmojiData>({
key: "emoji",
default: {
categories: [],
emojiMap: {}, // 카테고리 별 이모지 배열이 담긴 객체
},
});
// service/EmojiService.ts
const getEmojiCategory = () => {
const setEmojiData = store.setData<EmojiData>(emojiData);
makeRequest<string[], EmojiCategories>(() => emojiApi.getCategories(), {
select: (data) => {
return data.map(({ slug }) => slug);
},
onSuccess: (data) => {
setEmojiData((prev) => ({
...prev,
categories: data,
}));
},
});
};
카테고리 목록을 불러오면, 카테고리 별로 이모지들을 불러와야 한다. 이 말은 즉슨, 카테고리가 10개라면 10번의 서버 요청을 날려야 한다는 것이다.
방법 1) 병렬 요청하기
현재는 무한스크롤로 구현하였기 때문에 사용하지 않는 메소드이긴 하지만.. 여러개의 요청을 병렬로 처리하는 메소드가 필요하기 때문에 makeRequestAll
라는 메소드를 구현하기도 했다.
(tanstack-query 의 useQueries 를 생각하며 작성한..)
// fetch 함수를 배열로 받아 병렬로 요청을 처리하는 함수
const makeRequestAll = async <ReturnType = any, DataType = any>(
fetchFns: (() => Promise<Response>)[],
requestOptions?: RequestOptions<ReturnType, DataType>
): Promise<Result<ReturnType>> => {
const { onSuccess, onError, onStart, onEnd, select } = requestOptions ?? {};
if (onStart) onStart();
try {
const responses = await Promise.all(fetchFns.map((fetchFn) => fetchFn()));
// 병렬 요청!
const datas = await Promise.all(
responses.map((response) => {
if (!response.ok) {
throw new Error(
`API Error ${response.status} : ${response.statusText}`
);
}
return response.json();
})
);
const resultData = select ? select(datas) : datas;
if (onSuccess) onSuccess(resultData);
return { data: resultData, isSuccess: true, isError: false };
} catch (error: any) {
if (onError) onError(error);
return { data: undefined, isSuccess: false, isError: true };
} finally {
onEnd && onEnd();
}
};
export default makeRequestAll;
makeRequestAll
로 모든 이모지들을 불러오는 로직은 getEmojiCategory 의 onSuccess 에 연결하면 된다. (카테고리 불러오기 성공 → 전체 이모지 불러오기)
카테고리 중 ‘people-body’ 로 요청할 때가 최대 4초로 가장 오래 걸렸고, 전체 요청 시간도 그 정도 걸렸다. 따라서 병렬적으로 요청하는 것은 성공했다고 볼 수 있다.
하지만 이렇게 되면 찾으려는 이모지와는 별개로, 사용자는 무조건 4초는 빈 화면을 보고 있어야한다.
열심히 구현한 것 치고 사용성이 많이 떨어진다는 생각이 들었다.
방법 2) 무한 스크롤
무한 스크롤 방식으로 이모지를 더 찾아볼 지를 사용자에게 맡기는 방편이 낫다는 생각이 들었다.
카테고리가 정확히 나누어져 있기 때문에, 이모지를 요청할 때 마다 다음 카테고리로 넘어가는 방식이면 무한 스크롤을 구현할 수 있다. (커서 기반 페이지네이션 느낌!)
사실 노션 이모지 기능은 무한 스크롤로 되어 있다 하하 🙃
cursor
: 몇 번째 카테고리로 요청해야 하는 지 포인터역할done
: 모든 이모지를 다 불러왔는지scrollTop
: 이모지창 리렌더링 시 스크롤이 이전 위치에 그대로 있게 하기 위한 값// store.ts
export const infiniteEmojiData = store.addData<InfiniteEmojiData>({
key: "infinite-emoji",
default: {
cursor: 0,
done: false,
scrollTop: 0,
},
});
가장 중요한, intersector element와 콜백 함수를 받아서 해당 요소가 관찰될 때 마다 콜백을 실행시켜주는 observeIntersector
를 작성해 주었다.
export const observeIntersector = (
intersector: HTMLElement,
callback: VoidFunction,
) => {
if (!intersector) return;
const onIntersect = (
entries: IntersectionObserverEntry[],
observer: IntersectionObserver,
) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
callback();
observer.unobserve(intersector);
}
});
};
const observer = new IntersectionObserver(onIntersect, {
threshold: 0.5,
});
observer.observe(intersector);
};
스크롤을 쫘라락 내려서 맨 아래의 intersector 가 화면에 등장하면, 이모지들을 불러오는 것이다. 이모지들이 쫘라락 등장하고.. 또 스크롤을 내리면 다시 남은 이모지들을 불러오고..! 그래서 무한스크롤이다.
intersector 영역이 관찰될 때 마다 실행되어야 하는 로직을 코드로 표현하면 아래와 같다.
// service/EmojiService.ts
const getInfiniteEmoji = () => {
const [{ cursor }, setInfiniteEmojiData] =
store.useData<InfiniteEmojiData>(infiniteEmojiData);
const [{ categories }, setEmojiData] = store.useData<EmojiData>(emojiData);
makeRequest<EmojiByCategory, EmojiList>(
() => emojiApi.getEmojiByCategory(categories[cursor]),
{
// 이모지 리스트 가공
select: (data) => ({
[categories[cursor]]: data.map(({ character }) => character),
}),
// 성공 시 store 데이터 갱신
onSuccess: (data) => {
// 기존 EmojiData 에 새로운 이모지들 합쳐주기
setEmojiData((prev) => ({
...prev,
emojiMap: { ...prev.emojiMap, ...data },
}));
// 무한스크롤 관련 데이터 갱신시키기
setInfiniteEmojiData((prev) => ({
...prev,
cursor: prev.cursor + 1,
done: cursor === categories.length - 1,
}));
},
},
);
};
{ ‘카테고리이름’: [😃, 😄, 🥹, …] }
이런 식으로 가공해야 한다.마지막으로 모든 것을 Emoji 컴포넌트에 연결시켜주면 된다.
// components/Emoji.ts
class Emoji extends Component<EmojiProps>{
// 생략
// 렌더링될 때 마다 실행
rendered() {
// 기존 스크롤 위치 불러오기 + 스크롤 이전과 똑같이 내려주기
const [{ scrollTop }, setInfiniteEmojiData] =
store.useData<InfiniteEmojiData>(infiniteEmojiData);
const rootEl = this.findElement<HTMLElement>(".emoji");
rootEl.scrollTo({ top: scrollTop });
// intersector 요소 찾아서 observeIntersector 실행시키기
const intersectorEl = this.findElement<HTMLDivElement>(".intersector");
// intersectorEl 이 관찰될 때 마다,
observeIntersector(intersectorEl, () => {
// 새로운 이모지 불러오기
emoji.getInfiniteEmoji();
// 내려가있는 스크롤 위치 저장
setInfiniteEmojiData((prev) => ({
...prev,
scrollTop: rootEl.scrollTop,
}));
});
}
}
자연스럽게 하기 위해, 다음 차례에 가져와야 하는 카테고리의 제목을 intersector 로 설정해주었다. intersector 가 화면에 보일 때 마다 새로운 이모지들을 잘 불러오는 모습이다!
제공받은 노션 api 에는 title, content 필드 두가지가 있다. 즉, emoji 를 위한 필드는 없기 때문에 title 필드를 그대로 활용해야 한다.
이 부분을 해결하기 위해, split 과 join 을 시켜주는 유틸 함수를 구현해 주었다.
const seperator = import.meta.env.EMOJI_SEPERATOR;
// 서버로 보낼 때 거쳐야 하는 함수
export const joinTitleWithEmoji = (emoji: string, title: string) =>
[emoji, title].join(seperator);
// 클라이언트에서 보여줄 때 거쳐야 하는 함수
export const splitTitleWithEmoji = (title: string) => {
const [emojiValue, titleValue] = title.split(seperator);
return [emojiValue || "", titleValue || ""] as const;
};
사실 상당히 간단한 방법이다.
서버로 보낼 때는 joinTitleWithEmoji
함수를 통해 이모지와 제목을 합쳐서 보내고, 서버에서 받은 데이터를 화면에 보여줄 때는 splitTitleWithEmoji
를 통해 나누어서 보여주면 된다.
class Editor extends Component<EditorProps> {
editorValue() {
const titleEl = this.findElement<HTMLTextAreaElement>("#title");
const contentEl = this.findElement<HTMLDivElement>("#rich-editor");
const emojiEl = this.findElement<HTMLButtonElement>("#emoji");
const isEmojiEmpty = emojiEl.classList.contains("empty");
return {
title: joinTitleWithEmoji(
isEmojiEmpty ? "" : emojiEl.innerText,
titleEl.value,
),
content: contentEl.innerHTML || "",
};
}
// 생략
}
editorValue 는 서버로 put 요청을 보내기 위해, title 필드와 content 필드가 있는 requestBody 를 완성하는 메소드이다.
이런 식으로 joinTitleWithEmoji 를 통해 title 필드를 완성해주는 것이다.
컴포넌트는 이런식으로 분리해 주었다.
EmojiInput
은 이모지 입력 시 실행되어야 하는 onInput 과, 현재 이모지 값인 value 를 prop 으로 받는다.
Emoji
는 무한스크롤 기능이 붙어 있는 이모지 목록 컴포넌트이다. 클릭 이벤트 발생 시 실행되어야 하는 onSelect 와 onBlur 를 prop 으로 받는다.
부가 기능으로, 세션 스토리지를 활용해서 최근 사용한 이모지를 저장할 수 있도록 했다.
다시 건들게 된다면, 이모지를 검색하는 기능을 추가할 예정이다!
멋쪄요 😍🔥