본문 바로가기
Frontend2026년 10월 6일16분 읽기

모바일 CSS가 안 먹던 이유 — @media 위치와 캐스케이드 소스 순서, 인라인 스타일 함정

YS
김영삼
조회 10
모바일 CSS가 안 먹던 이유 — @media 위치와 캐스케이드 소스 순서, 인라인 스타일 함정

@media 블록은 안에 든 규칙의 우선순위를 올려 주지 않는다. 그래서 선택자 명시도가 같다면, 미디어쿼리 블록이 기본 규칙보다 위에 있을 때 뒤에 나온 기본 규칙이 모바일 규칙을 그대로 덮어쓴다.

youngsam.net 프로그램 목록(/programs)의 모바일 화면을 다시 디자인하다가 정확히 이 함정에 빠졌다. 분명히 모바일용 규칙을 써 넣었는데 일부만 적용되고 나머지는 꿈쩍도 안 했다. 원인은 두 개였다. 하나는 globals.css 안에서 @media 블록이 놓인 위치, 다른 하나는 JSX에 박혀 있던 인라인 스타일이다. 둘 다 "무엇을 썼느냐"가 아니라 "어디에 있느냐"의 문제였다.

3줄 요약
  • 미디어쿼리는 조건일 뿐 명시도가 아니다. 명시도가 같으면 소스에서 나중에 나온 규칙이 이긴다.
  • JSX 인라인 스타일(style={{...}})은 스타일시트보다 우선한다. CSS를 아무리 고쳐도 안 먹으면 인라인부터 의심한다.
  • 해결은 @media 블록을 기본 규칙 뒤로 옮기고, 레이아웃용 인라인 스타일을 지우는 것. !important로 덮지 않았다.
삽질 기록 · 분명 썼는데 반만 먹는 모바일 CSS
목록 페이지 모바일 화면을 정리하려고 부제, 메타 정보 줄, 태그 같은 모바일 전용 규칙을 globals.css에 추가했다. 390px 폭으로 캡처해 보니 일부는 바뀌었는데 일부는 PC 값 그대로였다. 브라우저 개발자 도구 Styles 패널을 열어 보니 내가 쓴 모바일 규칙이 분명 목록에 있었고, 줄 그어진(취소선) 상태였다. 그 위에 같은 선택자의 기본 규칙이 살아 있었다. 파일을 거슬러 올라가 보니 모바일 @media 블록이 해당 기본 규칙들보다 위쪽에 있었다. 같은 명시도라면 나중에 나온 쪽이 이기니, 기본 규칙이 모바일 규칙을 덮어쓰고 있던 것이다. 여기에 카테고리 칩 줄은 아예 다른 이유로 안 먹었는데, 그건 아래에서 따로 설명한다.

PART 01캐스케이드는 어떤 순서로 승자를 고르나

같은 요소의 같은 속성에 여러 선언이 걸리면 브라우저는 캐스케이드 규칙으로 하나를 고른다. 많은 사람이 "명시도(specificity)가 높은 게 이긴다"까지만 기억하는데, 실제로는 명시도 앞에도 단계가 있고 뒤에도 단계가 하나 더 있다. 그 마지막 단계가 바로 소스 순서다.

캐스케이드 우선순위 단계 (위에서 먼저 결판이 나면 아래는 보지 않는다)
단계비교하는 것내 사례에서의 의미
1. 출처와 중요도브라우저 기본 / 사용자 / 작성자 스타일, 그리고 !important 여부둘 다 작성자 스타일, !important 없음 → 동점
2. 컨텍스트섀도 DOM 안팎 같은 캡슐화 경계해당 없음 → 동점
3. 요소에 붙은 스타일인라인 style 속성인지칩 줄은 여기서 인라인이 이김
4. 캐스케이드 레이어@layer 선언 순서레이어를 안 써서 동점
5. 명시도선택자의 (id, class, 요소) 점수같은 클래스 선택자 → 동점
6. 소스 순서나중에 선언된 쪽이 이김여기서 기본 규칙이 이김 — 원인

이 표에서 @media가 어디에도 없다는 게 핵심이다. 미디어쿼리는 "이 조건이면 안의 규칙을 후보로 넣는다"는 필터일 뿐, 후보가 된 규칙에 가산점을 주지 않는다. 그래서 @media (max-width: 768px) { .card { ... } } 안의 .card와 바깥의 .card는 명시도가 똑같다. 둘 다 클래스 하나짜리다. 1단계부터 5단계까지 전부 비기면 6단계, 소스 순서로 넘어간다.

솔직히 이건 알고 있던 지식이었다. 그런데 규칙이 잔뜩 쌓인 큰 전역 CSS 파일에서 수정할 곳만 찾아 들어가 작업하다 보면, 내가 지금 쓰는 블록이 파일 전체에서 어디쯤인지 감각이 사라진다. 지식이 있어도 구조가 그걸 놓치게 만든다.

PART 02고장 난 코드와 고친 코드

상황을 최소한으로 줄이면 이렇다. 모바일 블록이 먼저 나오고, 그 아래에 같은 선택자의 기본 규칙이 다시 나온다. 화면 폭이 390px이면 두 규칙이 모두 후보가 되고, 나중에 나온 기본 규칙이 이긴다.

같은 명시도에서 소스 순서가 승패를 가른다
/* 고장: 모바일 블록이 기본 규칙보다 위에 있다 */
@media (max-width: 768px) {
  .prog-card .desc { font-size: 13px; -webkit-line-clamp: 1; }
  .prog-card .meta { display: flex; gap: 8px; }
}

/* ... 수백 줄 뒤 ... */

.prog-card .desc { font-size: 15px; -webkit-line-clamp: 2; }
.prog-card .meta { display: grid; }   /* 이게 모바일 값을 덮는다 */


/* 고침: 기본 규칙 바로 뒤에 @media를 둔다 */
.prog-card .desc { font-size: 15px; -webkit-line-clamp: 2; }
.prog-card .meta { display: grid; }

@media (max-width: 768px) {
  .prog-card .desc { font-size: 13px; -webkit-line-clamp: 1; }
  .prog-card .meta { display: flex; gap: 8px; }
}

실제로 한 일은 정말 이게 전부다. @media 블록을 해당 기본 규칙들 뒤로 옮겼다. 선택자를 더 길게 써서 명시도를 올리는 방법도 있었지만 하지 않았다. 그건 이번 판만 이기는 방법이고, 다음에 누군가(대개 미래의 나) 기본 규칙을 고칠 때 또 이상한 싸움을 만든다.

영삼
내 생각
!important로 덮는 건 문제를 푸는 게 아니라 빚을 늘리는 거라고 본다. 한 번 붙이면 그걸 이기기 위해 다음 규칙에도 붙여야 하고, 결국 파일 전체가 !important 경쟁이 된다. 순서 문제는 순서를 고쳐서 풀어야 한다. 나는 이번에도 !important를 한 줄도 쓰지 않았다.

PART 03CSS를 고쳐도 안 먹을 때 — 인라인 스타일

두 번째 문제는 카테고리 칩 줄(.filters)이었다. PC에서는 칩이 여러 줄로 감싸지는 게 괜찮지만, 모바일에서는 칩이 서너 줄로 쌓이면 목록이 한참 아래로 밀린다. 그래서 모바일에서는 한 줄로 두고 가로 스크롤(flex-wrap: nowrap + overflow-x: auto)로 바꾸려 했다. @media 위치를 고친 뒤에도 이것만은 계속 감싸졌다.

원인은 JSX였다. 그 요소에 style={{ flexWrap: 'wrap' }}이 인라인으로 붙어 있었다. 인라인 스타일은 캐스케이드 3단계, 요소에 붙은 스타일이라 레이어나 명시도 비교까지 가기도 전에 작성자 스타일시트를 이긴다. 스타일시트 쪽에서 이길 방법은 사실상 !important뿐이다. 나는 인라인을 지우고, 기본 값과 모바일 값을 둘 다 CSS로 옮겼다.

인라인 flexWrap을 지우고 CSS로 옮기기
// 전: 인라인이 CSS의 모바일 규칙을 이긴다
<div className="filters" style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
  {cats.map((c) => <Chip key={c.slug} {...c} />)}
</div>

// 후: 레이아웃은 전부 CSS로
<div className="filters">
  {cats.map((c) => <Chip key={c.slug} {...c} />)}
</div>

/* globals.css */
.filters { display: flex; gap: 8px; flex-wrap: wrap; }
@media (max-width: 768px) {
  .filters { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
}
인라인은 개발자 도구에서도 티가 덜 난다. Styles 패널 맨 위에 element.style이라는 항목으로 보이는데, 스타일시트 규칙을 찾느라 아래만 보다 보면 놓치기 쉽다. CSS를 고쳤는데 아무 변화가 없으면 맨 위 element.style부터 본다.
영삼
내 생각
인라인 스타일은 레이아웃용으로 쓰지 말자는 게 내 입장이다. 사용자 데이터로 정해지는 색이나 진행률 막대 폭처럼 진짜 값이 동적인 경우에만 인라인이 정당하다. flexWrap, display, gap 같은 레이아웃 속성은 반응형으로 바뀔 가능성이 높고, 인라인에 두는 순간 미디어쿼리가 손댈 수 없는 영역이 된다.

PART 04디버깅 순서 — 취소선을 찾아라

이번 일을 겪고 "CSS가 안 먹는다" 싶을 때 보는 순서를 정리했다. 대부분은 2번이나 3번에서 답이 나온다.

1
실제 폭에서 재현한다
개발자 도구 기기 모드나 캡처 도구로 390px 같은 실제 모바일 폭을 맞춘다. 창을 대충 줄여서 보면 미디어쿼리 경계를 넘었는지조차 헷갈린다.
2
요소를 고르고 Styles 패널에서 취소선을 찾는다
내 규칙이 목록에 있는데 줄이 그어져 있다면 "적용 대상은 맞는데 경쟁에서 졌다"는 뜻이다. 목록에 아예 없다면 선택자가 안 맞거나 미디어쿼리 조건이 거짓이다.
3
이긴 규칙의 출처를 본다
맨 위가 element.style이면 인라인이다. 같은 선택자의 다른 규칙이면 규칙 옆에 표시되는 파일과 줄 번호를 비교해 소스 순서를 확인한다.
4
Computed 탭으로 최종값을 확인한다
속성을 펼치면 어떤 선언이 최종값을 만들었는지 따라갈 수 있다. 여러 파일이 섞인 빌드 결과에서 특히 유용하다.
5
구조로 고친다
순서 문제면 블록을 옮기고, 인라인이면 지운다. 명시도를 부풀리거나 !important를 붙이는 건 마지막 수단으로도 쓰지 않으려 한다.

나는 수정 후에도 반드시 캡처 도구로 실제 폭에서 한 번 더 찍어 본다. 개발자 도구에서 맞아 보여도 실제 기기 폭에서는 다른 미디어쿼리 경계에 걸리는 경우가 있기 때문이다. 이번에도 390px 캡처로 최종 확인했다.

PART 05모바일 우선(min-width)과 데스크톱 우선(max-width)

소스 순서 문제는 미디어쿼리를 어느 방향으로 쓰느냐와도 엮여 있다. 기본 규칙을 모바일 값으로 쓰고 큰 화면에서 덮어쓰는 방식이 모바일 우선, 기본을 PC 값으로 쓰고 작은 화면에서 덮어쓰는 방식이 데스크톱 우선이다. 어느 쪽이든 "덮어쓰는 블록은 뒤에" 와야 한다는 원칙은 같다.

모바일 우선min-width
  • 기본 규칙 = 모바일 값
  • @media (min-width: 768px)로 넓은 화면을 추가
  • 폭이 커질수록 블록을 순서대로 쌓으면 자연스럽게 뒤가 이긴다
  • 새로 시작하는 프로젝트에 권장되는 관례
데스크톱 우선max-width
  • 기본 규칙 = PC 값
  • @media (max-width: 768px)로 작은 화면을 덮어씀
  • 이미 PC 기준으로 짜인 사이트에 모바일을 덧붙일 때 현실적
  • 덮는 블록이 기본보다 위로 가면 바로 내 사고가 난다

youngsam.net 목록 페이지는 PC 화면이 먼저 완성된 상태였고, 이번 작업 목표도 PC는 그대로 두고 모바일만 바꾸는 것이었다. 그래서 데스크톱 우선 구조를 유지했다. 대신 모바일 블록을 컴포넌트의 기본 규칙 바로 뒤에 붙이는 걸 원칙으로 삼았다. 전역 파일 맨 아래에 모바일 규칙을 몰아 두는 방식도 순서상으로는 안전하지만, 컴포넌트 하나를 고칠 때 파일 위아래를 오가야 해서 결국 놓치게 된다.

구조적으로 확실히 하고 싶다면 @layer도 방법이다. 예를 들어 @layer base, components, responsive;처럼 레이어 순서를 먼저 선언해 두면, 레이어 안 규칙은 파일 어디에 쓰여 있든 선언된 레이어 순서로 우선순위가 정해진다. 캐스케이드 4단계가 소스 순서(6단계)보다 먼저 결판을 내기 때문이다. 다만 레이어에 안 들어간 규칙이 레이어 안 규칙보다 이긴다는 점, !important는 레이어 순서가 뒤집힌다는 점까지 알고 써야 한다. 이번에는 범위가 작아서 블록 이동으로 끝냈다.

PART 06그래서 모바일 화면은 이렇게 바뀌었다

원인을 걷어 내고 나니 리디자인 자체는 금방 끝났다. 모바일 카드에만 적용한 변화는 이렇다. PC 화면은 손대지 않았다.

모바일 목록 리디자인에서 바꾼 것
✓부제: 프로그램 설명 앞에 붙던 "이름 —" 접두를 떼고 한 줄 설명만 남김
✓메타 한 줄: 버전, 다운로드 수, 평점을 한 줄에 나란히
✓태그: 3개만 보이고 나머지는 "+N"으로 접기
✓썸네일: 이미지 칸을 aspect-ratio: 1200 / 630으로 맞춰 원본이 잘리지 않게
✓카테고리 칩: 한 줄 가로 스크롤(인라인 스타일 제거 후 CSS로)
✓390px 폭 캡처로 최종 확인

태그를 3개로 자른 건 카드 높이를 고르게 하려는 의도였다. 태그 수는 프로그램마다 다르니, 다 보여 주면 좁은 화면에서 카드마다 줄 수가 달라진다. 메타 정보를 한 줄로 모은 것도 비슷하다. 작은 화면에서는 정보 개수보다 정렬감이 읽기 편함을 만든다.

영삼
내 생각
큰 전역 CSS 파일에서는 규칙이 "어디 있느냐"가 "무엇이냐"만큼 중요하다. 같은 선언도 위치가 바뀌면 결과가 반대가 된다. 그래서 나는 전역 CSS를 쓸 때 컴포넌트 단위로 묶고, 그 컴포넌트의 미디어쿼리는 바로 그 아래에 둔다. 이렇게 하면 파일을 위에서 아래로 읽는 순서가 곧 우선순위 순서가 된다.
모바일 CSS가 안 먹을 때 기억할 것
  1. 미디어쿼리는 명시도를 올리지 않는다. 같은 명시도면 뒤에 나온 규칙이 이긴다.
  2. 덮어쓰는 @media 블록은 기본 규칙 바로 뒤에 둔다. 구조로 강제하고 싶으면 @layer를 쓴다.
  3. 인라인 스타일은 스타일시트를 이긴다. 레이아웃 속성은 인라인에 두지 않는다.
  4. Styles 패널의 취소선과 element.style부터 확인하고, 실제 폭에서 캡처로 검증한다.
  5. !important와 명시도 부풀리기는 다음 사고의 씨앗이다.

자주 묻는 질문

미디어쿼리 안에 쓴 CSS가 적용되지 않는 이유는 무엇인가요?

가장 흔한 원인은 소스 순서다. 미디어쿼리는 명시도를 높여 주지 않기 때문에, 같은 선택자의 기본 규칙이 미디어쿼리 블록보다 뒤에 있으면 기본 규칙이 이긴다. 블록을 기본 규칙 뒤로 옮기면 해결된다. 조건 자체가 맞지 않는 경우도 있으니 실제 폭에서 확인한다.

@media는 명시도(specificity)를 높여 주나요?

아니다. @media는 규칙을 적용할지 말지 정하는 조건일 뿐이고, 안에 든 선택자의 명시도는 바깥과 똑같이 계산된다. 그래서 명시도가 같은 경쟁에서는 소스 순서가 결과를 정한다.

인라인 스타일을 CSS 파일에서 덮어쓸 수 있나요?

일반 선언으로는 안 된다. 인라인 style 속성은 캐스케이드에서 작성자 스타일시트보다 먼저 이기기 때문에, 스타일시트에서 이기려면 !important가 필요하다. 그보다는 인라인을 지우고 값을 CSS로 옮기는 게 근본적인 해결이다.

모바일 우선(min-width)과 데스크톱 우선(max-width) 중 무엇이 좋나요?

새로 시작한다면 모바일 우선이 관례이고, 넓은 화면 블록을 순서대로 쌓기만 하면 돼서 순서 실수가 적다. 이미 PC 기준으로 완성된 사이트에 모바일을 덧붙일 때는 데스크톱 우선이 현실적이다. 어느 쪽이든 덮어쓰는 블록은 뒤에 와야 한다.

@layer를 쓰면 소스 순서 문제가 사라지나요?

레이어 사이의 우선순위는 선언된 레이어 순서로 정해지므로, 파일 안 위치 때문에 생기는 문제는 크게 줄어든다. 다만 같은 레이어 안에서는 여전히 명시도와 소스 순서가 적용되고, 레이어 밖 규칙이 레이어 안 규칙보다 이긴다는 점은 알고 써야 한다.

참고 자료

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록