웹 사이트를 만들 때 보통 디자인 도구(Figma 등)에서 만든 이미지들을 그냥 PNG나 JPEG로 뽑아서 아무 생각 없이 프로젝트에 넣곤 했습니다.

그러다 최근 진행했던 웹 서비스에서 큰 문제를 하나 만났습니다. 메인 화면에 들어가는 고화질 배너 이미지 몇 개를 PNG 파일 그대로 넣었더니, 이미지 파일 몇 개 용량만 합쳐도 5MB가 훌쩍 넘어가는 참사가 일어난 것입니다.

모바일로 접속했을 때 이미지가 위에서부터 천천히 스캔되듯이 지저분하게 내려오면서 로딩되는 모습을 보고 "아, 이미지 포맷부터 당장 바꿔야겠다"는 결심을 하게 되었습니다.

오늘은 구시대 포맷인 PNG/JPEG를 버리고 차세대 포맷인 WebP와 AVIF로 전환하면서 겪었던 과정과, 실무에서 브라우저 깨짐 없이 안전하게 적용했던 노하우를 공유해 봅니다.

1. 내가 부딪힌 문제: "화질은 포기하기 싫고, 용량은 너무 크다"

프로젝트 배너에 글씨와 그래픽이 섞여 있다 보니, 화질이 깨지는 JPEG 대신 깨끗하게 나오는 PNG 포맷을 사용했습니다. 하지만 PNG는 무손실 압축 방식이라 파일 용량이 어마무시하게 컸습니다.

  • 메인 배너 PNG 파일 1개 용량: 무려 2.4 MB
  • 문제점: 접속할 때마다 유저의 데이터와 시간을 엄청나게 갉아먹고 있었고, 네트워크가 조금만 느려도 화면이 텅 비어 보이는 현상 발생.

화질을 떨어뜨리지 않으면서 용량만 획기적으로 줄일 수 있는 방법을 찾다가 구글이 만든 WebP와 차세대 비디오 코덱 기반의 AVIF 포맷을 알게 되었습니다.

2. 해결책 1: HTML <picture> 태그로 구형 브라우저 호환성 지키기

WebP나 AVIF가 아무리 용량이 작고 좋아도, "혹시 최신 포맷을 지원하지 않는 구형 브라우저에서는 이미지가 엑박(X)으로 깨지면 어쩌지?" 하는 걱정이 들었습니다.

이 문제를 구원해 준 것이 바로 HTML5의 <picture> 태그였습니다. 브라우저에게 "네가 AVIF를 지원하면 AVIF를 보여주고, 안 되면 WebP를, 그것도 안 되면 기존 JPEG를 보여줘" 하고 우선순위를 정해주는 방식입니다.

HTML
 
<!-- ⭕ 내 프로젝트에 직접 적용한 크로스 브라우징 picture 태그 -->
<picture>
  <!-- 1. 가장 가볍고 최신인 AVIF 포맷을 최우선으로 시도 -->
  <source srcset="/images/main-banner.avif" type="image/avif" />
  
  <!-- 2. AVIF 미지원 시 WebP 포맷 사용 -->
  <source srcset="/images/main-banner.webp" type="image/webp" />
  
  <!-- 3. 최신 포맷을 둘 다 지원하지 않는 구형 브라우저용 기본 이미지 -->
  <img 
    src="/images/main-banner.jpg" 
    alt="메인 프로모션 배너" 
    width="1200" 
    height="600"
    loading="lazy"
  />
</picture>

이렇게 코드를 짜두니 에러 걱정 없이 마음 편하게 최신 이미지 포맷을 도입할 수 있었습니다.

<picture>, <source>, <img> 구조 예시

3. 해결책 2: Node.js(Sharp)로 자동 변환 스크립트 만들어 버리기

이미지가 한두 개가 아니다 보니 일일이 변환 사이트에 업로드해서 다운로드하는 작업이 너무 귀찮았습니다. 그래서 Node.js의 이미지 처리 라이브러리인 sharp를 활용해, 명령어 하나만 치면 프로젝트 내 모든 PNG 파일이 WebP와 AVIF로 자동 변환되는 스크립트를 직접 짜서 해결했습니다.

JavaScript
 
// convert.js (내가 직접 만든 이미지 자동 변환 스크립트)
const sharp = require('sharp');
const path = require('path');

const inputPath = path.join(__dirname, 'public/images/main-banner.png');

// PNG를 WebP로 자동 변환 (품질 80% 설정)
sharp(inputPath)
  .webp({ quality: 80 })
  .toFile(path.join(__dirname, 'public/images/main-banner.webp'))
  .then(() => console.log('✅ WebP 변환 성공!'));

// PNG를 AVIF로 자동 변환 (품질 75% 설정)
sharp(inputPath)
  .avif({ quality: 75 })
  .toFile(path.join(__dirname, 'public/images/main-banner.avif'))
  .then(() => console.log('🚀 AVIF 변환 성공!'));

터미널에서 node convert.js만 실행하면 몇 초 만에 고용량 PNG가 초경량 파일들로 쓱 변환되어 나와서 개발 생산성이 엄청나게 올라갔습니다.

4. 직접 눈으로 확인한 용량 비교: "2.4MB가 210KB가 되는 기적"

변환을 마치고 폴더 안에 있는 동일한 이미지의 포맷별 파일 용량을 직접 확인해 보았습니다.

  • 기존 원본 PNG: 2,400 KB (2.4MB)
  • WebP 변환 후: 420 KB (약 82% 용량 감소!)
  • AVIF 변환 후: 210 KB (약 91% 용량 감소!)

화질은 눈으로 봤을 때 원본 PNG와 차이를 구분할 수 없을 정도로 선명한데, 파일 용량은 무려 10분의 1 수준으로 줄어들었습니다. 그 결과 메인 페이지에 진입할 때 이미지가 버벅거리지 않고 즉시 뜨는 쾌적한 환경을 만들 수 있었습니다.

Node.js 환경에서 sharp 라이브러리로 PNG 이미지를 WebP와 AVIF로 자동 변환해 주는 스크립트 코드

 

5. 깨달은 점 및 요약

이전에는 프론트엔드 성능 최적화라고 하면 무조건 복잡한 코드 리팩토링이나 상태 관리 최적화만 생각했었습니다.

하지만 이번 경험을 통해 이미지 포맷을 WebP나 AVIF로 바꾸는 것 같은 단순한 시도가 웹사이트 성능에 얼마나 파격적인 영향을 주는지 깨달았습니다.

혹시 지금 만들고 계신 프로젝트의 로딩 속도가 답답하다면, 소스 코드보다 먼저 '내가 넣은 이미지 파일의 포맷과 용량'부터 점검해 보시면 좋을 것 같습니다!

리액트(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과 친해지시기를 추천합니다. 실수해도 복구하는 방법을 알면 개발이 훨씬 더 자신감 넘쳐집니다!

+ Recent posts