제대로 파는 HTML & CSS - 2주차 진도 시청하기
안녕하세요. 프론트엔드 스터디 2주차에 오신 것을 환영합니다. 이번 주차에서는 사용자로부터 정보를 입력받는 폼(Form) 태그들과, 화면을 꾸미기 위한 첫 걸음인 CSS 연결 방법 및 선택자 규칙을 배웠습니다. 지금까지 "보여주기만" 하던 페이지에서, 드디어 사용자와 인터렉션하는 페이지를 만들 수 있게 된 거예요.
아래 10문제를 막힘없이 풀 수 있다면,
이번 주 학습 영상을 생략하거나, 필요한 부분만 선택적으로 시청하셔도 좋습니다.
바로 하단의 제가 추가로 알면 좋은 개념들을 확인해보세요.
폼을 처음 배울 때 id와 name이 왜 둘 다 필요한지 헷갈리는 경우가 많습니다.
핵심만 정리하면 이렇습니다.
id: 페이지 안에서 요소를 유일하게 식별하는 용도입니다. <label>의 for 속성이 id를 참조해서 연결되고, CSS나 JavaScript에서 특정 요소를 잡을 때도 씁니다.name: 폼 데이터를 서버로 전송할 때 "키(key)" 역할을 합니다. 서버는 name 값을 기준으로 어떤 데이터인지를 구분합니다.<form action="/login" method="POST">
<label for="user-email">이메일</label>
<input id="user-email" name="email" type="email" />
<label for="user-pw">비밀번호</label>
<input id=user-pw
위 폼에서 제출 버튼을 누르면 서버에는 email=입력값&password=입력값 형태로
데이터가 전달됩니다. 만약 name 속성이 없으면 해당 입력값은
서버로 전송되지 않습니다. 눈에 보이지만 실제로는 무시되는
셈이죠.
<form>의 method 속성에는 GET과 POST 두 가지를 지정할 수 있습니다. 둘의 차이는 데이터를 어디에 실어 보내느냐입니다.
?email=test@a.com&password=1234처럼 노출됩니다.
검색, 필터링처럼 민감하지 않은 데이터를 전달할 때 사용합니다.<!-- 검색: GET이 적합 -->
<form action="/search" method="GET">
<input name="q" type="text" placeholder="검색어 입력" />
<button type="submit">검색</button>
</form>
<!-- 로그인: POST가 적합 -->
<form action="/login" method=POST
"그러면 그냥 전부 POST로 보내면 안 되나?" 라는 의문이 들 수 있습니다. 기능적으로는 가능하지만, GET에는 POST가 대체할 수 없는 고유한 장점이 있습니다.
?q=맛집처럼 검색
조건이 URL에 담기기 때문에, 그 URL을 친구에게 보내면 똑같은 검색 결과를 볼 수
있습니다. POST는 URL에 정보가 없어서 링크 공유가 불가능합니다.팁: "주소창에 보여도 괜찮은가?" 괜찮다면 GET, 아니라면 POST입니다.
회원가입 폼처럼 입력 필드가 많아지면, 관련된 항목끼리 시각적·의미적으로 그룹을 지어주는 것이 좋습니다. <fieldset>과 <legend>가 바로 그 역할을 합니다.
<form action="/register" method="POST">
<fieldset>
<legend>기본 정보</legend>
<label for="reg-name">이름</label>
<input id="reg-name" name="username" type="text" />
<label for="reg-email이메일
<fieldset>은 시각적으로 테두리를 그려 그룹을 보여주고, <legend>는 그 그룹의 제목 역할을 합니다. 스크린 리더는 <legend>를 먼저 읽어 주기 때문에 접근성에도 큰 도움이 됩니다.
CSS를 HTML에 연결하는 방법은 세 가지가 있습니다. 각각의 특징과 실무에서 어떤 것을 권장하는지 알아봅시다.
<!-- 1. 인라인 스타일: 태그에 직접 작성 -->
<p style="color: red; font-size: 16px;">빨간 글씨</p>
<!-- 2. 내부 스타일 시트: <head> 안에 <style> 태그 사용 -->
<head>
<style>
p {
color: blue;
}
</style>
</head>
<!-- 3. 외부 스타일 시트: 별도 .css 파일을 <link>로 연결 (가장 권장) -->
실무에서는 거의 대부분 외부 스타일 시트를 사용합니다. HTML과 CSS를 분리하면 한 파일의 수정이 연결된 모든 페이지에 즉시 반영되므로, 유지보수가 압도적으로 편해집니다. 인라인 스타일은 우선순위가 가장 높아 디버깅이 어려워지기 때문에 가급적 피하는 것이 좋습니다.
CSS의 핵심은 "어떤 요소에 스타일을 적용할 것인가"를 정하는 선택자(Selector) 규칙입니다. 자주 쓰이는 선택자들을 정리해보겠습니다.
/* 1. 태그 선택자: 해당 태그 전부 */
p {
color: gray;
}
/* 2. 클래스 선택자: 마침표(.)로 시작 */
.highlight {
background-color: yellow;
}
/* 3. 아이디 선택자: 샵(#)으로 시작, 페이지당 하나만 */
#main-title {
font-size: 32px;
}
/* 4. 자손 결합자: 공백으로 내부 요소 선택 */
nav a {
text-decoration: none;
}
/* 5. 자식 결합자: >로 직계 자식만 선택 */
ul > li {
list-style: square;
}
/* 6. 그룹 선택자: 쉼표로 여러 선택자에 같은 스타일 */
h1,
우선순위(Specificity) 규칙을 꼭 기억하세요. 인라인 스타일 > 아이디(#) > 클래스(.) > 태그 순입니다. 같은 우선순위라면 나중에 작성된 스타일이 적용됩니다. 이 원리를 모르면 "분명 CSS를 썼는데 왜 안 먹히지?"라는 상황을 자주 마주하게 됩니다.
CSS 속성 중에는 모든 브라우저가 동시에 지원하지 않는 것들이 있습니다. 새로운 CSS 기능이 표준으로 확정되기 전, 각 브라우저가 실험적으로 먼저 구현할 때 벤더 프리픽스(Vendor Prefix)를 붙여서 제공합니다.
/* 과거에는 이렇게 브라우저별로 따로 작성해야 했습니다 */
.box {
-webkit-transition: all 0.3s; /* Chrome, Safari */
-moz-transition: all 0.3s; /* Firefox */
-ms-transition: all 0.3s; /* IE, Edge */
-o-transition: all 0.3s; /* Opera */
transition: all 0.3s; /* 표준 */
}
-webkit-: Chrome, Safari, Edge(Chromium 기반) 등
WebKit/Blink 엔진 브라우저-moz-: Firefox (Gecko 엔진)-ms-: Internet Explorer, 구 Edge-o-: Opera (현재는 Chromium 기반이라 -webkit- 사용)다행히 지금은 대부분의 CSS 속성이 표준화되어, 벤더 프리픽스 없이도 잘 작동합니다. 하지만 일부 속성은 여전히 프리픽스가 필요한데, 이것을 일일이 외워서 작성할 필요는 없습니다. Autoprefixer라는 도구가 빌드 과정에서 자동으로 필요한 프리픽스를 추가해주기 때문입니다.
/* 개발자가 작성하는 코드 */
.box {
user-select: none;
}
/* Autoprefixer가 빌드 시 자동으로 변환 */
.box {
-webkit-user-select: none; /* Safari */
user-select: none;
}
어떤 CSS 속성이 어떤 브라우저에서 지원되는지 확인하려면 Can I Use 사이트를 활용하세요. 속성명을 검색하면 브라우저별 지원 현황을 한눈에 볼 수 있습니다.
Vite, Next.js 같은 모던 빌드 도구에는 PostCSS와 Autoprefixer가 기본으로
포함되어 있어서, 벤더 프리픽스를 직접 작성할 일이 거의 없습니다. Tailwind
CSS도 내부적으로 Autoprefixer를 사용하므로 select-none 같은 유틸리티
클래스만 쓰면 프리픽스는 자동 처리됩니다. 원리만 알아두고, 실무에서는 도구에
맡기면 됩니다.
7. CSS 변수 (Custom Properties) — 색상과 간격을 한 곳에서 관리하기
프로젝트가 커지면 같은 색상 코드(#3b82f6)를 수십 곳에서 반복 사용하게
됩니다. 나중에 브랜드 색상을 바꾸려면 모든 파일을 찾아다니며 수정해야 하겠죠?
CSS 변수를 사용하면 한 곳에서 값을 정의하고, 여러 곳에서 참조
할 수 있습니다.
/* :root에 변수 정의 (전역) */
:root {
--color-primary: #3b82f6;
--color-gray: #6b7280;
--spacing-md: 16px;
--radius-lg: 12px;
}
/* 변수 사용 */
.button {
background-color: var(--color-primary);
padding: var(--spacing-md);
border-radius: var(--radius-lg);
color: white;
}
.link
--로 시작하는 이름이 CSS 변수이고, var() 함수로 값을 불러옵니다. :root에
정의하면 페이지 전체에서 사용할 수 있고, 특정 요소 안에 정의하면 그 요소와
자식들에서만 사용됩니다.
CSS 변수의 가장 강력한 점은 JavaScript로 런타임에 값을 바꿀 수 있다는 것입니다. 이를 활용하면 다크 모드 전환도 간단해집니다.
/* 라이트 모드 (기본) */
:root {
--bg-color: #ffffff;
--text-color: #1a1a1a;
}
/* 다크 모드 */
:root[data-theme="dark"] {
--bg-color: #1a1a1a;
--text-color: #f0f0f0;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
}
Tailwind CSS v4는 내부적으로 CSS 변수를 적극 활용합니다. tailwind.config에서
정의한 색상, 간격, 폰트 등이 자동으로 CSS 변수로 생성되어
var(--color-blue-500) 같은 형태로 접근할 수 있습니다. React에서 인라인
스타일로 CSS 변수를 참조하는 것도 흔한 패턴입니다. style={{ color: 'var(--color-primary)' }}처럼요.
5번 섹션에서 배운 선택자는 "어떤 요소"를 선택하는 것이었다면,
의사 클래스(Pseudo-class)는 요소의 특정 상태
를 선택합니다. 콜론(:) 하나를 붙여서 사용합니다.
/* 마우스를 올렸을 때 */
.button:hover {
background-color: #2563eb;
}
/* 클릭하는 순간 */
.button:active {
transform: scale(0.98);
}
/* 키보드 포커스가 갔을 때 (Tab 키로 이동) */
.input:focus {
outline: 2px solid #3b82f6;
border-color: #3b82f6;
}
/* 첫 번째 자식 요소 */
li:first-child {
font-weight: bold;
}
/* 마지막 자식 요소 */
li
실무에서 가장 자주 쓰이는 의사 클래스를 정리하면 이렇습니다.
| 의사 클래스 | 용도 | 예시 |
|---|---|---|
:hover | 마우스를 올렸을 때 | 버튼 배경색 변경 |
:focus | 포커스가 갔을 때 | input 테두리 강조 |
:active | 클릭하는 순간 | 버튼 눌림 효과 |
참고로 2번 섹션에서 배운 ::before, ::after는 콜론이 두 개인
의사 요소(Pseudo-element)이고, 여기서 다루는 :hover,
:focus 등은 콜론이 하나인 의사 클래스(Pseudo-class)입니다.
이름이 비슷하지만 역할이 다릅니다. 의사 요소는 "가상의 요소를 생성"하고, 의사
클래스는 "기존 요소의 특정 상태를 선택"합니다.
Tailwind CSS에서는 의사 클래스를 접두사로 간단히 적용합니다.
hover:bg-blue-700, focus:ring-2, first:font-bold, disabled:opacity-50
같은 형태입니다. React에서는 onMouseEnter, onFocus 같은 이벤트 핸들러로
상태를 제어하는 방식도 쓰이지만, 단순한 시각적 변화는 CSS 의사 클래스가 훨씬
간결하고 성능도 좋습니다.
9. 폼 유효성 검증 — JavaScript 없이 입력값 검사하기
2주차에서 폼을 만드는 법을 배웠는데, 사용자가 필수 항목을 빈칸으로 두거나 잘못된 형식으로 입력하면 어떻게 할까요? HTML5부터는 JavaScript 없이도 기본적인 유효성 검증이 가능합니다.
<form action="/register" method="POST">
<!-- 필수 입력 -->
<input name="username" type="text" required placeholder="이름 (필수)" />
<!-- 이메일 형식 자동 검증 -->
<input name="email" type="email" required placeholder="이메일" />
<!-- 최소/최대 글자 수 제한 -->
<input
| 속성 | 용도 | 예시 |
|---|---|---|
required | 필수 입력 | 빈칸이면 제출 불가 |
minlength / maxlength | 글자 수 제한 | 비밀번호 최소 8자 |
min / max | 숫자/날짜 범위 |
datalist — 자동완성 드롭다운<select>는 정해진 옵션 중에서만 선택해야 하지만, <datalist>는 추천
목록을 보여주면서도 직접 입력도 가능합니다. 검색창의 자동완성처럼
동작합니다.
<label for="lang">사용 가능한 프로그래밍 언어</label>
<input
list="languages"
id="lang"
name="language"
placeholder="언어 선택 또는 입력"
/>
<datalist id="languages">
<option value="JavaScript"></option
<input>의 list 속성과 <datalist>의 id를 일치시키면 연결됩니다.
사용자가 글자를 입력하면 매칭되는 옵션이 자동으로 필터링되어 보여집니다.
React에서는 폼 유효성 검증을 React Hook Form이나 Zod 같은 라이브러리로 처리하는 것이 일반적입니다. HTML 기본 유효성 검증보다 훨씬 세밀한 제어가 가능하고, 에러 메시지를 커스텀하기도 쉽습니다. 하지만 HTML 기본 검증은 JavaScript가 로드되기 전에도 동작하므로, 두 방식을 함께 사용하는 것이 가장 견고합니다.
2주차에서 배운 <form action="/login" method="POST">처럼 HTML 폼이 직접 서버에
데이터를 전송하는 방식은, 요즘 실무에서는 거의 사용하지 않습니다.
왜일까요?
핵심 이유는 페이지 전체가 새로고침된다는 점입니다. 폼을 제출하면 브라우저가 서버에 요청을 보내고, 서버가 새 HTML 페이지를 통째로 돌려줍니다. 로그인 버튼을 눌렀을 때 화면 전체가 깜빡이며 바뀌는 그 방식입니다. 사용자 경험이 떨어지고, 서버도 매번 전체 HTML을 렌더링해야 하므로 비효율적입니다.
반면 요즘 웹 앱은 버튼을 눌러도 화면이 깜빡이지 않고, 필요한 데이터만 주고받습니다. 이것이 가능한 이유가 바로 JavaScript에서 HTTP 요청을 직접 보내는 방식이기 때문입니다.
<!-- 구식: 폼 제출 → 페이지 전체 새로고침 -->
<form action="/login" method="POST">
<input name="email" type="email" />
<button type="submit">로그인</button>
</form>
// 요즘 방식: JS에서 직접 요청 → 화면은 그대로, 데이터만 교환
async function handleLogin(email, password) {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
// 로그인 성공 시 화면 업데이트
}
여기서부터는 아직 배우지 않은 내용이지만, HTTP 요청을 보내는 방법과 트렌드에 대해 간단히 소개하겠습니다. 스킵하셔도 무방합니다. JavaScript에서 HTTP 요청을 보내는 대표적인 두 가지 방법이 있습니다.
fetch는 브라우저에 내장된 API입니다. 별도 설치 없이 바로 쓸
수 있고, Promise 기반으로 동작합니다. 다만 몇 가지 불편한 점이 있는데, 에러
상태(400, 500 등)에서도 Promise가 reject되지 않아 직접 체크해야 하고,
요청마다 headers나 body: JSON.stringify(...) 를 반복해서 써야 합니다.
axios는 오랫동안 가장 많이 쓰인 HTTP 클라이언트
라이브러리입니다. 400·500 에러를 자동으로 reject해주고, 요청·응답 인터셉터,
자동 JSON 변환 등 편의 기능이 풍부합니다. 그러나 지금은
레거시 느낌이 강해지고 있습니다. 번들 크기가 있고, fetch가
표준으로 자리잡으면서 굳이 써야 할 이유가 줄었기 때문입니다.
// fetch: 내장 API, 에러 처리를 직접 해야 함
const res = await fetch("/api/users");
if (!res.ok) throw new Error("요청 실패"); // 이걸 안 하면 400/500도 그냥 통과
const data = await res.json();
// axios: 설치 필요, 에러는 자동 throw, JSON 변환도 자동
const { data } = await axios.get("/api/users");
최근에는 fetch의 단점을 보완한 경량 라이브러리들이 주목받고 있습니다.
ky는 브라우저·Edge Runtime용 fetch 래퍼입니다. fetch와 거의 동일한 인터페이스를 유지하면서, HTTP 에러 자동 throw, JSON 자동 파싱, 재시도(retry) 기능 등을 추가했습니다. 번들 크기가 매우 작아 프론트엔드에 적합합니다.
got은 Node.js 서버 환경에 특화된 라이브러리입니다. 스트리밍, 페이지네이션, 강력한 재시도 로직 등 서버 사이드에서 필요한 기능이 풍부합니다.
// ky: fetch 인터페이스 그대로, 편의 기능 추가 (프론트/Edge)
import ky from "ky";
const data = await ky.get("/api/users").json(); // 에러 자동 throw, JSON 자동 파싱
// got: Node.js 서버 환경에 최적화
import got from "got";
const data = await got("/api/users").json();
Next.js App Router 환경에서는 선택이 더 중요해집니다. Next.js는 서버 컴포넌트에서 직접 데이터를 페치하는 구조이기 때문에, 브라우저 전용 라이브러리를 서버에서 쓰면 문제가 생깁니다.
fetch를 그대로 사용하는
것이 가장 권장됩니다. Next.js의 fetch는 캐싱·재검증(revalidate) 기능이
내장되어 있어, 같은 요청을 중복 실행하지 않고 자동으로 최적화해줍니다.
got이나 axios를 쓰면 이 캐싱 혜택을 받을 수 없습니다. -
클라이언트 컴포넌트: 브라우저에서 실행되므로 fetch, ky,
axios 모두 사용할 수 있습니다. 보통 TanStack Query(React Query)와 함께
써서 로딩·에러 상태 관리를 자동화합니다.정리하면, axios는 레거시가 되어가고 있고, 서버에서는 Next.js
내장 fetch, 클라이언트에서는 ky 같은 경량 래퍼가 트렌드입니다. 지금 당장
외울 필요는 없지만, "폼이 직접 서버에 데이터를 보내는 시대는 지났다"는 흐름은
기억해두세요.
Q1. 네이버나 구글에서 검색을 해보고, 주소창 URL을 확인해보세요. 어떤 방식(GET/POST)을 사용하고 있나요? 그리고 왜 로그인 폼은 같은 방식을 쓰지 않는다고 생각하나요?
Q2. 아래 CSS 코드에서
.btn텍스트는 최종적으로 무슨 색이 될까요? 그리고 왜 그 색이 적용되는지, 어떤 규칙 때문인지 설명해보세요.cssp { color: green; } .btn { color: red; } #submit { color: blue; }html<p class="btn" id="submit">제출</p>
:first-child | 첫 번째 자식 | 목록 첫 항목 스타일 |
:last-child | 마지막 자식 | 목록 마지막 구분선 제거 |
:nth-child(n) | n번째 자식 | 표 줄무늬 배경 |
:disabled | 비활성화된 요소 | 비활성 버튼 회색 처리 |
:not() | 특정 조건 제외 | .item:not(:last-child) |
| 나이 1~150 |
pattern | 정규 표현식 매칭 | 전화번호 형식 |
type | 입력 형식 자동 검증 | email, url, number |