이모지 아이콘을 SVG로 바꾸다가, 더 큰 원인을 발견해 함께 고쳤다.
■ 폰트: 이름만 부르고 파일은 안 받아오고 있었다
global.css에 font-family: "Pretendard"가 적혀 있었지만 @font-face도 CDN
링크도 없었다. 그래서 이 글꼴이 따로 깔린 사람 외에는 전원 맑은 고딕으로
보고 있었다 — 앱과 학습 문서 19개 전부. 에러가 안 나서 조용히 넘어간 케이스.
- PretendardStdVariable.woff2(285KB)를 public/fonts에 self-host
· CDN 의존 없음 → 외부 서비스가 죽어도, 오프라인에서도 안 깨진다
· variable font → 파일 하나가 굵기 45~920을 전부 담는다
· font-display: swap → 받는 동안 흰 화면(FOIT) 방지
- 전체판(2MB) 대신 Std판(상용 한글 2,350자)을 골랐다. 앱이 쓰는 한글
1,276자가 Std에 전부 있는지 브라우저에서 실측 확인했다(폴백 0자).
- 학습 문서 19개에도 같은 @font-face 적용
■ 아이콘: 이모지 95종 → Lucide 132개
이모지는 OS마다 다른 그림으로 렌더링되고, 굵기·색을 통제할 수 없고,
스크린리더가 "bust in silhouette"처럼 읽는다.
- 카탈로그의 icon: '📜'(문자) → Icon: ScrollText(컴포넌트)로 전환
- strokeWidth={1.5}로 굵기 통일, aria-hidden으로 장식임을 명시
- 개별 import만 사용(트리셰이킹) → gzip +14KB
- slug: null인 '개발 환경 설치 가이드' 항목이 일괄 변환에서 누락돼
Icon이 undefined가 될 뻔한 것을 정합성 검사(항목 수 vs Icon 수)로 잡았다
■ 토큰: 여백·글자 크기·굵기를 :root로 승격
--space-1~7(4의 배수), --text-xs~2xl, --weight-*, --radius-sm/lg/full.
값을 직접 박으면 다크 모드에서 깨지고 화면마다 미묘하게 어긋난다.
CSS 프레임워크는 도입하지 않았다 — 손으로 쓴 global.css와 충돌하고,
교재로서 "왜 이렇게 썼는지"가 사라지기 때문이다.
■ /design: 재료를 고르는 화면
색·글자·여백·모서리를 실제 CSS 변수로 렌더링(다크 모드 자동 반영) +
Lucide 아이콘 1,993개 검색·복사. lucide-react는 아이콘 하나를 이름 3개로
내보내(Rocket/RocketIcon/LucideRocket) 5,981개처럼 보이므로 별칭을 걸렀다.
아이콘 전체를 훑어야 해 무거운 화면이라 lazy로 분리했다
→ 첫 화면 번들은 1.9KB만 증가(575.57 → 577.44KB).
검증: ESLint 통과, Vitest 30개 통과, 프로덕션 빌드 성공, 콘솔 에러 0.
브라우저에서 수습 7카테고리 + 초급~특급 4과정 = 11개 화면 전수 확인
(각 12카드/12아이콘/이모지 0).
문서: DESIGN.md(폰트·토큰·비용), ICONS.md(규약·대응표 132행)
205 lines
15 KiB
HTML
205 lines
15 KiB
HTML
<!doctype html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>코드리뷰 실습 가이드 · AWESOMEDEV</title>
|
|
<style>
|
|
/* 웹폰트: 앱과 같은 Pretendard를 문서에도 적용한다(선언만 있고 파일이 없던 문제 수정). */
|
|
@font-face {
|
|
font-family: "Pretendard";
|
|
src: url("/fonts/PretendardStdVariable.woff2") format("woff2-variations");
|
|
font-weight: 45 920;
|
|
font-display: swap;
|
|
}
|
|
|
|
:root {
|
|
--paper: #FAFBFD; --card: #FFFFFF; --ink: #101A28; --ink-soft: #46536A;
|
|
--ink-faint: #7B8798; --line: #E4E8F0; --line-soft: #EFF2F7;
|
|
--primary: #2159C5; --primary-soft: #E5EDFB; --primary-deep: #163F8F;
|
|
--amber: #B9772A; --amber-soft: #F6EBDA;
|
|
--teal: #0E8C86; --teal-soft: #E0F1EF;
|
|
--rose: #B4463C; --rose-soft: #F7E7E4;
|
|
--code-bg: #F1F4F9;
|
|
--shadow: 0 1px 2px rgba(16,26,40,.05), 0 8px 24px -12px rgba(16,26,40,.16);
|
|
--radius: 14px;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--paper: #0B1017; --card: #131A24; --ink: #E8EDF4; --ink-soft: #A4B0C0;
|
|
--ink-faint: #74808F; --line: #263140; --line-soft: #1C242F;
|
|
--primary: #6AA0F0; --primary-soft: #172A44; --primary-deep: #AECBF8;
|
|
--amber: #DBA25C; --amber-soft: #2C2318;
|
|
--teal: #4FC7BE; --teal-soft: #14302E; --rose: #E08A80; --rose-soft: #331E1B;
|
|
--code-bg: #161E28;
|
|
--shadow: 0 1px 2px rgba(0,0,0,.35), 0 10px 30px -14px rgba(0,0,0,.65);
|
|
}
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
|
|
body { margin: 0; background: var(--paper); color: var(--ink);
|
|
font-family: "Pretendard","Apple SD Gothic Neo","Malgun Gothic","Noto Sans KR",system-ui,sans-serif;
|
|
line-height: 1.72; -webkit-font-smoothing: antialiased; letter-spacing: -0.01em; }
|
|
.wrap { max-width: 820px; margin: 0 auto; padding: clamp(20px,4vw,48px); }
|
|
.eyebrow { font-size: 12px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--primary); display: flex; align-items: center; gap: 10px; }
|
|
.eyebrow::before { content:""; width: 22px; height: 2px; background: currentColor; border-radius: 2px; }
|
|
header.mast { border: 1px solid var(--line); border-radius: var(--radius);
|
|
background: radial-gradient(120% 140% at 100% 0%, var(--primary-soft) 0%, transparent 55%), var(--card);
|
|
box-shadow: var(--shadow); padding: clamp(26px,4vw,42px); }
|
|
.wordmark { font-size: 13px; font-weight: 800; letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink-soft); }
|
|
.wordmark b { color: var(--primary); }
|
|
header.mast h1 { font-size: clamp(27px,5.4vw,40px); line-height: 1.15; margin: 16px 0 0; font-weight: 800; letter-spacing: -0.03em; text-wrap: balance; }
|
|
header.mast .lede { margin: 16px 0 0; font-size: clamp(15px,2.4vw,17px); color: var(--ink-soft); max-width: 58ch; }
|
|
.mast-meta { margin-top: 22px; display: flex; flex-wrap: wrap; gap: 10px; }
|
|
.chip { font-size: 13px; font-weight: 600; color: var(--ink-soft); background: var(--paper); border: 1px solid var(--line); padding: 6px 12px; border-radius: 999px; display: inline-flex; align-items: center; gap: 7px; }
|
|
.chip .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--primary); }
|
|
section { margin-top: clamp(30px,5vw,46px); scroll-margin-top: 20px; }
|
|
section > h2 { font-size: clamp(20px,3.4vw,25px); font-weight: 800; letter-spacing: -0.025em; margin: 12px 0 0; text-wrap: balance; }
|
|
section > .sub { color: var(--ink-soft); margin: 9px 0 0; font-size: 15px; max-width: 64ch; }
|
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: clamp(18px,3vw,26px); }
|
|
.mt { margin-top: 16px; }
|
|
p { margin: 0; } strong, b { font-weight: 700; }
|
|
h3 { font-size: 16.5px; font-weight: 800; margin: 0 0 8px; letter-spacing: -0.01em; }
|
|
|
|
.why { display: grid; gap: 12px; margin-top: 16px; }
|
|
.why li { list-style: none; display: grid; grid-template-columns: 26px 1fr; gap: 12px; font-size: 14.5px; color: var(--ink-soft); }
|
|
.why .n { width: 22px; height: 22px; border-radius: 6px; background: var(--primary); color: #fff; font-weight: 800; font-size: 12px; display: grid; place-items: center; margin-top: 3px; }
|
|
@media (prefers-color-scheme: dark){ .why .n { color:#0B1017; } }
|
|
.why b { color: var(--ink); }
|
|
|
|
.flow { display: grid; gap: 10px; margin-top: 16px; counter-reset: fl; }
|
|
.flow .step { display: grid; grid-template-columns: 34px 1fr; gap: 14px; align-items: start; padding: 14px 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); }
|
|
.flow .step::before { counter-increment: fl; content: counter(fl); width: 28px; height: 28px; border-radius: 8px; background: var(--primary-soft); color: var(--primary-deep); font-weight: 800; display: grid; place-items: center; font-variant-numeric: tabular-nums; }
|
|
.flow .step .t { font-weight: 700; color: var(--ink); }
|
|
.flow .step .d { color: var(--ink-soft); font-size: 14px; margin-top: 2px; }
|
|
|
|
.checklist { margin-top: 14px; display: grid; gap: 9px; }
|
|
.checklist .item { display: grid; grid-template-columns: 22px 1fr; gap: 11px; font-size: 14.5px; color: var(--ink-soft); align-items: start; }
|
|
.checklist .box { width: 17px; height: 17px; border: 1.5px solid var(--primary); border-radius: 5px; margin-top: 3px; }
|
|
.checklist b { color: var(--ink); }
|
|
|
|
.ex { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 16px; }
|
|
@media (max-width: 620px){ .ex { grid-template-columns: 1fr; } }
|
|
.ex .col { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; }
|
|
.ex .col .hd { padding: 9px 14px; font-size: 13px; font-weight: 800; }
|
|
.ex .bad .hd { background: var(--rose-soft); color: var(--rose); }
|
|
.ex .good .hd { background: var(--teal-soft); color: var(--teal); }
|
|
.ex .bd { padding: 12px 14px; font-size: 13.5px; color: var(--ink-soft); line-height: 1.6; }
|
|
.ex .bd .q { display: block; margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid var(--line-soft); }
|
|
.ex .bd .q:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: 0; }
|
|
code, .icode { background: var(--code-bg); border-radius: 5px; padding: 1px 6px; font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: 0.9em; }
|
|
|
|
.tip { margin-top: 16px; background: var(--teal-soft); border: 1px solid color-mix(in srgb, var(--teal) 28%, transparent); border-radius: 12px; padding: 14px 16px; font-size: 14px; color: var(--ink-soft); }
|
|
.tip b { color: var(--teal); }
|
|
.warn { margin-top: 16px; background: var(--amber-soft); border: 1px solid color-mix(in srgb, var(--amber) 32%, transparent); border-radius: 12px; padding: 14px 16px; font-size: 14px; color: var(--ink-soft); }
|
|
.warn b { color: var(--amber); }
|
|
|
|
footer { margin-top: 48px; padding-top: 20px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px; color: var(--ink-faint); font-size: 13px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
<header class="mast">
|
|
<div class="wordmark"><b>AWESOMEDEV</b> · 실습 가이드</div>
|
|
<div class="eyebrow" style="margin-top:16px">Code Review</div>
|
|
<h1>코드리뷰 실습 가이드</h1>
|
|
<p class="lede">코드리뷰는 "누가 잘못했나"를 찾는 자리가 아니라, <b>팀의 코드를 함께 좋게 만드는</b> 자리예요.
|
|
5~8주차 티켓 작업 내내, 여러분은 서로의 PR을 리뷰합니다. 리뷰를 <b>잘 주는 법</b>과 <b>잘 받는 법</b>을 여기서 익혀요.</p>
|
|
<div class="mast-meta">
|
|
<span class="chip"><span class="dot"></span>5~8주차 · 상시</span>
|
|
<span class="chip"><span class="dot"></span>도구: Gitea Pull Request</span>
|
|
<span class="chip"><span class="dot"></span>평가 ③ 협업·소통과 연결</span>
|
|
</div>
|
|
</header>
|
|
|
|
<section>
|
|
<div class="eyebrow">Why</div>
|
|
<h2>왜 신입 때부터 리뷰를 배우나</h2>
|
|
<p class="sub">실무 개발자는 코드를 짜는 시간만큼 남의 코드를 읽습니다. 리뷰는 선택이 아니라 일하는 방식이에요.</p>
|
|
<ul class="why">
|
|
<li><span class="n">1</span><div><b>버그는 혼자 못 잡는다.</b> 내 코드의 빈틈은 내 눈에 잘 안 보여요. 다른 눈 하나가 사고를 막습니다 — 우리 감사에서 나온 버그들도 대부분 "한 번 더 읽었으면" 잡혔을 것들이에요.</div></li>
|
|
<li><span class="n">2</span><div><b>남의 코드를 읽으며 는다.</b> 동료가 문제를 어떻게 풀었는지 보는 게 가장 빠른 학습이에요. 리뷰는 공짜 과외입니다.</div></li>
|
|
<li><span class="n">3</span><div><b>코드에 대한 대화가 팀을 만든다.</b> "왜 이렇게 했어?"를 감정 없이 주고받는 연습 — 이게 되는 사람이 함께 일하기 좋은 사람이에요.</div></li>
|
|
<li><span class="n">4</span><div><b>기록이 남는다.</b> PR의 리뷰 코멘트는 여러분이 어떻게 성장했는지 보여주는 증거예요. 평가 ③ 협업·소통의 핵심 근거가 됩니다.</div></li>
|
|
</ul>
|
|
</section>
|
|
|
|
<section>
|
|
<div class="eyebrow">Flow</div>
|
|
<h2>우리 팀 리뷰 흐름</h2>
|
|
<p class="sub">티켓 하나를 끝낼 때마다 이 순서를 돕니다. 4명이라 서로를 다 볼 수 있어요.</p>
|
|
<div class="flow">
|
|
<div class="step"><div><div class="t">브랜치에서 작업하고 PR을 연다</div><div class="d">티켓별 브랜치(예: <code>feat/empty-state</code>)에서 작업 → Gitea에서 Pull Request 생성. 제목·설명에 "무엇을 왜 했는지" + 스크린샷(화면 변경 시)을 적어요.</div></div></div>
|
|
<div class="step"><div><div class="t">리뷰어를 지정한다 — 동료 1명 + 멘토</div><div class="d">동료 수습생 한 명과 멘토를 리뷰어로. 동료끼리 먼저 보는 게 핵심이에요 — 같은 눈높이의 질문이 가장 배움이 큽니다.</div></div></div>
|
|
<div class="step"><div><div class="t">리뷰어는 24시간 안에 코멘트를 남긴다</div><div class="d">아래 체크리스트로 읽고, 줄 단위로 코멘트. 좋은 점도 꼭 하나 남겨요("여기 이렇게 나눈 거 좋네요").</div></div></div>
|
|
<div class="step"><div><div class="t">작성자는 코멘트에 답하고 반영한다</div><div class="d">고치면 "반영했어요", 다르게 생각하면 "이래서 이렇게 했는데 어때요?"로 답. <b>침묵하고 그냥 merge하지 않기.</b></div></div></div>
|
|
<div class="step"><div><div class="t">승인(Approve) 후 merge</div><div class="d">리뷰어가 Approve하면 작성자가 merge. 배포는 멘토와 함께(운영은 학생 사용 시간을 피해서).</div></div></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div class="eyebrow">Reviewer</div>
|
|
<h2>리뷰할 때 — 무엇을 볼까 (체크리스트)</h2>
|
|
<p class="sub">위에서 아래로. 위쪽이 더 중요해요. 스타일 지적보다 "돌아가는가·안전한가"가 먼저입니다.</p>
|
|
<div class="card mt">
|
|
<div class="checklist">
|
|
<div class="item"><span class="box"></span><div><b>정말 동작하나</b> — 직접 받아서 실행해 봤나? 엣지 케이스(0건, 빈 값, 아주 긴 입력)는?</div></div>
|
|
<div class="item"><span class="box"></span><div><b>안전한가</b> — 사용자 입력을 그대로 믿지 않나? 남의 데이터에 접근되지 않나? (우리가 배운 것들)</div></div>
|
|
<div class="item"><span class="box"></span><div><b>읽히나</b> — 3개월 뒤의 내가, 처음 보는 동료가 이 코드를 이해할까? 이름이 하는 일을 말해주나?</div></div>
|
|
<div class="item"><span class="box"></span><div><b>테스트가 있나</b> — 규칙이 있는 로직이면 테스트로 못 박았나?</div></div>
|
|
<div class="item"><span class="box"></span><div><b>범위가 맞나</b> — 티켓과 상관없는 "김에 수정"이 섞여 있지 않나? PR은 작아야 리뷰가 됩니다.</div></div>
|
|
<div class="item"><span class="box"></span><div><b>우리 패턴을 따르나</b> — 옆 코드와 같은 방식인가? (프론트 Field 부품, 백엔드 서비스 계층 등)</div></div>
|
|
</div>
|
|
</div>
|
|
<div class="tip"><b>리뷰의 황금률:</b> 사람이 아니라 코드를 짚는다. "너 왜 이렇게 했어?"(X) → "이 부분은 이러이러해서 이렇게 하면 어떨까요?"(O). 질문형으로, 근거와 함께.</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div class="eyebrow">Examples</div>
|
|
<h2>같은 지적, 다르게 쓰기</h2>
|
|
<p class="sub">내용이 맞아도 말투가 팀을 만들거나 무너뜨려요. 왼쪽처럼 말고 오른쪽처럼.</p>
|
|
<div class="ex">
|
|
<div class="col bad">
|
|
<div class="hd">🚫 이렇게 말고</div>
|
|
<div class="bd">
|
|
<span class="q">"이거 버그임"</span>
|
|
<span class="q">"왜 여기서 컴포넌트를 또 만들었어요?"</span>
|
|
<span class="q">"그냥 다 지우고 다시 하세요"</span>
|
|
<span class="q">(코멘트 없이 Approve만 누름)</span>
|
|
</div>
|
|
</div>
|
|
<div class="col good">
|
|
<div class="hd">✅ 이렇게</div>
|
|
<div class="bd">
|
|
<span class="q">"0건일 때 <code>.map()</code>이 빈 배열이라 화면이 비어요. 빈 상태 안내를 넣으면 어떨까요?"</span>
|
|
<span class="q">"컴포넌트를 함수 안에 정의하면 렌더마다 새로 만들어져 포커스가 풀려요(가입 폼에서 났던 그 버그!). 밖으로 빼면 어떨까요?"</span>
|
|
<span class="q">"방향은 좋아요! 이 함수만 두 가지 일을 해서, 조회와 저장을 나누면 더 읽기 쉬울 것 같아요."</span>
|
|
<span class="q">"여기 이름 나눈 거 깔끔하네요 👍 한 가지만: …"</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<div class="eyebrow">Author</div>
|
|
<h2>리뷰받을 때 — 잘 받는 것도 실력</h2>
|
|
<p class="sub">리뷰는 내 코드에 대한 것이지 나에 대한 게 아니에요. 방어하지 말고 배우면 됩니다.</p>
|
|
<div class="card mt">
|
|
<div class="checklist">
|
|
<div class="item"><span class="box"></span><div><b>고맙다고 시작한다.</b> 리뷰어는 내 시간을 아껴 준 거예요.</div></div>
|
|
<div class="item"><span class="box"></span><div><b>이해가 안 되면 되묻는다.</b> "이 부분 왜 그런지 조금만 더 알려줄 수 있어요?" — 몰라서 묻는 건 부끄러운 게 아니에요.</div></div>
|
|
<div class="item"><span class="box"></span><div><b>동의하지 않으면 근거로 답한다.</b> 감정이 아니라 "이래서 이렇게 했는데, 어떻게 생각해요?"로.</div></div>
|
|
<div class="item"><span class="box"></span><div><b>고쳤으면 알린다.</b> 코멘트마다 "반영했어요" / "이건 이래서 그대로 뒀어요"로 마무리.</div></div>
|
|
</div>
|
|
</div>
|
|
<div class="warn"><b>하지 말 것:</b> 리뷰를 무시하고 조용히 merge / "그냥 되게만 하면 되잖아요" / 지적을 인신공격으로 받기. 리뷰를 잘 받는 사람이 가장 빨리 큽니다.</div>
|
|
</section>
|
|
|
|
<footer>
|
|
<span>AWESOMEDEV · 코드리뷰 실습 가이드</span>
|
|
<span>평가 ③ 협업·소통 · 5~8주차 상시</span>
|
|
</footer>
|
|
</div>
|
|
</body>
|
|
</html>
|