[웹 개발 기초] 10화: 자바스크립트로 스크롤 인터랙션 구현하기 (완결)

안녕하세요, 여러분! 드디어 마지막 화에 도달하셨습니다. 긴 여정을 끝까지 함께해 주셔서 진심으로 감사드립니다.

지금까지 HTML로 웹사이트의 뼈대를 세우고, CSS로 아름답게 꾸미는 방법을 함께 배워왔는데요. 이번 마지막 화에서는 드디어 자바스크립트(JavaScript) 를 활용하여 웹사이트에 생동감 있는 기능을 더해보겠습니다.

이번 화에서 구현할 기능은 두 가지입니다.

  1. 스크롤 인터랙션: 페이지를 내리면 네비게이션 바가 반투명한 유리처럼 우아하게 변하는 효과
  2. 햄버거 메뉴: 모바일에서 메뉴 버튼을 누르면 세로로 펼쳐지는 반응형 네비게이션

완성하고 나면 훨씬 완성도 높고 세련된 포트폴리오가 될 것입니다. 기대해 주세요!


1. 자바스크립트 파일 연결

가장 먼저 script.js 파일을 만들고, HTML의 <body> 태그가 닫히기 바로 직전에 연결해 주어야 합니다. 이 위치에 작성하는 이유는 HTML의 모든 요소가 먼저 로드된 이후에 스크립트가 실행되도록 보장하기 위해서입니다.

<!-- index.html 맨 하단 -->
    <script src="script.js"></script>
</body>
</html>

2. CSS 클래스 준비 (Glassmorphism)

자바스크립트로 스타일을 직접 변경하는 것보다, CSS에 클래스를 미리 정의해 두고 자바스크립트로는 클래스만 추가/제거하는 방식이 훨씬 깔끔하고 유지보수하기 좋습니다.

평소에는 투명한 상태였다가, .scrolled라는 클래스가 추가되는 순간 반투명한 유리 효과(Glassmorphism)가 적용되도록 style.css에 미리 정의해 두겠습니다.

/* style.css */

/* 스크롤 되었을 때 적용될 스타일 */
nav.scrolled {
  background: rgba(255, 255, 255, 0.7); /* 흰색인데 70% 불투명 */

  /* 배경을 흐리게 만드는 유리 효과 (Glassmorphism) */
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px); /* 사파리 브라우저 호환 */

  padding: 15px 50px; /* 높이를 살짝 줄여서 날렵하게 */
  box-shadow: 0 4px 30px rgba(0, 0, 0, 0.05); /* 그림자 추가 */
}

/* 모바일에서는 패딩 유지 (nav.scrolled가 미디어 쿼리보다 특이성이 높아 덮어쓰기 방지) */
@media (max-width: 768px) {
  nav.scrolled {
    padding: 15px 20px;
  }
}

3. 자바스크립트 로직 작성

이제 핵심인 스크롤 감지 코드를 작성해 보겠습니다. 주석을 따라 읽어보시면 흐름이 자연스럽게 이해되실 거예요.

// script.js

// 1. HTML에서 네비게이션 바 요소를 찾아 변수에 저장합니다.
const navbar = document.getElementById('navbar');

// 2. 윈도우(창)에 스크롤 이벤트 리스너를 달아줍니다.
// 스크롤이 발생할 때마다 이 함수가 실행됩니다.
window.addEventListener('scroll', () => {
  // 3. 현재 스크롤의 Y축 위치(얼마나 내려왔는지)를 확인합니다.
  const scrollPosition = window.scrollY;

  // 4. 스크롤이 50px 이상 내려갔다면?
  if (scrollPosition > 50) {
    // 네비게이션 바에 'scrolled' 클래스를 추가합니다. -> CSS 스타일 적용됨
    navbar.classList.add('scrolled');
  } else {
    // 50px 미만이라면 (다시 위로 올라왔다면)?
    // 'scrolled' 클래스를 제거합니다. -> 원래 투명한 상태로 복귀
    navbar.classList.remove('scrolled');
  }
});

4. 햄버거 메뉴 구현하기

모바일에서 네비게이션 메뉴가 가로로 나열되면 화면이 좁아질수록 답답해 보입니다. 일반적으로 모바일에서는 아이콘 버튼(☰)을 누르면 메뉴가 세로로 펼쳐지는 햄버거 메뉴 패턴을 사용합니다.

HTML — 햄버거 버튼 추가

<nav> 안에 버튼 하나를 추가하고, 메뉴 <ul>에 id="menu"를 달아줍니다.

<nav id="navbar">
  <div class="logo">MyPortfolio.</div>
  <button class="hamburger" id="hamburger" aria-label="메뉴 열기">
    <span></span>
    <span></span>
    <span></span>
  </button>
  <ul class="menu" id="menu">
    ...
  </ul>
</nav>

CSS — 버튼 스타일 및 모바일 동작

데스크탑에서는 버튼을 숨기고, 모바일에서는 메뉴를 숨겼다가 .open 클래스가 붙으면 펼쳐지도록 합니다.

/* 햄버거 버튼 — 데스크탑에서는 숨김 */
.hamburger {
  display: none;
  flex-direction: column;
  gap: 5px;
  background: none;
  border: none;
  cursor: pointer;
  padding: 5px;
}

.hamburger span {
  display: block;
  width: 25px;
  height: 2px;
  background-color: var(--dark);
  border-radius: 2px;
  transition: 0.3s;
}

@media (max-width: 768px) {
  .hamburger {
    display: flex; /* 모바일에서만 버튼 표시 */
  }

  .menu {
    display: none; /* 평소에는 숨김 */
    position: absolute;
    top: 100%;
    left: 0;
    width: 100%;
    flex-direction: column;
    background: white;
    padding: 20px 30px;
    box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
    gap: 15px;
  }

  .menu.open {
    display: flex; /* 클래스가 붙으면 펼쳐짐 */
  }
}

JavaScript — 클릭 시 토글

버튼을 클릭할 때마다 메뉴에 .open 클래스를 추가/제거합니다. toggle()은 클래스가 없으면 추가하고, 있으면 제거하는 편리한 메서드입니다.

// 햄버거 메뉴 토글
const hamburger = document.getElementById('hamburger');
const menu = document.getElementById('menu');

hamburger.addEventListener('click', () => {
  menu.classList.toggle('open');
});

// 메뉴 링크 클릭 시 메뉴 닫기
document.querySelectorAll('.menu a').forEach(link => {
  link.addEventListener('click', () => {
    menu.classList.remove('open');
  });
});

5. 시리즈를 마치며

축하드립니다! 여러분은 이제 HTML, CSS, JavaScript를 모두 활용하여 나만의 반응형 포트폴리오 웹사이트를 완성하셨습니다. 처음 빈 화면에서 시작해 여기까지 오신 것이 정말 대단하다고 생각합니다.

자바스크립트 인터랙션 메뉴

지금까지 우리가 함께 배운 내용을 정리해 볼까요?

  1. HTML: 시맨틱 태그로 웹사이트의 구조 잡기
  2. CSS: Flexbox와 Grid로 레이아웃 구성, 변수 활용, 반응형 디자인 적용
  3. Design: 그라디언트, 그림자, 애니메이션으로 디테일 살리기
  4. JS: DOM 조작과 이벤트 리스너로 인터랙션 구현하기

완성된 프로젝트를 바탕으로 여러분만의 사진, 소개 글, 프로젝트로 내용을 채워보시길 권해드립니다. 나아가 GitHub Pages 등을 통해 실제 인터넷에 배포하는 과정까지 도전해 보신다면 정말 값진 경험이 될 것입니다.

시리즈를 마무리하며 돌아보면, 여러분은 생각보다 훨씬 많은 것을 배우셨습니다. 그 자신감을 가지고 다음 프로젝트에도 도전해 보시길 바랍니다.


[전체 소스 코드]

완성된 코드는 아래 세 개의 파일로 구성되어 있습니다.

index.html: 웹페이지 구조

<!-- index.html -->
<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>포트폴리오 | Web Developer</title>
    <!-- Google Fonts: Noto Sans KR -->
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;700;900&display=swap"
      rel="stylesheet"
    />
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <nav id="navbar">
      <div class="logo">MyPortfolio.</div>
      <button class="hamburger" id="hamburger" aria-label="메뉴 열기">
        <span></span>
        <span></span>
        <span></span>
      </button>
      <ul class="menu" id="menu">
        <li><a href="#home">Home</a></li>
        <li><a href="#about">About</a></li>
        <li><a href="#projects">Projects</a></li>
        <li><a href="#contact">Contact</a></li>
      </ul>
    </nav>

    <header id="home" class="hero">
      <div class="hero-shape"></div>
      <div class="hero-content">
        <h1>Creative<br /><span class="highlight">Web Developer</span></h1>
        <p>HTML, CSS, JavaScript로 만드는<br />인터랙티브한 웹 경험을 제공합니다.</p>
        <div class="btn-group">
          <button class="btn primary">프로젝트 보기</button>
          <button class="btn secondary">연락하기</button>
        </div>
      </div>
    </header>

    <section id="about" class="section">
      <div class="container">
        <h2>About Me</h2>
        <div class="about-wrapper">
          <img src="https://picsum.photos/300/300?grayscale" alt="Profile" class="profile-img" />
          <div class="about-desc">
            <h3>"코드로 가치를 만드는 개발자"</h3>
            <p>
              안녕하세요. 사용자 경험(UX)을 최우선으로 생각하는 웹 개발자입니다.<br />
              깔끔한 코드와 직관적인 디자인을 통해 문제를 해결하는 것을 즐깁니다.
            </p>
            <div class="skills-container">
              <span class="skill-tag">HTML5</span>
              <span class="skill-tag">CSS3</span>
              <span class="skill-tag">JavaScript</span>
              <span class="skill-tag">React</span>
              <span class="skill-tag">Node.js</span>
            </div>
          </div>
        </div>
      </div>
    </section>

    <section id="projects" class="section" style="background-color: #f8f9fa">
      <div class="container">
        <h2>My Projects</h2>
        <p>최근 진행한 주요 프로젝트들을 소개합니다.</p>
        <div class="project-grid">
          <div class="project-card">
            <img src="https://picsum.photos/600/400?random=1" alt="E-commerce App" class="project-img" />
            <div class="project-info">
              <h3>E-commerce App</h3>
              <p>React와 Node.js로 만든 쇼핑몰</p>
            </div>
          </div>
          <div class="project-card">
            <img src="https://picsum.photos/600/400?random=2" alt="Portfolio Site" class="project-img" />
            <div class="project-info">
              <h3>Portfolio Site</h3>
              <p>HTML/CSS만으로 만든 반응형 웹</p>
            </div>
          </div>
          <div class="project-card">
            <img src="https://picsum.photos/600/400?random=3" alt="Task Manager" class="project-img" />
            <div class="project-info">
              <h3>Task Manager</h3>
              <p>생산성을 높여주는 투두 리스트</p>
            </div>
          </div>
        </div>
      </div>
    </section>

    <section id="contact" class="section">
      <div class="container">
        <h2>Contact Me</h2>
        <p>새로운 프로젝트와 협업 제안을 기다리고 있습니다.</p>
        <form class="contact-form">
          <input type="text" placeholder="이름" />
          <input type="email" placeholder="이메일" />
          <textarea rows="5" placeholder="메시지"></textarea>
          <button type="submit" class="btn primary">보내기</button>
        </form>
      </div>
    </section>

    <footer>
      <p>&copy; 2026 My Portfolio. All rights reserved.</p>
    </footer>
    <script src="script.js"></script>
  </body>
</html>

style.css: 디자인 및 스타일

/* style.css */
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;900&display=swap');

:root {
  --primary: #6c5ce7;
  --secondary: #a29bfe;
  --dark: #2d3436;
  --light: #f9f9f9;
}

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: 'Noto Sans KR', sans-serif;
}

html {
  scroll-behavior: smooth;
}

body {
  background-color: var(--light);
  color: var(--dark);
  overflow-x: hidden;
}

nav {
  position: fixed;
  top: 0;
  width: 100%;
  padding: 20px 50px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  z-index: 1000;
  transition: all 0.4s ease;
}

.logo {
  font-size: 1.5rem;
  font-weight: 900;
  color: var(--primary);
  cursor: pointer;
}

.menu {
  display: flex;
  list-style: none;
  gap: 30px;
}

.menu a {
  text-decoration: none;
  color: var(--dark);
  font-weight: 500;
  transition: 0.3s;
}

.menu a:hover {
  color: var(--primary);
}

/* 햄버거 버튼 — 데스크탑에서는 숨김 */
.hamburger {
  display: none;
  flex-direction: column;
  gap: 5px;
  background: none;
  border: none;
  cursor: pointer;
  padding: 5px;
  z-index: 1001;
}

.hamburger span {
  display: block;
  width: 25px;
  height: 2px;
  background-color: var(--dark);
  border-radius: 2px;
  transition: 0.3s;
}

.hero {
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  position: relative;
  background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%);
}

.hero h1 {
  font-size: 4rem;
  line-height: 1.1;
  margin-bottom: 20px;
  font-weight: 900;
}

.highlight {
  background: linear-gradient(to right, var(--primary), var(--secondary));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.hero p {
  font-size: 1.2rem;
  color: #636e72;
  margin-bottom: 40px;
  line-height: 1.6;
}

.btn-group {
  display: flex;
  gap: 15px;
  justify-content: center;
}

.btn {
  padding: 12px 30px;
  border-radius: 50px;
  font-size: 1rem;
  font-weight: 700;
  cursor: pointer;
  transition:
    transform 0.2s,
    box-shadow 0.2s;
  border: none;
}

.btn.primary {
  background: var(--primary);
  color: white;
  box-shadow: 0 10px 20px rgba(108, 92, 231, 0.3);
}

.btn.secondary {
  background: white;
  color: var(--primary);
  border: 2px solid var(--primary);
}

.btn:hover {
  transform: translateY(-3px);
}

.hero-shape {
  position: absolute;
  top: -10%;
  right: -5%;
  width: 600px;
  height: 600px;
  background: linear-gradient(45deg, var(--secondary), var(--primary));
  border-radius: 50%;
  filter: blur(90px);
  opacity: 0.3;
  z-index: 0;
  animation: float 8s ease-in-out infinite;
}

@keyframes float {
  0%,
  100% {
    transform: translateY(0) scale(1);
  }
  50% {
    transform: translateY(-20px) scale(1.05);
  }
}

.hero-content {
  z-index: 1;
}

.section {
  min-height: 100vh;
  padding: 100px 20px;
  background: white;
  display: flex;
  justify-content: center;
}

.container {
  max-width: 800px;
  text-align: center;
}

.about-wrapper {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 60px;
  margin-top: 50px;
  text-align: left;
}

.profile-img {
  width: 280px;
  height: 280px;
  border-radius: 50%;
  object-fit: cover;
  box-shadow: 20px 20px 0px var(--secondary);
  border: 5px solid white;
}

.about-desc {
  flex: 1;
  max-width: 500px;
}

.about-desc h3 {
  font-size: 1.8rem;
  margin-bottom: 20px;
  color: var(--primary);
}

.about-desc p {
  font-size: 1.1rem;
  line-height: 1.7;
  color: #636e72;
  margin-bottom: 30px;
}

.skills-container {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.skill-tag {
  padding: 8px 16px;
  background: white;
  border: 1px solid var(--secondary);
  color: var(--primary);
  border-radius: 20px;
  font-weight: 600;
  font-size: 0.9rem;
  transition: 0.3s;
  cursor: default;
}

.skill-tag:hover {
  background: var(--primary);
  color: white;
  transform: translateY(-3px);
  box-shadow: 0 5px 15px rgba(108, 92, 231, 0.3);
}

.project-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 30px;
  margin-top: 50px;
  width: 100%;
  max-width: 1000px;
}

.project-card {
  background: white;
  border-radius: 15px;
  overflow: hidden;
  box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05);
  transition: transform 0.3s ease;
  text-align: left;
}

.project-card:hover {
  transform: translateY(-10px);
}

.project-img {
  width: 100%;
  height: 200px;
  object-fit: cover;
}

.project-info {
  padding: 20px;
}

.project-info h3 {
  margin-bottom: 10px;
  color: var(--dark);
  font-size: 1.2rem;
}

.project-info p {
  font-size: 0.9rem;
  color: #636e72;
  margin-bottom: 0;
  line-height: 1.4;
}

.contact-form {
  display: flex;
  flex-direction: column;
  gap: 15px;
  width: 100%;
  max-width: 500px;
  margin-top: 40px;
}

.contact-form input,
.contact-form textarea {
  padding: 15px;
  border: 1px solid #dfe6e9;
  border-radius: 8px;
  font-size: 1rem;
  font-family: inherit;
  resize: vertical;
}

.contact-form input:focus,
.contact-form textarea:focus {
  outline: none;
  border-color: var(--primary);
}

footer {
  padding: 30px;
  text-align: center;
  background-color: var(--dark);
  color: rgba(255, 255, 255, 0.7);
  font-size: 0.9rem;
}

@media (max-width: 768px) {
  .about-wrapper {
    /* 가로 배치 -> 세로 배치로 변경 */
    flex-direction: column;
    text-align: center; /* 텍스트 가운데 정렬 */
  }

  .profile-img {
    /* 모바일에서는 이미지 크기를 조금 줄입니다. */
    width: 200px;
    height: 200px;

    /* 그림자 크기도 함께 조정해 줍니다. */
    box-shadow: 10px 10px 0px var(--secondary);
  }

  .about-desc {
    width: 100%; /* 너비가 화면에 꽉 차도록 설정합니다. */
  }

  .skills-container {
    justify-content: center; /* 스킬 태그들도 가운데 정렬해 줍니다. */
  }
  .hero h1 {
    font-size: 2.5rem; /* 4rem -> 2.5rem으로 축소 */
  }

  nav {
    padding: 15px 20px; /* 네비게이션 여백 축소 */
  }

  .section {
    padding: 60px 20px; /* 섹션 여백 축소 */
  }
}
nav.scrolled {
  background: rgba(255, 255, 255, 0.7); /* 흰색인데 70% 불투명 */

  /* 배경을 흐리게 만드는 유리 효과 (Glassmorphism) */
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px); /* 사파리 브라우저 호환 */

  padding: 15px 50px; /* 높이를 살짝 줄여서 날렵하게 */
  box-shadow: 0 4px 30px rgba(0, 0, 0, 0.05); /* 그림자 추가 */
}

@media (max-width: 768px) {
  .project-grid {
    /* 모바일에서는 한 열로 카드를 표시합니다. */
    grid-template-columns: 1fr;
  }

  nav.scrolled {
    padding: 15px 20px;
  }

  .hamburger {
    display: flex;
  }

  .menu {
    display: none;
    position: absolute;
    top: 100%;
    left: 0;
    width: 100%;
    flex-direction: column;
    background: white;
    padding: 20px 30px;
    box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08);
    gap: 15px;
  }

  .menu.open {
    display: flex;
  }
}

script.js: 스크롤 효과 + 햄버거 메뉴 스크립트

// 1. HTML에서 네비게이션 바 요소를 찾아 변수에 저장합니다.
const navbar = document.getElementById('navbar');

// 2. 윈도우(창)에 스크롤 이벤트 리스너를 달아줍니다.
// 스크롤이 발생할 때마다 이 함수가 실행됩니다.
window.addEventListener('scroll', () => {
  // 3. 현재 스크롤의 Y축 위치(얼마나 내려왔는지)를 확인합니다.
  const scrollPosition = window.scrollY;

  // 4. 스크롤이 50px 이상 내려갔다면?
  if (scrollPosition > 50) {
    // 네비게이션 바에 'scrolled' 클래스를 추가합니다. -> CSS 스타일 적용됨
    navbar.classList.add('scrolled');
  } else {
    // 50px 미만이라면 (다시 위로 올라왔다면)?
    // 'scrolled' 클래스를 제거합니다. -> 원래 투명한 상태로 복귀
    navbar.classList.remove('scrolled');
  }
});

// 햄버거 메뉴 토글
const hamburger = document.getElementById('hamburger');
const menu = document.getElementById('menu');

hamburger.addEventListener('click', () => {
  menu.classList.toggle('open');
});

// 메뉴 링크 클릭 시 메뉴 닫기
document.querySelectorAll('.menu a').forEach(link => {
  link.addEventListener('click', () => {
    menu.classList.remove('open');
  });
});

참고 링크

mdn javascript