CSS는 왜 틀려도 아무 말을 하지 않을까? - 1주차 HTML/CSS 과제 회고

김희수·4일 전
post-thumbnail

들어가며

1주차 세미나에서 처음으로 HTML과 CSS를 배웠다.
과제는 HTML과 CSS만으로(JS 금지) 자기소개 페이지를 만드는 것이었다.
헤더, 사이드 목차, 포토 덤프, 사진첩, 관심사 카드, 편지, 푸터까지 7개 영역을 구현해야 했고,
기본 과제를 모두 끝낸 뒤 심화 과제를 할 수 있는 만큼 하나씩 얹는 방식으로 진행했다.

처음 배운 개념을 하나씩 적용하면서 페이지를 완성했지만, 가장 오래 붙잡고 있었던 건 새로운 개념이 아니라 에러 메시지 하나 없이 화면만 이상하게 나오는 상황이었다.

이 글에서는 먼저 기본 과제와 심화 과제에 사용한 개념을 정리하고, 그 다음 직접 겪은 두 가지 버그를 통해 "CSS는 왜 틀려도 조용할까?"를 파고들어 보려고 한다.


1. 기본 과제에 사용한 HTML, CSS 개념

HTML: 문서의 구조와 의미

개념사용한 곳왜 썼는가
<!DOCTYPE html>, lang="ko"문서 최상단브라우저를 표준 모드로 렌더링. 스크린 리더와 번역기에 문서 언어 안내
meta viewport<head>이게 없으면 모바일에서 반응형 미디어 쿼리가 의도대로 동작하지 않음
시맨틱 태그header, nav, main, section, article, figure, footer영역의 의미를 드러내서 검색 엔진과 보조 기술이 구조를 이해
div목차와 본문을 감싸는 .container의미 없이 배치만을 위한 래퍼라서 시맨틱 태그 대신 div 사용
alt모든 이미지이미지를 볼 수 없는 사용자를 위한 대체 텍스트
앵커 링크 href="#id"사이드 목차클릭한 섹션으로 이동

관심사 카드는 하나하나가 독립적인 콘텐츠라서 article을 썼다.
"이 태그는 무슨 의미인가?"를 고민하면서 태그를 고르는 게 생각보다 재밌었다.

CSS: 공통

개념왜 썼는가
외부 스타일 시트 분리섹션별로 header.css, navigation.css 등으로 나눔. <link> 순서가 곧 cascade에서의 선언 순서
전체 선택자 리셋브라우저 기본 margin, padding을 초기화해서 의도하지 않은 여백 삭제
box-sizing: border-boxwidth에 padding과 border까지 포함시켜 크기 계산을 직관적으로 만듦
스티키 푸터body에 display: flex; flex-direction: column; min-height: 100vh, 본문에 flex: 1을 줘서 내용이 적어도 푸터가 바닥에 붙게 함

CSS: 영역별

헤더

  • position: fixed + z-index로 스크롤과 무관하게 상단에 고정
  • grid-template-columns: repeat(3, minmax(0, 1fr))로 정확히 3등분
    그냥 1fr은 "내용보다 작아지지 않는다"는 최소값이 숨어 있어서, 제목이 길면 가운데 칸이 넓어질 수 있음
    minmax(0, 1fr)로 최소값을 0으로 지정하면 내용과 무관하게 1/3을 유지

사이드 목차

  • position: sticky로 스크롤을 따라오게 함
    처음에는 sticky가 동작하지 않았는데, 부모 grid의 기본값 align-items: stretch 때문에 목차가 본문 높이만큼 늘어나서 움직일 여유 공간이 없었기 때문
    align-items: start로 해결
  • scroll-margin-top으로 앵커 이동 시 섹션 제목이 고정 헤더에 가려지지 않게 보정
  • :hover로 마우스를 올렸을 때 배경색이 바뀌게 함

포토 덤프

  • grid-template-areas로 그림을 그리듯 배치
  • 시안을 직접 재 보니 열 너비가 약 209 / 209 / 181 / 240px이었고, 비율로 바꾸면 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; }으로 숨김. 브라우저마다 방식이 달라서 두 가지를 함께 써야 함

관심사 카드

  • grid + 미디어 쿼리로 5 → 4 → 3 → 2열 반응형을 만듦
    미디어 쿼리는 큰 값부터 작성. 화면이 700px이면 1200px 이하, 992px 이하, 768px 이하 조건이 모두 참인데, 구체성이 같으니 마지막에 선언된 2열이 이기기 때문
  • aspect-ratio: 1 / 1 + object-fit: cover로 썸네일 크기를 통일하고 찌그러짐을 막음
  • 버튼 영역에 margin-top: auto를 줘서 제목 길이가 달라도 버튼 줄이 맞도록 구현

To. 웹토리

  • ::before, ::after 가상 요소와 content로 HTML을 건드리지 않고 문장 앞뒤에 하트를 붙임
    문장을 추가해도 하트가 자동으로 붙음

2. 심화 과제: 인터랙션 얹기

심화 과제는 대부분 마우스를 올렸을 때의 반응이었다.
기본 과제 코드를 건드리지 않고, 각 CSS 파일에 규칙을 추가하는 방식으로 작업했다.
같은 선택자를 다시 쓰면 기존 속성은 유지되고 새 속성만 더해지며, 겹치는 속성은 나중에 선언된 쪽이 이긴다.

2-1. 헤더: 아바타 hover 그림자와 소셜 링크

.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로 원래 페이지에 접근하지 못하게 막음

2-2. 사이드 목차: 첫/끝 항목만 다른 모서리

.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; }를 추가하면 대비할 수 있음

2-3. 포토 덤프: 정해진 칸 안에서 확대

.photo {
    overflow: hidden; /* 기본 과제에서 이미 넣어 둔 속성 */
}

.photo img {
    transition: transform 0.4s;
}

.photo:hover img {
    transform: scale(1.1);
}
  • 명세의 핵심은 "정해진 영역 안에서 사진이 커지는 방식". 부모인 figure에 overflow: hidden이 있어서, 1.1배로 커진 사진 중 칸을 벗어난 부분은 잘려 보이지 않음
  • 크기를 width로 키우면 레이아웃을 다시 계산하는 리플로우가 일어남. transform은 세미나에서 배운 렌더링 과정 중 합성(Composite) 단계에서 처리되어, 리플로우와 리페인트를 거치지 않아 훨씬 부드러움
  • 같은 이유로 hover 시 "확대"나 "이동"은 전부 transform으로 구현

2-4. 사진첩: 끊김 없는 무한 루프 배너

가장 오래 고민한 심화 과제. 원리는 생각보다 단순했다.

<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개가 이어 붙어 있음
  • 원리: 필름을 자기 길이의 정확히 절반(-50%)만큼 왼쪽으로 옮김. 이동이 끝나는 순간, 두 번째 묶음이 정확히 첫 번째 묶음이 있던 자리에 있음. 내용이 똑같으니 애니메이션이 처음(0)으로 되돌아가도 사람 눈에는 계속 이어지는 것처럼 보임
  • 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)에서는 애니메이션 제거. 운영체제에서 "동작 줄이기"를 켠 사용자에게 계속 움직이는 화면은 불편할 수 있기 때문

2-5. 관심사 카드: 떠오르는 카드와 버튼 색 전환

.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;
}
  • 카드는 위로 6px 떠오르면서 그림자가 커져서 "들어 올린" 느낌을 줌
  • 버튼은 연한 색에서 진한 색으로 0.3초 동안 바뀌어, 명세의 "자연스러운 컬러 변경"을 만족
  • transition: all 0.3s로 한 번에 쓸 수도 있지만, 실제로 바뀌는 속성만 나열. all은 의도하지 않은 속성 변화까지 애니메이션되고, 어떤 속성이 움직이는지 코드만 보고 알기 어렵기 때문

3. 막혔던 순간들

개념을 적용하는 것보다 더 오래 걸린 건 아무 에러도 없는데 화면이 이상한 상황이었다.

사건 1: 포토 덤프가 한 줄로 나열됐다

시안대로라면 위 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로 한 단어를 고치자 시안과 같은 배치가 나왔다.

사건 2: 넓은 화면에서만 카드가 1열이었다

관심사 카드가 넓은 화면에서 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열이 되고,
미디어 쿼리 안은 철자가 맞았기 때문에 좁은 화면에서만 정상이었던 것이다.


4. 두 사건의 공통점: 에러 메시지가 없었다

두 버그 모두 콘솔에 빨간 에러 하나 뜨지 않았다.
JS였다면 오타 하나에 에러가 났을 텐데, CSS는 왜 이렇게 조용할까?

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에서 "적용이 안 된다"에는 틀려서 버려진 경우와 맞지만 조건이 안 맞아 효과가 없는 경우가 있다.


5. 그럼 어떻게 찾을까

에러가 안 뜨니, 직접 찾는 도구를 써야 한다.

  • 개발자도구 Styles 패널: 요소를 선택하면 적용된 CSS가 보임. 무효한 선언에는 취소선과 경고 아이콘이 붙고, 마우스를 올리면 이유를 알려줌
  • grid 배지: Elements 패널에서 grid 컨테이너 옆에 grid 배지가 붙음. 배지가 없다면 grid가 아니라는 뜻(사건 1). 배지를 누르면 격자선과 영역 이름이 화면에 겹쳐 보임
  • VS Code 물결 밑줄: 모르는 속성 이름 아래에 밑줄이 생기고 Unknown property라고 알려줌(사건 2)
  • 자동 완성: 속성 이름을 끝까지 타이핑하지 않고 목록에서 고르면 오타가 생길 일이 없음

6. 그 외에 배운 것: 브라우저와 Git은 "저장된 파일"을 읽는다

번외로, 코드를 다 썼는데 브라우저에 완전히 빈 화면이 나온 적도 있었다.
원인은 VS Code에서 파일을 저장하지 않은 것이었다.

브라우저도, Git도 편집기 화면이 아니라 디스크에 저장된 파일을 읽는다.
그래서 빈 파일이 그대로 커밋되기까지 했다.
이후로는 VS Code의 Auto Save를 켜고, 커밋 전에 git diff로 내가 쓴 코드가 초록 줄로 보이는지 확인하는 습관을 들였다.


마치며

처음 HTML과 CSS를 배우면서 느낀 건,
CSS는 틀렸다고 말해 주지 않는 대신 틀려도 페이지가 끝까지 살아남도록 설계된 언어라는 점이다.

그 덕분에 웹은 수많은 브라우저에서 깨지지 않고 동작하지만,
작성하는 입장에서는 "왜 안 되지?"를 스스로 찾아내야 한다.

심화 과제를 하면서는 "어떻게 움직이게 할까"보다
어떤 속성으로 움직이게 해야 브라우저가 덜 힘들까(transform vs width),
움직임이 불편한 사람은 없을까(prefers-reduced-motion)를 함께 고민하게 됐다.

이번 과제를 통해 개념보다 개발자 도구로 원인을 추적하는 습관을 얻을 수 있었다.


참고 자료

0개의 댓글