챕터 14: 조건부 렌더링 테크닉
서론
실제 웹 애플리케이션에서는 상황에 따라 다른 UI를 보여줘야 하는 경우가 많습니다. 로그인 상태에 따라 다른 메뉴를 보여주고, 데이터 로딩 중에는 로딩 스피너를 표시하며, 에러가 발생하면 에러 메시지를 보여줘야 합니다. 이처럼 조건에 따라 다른 컴포넌트나 요소를 렌더링하는 것을 조건부 렌더링이라고 합니다.
React에서는 JavaScript의 조건문을 활용하여 매우 유연하게 조건부 렌더링을 구현할 수 있습니다. 이번 챕터에서는 다양한 조건부 렌더링 패턴과 실제 활용 사례를 통해 동적인 UI를 만드는 방법을 마스터해보겠습니다.
본론
if문을 사용한 조건부 렌더링
가장 기본적인 방법은 if문을 사용하는 것입니다:
'use client'
import { useState } from 'react'
export default function ConditionalWithIf() {
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [userRole, setUserRole] = useState('guest') // guest, user, admin
// if문을 사용한 조건부 렌더링
const renderUserPanel = () => {
if (!isLoggedIn) {
return (
<div className="bg-gray-100 p-4 rounded">
<p className="mb-2">로그인이 필요합니다</p>
<button
onClick={() => setIsLoggedIn(true)}
className="bg-blue-500 text-white px-4 py-2 rounded"
>
로그인
</button>
</div>
)
}
if (userRole === 'admin') {
return (
<div className="bg-red-100 p-4 rounded">
<h3 className="font-bold text-red-700">관리자 패널</h3>
<p>모든 권한을 가지고 있습니다</p>
<button className="mt-2 bg-red-500 text-white px-4 py-2 rounded">
관리자 설정
</button>
</div>
)
}
if (userRole === 'user') {
return (
<div className="bg-green-100 p-4 rounded">
<h3 className="font-bold text-green-700">사용자 패널</h3>
<p>일반 사용자 권한입니다</p>
<button className="mt-2 bg-green-500 text-white px-4 py-2 rounded">
프로필 설정
</button>
</div>
)
}
return null
}
return (
<div className="p-8 max-w-md mx-auto">
<h2 className="text-2xl font-bold mb-4">사용자 대시보드</h2>
{renderUserPanel()}
{/* 테스트용 컨트롤 */}
{isLoggedIn && (
<div className="mt-4 space-y-2">
<select
value={userRole}
onChange={(e) => setUserRole(e.target.value)}
className="border rounded px-3 py-2 w-full"
>
<option value="guest">게스트</option>
<option value="user">일반 사용자</option>
<option value="admin">관리자</option>
</select>
<button
onClick={() => setIsLoggedIn(false)}
className="bg-gray-500 text-white px-4 py-2 rounded w-full"
>
로그아웃
</button>
</div>
)}
</div>
)
}
삼항 연산자 활용
삼항 연산자는 간단한 조건부 렌더링에 매우 유용합니다:
'use client'
import { useState } from 'react'
export default function TernaryOperator() {
const [isDarkMode, setIsDarkMode] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [notifications, setNotifications] = useState(3)
return (
<div className={isDarkMode ? 'bg-gray-900 text-white' : 'bg-white text-gray-900'}>
<div className="p-8">
<h2 className="text-2xl font-bold mb-4">
{isDarkMode ? '🌙 다크 모드' : '☀️ 라이트 모드'}
</h2>
{/* 간단한 삼항 연산자 */}
<button
onClick={() => setIsDarkMode(!isDarkMode)}
className={`px-4 py-2 rounded ${
isDarkMode
? 'bg-gray-700 hover:bg-gray-600 text-white'
: 'bg-gray-200 hover:bg-gray-300 text-gray-900'
}`}
>
{isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'}
</button>
{/* 중첩된 삼항 연산자 (권장하지 않음) */}
<div className="mt-6">
<div className={`border rounded p-4 ${isDarkMode ? 'border-gray-700' : 'border-gray-300'}`}>
<h3 className="font-bold mb-2">알림</h3>
<p>
{notifications === 0
? '새로운 알림이 없습니다'
: notifications === 1
? '1개의 새로운 알림'
: `${notifications}개의 새로운 알림`}
</p>
{/* 버튼 그룹 */}
<div className="mt-2 space-x-2">
<button
onClick={() => setNotifications(n => n + 1)}
className="text-blue-500"
>
알림 추가
</button>
{notifications > 0 ? (
<button
onClick={() => setNotifications(0)}
className="text-red-500"
>
모두 읽음
</button>
) : null}
</div>
</div>
</div>
{/* 확장/축소 컨텐츠 */}
<div className="mt-6">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 font-bold"
>
<span className="text-xl">
{isExpanded ? '▼' : '▶'}
</span>
상세 정보
</button>
{isExpanded ? (
<div className={`mt-2 p-4 rounded ${
isDarkMode ? 'bg-gray-800' : 'bg-gray-100'
}`}>
<p>이것은 확장된 콘텐츠입니다.</p>
<p>여러 줄의 정보를 포함할 수 있습니다.</p>
<p>클릭하면 숨길 수 있습니다.</p>
</div>
) : (
<p className="mt-2 text-gray-500">
클릭하여 더 보기
</p>
)}
</div>
</div>
</div>
)
}
&& 연산자로 조건부 표시
특정 조건일 때만 요소를 표시하려면 && 연산자가 편리합니다:
'use client'
import { useState } from 'react'
export default function LogicalAndOperator() {
const [showWarning, setShowWarning] = useState(false)
const [hasNewMessage, setHasNewMessage] = useState(true)
const [errors, setErrors] = useState(['에러 1', '에러 2'])
const [isLoading, setIsLoading] = useState(false)
const [user, setUser] = useState({ name: '홍길동', isPremium: true })
return (
<div className="p-8 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold mb-6">&& 연산자 활용</h2>
{/* 경고 메시지 */}
{showWarning && (
<div className="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-4">
<p className="text-yellow-700">
⚠️ 주의: 이것은 중요한 경고 메시지입니다!
</p>
</div>
)}
{/* 새 메시지 배지 */}
<div className="mb-4">
<button className="relative bg-blue-500 text-white px-6 py-2 rounded">
메시지함
{hasNewMessage && (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs px-2 py-1 rounded-full">
NEW
</span>
)}
</button>
</div>
{/* 에러 목록 */}
{errors.length > 0 && (
<div className="bg-red-100 border border-red-400 rounded p-4 mb-4">
<h3 className="font-bold text-red-700 mb-2">오류 발생:</h3>
<ul className="list-disc list-inside text-red-600">
{errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
{/* 로딩 오버레이 */}
{isLoading && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4">로딩 중...</p>
</div>
</div>
)}
{/* 프리미엄 사용자 전용 */}
{user && user.isPremium && (
<div className="bg-gradient-to-r from-purple-400 to-pink-400 text-white p-4 rounded mb-4">
<p className="font-bold">⭐ 프리미엄 회원 전용 콘텐츠</p>
<p>특별한 혜택을 누리세요!</p>
</div>
)}
{/* 컨트롤 버튼들 */}
<div className="space-y-2">
<button
onClick={() => setShowWarning(!showWarning)}
className="bg-yellow-500 text-white px-4 py-2 rounded mr-2"
>
경고 토글
</button>
<button
onClick={() => setHasNewMessage(!hasNewMessage)}
className="bg-blue-500 text-white px-4 py-2 rounded mr-2"
>
새 메시지 토글
</button>
<button
onClick={() => setErrors(errors.length ? [] : ['에러 1', '에러 2'])}
className="bg-red-500 text-white px-4 py-2 rounded mr-2"
>
에러 토글
</button>
<button
onClick={() => {
setIsLoading(true)
setTimeout(() => setIsLoading(false), 2000)
}}
className="bg-green-500 text-white px-4 py-2 rounded"
>
로딩 시작 (2초)
</button>
</div>
</div>
)
}
Switch 문을 활용한 다중 조건
여러 조건을 처리할 때는 switch문이 유용합니다:
'use client'
import { useState } from 'react'
export default function SwitchStatement() {
const [status, setStatus] = useState('idle') // idle, loading, success, error
const [userType, setUserType] = useState('visitor')
// Switch문을 사용한 렌더링
const renderStatusMessage = () => {
switch(status) {
case 'idle':
return (
<div className="bg-gray-100 p-4 rounded">
<p className="text-gray-600">대기 중...</p>
</div>
)
case 'loading':
return (
<div className="bg-blue-100 p-4 rounded">
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500 mr-2"></div>
<p className="text-blue-600">처리 중...</p>
</div>
</div>
)
case 'success':
return (
<div className="bg-green-100 p-4 rounded">
<p className="text-green-600">✅ 성공적으로 완료되었습니다!</p>
</div>
)
case 'error':
return (
<div className="bg-red-100 p-4 rounded">
<p className="text-red-600">❌ 오류가 발생했습니다.</p>
</div>
)
default:
return null
}
}
// 사용자 타입별 UI
const renderUserInterface = () => {
switch(userType) {
case 'visitor':
return (
<div className="border-2 border-gray-300 rounded-lg p-6">
<h3 className="text-xl font-bold mb-3">방문자 화면</h3>
<p className="text-gray-600 mb-4">제한된 기능만 사용 가능합니다</p>
<button className="bg-blue-500 text-white px-4 py-2 rounded">
회원가입
</button>
</div>
)
case 'member':
return (
<div className="border-2 border-blue-300 rounded-lg p-6 bg-blue-50">
<h3 className="text-xl font-bold mb-3 text-blue-700">회원 화면</h3>
<div className="space-y-2">
<button className="w-full bg-blue-500 text-white px-4 py-2 rounded">
프로필 보기
</button>
<button className="w-full bg-blue-500 text-white px-4 py-2 rounded">
설정
</button>
</div>
</div>
)
case 'premium':
return (
<div className="border-2 border-yellow-300 rounded-lg p-6 bg-gradient-to-br from-yellow-50 to-orange-50">
<h3 className="text-xl font-bold mb-3 text-orange-700">
⭐ 프리미엄 회원
</h3>
<div className="space-y-2">
<button className="w-full bg-orange-500 text-white px-4 py-2 rounded">
프리미엄 콘텐츠
</button>
<button className="w-full bg-orange-500 text-white px-4 py-2 rounded">
1:1 상담
</button>
<button className="w-full bg-orange-500 text-white px-4 py-2 rounded">
특별 혜택
</button>
</div>
</div>
)
case 'admin':
return (
<div className="border-2 border-red-300 rounded-lg p-6 bg-red-50">
<h3 className="text-xl font-bold mb-3 text-red-700">
🔧 관리자 화면
</h3>
<div className="space-y-2">
<button className="w-full bg-red-500 text-white px-4 py-2 rounded">
사용자 관리
</button>
<button className="w-full bg-red-500 text-white px-4 py-2 rounded">
시스템 설정
</button>
<button className="w-full bg-red-500 text-white px-4 py-2 rounded">
로그 확인
</button>
</div>
</div>
)
default:
return <p>알 수 없는 사용자 타입</p>
}
}
return (
<div className="p-8 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold mb-6">Switch문 활용</h2>
{/* 상태 메시지 */}
<div className="mb-6">
<h3 className="font-bold mb-2">작업 상태</h3>
{renderStatusMessage()}
<div className="mt-2 space-x-2">
<button
onClick={() => setStatus('idle')}
className="bg-gray-500 text-white px-3 py-1 rounded text-sm"
>
대기
</button>
<button
onClick={() => setStatus('loading')}
className="bg-blue-500 text-white px-3 py-1 rounded text-sm"
>
로딩
</button>
<button
onClick={() => setStatus('success')}
className="bg-green-500 text-white px-3 py-1 rounded text-sm"
>
성공
</button>
<button
onClick={() => setStatus('error')}
className="bg-red-500 text-white px-3 py-1 rounded text-sm"
>
에러
</button>
</div>
</div>
{/* 사용자 타입별 UI */}
<div className="mb-6">
<h3 className="font-bold mb-2">사용자 타입</h3>
<select
value={userType}
onChange={(e) => setUserType(e.target.value)}
className="border rounded px-3 py-2 mb-4 w-full"
>
<option value="visitor">방문자</option>
<option value="member">일반 회원</option>
<option value="premium">프리미엄 회원</option>
<option value="admin">관리자</option>
</select>
{renderUserInterface()}
</div>
</div>
)
}
실습: 동적 폼 검증
조건부 렌더링을 활용한 실시간 폼 검증 시스템을 만들어봅시다:
'use client'
import { useState } from 'react'
export default function DynamicFormValidation() {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: '',
age: '',
agree: false
})
const [touched, setTouched] = useState({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitSuccess, setSubmitSuccess] = useState(false)
// 유효성 검사 함수들
const validators = {
username: (value) => {
if (!value) return '사용자명을 입력하세요'
if (value.length < 3) return '3자 이상 입력하세요'
if (value.length > 20) return '20자 이하로 입력하세요'
if (!/^[a-zA-Z0-9_]+$/.test(value)) return '영문, 숫자, _만 사용 가능합니다'
return null
},
email: (value) => {
if (!value) return '이메일을 입력하세요'
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return '올바른 이메일 형식이 아닙니다'
return null
},
password: (value) => {
if (!value) return '비밀번호를 입력하세요'
if (value.length < 8) return '8자 이상 입력하세요'
if (!/(?=.*[a-z])/.test(value)) return '소문자를 포함해야 합니다'
if (!/(?=.*[A-Z])/.test(value)) return '대문자를 포함해야 합니다'
if (!/(?=.*\d)/.test(value)) return '숫자를 포함해야 합니다'
return null
},
confirmPassword: (value) => {
if (!value) return '비밀번호 확인을 입력하세요'
if (value !== formData.password) return '비밀번호가 일치하지 않습니다'
return null
},
age: (value) => {
if (!value) return '나이를 입력하세요'
const age = parseInt(value)
if (isNaN(age)) return '숫자를 입력하세요'
if (age < 14) return '14세 이상만 가입 가능합니다'
if (age > 120) return '올바른 나이를 입력하세요'
return null
},
agree: (value) => {
if (!value) return '약관에 동의해야 합니다'
return null
}
}
// 에러 확인
const getError = (field) => {
if (!touched[field]) return null
return validators[field](formData[field])
}
// 모든 필드 유효성 확인
const isFormValid = () => {
return Object.keys(validators).every(field =>
validators[field](formData[field]) === null
)
}
// 입력 처리
const handleChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }))
}
// 포커스 아웃 처리
const handleBlur = (field) => {
setTouched(prev => ({ ...prev, [field]: true }))
}
// 제출 처리
const handleSubmit = async (e) => {
e.preventDefault()
// 모든 필드를 touched로 설정
const allTouched = {}
Object.keys(validators).forEach(field => {
allTouched[field] = true
})
setTouched(allTouched)
if (isFormValid()) {
setIsSubmitting(true)
// API 호출 시뮬레이션
setTimeout(() => {
setIsSubmitting(false)
setSubmitSuccess(true)
}, 2000)
}
}
// 성공 화면
if (submitSuccess) {
return (
<div className="max-w-md mx-auto p-8 text-center">
<div className="text-6xl mb-4">🎉</div>
<h2 className="text-2xl font-bold text-green-600 mb-2">
가입 완료!
</h2>
<p className="text-gray-600">
환영합니다, {formData.username}님!
</p>
<button
onClick={() => {
setSubmitSuccess(false)
setFormData({
username: '',
email: '',
password: '',
confirmPassword: '',
age: '',
agree: false
})
setTouched({})
}}
className="mt-4 bg-blue-500 text-white px-6 py-2 rounded"
>
다시 시작
</button>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="max-w-md mx-auto p-8">
<h2 className="text-2xl font-bold mb-6">회원가입</h2>
{/* 사용자명 */}
<div className="mb-4">
<label className="block text-gray-700 mb-2">
사용자명
{touched.username && !getError('username') && (
<span className="text-green-500 ml-2">✓</span>
)}
</label>
<input
type="text"
value={formData.username}
onChange={(e) => handleChange('username', e.target.value)}
onBlur={() => handleBlur('username')}
className={`w-full border rounded px-3 py-2 ${
getError('username')
? 'border-red-500 bg-red-50'
: touched.username
? 'border-green-500 bg-green-50'
: 'border-gray-300'
}`}
/>
{getError('username') && (
<p className="text-red-500 text-sm mt-1">{getError('username')}</p>
)}
</div>
{/* 이메일 */}
<div className="mb-4">
<label className="block text-gray-700 mb-2">
이메일
{touched.email && !getError('email') && (
<span className="text-green-500 ml-2">✓</span>
)}
</label>
<input
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
onBlur={() => handleBlur('email')}
className={`w-full border rounded px-3 py-2 ${
getError('email')
? 'border-red-500 bg-red-50'
: touched.email
? 'border-green-500 bg-green-50'
: 'border-gray-300'
}`}
/>
{getError('email') && (
<p className="text-red-500 text-sm mt-1">{getError('email')}</p>
)}
</div>
{/* 비밀번호 */}
<div className="mb-4">
<label className="block text-gray-700 mb-2">
비밀번호
{touched.password && !getError('password') && (
<span className="text-green-500 ml-2">✓</span>
)}
</label>
<input
type="password"
value={formData.password}
onChange={(e) => handleChange('password', e.target.value)}
onBlur={() => handleBlur('password')}
className={`w-full border rounded px-3 py-2 ${
getError('password')
? 'border-red-500 bg-red-50'
: touched.password
? 'border-green-500 bg-green-50'
: 'border-gray-300'
}`}
/>
{getError('password') && (
<p className="text-red-500 text-sm mt-1">{getError('password')}</p>
)}
{/* 비밀번호 강도 표시 */}
{formData.password && (
<div className="mt-2">
<div className="flex gap-1">
<div className={`h-1 flex-1 rounded ${
formData.password.length >= 8 ? 'bg-green-500' : 'bg-gray-300'
}`}></div>
<div className={`h-1 flex-1 rounded ${
formData.password.length >= 8 && /[a-z]/.test(formData.password) && /[A-Z]/.test(formData.password)
? 'bg-green-500' : 'bg-gray-300'
}`}></div>
<div className={`h-1 flex-1 rounded ${
formData.password.length >= 8 && /[a-z]/.test(formData.password) && /[A-Z]/.test(formData.password) && /\d/.test(formData.password)
? 'bg-green-500' : 'bg-gray-300'
}`}></div>
</div>
</div>
)}
</div>
{/* 비밀번호 확인 - 비밀번호 입력 후에만 표시 */}
{formData.password && (
<div className="mb-4">
<label className="block text-gray-700 mb-2">
비밀번호 확인
{touched.confirmPassword && !getError('confirmPassword') && (
<span className="text-green-500 ml-2">✓</span>
)}
</label>
<input
type="password"
value={formData.confirmPassword}
onChange={(e) => handleChange('confirmPassword', e.target.value)}
onBlur={() => handleBlur('confirmPassword')}
className={`w-full border rounded px-3 py-2 ${
getError('confirmPassword')
? 'border-red-500 bg-red-50'
: touched.confirmPassword
? 'border-green-500 bg-green-50'
: 'border-gray-300'
}`}
/>
{getError('confirmPassword') && (
<p className="text-red-500 text-sm mt-1">{getError('confirmPassword')}</p>
)}
</div>
)}
{/* 나이 */}
<div className="mb-4">
<label className="block text-gray-700 mb-2">
나이
{touched.age && !getError('age') && (
<span className="text-green-500 ml-2">✓</span>
)}
</label>
<input
type="number"
value={formData.age}
onChange={(e) => handleChange('age', e.target.value)}
onBlur={() => handleBlur('age')}
className={`w-full border rounded px-3 py-2 ${
getError('age')
? 'border-red-500 bg-red-50'
: touched.age
? 'border-green-500 bg-green-50'
: 'border-gray-300'
}`}
/>
{getError('age') && (
<p className="text-red-500 text-sm mt-1">{getError('age')}</p>
)}
</div>
{/* 약관 동의 */}
<div className="mb-6">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.agree}
onChange={(e) => {
handleChange('agree', e.target.checked)
handleBlur('agree')
}}
className="mr-2"
/>
<span className="text-gray-700">이용약관에 동의합니다</span>
{touched.agree && formData.agree && (
<span className="text-green-500 ml-2">✓</span>
)}
</label>
{getError('agree') && (
<p className="text-red-500 text-sm mt-1">{getError('agree')}</p>
)}
</div>
{/* 제출 버튼 */}
<button
type="submit"
disabled={isSubmitting || (Object.keys(touched).length > 0 && !isFormValid())}
className={`w-full py-2 rounded font-bold transition-colors ${
isSubmitting || (Object.keys(touched).length > 0 && !isFormValid())
? 'bg-gray-400 cursor-not-allowed text-gray-200'
: 'bg-blue-500 hover:bg-blue-600 text-white'
}`}
>
{isSubmitting ? (
<span className="flex items-center justify-center">
<span className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></span>
처리 중...
</span>
) : (
'가입하기'
)}
</button>
{/* 진행 상황 표시 */}
{Object.keys(touched).length > 0 && (
<div className="mt-4 text-center text-sm text-gray-600">
진행률: {Object.keys(validators).filter(field => !getError(field)).length} / {Object.keys(validators).length}
</div>
)}
</form>
)
}
결론
조건부 렌더링은 React 애플리케이션을 동적이고 인터랙티브하게 만드는 핵심 기술입니다. 이번 챕터에서 우리는 if문, 삼항 연산자, && 연산자, switch문 등 다양한 조건부 렌더링 패턴을 배웠습니다.
각 패턴은 상황에 따라 장단점이 있습니다. 간단한 조건에는 && 연산자나 삼항 연산자가 적합하고, 복잡한 조건에는 if문이나 switch문이 더 읽기 쉽습니다. 중요한 것은 코드의 가독성과 유지보수성을 고려하여 적절한 패턴을 선택하는 것입니다.
조건부 렌더링을 State, Props, 이벤트 처리와 결합하면 사용자의 행동과 애플리케이션 상태에 따라 유연하게 반응하는 UI를 만들 수 있습니다. 다음 챕터에서는 반복 렌더링과 리스트 처리를 다루어, 동적인 데이터 목록을 효율적으로 표시하는 방법을 배워보겠습니다!