New 포트폴리오

김동현·2023년 4월 26일
0

개인 프로젝트

목록 보기
9/13
post-thumbnail

목적

구 버전의 포트폴리오가 너무 허접해서 새 포트폴리오를 만들기로 했다.
이번엔 styled components으로 디자인하고 create-react-app 과 vite 대신 next.js로 개발해보았다.

사용한 기술

react18+ ( react function components )
styled components
next.js(13미만 버전의 코드형식)
react-icons
react-intersection-observer
reqeustAnimationFrame API
typed.js
useMemo Hook

느낀점

우선 next.js 공식 홈페이지 튜토리얼이 12버전인것을 뒤늦게 알아차렸다.
공부할 때만 하더라도 13버전인줄 알았다.
이왕 이렇게 된 것, 13버전 숙달을 위한 프로젝트를 하나 더 해야겠다.

이번 프로젝트에서는 api 기능이 필요없어서 따로 구현하지는 않았지만 next.js에서의 api기능은 정말 간단했다.
api폴더에 js파일을 만들고

// 미리 작성되어 나오는 파일
export default function handler(req, res) {
  res.status(200).json({ name: 'John Doe' })
}

요런식으로 하면 끝이다.
routing도 폴더 구조 그대로 가져오기 때문에 정말 간편하고 쉬운 프레임워크란 것을 느꼈다.

다만, styled components와의 호환이 잘 되지 않는 느낌이었다.
콘솔창에 에러가 계속 뜨는데 찾아보니 개발서버에서만 나오는 에러이며 서버와 클라이언트에서의 클래스명이 서로 달라서 발생하는 에러였다.
프레임워크간에 충돌이 나면 발생할 수 있는 에러라고 한다.

이러한 에러는 next.js의 hydrogen 기능때문에 일어난다.
pre-rendering과 first-rendering 사이의 데이터가 다르기 때문이다.

이러한 점을 next.js팀에서도 인지했는지 공식 홈페이지에 가보면 styled components를 사용하기 위한 설치 툴체인을 따로 제공하고 있는걸 봤다.
즉, next.js 와 styled components를 따로 설치하면 에러가 나고 next.js에서 제공하는 툴체인으로 설치하면 에러가 안나는것 같다.( 안해봐서 정확히 모른다.)

어쨌뜬 개발서버에서만 발생하는 에러라고 하기에, 무시하고 계속 진행했다.
현재 donghyunportfolio.com에 배포하고 있는 중인데 에러가 안나는 걸 보니 개발서버에서만 발생하는 에러가 맞았나 보다.


네비게이션 바에서 링크를 클릭하면 페이지 내의 섹션으로 이동하는 기능을 구현했다.
그런데 CSS의 scroll-behavior: smooth 가 제대로 동작하지 않는 것을 확인했다.
필요없는 코드를 모두 제거하고 가장 기본적인 형태의 코드로 디버깅해보니 Link 컴포넌트가 문제였다.

next.js에서의 Link 컴포넌트는 동적 라우팅을 위한 목적으로 만들어진 컴포넌트이다.
이 경우엔 페이지 내에서의 이동이었기 때문에 부적절한 것이었다.

그런데 왜 안될까?
Link 컴포넌트도 결국 a 엘리먼트로 변환되고 랜더링 될 텐데 말이다.
개발자 도구를 열어보니 그 답이 나왔다.

<Link href="#home">Home</Link>

이 코드는 아래의 코드로 변한다.

<a href="/#home">Home</a>

# 이 아닌 /# 이 붙어있다.
즉, 라우팅을 한 뒤에 #home 으로 이동하는 방식이었다.
그래서 동작하지 않은 것이다.

따라서 Link 컴포넌트 대신 a 엘리먼트를 적용했더니 제대로 동작했다.


img 엘리먼트와 같은 replaced element는 flex, grid 아이템일 경우엔 object-fit 속성이 동작하지 않는다.

알고 있었던 개념이다.

따라서 flex, grid 아이템 내에서 replaced element를 사용할 때는 컨테이너를 만들고 컨테이너의 크기를 고정시킨 후, replaced element에 width:100%, height:100% 하는 방식으로 사용했다.

<div class="container">
  <div class="item"><img .../ ></div>
  <div class="item"><img .../ ></div>
</div>
.container{
  display:flex;
}
.item{
  flex: 1 1 200px;
}
img{
  width: 100%;
  height: 100%;
}

이 방법의 불편함은 object-fit의 값인 contain, cover를 구현하기 힘들다는 점이다.
그동안 불편했지만 어찌어찌 잘 구현이 되어서 넘어갔는데 이번에 이 문제와 직면하게 되었다.

검색을 해서 좋은 방법을 찾아냈다.

.container{
  display:flex;
}
.item{
  flex: 1 1 200px;
}
img{
  width: 100%;
  height:100%;
  object-fit:cover;
  object-position: left bottom;
}

바로 위와 같은 방식이다.
달라진건 위의 코드에서 object-* 속성을 추가한 것 뿐이다.
flex, grid 아이템에서 object-* 속성을 사용하지 못하는 이유가 replaced element의 크기를 flex, grid를 적용한 부모 엘리먼트가 정하기 때문이다.
따라서 replaced element를 감싸는 컨테이너를 만들고 replaced element의 크기를 명시적으로 설정하면 object-* 속성을 사용할 수가 있었던 것이다
(요부분을 몰랐었네...)

또는 아래와 같은 방법으로도 사용가능하다.

.container{
  display:flex;
}
.item{
  flex: 1 1 200px;
  position: relative;
}
img{
 position:absolute;
  top:0;
  left:0;
  width: 100%;
  height:100%;
  object-fit:cover;
  object-position: left bottom;
}

아래 방법의 의문점은 이 방법으로 할때 replaced element의 크기를

top:0;
left:0;
width: 100%;
height:100%;

로만 컨테이너와 동기화 시켜야 한다는 점이다.
inset:0 으로 동기화시키면 적용되지 않는다.

찾아보니, inset은 컨테이너로부터 간격을 계산하는 속성이라서 자식 엘리먼트의 크기로 보기엔 무리가 있다고 한다.(근데 그게 그거아닌가... 뭔가 찝찝하네)

뭔가 직관적으로

top:0;
left:0;
width: 100%;
height:100%;

는 자식 엘리먼트로부터 부모 엘리먼트로 계산해나가는 느낌이라면
inset:0 은 부모 엘리먼트로부터 자식 엘리먼트로 계산해나가는 느낌으로 받아들여진다.

그래서 안되나보다 ㅎ..


이전의 프로젝트에서는 뷰포트에 특정 엘리먼트가 나타날 때 애니메이션을 부여하기 위해서 IntersectionObserver API를 이용했었다.
직접 Web API를 가져와서 커스텀 훅과 커스텀 컴포넌트를 만들어 사용했었다.

npm 패키지를 찾아보니 react-intersection-observer 라는 사용하기 쉬운 패키지가 있었다.
깃헙에서 사용법을 보니 꽤 간단했다.

<InView>
  {({ inView, ref }) => (
    <div inView>
      <p ref={ref}>abc</p>
    </div>
  )}
</InView>

InView 컴포넌트를 이용하면 끝이었다.
InView 컴포넌트는 함수 컴포넌트를 children으로 전달받는 컴포넌트였다.
ref로 참조할 엘리먼트를 설정하고 해당 엘리먼트가 뷰포트에 나타나는 순간 inView state가 true로 변한다.

뷰포트에서 보여지고 안보여질 때마다 true / false로 변하니까 클래스명으로 전달해서 애니메이션으로 사용할 수도 있고 props으로 전달할 수도 있고 알아서 하면 된다.

나 같은 경우엔 <div $inView={inView} /> 로 styled-component의 prop으로 전달해서 애니메이션을 트리거했다.


마우스 이동에 따른 3D 입체 동작을 수행하는 것을 내 사진 엘리먼트를 대상으로 적용해보고 싶었다.
당장 생각나는건 해당 엘리먼트에 mousemove, mouseout 이벤트를 추가하는 것이었다.

리액트에서의 이벤트 핸들러 추가방법은 addEventListenr 로 하는게 아닌, 인라인스타일로 추가하는 방법이 일반적이라고 배웠다.

<ImgContent onMouseMove={mouseMoveHandler} onMouseOut={mouseOutHandler} >
...
</ImgContent>

핸들러에 console.log(e)를 찍어보니 마우스가 동작할때 마다 뭔가 열심히 출력된다.
엘리먼트를 기준으로 위치좌표를 알아내기 위해 e.offsetX를 출력했는데 undefined가 나온다.
그런데 e.clientX는 잘 나온다.
이..뭔..
따라서 e.offsetX대신 e.clientX를 기준으로 위치좌표를 알아내기로 했다.

const x = e.clientX;
const y = e.clientY;
const boxRect = box.getBoundingClientRect();
const boxCenterX = boxRect.left + boxRect.width / 2;
const boxCenterY = boxRect.top + boxRect.height / 2;
const deltaX = x - boxCenterX;
const deltaY = y - boxCenterY;

이런식으로 작성했다.

이 블로그를 작성하는 도중에 알게 되었는데, 리액트에서는 이벤트 핸들러로 이벤트 객체를 넘길때 기존의 이벤트 객체를 넘겨주는게 아니라고 한다.

리액트 공식 홈페이지 曰:
이벤트 핸들러는 모든 브라우저에서 이벤트를 동일하게 처리하기 위한 이벤트 래퍼 SyntheticEvent 객체를 전달받습니다. stopPropagation() 와 preventDefault()를 포함해서 인터페이스는 브라우저의 고유 이벤트와 같지만 모든 브라우저에서 동일하게 동작합니다.
브라우저의 고유 이벤트가 필요하다면 nativeEvent 어트리뷰트를 참조하세요. 합성 이벤트는 브라우저 고유 이벤트에 직접 대응되지 않으며 다릅니다. 예를 들어 onMouseLeave에서 event.nativeEvent는 mouseout 이벤트를 가리킵니다. 구체적인 연결은 공개된 API의 일부가 아니며 언제든지 변경될 수 있습니다. 모든 합성 이벤트 객체는 다음 어트리뷰트를 가집니다.

알고보니 e.nativeEvent.offsetX 로 하면 되는 것이다.
실행해서 잘 동작하는지도 확인해봤다.

여기까지가 마우스 움직임에 따른 뷰포트 기준의 좌표를 엘리먼트 기준의 좌표로 변화한 것이다.

이 좌표를 가지고 엘리먼트에 3d 입체 효과를 주기위해 뭔가 rotate() 의 x각도와 y각도를 어쩌구 저쩌구 하면 될 듯했다.

const rotateX = deltaY / 10;
const rotateY = -deltaX / 10;
box.style.transform = 
  `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.1, 1.1, 1.1)`;

요로콤 하니까 적절하게 잘 동작한다.

입체감을 주기 위해 perspective와 scale을 적용했다.

그런데 마우스가 움직일 때마다 너무 많은 비용이 든다는 느낌적인 느낌이 느껴졌다.

이런 이벤트 최적화에 사용할 수 있는 Web API로 requestAnimationFrame API 가 있다.

let requestId;
  const mouseMoveHandler = (e) => {
    const box = e.currentTarget;
    const x = e.clientX;
    const y = e.clientY;
    const boxRect = box.getBoundingClientRect();
    const boxCenterX = boxRect.left + boxRect.width / 2;
    const boxCenterY = boxRect.top + boxRect.height / 2;
    const deltaX = x - boxCenterX;
    const deltaY = y - boxCenterY;
    const rotateX = deltaY / 10;
    const rotateY = -deltaX / 10;
  
    cancelAnimationFrame(requestId);
    requestId = requestAnimationFrame(() => {
      box.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.1, 1.1, 1.1)`;
    });
  };

requestAnimationFrame API 는 초당 60bps로 랜더링을 목표로 하되, 브라우저나 컴퓨터의 성능에 따라 알아서 맞춰준다.


프로젝에서 사용된 데이터들은 하드코딩한 것이 아니라 constants.js 한 곳에 모아서 사용했다.

프로젝트를 하나 둘 씩 만들때마다 이 파일만 수정하면 된다.

결과물

깃헙 저장소 : 깃헙↗️

라이브 사이트 : 호스팅↗️

profile
프론트에_가까운_풀스택_개발자

0개의 댓글