웹퍼블리셔 포트폴리오 제작

조아·2026년 1월 13일
post-thumbnail

✍️ Intro

중고 신입으로 입사를 준비하면서 포트폴리오를 새롭게 제작하게 되었습니다.

보여주고 싶은 내용을 중요도 순으로 배치하여 한눈에 파악할 수 있도록 구성했으며,
각 프로젝트를 클릭하면 상세 페이지로 이동해 내용을 확인할 수 있습니다.

포트폴리오에서 프로젝트가 가장 중요한 부분이라고 생각하여
소개 화면 바로 아래에 배치하였고, 애니메이션을 적용하여 시각적으로도 눈에 잘 들어오도록 구성했습니다.

그리고 처음 접하는 분들도 대략 어떤 프로젝트인지 쉽고 빠르게 파악할 수 있도록
각 프로젝트별로 상세 페이지를 제작해 구현 기능과 트러블 슈팅을 정리했습니다.
이를 통해 작업 결과뿐만 아니라 문제 해결 과정까지 함께 전달하고자 했습니다.

📌 상세 정보

https://hayeon-portfolio.vercel.app/

개발 기간: 2025/12 ~ 2026/01 (한 달 소요)

Design tool: Figma

사용 기술 및 툴: HTML5 / SCSS / JavaScript

ETC: Github 사용 / Windows 환경에서 개발

배포: Vercel

📁 폴더 구조

📁 assets

  • 이미지, 아이콘 등을 모아놓은 폴더입니다.

📁 data

  • 블로그 데이터를 담은 JSON 파일이 들어있는 폴더입니다.

📁 js

  • JavaScript 파일을 모아놓은 폴더입니다.

📁 project

  • 프로젝트 상세페이지에 해당되는 html 파일들을 모아놓은 폴더입니다.

📁 styles

  • base: 프로젝트 전역에 공통적으로 적용되는 기본 스타일을 모아둔 폴더입니다.
  • components: 작은 스타일 조각들을 관리하는 폴더입니다.
  • contants: 스타일 관련 변수를 정의한 폴더입니다.
    - 컬러 팔레트, 타이포그래피, z-index 등의 값을 변수로 선언하여 스타일을 일관성있게 사용할 수 있도록 하였습니다.
  • layouts: 페이지의 기본 구조나 공통 레이아웃에 적용되는 스타일을 정의한 폴더입니다.
  • mixins: 반복적으로 사용되는 스타일 속성을 재사용하기 위해 만든 폴더입니다.
    - flex, position, media query 등 자주 사용하는 속성을 SCSS의 @mixin 문법을 활용해 정의했습니다.
  • modules: 여러 군데에서 사용되는 스타일을 정의한 폴더입니다.

✨ 구현 기능

📍 @keyframe 무한 롤링 애니메이션

@keyframes를 활용해 블로그 슬라이드가 무한으로 롤링되는 애니메이션을 구현했습니다.
슬라이드 아이템을 감싸는 리스트를 하나 더 복제한 뒤, 기존 리스트 바로 오른쪽에 배치해 연속적인 흐름을 만들었습니다.

애니메이션은 두 개의 리스트(real, clone)가 서로 다른 타이밍으로 왼쪽 방향으로 이동하도록 구성했습니다. 이 방식으로 한쪽 리스트가 화면을 벗어나는 동안 다른 리스트가 자연스럽게 뒤를 이어 등장하게 됩니다.

특히 real 리스트는 애니메이션 진행률이 50%에 도달했을 때 화면 왼쪽 바깥으로 완전히 사라지도록 설정했고, 바로 다음 프레임인 50.01% 지점에서 translateX(100%)로 위치를 이동시켜 clone 리스트의 오른쪽 뒤에 다시 배치되도록 했습니다.
이를 통해 위치 이동으로 인한 깜빡임 없이 슬라이드가 자연스럽게 이어지도록 했습니다.

또한 슬라이드 영역에 마우스를 올리면 애니메이션이 일시 정지되도록 animation-play-state: paused를 적용해 사용자가 콘텐츠를 확인하기 쉽도록 UX를 고려했습니다.

// 무한 롤링 애니메이션 적용
  .rolling-container {
    display: flex;
    overflow: hidden;

    &:hover {
      .rolling-wrapper {
        animation-play-state: paused;
      }
    }
  }

  .rolling-wrapper {
    &.real {
      animation: rolling-left-1 60s linear infinite;
    }

    &.clone {
      animation: rolling-left-2 60s linear infinite;
    }

    ul {
      @include flexbox.flexbox(start);
    }
  }
  
  // 무한 롤링 애니메이션 정의
  @keyframes rolling-left-1 {
  0% {
    transform: translateX(0%);
  }
  50% {
    transform: translateX(-100%);
  }
  50.01% {
    transform: translateX(100%);
  }
  100% {
    transform: translateX(0%);
  }
}

@keyframes rolling-left-2 {
  0% {
    transform: translateX(0%);
  }
  100% {
    transform: translateX(-200%);
  }
}

📍 마우스 오버 감지 애니메이션

마우스 오버 영역을 보다 안정적으로 감지하기 위해
페이크 영역(head-list)과 실제 콘텐츠 영역(tail-list)을 분리하여 구성했습니다.

1️⃣ 영역 분리 구조

  • project-head-list → 마우스 오버 감지를 위한 페이크 영역
  • project-tail-list → 실제 콘텐츠와 애니메이션이 적용되는 영역

각 head-item은 퍼센트 단위의 width를 가지고 있어 화면을 균등하게 분할하며,
마우스 위치에 따라 어떤 영역이 hover 되었는지를 명확하게 판단할 수 있도록 했습니다.

<ul class="project-head-list">
  <li class="project-head-item">
    <a href="/project/project01.html">1</a>
  </li>
  <li class="project-head-item">
    <a href="/project/project02.html">2</a>
  </li>
  <li class="project-head-item">
    <a href="/project/project03.html">3</a>
  </li>
</ul>

<ul class="project-tail-list">
  <li class="project-tail-item">...</li>
  <li class="project-tail-item">...</li>
  <li class="project-tail-item">...</li>
</ul>
  • head-item 요소에 width 퍼센트 값으로 영역을 지정
  • 스크립트로 head-item 요소의 마우스 이벤트 효과를 감지하여 현재 hover된 요소를 구분
  • hover된 head-item 요소에 해당되는 tail-tiem 요소의 width 값을 100%로 줘서 너비를 꽉 차지

2️⃣ 마우스 이벤트 감지 로직

스크립트에서는 head-item 요소에 mouseenter 이벤트를 등록해
현재 hover된 요소의 인덱스를 기준으로 대응되는 tail-item을 제어합니다.

hover가 발생하면 해당 tail-item에 hover 클래스를 추가하고,
이 클래스에 width: 100% 등의 스타일을 적용해 선택된 콘텐츠가 화면을 가득 차지하도록 했습니다.

const projectHeadList = document.querySelectorAll('.project-head-item');
const projectTailList = document.querySelectorAll('.project-tail-item');

projectHeadList.forEach((item, idx) => {
  item.addEventListener('mouseenter', function () {
    projectTailList[idx].classList.add('hover');

    item.addEventListener('mouseleave', function () {
      projectTailList[idx].classList.remove('hover');
    });
  });
});

📍 탭 메뉴 클릭 시 스크롤 이동, 스크롤에 따른 탭 활성화

탭 클릭 시에는 해당 섹션으로 부드럽게 스크롤 이동하고,
사용자가 직접 스크롤할 경우에는 현재 위치에 맞는 탭이 자동으로 활성화되도록 구성했습니다.

1️⃣ 탭 메뉴 클릭 시 스크롤 이동
비활성화된 탭 메뉴를 클릭하면 다음 과정을 거쳐 동작합니다.

  1. 클릭된 탭에 is-active 클래스를 추가해 활성화
  2. 기존 활성화된 탭에서 is-active 클리스를 제거
  3. 클릭한 탭과 동일한 id를 가진 탭 패널을 찾음
  4. 해당 패널의 y축 위치를 계산해 해당 패널로 스크롤 이동

스크롤 시 화면 상단에 고정되어 있는 헤더 바로 아래에 위치할 수 있도록 getBoundingClientRect().top 메서드로 구한 y축 절대 좌표 값에 헤더 높이값을 뺀 만큼 이동합니다.

const sectionTab = document.querySelector('.header .header-tab-list');
const sectionTabButtonList = sectionTab.querySelectorAll(
  '.header-tab-item button',
);
let currentActiveTab = sectionTab.querySelector('.is-active');

const TOP_HEADER_DESKTOP = 86;

let disableUpdating = false;

/* 비활성화 탭을 클릭 시 활성화시키기 */
function toggleActiveTab() {
  const tabItem = this.parentNode;

  if (tabItem !== currentActiveTab) {
    disableUpdating = true;
    tabItem.classList.add('is-active');
    currentActiveTab.classList.remove('is-active');
    currentActiveTab = tabItem;

    setTimeout(() => {
      disableUpdating = false;
    }, 1000);
  }
}

/* 탭 클릭 시 해당 섹션으로 스크롤 이동 */
function scrollToTabPanel() {
  const tabPanelId = this.parentNode.getAttribute('aria-labelledby');
  const tabPanel = document.querySelector(`#${tabPanelId}`);

  const scrollAmount =
    tabPanel.getBoundingClientRect().top - TOP_HEADER_DESKTOP;

  window.scrollBy({
    top: scrollAmount,
    behavior: 'smooth',
  });
}

sectionTabButtonList.forEach((button) => {
  button.addEventListener('click', toggleActiveTab);
  button.addEventListener('click', scrollToTabPanel);
});

2️⃣ 스크롤에 따라 탭 메뉴 활성화
페이지 로드 시점이나 창 크기 변경 시에는
각 섹션의 y축 위치 값이 달라질 수 있기 때문에
load, resize 이벤트에 각 섹션 위치를 계산하는 함수를 연결했습니다.

그리고 스크롤할 때마다 현재 스크롤 위치가 어떤 섹션 영역에 해당되는지 판단해 해당되는 탭을 활성화시킬 수 있도록 scroll 이벤트에 해당 함수를 연결했습니다.

이때 반응형 레이아웃을 고려해 스크롤 기준값을 디바이스 크기에 따라 다르게 계산했습니다.

/* 탭 패널 y축 위치 감지 */
const sectionTabPanelIdList = [
  'about',
  'project',
  'career',
  'skill',
  'activity',
  'blog',
];

const sectionTabPanelList = sectionTabPanelIdList.map((panelId) => {
  const tabPanel = document.querySelector(`#${panelId}`);
  return tabPanel;
});

const sectionTabPanelPositionMap = {};

function detectTabPanelPosition() {
  sectionTabPanelList.forEach((panel) => {
    const id = panel.getAttribute('id');
    const position = panel.getBoundingClientRect().top + window.scrollY;
    sectionTabPanelPositionMap[id] = position;
  });

  updateActiveTabOnScroll();
}

/* 스크롤 위치에 따라서 active 탭 업데이트 */
function updateActiveTabOnScroll() {
  if (disableUpdating) return;

  const scrollAmount =
    window.scrollY +
    (window.innerWidth >= 1200
      ? TOP_HEADER_DESKTOP + 130
      : TOP_HEADER_DESKTOP + 110);

  let newActiveTab;
  if (scrollAmount >= sectionTabPanelPositionMap['blog']) {
    newActiveTab = sectionTabButtonList[5];
  } else if (scrollAmount >= sectionTabPanelPositionMap['activity']) {
    newActiveTab = sectionTabButtonList[4];
  } else if (scrollAmount >= sectionTabPanelPositionMap['skill']) {
    newActiveTab = sectionTabButtonList[3];
  } else if (scrollAmount >= sectionTabPanelPositionMap['career']) {
    newActiveTab = sectionTabButtonList[2];
  } else if (scrollAmount >= sectionTabPanelPositionMap['project']) {
    newActiveTab = sectionTabButtonList[1];
  } else {
    newActiveTab = sectionTabButtonList[0];
  }

  const bodyHeight = document.body.offsetHeight;
  if (window.scrollY + window.innerHeight === bodyHeight) {
    newActiveTab = sectionTabButtonList[5];
  }

  if (newActiveTab) {
    newActiveTab = newActiveTab.parentNode;

    if (newActiveTab !== currentActiveTab) {
      newActiveTab.classList.add('is-active');

      if (currentActiveTab !== null) {
        currentActiveTab.classList.remove('is-active');
      }
      currentActiveTab = newActiveTab;
    }
  }
}

/* global _ */
window.addEventListener('load', detectTabPanelPosition);
window.addEventListener('resize', _.throttle(detectTabPanelPosition, 1000));
window.addEventListener('scroll', _.throttle(updateActiveTabOnScroll, 300));

📍 throttle 웹 성능 개선

위의 scroll, resize 이벤트에 함수가 걸려있어 스크롤을 조금만 움직이거나, 창 크기를 살짝만 조절해도 함수가 과도하게 반복 실행되어 성능 저하가 우려되었습니다.

문제 해결을 위해 lodash의 throttle을 활용하여 함수를 일정 간격으로만 실행되도록 제한하였습니다.


📍 JSON 데이터 fetch로 불러오기

무한 롤링 애니메이션에 들어가는 블로그 슬라이드 갯수가 많아 하드코딩하면 코드가 너무 길어지고 유지보수하기 번거로울 것 같았습니다.

그래서 하드코딩 대신 JSON 데이터를 만들어서 fetch를 이용하여 받아온 뒤 동적으로 요소들이 생성되도록 만들어, 블로그 데이터를 쉽고 빠르게 수정할 수 있게 하고 싶었습니다.

fetch() 함수로 JSON 파일의 데이터를 요청하고 받은 응답을 JavaScript 객체로 변환해 가공하여 화면에 출력했습니다. 그리고 에러가 났을 경우를 대비하기 위해 catchresponse.ok 검사를 통해 에러 처리를 하였습니다.

fetch('../data/blog-data.json')
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 오류! 상태: ${response.status}`);
    }
    return response.json();
  })
  .then((posts) => {
    renderBlogData(posts);

    const container = document.querySelector('.rolling-container');
    const originalWrapper = document.querySelector('.rolling-wrapper');
    originalWrapper.classList.add('real');

    const clone = originalWrapper.cloneNode(true);
    clone.classList.add('clone');

    container.appendChild(clone);
  })
  .catch((error) => {
    console.log(error.message);
  });

💬 회고록

회고록은 혼잣말처럼 반말로 작성하겠습니다! 👀

🌱 프로젝트 진행 전

이직을 준비하면서 기존에 만들어 둔 포트폴리오를 사용하려고 했으나, 프로젝트를 간단히 소개하는 이전의 방식으로는 작업의 깊이를 충분히 전달하기 어렵다고 판단했다.
이에 각 프로젝트마다 상세페이지를 만들고 구현 기능과 트러블 슈팅을 정리하여 작업 과정을 자세히 보여주는 방식으로 포트폴리오를 새롭게 구성했다.

그리고 수많은 포트폴리오를 봐야하는 면접관 입장에서 내가 어떤 이력을 가진 사람인지, 프로젝트에 대해 쉽고 빠르게 파악할 수 있도록 구성하는 것에 대해 고민을 많이 했다. 그래서 스크롤 형식으로 메인 페이지를 만들어 필수적인 것들만 심플하게 넣고 디테일한 부분은 클릭하여 추가로 볼 수 있도록 구성하였다.

화려한 인터랙션의 포트폴리오를 만들고 싶었지만 회사를 그만두고 취업을 빨리 해야하는 상황이었기에 오랜 시간 공들여서 디자인할 여유가 없었다. 그래서 빨리 완성하고 남는 시간에 GSAP, 브라우저 렌더링 등의 공부를 하는 것이 더 좋겠다고 판단하여 디자인은 비교적 심플하게 만들었다.

🌱 프로젝트 진행 후 얻은 것

이번 프로젝트 제작을 통해 throttle을 사용하여 웹 성능을 개선하는 방법에 대해 알게 되었다.

퍼블리셔로 근무할 때 DOM 이벤트를 사용할 일이 정말 많았는데
너무 많은 이벤트를 적용하면 성능이 떨어지는 것이 눈에 보일 때가 종종 있었다.

앞으로 실무에서도 간편하게 활용하기 좋을 것 같다👍

🌱 더 공부해야 할 것

throttle 이외에도 웹 성능을 개선하는 방법에 대해 더 알아봐야겠다.
그리고 개선되는 원리를 더 잘 이해할 수 있도록 웹 작동 방식에 대해 깊은 공부가 필요할 것 같다.



부족한 긴 글 봐주셔서 감사합니다!! 🙇‍♀️

profile
그냥 계속 하기 🏃

0개의 댓글