teklog

기술 블로그 개발 후기 1

2022/09/06

n°8

category : Recap

img


프로젝트 개요는 리드미에서 확인할 수 있습니다.

> 프로젝트 리드미 살펴보기


드디어 회고


그동안 현업에서 갈고 닦은 모든 것을 쏟아부은 프로젝트였기 때문에, 그 어떤 프로젝트보다 애정이 간다. 애정이 간다는 것만으로 어느정도 성공한 것 같다. 오랫동안 가꾸어가고 싶은 마음이 들고, 가능한 계속 이 블로그를 사용하고 싶다. 배포 이후에 글을 많이 쓰진 못했는데, 최적화&버그 수정에 더 힘을 쓰고 있었다. 이런저런 이유로 처음으로 쓰는 글이니 여러모로 두서없이 쓰여질 것 같다.



1. 프로젝트 목표


프로젝트의 초기 목표에 대해 회고해보았다.

목표는 다음 순서대로 중요했다.



  1. 사용하기 좋은 블로그를 만들 것 (작성자, 방문자 포함)
  2. UI가 매력적인 블로그를 만들 것
  3. 회사에서 사용하는 핵심적인 기술을 최대한 활용할 것



1 - UX


프론트 개발자라면 당연히 신경 써야 하는 부분이고, 워낙 당연한 얘기라 공허하게 들릴 수 있다. 그러나 이번 프로젝트를 통해 '최상의 사용자 경험'이 얼마나 달성하기 어려운 것인지 체감할 수 있었다. 이와 관련하여 글 후반부에서 지난했던 최적화 과정..을 통해 더 깊게 돌아볼 예정이다. 최적화 뿐만 아니더라도, 블로그를 사용하면서 문득 알게 된 사용자 경험 요인들이 꽤 있었기에 함께 다루어볼 예정.



2 - UI


역시나 디자인은 내 영역이 아니기 때문에 무척 어려웠다. 폰트를 고르는 일, 가독성을 위한 적절한 여백, 사이트 전반의 컬러링 등. 페이지의 전체적인 레이아웃을 참고할 레퍼런스가 없었기 때문에 난감했다. 어떤 페이지는 개발보다 디자인을 결정하는데 더 시간이 걸릴 정도로. 게다가 배포를 해도 그게 끝이 아니다.. 배포 이후에도 디자인의 작은 부분에서 계속 잘못된(?) 느낌을 받는다. 여백의 크기라던지, 요소의 위치라던지 개선할 부분이 끝없이 보인다.


그렇다고 레퍼런스가 전혀 없는 건 아니었다. 작가들의 포트폴리오 사이트를 자주 봤었기 때문에, 좋아하던 사이트에서 느낀 특정한 미감을 따르려 했다. (깔끔함? 미니멀? 화이트 큐브 갤러리? 단순&직관적이고 이미지가 부각되어 보이는 공통점이 있다.)



레퍼런스의 여러 요소들이 반응형에 맞춰서 반영되도록 신경을 썼다. 그래도 여전히 좀 어설프다 ㅎㅎ


기획부터 이미지가 중요한 요소였다. 첫째로 내가 그동안 촬영해온 사진들을 보여주고 싶었고, 둘째론 글을 읽을 때 아무리 간단한 내용이더라도 이미지가 첨부된 글이 더 눈이 갔기 때문이다. 거의 모든 페이지에 이미지가 들어가도록 개발하였고, 막바지에는 그냥 사진 갤러리 페이지를 하나 만들었다. (실은 사진/일상 블로그를 새로 파려고 했는데, 두 번 할 일은 아닌 것 같았다..)



3 업무 적응


기술 스택부터 폴더 구조까지 철저히 회사 컨벤션을 따르려고 노력했다. 사수들이 이미 개발해둔 코드를 재사용하는 일이 많았기 때문에, 회사의 기술 스택을 사용해 처음부터 내 힘으로 개발하고 싶었다.


가장 많이 사용된 것은 서버사이드 렌더링(SSR)과 정적 사이트 생성(SSG)이었다. 회사에서는 토큰 관리와 실시간으로 업데이트 되는 데이터(뉴스, 코인 시세표 etc), SEO 등의 이유로 거의 모든 페이지에서 서버 사이드 렌더링을 하고 있었고, 상세 페이지는 대부분 정적 사이트 생성을 하고 있었다. 이 프로젝트를 통해 SSR/SSG 관련 지식이나 경험은 어느정도 쌓을 수 있었다. 그외로는 next/auth로 마이그레이션이 예정되어 있었기 때문에 블로그에 적용해봤다.


한 가지 아쉬운 점은 회사 컨벤션을 따르면서 생겨난 불필요한 코드들이다. 정확히는 불필요한 컴포넌트의 분리였다. 현업에서는 분자 단계가 없는, 꽤 헐거운 아토믹 패턴을 따르고 있었는데 이 프로젝트에서도 굳이 필요했을까 싶다. 불필요하게 분리된 컴포넌트는 너무 많은 뎁스를 만들어 개발이 어려워지고, 코드의 가독성도 떨어진다. 무엇보다 설계 단계의 바램처럼 아토믹 패턴대로 컴포넌트가 재사용되지 않는 치명적인 단점이 있다. "아토믹 패턴을 지켰다"는 게 이 프로젝트의 포인트는 아니었다. 오히려 무조건 컴포넌트를 분리하는 게 만사가 아니라는 점이 포인트였다. 개발 후반에는 불필요한 컴포넌트 분리는 최대한 줄이려고 노력했다. 결국엔 개발의 효율성, 생산성에 도움이 되느냐-가 중요하다는 당연한 깨달음을 얻었다.


오해를 덜고자 첨언하자면, 회사에서는 효율적으로 패턴을 사용하고 있었다! 아톰 단위의 컴포넌트는 공용 라이브러리로 관리했었는데, 상당히 유용했다. 또한 월~년 단위로 레이아웃이 자주 바뀌었기 때문에, 템플릿-페이지 단위로 개발하는 것이 유용한 지점도 분명 있었다. 아마 바쁜 일정에 맞추어 적응된 패턴이 아닐까 싶다.


2. 폴더 구조


img


-- components


  • Atom : 보통 페이지에서 map으로 뿌려지는 컴포넌트들을 넣었다. 엄격히 따지면 아토믹 패턴의 분자에 해당될 컴포넌트들도 이곳에 넣었다.
  • Module : Footer, Header, Layout 등 페이지에 공통적으로 사용되는 컴포넌트들이 들어간다.
  • pages : 특정 페이지에만 들어가는 특정 컴포넌트들이 들어있다. 예를 들어 메인 페이지의 소개글이나 인포 페이지의 정보글을 담은 컴포넌트들이 있다. 하부 경로에 다시 페이지별로 폴더를 분리해두었다.
  • Template : 템플릿 컴포넌트들이 들어간다. 개발 후반에는 효율성을 위해 뎁스가 필요하지 않은 페이지들은 템플릿 컴포넌트에서 개발을 끝냈다. (나만 사용하는 로그인/로그아웃, 글 작성/수정 페이지 등)
  • MetaTag.tsx : 모든 페이지에서 공용으로 사용되는 메타 태그 입력용 컴포넌트이다. 정말 너무 편리하다!! fetching data를 props로 넘겨받아 그대로 title, description, 등등으로 넣어준다. 또한 구글 애널리틱스의 스크립트도 이쪽에 두어서, 굳이 _app.tsx에 useEffect로 관리할 필요가 없어진다. 효자 컴포넌트다..


-- libs


  • post : 블로그 포스팅의 create, update, delete 요청을 보내는 fetch함수들이 들어있다. 후에 여기 있는 함수들은 테스트 코드 추가 예정.
  • recoil: recoil state가 들어간다. day/night 모드 때문에 상태 관리로 recoil을 사용 중이다.
  • swr: deprecated... 개발을 하다보니 굳이 swr이 필요없어서 사용하지 않게 되었다.
  • utils: html의 태그를 지워주는 함수, 간단한 날짜 변환, 게시글 이미지가 없을 때 카테고리에 따라 프리셋 이미지 경로를 넣어주는 함수 등. 정말 편의를 위해 개발한 함수들이 모여져 있다.
  • galleryImages: 갤러리에 들어갈 사진들의 크기와 경로들을 객체 배열로 모아두었다. 모듈화하여 갤러리 페이지에선 간단히 map 함수를 통해 사진들을 출력할 수 있다.
  • gtag: google analytics 스크립트.
  • prisma: 프리즈마 클라이언트 객체를 모듈화해서 사용했다. 그렇지 않으면 "Too many PrismaClient instances" 에러가 발생하기 때문에 꼭 모듈화하여 사용해야한다.


-- pages


  • api : 모든 백엔드 api가 들어있는 폴더. 처음엔 api가 page 폴더에 있는 게 너무 낯설었는데, 이제는 너무 편리하게 느껴진다. 뿐만 아니라 api까지 개발을 해보니 SSR의 getServerSideProps와 백엔드를 연계해서 이해하는데 도움이 많이 됐다.
  • _app과 _document는 후에 코드와 같이 살펴볼 것이다.
  • 이 외는 페이지 경로에 해당하는 부분들이니 궁금하신 분들은 따로 살펴보시면 좋을 것 같다.


-- types


  • 현업에서처럼 타입들도 따로 경로를 만들어 관리했다. 타입이 길지 않다면 컴포넌트에 interface를 작성한 것도 있었지만, 반복되는 타입이 많았기 때문에 대부분 모듈화한 타입을 사용했다.


--etc


  • style은 styled-components의 globalStyles와 theme을 사용했다. 역시나 너무 편리한 스타일드 컴포넌츠.. 회사에서 props로 ui 동적 업데이트와 재사용을 상당히 많이 사용하기 때문에, 부트캠프에서 처음 배웠을 때보다 200% 활용하고 있는 느낌이다. Next.js와 함께 사용하기 위해서 babelrc를 설정해줘야 하는데 검색하면 많이 나오니 참고하시길 바람..
  • 패키지 관리는 npm을 사용했다. yarn berry로 버전 업데이트 때 마이그레이션 예정.
  • 설정 파일들은 공유할 코드가 있는 것들만 따로 살펴볼 예정이다.



3. 공유하고 싶은 코드


1. 블로그 리스트 페이지 blog/index.tsx

경로 teklog.site/blog


...

const index = ({
  list,
  categories,
}: {
  list: IBlogGetListItem[];
  categories: IBlogGetCategorySideBar[];
}) => {
  return (
    <>
      <MetaTag
        title="teklog - blog"
        url="https://www.teklog.site/blog"
        description="Teklog - Blog List page"
      />
      <BlogListPageTemplage posts={list} categories={categories} />
    </>
  );
};


export default index;


export const getServerSideProps: GetServerSideProps = async ({
  res,
  query,
}) => {

// 최적화를 위한 cache 설정
  res.setHeader(
    "Cache-Control",
    "public, s-maxage=10, stale-while-revalidate=59"
  );
  const { page, category, tag } = query;
  const categories = await getBlogCategoryList();


  if (category) {
    const categoryPosts = await getBlogCategoryPost(category);
    return {
      props: { list: categoryPosts[0].posts, categories },
    };
  }


  if (tag) {
    const tagPosts = await getBlogTagPost(tag);
    return {
      props: { list: tagPosts[0].posts, categories },
    };
  }


  /* Branch If page or any query doesn't exist*/
  if (!page) {
    return {
      redirect: {
        destination: "/blog?page=1",
        permanent: false,
      },
    };
  }


  const posts = await getBlogList(page);


  return {
    props: { list: posts, categories },
  };
};

다음 두 가지 이유 때문에 공유하고 싶었다.



  1. 효율적인 컴포넌트 재사용
  2. 서버사이드에서의 분기 처리


서버사이드에서 쿼리에 따라 템플릿에 넘겨줄 prop을 분기처리한 것이 상당히 편리했다. 블로그 리스트 페이지에는 카테고리, 태그 필터가 있는데, 클릭 시 해당 쿼리로 이동하면서 새로운 데이터를 요청한다. 이때 쿼리에 따라 서버사이드에서 분기가 이루어지는데, api가 반환하는 데이터를 그대로 템플릿에 내려주어 컴포넌트가 재사용이 가능하게끔 하였다. 따로 필터링 결과 페이지를 만들 필요가 없어서 매우 편리했다! 또한 유저가 존재하지 않는 blog의 경로로 직접 타이핑하여 접근하려고 할 때 첫번째 페이지로 redirect할 수 있도록 분기 처리하였다.


2. 블로그 디테일 페이지 blog/[id].tsx

경로 teklog.site/blog/1

const index = (props: IProps) => {
  /* Wrong Paths Branch*/
  const router = useRouter();
  if (!props.post) {
    setTimeout(() => router.push("/blog"), 60000);
    return (
      <>
        <MetaTag
          title="teklog"
          description="teklog - loading"
          url={`https://www.teklog.site/blog`}
        />
        <Loading />
      </>
    );
  }


  const { post, categoryList } = props;
  const { detail, nav } = post;
  const { content, createdAt, id, title, categories, tags } = detail;
  return (
    <>
      <MetaTag
        title={`${title} - Teklog`}
        description={content}
        url={`https://www.teklog.com/site/blog/${id}`}
      />
      <BlogDetailPageTemplate
        content={content}
        createdAt={createdAt}
        id={id}
        title={title}
        category={categories.name}
        tags={tags.map((item) => item.tag)}
        nav={nav}
        categories={categoryList}
      />
    </>
  );
};


export default index;


export const getStaticPaths: GetStaticPaths = async () => {
  const postsId = await getBlogDetailId();
  const paths = postsId?.map((post: { id: number }) => ({
    params: { id: post.id.toString() },
  }));


  return { paths, fallback: 'blocking'};
};


export const getStaticProps: GetStaticProps = async ({ params }) => {
  const post = await getBlogDetail(params.id);
  const categoryList = await getBlogCategoryList();


  return {
    props: { post, categoryList },
    revalidate: 5,
  };
};

디테일 페이지는 모두 SSG를 사용하였다. 정확히는 Incremental Static Regeneration을 사용했다. 블로그 포스팅들은 빌드 시 HTML로 생성되지만, 빌드 이후에 게시글을 작성하고, 작성한 게시글을 수정할 때도 자주 있었기 때문이다. fallback을 'blocking'으로 해주어 빌드 시 생성되지 않은 페이지 경로에 접근할 때 404 페이지 대신에 loading 페이지가 나오도록 분기 처리를 하고, setTimeOut을 이용하여 1분 뒤에 블로그 리스트 페이지로 redirect 되도록 하였다. 블로그를 사용하면서 생각보다 자주 이 fallback 페이지를 볼 수 있었다. 404 페이지가 나온다면 사용자는 아마도 브라우저를 그냥 닫아버릴 가능성이 크기 때문에 신경을 쓴 보람이 있다.


3. 카테고리 사이드바 BlogSideBar.tsx

const BlogSideBar = ({
  categories,
  padding = "2rem",
  mobilePadding,
}: {
 ...
}) => {
  const router = Router;


  const [mainToggle, setMainToggle] = useState<boolean>(false);
  const [mainCategory, setMainCategory] = useState<boolean>(false);


  /* subCategory Object Assign*/
  const subKeys = categories
    .map((item) => item.name)
    .reduce((acc, item) => {
      return { ...acc, [item]: false };
    }, {});


  const [subCategory, setSubCategory] = useState(subKeys);


  const handleMainToggle = () => {
    setMainToggle((prev) => !prev);
    setTimeout(() => setMainCategory((prev) => !prev), 300);
  };


  const handleSubToggle = (name: string) => {
    setSubCategory((prev) => {
      return { ...prev, [name]: !prev[name] };
    });
  };

...

다른 블로그(velog)를 이용하지 않은 이유 중 하나가 이 '카테고리 사이드바'가 없었기 때문일 정도로 내겐 중요한 요소였다. 중요한 기능이기에 UI/UX에 더 신경 썼다. 기본적으로 사이드바는 두번 접히도록 하였다. 카테고리의 이름을 누르면 블로그 리스트의 필터링된 결과를, 게시글 제목을 누르면 해당 글의 디테일 페이지로 이동한다. CSS를 이용한 카테고리 화살표의 애니메이션, 해당 카테고리에 10개 이상의 게시글이 있다면 ...more로 표시하기, 모바일에서의 위치 등등. 디테일한 부분에서 신경을 많이 썼다.


코드에서 공유하고 싶은 부분은 subCategory의 초기값인 subKeys이다. subCategory는 fetch로 받아오는 카테고리들과 연결되는 state이다. 각각의 카테고리가 따로 접히고 열리게끔 ui를 업데이트 하기 위해선 사실 분리된 새로운 컴포넌트와 그 안의 state를 두는 것이 당장 개발하기엔 편리했다. 하지만 여기서 뎁스가 더 깊어지면 오히려 더 비효율적이라고 판단했다. mainCategory는 BlogSideBar에 있는데 subCategory만 따로 분리되는 점이 어색해 보였고, 불필요한 prop을 늘리는 것으로 느껴졌기 때문이다. 그래서 하나의 state가 동적으로 생성되는 여러 개의 카테고리를 관리할 수 있도록 subKeys로 state의 초기값을 넣어주었고, 핸들러에 있는 setter도 이에 대응해서 개발하였다.



4. _document.tsx

...

// styled-components SSR 설정
export default class _document extends Document {
  static async getInitialProps(
    ctx: DocumentContext
  ): Promise<DocumentInitialProps> {
    const sheet = new ServerStyleSheet();
    const originalRenderPage = ctx.renderPage;


    try {
      ctx.renderPage = () =>
        originalRenderPage({
          enhanceApp: (App) => (props) =>
            sheet.collectStyles(<App {...props} />),
        });


      const initialProps = await Document.getInitialProps(ctx);
      return {
        ...initialProps,
        styles: [
          <>
            {initialProps.styles}
            {sheet.getStyleElement()}
          </>,
        ],
      };
    } finally {
      sheet.seal();
    }
  }

...


_document.tsx에는 구현할 때 특별히 난이도가 높았던 사항은 없었다. 다만 SSR을 사용하고 있기 때문에 styled-components 설정을 위해서 _documents에 코드를 작성해주어야 했다.


5. _app.tsx

...

if (typeof window !== "undefined") {
  smoothscroll.polyfill();
}


const MyApp = ({ Component, pageProps }: AppProps) => {
  return (
    <SessionProvider session={pageProps.session}>
      <ThemeProvider theme={theme}>
        <GlobalStyles />
        <RecoilRoot>
          <Component {...pageProps} />
        </RecoilRoot>
      </ThemeProvider>
    </SessionProvider>
  );
};


export default MyApp;

마찬가지로 크게 특별한 것은 없는 _app.tsx이다. next/auth, styled-components, recoil을 사용하여 다음과 같이 설정해주었다. safari 브라우저에서는 behavior: "smooth"가 동작하지 않기 때문에 polyfill을 사용하였다.


내용이 길어서 2부로 이어집니다..