
1주차 세미나에서 처음으로 HTML과 CSS를 배웠다.
과제는 HTML과 CSS만으로(JS 금지) 자기소개 페이지를 만드는 것이었다.
헤더, 사이드 목차, 포토 덤프, 사진첩, 관심사 카드, 편지, 푸터까지 7개 영역을 구현해야 했고,
기본 과제를 모두 끝낸 뒤 심화 과제를 할 수 있는 만큼 하나씩 얹는 방식으로 진행했다.
처음 배운 개념을 하나씩 적용하면서 페이지를 완성했지만, 가장 오래 붙잡고 있었던 건 새로운 개념이 아니라 에러 메시지 하나 없이 화면만 이상하게 나오는 상황이었다.
이 글에서는 먼저 기본 과제와 심화 과제에 사용한 개념을 정리하고, 그 다음 직접 겪은 두 가지 버그를 통해 "CSS는 왜 틀려도 조용할까?"를 파고들어 보려고 한다.

| 개념 | 사용한 곳 | 왜 썼는가 |
|---|---|---|
<!DOCTYPE html>, lang="ko" | 문서 최상단 | 브라우저를 표준 모드로 렌더링. 스크린 리더와 번역기에 문서 언어 안내 |
meta viewport | <head> | 이게 없으면 모바일에서 반응형 미디어 쿼리가 의도대로 동작하지 않음 |
| 시맨틱 태그 | header, nav, main, section, article, figure, footer | 영역의 의미를 드러내서 검색 엔진과 보조 기술이 구조를 이해 |
div | 목차와 본문을 감싸는 .container | 의미 없이 배치만을 위한 래퍼라서 시맨틱 태그 대신 div 사용 |
alt | 모든 이미지 | 이미지를 볼 수 없는 사용자를 위한 대체 텍스트 |
앵커 링크 href="#id" | 사이드 목차 | 클릭한 섹션으로 이동 |
관심사 카드는 하나하나가 독립적인 콘텐츠라서 article을 썼다.
"이 태그는 무슨 의미인가?"를 고민하면서 태그를 고르는 게 생각보다 재밌었다.
| 개념 | 왜 썼는가 |
|---|---|
| 외부 스타일 시트 분리 | 섹션별로 header.css, navigation.css 등으로 나눔. <link> 순서가 곧 cascade에서의 선언 순서 |
| 전체 선택자 리셋 | 브라우저 기본 margin, padding을 초기화해서 의도하지 않은 여백 삭제 |
box-sizing: border-box | width에 padding과 border까지 포함시켜 크기 계산을 직관적으로 만듦 |
| 스티키 푸터 | body에 display: flex; flex-direction: column; min-height: 100vh, 본문에 flex: 1을 줘서 내용이 적어도 푸터가 바닥에 붙게 함 |
헤더
position: fixed + z-index로 스크롤과 무관하게 상단에 고정grid-template-columns: repeat(3, minmax(0, 1fr))로 정확히 3등분1fr은 "내용보다 작아지지 않는다"는 최소값이 숨어 있어서, 제목이 길면 가운데 칸이 넓어질 수 있음minmax(0, 1fr)로 최소값을 0으로 지정하면 내용과 무관하게 1/3을 유지사이드 목차
position: sticky로 스크롤을 따라오게 함align-items: stretch 때문에 목차가 본문 높이만큼 늘어나서 움직일 여유 공간이 없었기 때문align-items: start로 해결scroll-margin-top으로 앵커 이동 시 섹션 제목이 고정 헤더에 가려지지 않게 보정:hover로 마우스를 올렸을 때 배경색이 바뀌게 함포토 덤프
grid-template-areas로 그림을 그리듯 배치7fr 7fr 6fr 8fr이었다. 전체 비율은 aspect-ratio: 15 / 8로 고정사진첩
display: flex + overflow-x: auto + scroll-snap-type: x mandatory로 가로 스크롤과 스냅을 구현flex-shrink: 0이 핵심. 기본값 1이면 사진이 컨테이너에 맞춰 줄어들어서 스크롤 자체가 생기지 않음scrollbar-width: none과 ::-webkit-scrollbar { display: none; }으로 숨김. 브라우저마다 방식이 달라서 두 가지를 함께 써야 함관심사 카드
1200px 이하, 992px 이하, 768px 이하 조건이 모두 참인데, 구체성이 같으니 마지막에 선언된 2열이 이기기 때문aspect-ratio: 1 / 1 + object-fit: cover로 썸네일 크기를 통일하고 찌그러짐을 막음margin-top: auto를 줘서 제목 길이가 달라도 버튼 줄이 맞도록 구현To. 웹토리
::before, ::after 가상 요소와 content로 HTML을 건드리지 않고 문장 앞뒤에 하트를 붙임심화 과제는 대부분 마우스를 올렸을 때의 반응이었다.
기본 과제 코드를 건드리지 않고, 각 CSS 파일에 규칙을 추가하는 방식으로 작업했다.
같은 선택자를 다시 쓰면 기존 속성은 유지되고 새 속성만 더해지며, 겹치는 속성은 나중에 선언된 쪽이 이긴다.
.avatar {
transition: box-shadow 0.3s;
}
.avatar:hover {
box-shadow: 0 4px 12px rgba(19, 112, 106, 0.5);
}
box-shadow의 값은 순서대로 가로 이동, 세로 이동, 흐림 정도, 색transition이 없으면 그림자가 순간적으로 생김. 0.3초에 걸쳐 바뀌게 하니 훨씬 자연스러움소셜 링크는 <a>에 다음 두 속성을 함께 줬다.
<a href="https://github.com/아이디" target="_blank" rel="noopener noreferrer">
target="_blank": 새 탭에서 염rel="noopener noreferrer": 새로 열린 페이지가 window.opener로 원래 페이지에 접근하지 못하게 막음.side-nav li:first-child a {
border-radius: 16px 16px 0 0;
}
.side-nav li:last-child a {
border-radius: 0 0 16px 16px;
}
border-radius에 값을 4개 주면 왼쪽 위 → 오른쪽 위 → 오른쪽 아래 → 왼쪽 아래(시계 방향)로 적용border-radius: 10px가 그대로 남아 네 모서리 모두 둥글게 구현.side-nav a의 구체성은 (0, 0, 1, 1)이고, .side-nav li:first-child a는 클래스 1개 + 가상 클래스 1개 + 태그 2개라 (0, 0, 2, 2). 구체성이 더 높아서 기본 값을 덮어쓸 수 있음first-child이면서 last-child. 이때는 나중에 선언된 last-child가 이겨서 위쪽 모서리가 각지게 됨. li:only-child a { border-radius: 16px; }를 추가하면 대비할 수 있음.photo {
overflow: hidden; /* 기본 과제에서 이미 넣어 둔 속성 */
}
.photo img {
transition: transform 0.4s;
}
.photo:hover img {
transform: scale(1.1);
}
figure에 overflow: hidden이 있어서, 1.1배로 커진 사진 중 칸을 벗어난 부분은 잘려 보이지 않음width로 키우면 레이아웃을 다시 계산하는 리플로우가 일어남. transform은 세미나에서 배운 렌더링 과정 중 합성(Composite) 단계에서 처리되어, 리플로우와 리페인트를 거치지 않아 훨씬 부드러움transform으로 구현가장 오래 고민한 심화 과제. 원리는 생각보다 단순했다.
<div class="banner">
<div class="banner-track">
<div class="banner-group"> <!-- 사진 묶음 A --> </div>
<div class="banner-group" aria-hidden="true"> <!-- A와 똑같은 복제본 --> </div>
</div>
</div>
.banner {
overflow: hidden;
}
.banner-track {
display: flex;
width: max-content;
animation: slide 30s linear infinite;
}
.banner-group {
display: flex;
gap: 16px;
padding-right: 16px;
}
@keyframes slide {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.banner는 창문, .banner-track은 긴 필름. 필름에는 똑같은 사진 묶음 2개가 이어 붙어 있음width: max-content: 필름 길이를 "사진을 전부 한 줄로 놓은 길이"로 정해야 -50%가 정확히 묶음 하나 길이가 됨padding-right: 16px: 묶음 안의 사진 간격은 gap이 만들지만, 묶음과 묶음 사이 이음새에는 간격이 없음. 그래서 이음새에서만 사진이 붙어 보이며 덜컹거렸고, 묶음 끝에 gap과 같은 16px을 더해 해결linear: 기본값 ease는 시작과 끝에서 느려짐. 무한 반복에서는 매 바퀴마다 속도가 출렁이므로 일정한 속도인 linear를 써야 함aria-hidden="true"와 빈 alt=""를 줘서 스크린 리더가 같은 사진을 두 번 읽지 않게 구현.banner:hover .banner-track { animation-play-state: paused; }로 마우스를 올리면 멈춰서 사진을 볼 수 있게 구현@media (prefers-reduced-motion: reduce)에서는 애니메이션 제거. 운영체제에서 "동작 줄이기"를 켠 사용자에게 계속 움직이는 화면은 불편할 수 있기 때문.card {
transition: transform 0.3s, box-shadow 0.3s;
}
.card:hover {
transform: translateY(-6px);
box-shadow: 0 12px 24px rgba(19, 112, 106, 0.2);
}
.btn-like,
.btn-dislike {
transition: background-color 0.3s, color 0.3s;
}
.btn-like:hover {
background-color: #3b8fd0;
color: #ffffff;
}
transition: all 0.3s로 한 번에 쓸 수도 있지만, 실제로 바뀌는 속성만 나열. all은 의도하지 않은 속성 변화까지 애니메이션되고, 어떤 속성이 움직이는지 코드만 보고 알기 어렵기 때문개념을 적용하는 것보다 더 오래 걸린 건 아무 에러도 없는데 화면이 이상한 상황이었다.
시안대로라면 위 3칸, 아래 큰 칸과 작은 칸 2개로 나뉘어야 했는데, 사진 6장이 가로 한 줄로 늘어서 있었다.

grid-template-areas 문자열을 몇 번이나 다시 봤지만 문제가 없었다. 원인은 첫 줄이었다.
.photo-dump {
display: flex; /* grid가 아니라 flex */
grid-template-columns: 7fr 7fr 6fr 8fr;
grid-template-areas:
"a b c c"
"d d d e"
"d d d f";
gap: 16px;
}
grid-template-columns, grid-template-areas, grid-area는 부모가 display: grid일 때만 동작한다.
flex 컨테이너에서는 문법이 맞아도 아무 효과가 없다.
결국 flex의 기본 방향인 가로 한 줄로 나열되고, gap만 적용된 것이었다.
display: grid로 한 단어를 고치자 시안과 같은 배치가 나왔다.

관심사 카드가 넓은 화면에서 5열이 아니라 1열로 세로로 쌓였다.
그런데 이상하게도 창을 줄이면 4열, 3열, 2열은 정상으로 나왔다.

.cards {
display: grid;
grid-template-coulmns: repeat(5, minmax(0, 1fr)); /* columns 오타 */
gap: 16px;
}
@media (max-width: 1200px) {
.cards {
grid-template-columns: repeat(4, minmax(0, 1fr)); /* 여기는 철자가 맞다 */
}
}
columns를 coulmns로 적은 오타였다.
기본 규칙의 열 정의가 무시되니 넓은 화면에서는 grid 기본값인 1열이 되고,
미디어 쿼리 안은 철자가 맞았기 때문에 좁은 화면에서만 정상이었던 것이다.

두 버그 모두 콘솔에 빨간 에러 하나 뜨지 않았다.
JS였다면 오타 하나에 에러가 났을 텐데, CSS는 왜 이렇게 조용할까?
CSS 명세에는 파싱 오류를 처리하는 규칙이 정해져 있다.
브라우저는 모르는 속성 이름이나 잘못된 값을 만나면, 그 선언 하나만 버리고 다음 선언을 계속 읽는다.
페이지 전체를 멈추지 않는다.
그래서 grid-template-coulmns는 "모르는 속성"으로 취급되어 그 줄만 사라졌고,
나머지 display: grid, gap은 그대로 적용됐다.
이유는 호환성이다.
웹 페이지는 수많은 종류와 버전의 브라우저에서 열린다.
새 CSS 속성이 나올 때마다 옛 브라우저가 "모르는 속성이네? 에러!"를 내고 멈춘다면,
새 기능을 쓰는 순간 옛 브라우저 사용자의 화면이 통째로 깨질 것이다.
모르는 것을 조용히 건너뛰기 때문에 이런 작성이 가능하다.
.page {
min-height: 100vh; /* 모든 브라우저가 아는 값 */
min-height: 100dvh; /* 새 단위. 모르는 브라우저는 이 줄만 무시하고 위 값을 쓴다 */
}
새 기능을 쓰면서도 옛 브라우저를 위한 대비책을 남겨 둘 수 있는 것이다.
cascade에서 나중에 선언된 값이 이긴다는 규칙과 맞물려, 아는 브라우저는 새 값을, 모르는 브라우저는 이전 값을 사용한다.
두 사건을 다시 보면, 사실 성격이 조금 다르다.
사건 1 (display: flex) | 사건 2 (coulmns) | |
|---|---|---|
| 선언 자체 | 문법적으로 올바름 | 존재하지 않는 속성이라 무효 |
| 브라우저 처리 | 읽긴 하지만 flex 컨테이너라서 효과가 X | 파싱 단계에서 버려짐 |
| 결과 | grid 속성 전체가 무력화 | 그 한 줄만 사라짐 |
즉 CSS에서 "적용이 안 된다"에는 틀려서 버려진 경우와 맞지만 조건이 안 맞아 효과가 없는 경우가 있다.
에러가 안 뜨니, 직접 찾는 도구를 써야 한다.
grid 배지가 붙음. 배지가 없다면 grid가 아니라는 뜻(사건 1). 배지를 누르면 격자선과 영역 이름이 화면에 겹쳐 보임Unknown property라고 알려줌(사건 2)번외로, 코드를 다 썼는데 브라우저에 완전히 빈 화면이 나온 적도 있었다.
원인은 VS Code에서 파일을 저장하지 않은 것이었다.
브라우저도, Git도 편집기 화면이 아니라 디스크에 저장된 파일을 읽는다.
그래서 빈 파일이 그대로 커밋되기까지 했다.
이후로는 VS Code의 Auto Save를 켜고, 커밋 전에 git diff로 내가 쓴 코드가 초록 줄로 보이는지 확인하는 습관을 들였다.
처음 HTML과 CSS를 배우면서 느낀 건,
CSS는 틀렸다고 말해 주지 않는 대신 틀려도 페이지가 끝까지 살아남도록 설계된 언어라는 점이다.
그 덕분에 웹은 수많은 브라우저에서 깨지지 않고 동작하지만,
작성하는 입장에서는 "왜 안 되지?"를 스스로 찾아내야 한다.
심화 과제를 하면서는 "어떻게 움직이게 할까"보다
어떤 속성으로 움직이게 해야 브라우저가 덜 힘들까(transform vs width),
움직임이 불편한 사람은 없을까(prefers-reduced-motion)를 함께 고민하게 됐다.
이번 과제를 통해 개념보다 개발자 도구로 원인을 추적하는 습관을 얻을 수 있었다.