커스텀 Velog를 만들어보자 📝

김성현·2025년 8월 22일
1

안녕하세요,
오늘은 포트폴리오에 Velog를 띄우는 방법을 소개해보려고 합니다.
포폴 색상에 맞춰 파란색 Velog로 커스텀해봤습니다.

📝 Velog를 띄운 이유

보통 Velog, Tistory와 개인 블로그 사이트를 따로 관리하시는 개발자분들이 많은데요,
'그냥 포스트를 한 번에 관리하는 게 편하지 않을까?' 라는 생각에 만들어봤습니다.

⭐️ 1. Velog 포스트 가져오기

1-1. RSS 방식 ❌

처음에는 RSS 방식으로 포스트를 불러왔습니다.

https://v2.velog.io/rss/벨로그_아이디

위의 url을 주소창에 입력해보면 다음과 같은 xml 파일이 뜹니다.

'뭐야 엄청 쉽네?' 하고 데이터를 보니, 최근 20개의 포스트밖에 없었습니다.
Velog는 무한스크롤 형식으로 다음 포스트들을 불러오는데 RSS 방식으로는 처음 로드된 20개의 포스트에만 접근할 수 있기 때문입니다 ㅠ 다른 방법을 찾아야겠다 싶었습니다.

1-2. GraphQL 쿼리로 요청하기 ✅

SQL문으로 DB에 저장된 데이터를 가져온다면, GraphQL은 클라이언트가 서버로부터 데이터를 가져올 때 사용합니다. 하나의 쿼리만으로 필요한 데이터들만 뽑아올 수 있다는 장점이 있습니다.

실제로 Velog 사이트의 네트워크 탭을 보면 graphql이 계속 호출되는 것을 볼 수 있습니다.

Postman에서 다음과 같이 요청을 보내면 원하는 응답이 오는 것을 볼 수 있습니다.

Request URL
https://v2.velog.io/graphql
Content-Type : application
Body
query {
  posts(username: "벨로그아이디") {
    id
    title
    short_description
    body
    released_at
    ...
  }
}

GraphQL에서는 변수를 사용할 수 있는데, 저는 cursorlimit을 변수로 지정해줬습니다.

불러온 포스트 중 마지막 포스트의 id를 cursor 값으로 넘겨주면 다음 요청 때 이 cursor를 기준으로 다음 포스트들을 불러올 수 있습니다. 또한, limit으로 불러올 포스트의 개수를 정해줍니다.

    const query = `
      query Posts($username: String!, $cursor: ID, $limit: Int) {
        posts(username: $username, cursor: $cursor, limit: $limit) {
          id
          title
          short_description
          body
          url_slug
          tags
          released_at
        }
      }
    `;

1-3. CORS 해결하기

SOP(Same Origin Policy) 때문에 다른 Origin의 리소스를 막 가져올 수 없습니다. 프록시 서버로 우회하는 방법도 있지만, 저는 React-Query로 데이터를 캐싱하기 위해 Next에서 제공하는 API Route를 거쳐서 요청했습니다.

client -> API Routes -> https://v2.velog.io/graphql

루트에서 /api/posts/route.ts를 만들어주고, 다음과 같이 설정하면 됩니다.

// app/api/route.ts
const query = `
  query Posts($username: String!, $cursor: ID, $limit: Int) {
    posts(username: $username, cursor: $cursor, limit: $limit) {
      id
      title
      short_description
      body
      url_slug
      tags
      released_at
    }
  }
`;

const response = await fetch("https://v2.velog.io/graphql", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Access-Control-Allow-Origin": "*",
  },
  body: JSON.stringify({
    query,
    variables,
  }),
});

// more...

2. Hooks 만들기

다음과 같이 useFetchPosts 훅을 생성했습니다.

export function useFetchPosts({ username, cursor }: UseFetchPostsProps) {
  const params = new URLSearchParams({ username });
  if (cursor) params.append("cursor", cursor);

  const query = useQuery({
    queryKey: ['posts', cursor],
    queryFn: async () => {
      const response = await fetch(`/api/posts?${params}`);
      if (!response.ok) {
        throw new Error("포스트를 불러오지 못했어요 🥲");
      }
      return response.json();
    },
    staleTime: 1000 * 60 * 5,
  });

  return query;
}

3. Hooks 사용하기

Feed 페이지에서 다음과 같이 포스트를 받아왔습니다.

export function Feed() {
  const [posts, setPosts] = useAtom(postsStoreAtom);

  const { data: fetchedPosts, isLoading } = useFetchPosts({
    username: 'oilater',
    cursor: posts?.at(-1)?.id
  });

  const { observeRef } = useInfiniteScroll({
    onIntersect: () => {
      if(fetchedPosts) setPosts([...posts, ...fetchedPosts]);
    }
  });

  if (isLoading && posts.length === 0) {
    return <ListSkeleton />;
  }

  return (
    <div className={styles.wrapper}>
      <h1 className={styles.feedContainer}>Feed</h1>
      <VelogPostList value={posts} ref={observeRef} />
    </div>
  );
}

저는 데이터가 로딩중일 때 isLoading 상태를 안썼습니다. 무한스크롤로 다음 데이터를 받아오다보니, fetching할 때 isLoading에 걸려서 스켈레톤 UI가 뜨는 게 방해가 되더라구요. 그래서 Fallback UI는 처음 데이터가 로딩중일 때만 보여주었습니다.

참고로 무한 스크롤은 Intersection Observer를 통해 구현했습니다.
스크롤 이벤트를 매번 호출하는 scroll 방식보다 더 효율적이고 구현도 간단합니다.

4. 포스트에 Velog 스타일 입히기

4-1. useVelogStyle 훅

addStyleAsync 함수를 통해 각 포스트의 스타일을 입혀줬습니다.
모든 포스트를 일괄로 스타일을 입히지 않고, 포스트별로 입혀주는 방식으로 수정했습니다.

"use client";

export function VelogPost({ post }: VelogPostProps) {
  const { addStyleAsync } = useVelogStyle();
  const [styledContent, setStyledContent] = useState("");


  useEffect(() => {
    addStyleAsync(post.body)
      .then((res) => setStyledContent(res))
      .catch((err) => console.error("포스트 스타일 적용 실패: ", err));
  }, [post.body, addStyleAsync]);

  return (
    <Post>
      <Post.Title title={post.title} />
      <Post.Description 
        author="김성현" 
        postedAt={getRelativeDays(post.released_at)} 
      />
      <Post.Tags tags={post.tags} />
      <Post.Content body={styledContent} />
    </Post>
  );
}

4-2. 마크다운 커스텀

마크다운을 커스텀은 다음 순서대로 진행했습니다.

  1. marked 라이브러리를 통해 마크다운 텍스트를 HTML로 바꿔주기
  2. prismjs 라이브러리를 통해 마크다운에 언어별 스타일을 입혀주기
  3. 다 준비되면 DOMParser를 사용해 html로 바꿔주고, querySelectorAll을 돌면서 classList로 Velog style을 입히기

5. UI 이슈

5-1. Layout Shift 현상

포스트가 갑자기 중앙에서 원래 위치로 돌아오는 이슈가 있어서 코드를 살펴봤습니다.

export function Post({ children }: { children: React.ReactNode }) {
  return <div style={{ maxWidth: '768px', margin: '0 auto' }}>{children}</div>;
}

무심코 넘겼던 style 태그가 원인이었습니다. 인라인 CSS는 컴포넌트가 렌더링된 후 적용되기 때문에 기존에 설정한 CSS를 덮어씁니다. 만약 maxWidth, height 등과 같이 레이아웃을 변경하는 프로퍼티가 있다면 리플로우를 일으킵니다. 이 style 태그를 없애니 문제가 해결되었습니다.

리플로우는 JS에 의해 브라우저가 레이아웃을 그리는 과정을 다시 반복하는 것을 말합니다. 이 비용이 커서 최대한 피해야 합니다.

5-2. 데이터가 없었다가 로드되는 경우

아직 데이터를 받아오지 못했을 때 그냥 null을 return하거나, 빈 <div></div>를 띄우면 UI가 왔다갔다 거립니다. 이때는 wrapper에 minHeight를 설정하면 해결됩니다.

TMI. Next로 마이그레이션하기

처음에는 인터렉션 시스템을 React로 개발하다가, '어 이거 포트폴리오에 적용해봐도 되겠는데?'란 생각에 어쩌다보니 포트폴리오를 만들게 되었습니다. 하지만, SPA인 만큼 SEO에 불리했고, 라우팅도 복잡해질 것 같아서 진행했습니다.

Emotion에서 Vanilla-Extract로 갈아타기

CSS가 생각보다 골치 아팠습니다. 일단 기존에 사용하고 있던 Emotion은 런타임에 css를 주입하기 때문에 서버 컴포넌트와 호환되지 않았습니다. CSS-in-JS 중 고민하다가 결국 카카오스타일에서 디자인 시스템 개발에 도입했던 Vanilla-Extract로 결정했습니다.

Vanilla-Extract는 빌드 타임에 css 파일을 뽑아내는 '런타임 제로'라는 특징 때문에 서버 컴포넌트와 호환되며, 가볍고, 러닝 커브가 낮습니다. .css.ts 파일을 매번 생성해줘야 하는 것은 불편하지만, styles 폴더로 빼서 관리하면 오히려 스크립트가 깔끔해져서 만족스러웠습니다.

마무리

허전했던 포트폴리오에 Velog를 띄우니 뿌듯했습니다.

근데 그냥 MDX로 구현할 걸 그랬습니다...

레퍼런스

https://github.com/eungyeole/velog-readme-stats
https://velog.io/@insutance/velog-hits

0개의 댓글