프론트엔드 개발자 면접 질문 100선

React, Next.js, TailwindCSS, JavaScript, TypeScript, CSS를 학습하고 Vercel과 Supabase로 프로젝트를 만드는 분들을 위한 면접 질문과 답변 모음입니다.
자바스크립트 핵심, 타입스크립트, CSS와 테일윈드, 리액트 기초와 훅, 성능, Next.js, 웹과 브라우저, Supabase 연동, Vercel 배포까지 열 개 분야에 걸쳐 100문항을 담았습니다.
목차
- JavaScript 핵심
1.1 var, let, const는 어떻게 다르며 스코프는 각각 어떻게 동작하는가
1.2 호이스팅은 무엇이며 var와 let, const에서 어떻게 다르게 동작하는가
1.3 클로저는 무엇이며 어디에 쓰이는가
1.4 this는 무엇을 가리키며 어떤 규칙으로 결정되는가
1.5 프로토타입과 프로토타입 체인은 무엇인가
1.6 자바스크립트는 한 번에 하나만 처리하는데 어떻게 비동기가 가능한가
1.7 콜백, Promise, async/await는 어떻게 발전해 왔으며 어떻게 다른가
1.8 == 와 === 는 어떻게 다르며 무엇을 써야 하는가
1.9 얕은 복사와 깊은 복사는 어떻게 다른가
1.10 구조 분해 할당과 스프레드 연산자는 무엇이며 어떻게 쓰는가
1.11 map, filter, reduce는 각각 무엇을 하며 for 문과 비교해 어떤 점이 좋은가
1.12 이벤트 버블링과 캡처링은 무엇이며 이벤트 위임은 어떻게 활용하는가 - TypeScript
2.1 타입스크립트를 쓰는 이유는 무엇인가
2.2 type과 interface는 어떻게 다르며 무엇을 써야 하는가
2.3 제네릭은 무엇이며 왜 필요한가
2.4 유니온 타입과 인터섹션 타입은 어떻게 다른가
2.5 타입 좁히기는 무엇이며 어떻게 하는가
2.6 유틸리티 타입에는 무엇이 있으며 언제 쓰는가
2.7 any, unknown, never는 어떻게 다른가
2.8 타입 단언은 무엇이며 왜 조심해서 써야 하는가
2.9 enum과 리터럴 유니온 중 무엇을 쓰는 것이 좋은가
2.10 타입 추론은 무엇이며 타입을 어디까지 명시해야 하는가 - CSS와 TailwindCSS
3.1 박스 모델은 무엇이며 box-sizing은 어떤 역할을 하는가
3.2 position의 static, relative, absolute, fixed, sticky는 어떻게 다른가
3.3 flexbox는 무엇이며 주요 속성은 어떻게 동작하는가
3.4 grid는 무엇이며 flexbox와 어떻게 다른가
3.5 반응형 디자인은 어떻게 구현하며 미디어 쿼리는 무엇인가
3.6 CSS 명시도는 무엇이며 어떤 규칙으로 우선순위가 정해지는가
3.7 px, rem, em, % 단위는 각각 어떻게 다르며 언제 쓰는가
3.8 테일윈드는 어떻게 동작하며 일반 CSS와 무엇이 다른가
3.9 테일윈드의 유틸리티 방식은 어떤 장단점이 있는가
3.10 가상 클래스와 가상 요소는 무엇이며 어떻게 다른가 - React 기초
4.1 가상 DOM은 무엇이며 왜 쓰는가
4.2 JSX는 무엇이며 어떻게 동작하는가
4.3 컴포넌트와 props는 무엇이며 어떻게 쓰는가
4.4 state는 무엇이며 props와 어떻게 다른가
4.5 리액트의 단방향 데이터 흐름은 무엇이며 왜 그렇게 설계됐는가
4.6 리스트를 렌더링할 때 key는 왜 필요하며 인덱스를 key로 쓰면 안 되는 이유는 무엇인가
4.7 제어 컴포넌트와 비제어 컴포넌트는 어떻게 다른가
4.8 조건부 렌더링은 어떤 방법으로 하는가
4.9 리액트의 이벤트 처리는 일반 DOM 이벤트와 무엇이 다른가
4.10 컴포넌트 합성은 무엇이며 children은 어떻게 활용하는가
4.11 StrictMode는 무엇이며 개발 모드에서 컴포넌트가 두 번 실행되는 이유는 무엇인가 - React Hooks
5.1 useState는 어떻게 동작하며 상태 업데이트가 바로 반영되지 않는 이유는 무엇인가
5.2 useEffect는 무엇이며 의존성 배열은 어떤 역할을 하는가
5.3 useEffect의 cleanup 함수는 무엇이며 왜 필요한가
5.4 useRef는 어떤 용도로 쓰는가
5.5 useMemo는 무엇이며 언제 쓰는가
5.6 useCallback은 무엇이며 useMemo와 어떻게 다른가
5.7 커스텀 훅은 무엇이며 왜 만드는가
5.8 훅을 쓸 때 지켜야 하는 규칙은 무엇이며 왜 그런가
5.9 useContext는 무엇이며 어떤 문제를 해결하는가
5.10 useReducer는 무엇이며 useState와 어떻게 구분해 쓰는가 - React 심화와 성능
6.1 리액트에서 컴포넌트가 다시 그려지는 원인은 무엇인가
6.2 React.memo는 무엇이며 언제 효과가 있는가
6.3 상태 끌어올리기는 무엇이며 언제 필요한가
6.4 prop drilling은 무엇이며 어떻게 해결하는가
6.5 상태 관리 라이브러리는 왜 필요하며 무엇을 고려해 선택하는가
6.6 에러 바운더리는 무엇이며 어떻게 동작하는가
6.7 코드 스플리팅은 무엇이며 lazy와 Suspense로 어떻게 구현하는가
6.8 리액트에서 상태를 바꿀 때 불변성을 지켜야 하는 이유는 무엇인가
6.9 항목이 수천 개인 긴 목록은 어떻게 빠르게 렌더링하는가
6.10 useEffect를 쓰지 않아도 되는 경우는 언제인가
6.11 React 19에서 달라진 주요 기능은 무엇인가 - Next.js
7.1 CSR, SSR, SSG, ISR은 각각 무엇이며 어떻게 다른가
7.2 App Router와 Pages Router는 어떻게 다르며 무엇을 써야 하는가
7.3 서버 컴포넌트와 클라이언트 컴포넌트는 어떻게 다른가
7.4 Next.js의 파일 기반 라우팅과 동적 라우트는 어떻게 동작하는가
7.5 layout은 무엇이며 중첩 레이아웃은 어떻게 동작하는가
7.6 App Router에서 데이터 페칭과 캐싱은 어떻게 동작하는가
7.7 서버 액션은 무엇이며 어떻게 쓰는가
7.8 라우트 핸들러는 무엇이며 언제 쓰는가
7.9 Next.js에서 메타데이터와 SEO는 어떻게 다루는가
7.10 next/image는 이미지를 어떻게 최적화하는가
7.11 미들웨어는 무엇이며 어떤 일에 쓰는가
7.12 Next.js에서 환경 변수의 공개와 비공개는 어떻게 구분하는가 - 웹 기초와 브라우저
8.1 HTTP 요청과 응답은 어떤 구조로 이루어지는가
8.2 브라우저가 페이지를 화면에 그리는 과정은 어떻게 되는가
8.3 리플로우와 리페인트는 무엇이며 성능과 어떤 관계가 있는가
8.4 CORS는 무엇이며 왜 발생하는가
8.5 쿠키, 로컬 스토리지, 세션 스토리지는 어떻게 다른가
8.6 REST API는 무엇이며 어떻게 설계하는가
8.7 세션 방식과 토큰 방식 인증은 어떻게 다른가
8.8 시맨틱 HTML과 웹 접근성은 무엇이며 왜 중요한가
8.9 디바운스와 스로틀은 무엇이며 어떻게 다른가
8.10 웹 페이지의 성능은 어떤 방법으로 개선하는가 - Supabase 연동
9.1 Supabase는 무엇이며 무엇을 제공하는가
9.2 Supabase의 인증은 어떻게 동작하는가
9.3 RLS는 무엇이며 Supabase에서 왜 반드시 필요한가
9.4 anon 키와 service role 키는 어떻게 다르며 각각 어디에 쓰는가
9.5 클라이언트에서 데이터베이스를 직접 호출하는 구조는 안전한가
9.6 Supabase의 실시간 기능은 무엇이며 어떻게 활용하는가
9.7 Supabase 스토리지는 무엇이며 파일을 어떻게 다루는가
9.8 서버 컴포넌트와 클라이언트 컴포넌트에서 Supabase를 다룰 때 무엇을 고려해야 하는가 - Vercel 배포와 협업
10.1 Vercel은 무엇이며 배포는 어떻게 동작하는가
10.2 미리보기 배포는 무엇이며 협업에서 어떻게 활용하는가
10.3 환경 변수는 어떻게 관리하며 환경을 왜 분리하는가
10.4 빌드 시점 환경 변수와 런타임 환경 변수는 어떻게 다른가
10.5 깃을 이용한 협업과 변경 요청 리뷰는 어떻게 이루어지는가
10.6 배포 후 문제가 생겼을 때 프론트엔드 개발자는 어떻게 대응하는가
1. JavaScript 핵심
1.1 var, let, const는 어떻게 다르며 스코프는 각각 어떻게 동작하는가
var는 함수 단위로 유효 범위가 정해진다. 블록 안에서 선언해도 그 블록을 벗어나 함수 전체에서 접근할 수 있다. let과 const는 블록 단위로 유효 범위가 정해진다. 중괄호로 묶인 블록 안에서 선언하면 그 블록을 벗어나면 접근할 수 없다. 이 차이 때문에 반복문이나 조건문 안에서 변수를 다룰 때 결과가 달라진다.
let은 선언 후 값을 다시 할당할 수 있고, const는 한 번 할당하면 다시 할당할 수 없다. 다만 const가 막는 것은 변수에 다른 값을 다시 연결하는 것이지, 객체나 배열의 내부 값을 바꾸는 것까지 막지는 않는다. const로 선언한 객체라도 그 객체의 속성은 변경할 수 있다. 실무에서는 기본으로 const를 쓰고, 값을 다시 할당해야 할 때만 let을 쓰며, var는 거의 쓰지 않는다.
다음은 var와 let의 블록 스코프 차이를 보여주는 예제입니다.
function scopeTest() { if (true) { var a = 'var 변수'; let b = 'let 변수'; } console.log(a); // 'var 변수' - if 블록 밖에서도 접근된다 console.log(b); // ReferenceError - 블록을 벗어나 접근 불가 } // const는 재할당을 막지만 내부 값은 바꿀 수 있다 const user = { name: '김개발' }; user.name = '이코딩'; // 가능 - 객체 내부 변경 // user = { name: '박' }; // 에러 - 재할당 불가
- var는 함수 스코프, let과 const는 블록 스코프를 가진다.
- const는 재할당을 막지만 객체나 배열의 내부 값 변경은 막지 않는다.
- 기본으로 const를 쓰고 재할당이 필요할 때만 let을 쓴다.
1.2 호이스팅은 무엇이며 var와 let, const에서 어떻게 다르게 동작하는가
호이스팅은 변수와 함수 선언이 코드 실행 전에 끌어올려진 것처럼 처리되는 동작이다. 자바스크립트 엔진은 코드를 실행하기 전에 먼저 선언을 훑어 메모리에 등록한다. 그래서 선언보다 위에 있는 코드에서도 그 이름을 인식한다. 다만 변수의 종류에 따라 그 이름에 접근했을 때의 결과가 다르다.
var로 선언한 변수는 끌어올려지면서 undefined로 초기화된다. 그래서 선언 전에 읽으면 에러가 아니라 undefined가 나온다. let과 const도 끌어올려지기는 하지만 선언문에 도달하기 전까지는 값을 읽을 수 없는 상태로 남는다. 이 구간을 일시적 사각지대라 부르고, 이 안에서 접근하면 에러가 난다. 이 동작 덕분에 let과 const는 선언 전에 잘못 쓰는 실수를 일찍 잡아 준다. 함수 선언문은 통째로 끌어올려져 선언 전에도 호출할 수 있다.
다음은 호이스팅의 차이를 보여주는 예제입니다.
console.log(x); // undefined - var는 선언이 끌어올려져 초기화됨 var x = 10; console.log(y); // ReferenceError - let은 일시적 사각지대 let y = 20; sayHi(); // 정상 동작 - 함수 선언문은 통째로 끌어올려짐 function sayHi() { console.log('안녕하세요'); }
- 호이스팅은 선언이 코드 실행 전에 끌어올려진 것처럼 처리되는 동작이다.
- var는 undefined로 초기화되지만 let과 const는 선언 전 접근 시 에러가 난다.
- 함수 선언문은 통째로 끌어올려져 선언 위에서도 호출할 수 있다.
1.3 클로저는 무엇이며 어디에 쓰이는가
클로저는 함수가 자신이 만들어진 위치의 변수를 기억하는 것이다. 함수 안에서 다른 함수를 만들어 반환하면, 안쪽 함수는 바깥 함수의 변수에 계속 접근할 수 있다. 바깥 함수의 실행이 끝나 사라진 뒤에도, 반환된 안쪽 함수는 그 변수를 살아 있는 채로 붙잡고 있다. 이렇게 함수와 그 함수가 참조하는 바깥 변수가 함께 묶인 것이 클로저다.
클로저는 외부에서 직접 접근할 수 없는 값을 만들 때 유용하다. 바깥에서는 건드릴 수 없고 반환된 함수를 통해서만 다룰 수 있는 값을 둘 수 있어, 데이터를 감추고 정해진 방법으로만 다루게 한다. 이벤트 처리기나 콜백이 자신을 등록할 때의 상태를 기억해야 할 때도 클로저가 동작한다. 리액트의 훅도 내부적으로 클로저에 기대어 컴포넌트의 상태를 기억한다.
다음은 클로저로 숨겨진 값을 다루는 예제입니다.
function createCounter() { let count = 0; // 바깥에서 직접 접근 불가 return { increment() { count += 1; return count; }, decrement() { count -= 1; return count; }, getCount() { return count; }, }; } const counter = createCounter(); console.log(counter.increment()); // 1 console.log(counter.increment()); // 2 console.log(counter.getCount()); // 2 // count 변수는 반환된 함수들을 통해서만 다룰 수 있다
- 클로저는 함수가 만들어진 위치의 변수를 기억해 계속 참조하는 것이다.
- 바깥 함수가 끝난 뒤에도 안쪽 함수가 그 변수를 살아 있게 붙잡는다.
- 값을 감추거나 상태를 기억해야 할 때 쓰이며 리액트 훅의 바탕이 된다.
1.4 this는 무엇을 가리키며 어떤 규칙으로 결정되는가
this는 함수가 호출될 때 무엇을 기준으로 실행되는지를 가리킨다. 중요한 점은 this가 함수를 어디에 썼느냐가 아니라 어떻게 호출했느냐에 따라 정해진다는 것이다. 같은 함수라도 호출 방식이 달라지면 this가 가리키는 대상이 바뀐다. 그래서 this를 헷갈려 하는 경우가 많다.
객체의 메서드로 호출하면 this는 그 객체를 가리킨다. 일반 함수로 그냥 호출하면 this는 전역 객체이거나 엄격 모드에서는 undefined가 된다. 화살표 함수는 자신만의 this를 만들지 않고, 자신이 정의된 바깥의 this를 그대로 쓴다. 그래서 콜백 안에서 바깥의 this를 유지하고 싶을 때 화살표 함수가 편리하다. call, apply, bind로 this를 직접 지정할 수도 있다. 리액트에서 클래스 컴포넌트의 메서드를 화살표 함수로 쓰거나 bind 하는 이유도 this를 컴포넌트에 묶기 위해서다.
다음은 호출 방식에 따라 this가 달라지는 예제입니다.
const person = { name: '김개발', greet() { console.log(this.name); // '김개발' - 객체의 메서드로 호출 }, greetLater() { setTimeout(() => { console.log(this.name); // '김개발' - 화살표 함수가 바깥 this를 유지 }, 100); }, }; person.greet(); person.greetLater(); const fn = person.greet; fn(); // undefined - 일반 함수로 호출되어 this가 person이 아님
- this는 함수를 어떻게 호출했는지에 따라 정해진다.
- 객체 메서드 호출은 그 객체를, 일반 호출은 전역이나 undefined를 가리킨다.
- 화살표 함수는 바깥의 this를 그대로 써서 콜백에서 this 유지에 편리하다.
1.5 프로토타입과 프로토타입 체인은 무엇인가
자바스크립트의 모든 객체는 다른 객체를 부모처럼 연결해 둔다. 이 연결된 부모 객체를 프로토타입이라 한다. 어떤 객체에서 속성이나 메서드를 찾을 때, 그 객체에 없으면 연결된 프로토타입으로 올라가 찾고, 거기에도 없으면 다시 그 위로 올라간다. 이렇게 위로 이어진 사슬이 프로토타입 체인이다. 사슬의 끝까지 가도 없으면 undefined가 나온다.
이 구조 덕분에 같은 기능을 모든 객체가 각자 복사해 가지지 않고 프로토타입에 한 번만 두고 공유할 수 있다. 배열의 map이나 filter 같은 메서드도 각 배열이 직접 가진 것이 아니라 배열의 프로토타입에 있고, 우리가 만든 배열이 체인을 따라 그것을 빌려 쓴다. 클래스 문법도 겉모습만 다를 뿐 내부는 이 프로토타입 구조로 동작한다. 상속 역시 프로토타입 체인으로 구현된다.
- 모든 객체는 부모 역할을 하는 프로토타입을 연결해 둔다.
- 속성을 찾을 때 없으면 프로토타입 체인을 따라 위로 올라가며 찾는다.
- 공통 기능을 프로토타입에 두고 공유하며 상속도 이 구조로 동작한다.
1.6 자바스크립트는 한 번에 하나만 처리하는데 어떻게 비동기가 가능한가
자바스크립트 자체는 한 번에 하나의 작업만 실행하는 단일 흐름으로 동작한다. 그런데도 네트워크 요청이나 타이머 같은 오래 걸리는 일을 기다리는 동안 화면이 멈추지 않는다. 비밀은 이런 느린 작업을 자바스크립트 엔진이 직접 붙잡고 기다리지 않고, 브라우저나 실행 환경에 맡긴다는 데 있다. 작업을 맡겨 두고 자바스크립트는 다음 코드를 계속 실행한다.
맡긴 작업이 끝나면 그에 연결된 콜백 함수가 대기열에 들어간다. 이벤트 루프는 지금 실행 중인 코드가 모두 끝나 실행 흐름이 비는 순간을 기다렸다가, 대기열에 있는 콜백을 꺼내 실행한다. 이때 약속을 다루는 콜백은 일반 타이머 콜백보다 먼저 처리되는 우선 대기열에 들어간다. 그래서 같은 시점에 등록해도 실행 순서가 달라진다. 이 구조 덕분에 단일 흐름이면서도 여러 작업을 기다리며 멈추지 않고 처리한다.
다음은 실행 순서를 보여주는 예제입니다.
console.log('1 시작'); setTimeout(() => console.log('2 타이머'), 0); Promise.resolve().then(() => console.log('3 프로미스')); console.log('4 끝'); // 출력 순서: 1 시작, 4 끝, 3 프로미스, 2 타이머 // 동기 코드가 먼저 끝나고, 프로미스 콜백이 타이머 콜백보다 먼저 실행된다
- 자바스크립트는 단일 흐름이며 느린 작업은 실행 환경에 맡긴다.
- 끝난 작업의 콜백은 대기열에 들어가고 이벤트 루프가 흐름이 빌 때 실행한다.
- 프로미스 콜백은 타이머 콜백보다 먼저 처리되는 우선 대기열에 들어간다.
1.7 콜백, Promise, async/await는 어떻게 발전해 왔으며 어떻게 다른가
비동기 작업의 결과를 다루는 방법은 콜백에서 시작했다. 작업이 끝나면 실행할 함수를 넘겨 두는 방식이다. 그런데 비동기 작업이 줄줄이 이어지면 콜백 안에 콜백을 또 넣어야 해서 코드가 깊게 중첩되고 읽기 어려워진다. 오류 처리도 단계마다 따로 해야 해서 번거롭다. 이 문제를 흔히 콜백 지옥이라 부른다.
Promise는 비동기 작업의 결과를 담는 객체로, then으로 다음 작업을 이어 붙이고 catch로 오류를 한곳에서 처리한다. 중첩 대신 사슬처럼 평평하게 이어 쓸 수 있다. async와 await는 이 Promise를 더 읽기 쉽게 쓰는 문법이다. await를 붙이면 그 작업이 끝날 때까지 기다린 결과를 마치 동기 코드처럼 받아 쓸 수 있다. 오류는 try와 catch로 묶어 처리한다. 동작 원리는 Promise 그대로지만 코드 모양이 위에서 아래로 읽혀 이해하기 쉽다.
다음은 같은 작업을 async/await로 작성한 예제입니다.
async function loadUser(id) { try { const res = await fetch('/api/users/' + id); if (!res.ok) throw new Error('요청 실패'); const user = await res.json(); return user; } catch (err) { console.error('불러오기 오류', err); throw err; } } loadUser(1).then((user) => console.log(user));
- 콜백은 중첩이 깊어지면 읽기 어렵고 오류 처리가 번거롭다.
- Promise는 then과 catch로 작업을 평평하게 이어 붙이고 오류를 한곳에서 다룬다.
- async/await는 Promise를 동기 코드처럼 읽기 쉽게 쓰는 문법이다.
1.8 == 와 === 는 어떻게 다르며 무엇을 써야 하는가
== 는 두 값을 비교할 때 타입이 다르면 한쪽을 자동으로 변환한 뒤 비교한다. 그래서 숫자 1과 문자열 1을 같다고 판단한다. 이 자동 변환 규칙은 직관과 어긋나는 경우가 많아, 의도하지 않은 결과를 낳고 버그의 원인이 된다. 예를 들어 빈 문자열과 0, 그리고 false가 서로 같다고 나오는 식이다.
=== 는 타입을 변환하지 않고, 값과 타입이 모두 같을 때만 같다고 판단한다. 숫자 1과 문자열 1은 타입이 다르므로 다르다고 나온다. 결과를 예측하기 쉽고 의도하지 않은 변환이 없으므로 실무에서는 === 를 기본으로 쓴다. 값이 있는지 없는지를 느슨하게 확인할 때처럼 == 가 의도적으로 편한 드문 경우를 빼면, 항상 === 를 쓰는 편이 안전하다.
- == 는 타입이 다르면 자동 변환 후 비교해 예상 밖의 결과를 낸다.
- === 는 값과 타입이 모두 같아야 같다고 판단한다.
- 결과가 예측 가능한 === 를 기본으로 쓴다.
1.9 얕은 복사와 깊은 복사는 어떻게 다른가
객체나 배열을 복사할 때 얕은 복사는 가장 바깥 단계만 새로 만들고, 내부에 들어 있는 객체는 원본과 같은 것을 가리킨다. 그래서 복사본의 바깥 속성을 바꾸면 원본에 영향이 없지만, 내부 객체의 값을 바꾸면 원본의 그 객체도 함께 바뀐다. 스프레드 연산자나 Object.assign이 얕은 복사를 한다. 한 단계만 있는 단순한 객체라면 얕은 복사로 충분하다.
깊은 복사는 내부에 중첩된 객체까지 전부 새로 만들어, 복사본과 원본이 어떤 값도 공유하지 않게 한다. 그래서 복사본의 내부 깊은 곳을 바꿔도 원본은 그대로다. 중첩이 있는 객체를 안전하게 복사하려면 깊은 복사가 필요하다. 최신 환경에서는 structuredClone 함수로 깊은 복사를 할 수 있다. 리액트에서 상태를 업데이트할 때 중첩된 부분을 바꾼다면 그 경로를 새 객체로 만들어 줘야 변경이 제대로 감지된다.
다음은 얕은 복사의 한계를 보여주는 예제입니다.
const original = { name: '김개발', address: { city: '서울' } }; const shallow = { ...original }; // 얕은 복사 shallow.name = '이코딩'; shallow.address.city = '부산'; console.log(original.name); // '김개발' - 바깥 속성은 영향 없음 console.log(original.address.city); // '부산' - 내부 객체는 공유되어 함께 바뀜 const deep = structuredClone(original); // 깊은 복사 deep.address.city = '대구'; console.log(original.address.city); // 영향 없음
- 얕은 복사는 바깥 단계만 새로 만들고 내부 객체는 원본과 공유한다.
- 깊은 복사는 중첩된 객체까지 전부 새로 만들어 아무것도 공유하지 않는다.
- 중첩 객체를 안전하게 복사하려면 structuredClone 같은 깊은 복사를 쓴다.
1.10 구조 분해 할당과 스프레드 연산자는 무엇이며 어떻게 쓰는가
구조 분해 할당은 객체나 배열에서 필요한 값을 한 번에 꺼내 변수에 담는 문법이다. 객체에서는 속성 이름으로 꺼내고, 배열에서는 순서대로 꺼낸다. 여러 줄에 걸쳐 속성을 하나씩 꺼내던 코드를 한 줄로 줄여 준다. 함수의 매개변수에서도 바로 구조 분해를 써서 필요한 값만 받을 수 있어, 리액트에서 props를 받을 때 흔히 쓴다.
스프레드 연산자는 객체나 배열의 내용을 펼쳐서 새 객체나 배열에 담는다. 기존 값을 복사하면서 일부를 더하거나 바꾼 새 값을 만들 때 쓴다. 원본을 그대로 두고 새것을 만들기 때문에, 불변성을 지켜야 하는 리액트 상태 업데이트에서 특히 자주 쓴다. 함수에 인자를 펼쳐 넘기거나, 여러 배열을 합칠 때도 쓴다. 나머지 매개변수와 모양이 같지만, 펼치는 쪽이면 스프레드이고 모으는 쪽이면 나머지다.
다음은 구조 분해와 스프레드를 함께 쓰는 예제입니다.
const user = { id: 1, name: '김개발', role: 'admin' }; // 구조 분해 - 필요한 값만 꺼낸다 const { name, role } = user; console.log(name, role); // 김개발 admin // 스프레드 - 기존 값을 복사하며 일부만 바꾼 새 객체를 만든다 const updated = { ...user, role: 'user' }; console.log(updated); // { id: 1, name: '김개발', role: 'user' } // 배열 합치기 const a = [1, 2]; const b = [3, 4]; const merged = [...a, ...b]; // [1, 2, 3, 4]
- 구조 분해는 객체나 배열에서 필요한 값을 한 번에 꺼내 변수에 담는다.
- 스프레드는 내용을 펼쳐 새 객체나 배열을 만들어 원본을 건드리지 않는다.
- 불변성을 지켜야 하는 리액트 상태 업데이트에서 스프레드를 자주 쓴다.
1.11 map, filter, reduce는 각각 무엇을 하며 for 문과 비교해 어떤 점이 좋은가
map은 배열의 각 요소를 변환해 같은 길이의 새 배열을 만든다. filter는 조건을 만족하는 요소만 골라 새 배열을 만든다. reduce는 배열을 하나씩 돌면서 값을 누적해 최종 결과 하나를 만든다. 합계를 구하거나 배열을 객체로 바꾸는 등 폭넓게 쓴다. 이 세 함수는 모두 원본 배열을 바꾸지 않고 새 값을 반환한다.
for 문으로도 같은 일을 할 수 있지만, 이 함수들은 무엇을 하려는지가 이름에 드러나 코드의 의도가 분명하다. 변환인지 걸러내기인지 누적인지가 한눈에 보인다. 또 원본을 바꾸지 않아 예상치 못한 부수 효과가 적고, 함수를 이어 붙여 여러 단계를 연결하기 쉽다. 리액트에서 데이터 목록을 화면 요소로 바꿀 때 map을 거의 항상 쓴다. 다만 단순 반복이나 중간에 멈춰야 하는 경우에는 일반 반복문이 더 맞을 때도 있다.
다음은 세 함수를 함께 쓰는 예제입니다.
const products = [ { name: '키보드', price: 30000 }, { name: '마우스', price: 15000 }, { name: '모니터', price: 200000 }, ]; // 5만원 이하만 골라서 이름만 뽑기 const cheapNames = products .filter((p) => p.price <= 50000) .map((p) => p.name); console.log(cheapNames); // ['키보드', '마우스'] // 전체 가격 합계 구하기 const total = products.reduce((sum, p) => sum + p.price, 0); console.log(total); // 245000
- map은 변환, filter는 걸러내기, reduce는 누적을 담당한다.
- 세 함수 모두 원본을 바꾸지 않고 새 값을 반환한다.
- 의도가 이름에 드러나고 이어 붙이기 쉬워 데이터 가공에 유리하다.
1.12 이벤트 버블링과 캡처링은 무엇이며 이벤트 위임은 어떻게 활용하는가
화면의 한 요소에서 이벤트가 일어나면 그 이벤트는 혼자 끝나지 않고 부모 요소들로 퍼진다. 가장 안쪽 요소에서 시작해 바깥쪽 부모로 거슬러 올라가며 전달되는 것을 버블링이라 한다. 반대로 바깥에서 안쪽으로 내려오며 전달되는 단계를 캡처링이라 한다. 기본적으로 이벤트 처리기는 버블링 단계에서 동작하므로, 자식에서 일어난 클릭을 부모에서도 잡을 수 있다.
이벤트 위임은 이 버블링을 이용해, 자식마다 따로 처리기를 다는 대신 부모 하나에만 처리기를 달고 어느 자식에서 일어났는지 확인하는 방식이다. 목록처럼 자식이 많거나 동적으로 늘어나는 경우, 처리기를 하나만 달면 되니 메모리를 아끼고 나중에 추가된 자식도 자동으로 처리된다. 이벤트가 실제로 시작된 요소는 이벤트 객체의 target으로 확인한다. 리액트도 내부적으로 이벤트를 한곳에 모아 처리하는 비슷한 구조를 쓴다.
다음은 이벤트 위임으로 목록 클릭을 처리하는 예제입니다.
const list = document.querySelector('#todo-list'); // 부모 하나에만 처리기를 달고 어느 항목인지 확인한다 list.addEventListener('click', (event) => { const item = event.target.closest('li'); if (!item) return; console.log('클릭한 항목', item.dataset.id); }); // 항목이 나중에 추가되어도 같은 처리기가 그대로 동작한다
- 버블링은 안쪽에서 바깥으로, 캡처링은 바깥에서 안쪽으로 이벤트가 전달되는 단계다.
- 이벤트 위임은 부모 하나에 처리기를 달고 target으로 어느 자식인지 확인한다.
- 자식이 많거나 동적으로 늘어날 때 메모리를 아끼고 관리가 쉬워진다.
2. TypeScript
2.1 타입스크립트를 쓰는 이유는 무엇인가
자바스크립트는 변수에 어떤 타입의 값이든 담을 수 있고 타입을 미리 정하지 않는다. 편하지만 실수가 늦게 드러난다는 약점이 있다. 숫자가 와야 할 곳에 문자열을 넘기거나, 없는 속성에 접근하는 잘못이 코드를 실행해 그 지점에 도달해야만 오류로 나타난다. 규모가 커지면 이런 실수를 찾기 어렵고, 어떤 값이 들어오는지 파악하기도 힘들다.
타입스크립트는 값에 타입을 정해 두고, 코드를 실행하기 전에 타입이 맞는지 검사한다. 그래서 잘못된 사용을 작성하는 순간 편집기에서 바로 알려 준다. 함수가 무엇을 받아 무엇을 돌려주는지 타입으로 드러나므로, 코드가 곧 설명서 역할을 한다. 자동 완성과 안전한 이름 변경 같은 편집기 기능도 정확해진다. 결국 타입스크립트는 실수를 일찍 잡고 여러 사람이 함께 다루는 큰 코드를 안정적으로 관리하게 돕는다. 작성한 타입 정보는 빌드 후 자바스크립트로 변환될 때 사라지고 검사 용도로만 쓰인다.
- 자바스크립트는 타입 실수가 실행 시점에야 드러나 찾기 어렵다.
- 타입스크립트는 실행 전에 타입을 검사해 잘못된 사용을 미리 잡는다.
- 타입이 코드의 설명서가 되고 편집기 지원도 정확해져 큰 코드 관리에 유리하다.
2.2 type과 interface는 어떻게 다르며 무엇을 써야 하는가
type과 interface는 둘 다 객체의 모양을 정의하는 데 쓰여 많은 경우 서로 바꿔 쓸 수 있다. interface는 객체나 클래스의 구조를 표현하는 데 특화되어 있고, 같은 이름으로 여러 번 선언하면 자동으로 합쳐지는 특징이 있다. 또 extends로 다른 인터페이스를 확장하기 편하다. 라이브러리가 제공하는 타입을 사용자가 덧붙여 확장할 때 이 합쳐지는 성질이 유용하다.
type은 객체뿐 아니라 더 넓은 것을 다룬다. 여러 타입을 합치거나 둘 중 하나로 정하는 유니온, 특정 값만 허용하는 리터럴, 조건에 따라 달라지는 타입처럼 객체가 아닌 형태도 표현한다. 같은 이름으로 다시 선언하면 합쳐지지 않고 충돌한다. 실무에서는 객체 구조나 클래스 규약을 정의할 때 interface를, 유니온이나 복잡한 조합이 필요할 때 type을 쓰는 식으로 나누거나, 팀이 한 가지로 통일해 일관되게 쓴다.
다음은 type과 interface의 쓰임을 비교한 예제입니다.
// interface - 객체 구조 정의와 확장에 적합 interface User { id: number; name: string; } interface Admin extends User { role: 'admin'; } // type - 유니온이나 리터럴 같은 넓은 표현에 적합 type Status = 'pending' | 'active' | 'closed'; type Id = number | string; type Point = { x: number; y: number };
- 둘 다 객체 모양을 정의하며 많은 경우 바꿔 쓸 수 있다.
- interface는 같은 이름 선언이 합쳐지고 확장에 편하다.
- type은 유니온, 리터럴처럼 객체가 아닌 형태까지 표현한다.
2.3 제네릭은 무엇이며 왜 필요한가
제네릭은 타입을 미리 고정하지 않고, 쓰는 시점에 정하도록 비워 두는 방법이다. 어떤 값이든 담을 수 있는 함수나 자료 구조를 만들면서도 타입 안전성을 잃지 않게 한다. 예를 들어 배열의 첫 요소를 돌려주는 함수를 만들 때, 숫자 배열이든 문자열 배열이든 받을 수 있어야 한다. 그렇다고 아무 타입이나 받는 식으로 두면, 돌려받은 값의 타입을 알 수 없어진다.
제네릭을 쓰면 함수에 들어온 배열의 요소 타입을 기억해 두었다가, 반환값의 타입에 그대로 연결한다. 숫자 배열을 넘기면 숫자가, 문자열 배열을 넘기면 문자열이 반환된다는 것을 타입스크립트가 안다. 덕분에 하나의 함수로 여러 타입을 다루면서도 결과의 타입이 정확히 추론된다. 리액트에서 useState도 제네릭으로 상태의 타입을 받아, 그 상태를 다룰 때 타입을 안전하게 지켜 준다.
다음은 제네릭 함수의 예제입니다.
function first<T>(arr: T[]): T | undefined { return arr[0]; } const n = first([1, 2, 3]); // 타입은 number const s = first(['a', 'b', 'c']); // 타입은 string // 제네릭이 없다면 반환 타입을 정확히 알 수 없다 // 들어온 배열의 요소 타입을 T가 기억해 반환 타입에 연결한다
- 제네릭은 타입을 비워 두고 쓰는 시점에 정하는 방법이다.
- 하나의 함수로 여러 타입을 다루면서 결과 타입을 정확히 추론한다.
- 리액트의 useState도 제네릭으로 상태 타입을 안전하게 지킨다.
2.4 유니온 타입과 인터섹션 타입은 어떻게 다른가
유니온 타입은 여러 타입 중 하나일 수 있음을 뜻한다. 세로 막대 기호로 연결하며, 값이 그중 어느 하나에 해당하면 된다. 예를 들어 문자열이거나 숫자인 값을 표현할 수 있다. 함수의 인자가 여러 형태를 받을 수 있을 때나, 정해진 몇 가지 상태 값만 허용하고 싶을 때 쓴다. 유니온으로 받은 값은 실제로 어느 타입인지 확인한 다음에 안전하게 쓸 수 있다.
인터섹션 타입은 여러 타입을 모두 만족해야 함을 뜻한다. 앰퍼샌드 기호로 연결하며, 연결된 타입들의 속성을 전부 가진 값이 된다. 여러 타입의 속성을 합쳐 하나로 만들 때 쓴다. 곧 유니온은 여러 중 하나, 인터섹션은 여럿을 모두 합친 것이다. 객체 타입을 합쳐 확장하는 데 인터섹션을, 가능한 여러 값을 표현하는 데 유니온을 쓴다.
다음은 유니온과 인터섹션을 비교한 예제입니다.
// 유니온 - 둘 중 하나 type Result = string | number; let r: Result = '성공'; r = 200; // 둘 다 가능 // 인터섹션 - 둘을 모두 합침 type Named = { name: string }; type Aged = { age: number }; type Person = Named & Aged; const p: Person = { name: '김개발', age: 25 }; // 두 속성 모두 필요
- 유니온은 여러 타입 중 하나일 수 있음을 뜻한다.
- 인터섹션은 여러 타입을 모두 만족해 속성을 전부 가진다.
- 가능한 여러 값에는 유니온, 속성 합치기에는 인터섹션을 쓴다.
2.5 타입 좁히기는 무엇이며 어떻게 하는가
유니온 타입으로 받은 값은 여러 타입일 수 있어서 바로 쓰기 어렵다. 문자열이거나 숫자인 값이라면, 문자열에만 있는 기능을 쓰려 할 때 타입스크립트가 막는다. 숫자일 수도 있기 때문이다. 타입 좁히기는 코드의 흐름 속에서 값이 지금 정확히 어떤 타입인지 알려 줘서, 그 타입에 맞는 기능을 안전하게 쓰게 하는 것이다.
typeof로 기본 타입을 확인하거나, 특정 속성이 있는지 확인하거나, 값이 무엇과 같은지 비교하면 타입스크립트가 그 분기 안에서는 타입을 좁혀 준다. 예를 들어 값이 문자열인지 확인한 if 블록 안에서는 그 값을 문자열로 확정해 다루게 해 준다. 이 좁히기 덕분에 유니온 타입을 안전하게 분기해 처리할 수 있다. 좁히기를 잘 활용하면 타입 단언으로 억지로 우기지 않고도 타입을 정확히 다룰 수 있다.
다음은 타입 좁히기를 사용하는 예제입니다.
function format(value: string | number): string { if (typeof value === 'string') { return value.toUpperCase(); // 이 블록 안에서는 string으로 확정 } return value.toFixed(2); // 여기서는 number로 확정 } console.log(format('hello')); // HELLO console.log(format(3.14159)); // 3.14
- 유니온 타입은 여러 타입일 수 있어 바로 쓰기 어렵다.
- typeof나 속성 확인, 값 비교로 분기하면 그 안에서 타입이 좁혀진다.
- 좁히기를 쓰면 타입 단언 없이 유니온을 안전하게 처리한다.
2.6 유틸리티 타입에는 무엇이 있으며 언제 쓰는가
유틸리티 타입은 타입스크립트가 기본으로 제공하는, 기존 타입을 변형해 새 타입을 만드는 도구다. 이미 정의한 타입을 바탕으로 조금 다른 타입이 필요할 때, 처음부터 다시 쓰지 않고 변형해서 만든다. 자주 쓰는 것으로 모든 속성을 선택적으로 만드는 Partial, 일부 속성만 골라내는 Pick, 특정 속성을 빼는 Omit, 모든 속성을 읽기 전용으로 만드는 Readonly가 있다.
예를 들어 사용자 타입이 있고, 수정 요청에서는 일부 속성만 보낸다면 Partial로 모든 속성을 선택적으로 만들어 재사용한다. 화면에 보여 줄 때 민감한 속성을 빼고 싶다면 Omit으로 그 속성만 제외한다. 이렇게 하면 원본 타입과 변형된 타입이 연결되어, 원본이 바뀌면 변형된 타입도 함께 따라가 일관성이 유지된다. 비슷한 타입을 여러 벌 손으로 관리하는 수고를 덜어 준다.
다음은 유틸리티 타입을 사용하는 예제입니다.
interface User { id: number; name: string; email: string; password: string; } // 수정 요청 - 일부 속성만 보낼 수 있게 type UserUpdate = Partial<User>; // 화면 노출용 - 민감한 속성 제외 type PublicUser = Omit<User, 'password'>; // 필요한 속성만 골라내기 type UserPreview = Pick<User, 'id' | 'name'>;
- 유틸리티 타입은 기존 타입을 변형해 새 타입을 만드는 도구다.
- Partial은 선택적으로, Pick은 일부만, Omit은 특정 속성을 빼서 만든다.
- 원본과 연결되어 원본이 바뀌면 변형 타입도 따라가 일관성이 유지된다.
2.7 any, unknown, never는 어떻게 다른가
any는 타입 검사를 사실상 끄는 타입이다. any로 둔 값은 무엇이든 할 수 있고 어떤 곳에든 넣을 수 있어, 타입스크립트의 보호를 받지 못한다. 급할 때 쓰고 싶은 유혹이 있지만, any가 퍼지면 타입스크립트를 쓰는 의미가 사라지므로 되도록 피한다. unknown은 any처럼 어떤 값이든 담을 수 있지만, 그 값을 실제로 쓰기 전에 타입을 확인하도록 강제한다. 무엇인지 모르는 값을 안전하게 다루는 타입이다.
외부에서 들어와 타입을 모르는 값은 any 대신 unknown으로 받고, 좁히기로 타입을 확인한 뒤 쓰는 것이 안전하다. never는 어떤 값도 될 수 없는 타입이다. 항상 예외를 던지거나 끝나지 않는 함수의 반환 타입, 또는 유니온의 모든 경우를 다 처리해 남는 경우가 없을 때 나타난다. never는 모든 분기를 빠짐없이 처리했는지 확인하는 데 활용할 수 있다.
- any는 타입 검사를 꺼 버려 보호를 받지 못하므로 피한다.
- unknown은 어떤 값이든 담되 쓰기 전에 타입 확인을 강제한다.
- never는 어떤 값도 될 수 없는 타입으로 모든 경우 처리 확인에 쓰인다.
2.8 타입 단언은 무엇이며 왜 조심해서 써야 하는가
타입 단언은 개발자가 타입스크립트에게 이 값의 타입은 내가 아는 그것이라고 알려 주는 것이다. as 키워드로 쓴다. 타입스크립트가 추론한 타입이 실제와 다르다고 개발자가 확신할 때, 예를 들어 화면 요소를 특정 종류로 확정해 다룰 때 쓴다. 타입스크립트는 단언한 대로 믿고 그 뒤로 검사를 진행한다.
문제는 단언이 실제 값을 바꾸는 것이 아니라 검사만 우회한다는 점이다. 개발자의 판단이 틀리면 타입스크립트는 그것을 잡지 못하고, 실행 중에 오류가 난다. 곧 단언은 타입 안전성을 개발자가 책임지겠다는 약속이다. 그래서 꼭 필요한 곳에만 최소로 쓰고, 가능하면 타입 좁히기로 타입스크립트가 스스로 판단하게 하는 편이 안전하다. 아무 타입에나 단언하는 것은 막혀 있어, 무리한 단언은 경고로 드러나기도 한다.
- 타입 단언은 값의 타입을 개발자가 직접 알려 주는 것이다.
- 실제 값을 바꾸지 않고 검사만 우회하므로 판단이 틀리면 실행 오류가 난다.
- 꼭 필요한 곳에만 쓰고 가능하면 타입 좁히기로 대신한다.
2.9 enum과 리터럴 유니온 중 무엇을 쓰는 것이 좋은가
enum은 이름이 있는 상수들의 묶음을 정의하는 문법이다. 상태나 종류처럼 정해진 값 몇 가지를 이름으로 표현할 때 쓴다. 다만 enum은 빌드 후에도 실제 코드로 남아 결과물에 포함되고, 숫자 enum은 동작이 직관과 어긋나는 경우가 있어 주의가 필요하다.
리터럴 유니온은 허용할 값들을 문자열 그대로 나열해 유니온으로 묶는 방법이다. 빌드 후 추가 코드를 남기지 않고, 값이 곧 그 문자열이라 다루기 직관적이다. 자동 완성과 타입 검사도 잘 동작한다. 그래서 정해진 몇 가지 값을 표현할 때는 리터럴 유니온을 선호하는 경우가 많다. 다만 값들을 한곳에 모아 관리하거나 값을 순회해야 한다면 enum이나 상수 객체가 편할 수 있다. 상황에 맞게 고른다.
다음은 리터럴 유니온으로 상태를 표현한 예제입니다.
// 리터럴 유니온 - 빌드 후 추가 코드를 남기지 않는다 type OrderStatus = 'pending' | 'paid' | 'shipped' | 'done'; function updateStatus(status: OrderStatus) { console.log('상태 변경', status); } updateStatus('paid'); // 가능 // updateStatus('unknown'); // 에러 - 허용된 값이 아님
- enum은 이름 있는 상수 묶음이지만 빌드 후 코드로 남는다.
- 리터럴 유니온은 추가 코드 없이 정해진 값을 직관적으로 표현한다.
- 정해진 몇 가지 값에는 리터럴 유니온을 선호하는 경우가 많다.
2.10 타입 추론은 무엇이며 타입을 어디까지 명시해야 하는가
타입 추론은 개발자가 타입을 적지 않아도 타입스크립트가 값을 보고 타입을 알아내는 것이다. 변수에 숫자를 넣으면 그 변수는 숫자 타입으로 추론되고, 함수가 무엇을 반환하는지 보면 반환 타입도 추론된다. 그래서 모든 곳에 타입을 일일이 적지 않아도 타입 검사가 동작한다. 추론을 잘 활용하면 코드가 깔끔하면서도 안전하다.
모든 타입을 손으로 적으면 오히려 코드가 장황하고, 추론으로 충분한 곳까지 적는 것은 군더더기다. 반대로 추론에만 맡기면 의도가 흐려지는 곳도 있다. 보통 함수의 매개변수와 공개되는 함수의 반환 타입은 명시하는 편이 좋다. 그래야 함수의 사용법이 분명해지고, 안에서 실수로 다른 타입을 반환하면 잡힌다. 반면 지역 변수처럼 값에서 타입이 뻔히 보이는 곳은 추론에 맡긴다. 곧 경계가 되는 지점은 명시하고 내부는 추론에 맡기는 균형이 좋다.
- 타입 추론은 값을 보고 타입스크립트가 타입을 알아내는 것이다.
- 함수 매개변수와 공개 함수의 반환 타입은 명시하는 편이 좋다.
- 값에서 타입이 뻔한 지역 변수는 추론에 맡겨 군더더기를 줄인다.
3. CSS와 TailwindCSS
3.1 박스 모델은 무엇이며 box-sizing은 어떤 역할을 하는가
모든 화면 요소는 사각형 상자로 그려지고, 이 상자는 안쪽부터 내용, 안쪽 여백, 테두리, 바깥 여백의 네 겹으로 이루어진다. 내용은 글자나 이미지가 들어가는 영역이고, 안쪽 여백은 내용과 테두리 사이의 공간이며, 테두리는 상자의 경계선이고, 바깥 여백은 다른 요소와의 거리다. 이 네 겹이 합쳐져 요소가 화면에서 차지하는 크기가 정해진다. 이 구조를 박스 모델이라 한다.
box-sizing은 우리가 정한 너비와 높이가 어디까지를 포함하는지 정한다. 기본값에서는 너비가 내용 영역만 가리켜서, 안쪽 여백과 테두리가 더해지면 실제 크기가 우리가 적은 값보다 커진다. 그래서 크기를 계산하기 번거롭다. box-sizing을 border-box로 바꾸면 너비가 테두리까지 포함하게 되어, 적은 값이 곧 실제 차지하는 크기가 된다. 그래서 많은 프로젝트가 모든 요소에 border-box를 기본으로 적용한다. 테일윈드도 이 설정을 기본으로 깔아 둔다.
- 박스 모델은 내용, 안쪽 여백, 테두리, 바깥 여백의 네 겹으로 이루어진다.
- 기본값에서는 너비가 내용만 가리켜 여백과 테두리가 더해지면 크기가 커진다.
- border-box로 바꾸면 적은 너비가 곧 실제 차지하는 크기가 된다.
3.2 position의 static, relative, absolute, fixed, sticky는 어떻게 다른가
position은 요소를 어떤 기준으로 배치할지 정한다. static은 기본값으로, 요소가 문서의 흐름에 따라 차례대로 놓인다. relative는 원래 있던 자리를 기준으로 위치를 조금 옮길 수 있게 한다. 옮겨도 원래 차지하던 공간은 그대로 남는다. relative는 그 자체로도 쓰지만, 자식의 absolute 기준점 역할로도 자주 쓴다.
absolute는 문서의 흐름에서 빠져나와, 위치 기준이 되는 가장 가까운 조상을 기준으로 배치된다. 기준이 될 조상이 없으면 화면 전체를 기준으로 삼는다. fixed는 화면 자체를 기준으로 고정되어, 스크롤해도 같은 자리에 머문다. 상단 고정 메뉴나 떠 있는 버튼에 쓴다. sticky는 평소에는 흐름을 따라 움직이다가, 정해진 위치에 닿으면 그 자리에 붙어 고정된다. 스크롤하면 따라오는 제목 줄에 자주 쓴다.
- static은 기본 흐름, relative는 원래 자리를 기준으로 약간 이동한다.
- absolute는 흐름에서 빠져 가까운 기준 조상에 맞춰 배치된다.
- fixed는 화면에 고정되고 sticky는 닿는 순간 붙어 고정된다.
3.3 flexbox는 무엇이며 주요 속성은 어떻게 동작하는가
flexbox는 요소들을 한 줄이나 한 열로 나란히 배치하고 정렬하는 방법이다. 부모에 flex를 지정하면 그 자식들이 한 방향으로 늘어선다. 가로로 배치할지 세로로 배치할지는 방향 속성으로 정한다. 가로 메뉴, 카드 나열, 가운데 정렬처럼 한 방향 배치가 필요한 거의 모든 곳에 쓴다.
주된 방향의 정렬은 한 속성으로, 그에 수직인 방향의 정렬은 다른 속성으로 다룬다. 자식 사이를 띄우거나 양쪽 끝에 붙이거나 가운데로 모으는 일을 이 둘로 처리한다. 또 자식이 남는 공간을 나눠 갖거나 줄어들게 하는 정도를 지정해, 화면 너비에 따라 유연하게 늘고 줄게 만들 수 있다. 테일윈드에서는 flex, 방향, 정렬, 간격을 각각 짧은 클래스로 붙여 빠르게 구성한다.
다음은 flexbox로 양쪽 정렬하는 예제입니다.
.header { display: flex; justify-content: space-between; /* 주축 방향 - 양 끝으로 벌림 */ align-items: center; /* 교차축 방향 - 세로 가운데 */ gap: 16px; /* 자식 사이 간격 */ } /* 테일윈드로는 다음과 같이 표현한다 class="flex justify-between items-center gap-4" */
- flexbox는 자식들을 한 방향으로 나란히 배치하고 정렬한다.
- 주축 정렬과 교차축 정렬을 각각의 속성으로 다룬다.
- 자식이 공간을 나눠 갖게 해 화면 너비에 따라 유연하게 늘고 준다.
3.4 grid는 무엇이며 flexbox와 어떻게 다른가
grid는 요소를 행과 열로 이루어진 격자에 배치하는 방법이다. 부모에 격자의 열과 행을 정의해 두면 자식들이 그 칸에 들어간다. 가로와 세로를 동시에 다루므로, 행과 열이 함께 있는 이차원 배치에 강하다. 사진 갤러리, 대시보드, 복잡한 페이지 레이아웃처럼 가로세로 구조가 분명한 곳에 잘 맞는다.
flexbox는 한 방향 배치에 초점을 둔다. 가로 한 줄이나 세로 한 열을 정렬하는 데 강하다. grid는 가로와 세로를 한 번에 다룬다. 그래서 한 줄 안의 정렬은 flexbox가, 행과 열이 얽힌 전체 틀은 grid가 편하다. 둘은 경쟁이 아니라 역할이 다르며, 큰 틀은 grid로 잡고 그 안의 한 줄 정렬은 flexbox로 하는 식으로 함께 쓴다.
다음은 grid로 반응형 카드 목록을 만드는 예제입니다.
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 20px; } /* 칸 너비가 240px 아래로 좁아지면 열 수가 자동으로 줄어든다 테일윈드로는 grid grid-cols-... 와 gap 클래스를 쓴다 */
- grid는 행과 열로 이루어진 격자에 요소를 배치한다.
- flexbox는 한 방향, grid는 가로와 세로를 동시에 다룬다.
- 큰 틀은 grid로 잡고 그 안의 한 줄 정렬은 flexbox로 함께 쓴다.
3.5 반응형 디자인은 어떻게 구현하며 미디어 쿼리는 무엇인가
반응형 디자인은 화면 크기에 따라 배치가 달라지도록 만드는 것이다. 같은 페이지가 넓은 데스크톱에서는 여러 열로, 좁은 휴대폰에서는 한 열로 보이게 한다. 핵심 도구가 미디어 쿼리다. 미디어 쿼리는 화면 너비 같은 조건을 정해, 그 조건을 만족할 때만 적용되는 스타일을 묶는다. 너비가 일정 값 이상일 때만 적용되는 규칙을 두는 식이다.
흔히 좁은 화면을 기준으로 기본 스타일을 짜고, 화면이 넓어질 때 미디어 쿼리로 배치를 바꾸는 방식을 쓴다. 이렇게 작은 화면부터 시작하는 접근이 관리하기 쉽다. 테일윈드는 이 미디어 쿼리를 클래스 앞에 붙이는 접두사로 간단히 표현한다. 기본 클래스는 모든 크기에 적용되고, 특정 크기 접두사를 붙인 클래스는 그 크기 이상에서만 적용된다. 그래서 한 요소에 화면별 스타일을 나란히 적을 수 있다.
다음은 테일윈드로 반응형 배치를 만드는 예제입니다.
<!-- 기본은 한 열, md 이상은 두 열, lg 이상은 세 열 --> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div>카드 1</div> <div>카드 2</div> <div>카드 3</div> </div> <!-- md, lg 같은 접두사가 미디어 쿼리 역할을 한다 -->
- 반응형 디자인은 화면 크기에 따라 배치를 다르게 한다.
- 미디어 쿼리는 화면 조건을 정해 그때만 적용되는 스타일을 묶는다.
- 테일윈드는 클래스 앞 접두사로 화면별 스타일을 나란히 적는다.
3.6 CSS 명시도는 무엇이며 어떤 규칙으로 우선순위가 정해지는가
같은 요소에 여러 스타일 규칙이 겹치면, 그중 어느 것이 이길지 정하는 기준이 명시도다. 선택자가 얼마나 구체적으로 요소를 짚었는지에 따라 점수가 매겨진다. 아이디로 지정한 규칙이 클래스로 지정한 규칙보다 강하고, 클래스가 태그 이름으로 지정한 규칙보다 강하다. 더 구체적으로 짚을수록 우선순위가 높다.
명시도가 같으면 나중에 작성된 규칙이 이긴다. 그래서 스타일이 의도대로 적용되지 않을 때는 더 강한 선택자가 덮어쓰고 있는지 살핀다. 명시도 싸움이 심해지면 스타일을 강제로 이기게 만드는 표시까지 쓰게 되는데, 이는 나중에 더 큰 혼란을 부른다. 테일윈드는 대부분 같은 수준의 클래스만 써서 명시도를 비슷하게 유지하고, 적용 순서로 결과를 다룬다. 그래서 명시도 충돌로 인한 혼란이 적은 편이다.
- 명시도는 겹친 규칙 중 어느 것이 이길지 정하는 구체성 점수다.
- 아이디가 클래스보다, 클래스가 태그 이름보다 강하다.
- 명시도가 같으면 나중에 작성된 규칙이 이긴다.
3.7 px, rem, em, % 단위는 각각 어떻게 다르며 언제 쓰는가
px는 화면의 고정된 크기 단위다. 항상 같은 크기라 예측하기 쉽지만, 사용자가 브라우저 기본 글자 크기를 키워도 따라 커지지 않는다. rem은 문서의 기준 글자 크기를 배수로 따른다. 사용자가 기본 글자 크기를 키우면 rem으로 정한 값들이 함께 커지므로, 접근성 측면에서 글자나 여백에 rem을 쓰면 사용자의 설정을 존중하게 된다.
em은 자신이 속한 요소의 글자 크기를 기준으로 한다. 그래서 부모의 글자 크기가 바뀌면 함께 바뀌고, 중첩되면 누적되어 계산이 헷갈릴 수 있다. 퍼센트는 부모의 크기를 기준으로 한 비율이다. 너비를 부모의 절반으로 두는 식의 유연한 배치에 쓴다. 보통 글자와 여백은 rem으로 통일해 일관성과 접근성을 챙기고, 너비처럼 부모에 상대적인 값은 퍼센트나 유연한 단위를 쓴다. 테일윈드의 간격 클래스도 내부적으로 rem을 바탕으로 한다.
- px는 고정 크기, rem은 문서 기준 글자 크기의 배수다.
- em은 자신이 속한 요소의 글자 크기를 따라 중첩 시 누적된다.
- 글자와 여백은 rem으로 통일하면 일관성과 접근성을 챙긴다.
3.8 테일윈드는 어떻게 동작하며 일반 CSS와 무엇이 다른가
테일윈드는 미리 만들어 둔 작은 단위의 클래스를 조합해 화면을 꾸미는 방식이다. 여백을 주는 클래스, 글자 색을 정하는 클래스, 배치를 잡는 클래스를 요소에 직접 붙여 스타일을 완성한다. 별도의 CSS 파일에 선택자를 만들고 규칙을 쓰는 대신, 마크업 안에서 필요한 클래스를 나열한다. 그래서 화면을 만들면서 파일을 오가지 않고 빠르게 작업할 수 있다.
테일윈드는 빌드할 때 코드에서 실제로 쓰인 클래스만 찾아내 그만큼의 CSS만 만든다. 그래서 사용할 수 있는 클래스가 아무리 많아도 최종 결과물은 작게 유지된다. 최신 버전에서는 설정을 자바스크립트 설정 파일 없이 CSS 안에서 직접 하는 방식으로 바뀌어, 색이나 간격 같은 디자인 값을 CSS의 테마 지정으로 정의한다. 또 빌드 속도가 크게 빨라졌다. 일반 CSS가 선택자 중심이라면 테일윈드는 요소에 직접 붙이는 클래스 중심이라는 점이 가장 큰 차이다.
- 테일윈드는 작은 단위 클래스를 요소에 직접 붙여 스타일을 완성한다.
- 빌드할 때 실제 쓰인 클래스만 골라 만들어 결과물이 작다.
- 최신 버전은 설정을 CSS 안에서 직접 하고 빌드 속도가 빨라졌다.
3.9 테일윈드의 유틸리티 방식은 어떤 장단점이 있는가
장점은 빠른 작업 속도와 일관성이다. 클래스를 마크업에 바로 붙이므로 파일을 오가지 않고, 클래스 이름을 새로 짓는 고민도 없다. 미리 정해진 간격과 색 단계 안에서 고르므로 디자인이 들쭉날쭉해지지 않고 통일감이 생긴다. 빌드 후 결과물도 작아 성능에 유리하다. 또 쓰지 않는 스타일이 어디선가 남아 충돌하는 문제도 줄어든다.
단점은 마크업에 클래스가 길게 늘어서 한눈에 읽기 어려워질 수 있다는 점이다. 같은 묶음이 여러 곳에 반복되면 관리가 번거롭기도 하다. 이 문제는 반복되는 부분을 컴포넌트로 묶어 한곳에서 관리하면 크게 줄어든다. 리액트에서는 자연스럽게 컴포넌트 단위로 묶으므로 이 단점이 덜하다. 또 처음에는 클래스 이름을 익혀야 하는 부담이 있다. 정리하면 작은 단위로 빠르게 만드는 대신 마크업이 길어지는 맞바꿈이 있고, 컴포넌트로 묶어 보완한다.
- 장점은 빠른 작업, 디자인 일관성, 작은 결과물이다.
- 단점은 마크업에 클래스가 길게 늘어서 읽기 어려워질 수 있다는 점이다.
- 반복을 컴포넌트로 묶으면 단점이 크게 줄어든다.
3.10 가상 클래스와 가상 요소는 무엇이며 어떻게 다른가
가상 클래스는 요소가 특정 상태일 때만 적용되는 스타일을 정의한다. 마우스를 올렸을 때, 입력란에 초점이 갔을 때, 목록의 첫 번째일 때처럼 상태나 위치에 따라 달라지는 모습을 표현한다. 콜론 하나로 표시한다. 버튼에 마우스를 올리면 색이 바뀌는 효과가 대표적이다. 테일윈드에서는 이런 상태를 클래스 앞 접두사로 붙여 표현한다.
가상 요소는 실제 마크업에 없는 가상의 부분을 만들어 꾸민다. 요소의 내용 앞이나 뒤에 장식용 내용을 넣거나, 첫 글자나 첫 줄만 다르게 꾸미는 데 쓴다. 콜론 두 개로 표시한다. 곧 가상 클래스는 기존 요소의 특정 상태를, 가상 요소는 존재하지 않던 새 부분을 다룬다. 아이콘을 내용 앞에 붙이거나 말풍선의 꼬리를 그리는 장식에 가상 요소를 자주 쓴다.
다음은 가상 클래스와 가상 요소를 사용하는 예제입니다.
/* 가상 클래스 - 마우스를 올린 상태 */ .button:hover { background-color: #2563eb; } /* 가상 요소 - 내용 앞에 가상의 부분을 만들어 장식 */ .label::before { content: '★ '; color: gold; } /* 테일윈드로는 hover:bg-blue-600 처럼 상태를 접두사로 붙인다 */
- 가상 클래스는 요소의 특정 상태나 위치에 따라 스타일을 적용한다.
- 가상 요소는 마크업에 없는 가상의 부분을 만들어 꾸민다.
- 가상 클래스는 콜론 하나, 가상 요소는 콜론 두 개로 표시한다.
4. React 기초
4.1 가상 DOM은 무엇이며 왜 쓰는가
화면을 이루는 실제 구조를 DOM이라 한다. 화면을 바꾸려면 이 DOM을 직접 고쳐야 하는데, DOM 조작은 비용이 큰 작업이라 자주 일어나면 화면이 느려진다. 특히 데이터가 바뀔 때마다 화면 전체를 다시 그리면 낭비가 심하다. 리액트는 이 문제를 가상 DOM으로 해결한다. 가상 DOM은 실제 화면 구조를 본뜬 가벼운 사본을 메모리에 두는 것이다.
데이터가 바뀌면 리액트는 먼저 가상 DOM에서 새 모습을 만들고, 바뀌기 전의 가상 DOM과 비교해 무엇이 달라졌는지 찾는다. 그리고 실제로 달라진 부분만 골라 진짜 화면에 반영한다. 전체를 다시 그리지 않고 바뀐 곳만 고치므로 비용을 줄인다. 개발자는 화면이 어떻게 보여야 하는지만 선언하면 되고, 무엇을 어떻게 바꿀지는 리액트가 알아서 처리한다. 이 덕분에 직접 DOM을 일일이 조작하던 방식보다 코드가 단순해진다.
- DOM 직접 조작은 비용이 커서 자주 일어나면 화면이 느려진다.
- 가상 DOM은 화면 구조의 가벼운 사본을 메모리에 두고 변화를 비교한다.
- 실제로 달라진 부분만 골라 화면에 반영해 비용을 줄인다.
4.2 JSX는 무엇이며 어떻게 동작하는가
JSX는 자바스크립트 안에서 화면 구조를 HTML과 비슷한 문법으로 적게 해 주는 표현이다. 자바스크립트 코드와 화면 구조를 한곳에 자연스럽게 섞어 쓸 수 있어, 컴포넌트가 무엇을 그리는지 한눈에 보인다. 중괄호 안에 자바스크립트 값을 넣어 데이터를 화면에 끼워 넣고, 조건이나 반복도 자바스크립트 그대로 활용한다.
JSX는 브라우저가 바로 이해하는 문법이 아니다. 빌드 과정에서 자바스크립트 함수 호출로 변환된다. 우리가 적은 화면 구조가 컴포넌트와 속성, 자식을 인자로 받는 함수 호출로 바뀌고, 이 호출이 실행되면 가상 DOM을 이루는 객체가 만들어진다. 곧 JSX는 보기 좋게 쓰기 위한 겉모습이고, 실제로는 자바스크립트 함수 호출이다. 그래서 JSX 안에서 자바스크립트의 모든 표현을 쓸 수 있다.
다음은 JSX와 그 안에서 값을 끼워 넣는 예제입니다.
function Greeting({ name, isVip }: { name: string; isVip: boolean }) { return ( <div className="greeting"> <h1>안녕하세요, {name}님</h1> {isVip && <span>VIP 회원입니다</span>} </div> ); } // 중괄호 안에 자바스크립트 값을 넣고, && 로 조건부 렌더링을 한다
- JSX는 자바스크립트 안에서 화면 구조를 HTML처럼 적는 표현이다.
- 중괄호로 자바스크립트 값을 화면에 끼워 넣는다.
- 빌드 때 함수 호출로 변환되며 실행되면 가상 DOM 객체가 된다.
4.3 컴포넌트와 props는 무엇이며 어떻게 쓰는가
컴포넌트는 화면을 이루는 독립된 조각이다. 버튼, 카드, 입력란처럼 화면의 한 부분을 함수로 만들어 두고, 필요한 곳에서 가져다 조합한다. 같은 컴포넌트를 여러 곳에서 재사용할 수 있어 중복이 줄고, 화면을 작은 단위로 나눠 관리하기 쉬워진다. 리액트에서는 보통 함수로 컴포넌트를 만들고, 그 함수가 화면 구조를 반환한다.
props는 부모 컴포넌트가 자식 컴포넌트에 넘겨주는 데이터다. 함수에 인자를 넘기듯이, 컴포넌트에 속성을 적어 값을 전달한다. 같은 컴포넌트라도 받은 props에 따라 다른 내용을 그린다. 카드 컴포넌트에 제목과 내용을 props로 넘기면, 같은 카드 모양에 다른 내용이 채워진다. 중요한 점은 props는 받는 쪽에서 바꾸면 안 되는 읽기 전용 값이라는 것이다. 자식은 받은 props를 그대로 쓰고, 값을 바꿔야 하면 부모에게 알려 부모가 바꾸게 한다.
다음은 props를 받아 재사용하는 컴포넌트 예제입니다.
type CardProps = { title: string; description: string; }; function Card({ title, description }: CardProps) { return ( <div className="rounded-lg border p-4"> <h2 className="font-bold">{title}</h2> <p className="text-gray-600">{description}</p> </div> ); } // 같은 컴포넌트를 다른 props로 재사용한다 function App() { return ( <div className="grid gap-4"> <Card title="첫 번째" description="설명 1" /> <Card title="두 번째" description="설명 2" /> </div> ); }
- 컴포넌트는 화면을 이루는 독립된 조각으로 재사용할 수 있다.
- props는 부모가 자식에게 넘기는 데이터로 같은 컴포넌트를 다르게 그린다.
- props는 읽기 전용이라 자식이 바꾸지 않고 부모가 바꾼다.
4.4 state는 무엇이며 props와 어떻게 다른가
state는 컴포넌트가 스스로 관리하는, 시간에 따라 바뀌는 데이터다. 입력란에 입력한 값, 열려 있는지 닫혀 있는지, 현재 선택된 항목처럼 사용자의 조작이나 시간에 따라 변하는 것을 담는다. state가 바뀌면 리액트는 그 컴포넌트를 다시 그려 화면에 변화를 반영한다. 그래서 state는 화면을 움직이게 하는 원천이다.
props가 부모에게서 받아 바꿀 수 없는 값이라면, state는 컴포넌트가 자기 안에서 직접 관리하고 바꿀 수 있는 값이다. 한 컴포넌트의 state를 자식에게 props로 내려 주는 일은 흔하다. 그러면 부모의 state가 바뀔 때 자식도 새 값을 받아 다시 그려진다. state를 바꿀 때는 값을 직접 고치지 않고 리액트가 준 변경 함수를 써야 한다. 그래야 리액트가 변화를 알아채고 화면을 다시 그린다.
- state는 컴포넌트가 스스로 관리하는 시간에 따라 바뀌는 데이터다.
- state가 바뀌면 리액트가 컴포넌트를 다시 그린다.
- props는 받아서 못 바꾸고 state는 컴포넌트가 직접 바꾼다.
4.5 리액트의 단방향 데이터 흐름은 무엇이며 왜 그렇게 설계됐는가
리액트에서 데이터는 부모에서 자식으로 한 방향으로만 흐른다. 부모가 자식에게 props로 데이터를 내려 주고, 자식은 받은 데이터를 화면에 그린다. 자식이 부모의 데이터를 직접 바꾸지는 못한다. 데이터가 위에서 아래로만 흐르므로, 어떤 값이 어디서 와서 어디로 가는지 따라가기 쉽다.
이렇게 흐름을 한 방향으로 묶으면 데이터가 바뀌는 지점이 분명해진다. 화면에 이상이 생겼을 때 그 데이터를 가진 부모만 살피면 되므로 원인을 찾기 쉽다. 만약 여기저기서 서로의 값을 자유롭게 바꿀 수 있다면 어디서 바뀌었는지 추적하기 어려워진다. 자식이 값을 바꿔야 할 때는 부모가 변경 함수를 props로 내려 주고, 자식은 그 함수를 호출해 부모에게 변경을 요청한다. 데이터는 내려가고 변경 요청은 올라가는 구조다.
- 데이터는 부모에서 자식으로 한 방향으로만 흐른다.
- 흐름이 한 방향이라 값의 출처와 변경 지점을 추적하기 쉽다.
- 자식은 부모가 내려 준 함수를 호출해 변경을 요청한다.
4.6 리스트를 렌더링할 때 key는 왜 필요하며 인덱스를 key로 쓰면 안 되는 이유는 무엇인가
리액트는 목록을 다시 그릴 때 이전 목록과 새 목록을 비교해 무엇이 바뀌었는지 알아낸다. 이때 각 항목을 구별할 표시가 필요한데 그것이 key다. key가 있으면 리액트는 같은 key를 가진 항목을 같은 것으로 보고, 추가되거나 사라지거나 자리가 바뀐 항목을 정확히 가려낸다. 그래서 꼭 필요한 변화만 화면에 반영한다.
항목의 순서가 바뀌거나 중간에 추가와 삭제가 일어나는 목록에서 배열의 인덱스를 key로 쓰면 문제가 생긴다. 항목이 추가되거나 삭제되면 인덱스가 밀려서, 리액트가 다른 항목을 같은 것으로 착각한다. 그러면 입력 중이던 값이 엉뚱한 항목에 남거나 화면이 잘못 그려진다. 그래서 key에는 각 항목을 고유하게 가리키는 식별자를 쓰는 것이 안전하다. 데이터에 있는 고유한 아이디 같은 값을 쓴다.
다음은 고유 식별자를 key로 사용하는 예제입니다.
type Todo = { id: number; text: string }; function TodoList({ todos }: { todos: Todo[] }) { return ( <ul> {todos.map((todo) => ( <li key={todo.id}>{todo.text}</li> ))} </ul> ); } // key에 인덱스 대신 고유한 todo.id를 써서 항목을 정확히 구별한다
- key는 리액트가 목록 항목을 구별해 바뀐 것만 반영하게 한다.
- 인덱스를 key로 쓰면 추가나 삭제 시 항목이 밀려 착각이 생긴다.
- 각 항목을 고유하게 가리키는 식별자를 key로 쓴다.
4.7 제어 컴포넌트와 비제어 컴포넌트는 어떻게 다른가
입력 요소를 다루는 방식에 두 가지가 있다. 제어 컴포넌트는 입력값을 리액트의 state로 관리한다. 입력란의 값을 state에 두고, 사용자가 입력할 때마다 state를 갱신해 화면에 반영한다. 입력값이 항상 state와 일치하므로, 입력 도중에 값을 검사하거나 다른 곳에 곧바로 반영하기 쉽다. 리액트가 권장하는 기본 방식이다.
비제어 컴포넌트는 입력값을 state로 관리하지 않고, 필요할 때 입력 요소에서 직접 값을 읽어 온다. 입력하는 동안 리액트가 관여하지 않으므로 다시 그리는 일이 적다. 간단한 폼이나 외부 라이브러리와 연결할 때 쓰기도 한다. 정리하면 제어 컴포넌트는 값을 항상 state로 통제해 다루기 유연하고, 비제어 컴포넌트는 필요할 때만 값을 읽어 가볍다. 입력 도중 검증이나 즉시 반영이 필요하면 제어 방식이 적합하다.
다음은 제어 컴포넌트로 입력을 다루는 예제입니다.
import { useState } from 'react'; function NameForm() { const [name, setName] = useState(''); return ( <div> <input value={name} onChange={(e) => setName(e.target.value)} /> <p>입력한 이름 {name}</p> </div> ); } // 입력값을 state로 관리하므로 값이 항상 state와 일치한다
- 제어 컴포넌트는 입력값을 state로 관리해 항상 일치시킨다.
- 비제어 컴포넌트는 필요할 때 입력 요소에서 직접 값을 읽는다.
- 입력 도중 검증이나 즉시 반영이 필요하면 제어 방식이 적합하다.
4.8 조건부 렌더링은 어떤 방법으로 하는가
조건부 렌더링은 상황에 따라 화면에 다른 내용을 그리거나 일부를 보이고 숨기는 것이다. 로그인 여부에 따라 다른 버튼을 보여 주거나, 데이터를 불러오는 동안 로딩 표시를 보여 주는 식이다. 리액트는 화면을 자바스크립트로 다루므로, 자바스크립트의 조건 표현을 그대로 써서 조건부 렌더링을 한다.
흔히 쓰는 방법이 몇 가지 있다. 조건이 참일 때만 무언가를 보여 주려면 논리 연산자로 연결한다. 두 가지 중 하나를 보여 주려면 삼항 연산자를 쓴다. 더 복잡하면 컴포넌트 윗부분에서 조건에 따라 그릴 내용을 미리 변수에 담아 두고 그 변수를 그린다. 주의할 점은 논리 연산자로 연결할 때 앞의 값이 숫자 0이면 의도와 달리 0이 화면에 그려질 수 있다는 것이다. 그래서 조건을 참과 거짓으로 분명히 만들어 주는 편이 안전하다.
다음은 여러 조건부 렌더링 방법을 보여주는 예제입니다.
function Status({ isLoading, user }: { isLoading: boolean; user: string | null }) { if (isLoading) { return <p>불러오는 중</p>; // 이른 반환으로 분기 } return ( <div> {user ? <p>{user}님 환영합니다</p> : <p>로그인이 필요합니다</p>} {user && <button>로그아웃</button>} </div> ); }
- 조건부 렌더링은 상황에 따라 다른 내용을 그리거나 숨기는 것이다.
- 논리 연산자, 삼항 연산자, 이른 반환을 상황에 맞게 쓴다.
- 논리 연산자 앞 값이 0이면 0이 그려질 수 있어 조건을 분명히 만든다.
4.9 리액트의 이벤트 처리는 일반 DOM 이벤트와 무엇이 다른가
리액트에서 이벤트는 요소에 직접 이벤트 처리기를 다는 대신, JSX 속성으로 함수를 넘겨 처리한다. 클릭이나 입력 같은 동작에 실행할 함수를 속성으로 연결한다. 겉보기에는 각 요소에 처리기를 단 것 같지만, 리액트는 내부적으로 이벤트를 한곳에 모아 처리하는 방식을 쓴다. 그래서 요소마다 따로 처리기를 거는 것보다 효율적이다.
리액트가 넘겨 주는 이벤트 객체는 브라우저마다 다른 이벤트의 차이를 감싸 일관되게 만든 것이다. 그래서 어느 브라우저에서나 같은 방식으로 이벤트를 다룰 수 있다. 기본 동작을 막거나 전파를 멈추는 일도 이 객체의 메서드로 한다. 처리기에 인자를 넘겨야 할 때는 함수를 새로 감싸 넘긴다. 다만 그렇게 매번 새 함수를 만드는 것이 잦은 다시 그리기와 만나면 성능에 영향을 줄 수 있어, 필요하면 함수를 기억해 두는 방법을 함께 쓴다.
- 리액트는 JSX 속성으로 함수를 넘겨 이벤트를 처리한다.
- 내부적으로 이벤트를 한곳에 모아 처리해 효율적이다.
- 이벤트 객체는 브라우저 차이를 감싸 일관되게 만든 것이다.
4.10 컴포넌트 합성은 무엇이며 children은 어떻게 활용하는가
컴포넌트 합성은 작은 컴포넌트를 조합해 큰 컴포넌트를 만드는 것이다. 리액트는 상속보다 합성을 권장한다. 공통된 틀을 만들어 두고 그 안에 다른 내용을 끼워 넣는 식으로 재사용한다. 이때 핵심 도구가 children이다. children은 컴포넌트의 여는 태그와 닫는 태그 사이에 넣은 내용을 가리키는 특별한 props다.
예를 들어 테두리와 그림자가 있는 카드 틀을 만들고, 그 안에 들어갈 내용은 children으로 받으면, 같은 카드 틀에 어떤 내용이든 담을 수 있다. 틀은 한 번만 만들고 내용은 쓰는 곳마다 자유롭게 바꾼다. 이렇게 하면 레이아웃이나 공통 모양을 재사용하면서 안의 내용은 유연하게 채울 수 있다. 여러 자리에 각각 다른 내용을 끼워야 한다면 children 외에 별도의 props로 컴포넌트를 받아 자리마다 끼우기도 한다.
다음은 children으로 카드 틀을 재사용하는 예제입니다.
function Card({ children }: { children: React.ReactNode }) { return <div className="rounded-lg border p-4 shadow">{children}</div>; } function App() { return ( <Card> <h2>제목</h2> <p>이 내용이 children으로 전달된다</p> </Card> ); }
- 합성은 작은 컴포넌트를 조합해 큰 컴포넌트를 만드는 방식이다.
- children은 태그 사이에 넣은 내용을 가리키는 특별한 props다.
- 공통 틀을 만들고 내용은 children으로 받아 유연하게 재사용한다.
4.11 StrictMode는 무엇이며 개발 모드에서 컴포넌트가 두 번 실행되는 이유는 무엇인가
StrictMode는 리액트가 잠재된 문제를 개발 단계에서 미리 찾도록 돕는 도구다. 애플리케이션을 StrictMode로 감싸면, 리액트는 개발 모드에서 의도적으로 일부 함수를 두 번 실행한다. 컴포넌트 함수와 일부 훅의 동작을 한 번 더 돌려 보는 것이다. 이는 실제 배포된 화면에는 영향을 주지 않고 개발 중에만 일어난다.
두 번 실행하는 이유는 순수하지 않은 코드를 드러내기 위해서다. 컴포넌트는 같은 입력에 같은 결과를 내야 하고, 바깥에 영향을 주는 작업은 정해진 자리에서만 해야 한다. 만약 컴포넌트가 실행되는 도중에 바깥 값을 바꾸는 등 규칙을 어기면, 두 번 실행했을 때 결과가 어긋나 문제가 눈에 띈다. 또 정리 작업이 제대로 되어 있는지도 확인할 수 있다. 그래서 개발 중에 콘솔 기록이 두 번 찍히는 것은 오류가 아니라 점검 동작이다.
- StrictMode는 잠재된 문제를 개발 단계에서 미리 찾도록 돕는다.
- 개발 모드에서 컴포넌트와 일부 훅을 의도적으로 두 번 실행한다.
- 두 번 실행은 순수하지 않은 코드와 정리 누락을 드러내는 점검이다.
5. React Hooks
5.1 useState는 어떻게 동작하며 상태 업데이트가 바로 반영되지 않는 이유는 무엇인가
useState는 컴포넌트에 상태 값을 두고 그 값을 바꿀 함수를 함께 돌려준다. 현재 값과 변경 함수를 받아, 값을 화면에 그리고 변경 함수로 값을 바꾼다. 변경 함수를 호출하면 리액트는 컴포넌트를 다시 실행해 새 값으로 화면을 그린다. 이 과정을 통해 사용자의 조작이 화면에 반영된다.
변경 함수를 호출해도 그 줄 바로 다음에서 상태 값을 읽으면 여전히 이전 값이 나온다. 상태가 즉시 바뀌는 것이 아니라, 다음번 다시 그릴 때 새 값으로 반영되기 때문이다. 한 번의 처리 안에서 상태 값은 고정되어 있다. 그래서 이전 값을 바탕으로 여러 번 바꿔야 할 때는 새 값을 직접 넣는 대신, 이전 값을 받아 계산하는 함수를 넘기는 방식을 쓴다. 그래야 여러 변경이 누적되어 제대로 반영된다.
다음은 함수형 업데이트로 안전하게 값을 누적하는 예제입니다.
import { useState } from 'react'; function Counter() { const [count, setCount] = useState(0); function addThree() { // 이전 값을 받아 계산하는 방식이라야 세 번이 모두 반영된다 setCount((prev) => prev + 1); setCount((prev) => prev + 1); setCount((prev) => prev + 1); } return <button onClick={addThree}>현재 {count}</button>; }
- useState는 현재 값과 변경 함수를 돌려주고 변경 시 다시 그린다.
- 상태는 즉시 바뀌지 않고 다음 렌더링에 새 값으로 반영된다.
- 이전 값을 바탕으로 여러 번 바꿀 때는 함수형 업데이트를 쓴다.
5.2 useEffect는 무엇이며 의존성 배열은 어떤 역할을 하는가
useEffect는 화면을 그린 뒤에 실행할 작업을 등록하는 훅이다. 데이터를 불러오거나, 외부와 연결을 맺거나, 화면 밖 시스템과 상호작용하는 일처럼 그리기 자체와 별개인 작업을 여기에 둔다. 컴포넌트가 화면에 나타나거나 특정 값이 바뀐 다음에 이 작업이 실행된다. 그리기 도중이 아니라 그리기가 끝난 뒤에 실행된다는 점이 중요하다.
의존성 배열은 이 작업을 언제 다시 실행할지 정한다. 배열에 넣은 값이 이전과 달라졌을 때만 작업을 다시 실행한다. 빈 배열을 넣으면 처음 나타날 때 한 번만 실행되고, 배열을 아예 생략하면 그릴 때마다 매번 실행된다. 그래서 배열에 작업이 의존하는 값을 빠짐없이 넣는 것이 중요하다. 필요한 값을 빠뜨리면 옛 값을 기억한 채 동작해 버그가 생기고, 불필요한 값을 넣으면 작업이 너무 자주 실행된다.
다음은 의존성 배열로 실행 시점을 정하는 예제입니다.
import { useEffect, useState } from 'react'; function UserProfile({ userId }: { userId: number }) { const [user, setUser] = useState<string | null>(null); useEffect(() => { fetch('/api/users/' + userId) .then((res) => res.json()) .then((data) => setUser(data.name)); }, [userId]); // userId가 바뀔 때마다 다시 불러온다 return <p>{user ?? '불러오는 중'}</p>; }
- useEffect는 화면을 그린 뒤 실행할 작업을 등록한다.
- 의존성 배열에 넣은 값이 바뀔 때만 작업이 다시 실행된다.
- 작업이 의존하는 값을 빠짐없이 배열에 넣어야 버그가 없다.
5.3 useEffect의 cleanup 함수는 무엇이며 왜 필요한가
useEffect 안에서 함수를 반환하면, 그 함수가 정리 작업으로 실행된다. 이를 cleanup이라 한다. 정리 작업은 컴포넌트가 화면에서 사라질 때, 그리고 의존성이 바뀌어 작업을 다시 실행하기 직전에 호출된다. 곧 이전에 벌여 놓은 것을 거두는 자리다. 등록한 작업이 무언가를 켜 두거나 연결을 맺었다면, 그것을 끄거나 끊는 일을 여기서 한다.
cleanup이 없으면 문제가 쌓인다. 예를 들어 타이머를 켜 두고 정리하지 않으면, 컴포넌트가 사라진 뒤에도 타이머가 계속 돌며 자원을 낭비하고, 이미 사라진 컴포넌트의 상태를 건드리려다 오류가 난다. 외부와의 연결을 맺고 끊지 않으면 연결이 쌓여 메모리가 샌다. 그래서 작업에서 켜거나 맺은 것이 있으면, 짝을 맞춰 cleanup에서 끄거나 끊어야 한다. 구독을 등록했으면 구독을 해제하고, 타이머를 켰으면 타이머를 끈다.
다음은 cleanup으로 타이머를 정리하는 예제입니다.
import { useEffect, useState } from 'react'; function Clock() { const [time, setTime] = useState(new Date()); useEffect(() => { const timerId = setInterval(() => setTime(new Date()), 1000); return () => clearInterval(timerId); // 사라질 때 타이머를 끈다 }, []); return <p>{time.toLocaleTimeString()}</p>; }
- useEffect가 반환한 함수가 정리 작업으로 실행된다.
- 컴포넌트가 사라질 때와 작업을 다시 실행하기 직전에 호출된다.
- 켜거나 맺은 것을 짝을 맞춰 끄거나 끊어 자원 낭비를 막는다.
5.4 useRef는 어떤 용도로 쓰는가
useRef는 두 가지 용도로 쓴다. 첫째는 화면의 실제 요소에 직접 접근하는 것이다. 입력란에 초점을 주거나, 요소의 크기를 재거나, 외부 라이브러리에 요소를 넘겨야 할 때, ref를 요소에 연결하면 그 실제 요소를 가리킨다. 리액트가 그린 진짜 화면 요소를 직접 다뤄야 하는 드문 경우에 쓴다.
둘째는 다시 그려도 유지되어야 하지만 화면과는 상관없는 값을 담는 것이다. ref에 담은 값은 바뀌어도 컴포넌트를 다시 그리지 않는다. 이 점이 state와 다르다. state는 바뀌면 화면을 다시 그리지만, ref는 조용히 값만 바뀐다. 그래서 타이머의 식별자나 이전 값처럼 화면에 보일 필요는 없지만 보관해야 하는 값을 담기에 좋다. 화면에 반영되어야 하는 값은 state로, 그렇지 않은 값은 ref로 다룬다.
다음은 useRef로 입력란에 초점을 주는 예제입니다.
import { useRef } from 'react'; function SearchBox() { const inputRef = useRef<HTMLInputElement>(null); function focusInput() { inputRef.current?.focus(); // 실제 입력 요소에 초점을 준다 } return ( <div> <input ref={inputRef} /> <button onClick={focusInput}>검색창으로 이동</button> </div> ); }
- useRef는 화면의 실제 요소에 직접 접근할 때 쓴다.
- 다시 그려도 유지되지만 화면과 무관한 값을 담을 때도 쓴다.
- ref 값은 바뀌어도 다시 그리지 않아 state와 구분된다.
5.5 useMemo는 무엇이며 언제 쓰는가
useMemo는 계산 결과를 기억해 두었다가, 필요 없으면 다시 계산하지 않게 하는 훅이다. 컴포넌트는 다시 그릴 때마다 안의 코드를 처음부터 다시 실행한다. 그 안에 비용이 큰 계산이 있으면 매번 반복되어 느려질 수 있다. useMemo로 그 계산을 감싸고 의존하는 값을 지정하면, 그 값이 바뀌지 않는 한 이전에 계산한 결과를 그대로 돌려준다.
주의할 점은 useMemo 자체도 약간의 비용이 있다는 것이다. 그래서 모든 계산을 감싸는 것은 오히려 손해다. 정말 무거운 계산이거나, 계산 결과가 다른 곳의 불필요한 다시 그리기를 막는 데 쓰일 때 의미가 있다. 가벼운 계산까지 감싸면 관리만 복잡해지고 이득이 없다. 먼저 단순하게 짜고, 실제로 느린 곳을 확인한 다음에 적용하는 편이 좋다.
다음은 무거운 계산을 useMemo로 기억하는 예제입니다.
import { useMemo, useState } from 'react'; function ProductList({ products }: { products: { name: string; price: number }[] }) { const [keyword, setKeyword] = useState(''); // products나 keyword가 바뀔 때만 다시 거른다 const filtered = useMemo( () => products.filter((p) => p.name.includes(keyword)), [products, keyword] ); return ( <div> <input value={keyword} onChange={(e) => setKeyword(e.target.value)} /> <p>{filtered.length}개 검색됨</p> </div> ); }
- useMemo는 계산 결과를 기억해 불필요한 재계산을 막는다.
- 의존하는 값이 바뀌지 않으면 이전 결과를 그대로 돌려준다.
- 자체 비용이 있으므로 정말 무거운 계산에만 쓴다.
5.6 useCallback은 무엇이며 useMemo와 어떻게 다른가
useCallback은 함수를 기억해 두었다가, 의존하는 값이 바뀌지 않으면 같은 함수를 그대로 돌려주는 훅이다. 컴포넌트는 다시 그릴 때마다 안의 함수를 새로 만든다. 보통은 문제가 없지만, 그 함수를 자식 컴포넌트에 props로 넘기거나 useEffect의 의존성으로 쓸 때는 매번 새 함수가 되는 것이 문제가 된다. 새 함수로 보이면 자식이 불필요하게 다시 그려지거나 작업이 다시 실행된다.
useMemo가 계산한 값을 기억한다면, useCallback은 함수 자체를 기억한다. 둘은 사실상 비슷한 도구이고, useCallback은 함수를 다루는 useMemo의 특수한 형태로 볼 수 있다. useCallback도 자체 비용이 있으므로, 단순히 함수를 넘긴다고 늘 쓸 필요는 없다. 기억된 자식에게 함수를 넘기거나 의존성으로 쓰는 등, 함수의 신원이 유지되어야 이득이 있는 경우에 쓴다.
다음은 useCallback으로 함수를 기억하는 예제입니다.
import { useCallback, useState } from 'react'; function Parent() { const [count, setCount] = useState(0); // 의존성이 없으므로 항상 같은 함수가 유지된다 const handleClick = useCallback(() => { setCount((prev) => prev + 1); }, []); return <Child onClick={handleClick} />; } function Child({ onClick }: { onClick: () => void }) { return <button onClick={onClick}>클릭</button>; }
- useCallback은 함수를 기억해 같은 함수를 유지한다.
- useMemo는 값을, useCallback은 함수를 기억한다.
- 기억된 자식에게 넘기거나 의존성으로 쓸 때 이득이 있다.
5.7 커스텀 훅은 무엇이며 왜 만드는가
커스텀 훅은 여러 컴포넌트에서 반복되는 상태 관련 로직을 함수 하나로 묶어 빼낸 것이다. 이름을 use로 시작하고, 안에서 useState나 useEffect 같은 기본 훅을 쓴다. 예를 들어 데이터를 불러오는 로직, 입력 폼을 다루는 로직, 화면 크기를 추적하는 로직이 여러 컴포넌트에 흩어져 있다면, 이를 커스텀 훅으로 묶어 한곳에서 관리한다.
커스텀 훅을 쓰면 같은 로직을 여러 컴포넌트가 깔끔하게 나눠 쓸 수 있다. 컴포넌트는 화면을 그리는 데 집중하고, 복잡한 상태 처리는 커스텀 훅에 맡긴다. 중요한 점은 커스텀 훅이 상태 자체를 공유하는 것이 아니라 로직을 공유한다는 것이다. 같은 커스텀 훅을 두 컴포넌트가 써도 각자 독립된 상태를 가진다. 로직의 모양만 같고 값은 따로 관리된다.
다음은 입력값을 다루는 커스텀 훅 예제입니다.
import { useState } from 'react'; function useInput(initial: string) { const [value, setValue] = useState(initial); const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value); return { value, onChange }; } function LoginForm() { const email = useInput(''); const password = useInput(''); return ( <form> <input {...email} /> <input {...password} type="password" /> </form> ); }
- 커스텀 훅은 반복되는 상태 로직을 함수로 묶어 빼낸 것이다.
- 이름을 use로 시작하고 안에서 기본 훅을 쓴다.
- 로직을 공유하며 상태는 쓰는 컴포넌트마다 따로 관리된다.
5.8 훅을 쓸 때 지켜야 하는 규칙은 무엇이며 왜 그런가
훅에는 두 가지 규칙이 있다. 첫째, 훅은 컴포넌트나 커스텀 훅의 최상위에서만 호출해야 한다. 조건문이나 반복문, 중첩된 함수 안에서 호출하면 안 된다. 둘째, 훅은 리액트 컴포넌트나 다른 커스텀 훅 안에서만 호출해야 한다. 일반 함수에서는 쓰지 않는다. 이 규칙을 어기면 리액트가 경고를 띄우거나 오류가 난다.
이런 규칙이 있는 이유는 리액트가 훅을 호출되는 순서로 구분하기 때문이다. 리액트는 컴포넌트가 그려질 때마다 훅이 항상 같은 순서로 같은 개수만큼 호출된다고 믿고, 그 순서로 각 훅의 상태를 짝지어 기억한다. 만약 조건문 안에서 훅을 호출하면, 조건에 따라 호출 순서나 개수가 달라져 짝이 어긋난다. 그러면 한 상태가 엉뚱한 훅에 연결되어 동작이 망가진다. 그래서 훅은 항상 같은 순서로 호출되도록 최상위에 두어야 한다.
- 훅은 컴포넌트나 커스텀 훅의 최상위에서만 호출한다.
- 조건문이나 반복문, 일반 함수 안에서 호출하면 안 된다.
- 리액트가 훅을 호출 순서로 구분하므로 순서가 흐트러지면 망가진다.
5.9 useContext는 무엇이며 어떤 문제를 해결하는가
useContext는 여러 단계 아래에 있는 컴포넌트에 데이터를 직접 전달하는 방법이다. props는 부모에서 자식으로 한 단계씩만 내려가므로, 깊이 있는 컴포넌트에 값을 주려면 중간 단계들이 그 값을 쓰지도 않으면서 계속 전달해야 한다. 이렇게 중간을 거쳐 끌고 내려가는 번거로움을 흔히 props 내려보내기 문제라 한다. useContext는 이 중간 단계를 건너뛴다.
먼저 컨텍스트를 만들고, 데이터를 공급하는 부모로 하위 트리를 감싼다. 그러면 그 안의 어떤 깊이에 있는 컴포넌트든 useContext로 그 값을 바로 꺼내 쓸 수 있다. 로그인한 사용자 정보, 화면 테마, 언어 설정처럼 앱 전체에서 두루 쓰는 값에 잘 맞는다. 다만 컨텍스트의 값이 바뀌면 그 값을 쓰는 컴포넌트들이 다시 그려지므로, 자주 바뀌는 값을 큰 범위에 두면 성능에 영향을 줄 수 있어 주의한다.
다음은 useContext로 테마 값을 공유하는 예제입니다.
import { createContext, useContext } from 'react'; const ThemeContext = createContext('light'); function App() { return ( <ThemeContext.Provider value="dark"> <Toolbar /> </ThemeContext.Provider> ); } function Toolbar() { return <ThemedButton />; // 중간 단계는 값을 전달하지 않는다 } function ThemedButton() { const theme = useContext(ThemeContext); // 깊은 곳에서 바로 꺼낸다 return <button className={theme}>현재 테마 {theme}</button>; }
- useContext는 깊은 곳의 컴포넌트에 값을 직접 전달한다.
- 중간 단계를 거쳐 props를 끌고 내려가는 번거로움을 없앤다.
- 값이 바뀌면 쓰는 컴포넌트가 다시 그려지므로 자주 바뀌는 값은 주의한다.
5.10 useReducer는 무엇이며 useState와 어떻게 구분해 쓰는가
useReducer는 상태를 다루는 또 다른 방법으로, 상태를 바꾸는 규칙을 한곳에 모아 관리한다. 상태와, 어떤 동작이 일어났는지를 받아 새 상태를 돌려주는 함수를 둔다. 컴포넌트는 무슨 일이 일어났는지만 알리고, 실제로 상태를 어떻게 바꿀지는 그 함수가 정한다. 그래서 상태 변경 로직이 컴포넌트 여기저기 흩어지지 않고 한곳에 모인다.
단순한 값 하나를 다룰 때는 useState가 간단하고 좋다. 반면 상태가 여러 값으로 이루어져 서로 얽혀 있거나, 상태를 바꾸는 경우가 많고 복잡할 때는 useReducer가 깔끔하다. 변경 규칙이 한 함수에 모여 있어 흐름을 파악하기 쉽고 검증하기도 좋다. 예를 들어 여러 항목과 입력 상태, 필터가 함께 얽힌 복잡한 화면 상태를 다룰 때 useReducer가 유리하다. 곧 단순하면 useState, 복잡하고 얽혀 있으면 useReducer를 고른다.
다음은 useReducer로 카운터 상태를 다루는 예제입니다.
import { useReducer } from 'react'; type Action = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' }; function reducer(state: number, action: Action): number { switch (action.type) { case 'increment': return state + 1; case 'decrement': return state - 1; case 'reset': return 0; } } function Counter() { const [count, dispatch] = useReducer(reducer, 0); return ( <div> <span>{count}</span> <button onClick={() => dispatch({ type: 'increment' })}>+</button> <button onClick={() => dispatch({ type: 'reset' })}>리셋</button> </div> ); }
- useReducer는 상태 변경 규칙을 한 함수에 모아 관리한다.
- 컴포넌트는 일어난 동작만 알리고 함수가 새 상태를 정한다.
- 단순하면 useState, 상태가 복잡하고 얽혀 있으면 useReducer를 쓴다.
6. React 심화와 성능
6.1 리액트에서 컴포넌트가 다시 그려지는 원인은 무엇인가
컴포넌트는 몇 가지 경우에 다시 그려진다. 자신의 상태가 바뀌었을 때, 부모로부터 받는 props가 바뀌었을 때, 부모가 다시 그려질 때, 그리고 자신이 구독한 컨텍스트 값이 바뀌었을 때다. 이 가운데 자주 간과되는 것이 부모가 다시 그려지면 자식도 함께 다시 그려진다는 점이다. 자식의 props가 그대로여도 부모가 그려지면 기본적으로 자식도 다시 실행된다.
다시 그린다는 것은 컴포넌트 함수를 다시 실행해 새 화면 모습을 계산하는 것이지, 곧바로 실제 화면을 통째로 바꾸는 것은 아니다. 리액트는 새로 계산한 모습을 이전과 비교해 실제로 달라진 부분만 화면에 반영한다. 그래서 다시 그리기가 일어났다고 항상 화면이 크게 바뀌는 것은 아니다. 다만 다시 그리는 계산 자체가 잦고 무거우면 느려질 수 있어, 불필요한 다시 그리기를 줄이는 것이 성능 개선의 핵심이 된다.
- 상태 변경, props 변경, 부모의 다시 그리기, 컨텍스트 변경이 원인이다.
- 부모가 다시 그려지면 props가 그대로여도 자식이 함께 다시 그려진다.
- 다시 그리기는 함수 재실행이며 달라진 부분만 화면에 반영된다.
6.2 React.memo는 무엇이며 언제 효과가 있는가
React.memo는 컴포넌트를 감싸서, 받는 props가 바뀌지 않으면 다시 그리지 않게 하는 도구다. 평소에는 부모가 다시 그려지면 자식도 따라 그려지는데, memo로 감싼 자식은 props가 그대로면 이전 결과를 그대로 유지한다. 그래서 부모가 자주 그려지지만 자식의 props는 잘 바뀌지 않는 경우에 불필요한 다시 그리기를 막는다.
다만 memo는 props가 같은지 비교하는 비용이 들고, props에 매번 새로 만들어지는 함수나 객체가 들어가면 늘 다르다고 판단해 효과가 없다. 그래서 memo는 함수를 기억하는 useCallback이나 값을 기억하는 useMemo와 짝을 이뤄야 제대로 동작하는 경우가 많다. 모든 컴포넌트를 memo로 감싸는 것은 비교 비용만 늘려 손해다. 자주 그려지는 부모 밑의 무거운 자식처럼, 실제로 이득이 있는 곳에만 쓴다.
- React.memo는 props가 그대로면 컴포넌트를 다시 그리지 않게 한다.
- props에 매번 새 함수나 객체가 들어가면 효과가 없다.
- useCallback이나 useMemo와 짝을 이루고 이득이 있는 곳에만 쓴다.
6.3 상태 끌어올리기는 무엇이며 언제 필요한가
상태 끌어올리기는 여러 컴포넌트가 같은 상태를 함께 써야 할 때, 그 상태를 공통 부모로 옮기는 것이다. 두 형제 컴포넌트가 같은 값을 공유해야 하는데 각자 따로 상태를 가지면, 한쪽이 바꿔도 다른 쪽이 모른다. 그래서 그 상태를 둘의 공통 부모로 올리고, 부모가 그 값을 두 자식에게 props로 내려 준다. 자식이 값을 바꿔야 하면 부모가 내려 준 변경 함수를 호출한다.
이렇게 하면 하나의 상태가 진짜 출처가 되고, 두 자식은 모두 그 하나를 바라본다. 값이 일관되게 유지되고 누가 출처인지 분명해진다. 예를 들어 입력란과 그 입력을 반영하는 미리보기가 형제라면, 입력값을 부모로 올려 둘이 공유한다. 다만 너무 높이 올리면 관련 없는 컴포넌트까지 거치게 되므로, 그 상태를 함께 쓰는 가장 가까운 공통 부모까지만 올리는 것이 좋다.
- 상태 끌어올리기는 공유할 상태를 공통 부모로 옮기는 것이다.
- 하나의 상태가 출처가 되어 자식들이 같은 값을 바라본다.
- 함께 쓰는 가장 가까운 공통 부모까지만 올린다.
6.4 prop drilling은 무엇이며 어떻게 해결하는가
prop drilling은 깊은 곳에 있는 컴포넌트에 값을 전달하려고, 그 값을 쓰지도 않는 중간 컴포넌트들이 계속 props로 넘겨 주는 상황이다. 값을 쓰는 곳은 한참 아래에 있는데, 위에서부터 단계마다 props를 적어 끌고 내려가야 한다. 중간 컴포넌트들이 자기와 상관없는 props를 떠안아 코드가 지저분해지고, 구조를 바꾸기도 어려워진다.
해결 방법은 몇 가지다. 앱 전체에서 두루 쓰는 값이라면 컨텍스트로 묶어 깊은 곳에서 바로 꺼내 쓴다. 컴포넌트 합성을 활용해, 값을 쓰는 컴포넌트를 위에서 만들어 children으로 내려 보내면 중간 단계를 건너뛸 수 있다. 상태가 복잡하고 넓게 공유된다면 상태 관리 라이브러리를 쓰기도 한다. 다만 몇 단계 정도의 얕은 전달이라면 그냥 props로 넘기는 것이 더 단순하고 명확할 때도 있다. 문제가 실제로 클 때 도구를 쓴다.
- prop drilling은 값을 쓰지 않는 중간 컴포넌트가 계속 전달하는 상황이다.
- 컨텍스트, 컴포넌트 합성, 상태 관리 라이브러리로 해결한다.
- 얕은 전달이면 그냥 props가 더 단순할 수 있다.
6.5 상태 관리 라이브러리는 왜 필요하며 무엇을 고려해 선택하는가
앱이 커지면 여러 화면이 같은 상태를 공유하고, 상태를 바꾸는 곳이 많아진다. 이때 컨텍스트만으로 모든 것을 다루면 값이 바뀔 때 넓은 범위가 함께 다시 그려지거나, 상태 변경 로직이 흩어져 관리가 어려워진다. 상태 관리 라이브러리는 앱 전역의 상태를 한곳에 모아 두고, 필요한 컴포넌트만 그 일부를 구독해 쓰게 한다. 그래서 관련된 부분만 다시 그려지고 로직도 정리된다.
선택할 때는 앱의 규모와 팀의 익숙함, 그리고 다루는 상태의 성격을 본다. 서버에서 받아 오는 데이터가 주된 고민이라면, 그 데이터의 가져오기와 캐싱, 갱신을 전담하는 도구가 잘 맞는다. 화면 자체의 상태가 복잡하다면 가벼운 전역 상태 도구가 적합하다. 무겁고 정형화된 큰 라이브러리는 규모가 클 때 질서를 주지만 작은 앱에는 과할 수 있다. 곧 무엇을 다루는 상태인지에 따라 도구를 고르고, 필요하지 않으면 기본 기능으로 충분하다.
- 여러 화면이 상태를 공유하고 변경 지점이 많아지면 필요해진다.
- 전역 상태를 모아 두고 필요한 부분만 구독해 다시 그리기를 줄인다.
- 서버 데이터인지 화면 상태인지, 규모가 어떤지에 따라 도구를 고른다.
6.6 에러 바운더리는 무엇이며 어떻게 동작하는가
에러 바운더리는 하위 컴포넌트에서 그리는 도중 오류가 났을 때, 앱 전체가 깨지는 대신 대체 화면을 보여 주는 장치다. 리액트에서는 어느 한 컴포넌트가 그리다가 오류를 던지면 기본적으로 화면 전체가 사라진다. 에러 바운더리로 일부분을 감싸 두면, 그 안에서 오류가 나도 오류는 그 경계에서 잡히고 나머지 화면은 멀쩡히 유지된다.
에러 바운더리는 오류를 잡았을 때 보여 줄 대체 화면을 정의하고, 오류 정보를 기록할 수도 있다. 사용자에게는 무언가 잘못됐다는 안내와 다시 시도할 방법을 보여 주고, 개발자는 오류를 수집해 원인을 파악한다. 다만 에러 바운더리는 그리는 도중의 오류를 잡는 것이라, 이벤트 처리기 안에서 난 오류나 비동기 작업의 오류는 잡지 못한다. 그런 오류는 일반적인 방법으로 따로 처리해야 한다.
- 에러 바운더리는 하위에서 그리다 난 오류를 잡아 대체 화면을 보여 준다.
- 오류가 경계에서 멈춰 나머지 화면은 유지된다.
- 이벤트 처리기나 비동기 작업의 오류는 잡지 못해 따로 처리한다.
6.7 코드 스플리팅은 무엇이며 lazy와 Suspense로 어떻게 구현하는가
코드 스플리팅은 앱의 코드를 한 덩어리로 묶지 않고 여러 조각으로 나눠, 필요할 때 그 조각만 불러오는 것이다. 모든 코드를 처음에 한꺼번에 내려받으면 첫 화면이 뜨기까지 오래 걸린다. 당장 보이지 않는 화면의 코드까지 미리 받기 때문이다. 코드를 나누면 처음에는 첫 화면에 필요한 만큼만 받고, 나머지는 그 화면으로 갈 때 받는다. 그래서 초기 로딩이 빨라진다.
리액트에서는 lazy로 컴포넌트를 필요할 때 불러오도록 지정하고, Suspense로 그 컴포넌트가 도착할 때까지 보여 줄 대기 화면을 정한다. 사용자가 그 화면으로 이동하면 해당 코드 조각을 내려받는 동안 대기 화면이 보이고, 다 받으면 실제 컴포넌트로 바뀐다. 라우트 단위로 나누는 것이 흔하고, 무겁지만 자주 쓰지 않는 컴포넌트를 나누기도 한다.
다음은 lazy와 Suspense로 코드를 나누는 예제입니다.
import { lazy, Suspense } from 'react'; // 필요할 때 불러오도록 지정한다 const Dashboard = lazy(() => import('./Dashboard')); function App() { return ( <Suspense fallback={<p>불러오는 중</p>}> <Dashboard /> </Suspense> ); }
- 코드 스플리팅은 코드를 조각으로 나눠 필요할 때만 불러온다.
- 처음에 받는 양이 줄어 초기 로딩이 빨라진다.
- lazy로 컴포넌트를 나누고 Suspense로 대기 화면을 정한다.
6.8 리액트에서 상태를 바꿀 때 불변성을 지켜야 하는 이유는 무엇인가
불변성을 지킨다는 것은 기존 객체나 배열을 직접 고치지 않고, 바뀐 내용을 담은 새 객체나 배열을 만들어 교체하는 것이다. 리액트는 상태가 바뀌었는지 판단할 때 이전 값과 새 값이 같은 것인지 비교한다. 이때 깊이 들여다보지 않고 두 값이 같은 대상인지만 빠르게 확인한다. 그래서 기존 객체를 그대로 두고 내부만 바꾸면, 리액트는 같은 대상이라고 보고 바뀌지 않았다고 판단한다.
그러면 화면이 갱신되지 않는다. 분명히 값을 바꿨는데 화면이 그대로인 흔한 원인이 바로 이것이다. 반대로 새 객체를 만들어 교체하면, 리액트는 다른 대상임을 보고 바뀌었다고 판단해 화면을 다시 그린다. 그래서 배열에 항목을 더할 때도 기존 배열을 직접 건드리지 않고 새 배열을 만들고, 객체의 속성을 바꿀 때도 스프레드로 복사해 새 객체를 만든다. 불변성을 지키면 변경 감지가 정확하고 빨라진다.
다음은 불변성을 지켜 상태를 바꾸는 예제입니다.
import { useState } from 'react'; function TodoApp() { const [todos, setTodos] = useState<string[]>([]); function addTodo(text: string) { // 기존 배열을 건드리지 않고 새 배열을 만들어 교체한다 setTodos((prev) => [...prev, text]); } // todos.push(text) 처럼 직접 바꾸면 변경이 감지되지 않는다 return <button onClick={() => addTodo('할 일')}>추가</button>; }
- 불변성은 기존 값을 고치지 않고 새 값을 만들어 교체하는 것이다.
- 리액트는 이전 값과 새 값이 같은 대상인지로 변경을 판단한다.
- 직접 고치면 같은 대상으로 보여 화면이 갱신되지 않는다.
6.9 항목이 수천 개인 긴 목록은 어떻게 빠르게 렌더링하는가
목록의 항목이 수천 개에 이르면, 그것을 모두 화면 요소로 만들어 그리는 것만으로도 느려진다. 화면에는 한 번에 일부만 보이는데도 보이지 않는 수천 개까지 전부 만들기 때문이다. 이 문제를 푸는 방법이 목록 가상화다. 가상화는 화면에 실제로 보이는 부분과 그 주변 약간만 요소로 만들고, 나머지는 만들지 않는다.
사용자가 스크롤하면 새로 보이게 된 항목만 그때그때 만들고, 화면에서 벗어난 항목은 치운다. 그래서 목록이 아무리 길어도 동시에 존재하는 요소 수가 일정하게 유지되어 빠르다. 전체 목록의 높이는 유지해서 스크롤 막대는 정상으로 보이게 한다. 직접 구현하기는 까다로워 보통 가상화를 해 주는 라이브러리를 쓴다. 무한 스크롤이나 큰 표를 다룰 때 함께 자주 쓰인다.
- 긴 목록은 보이지 않는 항목까지 모두 만들면 느려진다.
- 가상화는 화면에 보이는 부분과 주변만 요소로 만든다.
- 스크롤에 따라 요소를 만들고 치워 동시 요소 수를 일정하게 유지한다.
6.10 useEffect를 쓰지 않아도 되는 경우는 언제인가
useEffect는 화면 그리기와 별개인 외부 작업에 쓰는 도구인데, 그렇지 않은 일에까지 습관적으로 쓰는 경우가 많다. 대표적인 잘못이 다른 상태로부터 계산할 수 있는 값을 useEffect로 만들어 또 다른 상태에 저장하는 것이다. 예를 들어 목록과 검색어가 상태로 있을 때, 걸러진 목록을 useEffect로 계산해 상태에 담으면, 그릴 때 한 번 더 돌고 상태가 늘어 관리가 복잡해진다. 이런 값은 그냥 그리는 동안 계산하면 된다.
또 사용자의 동작에 반응하는 일은 useEffect가 아니라 그 동작을 처리하는 이벤트 처리기에서 직접 하는 것이 맞다. 버튼을 눌렀을 때 무언가를 보내는 일을, 상태를 바꾸고 그 변화를 useEffect로 감지해 처리하면 흐름이 빙 돌아 이해하기 어려워진다. useEffect는 외부 시스템과 맞춰야 하는 일, 곧 데이터 가져오기나 구독, 화면 밖과의 연결에만 쓰는 것이 좋다. 계산은 그리는 중에, 사용자 반응은 이벤트 처리기에서 다루면 코드가 단순해진다.
- 다른 상태로 계산되는 값은 useEffect로 저장하지 말고 그릴 때 계산한다.
- 사용자 동작에 대한 반응은 이벤트 처리기에서 직접 처리한다.
- useEffect는 데이터 가져오기나 구독 같은 외부 작업에만 쓴다.
6.11 React 19에서 달라진 주요 기능은 무엇인가
React 19는 비동기 작업과 폼 처리를 단순하게 만드는 기능을 여럿 들여왔다. 액션이라 부르는 방식으로 폼 제출 같은 작업을 다루면, 진행 중 상태와 오류 처리를 리액트가 상당 부분 알아서 맡는다. 이를 돕는 훅으로 폼 동작의 상태를 다루는 useActionState, 제출 중인지 알려 주는 useFormStatus가 있다. 손으로 관리하던 로딩과 오류 상태 코드가 크게 줄어든다.
useOptimistic은 서버 응답을 기다리는 동안 결과가 이미 반영된 것처럼 화면을 먼저 보여 주고, 응답이 오면 실제 값으로 맞추거나 실패 시 되돌리는 일을 돕는다. use라는 새 기능은 약속 같은 값을 그리는 도중에 읽을 수 있게 해 비동기 데이터를 더 자연스럽게 다루게 한다. 또 리액트 컴파일러가 도입되어, 예전에는 손으로 하던 다시 그리기 최적화를 상당 부분 자동으로 처리하는 방향으로 가고 있다. 전반적으로 직접 관리하던 일을 줄이고 단순하게 쓰도록 바뀌었다.
- 액션과 useActionState, useFormStatus로 폼 처리가 단순해졌다.
- useOptimistic은 응답 전 결과를 먼저 보여 주고 실패 시 되돌린다.
- use 기능과 리액트 컴파일러로 비동기 처리와 최적화가 쉬워졌다.
7. Next.js
7.1 CSR, SSR, SSG, ISR은 각각 무엇이며 어떻게 다른가
네 가지는 화면을 언제 어디서 만드느냐가 다르다. CSR은 브라우저에서 만든다. 빈 화면을 먼저 받고 자바스크립트가 실행되면서 내용을 채운다. 첫 화면이 뜨기까지 시간이 걸리고 검색 노출에 불리할 수 있지만, 한 번 뜨면 화면 전환이 매끄럽다. SSR은 서버에서 요청이 올 때마다 화면을 만들어 완성된 형태로 보낸다. 첫 화면이 빠르고 검색 노출에 유리하며, 매 요청마다 최신 데이터를 담을 수 있다.
SSG는 빌드할 때 미리 화면을 만들어 둔다. 요청이 오면 만들어 둔 것을 바로 내려주므로 가장 빠르다. 자주 바뀌지 않는 페이지에 잘 맞는다. 다만 내용이 빌드 시점에 고정되어, 데이터가 바뀌면 다시 빌드해야 한다. ISR은 SSG의 빠름을 유지하면서, 정해진 시간이 지나면 화면을 백그라운드에서 새로 만들어 갱신한다. 미리 만들어 두는 속도와 주기적인 최신화를 함께 얻는 방식이다. Next.js는 한 앱 안에서 페이지마다 이 방식을 골라 쓸 수 있다.
- CSR은 브라우저에서, SSR은 요청마다 서버에서 화면을 만든다.
- SSG는 빌드 때 미리 만들어 두어 가장 빠르다.
- ISR은 미리 만들어 두되 주기적으로 백그라운드에서 갱신한다.
7.2 App Router와 Pages Router는 어떻게 다르며 무엇을 써야 하는가
Pages Router는 Next.js의 초기 라우팅 방식이다. pages 폴더 안의 파일이 곧 경로가 되고, 데이터를 미리 가져오는 정해진 함수를 페이지에서 내보내는 식으로 동작한다. 오래 쓰여 안정적이지만, 서버 컴포넌트 같은 리액트의 최신 기능을 온전히 활용하기 어렵다. App Router는 그 뒤에 나온 새 방식으로, 지금의 표준이다.
App Router는 app 폴더를 쓰며 서버 컴포넌트를 기본으로 한다. 폴더로 경로를 구성하고, 정해진 이름의 파일로 페이지와 레이아웃, 로딩 화면, 오류 화면 등을 정의한다. 데이터 가져오기를 컴포넌트 안에서 직접 하고, 중첩 레이아웃과 스트리밍 같은 기능을 자연스럽게 쓴다. 새 프로젝트라면 App Router로 시작하는 것이 권장된다. 기존 Pages Router 프로젝트도 계속 동작하므로 당장 옮길 필요는 없지만, 앞으로의 방향은 App Router다.
- Pages Router는 초기 방식으로 pages 폴더와 정해진 데이터 함수를 쓴다.
- App Router는 app 폴더와 서버 컴포넌트를 기본으로 하는 현재 표준이다.
- 새 프로젝트는 App Router로 시작하는 것이 권장된다.
7.3 서버 컴포넌트와 클라이언트 컴포넌트는 어떻게 다른가
App Router에서 컴포넌트는 기본이 서버 컴포넌트다. 서버 컴포넌트는 서버에서 실행되어 결과만 브라우저로 보낸다. 그래서 데이터베이스나 비밀 키에 직접 접근할 수 있고, 컴포넌트 안에서 데이터를 바로 가져올 수 있다. 자바스크립트를 브라우저로 보내지 않으므로 받는 코드 양이 줄어 빠르다. 다만 서버에서 한 번 실행되고 끝나므로, 상태를 두거나 사용자의 클릭에 반응하거나 브라우저 기능을 쓰지는 못한다.
사용자와 상호작용하는 부분은 클라이언트 컴포넌트로 만든다. 파일 맨 위에 클라이언트 컴포넌트임을 알리는 표시를 적으면, 그 컴포넌트는 브라우저에서도 동작해 상태와 이벤트, 브라우저 기능을 쓸 수 있다. 권장되는 방식은 화면 대부분을 서버 컴포넌트로 두고, 상호작용이 필요한 작은 부분만 클라이언트 컴포넌트로 떼어 그 안에 두는 것이다. 그래야 브라우저로 보내는 코드를 최소로 줄이면서 필요한 곳에만 상호작용을 둘 수 있다.
다음은 서버 컴포넌트와 클라이언트 컴포넌트를 나누는 예제입니다.
// 서버 컴포넌트 - 데이터를 직접 가져온다 (기본값) async function PostPage() { const res = await fetch('https://api.example.com/posts'); const posts = await res.json(); return ( <div> {posts.map((p: { id: number; title: string }) => ( <article key={p.id}>{p.title}</article> ))} <LikeButton /> {/* 상호작용은 클라이언트 컴포넌트로 분리 */} </div> ); } export default PostPage;
'use client'; // 이 표시로 클라이언트 컴포넌트가 된다 import { useState } from 'react'; export default function LikeButton() { const [liked, setLiked] = useState(false); return <button onClick={() => setLiked(!liked)}>{liked ? '취소' : '좋아요'}</button>; }
- 서버 컴포넌트는 서버에서 실행되어 데이터에 직접 접근하고 코드를 덜 보낸다.
- 상태와 이벤트, 브라우저 기능이 필요하면 클라이언트 컴포넌트로 만든다.
- 대부분을 서버 컴포넌트로 두고 상호작용만 떼어 내는 것이 권장된다.
7.4 Next.js의 파일 기반 라우팅과 동적 라우트는 어떻게 동작하는가
Next.js는 폴더와 파일 구조가 곧 경로가 되는 파일 기반 라우팅을 쓴다. App Router에서는 app 폴더 안에 폴더를 만들면 그 폴더 이름이 경로 한 칸이 되고, 그 안에 페이지 파일을 두면 그 경로의 화면이 된다. 별도의 경로 설정을 손으로 적지 않고 폴더 구조만으로 경로가 정해지므로 직관적이다. 폴더를 중첩하면 경로도 그만큼 깊어진다.
동적 라우트는 경로의 일부가 값에 따라 달라지는 경우다. 게시글 상세 페이지처럼 글마다 주소가 다르지만 화면 구조는 같을 때, 폴더 이름을 대괄호로 감싸 그 자리를 변수로 만든다. 그러면 그 자리에 어떤 값이 와도 같은 페이지가 처리하고, 페이지는 실제 들어온 값을 받아 그에 맞는 데이터를 가져온다. 이렇게 하면 글이 수천 개여도 페이지 파일은 하나로 충분하다.
다음은 동적 라우트로 게시글 상세 페이지를 만드는 예제입니다.
// 파일 위치: app/posts/[id]/page.tsx // 주소 /posts/1, /posts/2 등이 모두 이 페이지로 처리된다 async function PostDetail({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; // 주소의 [id] 자리 값을 받는다 const res = await fetch('https://api.example.com/posts/' + id); const post = await res.json(); return <h1>{post.title}</h1>; } export default PostDetail;
- 폴더와 파일 구조가 곧 경로가 되는 파일 기반 라우팅을 쓴다.
- 폴더 이름을 대괄호로 감싸면 그 자리가 동적 라우트 변수가 된다.
- 페이지는 들어온 값을 받아 그에 맞는 데이터를 가져온다.
7.5 layout은 무엇이며 중첩 레이아웃은 어떻게 동작하는가
layout은 여러 페이지가 공통으로 두르는 틀이다. 헤더와 사이드바, 푸터처럼 페이지가 바뀌어도 유지되는 부분을 레이아웃에 둔다. 정해진 이름의 레이아웃 파일을 만들고 그 안에 페이지가 들어갈 자리를 두면, 그 폴더와 하위 폴더의 모든 페이지가 이 틀 안에 그려진다. 공통 부분을 한 번만 정의해 재사용하므로 중복이 줄어든다.
레이아웃은 중첩된다. 가장 바깥에 앱 전체를 두르는 레이아웃이 있고, 하위 폴더에 또 레이아웃을 두면 그 부분에만 추가로 적용된다. 예를 들어 앱 전체 레이아웃 안에, 관리자 영역에만 별도의 사이드바를 두는 레이아웃을 둘 수 있다. 또 중요한 점은 페이지를 이동해도 공통 레이아웃 부분은 다시 그려지지 않고 유지된다는 것이다. 바뀌는 페이지 영역만 교체되므로 이동이 매끄럽고, 레이아웃 안의 상태도 보존된다.
- layout은 여러 페이지가 공통으로 두르는 틀이다.
- 하위 폴더에 레이아웃을 두면 중첩되어 그 부분에만 더해진다.
- 페이지를 이동해도 공통 레이아웃은 유지되고 페이지 영역만 바뀐다.
7.6 App Router에서 데이터 페칭과 캐싱은 어떻게 동작하는가
App Router에서는 서버 컴포넌트 안에서 데이터를 직접 가져온다. 컴포넌트를 비동기로 만들고 데이터를 기다려 받아 바로 화면에 쓴다. 예전처럼 데이터를 미리 가져오는 정해진 함수를 따로 두지 않아도 된다. 여러 컴포넌트가 각자 필요한 데이터를 가까운 곳에서 가져오므로 코드가 흩어지지 않고 정리된다.
가져온 데이터는 캐싱으로 다룰 수 있다. 같은 데이터를 매번 새로 가져오지 않도록 보관해 두었다가 재사용하거나, 정해진 시간마다 새로 가져오거나, 항상 최신을 가져오도록 지정할 수 있다. 자주 바뀌지 않는 데이터는 보관해 두어 빠르게 내려주고, 실시간성이 중요한 데이터는 매번 새로 가져온다. 데이터가 바뀌었을 때 특정 캐시만 골라 무효화해 갱신하는 것도 가능하다. 이 캐싱 동작은 버전에 따라 기본값과 세부가 달라지므로, 쓰는 버전의 문서를 확인하는 것이 좋다.
- 서버 컴포넌트를 비동기로 만들어 데이터를 직접 가져온다.
- 데이터를 보관해 재사용하거나 주기적으로 또는 매번 새로 가져온다.
- 캐싱 기본값은 버전에 따라 다르므로 쓰는 버전 문서를 확인한다.
7.7 서버 액션은 무엇이며 어떻게 쓰는가
서버 액션은 클라이언트에서 일어난 동작에 대해 서버에서 실행되는 함수를 직접 호출하는 방법이다. 폼 제출이나 데이터 변경 같은 작업을 처리할 때, 예전에는 별도의 주소를 만들고 그곳으로 요청을 보내야 했다. 서버 액션을 쓰면 함수에 서버에서 실행된다는 표시를 붙이고, 그 함수를 폼이나 이벤트에 바로 연결한다. 그러면 그 함수가 서버에서 실행되어 데이터베이스를 다루거나 비밀 값을 안전하게 쓸 수 있다.
서버 액션의 장점은 클라이언트와 서버 사이의 요청을 손으로 만들지 않아도 된다는 것이다. 함수를 호출하듯 쓰면 리액트와 Next.js가 그 사이의 통신을 알아서 처리한다. 폼과 함께 쓰면 자바스크립트가 아직 준비되지 않은 상황에서도 동작하는 이점도 있다. 작업이 끝난 뒤 관련 데이터를 다시 가져와 화면을 갱신하는 것도 함께 처리할 수 있다. 데이터를 바꾸는 작업을 간결하고 안전하게 다루는 방법이다.
다음은 서버 액션으로 폼을 처리하는 예제입니다.
// app/new-post/page.tsx async function createPost(formData: FormData) { 'use server'; // 이 함수는 서버에서 실행된다 const title = formData.get('title'); // 여기서 데이터베이스에 안전하게 저장한다 console.log('저장할 제목', title); } export default function NewPostPage() { return ( <form action={createPost}> <input name="title" /> <button type="submit">등록</button> </form> ); }
- 서버 액션은 클라이언트 동작에 대해 서버 함수를 직접 호출한다.
- 함수에 서버 실행 표시를 붙여 폼이나 이벤트에 연결한다.
- 별도 주소 없이 데이터 변경을 간결하고 안전하게 처리한다.
7.8 라우트 핸들러는 무엇이며 언제 쓰는가
라우트 핸들러는 App Router에서 화면이 아니라 데이터를 응답하는 주소를 만드는 방법이다. 정해진 이름의 파일을 두고 요청 방식에 맞는 함수를 내보내면, 그 경로로 들어온 요청에 데이터를 돌려준다. 화면을 그리는 페이지와 달리, 외부에서 호출하는 데이터 창구 역할을 한다. 가져오기, 만들기, 수정, 삭제 같은 요청을 각각의 함수로 처리한다.
서버 액션이 같은 앱 안에서 데이터를 다루는 데 편하다면, 라우트 핸들러는 외부에서 호출해야 하는 창구가 필요할 때 쓴다. 예를 들어 외부 서비스가 보내는 알림을 받거나, 모바일 앱이나 다른 클라이언트가 호출할 데이터 창구를 만들 때 적합하다. 또 비밀 키를 써서 외부 서비스에 요청할 때, 그 키를 브라우저에 노출하지 않으려고 라우트 핸들러를 중간에 두기도 한다. 화면 안의 단순한 데이터 변경은 서버 액션이, 외부 연동 창구는 라우트 핸들러가 맞는 경우가 많다.
- 라우트 핸들러는 화면이 아니라 데이터를 응답하는 주소를 만든다.
- 외부에서 호출하는 창구가 필요할 때 적합하다.
- 비밀 키를 감춰 외부 서비스에 요청하는 중간 다리로도 쓴다.
7.9 Next.js에서 메타데이터와 SEO는 어떻게 다루는가
SEO는 검색 엔진이 페이지를 잘 이해하고 노출하도록 돕는 것이다. 이를 위해 페이지마다 제목과 설명 같은 메타데이터를 정확히 담아야 한다. 검색 결과에 보이는 제목과 요약, 공유했을 때 나타나는 미리보기가 모두 이 정보에서 온다. CSR만으로 만든 화면은 처음에 내용이 비어 있어 검색 노출에 불리하지만, Next.js는 서버에서 내용을 채워 보내므로 검색 엔진이 읽기 좋다.
App Router에서는 페이지나 레이아웃에서 메타데이터를 정의해 내보내면 Next.js가 적절한 위치에 넣어 준다. 고정된 메타데이터는 값을 그대로 내보내고, 페이지마다 달라지는 메타데이터는 데이터를 받아 만들어 내보낸다. 예를 들어 게시글 상세 페이지는 그 글의 제목을 가져와 페이지 제목으로 쓴다. 이렇게 하면 페이지마다 알맞은 제목과 설명이 들어가 검색 노출과 공유 미리보기가 좋아진다.
다음은 페이지에서 메타데이터를 정의하는 예제입니다.
import type { Metadata } from 'next'; // 고정 메타데이터 export const metadata: Metadata = { title: '서비스 소개', description: '우리 서비스를 소개하는 페이지입니다', }; export default function AboutPage() { return <h1>서비스 소개</h1>; }
- 메타데이터는 검색 결과 제목과 요약, 공유 미리보기의 바탕이 된다.
- Next.js는 서버에서 내용을 채워 보내 검색 노출에 유리하다.
- 페이지에서 메타데이터를 내보내며 데이터를 받아 동적으로도 만든다.
7.10 next/image는 이미지를 어떻게 최적화하는가
이미지는 보통 페이지에서 가장 무거운 자원이라, 그대로 쓰면 로딩이 느려진다. next/image는 이미지를 자동으로 최적화해 이 문제를 줄인다. 화면 크기에 맞춰 적당한 크기의 이미지를 내려주고, 더 가벼운 형식으로 바꿔 보낸다. 그래서 큰 원본을 작은 화면에까지 그대로 보내는 낭비를 막는다.
또 화면에 보이지 않는 이미지는 당장 받지 않고, 사용자가 스크롤해 가까워질 때 받는다. 그래서 첫 로딩의 부담이 준다. 이미지의 크기를 미리 알려 자리를 잡아 두므로, 이미지가 늦게 도착해 화면이 갑자기 밀리는 현상도 막는다. 일반 이미지 태그 대신 next/image를 쓰면 이런 최적화를 따로 신경 쓰지 않아도 적용된다. 너비와 높이를 지정하거나 공간을 채우는 방식을 정하는 정도만 해 주면 된다.
다음은 next/image로 최적화된 이미지를 넣는 예제입니다.
import Image from 'next/image'; export default function Profile() { return ( <Image src="/profile.jpg" alt="프로필 사진" width={200} height={200} priority // 첫 화면에 중요한 이미지는 먼저 불러온다 /> ); }
- next/image는 화면 크기에 맞는 크기와 가벼운 형식으로 이미지를 보낸다.
- 보이지 않는 이미지는 가까워질 때 불러와 첫 로딩 부담을 줄인다.
- 크기를 미리 잡아 두어 이미지 도착 시 화면이 밀리지 않는다.
7.11 미들웨어는 무엇이며 어떤 일에 쓰는가
미들웨어는 요청이 페이지나 라우트 핸들러에 닿기 전에 가로채 먼저 실행되는 코드다. 사용자가 어떤 주소로 들어오면, 그 요청을 처리하기 전에 미들웨어가 먼저 살펴보고 무언가를 결정한다. 정해진 위치에 미들웨어 파일을 두면, 지정한 경로로 들어오는 요청마다 이 코드가 동작한다. 요청과 응답의 길목에서 공통 처리를 하는 자리다.
대표적인 쓰임이 접근 제어다. 로그인하지 않은 사용자가 보호된 페이지로 들어오면 미들웨어가 그것을 감지해 로그인 페이지로 돌려보낸다. 페이지마다 검사 코드를 넣지 않고 길목에서 한 번에 처리한다. 그 밖에 지역이나 언어에 따라 다른 경로로 보내거나, 요청에 정보를 덧붙이는 일에도 쓴다. 미들웨어는 모든 요청의 길목에서 도는 만큼 가볍게 유지해야 하고, 무거운 작업은 두지 않는 것이 좋다.
- 미들웨어는 요청이 페이지에 닿기 전에 가로채 먼저 실행된다.
- 접근 제어나 경로 전환 같은 공통 처리를 길목에서 한 번에 한다.
- 모든 요청에서 도는 만큼 가볍게 유지한다.
7.12 Next.js에서 환경 변수의 공개와 비공개는 어떻게 구분하는가
환경 변수는 주소나 키처럼 환경에 따라 달라지는 값을 코드 밖에 두는 것이다. Next.js에서 환경 변수는 기본적으로 서버에서만 쓸 수 있다. 그래서 데이터베이스 접속 정보나 비밀 키 같은 민감한 값을 환경 변수에 두면 브라우저로 새어 나가지 않는다. 서버 컴포넌트나 서버 액션, 라우트 핸들러에서만 읽힌다.
반면 브라우저에서도 써야 하는 값은 정해진 접두사를 이름 앞에 붙인다. 이 접두사가 붙은 변수만 브라우저로 전달되어 클라이언트 코드에서 읽을 수 있다. 곧 접두사가 붙으면 공개되어 누구나 볼 수 있고, 붙지 않으면 서버에만 머문다. 그래서 비밀 키에는 절대 이 접두사를 붙이면 안 된다. 공개로 표시한 값은 브라우저에 그대로 노출된다는 점을 기억하고, 노출돼도 괜찮은 값에만 접두사를 붙여야 한다.
다음은 공개 변수와 비공개 변수를 구분해 쓰는 예제입니다.
# .env.local # 접두사가 없으면 서버에서만 읽힌다 - 비밀 값은 이렇게 둔다 DATABASE_PASSWORD=서버에만_머무는_비밀값 # NEXT_PUBLIC_ 접두사가 붙으면 브라우저에도 노출된다 - 공개해도 되는 값만 NEXT_PUBLIC_API_URL=https://api.example.com
- 환경 변수는 기본적으로 서버에서만 읽혀 비밀 값을 감춘다.
- 정해진 접두사를 붙인 변수만 브라우저로 전달되어 공개된다.
- 비밀 키에는 공개 접두사를 붙이면 안 된다.
8. 웹 기초와 브라우저
8.1 HTTP 요청과 응답은 어떤 구조로 이루어지는가
브라우저가 서버에 무언가를 요청하고 서버가 답하는 약속이 HTTP다. 요청은 무엇을 하려는지 나타내는 방식, 어떤 주소인지, 부가 정보를 담은 헤더, 그리고 보낼 데이터인 본문으로 이루어진다. 방식에는 데이터를 가져오는 것, 새로 만드는 것, 수정하는 것, 삭제하는 것 등이 있어 의도를 드러낸다. 헤더에는 어떤 형식의 데이터를 주고받는지, 인증 정보는 무엇인지 같은 내용이 담긴다.
응답은 요청이 어떻게 처리됐는지 알려 주는 상태 코드, 부가 정보를 담은 헤더, 그리고 실제 데이터인 본문으로 이루어진다. 상태 코드는 성공인지 실패인지를 숫자로 나타낸다. 200번대는 성공, 300번대는 다른 곳으로 이동, 400번대는 요청 쪽의 잘못, 500번대는 서버 쪽의 문제를 뜻한다. 그래서 응답을 받으면 먼저 상태 코드로 결과를 판단하고, 본문에서 실제 데이터를 꺼낸다. 프론트엔드 개발자는 이 구조를 알아야 요청을 제대로 만들고 응답을 올바르게 다룬다.
- 요청은 방식, 주소, 헤더, 본문으로 이루어진다.
- 응답은 상태 코드, 헤더, 본문으로 이루어진다.
- 상태 코드는 200번대 성공, 400번대 요청 오류, 500번대 서버 오류다.
8.2 브라우저가 페이지를 화면에 그리는 과정은 어떻게 되는가
브라우저는 받은 HTML을 읽어 화면 구조를 나타내는 트리로 바꾼다. 동시에 CSS를 읽어 스타일 정보를 정리한다. 이 둘을 합쳐, 실제로 화면에 그려질 요소들과 그 스타일을 담은 트리를 만든다. 화면에 보이지 않는 요소는 이 단계에서 빠진다. 이렇게 무엇을 어떤 모습으로 그릴지가 정해진다.
그다음 각 요소가 화면의 어디에 얼마만 한 크기로 놓일지 계산한다. 이 위치와 크기 계산을 거쳐, 실제로 화면에 색과 글자를 칠하는 단계로 넘어간다. 자바스크립트는 이 과정 중간에 끼어들어 구조나 스타일을 바꿀 수 있고, 그러면 영향받는 단계부터 다시 계산된다. 그래서 자바스크립트가 화면을 자주 건드리면 다시 계산이 반복되어 느려질 수 있다. 이 과정을 이해하면 무엇이 화면을 느리게 만드는지 가늠할 수 있다.
- HTML과 CSS를 합쳐 화면에 그려질 요소와 스타일 트리를 만든다.
- 각 요소의 위치와 크기를 계산한 뒤 실제로 색과 글자를 칠한다.
- 자바스크립트가 구조나 스타일을 바꾸면 영향받는 단계부터 다시 계산된다.
8.3 리플로우와 리페인트는 무엇이며 성능과 어떤 관계가 있는가
리플로우는 요소의 위치나 크기가 바뀌어 배치를 다시 계산하는 것이다. 너비를 바꾸거나 요소를 넣고 빼면, 그 요소뿐 아니라 영향받는 주변 요소들의 위치까지 다시 계산해야 한다. 리페인트는 위치와 크기는 그대로인데 색이나 그림자처럼 보이는 모습만 바뀌어 다시 칠하는 것이다. 리플로우는 배치 계산까지 포함하므로 리페인트보다 비용이 크다.
화면을 자주 바꾸는 코드는 이 두 가지를 반복해 일으켜 느려질 수 있다. 특히 반복문 안에서 요소의 크기를 하나씩 바꾸면 리플로우가 거듭 일어난다. 그래서 여러 변경을 한 번에 모아서 적용하거나, 배치에 영향을 주지 않는 방식으로 움직임을 표현해 리플로우를 줄인다. 또 화면을 읽는 작업과 바꾸는 작업을 번갈아 하면 리플로우가 잦아지므로, 읽기와 쓰기를 나눠 모으는 것이 좋다. 리액트는 변경을 모아 한 번에 반영하므로 이런 부담을 줄여 준다.
- 리플로우는 위치와 크기를 다시 계산하는 것으로 비용이 크다.
- 리페인트는 보이는 모습만 다시 칠하는 것이다.
- 변경을 모아 적용하고 배치에 영향을 덜 주는 방식으로 줄인다.
8.4 CORS는 무엇이며 왜 발생하는가
브라우저에는 한 출처에서 불러온 페이지가 다른 출처의 자원을 함부로 요청하지 못하게 막는 보안 장치가 있다. 출처는 주소의 체계와 도메인, 포트를 합친 것이다. 이 셋 중 하나라도 다르면 다른 출처로 본다. 이 장치가 없으면 악의적인 페이지가 사용자 몰래 다른 사이트에 요청을 보내 정보를 빼낼 수 있어, 브라우저가 기본적으로 다른 출처로의 요청을 제한한다.
CORS는 이 제한을 안전하게 푸는 약속이다. 서버가 응답에 어떤 출처의 요청을 허용하는지 밝히면, 브라우저는 그 허용 목록에 든 출처의 요청만 통과시킨다. 그래서 다른 출처의 서버에 요청했는데 서버가 허용을 밝히지 않으면, 브라우저가 응답을 막고 오류가 난다. 중요한 점은 이 허용을 정하는 쪽이 서버라는 것이다. 프론트엔드에서 CORS 오류를 만나면 서버가 우리 출처를 허용하도록 설정해야 풀린다. 개발 중에는 요청을 우회해 넘기는 방법을 쓰기도 한다.
- 브라우저는 다른 출처로의 요청을 기본적으로 제한한다.
- 출처는 주소 체계, 도메인, 포트를 합친 것이다.
- 서버가 허용 출처를 밝혀야 풀리며 프론트엔드만으로는 해결되지 않는다.
8.5 쿠키, 로컬 스토리지, 세션 스토리지는 어떻게 다른가
세 가지 모두 브라우저에 데이터를 저장하지만 성격이 다르다. 쿠키는 작은 데이터를 저장하며, 서버에 요청을 보낼 때마다 자동으로 함께 전송된다는 점이 특징이다. 그래서 로그인 상태를 유지하는 인증 정보처럼 서버가 매 요청에서 알아야 하는 값에 쓴다. 만료 시간을 정할 수 있고, 자바스크립트의 접근을 막아 보안을 높이는 설정도 둘 수 있다.
로컬 스토리지와 세션 스토리지는 서버로 자동 전송되지 않고 브라우저 안에만 머문다. 쿠키보다 더 많은 데이터를 담을 수 있고, 자바스크립트로 읽고 쓴다. 둘의 차이는 유지 기간이다. 로컬 스토리지는 사용자가 지우기 전까지 남아 브라우저를 닫았다 열어도 유지된다. 세션 스토리지는 탭을 닫으면 사라진다. 그래서 오래 보관할 설정은 로컬 스토리지에, 그 탭에서만 잠시 쓸 값은 세션 스토리지에 둔다. 다만 민감한 값은 이 둘에 두지 않는 것이 안전하다.
- 쿠키는 서버 요청마다 자동 전송되어 인증 정보에 쓴다.
- 로컬 스토리지와 세션 스토리지는 서버로 전송되지 않고 브라우저에 머문다.
- 로컬 스토리지는 계속 유지되고 세션 스토리지는 탭을 닫으면 사라진다.
8.6 REST API는 무엇이며 어떻게 설계하는가
REST API는 서버의 데이터를 자원으로 보고, 주소로 자원을 가리키며 요청 방식으로 자원에 무엇을 할지 나타내는 설계 방식이다. 예를 들어 게시글이라는 자원이 있으면, 그 자원을 가리키는 주소를 두고, 가져오는 방식으로 조회하고, 만드는 방식으로 새로 쓰고, 수정하는 방식으로 고치고, 삭제하는 방식으로 지운다. 주소는 동작이 아니라 자원의 이름으로 짓고, 무엇을 할지는 요청 방식이 나타낸다.
잘 설계된 REST API는 규칙이 일관되어 예측하기 쉽다. 자원을 복수 명사로 가리키고, 한 자원 아래의 하위 자원은 주소를 이어 표현한다. 결과는 적절한 상태 코드로 알려, 성공인지 잘못된 요청인지 권한이 없는지를 코드로 구분하게 한다. 프론트엔드 개발자는 이런 규칙을 알아야 API를 올바르게 호출하고, 받은 상태 코드에 따라 화면을 알맞게 처리한다. 자원 중심의 일관된 규칙이 REST의 핵심이다.
- REST는 데이터를 자원으로 보고 주소로 가리키며 방식으로 동작을 나타낸다.
- 주소는 동작이 아니라 자원 이름으로 짓는다.
- 결과를 적절한 상태 코드로 알려 일관되게 처리하게 한다.
8.7 세션 방식과 토큰 방식 인증은 어떻게 다른가
인증은 요청을 보낸 사람이 누구인지 확인하는 일이다. 세션 방식은 로그인에 성공하면 서버가 그 사용자의 정보를 서버에 보관하고, 그것을 가리키는 표를 브라우저에 쿠키로 준다. 이후 요청마다 그 표가 함께 가고, 서버는 표로 보관된 정보를 찾아 누구인지 안다. 정보가 서버에 있어 통제하기 쉽지만, 서버가 사용자마다 정보를 들고 있어야 한다.
토큰 방식은 로그인에 성공하면 서버가 사용자 정보를 담아 서명한 토큰을 발급한다. 브라우저는 이 토큰을 보관했다가 요청마다 보내고, 서버는 서명을 확인해 토큰이 진짜인지 검증한다. 정보가 토큰 안에 들어 있어 서버가 따로 보관하지 않아도 된다. 그래서 서버를 여러 대로 늘리기 쉽다. 다만 한 번 발급한 토큰은 만료 전까지 유효해 즉시 취소하기 어렵다는 점이 있다. 어느 방식이든 인증 정보를 안전하게 보관하고 전송하는 것이 중요하다.
- 세션 방식은 정보를 서버에 보관하고 가리키는 표를 쿠키로 준다.
- 토큰 방식은 서명한 토큰에 정보를 담아 서버가 보관하지 않는다.
- 토큰 방식은 서버 확장에 유리하나 즉시 취소가 어렵다.
8.8 시맨틱 HTML과 웹 접근성은 무엇이며 왜 중요한가
시맨틱 HTML은 각 부분의 의미에 맞는 태그를 쓰는 것이다. 모든 것을 의미 없는 일반 상자로 만들지 않고, 제목은 제목 태그로, 버튼은 버튼 태그로, 목록은 목록 태그로 표현한다. 그러면 마크업만 봐도 구조와 의미가 드러난다. 검색 엔진도 내용을 더 잘 이해하고, 브라우저의 기본 기능도 제대로 동작한다.
웹 접근성은 장애가 있는 사용자를 포함해 누구나 쓸 수 있게 만드는 것이다. 화면을 읽어 주는 보조 기술은 시맨틱 태그의 의미를 바탕으로 화면을 사용자에게 전달한다. 그래서 의미에 맞는 태그를 쓰면 접근성이 자연스럽게 좋아진다. 이미지에 설명을 달고, 키보드만으로도 모든 기능을 쓸 수 있게 하고, 색 대비를 충분히 두는 것도 접근성의 일부다. 접근성은 일부 사용자만을 위한 것이 아니라, 더 튼튼하고 모두가 쓰기 좋은 화면을 만드는 일이다.
- 시맨틱 HTML은 부분의 의미에 맞는 태그를 써서 구조를 드러낸다.
- 보조 기술은 시맨틱 태그의 의미를 바탕으로 화면을 전달한다.
- 접근성은 누구나 쓸 수 있는 더 튼튼한 화면을 만드는 일이다.
8.9 디바운스와 스로틀은 무엇이며 어떻게 다른가
디바운스와 스로틀은 짧은 시간에 너무 자주 일어나는 동작의 실행 횟수를 줄이는 방법이다. 검색창에 글자를 칠 때마다 요청을 보내거나, 스크롤할 때마다 계산을 하면 너무 잦아 부담이 된다. 이 둘은 그 횟수를 줄여 성능을 지킨다. 다만 줄이는 방식이 다르다.
디바운스는 동작이 멈추고 일정 시간이 지난 뒤에 한 번만 실행한다. 검색창에 글자를 계속 치는 동안에는 기다리다가, 입력을 멈추면 그제야 요청을 보낸다. 그래서 입력이 끝난 최종 상태에 반응하는 데 적합하다. 스로틀은 정해진 간격마다 한 번씩 실행한다. 아무리 자주 일어나도 그 간격에 한 번만 처리한다. 스크롤 위치를 주기적으로 확인하는 것처럼 진행 중에 일정하게 반응해야 할 때 적합하다. 곧 디바운스는 멈춘 뒤 한 번, 스로틀은 일정 간격마다 한 번이다.
다음은 디바운스로 검색 입력을 처리하는 예제입니다.
function debounce(fn, delay) { let timer; return function (...args) { clearTimeout(timer); // 이전 예약을 취소하고 timer = setTimeout(() => fn(...args), delay); // 다시 예약한다 }; } const onSearch = debounce((keyword) => { console.log('검색 요청', keyword); }, 300); // 입력이 멈추고 300밀리초가 지나야 한 번 실행된다
- 둘 다 자주 일어나는 동작의 실행 횟수를 줄인다.
- 디바운스는 동작이 멈춘 뒤 일정 시간이 지나면 한 번 실행한다.
- 스로틀은 정해진 간격마다 한 번씩 실행한다.
8.10 웹 페이지의 성능은 어떤 방법으로 개선하는가
성능 개선의 큰 방향은 받는 양을 줄이고, 받는 시점을 늦추고, 다시 하는 일을 줄이는 것이다. 받는 양을 줄이려면 이미지를 알맞은 크기와 가벼운 형식으로 보내고, 코드에서 쓰지 않는 부분을 덜어 낸다. 자바스크립트 묶음이 너무 크면 첫 로딩이 느리므로, 코드를 나눠 처음에 필요한 만큼만 받게 한다. 같은 자원을 매번 새로 받지 않도록 보관해 두고 재사용하는 것도 받는 양을 줄인다.
받는 시점을 늦추는 것은 당장 필요하지 않은 것을 미루는 것이다. 화면에 보이지 않는 이미지나 컴포넌트는 가까워질 때 받는다. 다시 하는 일을 줄이는 것은 불필요한 계산과 다시 그리기를 막는 것이다. 리액트에서는 불필요한 다시 그리기를 줄이고, 무거운 계산은 기억해 재사용한다. 무엇이 느린지는 짐작이 아니라 측정으로 확인해야 한다. 측정 도구로 느린 지점을 찾은 다음, 그곳을 골라 개선하는 것이 효율적이다. 측정 없이 여기저기 손대는 것은 효과가 적다.
- 받는 양을 줄이고 받는 시점을 늦추고 다시 하는 일을 줄인다.
- 이미지 최적화, 코드 나누기, 캐싱, 지연 로딩이 대표적이다.
- 짐작이 아니라 측정으로 느린 지점을 찾아 개선한다.
9. Supabase 연동
9.1 Supabase는 무엇이며 무엇을 제공하는가
Supabase는 백엔드를 직접 만들지 않고도 앱에 필요한 서버 기능을 갖추게 해 주는 서비스다. 핵심에 PostgreSQL 데이터베이스가 있고, 그 위에 인증, 데이터 접근을 위한 자동 생성 API, 파일 저장소, 실시간 기능을 함께 제공한다. 그래서 프론트엔드 개발자가 별도의 서버 코드를 많이 작성하지 않고도 데이터를 저장하고 사용자를 인증하며 파일을 다룰 수 있다. 파이널 프로젝트처럼 빠르게 동작하는 앱을 만들 때 잘 맞는다.
Supabase는 데이터베이스의 테이블을 바탕으로 데이터를 읽고 쓰는 창구를 자동으로 만들어 준다. 프론트엔드에서는 제공되는 라이브러리로 이 창구를 호출해 데이터를 다룬다. 화면에서 데이터베이스의 데이터를 가져오거나 저장하는 일을, 서버 코드를 거치지 않고 라이브러리 호출로 처리할 수 있다. 다만 이렇게 클라이언트에서 직접 데이터베이스에 접근하는 구조이기 때문에, 누가 어떤 데이터에 접근할 수 있는지를 데이터베이스 차원에서 막아 두는 것이 매우 중요하다.
- Supabase는 데이터베이스 위에 인증, API, 저장소, 실시간을 함께 제공한다.
- 테이블을 바탕으로 데이터를 다루는 창구를 자동으로 만들어 준다.
- 클라이언트가 직접 접근하는 구조라 접근 제어가 매우 중요하다.
9.2 Supabase의 인증은 어떻게 동작하는가
Supabase는 회원가입과 로그인을 처리하는 인증 기능을 갖추고 있다. 이메일과 비밀번호로 가입하거나, 외부 계정으로 로그인하거나, 일회용 링크나 코드로 로그인하는 방식 등을 지원한다. 프론트엔드에서는 제공되는 라이브러리의 인증 함수를 호출해 가입과 로그인을 처리한다. 비밀번호를 안전하게 보관하고 검증하는 일은 Supabase가 맡으므로, 직접 구현하지 않아도 된다.
로그인에 성공하면 Supabase는 그 사용자를 나타내는 토큰을 발급하고, 라이브러리가 이를 보관해 이후 요청에 자동으로 실어 보낸다. 그래서 로그인한 뒤의 데이터 요청은 그 사용자의 자격으로 처리된다. 누가 로그인했는지는 데이터베이스에서도 알 수 있어, 이 정보를 접근 제어에 활용한다. 곧 인증으로 사용자가 누구인지 확인하고, 그 정보를 바탕으로 그 사용자가 어떤 데이터에 접근할 수 있는지를 데이터베이스에서 결정한다.
- Supabase는 가입과 로그인을 처리하는 인증 기능을 제공한다.
- 로그인하면 사용자를 나타내는 토큰을 발급해 요청에 자동으로 싣는다.
- 로그인한 사용자 정보는 데이터베이스에서 접근 제어에 활용된다.
9.3 RLS는 무엇이며 Supabase에서 왜 반드시 필요한가
RLS는 데이터베이스의 표에서 행 단위로 접근을 통제하는 기능이다. 표에 규칙을 붙여, 어떤 사용자가 어떤 행을 읽고 쓸 수 있는지 데이터베이스가 직접 판단하게 한다. 예를 들어 사용자는 자기 글만 볼 수 있고 남의 글은 볼 수 없게 하는 규칙을 표에 붙인다. 그러면 모든 요청에 이 규칙이 자동으로 적용되어, 규칙에 맞지 않는 행은 아예 존재하지 않는 것처럼 처리된다.
Supabase에서 RLS가 반드시 필요한 이유는 클라이언트가 데이터베이스에 직접 접근하기 때문이다. 클라이언트 코드에는 데이터베이스에 접근하는 키가 들어 있고, 이 키는 누구나 볼 수 있다. RLS가 없으면 이 키만으로 표의 모든 데이터를 읽고 쓸 수 있어, 사실상 데이터가 공개된 것과 같다. RLS를 켜고 규칙을 제대로 두어야 비로소 클라이언트가 자기 데이터에만 접근하도록 막힌다. 그래서 RLS는 Supabase에서 사실상 인가를 담당하는 핵심 보안 장치다.
주의할 점은 표를 만드는 방법에 따라 RLS가 켜지기도 하고 꺼지기도 한다는 것이다. 대시보드의 표 편집기로 만들면 RLS가 켜지지만, 직접 작성한 SQL이나 마이그레이션으로 만들면 꺼진 채로 남는다. 그래서 표를 만들 때마다 RLS가 켜져 있는지, 규칙이 제대로 붙어 있는지 확인하는 습관이 중요하다. RLS가 꺼진 채 배포되어 데이터가 새는 사고가 실제로 자주 일어난다.
다음은 사용자가 자기 데이터만 볼 수 있게 하는 RLS 규칙 예제입니다.
-- 표에 행 수준 보안을 켠다 alter table posts enable row level security; -- 로그인한 사용자가 자신의 글만 조회하도록 허용하는 규칙 create policy "자기 글만 조회" on posts for select to authenticated using ( (select auth.uid()) = user_id ); -- auth.uid() 는 현재 로그인한 사용자의 식별자를 돌려준다
- RLS는 표에 규칙을 붙여 행 단위로 접근을 데이터베이스가 통제한다.
- 클라이언트가 직접 접근하고 키가 노출되므로 RLS가 없으면 데이터가 공개된다.
- SQL이나 마이그레이션으로 만든 표는 RLS가 꺼진 채 남으니 꼭 확인한다.
9.4 anon 키와 service role 키는 어떻게 다르며 각각 어디에 쓰는가
Supabase는 두 가지 키를 제공한다. anon 키는 낮은 권한의 공개용 키로, 클라이언트 코드에 넣어 브라우저에 노출되어도 되도록 설계됐다. 이 키로 들어온 요청은 RLS 규칙의 통제를 받는다. 그래서 anon 키를 가진 누구든 RLS가 허용하는 만큼만 데이터에 접근할 수 있다. 곧 anon 키의 안전함은 전적으로 RLS가 제대로 설정되어 있다는 전제에 기댄다.
service role 키는 높은 권한의 비밀 키로, RLS 규칙을 통째로 건너뛴다. 이 키를 쓰면 모든 행에 제한 없이 접근할 수 있다. 그래서 관리 작업이나 서버에서만 해야 하는 일에 쓰며, 절대 클라이언트 코드에 넣으면 안 된다. 브라우저에 노출되는 순간 데이터 전체가 위험해진다. service role 키는 서버 환경, 곧 라우트 핸들러나 서버 액션, 별도의 서버 함수 안에서만 써야 한다. 정리하면 클라이언트에는 anon 키를 쓰고 RLS로 막으며, 강한 권한이 필요한 서버 작업에만 service role 키를 쓴다.
- anon 키는 공개용 낮은 권한 키로 RLS 통제를 받는다.
- service role 키는 RLS를 건너뛰는 비밀 키로 서버에서만 쓴다.
- service role 키를 클라이언트에 넣으면 데이터 전체가 위험해진다.
9.5 클라이언트에서 데이터베이스를 직접 호출하는 구조는 안전한가
Supabase는 프론트엔드에서 라이브러리로 데이터베이스를 직접 다루게 해 준다. 서버 코드를 거치지 않으니 빠르게 개발할 수 있다. 그런데 이 편리함이 곧 위험이 되기도 한다. 클라이언트 코드와 그 안의 키는 누구나 들여다볼 수 있으므로, 데이터베이스를 보호하는 책임이 클라이언트 코드에 있어서는 안 된다. 클라이언트에서 거르거나 막는 것은 마음만 먹으면 우회할 수 있어 보호가 되지 못한다.
안전함은 데이터베이스 자체에 둔 RLS 규칙에서 나온다. 클라이언트가 어떤 요청을 보내든, 데이터베이스가 규칙에 따라 허용된 데이터만 돌려주고 허용된 변경만 받아들인다. 그래서 이 구조에서는 RLS를 빠짐없이 켜고 규칙을 꼼꼼히 두는 것이 안전의 전부라 해도 지나치지 않다. 또 비밀 키를 써야 하는 강한 권한의 작업은 클라이언트에서 하지 않고 서버 쪽으로 옮긴다. 곧 클라이언트 직접 호출 구조는 RLS를 제대로 갖추면 안전하고, 갖추지 못하면 매우 위험하다.
- 클라이언트 코드와 키는 노출되므로 보호 책임을 클라이언트에 두면 안 된다.
- 안전함은 데이터베이스에 둔 RLS 규칙에서 나온다.
- 강한 권한이 필요한 작업은 서버 쪽으로 옮긴다.
9.6 Supabase의 실시간 기능은 무엇이며 어떻게 활용하는가
Supabase는 데이터베이스의 변화를 실시간으로 클라이언트에 알려 주는 기능을 제공한다. 보통은 데이터를 보려면 클라이언트가 직접 요청해 받아 와야 한다. 실시간 기능을 쓰면 특정 표의 변화를 구독해 두고, 그 표에 새 행이 추가되거나 수정되거나 삭제되면 그 변화를 즉시 전달받는다. 그래서 다른 사용자가 만든 변화가 새로고침 없이 화면에 바로 반영된다.
채팅, 함께 편집하는 화면, 실시간으로 갱신되는 현황판처럼 여러 사용자의 변화가 바로 보여야 하는 기능에 잘 맞는다. 화면이 나타날 때 구독을 시작하고, 화면이 사라질 때 구독을 정리하는 것이 중요하다. 리액트에서는 useEffect 안에서 구독을 시작하고 정리 함수에서 구독을 해제한다. 그러지 않으면 연결이 쌓여 자원이 샌다. 또 실시간으로 전달되는 데이터도 RLS의 통제를 받으므로, 사용자는 자신이 볼 수 있는 변화만 전달받는다.
다음은 실시간 변화를 구독하고 정리하는 예제입니다.
import { useEffect } from 'react'; import { supabase } from './supabaseClient'; function ChatRoom() { useEffect(() => { const channel = supabase .channel('messages') .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages' }, (payload) => console.log('새 메시지', payload.new) ) .subscribe(); return () => { supabase.removeChannel(channel); // 화면이 사라질 때 구독 해제 }; }, []); return <div>채팅방</div>; }
- 실시간 기능은 표의 변화를 구독해 새로고침 없이 즉시 전달받는다.
- 채팅이나 현황판처럼 변화가 바로 보여야 하는 기능에 맞는다.
- 화면이 사라질 때 구독을 정리하고 전달 데이터도 RLS의 통제를 받는다.
9.7 Supabase 스토리지는 무엇이며 파일을 어떻게 다루는가
스토리지는 이미지나 문서 같은 파일을 저장하고 내려받게 해 주는 기능이다. 데이터베이스에는 글이나 숫자 같은 데이터를 두지만, 프로필 사진이나 첨부 파일처럼 덩치 큰 파일은 스토리지에 둔다. 파일은 버킷이라 부르는 공간에 담는다. 프론트엔드에서는 제공되는 라이브러리로 파일을 올리고 내려받으며, 올린 파일의 주소를 받아 화면에 보여 준다.
스토리지에도 접근 제어가 적용된다. 버킷을 공개로 두면 누구나 주소로 파일을 볼 수 있고, 비공개로 두면 권한이 있는 사용자만 접근할 수 있다. 비공개 파일은 일정 시간만 유효한 임시 주소를 발급받아 안전하게 공유한다. 누가 어떤 파일을 올리고 볼 수 있는지도 규칙으로 정한다. 그래서 프로필 사진처럼 공개해도 되는 파일과, 본인만 봐야 하는 파일을 구분해 다룰 수 있다. 사진을 올릴 때는 너무 큰 원본을 그대로 올리지 않도록 크기를 다루는 것도 함께 고려한다.
다음은 스토리지에 파일을 올리는 예제입니다.
import { supabase } from './supabaseClient'; async function uploadAvatar(file: File, userId: string) { const path = userId + '/' + file.name; const { error } = await supabase.storage .from('avatars') // avatars 버킷에 .upload(path, file); // 파일을 올린다 if (error) { console.error('업로드 실패', error.message); return; } console.log('업로드 성공', path); }
- 스토리지는 이미지나 문서 같은 큰 파일을 버킷에 저장한다.
- 버킷을 공개나 비공개로 두고 규칙으로 접근을 통제한다.
- 비공개 파일은 일정 시간만 유효한 임시 주소로 공유한다.
9.8 서버 컴포넌트와 클라이언트 컴포넌트에서 Supabase를 다룰 때 무엇을 고려해야 하는가
Next.js의 App Router에서 Supabase를 쓸 때는 어디에서 호출하는지에 따라 다루는 방식이 달라진다. 서버 컴포넌트나 서버 액션에서 호출하면 서버에서 실행되므로, 사용자의 로그인 정보를 쿠키에서 읽어 그 사용자의 자격으로 데이터를 다룬다. 데이터를 미리 가져와 완성된 화면을 보내고 싶을 때 서버에서 호출한다. 비밀 키가 필요한 강한 권한의 작업도 서버 쪽에서 한다.
클라이언트 컴포넌트에서 호출하면 브라우저에서 실행되므로, 사용자의 조작에 반응하거나 실시간 변화를 구독하는 일에 쓴다. 이때는 공개용 키를 쓰고 RLS의 통제를 받는다. 어느 쪽에서 호출하든 로그인 상태가 서버와 브라우저에서 일관되게 유지되도록 처리하는 것이 중요하다. 정리하면 미리 데이터를 채운 화면과 강한 권한 작업은 서버에서, 사용자 상호작용과 실시간은 클라이언트에서 다루고, 양쪽의 로그인 상태를 맞춘다.
- 서버에서 호출하면 미리 데이터를 채운 화면과 강한 권한 작업에 쓴다.
- 클라이언트에서 호출하면 사용자 상호작용과 실시간에 쓰며 RLS의 통제를 받는다.
- 어느 쪽이든 로그인 상태가 서버와 브라우저에서 일관되게 유지되어야 한다.
10. Vercel 배포와 협업
10.1 Vercel은 무엇이며 배포는 어떻게 동작하는가
Vercel은 프론트엔드 앱, 특히 Next.js 앱을 손쉽게 배포하고 운영하게 해 주는 서비스다. 깃 저장소를 Vercel에 연결해 두면, 코드를 저장소에 올릴 때마다 Vercel이 그 변화를 감지해 자동으로 빌드하고 배포한다. 개발자가 직접 서버를 마련하고 빌드해 올리는 과정을 대신 처리해 준다. Next.js를 만든 회사가 운영하는 서비스라 Next.js와 특히 잘 맞는다.
배포 과정은 대체로 이렇다. 개발자가 코드를 저장소의 주된 가지에 올리면, Vercel이 그 코드를 받아 빌드한다. 빌드가 성공하면 결과물을 전 세계에 흩어진 서버에 올려, 사용자가 가까운 곳에서 빠르게 받게 한다. 빌드에 실패하면 배포가 멈추고 알려 주므로, 잘못된 코드가 실제 서비스에 반영되는 것을 막는다. 이렇게 코드를 올리는 것만으로 배포가 이어지므로, 배포에 드는 수고가 크게 줄어든다.
- Vercel은 깃 저장소와 연결해 코드를 올리면 자동으로 빌드하고 배포한다.
- 결과물을 전 세계 서버에 올려 사용자가 가까운 곳에서 받게 한다.
- 빌드에 실패하면 배포가 멈춰 잘못된 코드의 반영을 막는다.
10.2 미리보기 배포는 무엇이며 협업에서 어떻게 활용하는가
미리보기 배포는 주된 가지가 아니라 작업 중인 가지의 코드를 따로 배포해 주는 기능이다. 새 기능을 별도의 가지에서 만들고 변경 요청을 올리면, Vercel이 그 가지의 코드를 실제 서비스와 분리된 임시 주소에 배포한다. 그래서 아직 합쳐지지 않은 작업물을 실제로 동작하는 화면으로 확인할 수 있다. 코드만 읽는 것보다 직접 눌러 보는 편이 문제를 훨씬 잘 드러낸다.
협업에서 이 기능은 큰 도움이 된다. 리뷰하는 사람이 코드를 내려받아 직접 실행하지 않아도, 미리보기 주소를 열어 변경된 화면을 바로 확인할 수 있다. 기획자나 디자이너도 같은 주소로 결과를 보고 의견을 줄 수 있다. 문제가 있으면 합치기 전에 고치므로, 잘못된 변경이 실제 서비스에 들어가는 것을 막는다. 변경 요청마다 그에 맞는 미리보기가 자동으로 만들어지므로, 별도 준비 없이 작업물을 공유하고 검토할 수 있다.
- 미리보기 배포는 작업 중인 가지를 실제 서비스와 분리해 임시 주소에 배포한다.
- 합치기 전에 동작하는 화면으로 변경을 확인할 수 있다.
- 리뷰어와 기획자가 주소만 열어 검토하고 의견을 줄 수 있다.
10.3 환경 변수는 어떻게 관리하며 환경을 왜 분리하는가
환경 변수는 주소나 키처럼 환경에 따라 달라지는 값을 코드 밖에 두는 것이다. 이런 값을 코드에 직접 적으면, 환경이 바뀔 때마다 코드를 고쳐야 하고 비밀 값이 코드에 노출된다. Vercel에서는 이런 값을 설정에 등록해 두고, 빌드와 실행 때 앱에 전달한다. 그래서 코드에는 값을 적지 않고 변수 이름만 쓰며, 실제 값은 Vercel이 안전하게 보관한다.
환경을 나누는 이유는 개발과 실제 서비스가 같은 자원을 쓰면 위험하기 때문이다. 개발 중에 시험 삼아 데이터를 지우거나 바꾸는 일이 실제 사용자 데이터에 영향을 주면 안 된다. 그래서 실제 서비스용, 미리보기용, 내 컴퓨터의 개발용으로 환경을 나누고, 각 환경에 맞는 값을 따로 둔다. 실제 서비스에서는 운영용 데이터베이스를, 개발에서는 시험용 데이터베이스를 가리키게 한다. 환경을 분리하면 실험이 실제 서비스에 영향을 주지 않아 안전하게 개발할 수 있다.
- 환경 변수는 환경에 따라 달라지는 값을 코드 밖에 두는 것이다.
- Vercel이 값을 안전하게 보관하고 빌드와 실행 때 앱에 전달한다.
- 실제 서비스와 개발 환경을 분리해 실험이 운영에 영향을 주지 않게 한다.
10.4 빌드 시점 환경 변수와 런타임 환경 변수는 어떻게 다른가
환경 변수가 언제 값으로 채워지는지에 따라 다루는 방식이 달라진다. 빌드 시점에 들어가는 변수는 코드를 빌드할 때 그 값이 결과물에 박힌다. 그래서 값을 바꾸려면 다시 빌드해야 반영된다. 브라우저로 보내는 공개 변수는 빌드할 때 결과물에 포함되어 나가므로 이 성격을 띤다. 곧 한 번 빌드되어 사용자에게 나간 값은 다시 빌드하기 전까지 그대로다.
런타임에 읽는 변수는 빌드가 아니라 서버가 실제로 요청을 처리하는 순간에 값을 읽는다. 서버에서만 쓰는 비밀 값이 이렇게 동작할 수 있어, 값을 바꾸면 다시 빌드하지 않고도 반영되기도 한다. 중요한 차이는 브라우저로 나가는 공개 값은 빌드 때 박혀 노출되고, 서버에서만 읽는 값은 사용자에게 나가지 않는다는 것이다. 그래서 비밀 값은 브라우저로 나가지 않는 서버 전용 변수로 두고, 공개해도 되는 값만 빌드 시점에 결과물에 포함시킨다. 값을 바꿨는데 반영되지 않으면 다시 빌드가 필요한지 확인한다.
- 빌드 시점 변수는 빌드 때 결과물에 박혀 바꾸려면 다시 빌드해야 한다.
- 런타임 변수는 서버가 요청을 처리할 때 읽는다.
- 공개 값은 빌드 때 박혀 노출되고 비밀 값은 서버 전용으로 둔다.
10.5 깃을 이용한 협업과 변경 요청 리뷰는 어떻게 이루어지는가
여러 사람이 한 코드를 함께 다룰 때는 깃으로 작업을 나눠 관리한다. 새 기능이나 수정은 주된 가지에서 바로 하지 않고, 별도의 가지를 만들어 그 안에서 작업한다. 그러면 작업 중인 코드가 실제 서비스의 코드와 섞이지 않는다. 작업이 끝나면 그 가지를 주된 가지에 합치자는 변경 요청을 올린다. 변경 요청에는 무엇을 왜 바꿨는지 적어, 동료가 맥락을 이해하게 한다.
변경 요청이 올라오면 동료가 코드를 살펴보고 의견을 단다. 더 나은 방법을 제안하거나 빠진 부분을 짚는다. 이 과정에서 잘못이 합쳐지기 전에 걸러지고, 코드의 품질이 함께 다듬어진다. Vercel의 미리보기 배포와 자동 검사가 이 과정을 돕는다. 변경 요청마다 미리보기가 만들어져 동작을 직접 확인하고, 정해진 검사를 통과해야 합칠 수 있게 한다. 리뷰가 끝나고 검사를 통과하면 주된 가지에 합치고, 그러면 Vercel이 실제 서비스에 배포한다.
- 새 작업은 별도의 가지에서 하고 끝나면 변경 요청을 올린다.
- 동료가 코드를 리뷰해 잘못을 합치기 전에 걸러낸다.
- 미리보기 배포와 자동 검사가 리뷰를 돕고 통과해야 합친다.
10.6 배포 후 문제가 생겼을 때 프론트엔드 개발자는 어떻게 대응하는가
배포한 뒤 문제가 드러나면 가장 먼저 할 일은 사용자에게 미치는 영향을 빠르게 줄이는 것이다. Vercel은 이전의 정상 배포로 되돌리는 기능을 제공한다. 새 배포에서 심각한 문제가 생기면 원인을 천천히 찾기 전에 먼저 이전 배포로 되돌려 서비스를 정상으로 돌려놓는다. 되돌리기는 빠르게 할 수 있어, 문제의 영향을 줄이는 가장 즉각적인 방법이다.
서비스를 안정시킨 다음 원인을 찾는다. 어떤 오류가 났는지 기록을 살피고, 어떤 변경에서 문제가 시작됐는지 추적한다. 화면에서 나는 오류는 사용자 환경에서 일어나므로, 오류를 수집해 두는 도구가 있으면 원인 파악이 쉽다. 원인을 고친 뒤에는 미리보기에서 충분히 확인하고 다시 배포한다. 같은 문제가 되풀이되지 않도록, 빠졌던 검사를 더하거나 확인 절차를 보완하는 것까지가 대응의 마무리다. 곧 먼저 되돌려 영향을 줄이고, 원인을 찾아 고치고, 재발을 막는 순서로 대응한다.
- 심각한 문제는 먼저 이전 정상 배포로 되돌려 영향을 줄인다.
- 서비스를 안정시킨 뒤 기록과 변경 이력으로 원인을 찾는다.
- 고친 뒤 미리보기로 확인해 배포하고 재발 방지까지 마무리한다.






댓글
댓글을 작성하려면 이 필요합니다.