시간을 쪼개서 조금 빠르게 작업을 진행하였다.
Alias 설정
본격적으로 웹 퍼블리싱에 들어가기에 앞서 import 할 때 보기 편하게 하기 위해 alias를 설정해 주었다.
먼저 vite-tsconfig-paths를 설치한다
yarn add vite-tsconfig-paths
어려운 건 없고 tsconfig.json paths 설정을 vite가 인식할 수 있도록 하는 모듈이다.
vite.config.ts에 모듈을 추가해 준다.
// vite.config.ts
export default defineConfig({
plugins: [
...
tsconfigPaths(),
...
],
})
그리고 ts.config.app.json 에 alias를 설정하면 끝난다.
나의 경우에는 FSD구조에 맞추어 alias를 설정하였다.
// tsconfig.app.json
{
"compilerOptions": {
...
/* Aliases */
"paths": {
"@app/*": ["./src/app/*"],
"@pages/*": ["./src/pages/*"],
"@features/*": ["./src/features/*"],
"@shared/*": ["./src/shared/*"],
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
이후 프로젝트 전반적으로 리팩토링 작업을 거치어 파일들을 alias에 맞추어 import 하였다.

웹 1차
아이템, 리스트 퍼블리싱
퍼블리싱 부분은 크게 설명할 것이 없어 간단히 코드만 띄우고 특이점만 설명하겠다.
아이템
만들어둔 useTrans를 유틸을 활용하여 국제화 적용 테스트를 해보았다.
div props를 기반으로 두어 컴포넌트로서 유동적으로 활용할 수 있게 하였다.
// src\features\magazine\ui\magazine-item.tsx
import type { ParserData } from "@shared/model/parser";
import { useTrans } from "@shared/lib/utils";
import { DateTime } from "luxon";
interface MagazineItemProps extends React.HTMLAttributes<HTMLDivElement> {
data: ParserData;
}
export const MagazineItem = ({ data, ...props }: MagazineItemProps) => {
const { trans } = useTrans();
return (
<div {...props}>
<p className="text-xs text-gray-500">{data.site.name}</p>
<h2 className="text-xl font-bold">{data.title}</h2>
<p className="text-sm text-gray-700">{trans("magazine.createdAt", "작성일")} {DateTime.fromISO(data.createdAt).toFormat("yyyy-MM-dd HH:mm")}</p>
{data.thumbnail && <img src={data.thumbnail} alt={data.title} className="mt-2 w-32 h-32 object-cover" />}
</div>
);
}
// src\locales\ko\common.json
{
"magazine": {
"createdAt": "작성일"
}
}
리스트
항목 별로 경계선을 만들어주는 tailwind 속성의 divide를 활용하였다
// src\pages\magazine\ui\initial\magazine-list.tsx
const data = useDataContext();
...
<div className="flex flex-col divide-y-1 divide-gray-200">
{
data?.map((item, index) => (
<MagazineItem
key={index}
data={item}
className="cursor-pointer py-15 px-10"
/>
))
}
</div>
...
결과

패널 구현
디테일 페이지를 대신할 사이드 패널을 구현하였다.
먼저 다양한 애니메이션을 시도하기 위해 tailwindcss-animated 설치
yarn add tailwindcss-animated
tailwind v4부터는 이렇게 모듈을 설치하면 index.css에 import를 해줘야 한다. (기존 config 파일이 사라졌기 때문에)
/* src\app\index.css */
...
@import "tailwindcss-animated";
...
먼저 추후 다양한 패널이 필요할 것을 고려하여 base용 panel컴포넌트를 만들었다.
좌, 우, 위, 아래에서 나타난다고 가정해서 props를 설정하였다.
타 요소의 영향을 피하기 위해 createPortal로 감쌌고 tailwind-animated의 fade속성을 적극 활용하여 시각적 효과를 주었다.
createPortal의 정확한 역할은 추후 다른 포스트로 다뤄보겠다.
// src\shared\ui\panel\panel.tsx
import { type PropsWithChildren } from "react";
import { createPortal } from "react-dom";
import { twMerge } from "tailwind-merge";
interface PanelProps extends PropsWithChildren{
isOpen: boolean;
position: "left" | "right" | "top" | "bottom";
className?: string;
}
export const Panel = ({ isOpen, position, children, className }: PanelProps) => {
if (!isOpen) return null;
return (
createPortal(
<div className={
twMerge(
"fixed z-10",
position === "left" && "left-0 top-0 animate-fade-right",
position === "right" && "right-0 top-0 animate-fade-left",
position === "top" && "top-0 left-0 animate-fade-down",
position === "bottom" && "bottom-0 left-0 animate-fade-up",
className
)
}>
{children}
</div>,
document.body
)
);
}
해당 컴포넌트를 활용해서 데이터를 띄어줄 magazine-panel도 제작하였다.
rss파싱으로 가져온 content는 대게 HTML형식이 때문에 dangerouslySetInnerHTML을 사용하여 content를 띄웠다. (뷰어로 지칭하겠다)
mount/unmout 없이 data 바뀔 시 스크롤 초기화를 위해 useEffect로 변화를 감지하여 scrollTop = 0을 설정
// src\features\magazine\ui\magazine-panel.tsx
import { XIcon } from "@shared/assets";
import type { ParserData } from "@shared/model/parser";
import { Panel } from "@shared/ui/panel"
import { useEffect, useRef } from "react";
export interface MagazinePanelProps {
data: ParserData | null;
isOpen: boolean;
onClose: () => void;
}
export const MagazinePanel = ({ data, isOpen, onClose }: MagazinePanelProps) => {
const viewerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (viewerRef.current) {
viewerRef.current.scrollTop = 0;
}
}, [data]);
if (!data) return null;
return (
<Panel
isOpen={isOpen}
position="right"
className="w-full h-full max-w-640 p-10"
>
<div className="rounded-xl shadow-xl flex flex-col gap-20 bg-white p-15 h-full">
<div className="flex flex-row justify-between">
<div className="text-xl">{data?.title}</div>
<button className="cursor-pointer" onClick={onClose}>
<XIcon />
</button>
</div>
<div
ref={viewerRef}
className="overflow-y-auto whitespace-pre-wrap viewer"
dangerouslySetInnerHTML={ { __html: data?.content ?? "" } }
/>
</div>
</Panel>
)
}
내부 뷰어에 viewer 클래스 네임을 줘서 따로 html태그들에 대한 스타일을 잡았다. (개취이기에 따로 코드는 x)
아까 만들어둔 리스트에 간단히 로직을 추가하여 아이템 클릭 시 패널이 뜨도록 하였다.
// src\pages\magazine\ui\initial\magazine-list.tsx
import { MagazineItem } from "@features/magazine";
import { MagazinePanel } from "@features/magazine/ui/magazine-panel";
import { useDataContext } from "@shared/lib/data";
import type { ParserData } from "@shared/model/parser";
import { useCallback, useState } from "react";
export const MagazineList = () => {
const data = useDataContext();
const [selectedData, setSelectedData] = useState<ParserData | null>(null);
const handleClickItem = useCallback((data: ParserData) => {
setSelectedData(data);
}, []);
const handleClosePanel = useCallback(() => {
setSelectedData(null);
}, []);
return (
<>
<div className="flex flex-col divide-y-1 divide-gray-200">
{
data?.map((item, index) => (
<MagazineItem
key={index}
data={item}
onClick={() => handleClickItem(item)}
className="cursor-pointer py-15 px-10"
/>
))
}
</div>
<MagazinePanel isOpen={!!selectedData} data={selectedData} onClose={handleClosePanel} />
</>
);
}
결과

구글 번역
해당 프로젝트의 고도화 목표 중 하나인 구글 번역을 추가해보려 한다.
구글 번역이 새로운 api는 유료 버전이라 구버전의 위젯 기능을 사용하여 구현하여야 해서 꽤나 불친절한 느낌을 받았다. (거의 십몇 년 된 것 같은 느낌...)
먼저 코드는 이렇게 작성했다.
// src\shared\ui\google-translate\google-translate-provider.tsx
...
declare global {
interface Window {
googleTranslateElementInit?: () => void;
google?: {
translate: {
TranslateElement: new (options: object, elementId: string) => any;
};
};
}
}
...
const [isEnabled, setIsEnabled] = useState(false);
const toggleTranslate = useCallback(() => setIsEnabled(prev => !prev), []);
useEffect(() => {
if (!isEnabled) return;
const langCode = i18next.language.split("-")[0];
window.googleTranslateElementInit = () => {
if (window.google?.translate) {
new window.google.translate.TranslateElement(
{
autoDisplay: true
},
"google_translate_element"
);
}
};
const script = document.createElement("script");
script.src = "//translate.google.com/translate_a/element.js?cb=googleTranslateElementInit";
script.async = true;
document.body.appendChild(script);
const handleChangeLanguage = (language = "") => {
const select = document.querySelector('select.goog-te-combo') as HTMLSelectElement | null;
if (select) {
select.value = language;
select.dispatchEvent(new Event('change'));
}
}
setTimeout(() => {
handleChangeLanguage(langCode);
}, 1000)
return () => {
window.location.reload();
};
}, [isEnabled]);
...
번역 부분은 방식만 봐도 구식 느낌이다.
- isEnable 사용여부 분기 처리
- true일 경우 init (초기 설정 파일) 작성
ㄴTranslateElement 부분에 language 설정하는 부분
(includeLanguages: 자체 위젯에서 사용할 언어들, pageLanguage: 현재 페이지의 언어)
이 있는데 나는 국제화를 적용할 거고 따로 위젯을 사용한 컨트롤을 하지 않을 거 기에 설정하지는 않았다. - script를 body에 추가
- 위젯의 dom속성을 활용하여 번역할 language를 설정 - 불필요한 ui요소를 만들기 싫어서 i18n의 현재 언어로 고정하였다.
ㄴscript적용을 기다려야 해서 setTimeout을 설정하여 1초 후에 적용시켰다. - 만약 isEnable false -> return 될 경우 세로고침으로 해당 번역 초기화
ㄴ사실 여기서 좀 골머리가 썩었던 게 요소들을 제거해서 초기화하는 방식을 노렸으나 정상 동작하지 않았다.
특정 요소들을 날려도 계속 뭔가 남아서 번역 작업이 이루어지거나 console 에러가 발생하여 일단 세로고침하는 형식으로 진행... 추후 다른 방법을 찾으면 해당 방식으로 적용해 보겠다.
그리고 해당 구글 번역 위젯의 쓸데없는 ui를 날리려면 index.css에서 설정을 해줘야 한다.
/* src\app\index.css */
...
@layer base {
body {
top: 0 !important;
}
#google_translate_element,
.goog-te-banner-frame,
.goog-te-gadget,
.goog-te-menu-value,
.skiptranslate,
.VIpgJd-ZVi9od-aZ2wEe-wOHMyf {
display: none !important;
}
}
...
위에 위젯 띄우면서 body에 강제로 top을 줘버리기에 important로 해서 top: 0을 줘야 한다.
해당 번역 로직은 useContext/Provider를 활용하여 전역으로 적용하였다.
사실 이것도 패널 부분만 번역하려 했는데 아무리 영역 제한을 특정 doc에만 걸어도 전체를 번역하기에 이렇게 전역으로 관리할 수 밖에 없었다.
useContext/Provider 관련해서는 이전 챕터에서 다뤘기에 따로 다루지는 않겠다.
결과
상단에 토글 버튼을 만들어 간단히 영어 포스트에 번역을 적용해 보았다.

구글 번역 부분은 보강이 좀 더 필요하다고 생각한다... 열심히 찾아보면 세로고침 없이 해제하는 법이 있을 거라고 생각이 든다.
구글 번역 개선 : https://inho-m.tistory.com/12
FE magazine 개발기 #4.1 - 구글 번역 개선
찾아보다가 세로고침 없이 구글 번역 복원로직을 발견했다!https://stackoverflow.com/questions/16281414/google-translator-how-to-manually-restore-original-language-with-javascript 혼자 테스트 할때는 iframe 내부 코드를 건
inho-m.tistory.com
#5 전에 #4. 5에서 GitHub Pages를 사용한 배포를 다뤄보려고 한다.
브랜치 #4
'개발 > 리액트' 카테고리의 다른 글
| FE Trend 개발기 #4.1 - 구글 번역 개선 (2) | 2025.07.18 |
|---|---|
| FE Trend 개발기 #4.5 - 배포하기 (with.GitHub Pages) (2) | 2025.07.18 |
| FE Trend 개발기 #3 - RSS Parser 수난기 (with.GitHub Action) (0) | 2025.07.16 |
| FE Trend 개발기 #2 - 목표 구체화, 기본 설정 (1) | 2025.07.11 |
| FE Trend 개발기 #1 - 시작, GitHub API(Octokit) (2) | 2025.07.10 |