2023.06.20 ~ 2023.08.21

DEMUU

Summary

Front-End Development

개인 프로젝트 / Demuu (Portfolio)

포트폴리오, 프로젝트 갤러리, 그리고 블로그 등을 한데 모은 통합 웹입니다.

이 웹사이트는 단순한 이력서와 포트폴리오 이상으로, 저를 보다 풍부하게 소개하고자 만들게 되었습니다.

포트폴리오, 프로젝트, 블로그 등 저와 관련된 모든 정보를 한 곳에서 확인할 수 있습니다.

웹 개발을 처음 공부했을 때부터 제작하고 싶었던 동적인 요소들이 풍부한 인터랙티브 웹을 디자인했습니다.

사용자가 웹을 탐색하는 동안 저만의 작업물과 이야기에 몰입할 수 있는 환경을 조성할 수 있을 것이라고 생각합니다.

Experience

다양하고 동적인 요소들이 많아 처음에 호스팅을 시작했을 때는 트래픽이 급증하여 정상적인 동작이 불가능한 정도로 렉이 발생했습니다.

이 문제를 해결하기 위해 꽤 어려운 최적화 작업을 수행했습니다.

초기에는 Github을 이용해 호스팅을 시도했지만, 해외에 서버가 위치해 있어 딜레이가 발생하였기 때문에

Https를 포기하고 국내 서버로 이전했습니다. 그 후에는 추가적인 최적화 작업을 진행하였습니다.

Server / Database 비용 절감 블로그 >

메인 페이지는 매우 동적인 요소들이 많아서 스크롤 시 한 섹션 씩 이동하도록 구현하였으며,

현재 보고 있는 섹션이 아닌 경우 다른 섹션들은 정지하도록 조치했습니다.

소스코드 보기

            
              import { section2Animation } from "./section2Js/section2Entry.js";
              import { section3Intro } from "./section3Js/section3.js";
              import { tubeIntro } from "./section4Js/tubeIntro.js";

              let timer;
              function scroll() {
                window.addEventListener("scroll", ()=> {
                  let scrollPosition = Math.floor(window.scrollY || document.documentElement.scrollTop);
                  section2Animation(scrollPosition);
                  section3Intro(scrollPosition);
                  tubeIntro(scrollPosition);  
                });

                window.onload = () => {
                  const sections = document.querySelectorAll(".section");
                  const sectionCount = sections.length;

                  let startY = 0;
                  let endY = 0;
                  let eventListenerBySize;
                  let isTouchMove = false; // 터치 슬라이드 여부를 확인하기 위한 플래그 변수

                  eventListenerBySize = "mousewheel";

                  const handleScroll = (event) => {
                    event.preventDefault();
                    if (timer) {
                      clearTimeout(timer);
                    }
                    timer = setTimeout(() => {
                      let delta = 0;

                      if (!event) event = window.event;
                      if (event.wheelDelta) {
                        delta = event.wheelDelta / 120;
                        if (window.opera) delta = -delta;
                      } else if (event.detail) delta = -event.detail / 3;

                      let moveTop = window.scrollY;
                      let currentSectionIndex = -1;

                      // Find the index of the currently visible section
                      for (let i = 0; i < sectionCount; i++) {
                        const rect = sections[i].getBoundingClientRect();
                        if (
                          rect.top >= 0 &&
                          window.getComputedStyle(sections[i]).display !== "none"
                        ) {
                          currentSectionIndex = i;
                          break;
                        }
                      }

                      // Scroll down: Move to the next section
                      if (delta < 0) {
                        if (currentSectionIndex < sectionCount - 1) {
                          moveTop +=
                            sections[currentSectionIndex + 1].getBoundingClientRect().top;
                        }
                      }
                      // Scroll up: Move to the previous section
                      else if (delta > 0) {
                        if (currentSectionIndex > 0) {
                          moveTop +=
                            sections[currentSectionIndex - 1].getBoundingClientRect().top;
                        }
                      }

                      window.scrollTo({ top: moveTop, left: 0, behavior: "smooth" });
                    }, 200);
                  };

                  window.addEventListener(eventListenerBySize, handleScroll, {
                    passive: false,
                  });
                  window.addEventListener(
                    "touchstart",
                    (event) => {
                      startY = event.touches[0].clientY;
                      isTouchMove = false; // 터치 시작 시 플래그 초기화
                    },
                    { passive: false }
                  );
                  window.addEventListener(
                    "touchmove",
                    (event) => {
                      event.preventDefault();
                      endY = event.touches[0].clientY;
                      isTouchMove = true; // 터치 이동 시 플래그 설정
                    },
                    { passive: false }
                  );
                  window.addEventListener("touchend", () => {
                    if (!isTouchMove) return; // 터치 이동이 없을 경우 스크롤 작동하지 않음

                    const delta = endY - startY;
                    let moveTop = window.scrollY;
                    let currentSectionIndex = -1;

                    // Find the index of the currently visible section
                    for (let i = 0; i < sectionCount; i++) {
                      const rect = sections[i].getBoundingClientRect();
                      if (
                        rect.top >= 0 &&
                        window.getComputedStyle(sections[i]).display !== "none"
                      ) {
                        currentSectionIndex = i;
                        break;
                      }
                    }

                    // Swipe up: Move to the next section
                    if (delta > 0) {
                      if (currentSectionIndex > 0) {
                        moveTop +=
                          sections[currentSectionIndex - 1].getBoundingClientRect().top;
                      }
                    }
                    // Swipe down: Move to the previous section
                    else if (delta < 0) {
                      if (currentSectionIndex < sectionCount - 1) {
                        moveTop +=
                          sections[currentSectionIndex + 1].getBoundingClientRect().top;
                      }
                    }

                    window.scrollTo({ top: moveTop, left: 0, behavior: "smooth" });
                  });
                };
              }

              export const scrollAction = () => {
                scroll();
              };
            
          

또한 모든 이미지들의 해상도를 한 단계 낮추고 압축 후 webp 확장자로 변환하여 용량을 약 60% 이상으로 줄였습니다.

아이콘 사용을 위해 Font Awesome를 사용했는데, cdn이나 all.css 파일을 사용하는 대신에

현재 사용 중인 Font Awesome 아이콘만 가져와 따로 CSS 파일을 만들어 용량을 110kb에서 6kb로 크게 감소시켰습니다.

특히 Gallery페이지에서는 기기에 부하가 가장 많이 걸리는 페이지였습니다.

Gallery 페이지에 있는 프로젝트 배너들 중 몇몇은 수많은 DOM을 직접 회전시켜 만드는 애니메이션이라 동영상으로 교체하였고,

가운데에 있는 배너를 제외하고 다른 배너들은 모두 애니메이션이 작동 중지 되도록 만들었습니다.

가장 아쉬운 건 아직도 성능이 낮은 기기에서는 렉이 유발되어 입체감이 있게 보이게 하기 위해 넣었던 반사효과를 삭제했습니다.

소스코드 보기

            
              /* 반사효과는 렌더링 부하 문제로 성능 낮은 기기에서 사용 불가 */
              -webkit-box-reflect: 
                below 0vw -webkit-gradient
                  (linear, left top, left bottom, from(transparent), 
                  color-stop(0.65, transparent), to(rgba(255, 255, 255, 0.4)));
            
          

그 외에도 Home과 Gallery 페이지를 PC 및 Mobile 전용 페이지로 따로 제작하여 빠른 로드를 실현하였습니다.

이 최적화 작업을 완료한 후에는 다시 Github 호스팅으로 변경했습니다.

기술적으로 어려웠던 문제도 몇 가지 있었습니다.

먼저, 메인 페이지의 첫 번째 섹션인 텍스트 및 이미지 슬라이드 부분입니다.

텍스트와 연도(year)는 상하로, 이미지는 좌우로, 슬라이드되는 애니메이션을 구현하는 것이었는데,

처음에는 간단하게 처리될 것이라고 생각했지만 실제로는 수학적인 계산이 많이 필요한 부분이어서 예상보다 훨씬 어려웠습니다.

이미지와 텍스트 슬라이드는 translate 값을 Js에서 동적으로 조절해 슬라이드 되도록 구현했으며,

슬라이드 후에는 스케일 업 다운이 실행되도록 하여 무한 루프를 돌렸습니다.

뿐만 아니라, 슬라이드 전환 로직, 스케일 조정 로직, 초기화, 및 무한 루프 로직을 분리하여,

사이사이에 텍스트 슬라이드, 연도 스케일 업 로직들을 모듈화해 넣어 유지보수에 용이하게 구현했습니다.

소스코드 보기

            
              import { changeTextAnimation } from "./TextAnimation.js";
              import { sinceActive } from "./since-active.js";

              const carouselUl = document.querySelector(".carosel");
              const innerWidth = window.innerWidth;
              const transionTime = 1;
              //가장 처음 이미지가 줄어드는 시간을 조절하는 변수 현재 3초
              const firstImageScaleDownTiming = 3000;
              //1000은 transition이 끝나자 마자 scaleUp시작
              let scaleUpTiming = transionTime * 1200;
              //스케일 up이 끝나고 다시 줄어들 타이밍 1000은 1초 뒤
              const scaleDownTiming = scaleUpTiming + transionTime * 1000 + 1000;
              let setTimeOutTime = 0;
              let timeId = "";
              let currentItemLength = 0;
              let currentSlide = 1;
              let timer = 4000;

              const createCopy = () => {
                const caroselItem = document.querySelectorAll(".carosel li");
                const copyLastItem = caroselItem[caroselItem.length - 1].cloneNode(true);
                const copyFirstItem = caroselItem[0].cloneNode(true);
                copyFirstItem.childNodes[1].classList.remove("widthUp");
                carouselUl.insertAdjacentElement("afterbegin", copyLastItem);
                carouselUl.insertAdjacentElement("beforeend", copyFirstItem);
                setCarouselWidth();
              };

              const setCarouselWidth = () => {
                currentItemLength = document.querySelectorAll(".carosel li").length;
                carouselUl.style.width = currentItemLength * innerWidth + "px";
                initSlidePosition();
              };

              const initSlidePosition = () => {
                let moveX = currentSlide * innerWidth;
                carouselUl.style.transition = "0s";
                carouselUl.style.transform = `translateX(-${moveX}px)`;
                autoPlay(timer);
                firstSlideInit();
              };

              const firstSlideInit = () => {
                const Slide = document.querySelectorAll(".carosel li div")[1];
                changeTextAnimation(0);
                setTimeout(() => {
                  Slide.classList.remove("widthUp");
                  sinceActive(currentSlide, transionTime);
                }, firstImageScaleDownTiming);
              };

              const imageScaleDown = (currentSlide) => {
                setTimeout(() => {
                  const Slide = document.querySelectorAll(".carosel li div");
                  Slide[currentSlide].classList.remove("widthUp");
                }, scaleDownTiming);
              };

              const imageScaleUp = (currentSlide) => {
                setTimeout(() => {
                  const Slide = document.querySelectorAll(".carosel li div");
                  Slide[currentSlide].classList.add("widthUp");
                }, scaleUpTiming);
              };

              const fistSlideDownAni = () => {
                const Slide = document.querySelectorAll(".carosel li div")[1];
                setTimeout(() => {
                  Slide.style.transition = `${transionTime}s`;
                  Slide.classList.remove("widthUp");
                }, scaleDownTiming - (scaleUpTiming + transionTime * 1000));
              };

              const imageSlide = () => {
                sinceActive(currentSlide, transionTime);
                changeTextAnimation(currentSlide);
                carouselUl.style.transition = `${transionTime}s`;
                currentSlide++;

                let moveX = currentSlide * innerWidth;
                carouselUl.style.transform = `translateX(-${moveX}px)`;
                imageScaleUp(currentSlide);
                imageScaleDown(currentSlide);

                if (currentSlide === currentItemLength - 1) {
                  imageScaleUp(currentSlide);
                  imageScaleUp(1);
                  clearInterval(timeId);

                  transionTime === 1
                    ? (setTimeOutTime = 1000 + scaleUpTiming)
                    : (setTimeOutTime = transionTime * 1000 + scaleUpTiming);

                  setTimeout(() => {
                    currentSlide = 1;
                    carouselUl.style.transition = "0s";
                    moveX = currentSlide * innerWidth;
                    carouselUl.style.transform = `translateX(-${moveX}px)`;
                    fistSlideDownAni();

                    setTimeout(() => {
                      sinceActive(currentSlide, transionTime);
                      changeTextAnimation(currentSlide);
                      currentSlide++;
                      moveX = currentSlide * innerWidth;
                      carouselUl.style.transition = `${transionTime}s`;
                      carouselUl.style.transform = `translateX(-${moveX}px)`;
                      imageScaleUp(currentSlide);
                      imageScaleDown(currentSlide);
                      autoPlay(timer);
                    }, timer - (transionTime * 1000 + scaleUpTiming));
                  }, setTimeOutTime);
                }
              };

              const autoPlay = (time) => {
                timeId = setInterval(imageSlide, time);
              };

              export const initCaroselUl = () => {
                if(window.innerWidth > 768) {
                  createCopy();
                }
              };
            
          

다음으론 SVG 코드와 애니메이션 적용이 어려웠던 부분입니다.

SVG 코드를 처음 다루는 경험이었기 때문에 디자인 및 SVG 제작에 대해 공부하면서 한계를 느꼈습니다.

또한, SVG 코드에 애니메이션을 적용하는 데 Gsap을 사용하였는데, 일일이 구현하기에는 한계가 있어,

웹 개발자들이 자신의 코드를 업로드하고 실험할 수 있는 코드 에디터 플랫폼인 CodePen을 활용하여

다양한 로직들을 참고하여 구현하는 데에 성공했습니다.

소스코드 보기

            
              export const section3Intro = (() => {
                let hasRun = true;
              
                // section3 position
                let section3Top = Math.floor
                  (window.scrollY + document.querySelector("#section3").getBoundingClientRect().top);
                return (scrollPosition) => {
                  if (scrollPosition === section3Top && hasRun) {
                    hasRun = false;
                    const circle = 
                      '<svg viewBox="0 0 67.4 67.4"><circle class="circle" cx="33.7" cy="33.7" r="33.7"/></svg>';
              
                    class Particle{
                        
                      constructor(svg, coordinates, friction){
                        this.svg = svg
                        this.steps = ($(window).height())/2
                        this.item = null
                        this.friction = friction
                        this.coordinates = coordinates
                        this.position = this.coordinates.y
                        this.dimensions = this.render()
                        this.move()
                        this.rotation = Math.random() > 0.5 ? "-" : "+"
                        this.scale = 0.4 + (Math.random()*2)
                        this.siner = $(window).width()/2.5 * Math.random()
                      }
                      destroy(){
                        this.item.remove()
                      }
                      
                      move(){
                        this.position = this.position - this.friction
                        let top = this.position;
                        let left = this.coordinates.x + Math.sin(this.position*Math.PI/this.steps) * this.siner;
                        this.item.css({
                          transform: "translateX("+left+"px) 
                            translateY("+top+"px) scale(" + this.scale + ") 
                            rotate("+(this.rotation)+(this.position + this.dimensions.height)+"deg)"
                        })
              
                        if(this.position < -(this.dimensions.height)){
                          this.destroy()
                          return false
                        }else{
                          return true
                        }
                      }
                      
                      render(){
                        this.item = $(this.svg, {
                          css: {
                            transform: "translateX("+this.coordinates.x+"px) translateY("+this.coordinates.y+"px)"
                          }
                        })
                        $("#particles").append(this.item)
                        return {
                          width: this.item.width(),
                          height: this.item.height()
                        }
                      }
                    }
              
              
                    let isPaused = false;
                    window.onblur = function() {
                        isPaused = true;
                    }.bind(this)
                    window.onfocus = function() {
                        isPaused = false;
                    }.bind(this)
              
                    let particles = [];
              
                    setInterval(function(){
                      if (!isPaused){
                        particles.push(
                          new Particle(circle, {
                          "x": (Math.random() * $(window).width()),
                          "y": $(window).height() + 100
                          }, (1 + Math.random()))
                        )
                      }
                    }, 180)
              
                    function update(){
                      particles = particles.filter(function(p){
                        return p.move()
                      })
                      requestAnimationFrame(update.bind(this))
                    }
                    update();
                  }
                }
              })();
            
          

Retrospective

이 프로젝트를 통해 기술뿐만 아니라 서버의 렌더링과 트래픽 최적화에 대한 다양한 방법을 배웠습니다.

처음에는 가벼운 마음으로 시작한 프로젝트가 예상보다 더 많은 시간이 소요되었습니다.

인터랙티브 웹을 처음 구축해보았는데, 성능이 낮은 기기에서도 원활하게 작동되어야 한다는 고려를 충분히 하지 않았던 것이 문제였습니다.

여러 기기에서 테스트를 진행하면서 문제가 발생하고, 이에 대한 최적화 작업을 나중에야 시작하게 되었습니다.

프로젝트 진행 기간은 계획보다 늦어졌지만,

이를 통해 최적화의 중요성과 어떤 부분에서 렌더링 지연과 부하가 발생하는지에 대한 이해를 얻을 수 있었습니다.

향후 다른 프로젝트에서는 이러한 문제를 미리 예방하기 위해 최적화 부분을 초기에 고려하여 개발할 계획입니다.