이번에 개인 프로젝트를 새로 런칭하고 설레는 마음으로 크롬 개발자 도구의 Lighthouse 성능 검사를 돌려보았습니다.

결과는 참담하게도 성능 점수 62점, LCP(최초 대형 콘텐츠 도출) 속도 3.2초.

스마트폰 모바일 LTE 환경으로 접속해 보니 메인 페이지 배너 이미지가 위에서부터 턱-턱-턱 둔하게 렌더링되면서 아래쪽에 있던 텍스트들이 밑으로 밀려나는 현상(CLS)까지 발생하고 있었습니다.

"요즘 같은 세상에 로딩에 3초나 걸리는 웹사이트를 누가 기다려줄까?" 하는 위기감이 들었고, 원인을 분석해 보니 아무 생각 없이 넣어둔 고용량 메인 배너 이미지(PNG, 3.8MB)가 범인이었습니다.

오늘은 기본 HTML <img> 태그를 버리고 Next.js의 Image 컴포넌트(next/image)를 도입해 로딩 속도를 0.8초까지 끌어올렸던 생생한 최적화 과정을 공유해 봅니다.

1. 내가 범했던 실수: "원본 PNG 이미지를 그냥 태그에 꽂아 넣었다"

처음엔 그냥 디자인 도구에서 추출한 3.8MB짜리 고해상도 PNG 배너를 public/images 폴더에 넣고 아래처럼 코드를 짰었습니다.

TypeScript
 
// ❌ 처음 내가 작성했던 최적화 안 된 코드
export default function HeroBanner() {
  return (
    <div className="banner-container">
      <img src="/images/main-banner.png" alt="메인 배너" />
    </div>
  );
}

이 코드가 왜 문제였냐면:

  1. 모바일 단말기에서도 3.8MB 원본을 통째로 다운로드받고 있었습니다.
  2. 이미지가 로딩되기 전까지 div 높이가 0px이었다가, 이미지 다운로드가 끝나는 순간 갑자기 400px로 커지며 아래 요소들을 강제로 밀어내는 불쾌한 현상이 일어났습니다.
  3. 브라우저가 PNG 확장자 그대로 다운로드하느라 용량이 전혀 줄어들지 않았습니다.

2. 구원투수: Next.js Image 컴포넌트로 리팩토링하기

Next.js 공식 문서를 뒤져가며 next/image 패키지를 적용해 코드를 고쳐나갔습니다.

TypeScript
 
import Image from 'next/image';

// ⭕ 내 경험을 바탕으로 수정한 최적화 코드
export default function HeroBanner() {
  return (
    <div className="relative w-full h-[400px] overflow-hidden rounded-2xl shadow-lg">
      <Image
        src="/images/main-banner.png"
        alt="메인 프로모션 배너 이미지"
        fill
        priority // 1. LCP 핵심 이미지이므로 지연 로딩을 끄고 최우선 다운로드!
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
        className="object-cover"
        placeholder="blur" // 2. 이미지가 다 떠오르기 전 부드러운 블러 미리보기 적용
        blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU58EUg=="
      />
    </div>
  );
}

💡 직접 적용해보며 깨달은 꿀팁 2가지

  • priority 속성의 중요성: 메인 페이지 상단에 바로 보이는 배너 이미지에는 반드시 priority를 붙여주어야 합니다. 이걸 안 붙이면 Next.js가 기본적으로 lazy loading(스크롤 내릴 때 로드)을 적용해버려서 오히려 초기 로딩이 더 늦어집니다.
  • fill과 부모 relative 관계: 이미지 크기를 반응형으로 고정할 때 fill을 쓰면 편리한데, 이때 반드시 감싸고 있는 부모 div에 relative 포지션과 height 값이 지정되어 있어야 배너가 깨지지 않고 예쁘게 잡힙니다.

Image 컴포넌트 사용 코드

3. 외부 이미지(Unsplash/S3) 불러올 때 만난 에러와 해결

개발하다가 유저 프로필 사진을 외부 서버 URL로 불러왔을 때 Invalid src prop ... hostname is not configured under images 라는 빨간색 에러 화면을 만났습니다.

Next.js는 보안과 악성 이미지 서버 공격을 막기 위해 허용된 도메인의 외부 이미지만 가져오도록 제한하고 있었습니다. next.config.js 파일에 제가 사용하는 외부 이미지 도메인을 명시해 주니 깔끔하게 해결되었습니다.

JavaScript
 
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com', // 사용 중인 외부 이미지 서버 도메인 추가
      },
    ],
  },
};

module.exports = nextConfig;

4. 최종 결과: 3.8MB ➔ 320KB, 성능 점수 94점 달성!

수정을 마치고 개발 서버를 다시 빌드한 뒤 크롬 개발자 도구 Network 탭을 열어보았습니다.

  • 이미지 포맷 변화: 원본 .png 파일이 브라우저에서 자동으로 최신 압축 포맷인 .webp로 변환되어 들어오고 있었습니다.
  • 용량 감축: 3.8MB ➔ 320KB (약 90% 용량 감소)
  • Lighthouse 점수: 62점 ➔ 94점으로 폭등!

Image 컴포넌트 적용 후 개발자 도구 Network 탭 200 OK 요청 성공 화면

5. 회고 및 느낀 점

이전에는 "이미지는 그냥 img 태그 써서 띄우면 되는 거 아닌가?" 하고 안일하게 생각했었습니다.

하지만 이번에 직접 성능 수치를 측정해 보고 next/image를 도입해 리팩토링해 보면서, 작은 이미지 최적화 하나가 전체 서비스의 품질과 사용자 경험(UX)을 얼마나 크게 좌우하는지 뼈저리게 체감할 수 있었습니다.

앞으로 작성하는 모든 프로젝트에서는 개발 초기 단계부터 이미지 최적화 전략을 적용하면 좋을 것 같습니다!

리액트(React)를 활용해 웹 애플리케이션을 개발할 때, 가장 기본이 되면서도 개발자를 가장 흔하게 대혼란에 빠뜨리는 주범은 바로 상태 관리(useState)와 부작용 처리(useEffect)입니다.

문법 자체는 매우 단순해 보이지만, 리액트 내부의 렌더링 라이프사이클과 비동기 업데이트 메커니즘을 명확히 이해하지 못하면 눈에 잘 띄지 않는 치명적인 버그를 만들어내곤 합니다.

오늘은 제가 리액트 프로젝트를 진행하며 직접 경험했던 대표적인 실수 3가지와 이를 해결하며 깨달은 리액트의 핵심 원리를 상세히 정돈해 보려고 합니다.

1. 실수 1: useEffect 내부 비동기 호출 시 '무한 렌더링' 루프 발생

가장 식은땀을 흘렸던 버그는 컴포넌트가 마운트될 때 API 데이터를 가져오도록 작성한 useEffect 코드에서 발생했습니다.

TypeScript
 
// ❌ 무한 렌더링을 유발했던 과거의 코드
function UserProfile() {
  const [userInfo, setUserInfo] = useState<UserInfo | null>(null);

  useEffect(() => {
    fetchUserData().then((data) => {
      setUserInfo(data); // 2. 상태가 변경되어 컴포넌트가 재렌더링됨
    });
  }, [userInfo]); // 1. userInfo 상태를 의존성 배열에 넣음 ➔ 3. 다시 useEffect 실행 (무한 반복!)

  return <div>{userInfo?.name}</div>;
}

💡 원인 및 해결책

useEffect 내부에서 setUserInfo를 통해 상태를 변경하고 있는데, 그 변경되는 userInfo 상태를 의존성 배열(Dependencies)에 넣어둔 것이 원인이었습니다. 상태 변경 ➔ 재렌더링 ➔ useEffect 재실행 ➔ 상태 변경의 무한 루프가 발생하여 서버에 수천 번의 API 요청이 전송되는 참사가 일어났습니다.

마운트 시점에 한 번만 실행되어야 하는 API 요청은 의존성 배열을 빈 배열([])로 설정하여 해결했습니다.

TypeScript
 
// ⭕ 올바르게 수정한 코드
useEffect(() => {
  fetchUserData().then((data) => {
    setUserInfo(data);
  });
}, []); // 빈 배열을 전달하여 마운트 시 단 1회만 실행되도록 보장
useState, useEffect로 작성한 코드


2. 실수 2: setState의 비동기적 특성을 오해하고 '이전 값'을 참조한 문제

카운트 수량을 올리거나 이전 상태를 기반으로 연쇄 계산을 수행할 때 자주 범했던 실수입니다.

TypeScript
 
// ❌ 원하는 대로 스코어가 2씩 증가하지 않는 코드
const handleDoubleScoreUp = () => {
  setScore(score + 1); // 현재 score가 0이라면 0 + 1 = 1 설정 요청
  setScore(score + 1); // 여전히 이전 closure의 score(0)를 참조하여 0 + 1 = 1 설정 요청
};

💡 원인 및 해결책

리액트의 상태 변경 함수(setState)는 즉각적으로 변수의 값을 변경하는 것이 아니라, 성능 최적화를 위해 리액트 렌더링 스케줄러에 업데이트를 예약(Batching)하는 비동기 방식으로 동작합니다. 따라서 연속으로 setState를 호출하면 동기적으로 반영되지 않고 이전 렌더링 시점의 클로저(Closure) 스코프 값을 참조하게 됩니다.

이 문제는 최신 상태 보장을 위해 함수형 업데이트(Functional Update) 방식을 적용하여 해결했습니다.

TypeScript
 
// ⭕ 함수형 업데이트를 적용해 항상 최신 상태를 기반으로 계산하도록 수정
const handleDoubleScoreUp = () => {
  setScore((prevScore) => prevScore + 1);
  setScore((prevScore) => prevScore + 1); // 이전 상태(prevScore)를 인수로 받아 정확히 2가 증가함
};

3. 실수 3: useEffect의 Cleanup(정리) 함수 누락으로 인한 메모리 누수

페이지 상단으로 이동하는 버튼이나 스크롤 프로그레스 바를 만들기 위해 window 객체에 스크롤 이벤트를 등록할 때 발생했던 실수입니다.

TypeScript
 
// ❌ 컴포넌트가 언마운트되어도 이벤트가 남아 메모리 누수가 일어나는 코드
useEffect(() => {
  const handleScroll = () => {
    setScrollPosition(window.scrollY);
  };
  
  window.addEventListener('scroll', handleScroll);
}, []);

💡 원인 및 해결책

useEffect 내에서 타이머(setInterval), Web API 이벤트 리스너(addEventListener), 혹은 WebSocket 연결을 등록해 두고 정리(Cleanup) 함수를 반환하지 않으면, 해당 컴포넌트가 화면에서 사라진(Unmount) 후에도 메모리에 잔재하여 성능 저하와 예기치 않은 동작을 일으킵니다.

useEffect의 return 구문에 리스너를 해제하는 클린업 로직을 추가하여 완벽

히 방지할 수 있었습니다.

TypeScript
 
// ⭕ 클린업 함수를 반환하여 메모리 누수를 방지한 코드
useEffect(() => {
  const handleScroll = () => {
    setScrollPosition(window.scrollY);
  };

  window.addEventListener('scroll', handleScroll);

  // 컴포넌트가 언마운트되거나 재실행되기 전 기존 리스너를 깨끗이 제거
  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, []);

useEffect내부의 로그 출력

4. 결론 및 느낀 점

리액트의 useState와 useEffect는 프론트엔드 개발 시 매일 사용하는 기본적인 훅이지만, 그 내부의 '불변성(Immutability)', '클로저(Closure)', '비동기 배칭(Batching)' 원리를 깊이 이해하지 못하면 쉽게 버그에 노출될 수 있습니다.

단순히 라이브러리의 문법만 외우기보다는, 렌더링 주기에 따라 상태가 어떻게 변화하고 이펙트가 언제 실행되는지 지속적으로 관찰하고 디버깅하는 습관이 좋은 리액트 개발자로 성장하는 핵심 열쇠라고 생각합니다.

프론트엔드 프로젝트를 진행하면서 코딩 자체보다 나를 더 식은땀 흘리게 만들었던 순간은 단연 'Git 명령어 실수'였습니다.

로직을 신나게 짜두고 커밋 하나 잘못 날려서 수시간 동안 작업했던 코드를 날려먹을 뻔하거나, Git Merge 충돌이 무서워서 소스 코드를 압축 파일로 복사해 두는 웃지 못할 삽질을 겪기도 했습니다.

오늘은 제가 프론트엔드 프로젝트를 진행하며 직접 겪었던 대표적인 Git 실수 3가지와, 이를 깔끔하게 해결했던 복구 명령어(Tip)를 정돈해 보려고 합니다.

1. 실수 1: 잘못된 브랜치에 코드를 다 짜놓았을 때 (git stash)

feature/login 브랜치에서 작업해야 하는데, 깜빡하고 main 브랜치나 다른 작업 브랜치에서 열심히 코드를 수정하고 있던 자신을 뒤늦게 발견했을 때입니다.

아직 커밋은 하지 않았는데 브랜치를 이동하자니 오류가 뜨고, 작업해 둔 코드가 날아갈까 봐 당황했던 기억이 있습니다.

💡 해결 방법: 임시 저장창고 git stash 활용하기

Bash
 
# 1. 현재 작업 중인 변경사항을 임시 저장소에 싹 감춰둡니다.
git stash

# 2. 올바른 작업 브랜치로 이동합니다.
git checkout feature/login

# 3. 임시 저장소에 있던 변경사항을 새로 이동한 브랜치에 꺼내옵니다.
git stash pop

이 3줄의 명령어만 알면 잘못된 브랜치에서 작성하던 코드를 안전하게 원하는 브랜치로 이사시킬 수 있습니다.

2. 실수 2: 커밋 메시지를 오타 내거나 파일을 빼먹고 커밋했을 때 (git commit --amend)

커밋을 날리자마자 커밋 메시지에 오타가 들어갔거나, 같이 올려야 하는 스타일 파일(CSS)을 빼먹고 커밋을 올렸다는 것을 깨달았을 때입니다. 새로운 커밋을 하나 더 만들자니 Git 히스토리가 지저분해져서 고민이었습니다.

git log 명령어 입력 결과

💡 해결 방법: 직전 커밋 덮어쓰기 --amend

Bash
 
# 빼먹은 파일이 있다면 먼저 Stage에 올립니다.
git add src/styles/main.css

# 직전 커밋 메시지를 수정하거나 파일을 포함해 덮어씁니다.
git commit --amend -m "fix: 올바른 커밋 메시지 수정"

이미 날린 직전 커밋을 새 커밋으로 더럽히지 않고 깔끔하게 수정할 수 있습니다.

3. 실수 3: 코드 수정 중 전으로 돌리고 싶을 때 (git restore & git reset)

코드를 이것저것 고치다가 망해서 "아, 그냥 오늘 작업하기 전 상태로 싹 되돌리고 싶다" 할 때가 있습니다. 수동으로 Ctrl + Z를 수십 번 누르다가 코드가 엉키는 참사를 겪곤 했습니다.

💡 해결 방법: 상황별 되돌리기 명령어

  • 아직 커밋하지 않은 파일의 변경사항 취소하기:
  • Bash
     
    git restore src/App.tsx
    
  • 직전 커밋을 취소하되 작성한 코드는 남겨두기 (Soft Reset):
  • Bash
     
    git reset HEAD~1
    
  • 지워진 커밋 찾아오기 (git reflog):
  • 실수로 git reset --hard를 눌러서 커밋이 지워졌더라도 당황하지 마세요. Git은 모든 행동을 기록해 둡니다. git reflog를 치면 삭제된 커밋 ID를 찾아 원상복구할 수 있습니다!

git status 명령어 결과

4. 글을 마치며

Git은 단순히 소스 코드를 저장하는 구글 드라이브가 아니라, 개발자의 실수를 언제든 안전하게 과거로 되돌려주는 강력한 타임머신입니다.

명령어가 익숙지 않다고 두려워하기보다는, 직접 브랜치를 만들어보고 stash나 reset 명령어를 연습해 보면서 Git과 친해지시기를 추천합니다. 실수해도 복구하는 방법을 알면 개발이 훨씬 더 자신감 넘쳐집니다!

최근 진행한 프론트엔드 프로젝트의 UI 스타일링 도구로 요즘 생태계에서 가장 핫한 Tailwind CSS를 도입해 보았습니다.

이전까지는 Plain CSS, SCSS, 혹은 CSS Modules 방식을 주력으로 사용하며 클래스명을 정의하고 스타일을 입혀왔습니다. 하지만 프로젝트의 규모가 조금씩 커질수록 CSS 파일 용량이 불어나고, 클래스 이름 지정(Naming)에 소비되는 시간이 늘어나는 등 여러 한계를 체감하게 되었습니다.

오늘은 유틸리티 우선(Utility-First) CSS 프레임워크인 Tailwind CSS를 실제 프로젝트에 직접 도입해 보면서 느꼈던 솔직한 장단점과, 가독성 단점을 극복했던 나만의 리팩토링 노하우를 깊이 있게 공유해 보려고 합니다.

1. 기존 CSS 방식의 한계: "클래스 이름 짓다가 하루가 다 가네요"

CSS를 작성할 때 개발자를 가장 괴롭히는 것 중 하나는 바로 BEM(Block Element Modifier) 커스텀 컨벤션에 맞추어 클래스명을 짓는 작업입니다.

.card__article-title--highlighted처럼 클래스명을 길게 정의하다 보면 "이 이름이 과연 이 컴포넌트의 역할을 명확히 설명하고 있는가?"에 대한 소모적인 고민을 하게 됩니다. 또한, 스타일에 아주 작은 수정사항(예: 마진 4px 추가)이 생겨도 JSX 파일과 CSS/SCSS 파일 사이를 번갈아 이동해야 하는 작업 전환(Context Switching) 비효율이 존재했습니다.

Tailwind CSS는 이러한 페인 포인트(Pain Point)를 직관적으로 해결해 주는 도구였습니다. 클래스명을 고민할 필요 없이, HTML/JSX 태그의 className 속성에 미리 정의된 유틸리티 클래스(flex, items-center, p-4, bg-blue-500 등)를 조합하여 즉시 스타일을 부여하는 방식이기 때문입니다.

2. 프로젝트에서 체감한 Tailwind CSS의 장점

① 개발 생산성 및 작업 속도의 획기적 향상

컴포넌트 로직이 담긴 JSX/TSX 파일 하나에서 마크업과 스타일링을 한 번에 끝낼 수 있었습니다. 더 이상 별도의 .module.css 파일을 만들고 import하는 번거로운 과정이 사라져, UI 레이아웃을 구성하는 속도가 이전 대비 2배 이상 단축되었습니다.

② 일관된 디자인 시스템(Design Token) 자동 유지

p-4 (16px), text-lg (18px), rounded-lg (8px) 등 Tailwind가 기본적으로 제공하는 규격화된 수치 단위만 사용하게 됩니다. 이 덕분에 마진이나 폰트 크기를 눈대중으로 어정쩡하게 설정하는 실수가 줄어들었고, 전체 페이지의 디자인 통일성이 자연스럽게 유지되었습니다.

tailwind로 작성한 코드

3. 실제로 부딪힌 단점: "HTML이 너무 더러워져요"

장점이 명확했던 만큼, 실무에서 부딪힌 치명적인 단점도 존재했습니다. 바로 JSX 태그의 className이 한도 끝도 없이 길어져 코드의 가독성을 심각하게 해친다는 점이었습니다.

TypeScript
 
// ❌ 가독성이 저하된 초기의 Tailwind 코드 예시
<button className="inline-flex items-center justify-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors duration-200">
  확인 버튼
</button>

버튼 하나를 만드는데 스타일 클래스가 수십 개나 나열되다 보니, 오랜만에 코드를 열었을 때 DOM 구조가 한눈에 들어오지 않고 스타일 코드에 가려지는 현상이 발생했습니다.

4. 가독성 단점을 해결한 나만의 리팩토링 노하우

이 문제를 극복하기 위해 프로젝트를 진행하며 다음과 같은 규칙을 세워 개선했습니다.

① @apply 지시어를 활용한 스타일 추상화

자주 반복 사용되는 버튼이나 인풋(Input) 스타일은 글로벌 CSS 파일 내에서 @apply 지시어를 사용하여 깔끔하게 공통 클래스로 묶어주었습니다.

CSS
 
/* main.css */
.btn-primary {
  @apply inline-flex items-center justify-center px-6 py-3 text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 transition-colors;
}

② React 컴포넌트 단위로 잘게 쪼개기

스타일이 복잡해진 영역은 그 자체로 재사용 가능한 작은 단위 컴포넌트로 분리하였습니다. 가령 공통 카드 UI, 뱃지, 모달 헤더 등을 별도 컴포넌트로 잘게 분리하니 JSX 본문의 가독성도 살리고 Tailwind의 이점도 그대로 유지할 수 있었습니다.

Tailwind로 작업된 결과물

5. 결론 및 총평

Tailwind CSS는 처음 접했을 때 "CSS를 HTML 안에 다시 집어넣는 퇴보가 아닌가?" 하는 지적을 받기도 합니다. 하지만 직접 경험해 본 Tailwind CSS는 단순한 인라인 스타일이 아니라, 잘 짜인 디자인 시스템을 가장 빠르게 마크업에 녹여낼 수 있는 강력한 생산성 도구였습니다.

클래스 이름 짓는 스트레스에서 벗어나 컴포넌트의 비즈니스 로직에 더 집중하고 싶은 프론트엔드 개발자분들은 Tailwind CSS 도입을  고려해보셨으면 좋을 것 같네요

+ Recent posts