본문 바로가기
Web Study/Next.js

Next.js로 SNS x.com 클론코딩하기 - 2

by 쿠리의일상 2024. 1. 22.

지난 시간에 이어 본격적인 디자인 클론을 시작합니다. 사실 디자인 요소는 메모하고자 하는 부분이 별로 없어서... 간만에 테일윈드가 아닌 CSS를 직접 사용해서 신선했습니다. 역시 근본이 좋긴하네요. 테일윈드는 가독성이 좋지 않은 편이고 특정 속성이 기억 안나서 공식문서를 종종 보기도 했는데...ㅎㅎ

 

레이아웃 클론

flex-glow & flex-shrink

레이아웃을 막연히 따라 만들다가 보니 flex-glow 의 이해가 부족함을 느꼈습니다. 그럼 어떻게 하나? 당연히 공식문서와 서칭을 해봐야겠죠.

https://developer.mozilla.org/ko/docs/Web/CSS/flex-grow

 

flex-grow - CSS: Cascading Style Sheets | MDN

flex-grow CSS property 는 flex-item 요소가, flex-container 요소 내부에서 할당 가능한 공간의 정도를 선언합니다. 만약 형제 요소로 렌더링 된 모든 flex-item 요소들이 동일한 flex-grow 값을 갖는다면, flex-conta

developer.mozilla.org

가운데 정렬은 width, height 를 지정해준 다음 주로 margin 값을 auto로 줘서 구현하곤 했는데 대부분의 레이아웃 요소는 flex 를 사용하므로 flex-glow를 사용한 가운데 정렬은 신선했습니다.

공식문서에선 아래처럼 설명하고 있고, 트위터는 큰 container를 기준으로 가운데 정렬이 되어 있음을 알 수 있습니다.


flex-grow CSS property 는 flex-item 요소가, flex-container 요소 내부에서 할당 가능한 공간의 정도를 선언합니다. 만약 형제 요소로 렌더링 된 모든 flex-item 요소들이 동일한 flex-grow 값을 갖는다면, flex-container 내부에서 동일한 공간을 할당받습니다. 하지만 flex-grow 값으로 다른 소수값을 지정한다면, 그에 따라 다른 공간값을 나누어 할당받게 됩니다.

즉 flex-glow 는 남는 여백을 분배해서 채우는 방법인 것입니다. 이를 통해 가운데 정렬로 활용한 것이고요. 새로운 방식이라 이것저것 찾아보았습니다.

이와 더불어 종종 헷갈리던 flex-shrink도 찾아보았습니다. 해당 속성은 레이아웃을 벗어난 아이템 너비를 분배하고 줄여주는 속성입니다. 벗어나야한다는 조건이 붙으므로 flex-wrap: wrap 속성이 부여되지 않은 nowrap 상태여야 합니다.

기본적으로 default 가 1이므로 자동으로 아이템이 축소되곤 하는데 이를 막기 위해선 직접 flex-shrink: 0을 선언해줘야 합니다.

 

Active Link

홈페이지 navbar에서 메뉴를 클릭하면 짙어지는 효과를 의미한다고 합니다. navbar는 기본적으로 주소를 이동하므로 당연히 주소와 연동이 필요합니다.

이 기능을 위해선 서버 컴포넌트에선 불가하고 클라이언트 컴포넌트에서 작업이 필요하다고 합니다. 1편에서 말했다시피 클라이언트 컴포넌트 아래는 서버 컴포넌트가 있을 수 없다고 했으므로 클라이언트 컴포넌트를 별개로 구분해줄 필요가 있습니다.

navbar 전용 컴포넌트를 만들어주고, 해당 컴포넌트 안에 넥스트에서 지원되는 useSelectedLayoutSegment 훅을 이용합니다. 해당 훅은 당연히 클라이언트 컴포넌트 전용이며, 현재 active 된 최상위 라우트 주소를 반환해줍니다.

import { useSelectedLayoutSegment } from 'next/navigation';

만약 최상위를 포함한 모든 하위의 라우트 주소를 알고 싶다면, useSelectedLayoutSegments 훅을 사용해주면 된다고 합니다. 배열 형태로 라우트 주소를 반환해줍니다.

 const segment = useSelectedLayoutSegment();

이렇게 가져온 active route는 navbar에서 아래처럼 삼항 조건식으로 볼드 처리 해주면 됩니다.

<li>
    <Link href="/home">
      <div className={style.navPill}>
        {segment === 'home' ? (
          // 볼드 처리
        ) : (
          // 기본
        )}
      </div>
    </Link>
</li>

 

use client 사용 여부

보통 리액트에서 사용되던 훅이나 이벤트 리스너들이 있으면 클라이언트(브라우저)에서 작동하는 것이므로 클라이언트 컴포넌트가 되어야 합니다. 그러므로 서버 컴포넌트에서 분리시켜주는 게 중요합니다.

좌측 navbar 를 보시면 아래 로그아웃의 경우 onClick이 들어갈 수밖에 없습니다. 그러므로 원래의 navbar 컴포넌트는 서버 컴포넌트므로 분리해줄 필요가 있습니다.

 

backdrop-filter

이 속성도 자주 사용해보지 않아서 정리하려고 합니다. 

https://developer.mozilla.org/en-US/docs/Web/CSS/backdrop-filter

 

backdrop-filter - CSS: Cascading Style Sheets | MDN

The backdrop-filter CSS property lets you apply graphical effects such as blurring or color shifting to the area behind an element. Because it applies to everything behind the element, to see the effect the element or its background needs to be transparent

developer.mozilla.org

이 속성은 지정한 요소 뒤의 그래픽 요소의 블러나 색상을 바꿔줄 수 있는 속성입니다. 여러 속성들이 있어서 공식문서는 필수겠네요. x에선 블러처리를 해줘서 blur() 를 사용해주었습니다.

 

Context API

보통 전역 상태 관리하면 관련 라이브러리를 써서 Context API를 직접 사용해본 적은 없는데 강의에서 써봤습니다. 기본적으로 상태 관리를 위해선 Provider 로 묶어서 어느 영역을 상태관리 해줄 지 정해줘야합니다.

import { createContext } from 'react';

이 기능을 활용하여 추천탭과 팔로우탭의 상태를 관리하고자 할 때

export const TabContext = createContext({
  tab: 'rec',
  setTab: (value: 'rec' | 'fol'): void => {},
});

createContext() 로 위처럼 만들어줍니다.

export default function TabProvider({ children }: Props) {
  const [tab, setTab] = useState('rec');

  return (
    <TabContext.Provider value={{ tab, setTab }}>
      {children}
    </TabContext.Provider>
  );
}

Provider로 children을 넣어줘서 감싸주면 됩니다. 이때 value 값에 위에 지정해준 Context 내용을 기재해줍니다.

결론적으로 TabProvider > Tab 형태가 되면 아래와 같은 화면을 볼 수 있습니다.

export default function HomePage() {
  return (
    <main className={style.main}>
      <TabProvider>
        <Tab />
      </TabProvider>
    </main>
  );
}

왜 굳이 TabProvider 를 만들었는가 하면, HomePage 컴포넌트는 서버 컴포넌트므로 클라이언트의 기능을 못 쓰기 때문에 클라이언트 컴포넌트를 따로 만들어서, 불러와주는 형식으로 사용한 것입니다.

 

 

Server Action

이벤트 리스너가 필연적으로 많이 들어가게되는 폼 관련 컴포넌트는 당연하게도 클라이언트 컴포넌트가 될 수밖에 없습니다. 서버 액션이라는 기능이 서버 정보를 바로 보낼 수 있다는데, 아직 stable이 아니므로 다루지 않는다고 합니다. 서버 액션이라는 용어를 처음 들어봐서 간단하게 찾아보았습니다.

서버 관련 기능들을 컴포넌트 안에서 해결할 수 있기 때문에 API를 따로 만들 일 없이 거의 모든 기능을 컴포넌트 안에서 개발 가능한 기능

이는 php 등에서 사용하던 방식으로 프론트와 백이 뒤섞이기 때문에 많은 개발자들이 좋아하는 방식은 아니라고 합니다...

 

사용법은 우선 서버 액션을 사용한다는 설정을 next.config.js 에 해줍니다.

module.exports = {
  experimental: {
    serverActions: true,
  },
};

그런 다음, 서버 액션을 간단하게 구현한 뒤 이 기능을 form 태그 안 action에 넣어주면 끝

<form action={createSA}> ... </form>

서버 액션은 리액트에서 클릭 또는 서브밋 이벤트로 폼을 제출하는 방식이 아닌, 폼의 action을 사용해 서버로 폼의 데이터를 제출해주는 것입니다. form 태그 안 정보 FormData로 받아오게 되고, formdata.get() 형식으로 관련 정보를 받아올 수 있다고 합니다. 

그렇다면 서버 액션은 어떻게 만드는가에 대해.. 여기서 'use server' 명령을 해줘야 합니다.

"use server";

export const createSA = async (formData: FormData) => {
  const title = formData.get("title") as string;
  // ...
};

일단 이론은 얼추 이해됐는데 직접 사용하기 전까진 와닿지는 않습니다..ㅎㅎ stable 된 이후에 관련 기능을 다시 정리할 필요가 있을 것 같습니다.

 

day.js

시간/일자 관련 라이브러리는 moment.js 를 쓰다가 dayjs로 넘어왔다고 합니다. moment.js는 이제 업데이트를 하지 않고 큰 사이즈를 가지고 있는 등의 단점으로 이제 사용하지 않는다고 합니다.

day.js 는 가볍고 무엇보다 moment.js 와 달리 변경 불가한 구조라서 사용된다는데, 간단하게 사용법을 찾아보았습니다. 

import dayjs from "dayjs";

 

현재 시간 및 시간 객체 생성

const now = dayjs()
now.format()

 

날짜 및 시간 지정 객체 생성

const date = dayjs("2024-01-22")
date.format() // 2024-01-22T00:00:00+09:00

이처럼 원하는 날짜와 시간을 입력하여 객체를 생성할 수 있습니다.

format() 함수는 기본적으로 default 형태로 반환하지만, 직접 원하는 형태를 입력하여 해당 형태의 문자열로 변경해줄 수 있습니다. "YY-MM=DD", "DD/MM/YY" 등 Y는 년도, M은 달, D는 일, H는 시간, m 분, s 초를 해당합니다.

 

날짜 객체에서 원하는 단위를 구하기

var now = dayjs();
now.format();

now.get("year");
now.get("y"); 

now.get("date");
now.get("D");

now.get("day"); // 0 (요일 - 일요일 : 0, 토요일 : 6)
now.get("d"); // 0 (요일 - 일요일 : 0, 토요일 : 6)

now.get("hour");
now.get("h"); 

now.get("minute");
now.get("m");

 

그외에도 날짜 및 시간을 더하는 add()나 빼는 subtract(), 두 날짜 사이의 차이를 구할 수 있는 diff() 등의 함수가 존재합니다. 자세한 사항은 필요한 내용에 따라 공식문서를 참고하는 것을 추천드립니다. 

https://day.js.org/

 

Day.js · 2kB JavaScript date utility library

2kB JavaScript date utility library

day.js.org

date-fns 라는 라이브러리도 존재하니 혹여 필요하다면 참고하는 것이 좋을 것 같습니다.

 

그럼 x 홈페이지에서 Post를 게시했을 때 나오는 시간을 어떻게 처리하느냐가 해당 라이브러리를 설치한 이유로, dayjs 의 fromNow() 함수를 사용합니다. 해당 함수는 현재 날짜로부터 몇 시간 전인지 알려주는 함수입니다.

이는 dayjs를 import 했다고 바로 사용해줄 순 없고, 플러그인 형태로 사용해야 하므로 아래와 같이 dayjs/plugin 에서 import 해준 다음, dayjs에 extend()해줘야 합니다.

import relativeTime from 'dayjs/plugin/relativeTime'
dayjs.extend(relativeTime)

 

dayjs(target.createdAt).fromNow(true)

위처럼 만들어준 시간을 기준으로 잡고, 현재 시간에 비해 얼마 전인지 표현해줄 수 있습니다. 참고로 fromNow()의 true는 ago, ~전 의 부사의 사용 여부를 의미합니다. 사용하지 않는 게 바로 true입니다. (default 는 false)

 

그리고 기본 언어는 영어이고, 한글 또한 지원하므로 한글 locale 을 설정해줘야 합니다.

import 'dayjs/locale/ko';
dayjs.locale('ko');

 

classnames

x의 게시글들을 보면 좋아요, 리트윗 등의 버튼들이 존재합니다. 이 버튼들을 만들 때 사용해줄 classnames 라이브러리는 Css Module에서 css를 객체 형식으로 불러올 때 유용합니다. 하나의 태그 안에 여러 개의 class를 가질 수 있다는 점을 활용하여 조건부로 class 를 가질 수 있게끔 보조하는 라이브러리입니다.

 

여러 사용법이 있는데, 강의에서 사용해준 방법으로는 

import cx from 'classnames';

<div className={cx(style.commentButton, { [style.commented]: commented })}> ... </div>
<div className={cx(style.repostButton, reposted && style.reposted)}> ... </div>
<div className={cx([style.heartButton, liked && style.liked])}> ... </div>

우선 첫번째 방식을 보면 style.commentButton은 조건 없이 추가된 것이며, { [style.commented]: commented } 부분은 commented 가 true 일 때 style.commented 가 추가된다는 의미입니다.

그렇다면 두 세번째 방식은 동일하게 style.repostButton, style.heartButton 은 조건없이 추가되고, reposted와 liked 조건이 true 일 때 style.reposted, style.liked 가 추가되는 것입니다. 다만 classnames 안에 배열 형태 또는 ,를 구분자로 사용하여 나열 해줘도 동일한 결과를 얻을 수 있다는 것을 알 수 있습니다.

.heartButton button {
  background-color: rgba(249, 24, 128, 0.01);
}

.heartButton:hover button {
  background-color: rgba(249, 24, 128, 0.1);
}

.heartButton.liked svg,
.heartButton:hover svg {
  fill: rgb(228, 34, 126);
}

.heartButton.liked .count,
.heartButton:hover .count {
  color: rgb(228, 34, 126);
}

하트 버튼의 스타일을 보면, 위와 같이 liked 스타일과 heartButton 스타일이 같이 있을 경우에만 실행되게끔 스타일링하여 classnames 를 활용했음을 알 수 있습니다.

 

참고: https://github.com/ZeroCho/next-app-router-z/tree/master/ch2-1