[ Introduction ]
Blog
- Critical CSSNextJstailwind v4.0
[블로그 소개] 프론트 엔드 - 성능 최적화
[블로그 소개] 프론트엔드 방문해주셔서 감사합니다. 1번째 글 이후 블로그의 마음에 안드는 부분 수정을 하느라 2번째 글 쓰기까지 많은 시간이 걸린 것 같습니다. 이번 글에서는 저가 어떤 방식들로 블로그를 최적화를 했는지 소개하려고 합니다. 우선 글을 읽기 앞서 함께(?) 축하할 일은 최적화 결과 Lighthouse 4가지 카테고리에서 모두 100점을 달성했다는 것입니다. 하하 이번 글에서 소개할 것은 성능에 영향을 끼치는 원인을 어떻게 찾았는지, 그리고 어떻게 최적화 했는지 소개드릴려고 합니다. 여기에 더해 Lighthouse로 점수로 측정되지는 않지만, [네트워크가 오류날 정도로 느린 상황]이나 [새로고침 상황에서 하얀색 깜박임으로 인한 불편한 상황]을 개선한 과정도 소개드리겠습니다. 우선 아래 사진은 최적화를 완전히 마치고 홈화면을 Lighthouse로 성능, 접근성, 권장사항, 검색엔진 4가지 카테고리의 점수를 측정한 결과들입니다. Lighthouse는 크롬 개발자도구의 Lighthouse탭에서 누구나 성능측정을 쉽게 할 수 있도록 만든 툴입니다. Lighthouse 점수 측정시 주의할 점은 사용자 네트워크, 컴퓨터, 브라우저 환경에 따라 다른 결과를 보여줄 수 있다는 점입니다. 대표적인 예로 ThirdParty Cookie를 브라우저에서 차단하지 않았거나, 네트워크가 느리면 성능 측정 결과가 달라집니다. 성능 측정할 때 뿐만 아니라 평소에도 브라우저 설정으로 써드파티 Cookie는 차단하시길 추천드립니다. Home 화면(어드민 로그인) Home 화면(일반유저 로그인) Home 화면(비 로그인) 어드민 계정은 프로필 버튼 개수가 달라 리플로우가 발생하여 CLS 0.005의 수치를 갖음. 일반 계정은 의도한 버튼 개수가 출력되어 리플로우 발생없이 CLS 0의 수치를 갖음. 비로그인시 third party 라이브러리로 에러 메시지 출력되어 권장사항 점수가 낮아짐. 성능 저하 원인을 어떻게 파악했고 이를 바탕으로 LCP, FCP, CLS를 어떻게 개선했는지 소개드리겠습니다. 우선 각 용어에 대해 간단 설명과 최적화 전 YoonCarrot 사이트 성능 측정 결과를 보여드리겠습니다. LCP는 Largest Contentful Paint의 약자로 가장 용량이 큰 컨텐츠 표시 시점. FCP는 First Contentful Paint의 약자로 최초 화면에 렌더링 시작 할 때까지 시간. CLS는 Cumulative Layout Shift의 약자로 웹 페이지 출력 후 Layout Shift 발생 정도. 성능 측정 결과 진단된 원인 위 Lighthouse 성능 보고서를 확인하면 FCP, LCP, TBT, CLS같은 지표와 함께, 예상되는 원인들까지 진단해줍니다. 만약 지표의 의미를 안다면 LayoutShift가 일어나고 있거나 렌더링을 Blocking 시간이 존재한다는 것을 금방 알아낼 수 있습니다. 지표의 의미를 몰라도 진단된 원인들과 해결 방법을 확인하고 성능 개선이 가능합니다. 하지만 위 툴 만으로는 자세한 원인 파악하기 힘들 수 있습니다. 그래서 브라우저 성능 분석 툴을 쓰기도 하는데요. 다음 사진은 브라우저 개발자도구에 있는 툴을 이용하여 분석한 사진입니다. 프레임과 시간 단위로 어떠한 일들이 일어났는지 확인할 수 있습니다. 성능 분석 도구로 분석을 할때는 FCP 74ms, DCL106ms, LCP 171ms가 나왔습니다. Lighthouse와 결과가 많이 다르죠?? 네 정상입니다. Lighthouse 결과 사진은 사실 자료를 안 남겨둬 과거의 사진이라 점수가 낮습니다. 그래도 쭉 이어서 가보겠습니다. 성능 분석 도구로 분석된 자료를 간단하게 분석해 보겠습니다. 시각적으로 볼 수 있는 프레임 자료부터 보면 [빈 프레임 2번 => 이미지 렌더링 없는 프레임 2번 => LCP까지 완료된 프레임] 이 과정으로 5번째 프레임만에게 보여줄만한 화면이 나왔습니다. 여기서 확인이 필요해 보이는 부분은 FCP 결과가 빈화면이고, 왜 LCP까지 2번 프레임을 그렸을까 같습니다. (네트워크 상황 등에 따라 빈화면이나 이미지 없는 프레임이 1번으로 바뀔 수도 있습니다.) 그래프를 보면 중간쯤에 분홍색 선이 보입니다. 저 선은 보통 Layout Shift가 여러 번 일어나면 생깁니다. 확인해보니 CookirRunt Font가 적용되고 일부 컴포넌트의 크기가 바뀌는 것을 확인했습니다. 그리고 GoogleLogin 버튼이 생길때도 Layout Shift가 일어나는 것을 확인할 수 있었습니다. 1.1 FCP와 LCP 최적화 (with Critical CSS) 위 두 가지 분석을 통해 저가 내린 결론은 다음과 같습니다. 위 FCP의 빈 프레임은 HTML에 CSS가 씌어지지 않고 렌더링 되었거나, 서버 상태가 안좋아서다. 위와 같은 상황에도 내 블로그는 첫 프레임부터 안정적인 화면을 보여줄 수 있어야 한다. FCP와 LCP사이의 여러 번의 프레임 생성은 여러 번의 레이아웃 쉬프트의 결과다. (1.2 리플로우 최적화 참고) 위의 결론들 중 1번째와 2번째 결론으로 블로그를 개선하기 위해 Critical CSS를 적용했습니다. CSS 파일을 파일로 다운 받아 적용하면 첫 프레임 화면이 스타일 적용이 안된 채 보여질 것입니다. 그래서 중요(Critical)한 CSS를 Inline으로 HTML에 함께 보내주게 됩니다. 그리고 이를 Critical CSS라고 합니다. NextJs 환경에서 Critical CSS를 적용하기 위해서는 build후 HTML을 파싱하여 CSS를 불러오는 부분을 해당 Inline CSS로 바꾸는 방법을 사용합니다. 위 고슴도치는 Critical CSS 적용하는 라이브러리 중 하나인 Beasties(구 Critters)의 마스코트입니다. 그리고 아래 postbuild에 tailwind 처리 후 만들어진 CSS를 inline CSS로 바꿔주는 코드와 vercel 배포 시 출력 되는 로그입니다. Postbuild.js import Critters from "beasties";; import fs from "fs"; import path from "path"; const critters = new Critters({ path: ".next/static/css", publicPath: "/_next/static/css/", inlineFonts: true, preloadFonts: false, preload: "media", }); function fromDir(startPath, filter) { if (!fs.existsSync(startPath)) { throw new Error("빌드 폴더 위치 맞나요?") } else { const files = fs.readdirSync(startPath); Promise.all( files.map(async file => { const filename = path.join(startPath, file); const stat = fs.lstatSync(filename); if (stat?.isDirectory()) { fromDir(filename, filter); } else if (filename.endsWith(filter)) { const inlined = await critters.process( fs.readFileSync(filename, "utf8"), ); fs.writeFileSync(filename, inlined); } }), ); }} const promise = new Promise(() => fromDir(".next/server/app", ".html")); promise.then(() => console.log("완료")); 현재 이 방법은 Vercel 서버 PageRouter 사용자만 가능합니다. AppRouter 사용자는 Inline CSS 사용을 위해서 Custom Server를 사용해야 합니다.(tailwind 사용 기준. 스크립트는 실행하나 적용은 안됨.) 저 Vercel Server+ App Router 사용 중이라 위 Postbuild.js 스크립트로 Inline CSS를 적용할 수 없었습니다. InlineCSS기능은 현재 experiment 기능으로 존재하고 있으며 정식 기능이 될 때 적용할 예정입니다. 만약 사용하고 싶으시면 아래 next.config.ts 같이 사용하시면 됩니다. next.config.ts import type { NextConfig } from 'next' const nextConfig: NextConfig = { experimental: { inlineCss: true, }, } export default nextConfig 그래서 저는 직접 Layout에서 중요한 스타일들을 추출해서 style태그에 넣어줬습니다. 지금은 이렇게 수동으로 넣어놨지만, 추후 실험기능인 inline css 기능이 정식으로 출시되면 적용할 예정입니다. 아래 Layout에서 html에 바로 style태그를 넣어줄 수 있게 만든 컴포넌트 입니다. HtmlHeadRegistry.tsx "use client"; import {useServerInsertedHTML} from "next/navigation"; import {PropsWithChildren} from "react"; export default function HtmlHeadRegistry({children}: PropsWithChildren) { useServerInsertedHTML(() => { return ( <> {` @base {span, p { font-size: 1rem; line-height: 1.5; } html { font-size: 16px; @media (width ); }); return <>{children}; } 1.2 CLS 개선과 프레임 개선 위에서 내린 결론 중에 아직 3번째 결론을 해결하지 못했습니다. 3번째 결론은 리플로우가 어떠한 원인으로 일어나는지 성능 분석 도구로 파악하면 쉽습니다. 우선 리플로우에 대해 설명하자면 브라우저가 변경된 style을 화면에 반영할 때, 요소의 크기, 위치, 구조가 변하는 경우에 리페인트와 함께 일어납니다. 색깔만 바뀌는 경우 리페인트만 일어나는 경우와 다르게 1단계 더 일어나 자원 소모가 더 큽니다. 리플로우는 성능에도 영향을 주지만, 급작스러운 위치 변동으로 사용자 경험에도 악영향을 줍니다. 하지만 이를 해결하기 위한 방법은 간단합니다. 위치 변동이 예상되는 경우 미리 width, height를 적용해 주는 것입니다. image에 width height 정보를 넣어주는 이유에도 해당합니다. 아래 Flex 박스에서 CSS 자동계산으로 Reflow가 일어나는 경우와, 미리 예상하고 width/height를 걸어둔 경우를 비교해 봤습니다. LCP, CLS가 확실이 달라지는 것을 수치로 확인할 수 있습니다. 자동 CSS 계산의존 Static 하게 Height 설정 ㅇ 원인 분석을 하기 위해 CLS를 일으키는 지점 2부분에 ["By 글꼴"], ["자동 CSS 계산"] 이라고 라벨링한 모습을 가장 아래 사진에서 확인할 수 있다. 글꼴에 의한 CLS 증가만 존재한다. 왼쪽은 자동 CSS 계산으로 출력할 때 데이터고 오른쪽은 이를 해결하기 위해 Static하게 Width/height 설정하여 레이아웃 변화가 없게 만들었을 때 데이터 입니다. CLS를 일으키는 지점이 글꼴의 변경 1개로 줄어들었습니다. 이후 글꼴의 변경으로 인한 레이아웃 변화도 해결했습니다. 엄선한 쿠키런 글씨체를 썼는데 놓아줬습니다. CLS없이 쿠키런 글씨체를 쓰려면 글꼴 style을 디테일하게 설정 해야하는데 오래 걸릴 것 같아서 대중적인 구글 폰트로 바꿨습니다. 목록을 보여주는 화면같이 동적일 수 밖에 없는 화면에는 flex를 쓰겠지만, 고정적으로 사용한다면 width, height를 설정해달라고 디자이너에게 요청하는 것이 성능과 사용자 사용성에 더 좋을 수도 있다고 생각합니다. 마지막으로 최종 최적화 성능 결과 보여드리겠습니다. FCP와 LCP가 거의 항상 동시에 일어납니다. 위 결과에선 114.78ms에 일어납니다. 따라서 첫 프레임에 렌더링하는 화면은 블로그에서 보여줘야하는 화면과 같아졌습니다. 어쩔 수 없는 맨 처음 1개의 빈 프레임 제외하고는 사용자는 항상 완벽한 화면 만을 빠르게 볼 수 있게 되었습니다. 1.3 이미지 포멧 최적화 결론부터 말하면 92%의 퀄리티로 이미지를 webp로 압축 후 원본 파일과 비교하여 더 낮은 용량의 파일을 업로드 하는 방식으로 만들었습니다. 처음에는 이미지 포멧을 항상 webp로 변경했는데, 막상 용량 비교를 해보니 일부 색이 다양한 JPG나 투명한 부분이 많은 PNG는 오히려 webp보다 용량이 작은 경우가 많았기 때문입니다. AVIF가 압축률이 더 좋지만, webp를 사용한 이유는 그만큼 렌더링 하는데 연산량이 많아지고 아직 AVIF는 지원하지 않는 경우가 있어서 사용하지 않았습니다. 1.4 성능 툴로 원인 파악하여 개선한 다른 내용 사실 위에서 소개한 분석 전에도 성능을 개선했는데, 그때 자료를 안 남겨서 개선했던 내용 중 흥미로운 한 가지 요약해서 설명드리겠습니다. CSP 설정으로 Nonce 사용하여 스크립트를 실행하게 만들었었는데, Script마다 Nonce값 주입이 필요하니 다이나믹 렌더링으로 생성된다는 것을 생각하지 못했습니다. 그래서 엄청 사이트가 느려졌고, 그 원인을 성능 분석을 통해 알게 되어 빠른 CSP를 구현할 다른 방법을 찾고있습니다. 이번에 소개할 것은 느린 네트워크 화면에서도 화면 깨짐을 최소하하며 렌더링 할 수 있도록 개선한 방법입니다. 안정된 환경에서는 CSS까지 받아와서 화면을 그리겠지만, 공공장소에서는 순간적으로 네트워크가 불안정한 경우 잠깐 CSS가 적용 안된 화면이 보이거나 전체 레이아웃이 뒤틀리면서 피로감을 느끼는 경우를 경험한 적이 있으실 겁니다. 이 경험을 최소화하기 위해서는 Critical CSS을 사용하면 됩니다. 사실 1.1 내용과 같은 방법을 적용하면 됩니다. 아래 두 사진은 네트워크가 불안정하여 HTML만 받을 경우를 비교한 사진입니다. 순수 Tailwind 4 + Next15(App Router) Tailwind 4 + Next15(App Router) + Critical CSS 왼쪽은 CSS파일과 HTML은 따로 받는 방법, 오른쪽은 중요한 CSS는 Inline CSS로 HTML에 함께 받는 방법을 적용한 결과입니다. 왼쪽 화면은 배경이 CSS 적용이 안되어서 맨 위에 플레이어가 나오지만, 오른쪽은 일부 CSS가 바로 적용되어 GNB, SNB, LOGO가 출력되고 있습니다. 왼쪽 화면은 네트워크가 안좋아 잠깐 나타난다면 전체 화면이 리플로우를 일으키며 UX적으로 나쁜 경험을 줄 확률이 높지만 오른쪽은 이미 레이아웃이 어느 정도 잡혀있어 괜찮습니다. 그리고 우측 방법을 사용하면 네트워크가 나쁘더라도 화면이 뜰 경우 최소한 사용할 정도로 만들 수 있습니다. 즉, Critical CSS의 장점으로 inline CSS를 조절하여 최소한 보여줄 화면의 스펙을 결정할 수 있다는 것입니다. 그렇다면 항상 Critical CSS를 사용해야 할까 궁금하여 저도 비교한 자료를 찾아봤습니다. 2.1 Only CSS vs Inline CSS vs Inline Style HTML + CSS Sheet HTML+ Inline CSS HTML + Inline Style HTML에 바로 Style이 적용되게 하는 방법은 Inline CSS와 Inline Style이 있습니다. 위 가운데와 우측 사진이 그 예시 입니다. 그러면 "어떤 방법이 가장 좋을까?" 라고 물을때, 결론부터 말하면 환경(디바이스, 네트워크, 브라우저 등..)에 따라 다르고, 개발자가 어느 정도 Critical CSS나 Inline Style로 적용할지에 따라 달라집니다. HTML 사이즈비교 SSR HTML 렌더링 시간 브라우저 렌더링 시간 CSS 이제 로딩시 흰화면 출력을 제거했습니다. 다음으로 새로고침 시 스크롤 위치에 맞는 화면과 배율 출력 해결 방법을 소개드리겠습니다. 3.2 스크롤 위치에 맞는 화면과 맞는 배율 보여주기 위 3.1 방법으로 하얀 화면은 막았지만 새로고침시 여전히 배경이 야경이여야 하는데 아침을 출력하거나, Scroll 위치와 안 맞는 배율의 화면을 출력하는 문제가 발생합니다. 저의 화면 전환 방식은 Scroll 위치에 따라 배율이 계산되고, 특정 위치에서 src가 바뀌는 방식입니다. 이를 해결하기 위해서는 새로고침시 같은 스크롤 위치를 기억하여 사용할 수 있어야 합니다. 그래서 unload 이벤트와 sessionStorage를 활용하여 위치를 기억하고, useLayoutEffect를 사용하여 스크롤 위치가 DOM에 반영된 상태로 렌더링 되도록 의도하였습니다 아래가 그 예시 코드입니다. ScrollVideo.tsx 'use client' export const ScrollVideo = () => { const [videoIndex, setVideoIndex] = useState(0); useLayoutEffect(() => { // 첫 렌더링에만 실행되는 Effect // SessionStorage에 scrollY 저장하는 함수. function storeScrollY() { sessionStorage.setItem("last-scroll-y", window.scrollY.toString()); } // reload했다면, last-scroll-y가 존재함. const lastScrollY = sessionStorage.getItem("last-scroll-y"); => 첫 렌더링 시 lastScrollY 존재하면 scale값 계산후 반영. // scrollY 변화에 따라 video src를 교체 scrollY.on("change", y => { flushSync(() => (y >= 400 ? setVideoIndex(1) : setVideoIndex(0))); }); // unload시 scrollY를 Session Storage에 저장. window.addEventListener('unload',storeScrollY); return () => { window.removeEventListener('unload',storeScrollY); scrollY.destroy(); }; }, []); return ( ); }; 하지만 실제로 위 방식대로 코드를 실행해도 여전히 아주 잠깐 흰 화면이 출력됩니다. 왜냐하면 HTML 파일을 받으면 컴포넌트들은 서버에서 만들어진 HTML placeholder 값대로 출력하고 그 다음 hydration이 일어나기 때문입니다. 그러면 어떻게 반영해야할 까요?? 저는 고민 끝에 React DOM이 생성되는 것 보다 먼저 반영되어야 한다고 결론 내렸습니다. 하지만 저에게 생각나는 방법은 Hydration Rule을 어겨야만 합니다. 그래도 어쩔 수 없다고 생각했습니다. 더 좋은 방법이 있을 것 같지만.. 저로서는 생각이 나지 않았습니다. 그래서 html에 아에 script태그로 스크롤 위치를 저장하는 이벤트를 추가하고, hydration전에 DOM에 바로 src를 바꿔서 껴주는 방법을 선택했습니다. 아래는 그 예시 코드와 실제 scrollPosition이 저장되는 저장소 사진입니다. RootLayout.tsx 'use client' export const ScrollVideo = () => { return ( <> ); } 이렇게 하면 hydration 전에 video 소스와 포스터도 바꾸고, scale값도 바꿉니다. 첫 화면 그리기도 전에 반영시킵니다. 결론은 엄청 잘 작동했습니다. 하지만 이렇게 해도 충분하지만, 원래 작동하던 시간과, 그때의 화면을 poster로 그려보고 싶어집니다. 그래서 한번 시도해 봤는데요. 따로 코드로는 적지 않고 방법과 결론만 말씀드리겠습니다. unload시에 canvas 객체를 생성해서 마지막 배경화면을 데이터로 sessionStorage에 저장하고, 동영상 재생시간도 저장했습니다. 그리고 새로고침하면 그대로 화면에 보이게 만들었습니다. 잘 작동은 합니다. 다만, 컴퓨터의 한계로 살짝 멈칫 멈칫한 것은 없앨 수 없었습니다. 굳이 이렇게 구현해도 사용자 경험에 달라지는 것이 없으니 리소스 낭비라고 판단했습니다. 그래서 poster와 재생시간까지 저장하여 활용하는 코드는 삭제했습니다. 자세한 설명은 에디터 글에서 했었기 때문에 문제 원인과 해결 방법을 간단히 설명하고 넘어가겠습니다. Viewer로 사용자에게 보여줄 때, TOC를 생성하게 됩니다. 그런데 이미지 로딩이 있으니 처음에 레이아웃 계산이 자꾸 바뀝니다.(이미지 width, height 설정하면 덜합니다) 그래서 TOC 스크롤 위치 계산하는 동안 해당 Element 위치가 바뀌니 부정확한 TOC가 됩니다. 그래서 ResizeObserver와 Debouncing을 통해 100ms 이상의 시간 동안 Size가 바뀌지 않으면 그때 TOC를 출력할 수 있도록 최적화 하며 해결했습니다. 처음에는 Spring과 OAuth 2.0을 사용하여 로그인을 구현했습니다. 이미 레퍼런스가 많아서 구현하기 쉽지만 OAuth 2.0으로 코드를 전달하는 과정에서 리다이렉트가 필수적으로 일어날 수 밖에 없었습니다. 왜냐하면 구글 로그인 후 url path를 통해 리다이렉트로 인증 Code를 백엔드에서 받아서 Authorization 토큰을 발급하여 api로 사용자 정보를 받기 때문입니다. 그래서 리다이렉트가 사용자 경험을 해친다고 생각하여 Redirect없는 로그인을 위해서 OAuth2.0대신 Authorization과 Authentication이 분리되어 프론트단에서 인증만 하고 인증 데이터를 post로 넘겨 받을 수 있는 구글 sdk를 사용했습니다. 프론트엔드 구현은 쉬운데 백엔드는 완전히 커스텀한 로그인이 되어버려 자바 스프링시큐리티의 AbstractAuthenticationProcessingFilter를 써야해서 구현해야할 것들이 너무 많았습니다. 만약 협업으로 로그인을 이렇게 구현한다고 하면 반드시 백엔드에게 양해를 구해야겠다는 생각을 했습니다. 물론, 이 SDK도 OAuth2.0 로그인되게 리다이렉트 가능은 합니다. 아무튼 실제 적용해보니 redirect가 없어서 깜박임 없는 화면이 제공되어 더 편안하게 화면을 볼 수 있었습니다. 다만 깜박임이 없어 로그인 했는지 인지하지 못할 수 있어 로딩 표시 같은 기능을 추가할 예정입니다. 컴포넌트 단위 CSS 파일 적절한 분리 컴포넌트 스트리밍 목록 등 필요한 부분에 적용 디바이스에 따른 이미지 출력 최적화(저장 포함) SEO를 위한 subscription 길이 요약하여 줄이기 Client단 ReactQuery 데이터 캐싱 Next서버 컴포넌트 스트리밍 데이터 ReactQuery 캐싱 디자인 시스템 정비(Space, Font 등) 스크롤 이벤트 관리 중앙화(Provider 생성) Font Color와 배경 시인성 개선
2025-03-27058 - NextJsProseMirrorShiki
[블로그 소개] 프론트엔드 - 에디터
[블로그 소개] 프론트엔드 안녕하세요? 첫 번째 블로그 글입니다. 읽어주셔서 감사합니다. 저는 직접 홈페이지를 운영해보고 싶었고, 프로그래밍 하면서 고민하고 해결한 것들을 기록하고 공유하고 싶어 블로그를 만들었습니다. 화학공학과 찐 이과 출신이라 많이 글을 못씁니다. 쓴 글을 수정해가며 더 효율적으로 지식을 공유할 수 있도록 노력하겠습니다. 함께 더 쉽고 높게 성장할 수 있으면 좋겠습니다. 에디터를 만드는데 1달 가까이 사용할 정도로 공들였습니다. Toast UI, Editor.js, React-md-editor,Tiptap까지 커스텀 플러그인으로 원하는 기능을 추가 해보고 사용성과 확장성을 확인할 정도로 진심 이였습니다. 저에게 간택된 블로그 에디터는 ProseMirror 엔진 기반의 Tiptap 에디터입니다. 특징은 확장성이 엄청나지만, 그만큼 작은 기능도 많은 코드가 필요하다는 점입니다. 그리고 Viewer를 JSON과 Text 형식 모두 SSR로 렌더링할 수 있어서 NextJs, 백엔드와 찰떡궁합 에디터입니다! (※ 간단한 기능의 확장만 필요하면 플러그인이 많은 다른 에디터 강력 추천) 에디터 SSR 확장성 엔진 WYSIWYG Markdown RIch Text 기타 editor.js 🟡 中上 자체 ✅ 🟡 ✅ 노션, Json, 문서 좋음, 추천 react-md-editor ❌ 中 자체 ❌ ✅ ❌ 이지윅 때문에 사용성 떨어짐 toast UI ❌ 下 ProseMirror ✅ ✅ ✅ 문서화가 나쁨, 플러그인 적음. quill 🟡 中上 Delta ✅ 🟡 ✅ 문서 좋음, JSON, 추천. TipTap ✅ 中上 ProseMirror ✅ 🟡 ✅ 기능 잘 나뉨. 문서 보통. 추천. ProseMirror ✅ 上 자체 ✅ ❌ 🟡 문서 최고. 러닝 커브. 힘듬. Toast UI Editor, Editor.js, React-md-editor는 내가 생각한 블로그 설계와 적합한지 확인하기 위해 사용했고 quill은 회사에서 사용해봤습니다. 모두 나름의 장단점이 있고 상황에 따라 적절한 에디터를 고르면 된다고 느꼈습니다. 하지만 react-md-editor와 toast UI에디터는 개인적으로 비추천합니다. react-md-editor는 이지윅이 안 돼서 만약 고객에게 제공할 에디터라면 엄청 구식으로 보일 것입니다. 그리고 toast-ui v3.0은 다운로드 수에 비해 플러그인이 적었고 문서화도 커스텀 기능을 추가하기엔 매우 부족했습니다. 위 두 에디터 제외하고는 전부 좋았고 각자 강점과 특색이 있었습니다. editor.js는 노션으로 유명한 에디터입니다. 하지만 막상 사용해보면 노션과 달리 부자연스럽게 느껴졌습니다. 더 매끄럽게 작동하도록 만들려면 많은 플러그인을 추가해야할 것 같습니다. 문서화는 깔끔해서 제대로 만들면 엄청 좋은 에디터가 만들어 질 것 같습니다. quill 에디터는 탄탄한 느낌의 에디터입니다. 일반적으로 필요한 기능의 플러그인은 다 갖췄고, 탄탄하게 설계되었으니 만약 회사에서 고객에게 에디터를 제공해야 한다면 '이 에디터가 맞아!' 라고 말할 것 같은 에디터입니다. 문서화도 잘 되어있어서 커스텀 기능 추가도 수월해 보입니다. Tiptap 에디터는 문서화는 평범하지만 ProseMirror를 엔진으로 사용하며 그 구조를 거의 그대로 사용합니다. 그래서 문서가 부족해서 플러그인 추가에 어려움이 있을 수 있지만 ProseMirror가 자세히 문서화 되어있어서 그 문서를 참고할 수 있습니다. 하지만 ProseMirror는 기능 추가에 많은 노력이 필요합니다. 예를 들면 글자를 굵게 만드는 기능을 추가하고 싶으면 Schema, Command, HTML Render 방식, parse방식 등 모두 설정해야 합니다. 그래서 TipTap의 플러그인들을 사용한다면 기본 기능들은 굳이 어렵게 구현할 필요가 없기 때문에 서로 상호보완적인 면이 있습니다. 만약 회사에서 이 에디터를 사용한다면 그 이유는 최고의 제품을 위해 커스텀한 기능이 많이 필요하기 때문일 것입니다. 위 장단점은 모두 제 개인적인 의견이고, 실제 사람마다 생각은 다를 수 있으니 참고 용으로 봐주셨으면 좋겠습니다. 추가적으로 저가 TipTap을 이용한 또 다른 이유는 서버에서 TEXT, JSON 데이터 모두 HTML로 서버에서 바꿀 수 있다는 점입니다. 반면 quill과 editor.js는 window객체를 참조하고 있어 서버에서 JSON을 HTML로 파싱은 가능하지만 TEXT(원본)는 불가능합니다. 서버에서 데이터를 바꿀 수 있다는 것은, Sanitize(HTML 소독)을 백엔드에서 더 수월하게 할 수 있고, 저장 데이터 형식에서도 자유로워 집니다. 저의 에디터에 필요한 기능들을 정리해 봤습니다. 기본적인 글씨체 변경, 문단 정렬, 테이블 기능, 이미지 업로드 등은 필수였고, 프로그래밍 개발자 블로그로서 다양한 언어를 지원하는 Code Syntax Highlighter가 가장 중요합니다. 이 외에 평소 쓰는 마크다운 문법, S3에 업로드하여 URL을 받는 기능, 사용자를 위한 TOC 기능이 에디터에 들어갈 기능입니다. 기본 텍스트 편집 기능 글씨체 변경 (굵게, 밑줄, 기울임, 색깔, 배경색) 글머리 기호, 번호 기호, 체크 리스트 문단 좌, 우, 가운데 정렬 마크다운 문법 지원 (#적고 띄우면 h1 태그.. 언어" 라고 입력하면 해당 언어에 맞는 블록 자동 생성 VS Code에서 복붙시 클립보드 MIME 정보로 해당 언어에 맞는 코드블럭 자동 생성. 별도 CSS 추가 적용. CodeHighlighter의 파싱없이 그대로 출력 가능. (별도 데이터 가공 필요X) const a = 1; const b = 2; function add(num1, num2) { return num1 + num2; } const answer = add(1, 2); include int main() { int arr = new int*[100]; // 행 포인터 배열 동적 할당 for (int i = 0; i < 100; ++i) { arr[i] = new int[100](); // 각 행을 동적 할당 및 0으로 초기화 } arr[0][0] = 1; std::cout << arr[0][0] << std::endl; // 메모리 해제 for (int i = 0; i < 100; ++i) { delete[] arr[i]; } delete[] arr; return 0; } 코드 블럭을 보면 언어에 맞게 예약어와 변수들이 강조된 것과 별도로 형광팬이 칠해진 모습을 볼 수 있습니다. Syntax에 맞게 코드를 보여주는 기능과 theme에 맞는 UI를 보여주는 기능은 Shiki라는 Syntax Highlighter를 사용했습니다. Shiki를 사용한 이유는 다른 Syntax Highlighter와 달리 Line 단위로 묶어서 파싱하며, 100개 이상의 언어를 처리할 수 있기 때문입니다. Line단위로 묶어 파싱되는 것이 좋은 이유가 궁금할 수 있는데, 순수 CSS로 Line Number 출력이 가능하기 때문입니다. 이 기능을 Toast UI Editor 등 다양한 에디터에 녹여보았지만 에디터에 맞게 녹인 느낌이 아니라 따로 노는 느낌이였습니다. 왜냐하면 에디터와 한몸처럼 작동하도록 녹일 방법을 못 찾았기 때문입니다. 하지만 ProseMirror는 확장성이 좋아서 위 기능을 에디터와 한 몸처럼 작동하게 녹일 방법이 보였고, ProseMirror를 더 제대로 공부하게된 계기가 되었습니다. 저가 말하는 에디터와 한 몸처럼 작동한다는 것은 별도 데이터 처리 없이 Text 데이터 그대로 수정할 때 사용할 수 있다는 것을 의미합니다. 별도로 처리를 하면 에디터 형식이 에디터 형식과 달라져서 수정할 때 사용하지 못하기 때문에 이중으로 데이터 보관이 필요하게 됩니다. 이 기능을 에디터에 녹일 때 Tiptap으로 추가한 플러그인들과 얽히고 설켜 어디서 잘못된건지 파악하는 것이 가장 힘들었습니다. 그러다 보니 어떤 라이프 사이클로 돌아가는지 더 자세히 알게 되었고 다른 사람들이 다양한 플러그인 코드들을 보면서 공부하게 되었습니다. 이 과정으로 알아낸 것을 통해 Highlighter가 강조한 텍스트를 ProseMirror Mark(서식 객체)로 변환하여 적용했고, Schema 정의가 충돌하지 않도록 정의했습니다. 그리고 Line마다 Span 태그와 className을 추가하여 css로 line number를 더 수월하게 처리할 수 있도록 하여 Syntext Highlighter와 Editor가 한 몸처럼 동작하도록 만들었습니다. ProseMirror Mark로 실시간 변환해줬기 때문에, 다른 Mark(서식)도 추가할 수 있게 되어서 형광팬 등의 기능을 코드 블럭에 추가할 수 있게 만들 수 있었습니다. 이 기능을 정상 작동시킬때 까지 2주가 넘게 소요되어서 완성할 때 얼굴에 미소가 자동으로 생길 정도로 기분이 좋았고 앞으로 ProseMirror로 더 많은 것을 능숙하고 빠르게 만들 수 있다는 것에 설레었습니다. 코드블럭에 형광팬, 밑줄 같은 서식을 적용할 수 있는 텍스트 에디터는 찾기 힘듭니다. 그리고 코드 블럭 기능 만을 위해 2주 넘는시간을 투자하여 완성한 기능입니다. 이 기능의 구현은 저에겐 엄청 값진 경험이고 앞으로 더 잘할 수 있는 원동력이 된 것 같습니다. 2.2 Viewer의 서버 사이드 렌더링 Viewer의 서버 사이드 렌더링(SSR) 때문에 여러 에디터를 사용하며 많은 시행착오를 겪었습니다. SSR이 가능해야만 NextJs에서 캐싱하여 빠르게 사용자에게 보여줄 수 있기 때문입니다. 저가 생각한 Viewer의 SSR 구현은 아래와 같습니다. Client 단에서 HTML로 파싱하여 Editor데이터와 출력용 데이터 Server에 전달하기. Editor 데이터와 화면 출력용 데이터로 2중으로 보관된다. 에디터 기능 일부가 수정 되었을 때, HTML을 정적으로 별도 보관하기 때문에 자동 반영되지 않는다. 서버는 HTML 그대로 보내주면 된다. 서버는 클라이언트가 보낸 정적 HTML에 대한 Sanitize만 가능하다. Server에서 Editor의 데이터로 직접 Parsing후 출력하기. 에디터 일부 기능 변경 시, 해당 변경 사항에 맞게 HTML을 그리기 때문에 자동 반영된다. 데이터를 2중으로 보관할 필요가 없다. 서버에서 파싱하기 때문에 서버에 부담을 줄 수 있다. Server에서 변경된 에디터 기능으로 HTML 렌더링이 달라져도 Sanitize 할 수 있어 보안이 훨씬 강화된다. Server에서 Editor 데이터로 파싱하여 HTML을 만든 후, 2중으로 보관하며, 에디터 기능 업데이트 시 HTML 갱신하기. 에디터 일부 기능 변경 시, 해당 변경 사항에 맞게 HTML을 그리기 때문에 자동 반영된다. 서버는 HTML 그대로 보내주면 된다. 변경된 HTML에 대한 Sanitize가 가능하다. 데이터는 2중으로 보관된다. 1번 방법은 에디터가 SSR이 불가능할 때 사용할 방법이며, 클라이언트가 서버 Sanitize가 방어하지 못하는 방식으로 직접 HTML을 조작하여 보낼 수 있어 보안에 구멍이 발생할 수도 있고 , 에디터 업데이트로 변경된 파싱 방법으로 만들어진 HTML을 보여줄 수 없을 것입니다. 2번 방법은 더 안전하고, 최신 업데이트 사항을 반영할 수 있지만, 서버에 부담을 줄 수 있기 때문에 적절하게 캐싱하여 보여줄 수 있어야 합니다. 그래서 저는 위 두 가지 장점을 합친 3번째 방법을 채택했습니다. 빠른 속도와, 보안, 안정성을 가질 수 있는 방법이라고 생각합니다. 2.3 이미지 업로드 이미지는 크기 조절과 정렬이 가능하고, 비율 유지 여부도 설정할 수 있습니다. 여러 플러그인 코드들을 보고 좋은 기능들만 가져와 합쳐서 커스텀했습니다. 사진 파일을 직접 ctrl+v로 입력하면 webp로 압축하고 원본과 비교하여 용량이 작은 파일을 백엔드 서버로 업로드하여 URL을 받아오는 방식입니다. 기존 이미지의 URL을 통해 업로드도 가능합니다. 이미지도 블럭 단위로 복사가 가능하며, 외부 사이트의 이미지를 URL로 올릴 수 있지만, CSP 정책 설정으로 이 사이트에서는 해당 이미지를 로드할 수 없습니다. 2.4 기본 텍스트 편집 기능 우선 화질은 죄송합니다 GIF를 Webp로 만들어도 용량이 커 GIF들은 최대한 색상을 손실시켜 압축했습니다. 기본 텍스트 기능은 대부분 Tiptap 플러그인에서 가져왔고 일부 기능들은 커스텀 했습니다. 예를 들면 Color Palette 중 1개는 커스텀 기능입니다. 모든 기능을 직접 구현했다면 아직도 완성하지 못했을 거라 생각합니다. 많은 기본 텍스트 기능을 제공해준 Tiptap 에디터 관계자 분들에게 감사합니다. 기능 구현에 에너지를 다 써버려서 디자인이 엉망입니다. 디자인은 기능과 사용성 개선과 함께 천천히 개선할 예정입니다. 2.5 테이블 기능 사진1 MPTI 프로젝트 사진2 콘스텔링크 프로젝트 사진3 럭퀴즈 프로젝트 쉽게 온라인 PT를 받을 수 있는 플랫폼 WebRTC로 P2P 화상 채팅 기능 살아 생전 처음 했던 프로젝트라.. 아키텍처조차 없다 ㅜㅜ 누구도 기부 내역과 사용처를 조작하지 못하는 블록체인 기반 치료비 모금 플랫폼 누구나 만들고 하기 쉬운 실시간 대규모 퀴즈 게임 플랫폼 콘스텔링크 아키텍처 럭퀴즈 아키텍처 기본 브라우저의 테이블 태그를 이용하여 만들어졌습니다. 모바일에서는 table 태그가 Width나 Height, 정렬이 제대로 적용되지 않습니다. 그래서 넘치는 경우에는 좌우 스크롤로 볼 수 있도록 개선했습니다. 셀 추가/병합/분리가 가능하고. 1행에 2개의 열을 갖는 것도 가능합니다. 사진 1 아래의 셀이 합쳐진 셀입니다. 2.6 TOC 기존 Heading 플러그인을 수정하여 사용자 정의 Table of Contents(TOC) 기능을 만들기 위해 입력 중, h태그 생성 시 전체 h태그를 찾은 뒤, 계산하여 자동으로 번호가 html id와 data set으로 할당되도록 만들었습니다. 처음에는 간단할 것 같았지만, 실제로 구현해보니 head 태그가 문서 내에서 몇 번째 태그 인지를 전체 문서를 뒤지며 정확하게 계산하는 부분이 까다로웠고, Text 또는 JSON 형식으로 내용을 추출한 후 다시 에디터에서 불러왔을 때 동일한 구조로 나타나기 위해서 HTML로 렌더링하고 파싱하는 부분도 신경써서 수정해야 했습니다. 파싱에서 끝나는 것이 아니라 Client의 화면에서는 스크롤바 쪽에 위치한 TOC를 클릭 시 Head별 위치 값을 계산해 이동할 수 있어야 합니다. 단순히 scrollIntoView를 쓰면 된다고 생각했지만, Sticky 컴포넌트의 존재로 계산을 제대로 못하는 문제가 발생했습니다. 왜냐하면 브라우저 scrollIntoView 함수나 Anchor를 사용하여 컴포넌트로 이동할 때 브라우저 화면 기준 컴포넌트 Y좌표와 + scrollY로 계산하는데 Sticky한 컴포넌트는 이 계산식이 경우에 따라 틀린 계산이 되기 때문입니다. 또 다른 이슈는 이미지의 로딩 속도에 따라 이동할 위치와 의도한 위치가 달라지는 문제입니다. 이 문제는 Debouncing과 ResizeObserver를 사용하여 해결했습니다. Debouncing은 사이즈 변경이 자주 일어나도 1번의 위치 계산만 하기 위함이고, ResizeObserver는 레이아웃 변화 감지를 위해 사용했습니다. 아래는 완성된 TOC 기능 사용 예시입니다. 기타 기능은 지속적으로 업데이트해 나갈 예정입니다. 물론 지금 이 글을 쓰는 순간에도 소개했던 기능들의 디테일한 부분 업데이트를 많이 했습니다. 시간이 날 때마다 반영하겠습니다. 3.1 Iframe (CodePen) 블로그 개선을 위해 블로그를 탐방하다가 필요해보여 추가한 기능입니다. Iframe 때문에 CSP 설정이 조금 약화 됐습니다. Iframe 기능 구현이 어려운 것은 아닙니다. 다만 보안쪽 관련해서 고민이 많은 필요합니다. Iframe을 추가 하기 전에도 느낀거지만 third party 홈페이지를 사용할 때 어떻게 하면 안전하게 사용할 수 있을지 항상 고민이 됩니다. 예를 들면 CSP 설정에서 "반드시 안전하게 사용하려면 이것을 추가해라!!" 라고 하지만 만약 그 설정을 반영하면 사용할 수 있는 라이브러리도 적어집니다. 저의 홈페이지도 안전을 위해 COEP 설정을 안전하게 해뒀는데, third party 사이트를 Iframe에 띄우면서 이 설정을 없애야 했습니다. 저의 백엔드 서버라면 COEP 설정을 원하는대로 할 수 있겠지만 third party 사이트에서 헤더를 맞춰줄 이유가 없기 때문에 저가 맞춰야 했습니다. 이런 부분을 어떻게 해결해야 보안에 문제가 없는지 고민을 만들어준 기능입니다.
2025-03-162120