fix: 기본기 전수 감사(38 에이전트, 172파일)에서 확정된 버그 10건 수정
[major]
- client.js 401 인터셉터: /auth/** 전체를 예외로 — 비밀번호 변경에서 현재 비번
틀리면 세션 만료로 오인해 /login으로 튕기던 버그 (에러 안내 도달 불가였음)
- Submission (assignment_id,user_id) 유니크 제약 추가(엔티티+운영DB) —
동시 제출 시 중복 행 → 이후 해당 과제 재제출 영구 500 방지. 자매 엔티티와 일관화
[minor]
- AuthContext: 마운트 시 /auth/me의 늦은 401이 로그인 성공을 덮는 경쟁 → 함수형 업데이트로 보존
- AssignmentsPage: key에 제출 id 포함 → 첫 제출 직후 카드 리마운트로 성공 메시지 소실
→ key를 과제 id로 고정, 메시지는 접히는 영역 밖으로
- CodeEditor: 텍스트 선택 후 Tab 시 커서가 선택 길이만큼 밀림 (selectionEnd+2 → selectionStart+2)
- div.olist>ol 마크업 오류로 목록 번호 이중 표시 — 7개 강좌 10곳 전부 ol.olist로 통일
(감사가 3파일 발견, 전역 grep으로 4파일 추가 확보)
- SignupPage 검증을 서버 규칙과 일치: 아이디 4자+영숫자, 생년월일 과거만
- LoginRequest @NotBlank + @Valid: username 누락 시 NPE 500 → 400
- server.error.include-message: always — 서버의 한국어 에러 사유가 화면까지 전달
('이미 사용 중인 아이디입니다' 등, 그동안 폴백 문구만 보였음)
검증: 테스트 22개 통과, 운영에서 400/409 message/비번변경 에러표시 확인.
각 버그의 원리를 소스 주석에 학습 포인트로 기록.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
03fdde7495
commit
0336213ca2
@ -11,7 +11,14 @@ import java.time.Instant;
|
||||
* 멘토가 피드백을 남기면 status가 SUBMITTED → REVIEWED로 바뀐다.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "submissions")
|
||||
// 학습 포인트: "학생당 과제당 제출물 1개" 규칙의 최종 방어선은 DB 유니크 제약이다.
|
||||
// 서비스의 '있으면 갱신, 없으면 생성' 검사(check-then-insert)는 거의 동시에 온
|
||||
// 두 요청(두 탭에서 동시 제출 등)이 둘 다 "없네? 생성!"을 통과할 수 있다.
|
||||
// 그러면 중복 행이 생기고, 이후 findByAssignmentAndUser(Optional 반환)가 2건을 만나
|
||||
// 그 과제의 재제출이 전부 500으로 죽는다. 제약이 있으면 최악의 경우에도 DB가 한쪽을 거절한다.
|
||||
// (CodingSubmission·Progress·CourseProgress도 같은 이유로 전부 이 제약을 갖고 있다.)
|
||||
@Table(name = "submissions",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"assignment_id", "user_id"}))
|
||||
public class Submission {
|
||||
|
||||
@Id
|
||||
|
||||
@ -55,7 +55,7 @@ public class AuthController {
|
||||
|
||||
/** POST /api/auth/login — 로그인. 성공 시 세션이 만들어지고 사용자 정보를 돌려준다. */
|
||||
@PostMapping("/login")
|
||||
public UserResponse login(@RequestBody LoginRequest loginRequest,
|
||||
public UserResponse login(@Valid @RequestBody LoginRequest loginRequest,
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response) {
|
||||
// 0) 무차별 대입 방어 — 비밀번호를 검증하기 "전에" 잠금부터 확인한다.
|
||||
|
||||
@ -1,11 +1,21 @@
|
||||
package dev.awesomedev.mirim.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 로그인 요청 본문 {username, password}를 담는 DTO다.
|
||||
*
|
||||
* 학습 포인트: record는 필드·생성자·getter를 자동으로 만들어 주는 자바 문법으로,
|
||||
* "데이터를 담기만 하는 그릇"인 DTO에 딱 맞는다.
|
||||
*
|
||||
* ⚠️ @NotBlank가 없던 시절의 버그: {"password":"x"}처럼 username 없이 요청하면
|
||||
* 컨트롤러 첫 줄의 username.toLowerCase()에서 NullPointerException → 500 서버 오류.
|
||||
* 사용자 입력은 "당연히 있겠지"가 통하지 않는다 — 없을 수 있는 모든 것을 검증한다.
|
||||
* (@Valid + @NotBlank 조합이면 null·빈 문자열 모두 400으로 정중히 거절된다.)
|
||||
*/
|
||||
public record LoginRequest(String username, String password) {
|
||||
public record LoginRequest(
|
||||
@NotBlank(message = "아이디를 입력해 주세요.") String username,
|
||||
@NotBlank(message = "비밀번호를 입력해 주세요.") String password
|
||||
) {
|
||||
}
|
||||
|
||||
@ -27,6 +27,14 @@ spring:
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
error:
|
||||
# 학습 포인트: 스프링 기본값은 에러 응답에 message를 "안 싣는" 것이다(never).
|
||||
# 우리는 ResponseStatusException에 한국어 사유("이미 사용 중인 아이디입니다" 등)를
|
||||
# 정성껏 담는데, 이 설정이 없으면 그 문구가 클라이언트에 전달되지 않아
|
||||
# 프론트의 err.response.data?.message가 항상 undefined였다(사용자는 두루뭉술한
|
||||
# 폴백 문구만 봄). always로 켜서 서버가 쓴 안내가 화면까지 닿게 한다.
|
||||
# 주의: 예외 메시지가 노출되므로, 예외에는 내부 정보(SQL, 경로 등)를 담지 말 것.
|
||||
include-message: always
|
||||
servlet:
|
||||
session:
|
||||
timeout: 8h # 하루 근무 동안 로그인 유지
|
||||
|
||||
@ -21,10 +21,17 @@ export function AuthProvider({ children }) {
|
||||
|
||||
useEffect(() => {
|
||||
// 앱 최초 로딩 시 세션 쿠키가 살아 있는지 서버에 물어본다.
|
||||
//
|
||||
// 학습 포인트 — 비동기 경쟁(race condition) 방어.
|
||||
// 이 요청이 끝나기 전에 사용자가 로그인을 마칠 수 있다(자동완성 + 빠른 제출).
|
||||
// 그때 뒤늦게 도착한 /auth/me의 401이 setUser(null)로 로그인 결과를 덮으면,
|
||||
// 로그인은 성공했는데 화면은 로그인 페이지로 튕기는 황당한 버그가 된다.
|
||||
// 그래서 함수형 업데이트로 "이미 로그인돼 있으면 건드리지 않는다":
|
||||
// prev가 있으면(=login()이 먼저 끝남) 그대로 두고, 없을 때만 결과를 적용한다.
|
||||
client
|
||||
.get('/auth/me')
|
||||
.then((res) => setUser(res.data))
|
||||
.catch(() => setUser(null)) // 401이면 비로그인 상태
|
||||
.then((res) => setUser((prev) => prev ?? res.data))
|
||||
.catch(() => setUser((prev) => prev)) // 401 = 비로그인. 단, 먼저 끝난 로그인은 보존
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
|
||||
@ -21,11 +21,15 @@ client.interceptors.response.use(
|
||||
(error) => {
|
||||
const status = error.response?.status;
|
||||
const url = error.config?.url || '';
|
||||
// 예외: 로그인 시도 자체의 401(비밀번호 틀림)과
|
||||
// 로그인 여부 확인용 /auth/me의 401은 리다이렉트하면 안 된다.
|
||||
// (로그인 페이지에서 무한 새로고침이 되어버린다.)
|
||||
const isAuthCheck = url.includes('/auth/login') || url.includes('/auth/me');
|
||||
if (status === 401 && !isAuthCheck && window.location.pathname !== '/login') {
|
||||
// 예외: /auth/** 의 401은 "세션 만료"가 아니라 그 기능 자체의 결과다 —
|
||||
// 로그인 실패(비밀번호 틀림), /auth/me(비로그인 확인),
|
||||
// 비밀번호 변경(현재 비밀번호 틀림) 모두 각 페이지가 직접 안내한다.
|
||||
// ⚠️ 실제로 났던 버그: 처음엔 login/me만 예외로 뒀더니, 비밀번호 변경에서
|
||||
// 현재 비밀번호를 틀리면 이 인터셉터가 "세션 만료"로 오인해 /login으로 튕겨 버려
|
||||
// 사용자가 에러 안내를 영영 못 봤다. 인터셉터처럼 "전역으로 가로채는" 코드는
|
||||
// 예외 목록이 좁으면 멀쩡한 기능을 잡아먹는다 — 의미 단위(/auth/ 전체)로 묶는 이유.
|
||||
const isAuthEndpoint = url.includes('/auth/');
|
||||
if (status === 401 && !isAuthEndpoint && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
|
||||
@ -34,9 +34,13 @@ export default function CodeEditor({ value, onChange, readOnly = false, minRows
|
||||
const next = value.slice(0, selectionStart) + ' ' + value.slice(selectionEnd);
|
||||
onChange(next);
|
||||
// 값이 바뀐 뒤에 커서를 옮겨야 하므로 다음 렌더 시점으로 미룬다.
|
||||
// 커서는 "삽입한 공백 2칸 뒤" = selectionStart + 2.
|
||||
// ⚠️ 원래 selectionEnd + 2로 잘못 계산했었다 — 텍스트를 선택한 채 Tab을 누르면
|
||||
// 선택 영역이 공백으로 치환되는데, 커서가 선택 길이만큼 오른쪽으로 밀렸다.
|
||||
// (선택이 없을 때는 start == end라 우연히 맞아서 눈에 안 띄었던 버그.)
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = selectionEnd + 2;
|
||||
textarea.selectionEnd = selectionEnd + 2;
|
||||
textarea.selectionStart = selectionStart + 2;
|
||||
textarea.selectionEnd = selectionStart + 2;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -89,9 +89,6 @@ function AssignmentCard({ assignment, submission, onSubmitted }) {
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
{savedMessage && (
|
||||
<p style={{ color: 'var(--teal)', fontSize: 14, marginBottom: 8 }}>{savedMessage}</p>
|
||||
)}
|
||||
<button className="btn btn-primary" type="submit" disabled={saving}>
|
||||
{saving ? '제출 중...' : submission ? '다시 제출하기' : '제출하기'}
|
||||
</button>
|
||||
@ -102,6 +99,14 @@ function AssignmentCard({ assignment, submission, onSubmitted }) {
|
||||
)}
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{/* 성공 메시지는 details "바깥"에 둔다 — 첫 제출 시 폼이 접혀도(open={!submission})
|
||||
메시지는 계속 보여야 하니까. 접히는 영역 안에 두면 성공하자마자 같이 숨어 버린다. */}
|
||||
{savedMessage && (
|
||||
<p style={{ color: 'var(--teal)', fontSize: 14, marginTop: 10, marginBottom: 0 }}>
|
||||
✅ {savedMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -213,9 +218,13 @@ export default function AssignmentsPage() {
|
||||
</div>
|
||||
{groupAssignments.map((assignment) => (
|
||||
<AssignmentCard
|
||||
// 학습 포인트: key에 "과제id-제출여부"를 함께 넣어,
|
||||
// 제출 데이터가 도착하면 카드가 새 초기값(내 제출 내용)으로 다시 만들어지게 한다.
|
||||
key={`${assignment.id}-${findSubmission(assignment.id)?.id ?? 'none'}`}
|
||||
// 학습 포인트: key는 과제 id만 — 안정된 값이어야 한다.
|
||||
// ⚠️ 원래 key에 제출 id까지 넣었었다("id-none" → "id-123"). 그런데 key가
|
||||
// 바뀌면 React는 "다른 카드"로 보고 통째로 새로 만들기 때문에(리마운트),
|
||||
// 첫 제출 직후 '제출 완료!' 메시지와 입력 상태가 몽땅 초기화되어
|
||||
// 사용자는 성공 안내를 보지 못했다. (과제·제출 데이터는 Promise.all로
|
||||
// 함께 도착하므로 제출 id를 key에 넣을 이유도 애초에 없었다.)
|
||||
key={assignment.id}
|
||||
assignment={assignment}
|
||||
submission={findSubmission(assignment.id)}
|
||||
onSubmitted={loadMySubmissions}
|
||||
|
||||
@ -72,14 +72,21 @@ export default function SignupPage() {
|
||||
}
|
||||
|
||||
// 학습 포인트: 프론트 검증은 편의(즉시 안내), 진짜 검증은 서버(우회 불가). 둘 다 있어야 한다.
|
||||
// ⚠️ 그리고 둘의 "규칙이 같아야" 한다 — 프론트가 느슨하면 사용자는 폼을 다 채운 뒤에야
|
||||
// 서버 400을 만난다. 아래 아이디·생년월일 규칙은 서버 SignupRequest의 검증과 일부러 똑같다.
|
||||
function validate() {
|
||||
if (!form.username.trim()) return '아이디를 입력해 주세요.';
|
||||
if (!/^[a-zA-Z0-9]{4,}$/.test(form.username.trim()))
|
||||
return '아이디는 영문·숫자만으로 4자 이상이어야 해요.';
|
||||
if (form.password.length < 8) return '비밀번호는 8자 이상이어야 해요.';
|
||||
if (form.password !== form.passwordConfirm) return '비밀번호 확인이 일치하지 않아요.';
|
||||
if (!form.name.trim()) return '이름을 입력해 주세요.';
|
||||
if (!/^[a-z0-9._-]{2,40}$/.test(form.companyEmailLocal))
|
||||
return '회사 이메일 아이디는 소문자·숫자·. _ - 만 쓸 수 있어요.';
|
||||
if (!form.birthDate) return '생년월일을 입력해 주세요.';
|
||||
// 서버의 @Past와 동일 규칙 — 오늘·미래 날짜는 생년월일이 될 수 없다.
|
||||
// (문자열 "YYYY-MM-DD"끼리는 사전순 비교가 곧 날짜 비교라 이렇게 써도 안전하다.)
|
||||
if (form.birthDate >= new Date().toISOString().slice(0, 10))
|
||||
return '생년월일을 다시 확인해 주세요 — 오늘 이전 날짜여야 해요.';
|
||||
if (!/^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$/.test(form.phone)) return '연락처 형식을 확인해 주세요.';
|
||||
if (!form.address.trim()) return '주소를 입력해 주세요.';
|
||||
if (!form.school.trim()) return '학교를 입력해 주세요.';
|
||||
|
||||
@ -276,8 +276,7 @@ export default function CloudNetworkPage() {
|
||||
배치합니다. 핵심 구분은 딱 하나 — <strong>인터넷에서 직접 들어올 수 있느냐</strong>예요.
|
||||
</p>
|
||||
<Code>{CODE_SUBNET}</Code>
|
||||
<div className="olist">
|
||||
<ol>
|
||||
<ol className="olist">
|
||||
<li>
|
||||
<strong>퍼블릭 서브넷</strong> — 인터넷 게이트웨이(정문)와 연결된 대로변.
|
||||
웹 서버처럼 <strong>손님(사용자)을 직접 맞아야 하는 것</strong>만 여기에 둬요.
|
||||
@ -288,7 +287,6 @@ export default function CloudNetworkPage() {
|
||||
아무리 두드려도 애초에 길이 없어요.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<svg className="diagram" viewBox="0 0 640 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="퍼블릭 서브넷과 프라이빗 서브넷 구조: 인터넷에서 게이트웨이(정문)를 지나 퍼블릭 서브넷의 웹 서버까지만 길이 있고, 프라이빗 서브넷의 데이터베이스로 가는 길은 없다">
|
||||
<rect x="196" y="24" width="420" height="260" rx="10" fill="none" stroke="var(--line)" strokeWidth="1.5" />
|
||||
<text x="210" y="46" fontSize="12.5" fontWeight="600" fill="var(--ink)">VPC (10.0.0.0/16)</text>
|
||||
|
||||
@ -313,14 +313,12 @@ export default function DebuggingPage() {
|
||||
로그 하나하나가 <strong>"이 지점에서 이 값은 뭐지?"라는 질문</strong>이 되도록 찍는 게 정석이에요.
|
||||
</p>
|
||||
<Code>{CODE_CONSOLE_LOG}</Code>
|
||||
<div className="olist">
|
||||
<ol>
|
||||
<ol className="olist">
|
||||
<li><strong>라벨을 붙인다</strong> — <span className="icode">'[함수명] 변수명 ='</span> 형식이면 어디서 찍힌 건지 바로 알아요.</li>
|
||||
<li><strong>관문마다 찍는다</strong> — 함수 입구·분기 직전·반환 직전. 데이터가 어디서부터 이상해졌는지 드러납니다.</li>
|
||||
<li><strong>객체는 통째로 찍는다</strong> — 필드 이름을 내 기억이 아니라 실제 데이터로 확인하세요.</li>
|
||||
<li><strong>다 잡으면 지운다</strong> — 디버그 로그를 커밋에 남기면 다음 사람의 콘솔이 쓰레기장이 돼요.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="warn">
|
||||
<b>주의</b> 로그가 <strong>아예 안 찍히는 것</strong>도 중요한 단서입니다!
|
||||
"값이 이상하다"가 아니라 "이 함수 자체가 호출되지 않는다"는 뜻이니까요.
|
||||
@ -456,13 +454,11 @@ export default function DebuggingPage() {
|
||||
부끄러운 일이 아니라 <strong>팀이 알아야 할 정보</strong>입니다.
|
||||
</p>
|
||||
<Code>{CODE_GOOD_QUESTION}</Code>
|
||||
<div className="olist">
|
||||
<ol>
|
||||
<ol className="olist">
|
||||
<li><strong>하려던 것</strong> — 목표를 알아야 답도 목표에 맞출 수 있어요.</li>
|
||||
<li><strong>에러 전문</strong> — 요약·스크린샷 말고 텍스트 통째로. 여러분이 지운 그 줄에 답이 있는 경우가 정말 많아요.</li>
|
||||
<li><strong>재현 방법 + 시도한 것</strong> — 이걸 쓰다가 스스로 답을 찾는 일이 놀랄 만큼 자주 일어납니다.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="tip">
|
||||
<b>직접 확인해 보기 ③</b> 이번 주에 만난 에러 하나를 골라, 위 템플릿대로
|
||||
<strong> Gitea(edu.awesomedevapp.com:3000) 이슈</strong>로 실제로 작성해 보세요.
|
||||
|
||||
@ -375,14 +375,12 @@ export default function DesignSystemPage() {
|
||||
완전히 다른 경험이에요 — 후자를 해 본 사람만 디자인 시스템을 "만들" 수 있습니다.
|
||||
</p>
|
||||
<Code>{CODE_DZ03_TABLE}</Code>
|
||||
<div className="olist">
|
||||
<ol>
|
||||
<ol className="olist">
|
||||
<li>Gitea(edu.awesomedevapp.com:3000)에서 <strong>티켓 DZ-03</strong>을 열고 본인을 담당자로 지정해요.</li>
|
||||
<li>위 표의 ①번부터 순서대로 채워요 — ①은 <span className="icode">global.css</span>, ②·③은 <span className="kbd">F12</span>가 무기예요.</li>
|
||||
<li>완성한 표를 DZ-03에 댓글로 올리고, "우리 플랫폼에 빠져 있다고 느낀 상태·토큰 1가지"도 함께 적어요.</li>
|
||||
<li>멘토 리뷰를 받으면 실습 완료! 지적받은 부분은 표를 고쳐서 다시 올려요.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="tip">
|
||||
<b>막히면</b> 섹션 2의 "직접 확인해 보기 ①"로 돌아가서 :root 보는 법을 복습하세요.
|
||||
값이 <span className="icode">var(--...)</span>로 나오면 그 변수를 :root에서
|
||||
|
||||
@ -218,13 +218,11 @@ export default function TcpUdpPage() {
|
||||
<strong> 3-way 핸드셰이크</strong>(세 번의 악수)라고 부릅니다.
|
||||
</p>
|
||||
<Code>{CODE_HANDSHAKE}</Code>
|
||||
<div className="olist">
|
||||
<ol>
|
||||
<ol className="olist">
|
||||
<li><strong>SYN</strong> — 내 PC가 "대화 시작하고 싶어요" 하고 손을 내밀어요.</li>
|
||||
<li><strong>SYN+ACK</strong> — 서버가 "네 잘 받았어요(ACK), 저도 시작할게요(SYN)" 하고 맞잡아요.</li>
|
||||
<li><strong>ACK</strong> — 내 PC가 "확인했어요!" 하고 악수를 완성해요. 이제 데이터 출발!</li>
|
||||
</ol>
|
||||
</div>
|
||||
<svg className="diagram" viewBox="0 0 640 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="TCP 3-way 핸드셰이크 시퀀스: 내 PC가 SYN을 보내고, 서버가 SYN+ACK로 답하고, 내 PC가 ACK를 보내면 연결이 수립된다">
|
||||
<rect x="75" y="16" width="130" height="38" rx="8" fill="var(--card)" stroke="var(--line)" />
|
||||
<text x="140" y="40" textAnchor="middle" fontSize="14" fontWeight="600" fill="var(--ink)">내 PC</text>
|
||||
@ -390,14 +388,12 @@ export default function TcpUdpPage() {
|
||||
<span className="icode">cmd</span> → 엔터로 터미널을 열 수 있어요.
|
||||
</div>
|
||||
<Code>{CODE_NETSTAT}</Code>
|
||||
<div className="olist">
|
||||
<ol>
|
||||
<ol className="olist">
|
||||
<li>원격 주소가 <span className="icode">:443</span>인 줄을 찾아보세요 — 전부 HTTPS 연결이에요.</li>
|
||||
<li>로컬 주소의 5만 번대 포트에 주목 — 내 쪽 <strong>임시 포트</strong>가 자동 배정된 거예요.</li>
|
||||
<li><span className="icode">tasklist</span>로 PID의 정체를 밝혀 보세요. 대부분 브라우저일 거예요.</li>
|
||||
<li>브라우저를 전부 끄고 다시 실행 — <span className="icode">ESTABLISHED</span> 줄이 확 줄어드는 걸 관찰!</li>
|
||||
</ol>
|
||||
</div>
|
||||
<p>
|
||||
여기서 <span className="icode">ESTABLISHED</span> 한 줄 한 줄이 섹션 2에서 배운
|
||||
3-way 악수가 <strong>성공적으로 끝난 흔적</strong>입니다. 참고로 UDP는 연결이라는
|
||||
|
||||
@ -269,14 +269,12 @@ export default function ToolIntellijPage() {
|
||||
디버거는 다릅니다 — <strong>시간을 멈추고 현장을 직접 걸어다니는</strong> 방식이거든요.
|
||||
</p>
|
||||
<Code>{CODE_DEBUGGER}</Code>
|
||||
<div className="olist">
|
||||
<ol>
|
||||
<ol className="olist">
|
||||
<li>의심 가는 줄에 <strong>브레이크포인트</strong>(빨간 점)를 찍고,</li>
|
||||
<li><strong>벌레 아이콘(Debug)</strong>으로 실행한 뒤,</li>
|
||||
<li>멈춘 순간의 <strong>Variables 창</strong>에서 변수의 진짜 값을 확인 —
|
||||
내 상상과 다른 값이 들어 있는 지점이 바로 버그의 현장입니다.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<p>
|
||||
<span className="kbd">F8</span>(한 줄씩)과 <span className="kbd">F7</span>(메서드 안으로)의
|
||||
차이만 익혀도 충분해요. 특정 변수를 계속 지켜보고 싶으면 우클릭 →
|
||||
|
||||
@ -219,13 +219,11 @@ export default function ToolShortcutsPage() {
|
||||
머릿속 생각도 잠깐 끊깁니다. 단축키가 손에 붙으면 "창 바꿔야지"라는 생각조차 없이
|
||||
손가락이 먼저 움직여요 — 자전거 탈 때 "페달을 밟아야지" 하고 생각하지 않는 것처럼요.
|
||||
</p>
|
||||
<div className="olist">
|
||||
<ol>
|
||||
<ol className="olist">
|
||||
<li>이번 주: 섹션 2~4의 표에서 <strong>내가 자주 하는 동작 5개</strong>만 고른다.</li>
|
||||
<li>그 5개를 쓸 때마다 마우스 대신 단축키로 — 어색해도 참는다.</li>
|
||||
<li>손에 붙으면(대략 3일) 다음 5개를 추가한다.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="tip">
|
||||
<b>한꺼번에 30개 외우려 하지 마세요</b> — 단축키는 암기 과목이 아니라
|
||||
<strong> 운동</strong>이에요. 하루 5개씩, 실제 상황에서 손으로 반복하는 게
|
||||
@ -409,13 +407,11 @@ export default function ToolShortcutsPage() {
|
||||
</p>
|
||||
<Code>{CODE_MISSION}</Code>
|
||||
<Code>{CODE_BINGO}</Code>
|
||||
<div className="olist">
|
||||
<ol>
|
||||
<ol className="olist">
|
||||
<li>미션 1(마우스 없이 창 전환)을 <strong>오늘</strong> 시작한다 — 벌점을 메모장에 정직하게 기록.</li>
|
||||
<li>빙고판을 종이에 그리거나 인쇄해서 모니터 옆에 붙인다.</li>
|
||||
<li>3일 안에 빙고 3줄을 완성하면 멘토에게 자랑한다. (진짜로 자랑해도 됩니다)</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="tip">
|
||||
<b>공부 팁</b> 새 단축키가 손에 붙는 데는 보통 <strong>3일의 어색함</strong>이
|
||||
필요해요. "마우스가 더 빠른데?" 싶은 그 3일을 버티는 게 이 코스의 전부입니다.
|
||||
|
||||
@ -276,13 +276,11 @@ export default function ToolTerminalPage() {
|
||||
모아 주는 마이크로소프트의 공식 터미널 앱이에요. 크롬에서 탭 여러 개 쓰듯이,
|
||||
탭마다 다른 셸을 띄울 수 있죠.
|
||||
</p>
|
||||
<div className="olist">
|
||||
<ol>
|
||||
<ol className="olist">
|
||||
<li>윈도우 11이라면 이미 기본 설치돼 있어요 — 시작 메뉴에서 <strong>"터미널"</strong> 검색.</li>
|
||||
<li>없다면 <strong>Microsoft Store</strong>에서 "Windows Terminal" 검색 → 설치. 끝!</li>
|
||||
<li>열면 기본으로 PowerShell 탭이 하나 떠요. 탭 줄의 <strong>∨</strong> 버튼을 누르면 다른 셸 목록이 나옵니다.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<Code>{CODE_WT_KEYS}</Code>
|
||||
<div className="tip">
|
||||
<b>직접 확인해 보기</b> Windows Terminal을 열고 <span className="kbd">Alt</span> +
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user