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

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

by 쿠리의일상 2024. 2. 12.

게시글 업로드

게시글을 올리기 위해 사용하는 textarea 의 크기가 자동으로 설정되게 해주는 라이브러리를 설치해줍니다.

npm install react-textarea-autosize

import reactTextareaAutosize from "react-textarea-autosize";

그저 단순히 텍스트 에리어의 크기를 늘려주는 라이브러리라 따로 설정할 건 없습니다!

 

게시글 이미지 프리뷰

  const [preview, setPreview] = useState<Array<string | ArrayBuffer | null>>([]);

우선 프리뷰 이미지를 담아줄 useState 를 지정해주고, 파일을 업로드하는 버튼에 onChangeFiles 함수를 만들어 줍니다.

 

  const onChangeFiles: ChangeEventHandler<HTMLInputElement> = (e) => {
    e.preventDefault();

    if (e.target.files) {
      Array.from(e.target.files).forEach((file, idx) => {
        const reader = new FileReader();
        reader.onloadend = () => {
          setPreview((prevPreview) => {
            const prev = [...prevPreview];
            prev[idx] = reader.result;
            return prev;
          });
        };
        reader.readAsDataURL(file);
      });
    }
  };

해당 함수를 하나하나 뜯어보자면, 우선 input 태그의 type file 이므로 원래 이벤트가 실행되지 않도록 preventDefault() 를 실행시켜줍니다. 그 다음이벤트 객체의 target 에 해당 이미지들이 담기므로 files 배열로부터 이미지들을 받아올 수 있습니다. 해당 배열을 돌며 각각의 FileReader 를 생성하고 파일을 읽어오고 끝나는 시점에서 onloadend 를 실행시키도록 만듭니다.

파일 리더의 result 속성에 읽어온 파일 정보가 들어가게 됩니다. 이 정보를 useState 에 담아주되, 앞전에 담아온 사항이 있을 수 있으니 [...prevPreview] 의 얕은 복사로 받아와주고 인덱스에 맞춰 result 를 담아줍니다.

 

FileReader

type 이 file인 input 태그나 API 요청에서 File/Blob 객체를 편하게 처리할 수 있는 객체입니다. 비동기적으로 동작하므로 페이지 렌더링에 영향을 주지 않게 됩니다.

const reader = new FileReader();

위처럼 new 연산자를 사용하여 객체를 생성하고, on 으로 시작하는 이벤트 핸들러 메서드를 활용하여 데이터를 읽어오는 과정을 거치고, 읽어온 파일 정보를 result 속성에 문자열로 받아오게 됩니다.

이벤트 핸들러에는 여러가지 있지만 대표적으로 아래와 같은 핸들러를 지원합니다.

  • onload
  • onerror
  • onprogress
  • onloadend <-> onloadstart
  • onabort

위 예제의 일부인 onloadend 핸들러를 사용하여 파일 로드가 끝나면 해당 함수의 내용을 실행하게 만들고, 파일을 readAsDataURL 을 통해 읽어옵니다.

reader.onloadend = () => {
  setPreview((prevPreview) => {
    const prev = [...prevPreview];
    prev[idx] = reader.result;
    return prev;
  });
};
reader.readAsDataURL(file);

읽어온 정보인 result 속성을 그냥 바로 사용할 순 없고 4가지의 방식중 선택해서 읽어올 수 있습니다.

  • readAsText()
    • 파일 객체의 내용을 텍스트로 읽기
  • readAsDataURL()
    • 파일 객체를 읽은 후 데이터 URL(바이너리 파일)로 변환, 이는 base64 로 이루어진 데이터입니다.
  • readAsArrayBuffer()
    • 파일 객체의 내용을 배열 버퍼로 읽음
  • readAsBinaryString()
    • 파일 객체의 내용을 비트 문자열로 읽어옴

그외 error 속성과 readyState 속성이 있으며, readyState 속성의 경우 0(읽기 작업 수행 안됨), 1(로딩, 읽는 중), 2(읽기 완료) 로 관리가 가능합니다.

 

파일 리더로 제대로 파일을 읽어왔다면 state 에 저장해준 이미지들을 특정 위치에 보이게끔 처리해줍니다. 이 경우엔 텍스트 에리어 아래에 추가해주도록 합니다.

 

게시하기 기능 구

이제 게시글과 이미지들을 올릴 수 있도록 게시하기 버튼을 누르면 요청을 보내줘야 합니다.

  const onSubmit: FormEventHandler = async (e) => {
    e.preventDefault();

    const formData = new FormData();
    formData.append("content", content);
    preview.forEach((p) => {
      p && formData.append("images", p.file);
    });

    fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/posts`, {
      method: "post",
      credentials: "include",
      body: formData,
    });
  };

이때 form 태그에 지정해준 onSubmit 이벤트 핸들러에 위와 같이 작성해줍니다. 해당 함수는 FormData 객체를 통해 정보를 담고 fetch시 body 에 담아서 보내줄 수 있습니다.

 

multipart/form-data 와 FormData

파일이나 이미지를 서버에 전송하기 위한 http 메시지의 content-type 속성에 해당합니다. 이 형식에 따라 메시지를 인코딩하여 전송하므로 이미지, 파일 등의 원본 파일 자체가 전송되는 것이 아니라는 점을 주의해야합니다.

주로 이미지나 파일을 서버로 보낼 때 FormData 를 사용하는데, append 메서드를 사용하여 (키, 값) 형식으로 객체를 채워줄 수 있습니다. 키에는 관련 정보를 식별 가능한 명명을 하는 것이 좋고, 값에는 위처럼 직접 지정해줘도 되고 혹여 input 태그의 value 를 받아와서 넣어줘도 됩니다.

그밖에 has(), get(), delete() 등의 메서드도 존재하므로 직접 정보를 다루지 않고 메서드를 활용해야합니다. 

 

로그인의 여부?

현재까지 구현한 바에 따르면 로그인을 거쳤지만 이는 서버에서 로그인 한 것이 아닌 프론트에서 로그인한 것입니다. 클라이언트의 경우 세션 쿠키로 로그인 여부를 확인할 수 있지만 백엔드의 경우는 세션 쿠키를 사용하지 않습니다. 별도의 토큰을 쓰는데, 강의 내에선 connect.sid 쿠키 토큰을 사용해준다고 합니다. 

  • 프론트 로그인 -> 세션 쿠키
  • 백엔드 로그인 -> connect.sid

그러므로 별도로 백엔드딴에서 로그인의 여부를 확인할 수 있는 쿠키를 추가 작성 해줄 필요가 있습니다. 

 

npm install cookie

cookie 라이브러리는 문자열로 된 쿠키를 쿠키 객체로 활용할 수 있게 만들어주는 라이브러리입니다. 해당 라이브러리와넥스트에서 지원해주는 쿠키를 가져와서 아래처럼 auth.js  에 추가해줍니다.

import { cookies } from 'next/headers'
import cookie from 'cookie';
// ...

 providers: [
    CredentialsProvider({
      async authorize(credentials) {
        const authResponse = await fetch(`${process.env.AUTH_URL}/api/login`, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({
            id: credentials.username,
            password: credentials.password,
          }),
        })
        let setCookie = authResponse.headers.get('Set-Cookie');
        console.log('set-cookie', setCookie);
        if (setCookie) {
          const parsed = cookie.parse(setCookie);
          cookies().set('connect.sid', parsed['connect.sid'], parsed); // 브라우저에 쿠키를 심어주는 것
        }
        if (!authResponse.ok) {
          return null
        }

        const user = await authResponse.json()
        console.log('user', user);
        return {
          email: user.id,
          name: user.nickname,
          image: user.image,
          ...user,
        }
      },
    }),
  ]

중간에 let setCookie 를 제외한 부분은 원래 작성했던 코드이며 아래의 코드만 추가된 건데요.

let setCookie = authResponse.headers.get("Set-Cookie");
if (setCookie) {
  const parsed = cookie.parse(setCookie);
  cookies().set("connect.sid", parsed["connext.sid"], parsed);
}

프론트측이 아닌 브라우저에 쿠키를 심는 코드입니다. 특히 cookie.parse 로 쿠키에 필요한 정보들을 cookies().set() 으로 넣어준 뒤 connect.sid 라는 임의의 쿠키명을 넣어주는 것입니다. 

 

요약하자면!

프론트 서버에 요청을 보낼 때는 쿠키를 통해서 정보를 얻어오고 

백엔드 서버에 요청할 때는 이 connect.sid 라우터 토큰을 통해 요청을 보내는 것입니다.

따로 지정해준 쿠키가 생겼습니다.

 

포스팅

보통 get은 200, post 의 성공은 201 코드로 나타내준다고 합니다.

지금까지 구현한 내용을 바탕으로 요청이 제대로 들어가고 있음을 알 수 있습니다. 이제 게시하기 버튼을 누르면 -> PostForm 내용을 비워주고 -> 아래 게시글 목록에 채워주는 작업을 해야합니다.

 

try {
      const response = await fetch(
        `${process.env.NEXT_PUBLIC_BASE_URL}/api/posts`,
        {
          method: "post",
          credentials: "include",
          body: formData,
        }
      );
      if (response.status === 201) {
        setContent("");
        setPreview([]);
        const newPost = await response.json();
        queryClient.setQueryData(
          ["posts", "recommends"],
          (prevData: { pages: Post[][] }) => {
            const shallow = { ...prevData, pages: [...prevData.pages] };
            shallow.pages[0] = [...shallow.pages[0]];
            prevData.pages[0].unshift(newPost);
            return shallow;
          }
        );
      }
    } catch (err) {}

게시하기 버튼에 위처럼 코드를 추가해줍니다. setContent와 setPreview 로 PostForm 을 초기화해주고 그 아래부턴 새 게시글을 ['posts', 'recommends'] 키에 넣어주는 과정입니다. 불변성을 지키기 위해 복잡하게 되어버렸습니다.

 

불변성

얕은 복사와 깊은 복사에 관련된 내용으로, 말그대로 변하지 않는 것을 의미합니다. 불변 데이터는 한번 생성되면 그 뒤에 변할 수 없습니다. 특히 원시타입에 해당하는 Boolean, String, Number, Null, undefined, Symbol 타입은 메모리 영역 안에서 변경이 불가하며 변수에 할당될 때 완전히 새로운 값이 만들어져서 재할당됩니다.

간단하게 number 타입을 예시로 들자면

let number = 1;
number = 10;

위의 number 변수는 10을 의미하는 1개로 구성된 것 같지만 실상은 동일한 타입의 값이 새로 생성 되고 1은 그대로 있되, number 변수가 10을 가리킬 것입니다.

불변성의 반대 타입은 객체 타입으로 변할 수 있는 값을 의미합니다. 배열을 예시로 들자면

let arr = [ 1 ];
let tmp = arr;

arr.push(2);
console.log(tmp); // [ 1, 2 ]
console.log(arr === tmp); // true

분명 arr의 값을 추가한 것임에도 tmp의 값도 변경된 것을 확인할 수 있습니다. 이는 tmp 가 arr의 값이 아닌 주소를 참조하고 있는 것이므로 주소 내의 값이 변하면 주소가 변한 것이 아니므로 이런 현상이 발생하게 되는 것입니다. 이런 현상은 예상한 대로 코드를 돌아가지 않게끔 만드는 주범이 되며, 디버깅에 어려움을 만들게 됩니다. 주소값만 비교하는 것을 얕은 복사라고도 하며, 우리가 원래 원하는 바는 arr의 값을 tmp에 복사하는 깊은 복사에 해당합니다.

이런 현상은 useState 의 객체 타입으로 초기화한 경우에 값이 변했음에도 상태가 변하지 않는 현상을 겪어봤을텐데요. 이 또한 불변성의 문제로 useState 의 setState 함수는 주소값의 변화에만 감지되기 때문에 객체 타입을 넣었다면 깊은 복사를 통해 상태를 관리해줘야 하는 수고로움이 생깁니다. 

이를 위해선 배열을 다룰 때 보통 map, 객체의 경우 ... 의 전개연산자를 사용해주는 것입니다. 간단하게 불변성을 지켜주기 위해선 Immer 라이브러리를 사용해줍니다.

즉 불변성을 지킨다는 것은 메모리 영역에서 값을 변경할 수 없게 하는 것입니다.