Sass로 반응형 블로그 헤더 만들기
Sass 변수·믹스인·부분 파일·BEM을 조합해 반응형 블로그 헤더를 구현하고 컴파일 결과를 확인합니다.
앞 글에서 Sass의 변수, 믹스인, 모듈을 익혔습니다. 이 글에서는 이 기능들과 BEM 이름 규칙을 함께 써서, 화면 너비에 따라 메뉴 배치가 바뀌는 블로그 헤더를 만듭니다. 자바스크립트 없이 CSS 레이아웃만으로 동작하는 헤더이며, 여러 부분 파일로 나눈 SCSS가 하나의 CSS로 컴파일되는 과정을 확인합니다.
프로젝트 준비
앞 글에서 Node.js와 npm이 설치되었는지 확인했다면 시작할 수 있습니다. 앞 장들에서 쓴 web-dev-practice 폴더에는 package.json과 Sass가 없고, CSS 아키텍처와 방법론에서 만든 css/style.css는 이 실습의 컴파일 결과로 덮어써집니다. 그래서 그 폴더와 다른 위치에서 다음 명령으로 새 web-dev-practice 폴더를 만들고 Sass를 개발 의존성으로 설치합니다.
mkdir web-dev-practice
cd web-dev-practice
npm init -y
npm install --save-dev sass설치하면 생기는 lockfile이 이 실습에서 쓰는 Sass의 버전을 고정하고, 이후의 npx sass는 이 프로젝트에 설치한 버전을 실행합니다. 파일은 다음 구조로 만듭니다.
HTML
sass_header.html에는 다음 코드를 씁니다. HTML은 SCSS가 아니라 컴파일될 css/style.css를 연결합니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sass 반응형 헤더 실습</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<header class="main-header">
<a href="#" class="main-header__logo">My Blog</a>
<nav class="main-nav" aria-label="주요 메뉴">
<ul class="main-nav__list">
<li class="main-nav__item"><a href="#" class="main-nav__link">홈</a></li>
<li class="main-nav__item"><a href="#" class="main-nav__link">카테고리</a></li>
<li class="main-nav__item"><a href="#" class="main-nav__link">글쓰기</a></li>
<li class="main-nav__item"><a href="#" class="main-nav__link">마이페이지</a></li>
</ul>
</nav>
</header>
<main class="content-wrapper">
<h1 class="page-title">반응형 웹과 Sass의 조화</h1>
<p class="content-copy">이 페이지는 Sass를 이용하여 헤더를 만들었습니다. 화면 크기를 줄여보세요!</p>
<p class="content-copy">변수, 얕은 중첩, 믹스인과 module로 역할과 의존성을 분명하게 관리할 수 있습니다.</p>
<div class="content-demo">
페이지 스크롤 영역
</div>
</main>
<footer class="site-footer">
<p>© 2026 Sass Practice.</p>
</footer>
</body>
</html>SCSS 파일
진입 파일과 모듈의 의존성
scss/style.scss는 CSS를 출력하는 세 부분 파일을 차례로 불러오는 진입 파일입니다. 진입 파일은 Sass가 컴파일을 시작하는 파일로, 여기서 불러온 순서대로 각 파일의 CSS가 css/style.css에 이어 붙습니다. 변수와 믹스인만 담은 _variables.scss와 _mixins.scss는 출력할 CSS가 없으므로 이 목록에 넣지 않았습니다.
// CSS를 출력하는 module만 순서대로 불러옵니다.
@use 'base';
@use 'layout';
@use 'header';@use로 불러온 변수와 믹스인은 그것을 불러온 파일 안에서만 보입니다. 그래서 style.scss가 모든 것을 미리 불러와 다른 파일에 나눠 주는 방식은 동작하지 않고, 각 부분 파일이 자기가 쓰는 모듈을 직접 @use해야 합니다.
SASS · BUILD TIME
Sass module은 사용하는 파일에서 직접 @use한다
style.scss의 @use가 변수와 mixin을 모든 partial에 전역으로 공급하지는 않는다. 각 stylesheet는 자신이 사용하는 module을 직접 불러온다.
공유 의존성이 많아도 module CSS를 네 번 출력하지 않는다
@use는 같은 compilation에서 module CSS를 한 번 포함한다. 그림의 화살표는 직접 @use 관계이며, entry의 namespace가 다른 stylesheet에 자동으로 전파되지는 않는다.
A → B는 A가 B를 직접 @use한다는 뜻이다. Dart Sass는 entry인 style.scss를 css/style.css로 컴파일한다.
위 그림은 이 실습의 여섯 파일 사이의 @use 관계를 보여 줍니다. 진입 파일 style.scss는 _base.scss, _layout.scss, _header.scss를 불러오고, 이 세 파일은 각자 _variables.scss를 직접 불러옵니다. _header.scss는 _mixins.scss도 불러오며, _mixins.scss도 다시 _variables.scss를 불러옵니다. A에서 B로 향하는 화살표는 A가 B를 직접 @use한다는 뜻이고, 코드가 실행되는 순서가 아닙니다.
그림 아래의 설명처럼 _variables.scss를 네 파일이 불러와도, @use는 한 번의 컴파일에서 같은 모듈을 한 번만 포함하므로 그 내용이 네 번 출력되지 않습니다. 진입 파일에서 붙인 이름 공간도 다른 파일로 전해지지 않습니다.
변수와 믹스인
scss/_variables.scss에는 색과 기준 너비를 모읍니다. 이 파일에는 변수 선언만 있고 CSS 규칙이 없으므로, 여러 파일이 불러와도 출력되는 CSS는 없습니다. 마지막 줄의 $mobile-breakpoint는 모바일과 넓은 화면을 나누는 기준값이며, 실제 미디어 쿼리 조건은 다음 믹스인 파일에서 이 값으로 계산합니다.
// _variables.scss
$primary-color: #3498db;
$text-color: #333;
$bg-color: #f0f2f5;
$surface-color: white;
$header-bg: #2c3e50;
$nav-link-color: white;
$nav-link-hover-color: #1abc9c; // 터쿼이즈
$mobile-breakpoint: 768px; // 767px 모바일 상한을 계산하는 기준값scss/_mixins.scss에는 모바일 화면에서만 규칙을 적용하는 믹스인을 만듭니다. 이 믹스인은 @include할 때 넘긴 규칙을 화면 너비 767px 이하의 미디어 쿼리로 감싸 주므로, 헤더 파일에서 미디어 쿼리 조건을 직접 적지 않아도 됩니다.
// _mixins.scss
@use 'variables' as vars;
$mobile-max: vars.$mobile-breakpoint - 1px;
@mixin mobile-only {
@media screen and (max-width: $mobile-max) {
@content;
}
}@use 'variables' as vars는 _variables.scss를 vars라는 짧은 이름 공간으로 불러옵니다. Sass에서는 미디어 쿼리의 조건 안에 변수와 계산식을 그대로 쓸 수 있으므로, 768px - 1px이 컴파일할 때 767px로 계산되어 들어갑니다. @content는 이 믹스인을 @include할 때 중괄호로 넘긴 규칙들이 들어갈 자리입니다.
@content가 반응형 CSS로 컴파일되는 과정 — 상태 추적
본문 768px−1px과 @content의 header column 블록이 767px media CSS로 합쳐진다. 정의/호출/산출물/브라우저 조건의 현재 값을 고정한 컴파일 추적이 유용하다.
SASS · BUILD TIME
@content는 호출 블록을 CSS 출력에 삽입한다
예제 _variables.scss의 $mobile-breakpoint는 768px이다. 1px을 뺀 767px media 조건 안에 @include가 전달한 헤더 규칙을 컴파일한다.
-
정의 ·
_mixins.scss@use 'variables' as vars; $mobile-max: vars.$mobile-breakpoint - 1px; @mixin mobile-only { @media screen and (max-width: $mobile-max) { @content; } }공통 미디어 조건 안에 호출 블록을 받을
@contentinsertion point를 둔다. -
호출 ·
_header.scss핵심 발췌@use 'mixins' as mix; @include 호출 블록 { .main-header { flex-direction: column; } }@include에 전달한 같은 요소의 선언이 mixin의@content위치에 들어간다. -
출력 ·
css/style.css핵심 발췌@media screen and (max-width: 767px) { .main-header { flex-direction: column; } }변수, 연산, mixin 호출은 사라지고 브라우저가 해석할 표준 CSS만 남는다.
믹스인 호출은 사라지고 media 조건은 CSS에 남는다
컴파일 출력에는 .main-header의 flex-direction: column과 767px media query가 남는다. 브라우저가 그 조건을 평가하며, JavaScript 호출이나 runtime callback은 실행하지 않는다.
@content는 runtime callback이 아니라 호출 블록을 컴파일 시 삽입하는 자리다. 미디어 feature query는 SassScript 변수와 연산식을 직접 받으므로 이 예제에는 인터폴레이션이 필요하지 않다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | @include에 전달한 같은 요소의 선언이 mixin의 @content 위치에 들어간다. |
| 원도표 조건·주석 | 호출 · _header.scss 핵심 발췌 @use 'mixins' as mix; @include 호출 블록 { .main-header { flex-direction: column; } } @include에 전달한 같은 요소의 선언이 mixin의 @content 위치에 들어간다. |
| 원도표 설명 | @content는 runtime callback이 아니라 호출 블록을 컴파일 시 삽입하는 자리다. 미디어 feature query는 SassScript 변수와 연산식을 직접 받으므로 이 예제에는 인터폴레이션이 필요하지 않다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| mixin 정의 | Sass: 정의 상태 / CSS: 아직 없음 | @content를 받을 규칙 틀을 만든다 | 정의 자체가 브라우저 적용 규칙은 아니다 |
| include와 컴파일 | 경계: 768−1=767px / CSS: media 규칙 산출 | 호출 내용의 header column 규칙이 767px media 안에 놓인다 | Sass 계산과 browser query 판정은 다르다 |
| 브라우저 조건 | viewport: 조건 만족 여부 / header: 조건에 따른 배치 | 산출 CSS가 실제 viewport 조건에서만 적용된다 | include 호출 시점을 화면 resize 사건으로 해석하지 않는다 |
@content가 반응형 CSS로 컴파일되는 과정 — 상태 추적
본문 768px−1px과 @content의 header column 블록이 767px media CSS로 합쳐진다. 정의/호출/산출물/브라우저 조건의 현재 값을 고정한 컴파일 추적이 유용하다.
SASS · BUILD TIME
@content는 호출 블록을 CSS 출력에 삽입한다
예제 _variables.scss의 $mobile-breakpoint는 768px이다. 1px을 뺀 767px media 조건 안에 @include가 전달한 헤더 규칙을 컴파일한다.
-
정의 ·
_mixins.scss@use 'variables' as vars; $mobile-max: vars.$mobile-breakpoint - 1px; @mixin mobile-only { @media screen and (max-width: $mobile-max) { @content; } }공통 미디어 조건 안에 호출 블록을 받을
@contentinsertion point를 둔다. -
호출 ·
_header.scss핵심 발췌@use 'mixins' as mix; @include 호출 블록 { .main-header { flex-direction: column; } }@include에 전달한 같은 요소의 선언이 mixin의@content위치에 들어간다. -
출력 ·
css/style.css핵심 발췌@media screen and (max-width: 767px) { .main-header { flex-direction: column; } }변수, 연산, mixin 호출은 사라지고 브라우저가 해석할 표준 CSS만 남는다.
믹스인 호출은 사라지고 media 조건은 CSS에 남는다
컴파일 출력에는 .main-header의 flex-direction: column과 767px media query가 남는다. 브라우저가 그 조건을 평가하며, JavaScript 호출이나 runtime callback은 실행하지 않는다.
@content는 runtime callback이 아니라 호출 블록을 컴파일 시 삽입하는 자리다. 미디어 feature query는 SassScript 변수와 연산식을 직접 받으므로 이 예제에는 인터폴레이션이 필요하지 않다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | @include에 전달한 같은 요소의 선언이 mixin의 @content 위치에 들어간다. |
| 원도표 조건·주석 | 호출 · _header.scss 핵심 발췌 @use 'mixins' as mix; @include 호출 블록 { .main-header { flex-direction: column; } } @include에 전달한 같은 요소의 선언이 mixin의 @content 위치에 들어간다. |
| 원도표 설명 | @content는 runtime callback이 아니라 호출 블록을 컴파일 시 삽입하는 자리다. 미디어 feature query는 SassScript 변수와 연산식을 직접 받으므로 이 예제에는 인터폴레이션이 필요하지 않다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| mixin 정의 | Sass: 정의 상태 / CSS: 아직 없음 | @content를 받을 규칙 틀을 만든다 | 정의 자체가 브라우저 적용 규칙은 아니다 |
| include와 컴파일 | 경계: 768−1=767px / CSS: media 규칙 산출 | 호출 내용의 header column 규칙이 767px media 안에 놓인다 | Sass 계산과 browser query 판정은 다르다 |
| 브라우저 조건 | viewport: 조건 만족 여부 / header: 조건에 따른 배치 | 산출 CSS가 실제 viewport 조건에서만 적용된다 | include 호출 시점을 화면 resize 사건으로 해석하지 않는다 |
위 그림은 이 믹스인이 반응형 CSS로 바뀌는 세 단계를 보여 줍니다. 믹스인의 정의는 768px에서 1px을 뺀 조건과 @content 자리를 가진 틀을 만들고, _header.scss에서 @include로 넘긴 .main-header의 세로 배치 규칙이 그 자리에 들어가 max-width: 767px 미디어 쿼리 안의 CSS로 컴파일됩니다. 브라우저는 출력된 미디어 쿼리의 조건만 평가하므로, 767px 이하에서는 세로로, 그 밖에서는 기본 가로 배치로 보여 줍니다. 컴파일한 CSS에는 Sass 변수와 믹스인 호출이 남지 않습니다.
기본, 레이아웃, 헤더
scss/_base.scss에는 요소의 기본 스타일을 씁니다. 선택자가 모두 *, body, a, ul 같은 요소 이름이고, 색은 vars.으로 시작하는 이름 공간의 변수로 가져옵니다. a의 color: inherit는 링크가 브라우저 기본 색 대신 부모의 글자색을 따르게 하므로, 링크 색을 각 영역의 규칙에서 정할 수 있습니다.
// _base.scss
@use 'variables' as vars;
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: vars.$bg-color;
color: vars.$text-color;
line-height: 1.6;
}
a {
text-decoration: none;
color: inherit;
}
ul {
list-style: none;
}scss/_layout.scss에는 본문과 푸터의 레이아웃을 씁니다. .content-demo는 페이지가 화면보다 길어지도록 높이를 준 빈 영역입니다.
// _layout.scss
@use 'variables' as vars;
.content-wrapper {
max-width: 1200px;
margin: 20px auto;
padding: 20px;
background-color: vars.$surface-color;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.page-title {
color: vars.$primary-color;
margin-bottom: 15px;
}
.content-copy {
margin-bottom: 10px;
}
.content-demo {
min-height: 32rem;
padding: 20px;
background-color: vars.$bg-color;
}
.site-footer {
text-align: center;
background-color: #ccc;
padding: 15px;
margin-top: 50px;
color: #555;
}본문 영역은 최대 1200px까지만 넓어지고 margin: 20px auto로 가운데에 놓입니다. .content-demo의 min-height: 32rem은 스크롤이 생길 만큼 페이지를 길게 만들기 위한 값입니다.
scss/_header.scss에는 BEM으로 이름 붙인 헤더를 씁니다. 앞쪽 규칙들이 넓은 화면의 가로 배치를 정하고, 아래쪽 @include mix.mobile-only 블록이 767px 이하에서만 적용할 세로 배치를 덧붙이는 구조입니다.
// _header.scss
@use 'variables' as vars;
@use 'mixins' as mix;
.main-header {
background-color: vars.$header-bg;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
.main-header__logo {
color: vars.$nav-link-color;
font-size: 1.8em;
font-weight: bold;
transition: color 0.2s ease;
&:hover {
color: vars.$nav-link-hover-color;
}
}
.main-nav__list {
display: flex;
gap: 25px;
}
.main-nav__link {
display: block;
color: vars.$nav-link-color;
font-weight: 500;
padding: 8px 0;
transition: color 0.2s ease, background-color 0.2s ease;
&:hover {
color: vars.$nav-link-hover-color;
}
}
@include mix.mobile-only {
.main-header {
flex-direction: column;
align-items: stretch;
gap: 12px;
padding: 12px 20px;
}
.main-header__logo {
text-align: center;
}
.main-nav__list {
flex-direction: column;
gap: 0;
}
.main-nav__item {
border-top: 1px solid rgba(255, 255, 255, 0.1);
&:last-child {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
}
.main-nav__link {
width: 100%;
padding: 12px 8px;
text-align: center;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
}BEM 클래스는 각각 최상위 규칙으로 두고, &:hover와 &:last-child처럼 상태가 분명한 경우에만 한 단계 중첩했습니다. 그래서 컴파일한 선택자가 .main-nav__link:hover처럼 짧게 유지됩니다. 모바일에서도 링크를 숨기지 않고 세로로 쌓으므로, 메뉴를 여닫는 자바스크립트 없이도 모든 메뉴를 쓸 수 있습니다.
컴파일과 확인
프로젝트 폴더(web-dev-practice)에서 터미널을 열고 감시 모드로 컴파일합니다. package.json과 sass_header.html이 있는 이 폴더에서 실행해야 scss/style.scss 경로를 찾을 수 있습니다.
npx sass --watch scss/style.scss:css/style.cssSCSS 파일을 저장할 때마다 css/style.css가 다시 만들어집니다. 감시 모드는 CSS를 만들 뿐이고, Live Server는 파일이 바뀌었을 때 브라우저를 새로고침해 줄 뿐이므로, 둘은 서로를 대신하지 않습니다.
sass_header.html을 브라우저로 열고 창의 너비를 바꿔 봅니다. 768px 이상에서는 로고가 왼쪽에, 메뉴가 오른쪽에 가로로 놓이고, 767px 이하에서는 로고와 메뉴가 세로로 쌓이며 모든 링크가 계속 보입니다. 링크의 목적지는 모두 #이라 실제로 이동하는 기능은 없습니다.
저장한 뒤 css/style.css를 열어 .main-header { display: flex; ... }와, @media screen and (max-width: 767px) 안의 flex-direction: column을 찾아보세요. SCSS의 $mobile-max와 @include mix.mobile-only는 사라지고, 계산된 조건과 일반 CSS 선언만 남아 있습니다. 컴파일 오류가 나면 터미널에 표시된 파일과 줄을 먼저 고칩니다. 오류가 나면 출력 CSS가 오류 내용을 담은 파일로 바뀔 수도 있으므로, 예전의 정상 CSS가 항상 남아 있다고 생각하지 않습니다.
정리
- 여러 SCSS 부분 파일을 진입 파일 하나에서 불러와 CSS 파일 하나로 컴파일하며, HTML은 컴파일한 CSS를 연결합니다.
@use로 불러온 모듈은 그 파일 안에서만 보이므로, 각 부분 파일이 자기가 쓰는 모듈을 직접 불러옵니다.- 같은 모듈을 여러 파일이 불러와도 한 번의 컴파일에서는 한 번만 출력됩니다.
@content가 있는 믹스인으로 미디어 쿼리 조건을 한곳에 두고, 조건 안에 넣을 규칙은@include할 때 넘깁니다.- BEM 클래스를 최상위에 두고 중첩을 얕게 유지하면, 컴파일한 선택자가 짧고 명시도가 낮게 유지됩니다.
- 감시 모드는 CSS를 만들고 Live Server는 브라우저를 새로고침하므로, 결과는 컴파일한 CSS와 화면에서 함께 확인합니다.
연습 문제
1. 기준 너비 바꾸기
메뉴가 길어져 900px보다 좁은 화면에서는 세로 배치가 필요해졌습니다. SCSS의 어느 값 하나를 바꿔야 할까요? 바꾼 뒤 899px과 900px 화면은 각각 어떻게 보일까요?
해설 보기
_variables.scss의 $mobile-breakpoint를 900px로 바꿉니다. 믹스인이 1px을 빼므로 컴파일한 CSS에는 max-width: 899px 조건이 들어갑니다.
899px 화면에서는 이 조건이 맞아 헤더와 메뉴가 세로로 쌓이고, 900px 화면에서는 조건이 맞지 않아 기본 가로 배치가 됩니다. 기준값을 바꿨다고 모든 기기에 알맞은 기준이 정해지는 것은 아니므로, 실제 메뉴 길이가 가로 배치에 들어가는지도 화면에서 확인합니다.
2. 진입 파일에서만 불러오면
_header.scss의 @use 'variables' as vars; 줄을 지우고, 대신 style.scss 맨 위에 @use 'variables' as vars;를 더했습니다. 컴파일하면 어떻게 될까요?
해설 보기
컴파일 오류가 납니다. @use로 불러온 모듈은 불러온 파일 안에서만 보이므로, style.scss에서 불러온 vars를 _header.scss에서는 쓸 수 없습니다. 터미널에는 vars라는 모듈을 찾을 수 없다는 오류와 함께 _header.scss에서 vars.$header-bg를 쓴 줄이 표시됩니다.
각 부분 파일이 자기가 쓰는 모듈을 직접 불러와야 하며, 그래야 파일만 보고도 무엇에 의존하는지 알 수 있습니다.
3. 모바일 규칙을 두 번 쓰면
_layout.scss에서도 @include mix.mobile-only { .content-wrapper { margin: 10px; } }를 쓰기로 했습니다. 컴파일한 CSS에 max-width: 767px 미디어 쿼리는 몇 개 생길까요? 이렇게 하려면 _layout.scss에 무엇을 더해야 할까요?
해설 보기
두 개가 생깁니다. @include를 쓴 곳마다 미디어 쿼리가 하나씩 출력되므로, _layout.scss와 _header.scss에서 온 같은 조건의 미디어 쿼리가 따로 들어갑니다. 결과는 같지만 출력이 조금 늘어나므로, 컴파일한 CSS에서 중복이 지나치지 않은지 확인합니다.
_layout.scss는 지금 _variables.scss만 불러오므로, 맨 위에 @use 'mixins' as mix;를 더해야 mix.mobile-only를 쓸 수 있습니다.
다음 글 CSS 프레임워크 활용에서는 Bootstrap과 Tailwind CSS처럼 미리 만들어진 스타일과 유틸리티 클래스로 화면을 빠르게 만드는 방법을 다룹니다.