CSS와 스타일링
CSS 격리(CSS Isolation)와 인라인 스타일을 활용해 컴포넌트를 꾸미는 방법을 배웁니다.
📌 학습 목표
- CSS 격리(CSS Isolation)의 개념과 필요성을 설명할 수 있습니다.
.razor.css파일을 생성하고 컴포넌트에 독립적인 스타일을 적용할 수 있습니다.- 빌드 시 스코프 속성이 자동으로 주입되는 원리를 이해하고 설명할 수 있습니다.
::deep선택자를 사용해 자식 컴포넌트 내부 요소에도 스타일을 전파할 수 있습니다.- 인라인 스타일과 동적 스타일 바인딩을 상황에 맞게 선택·적용할 수 있습니다.
📝 개념 설명
1. CSS 격리(CSS Isolation)란?
대규모 Blazor 애플리케이션에서 여러 컴포넌트가 같은 CSS 클래스명을 사용하면 스타일이 서로 충돌하는 문제가 발생합니다. 예를 들어 Header 컴포넌트와 Footer 컴포넌트 모두 .title 클래스를 정의했다면, 하나의 스타일 규칙이 두 컴포넌트 모두에 영향을 미칩니다.
CSS 격리(CSS Isolation)는 이 문제를 해결하기 위해 .NET 5부터 도입된 Blazor 내장 기능입니다. 각 컴포넌트에 고유한 스코프 식별자를 자동으로 생성·부여하여, 해당 컴포넌트 내부 HTML 요소에만 스타일이 적용되도록 격리합니다. 별도의 라이브러리 설치 없이 기본 제공되며, 파일 하나만 추가하면 즉시 사용할 수 있습니다.
2. .razor.css 파일 생성 방법
CSS 격리를 적용하려면 컴포넌트 파일과 동일한 폴더에 컴포넌트명.razor.css 파일을 생성합니다. 파일명이 반드시 컴포넌트 파일명과 일치해야 합니다.
예시 프로젝트 구조는 다음과 같습니다:
Pages/
├── Counter.razor ← 컴포넌트 파일
├── Counter.razor.css ← 격리 CSS 파일 (같은 폴더, 같은 이름)
├── Index.razor
└── Index.razor.css
Components/
├── ProductCard.razor
└── ProductCard.razor.css
Blazor는 빌드 시 모든 격리 CSS 파일을 자동으로 수집하여 {프로젝트명}.styles.css라는 번들 파일로 합칩니다. 이 번들 파일은 프로젝트 템플릿 기본 생성 시 App.razor(Blazor WebAssembly) 또는 _Host.cshtml(Blazor Server)에 이미 포함되어 있으므로, 개발자가 별도로 링크 태그를 추가할 필요가 없습니다.
3. 빌드 시 스코프 원리
Blazor는 빌드 과정에서 각 격리 CSS 파일에 대해 고유한 스코프 속성(b-xxxxxxxx 형태)을 자동 생성합니다. 이 속성은 해당 컴포넌트의 모든 HTML 요소에 자동으로 추가되고, CSS 선택자에도 동일하게 적용되어 스타일이 격리됩니다.
개발자가 작성하는 코드와 브라우저에서 실제 렌더링되는 코드를 비교해 보겠습니다:
<!-- 개발자가 작성한 Counter.razor -->
<h3 class='counter-title'>카운터</h3>
<!-- 브라우저에서 실제 렌더링된 HTML -->
<h3 class='counter-title' b-3xxtsmhmb0>카운터</h3>
/* 개발자가 작성한 Counter.razor.css */
.counter-title {
color: navy;
font-size: 1.8rem;
}
/* 빌드 후 실제 변환된 CSS (브라우저에 적용됨) */
.counter-title[b-3xxtsmhmb0] {
color: navy;
font-size: 1.8rem;
}
이 방식 덕분에 .counter-title 스타일은 오직 Counter 컴포넌트 내부 요소에만 적용되며, 다른 컴포넌트의 동일한 클래스명에는 전혀 영향을 주지 않습니다.
| 항목 | 글로벌 CSS | 격리 CSS |
|---|---|---|
| 적용 범위 | 전체 앱 | 해당 컴포넌트만 |
| 파일 위치 | wwwroot/css/ | 컴포넌트와 동일 폴더 |
| 파일 확장자 | .css | .razor.css |
| 스타일 충돌 위험 | 있음 | 없음 |
| 스코프 처리 | 없음 (그대로 사용) | 빌드 시 자동 스코프 주입 |
| 적합한 용도 | 공통 스타일, 리셋, 폰트 | 컴포넌트 전용 스타일 |
4. ::deep 선택자 — 자식 컴포넌트에 스타일 전파하기
CSS 격리의 스코프는 해당 컴포넌트의 직접적인 HTML 요소에만 적용됩니다. 만약 부모 컴포넌트의 격리 CSS에서 자식 컴포넌트 내부 요소에 스타일을 적용하려면 ::deep 선택자를 사용해야 합니다.
/* ParentComponent.razor.css */
/* ❌ 이 방식은 자식 컴포넌트 내부 h3에 적용되지 않음 */
.wrapper h3 {
color: red;
}
/* ✅ ::deep를 추가하면 자식 컴포넌트의 h3에도 적용됨 */
.wrapper ::deep h3 {
color: darkgreen;
border-bottom: 2px solid green;
}
/* ✅ 특정 클래스 전체를 자식까지 적용 */
::deep .btn {
border-radius: 8px;
font-weight: bold;
}
::deep는 반드시 격리 CSS 파일(.razor.css) 내에서만 효과가 있습니다. 일반 CSS 파일에서는 동작하지 않습니다. 또한 .wrapper ::deep h3처럼 앞에 부모 선택자를 붙여 사용 범위를 명확히 제한하는 것이 권장됩니다. ::deep만 단독으로 사용하면 컴포넌트 전체 하위 트리에 영향을 줄 수 있습니다.
5. 인라인 스타일(Inline Style)
CSS 파일 없이도 HTML 요소에 직접 스타일을 지정할 수 있습니다. Blazor에서도 일반 HTML과 동일하게 style 속성을 사용합니다. 가장 간단한 형태는 고정 인라인 스타일입니다:
<h3 style='color: navy; font-size: 1.5rem; font-weight: bold;'>
인라인 스타일 적용 예시
</h3>
<p style='background-color: #fffde7; padding: 12px; border-left: 4px solid #ffc107;'>
경고 박스 스타일
</p>
Blazor의 강력한 점은 C# 변수나 표현식으로 동적 인라인 스타일을 만들 수 있다는 것입니다. style='@변수명' 형태로 C# 문자열 변수를 style 속성에 직접 바인딩합니다:
@* 동적 스타일 바인딩 예시 *@
<p style='@dynamicStyle'>동적으로 바뀌는 스타일 텍스트</p>
<button @onclick='ToggleHighlight'>강조 전환</button>
@code {
private string dynamicStyle = "color: black; font-weight: normal;";
private bool isHighlighted = false;
private void ToggleHighlight()
{
isHighlighted = !isHighlighted;
dynamicStyle = isHighlighted
? "color: red; font-weight: bold; background-color: yellow; padding: 4px 8px;"
: "color: black; font-weight: normal;";
}
}
동적 클래스 바인딩도 자주 사용되는 패턴입니다:
@* 조건에 따라 클래스 전환 *@
<div class='@(isActive ? "card active" : "card")'>
내용
</div>
@* 여러 클래스 조합 *@
<button class='btn @(isLoading ? "btn-loading" : "")'>
@(isLoading ? "처리 중..." : "확인")
</button>
💡 예제 & 실습
CSS 격리를 실제로 활용하는 완전한 카드 컴포넌트를 만들어 보겠습니다. ProductCard.razor와 ProductCard.razor.css 두 파일을 생성합니다.
Step 1. 컴포넌트 파일 생성 — ProductCard.razor
@* Components/ProductCard.razor *@
<div class='card-container'>
<div class='card-header'>
<span class='card-badge'>@Category</span>
</div>
<div class='card-body'>
<h4 class='card-title'>@Title</h4>
<p class='card-desc'>@Description</p>
<span class='card-price'>@Price.ToString("N0")원</span>
</div>
<div class='card-footer'>
<button class='btn-buy @(IsSoldOut ? "btn-soldout" : "")'
@onclick='HandleBuy'
disabled='@IsSoldOut'>
@(IsSoldOut ? "품절" : "구매하기")
</button>
</div>
</div>
@code {
[Parameter] public string Title { get; set; } = "상품명";
[Parameter] public string Description { get; set; } = "상품 설명";
[Parameter] public string Category { get; set; } = "일반";
[Parameter] public decimal Price { get; set; } = 0;
[Parameter] public bool IsSoldOut { get; set; } = false;
[Parameter] public EventCallback OnBuy { get; set; }
private async Task HandleBuy()
{
if (!IsSoldOut)
await OnBuy.InvokeAsync();
}
}
Step 2. 격리 CSS 파일 생성 — ProductCard.razor.css
/* Components/ProductCard.razor.css */
/* 이 파일의 모든 스타일은 ProductCard 컴포넌트에만 적용됨 */
.card-container {
border: 1px solid #e0e0e0;
border-radius: 12px;
overflow: hidden;
max-width: 300px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s ease;
background-color: #ffffff;
}
.card-container:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
transform: translateY(-2px);
transition: all 0.2s ease;
}
.card-header {
background-color: #0d6efd;
padding: 10px 16px;
}
.card-badge {
color: white;
font-size: 0.78rem;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.card-body {
padding: 16px;
}
.card-title {
color: #1a1a2e;
font-size: 1.15rem;
font-weight: bold;
margin: 0 0 8px 0;
}
.card-desc {
color: #666;
font-size: 0.9rem;
line-height: 1.6;
margin-bottom: 12px;
}
.card-price {
color: #e63946;
font-size: 1.4rem;
font-weight: bold;
}
.card-footer {
padding: 12px 16px;
background-color: #f8f9fa;
border-top: 1px solid #e0e0e0;
}
.btn-buy {
width: 100%;
padding: 10px;
background-color: #0d6efd;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
font-weight: bold;
transition: background-color 0.2s ease;
}
.btn-buy:hover:not(:disabled) {
background-color: #0b5ed7;
}
.btn-soldout {
background-color: #adb5bd;
cursor: not-allowed;
}
Step 3. 부모 페이지에서 컴포넌트 사용 — Index.razor
@page "/"
@using YourProject.Components
<h3 class='page-section-title'>추천 상품</h3>
<div class='product-grid'>
<ProductCard Title='무선 노트북'
Description='가볍고 빠른 업무용 노트북. 배터리 12시간'
Category='전자제품'
Price='1299000'
OnBuy='@(() => ShowAlert("노트북 구매!"))' />
<ProductCard Title='인체공학 마우스'
Description='손목 부담을 줄인 무선 마우스'
Category='주변기기'
Price='59000'
IsSoldOut='true' />
<ProductCard Title='USB-C 허브'
Description='7-in-1 멀티포트 허브'
Category='주변기기'
Price='39000'
OnBuy='@(() => ShowAlert("허브 구매!"))' />
</div>
@code {
private void ShowAlert(string message)
{
Console.WriteLine(message);
}
}
Index.razor에서 .page-section-title이나 .product-grid 클래스를 정의하더라도, ProductCard.razor.css의 .card-title·.btn-buy 등의 스타일은 오직 ProductCard 컴포넌트 내부에만 적용됩니다. 다른 페이지에서 동일한 클래스명을 써도 스타일 충돌이 발생하지 않습니다.
Step 4. ::deep로 자식 컴포넌트 스타일 적용 — Index.razor.css
/* Index.razor.css */
.product-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
padding: 20px 0;
}
/* ProductCard 내부의 .card-title에 추가 스타일 적용 */
.product-grid ::deep .card-title {
font-family: 'Noto Sans KR', sans-serif;
}
/* ProductCard 내부의 모든 버튼에 공통 스타일 적용 */
.product-grid ::deep button {
letter-spacing: 0.5px;
}
⚠️ 자주 틀리는 것 / 주의사항
-
파일명 불일치:
Counter.razor의 격리 CSS 파일은 반드시Counter.razor.css여야 합니다.counter.css나CounterStyles.razor.css로 생성하면 격리 CSS로 인식되지 않고 일반 파일로 취급됩니다. -
자식 컴포넌트에 스타일이 적용되지 않는 문제: 부모 컴포넌트의 격리 CSS는 기본적으로 자식 컴포넌트 내부에는 적용되지 않습니다. 자식 내부까지 스타일을 전파하려면 반드시
::deep를 사용해야 합니다. -
::deep 남용 주의:
::deep는 격리의 장점을 부분적으로 포기하는 것입니다. 모든 스타일에::deep를 사용하면 CSS 격리의 의미가 없어집니다. 반드시 필요한 경우에만 제한적으로 사용하십시오. -
인라인 스타일의 우선순위: 인라인 스타일(
style속성)은 외부 CSS 파일보다 우선순위(specificity)가 높습니다. 격리 CSS와 인라인 스타일이 같은 속성을 정의하면 인라인 스타일이 이깁니다. 동적 스타일 바인딩 시 의도치 않게 격리 CSS를 덮어쓸 수 있으니 주의하십시오. -
번들 링크 태그 누락: 직접 만든 프로젝트(템플릿 없이 생성)에서는
{프로젝트명}.styles.css링크 태그가 없을 수 있습니다. 격리 CSS가 전혀 적용되지 않는다면App.razor또는_Host.cshtml에 아래 태그가 있는지 확인하십시오.<link href='YourProjectName.styles.css' rel='stylesheet' /> -
동적 클래스 바인딩 시 공백 처리: 아래와 같이 작성 시 클래스가 비어 있을 때 공백이 남습니다. 실제 적용에는 문제없지만, HTML 출력이 지저분해질 수 있습니다.
<!-- 빈 클래스가 남을 수 있음 --> <div class='card @(isActive ? "active" : "")'>...</div> <!-- 더 깔끔한 방식 --> <div class='@(isActive ? "card active" : "card")'>...</div>
🎯 마무리
Blazor의 CSS 격리는 컴포넌트 단위 개발 방식과 완벽하게 어울리는 강력한 스타일링 도구입니다. 각 컴포넌트가 자신만의 독립적인 스타일을 갖게 되므로, 대규모 프로젝트에서도 스타일 충돌 없이 유지보수하기 쉬운 코드를 작성할 수 있습니다.
실전에서는 다음 패턴을 권장합니다: 공통 스타일(폰트, 색상 변수, 레이아웃 그리드, 리셋 CSS)은 글로벌 CSS(wwwroot/css/)로 정의하고, 컴포넌트 고유 스타일은 격리 CSS(.razor.css)로 분리합니다. 인라인 스타일은 C# 로직에 따라 동적으로 바뀌어야 하는 스타일에 한정해서 사용하면 코드 가독성을 높이고 유지보수를 쉽게 만들 수 있습니다.
- CSS 격리는
컴포넌트명.razor.css파일을 컴포넌트와 같은 폴더에 생성하면 자동으로 활성화됩니다. - 빌드 시 고유 스코프 속성(
b-xxxxxxxx)이 HTML 요소와 CSS 선택자에 자동 주입되어, 스타일이 해당 컴포넌트에만 적용됩니다. - 격리 CSS는 기본적으로 자식 컴포넌트 내부에는 적용되지 않으며,
::deep선택자를 사용해야 자식 내부까지 스타일을 전파할 수 있습니다. - 인라인 스타일(
style='@변수명')은 C# 변수·표현식과 바인딩하여 조건에 따라 동적으로 스타일을 변경할 수 있습니다. - 공통 스타일은 글로벌 CSS, 컴포넌트 전용 스타일은 격리 CSS로 분리하는 것이 Blazor 스타일링의 모범 사례입니다.