React_Day.5 게시판에 날짜와 수정 기능을 붙여 완성하고, 렌더링과 무관하게 값을 들고 있는 useRef , 폼 하나로 추가·수정을 처리하는 학생 관리, 불필요한 리렌더링을 막는 memo 까지 정리한 날. Day.4에서 배열 state의 추가·삭제·수정 패턴을 익혔다. 오늘은 그걸 게시판에 마저 적용하고, 새로운 도구 두 가지( useRef , memo )를 배웠다. 1. 게시판 완성: 작성 시각과 글 수정 글을 쓴 시각을 같이 저장하고, 상세 화면에서 수정 모드 로 바꿔 고칠 수 있게 했다. 날짜 문자열 만들기 const now = new Date() const todayNow = `${now.getFullYear()}-` + `${(now.getMonth() + 1).toString().padStart(2, '0')}-` + `${now.getDate().toString().padStart(2, '0')} ` + `${now.getHours().toString().padStart(2, '0')}:` + `${now.getMinutes().toString().padStart(2, '0')}` // 예) 2026-09-30 17:32 new Date() 로 현재 시각 객체를 만든다. getMonth() 는 0부터 시작 (1월이 0)해서 항상 +1 을 해 준다. padStart(2, '0') : 문자열이 2자리가 되도록 앞에 0 을 채운다. 9 월이 09 로 나온다. 글 작성할 때와 수정할 때 같은 코드를 두 번 쓰게 되는데, 함수로 한 번만 만들어 두면 깔끔하다. const getNow = () => { const now = new Date() const p = (n) => String(n).padStart(2, '0') return `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())} ${p(now.getHours())}:${p(now.getMinutes())}` } 수정 모드 Day.4에서 한 "수정 중인가?"를 state로 기억하는 방식이다. 이번에는 글 하나를 상세로 보는 화면이라 modiMode 라는 true/false 로 처리한다. const [modiMode, setModiMode] = useState(false) const updatePost = () => { // 저장 버튼: posts에서 수정한 글(id가 같은 것)만 새 제목/내용/시각으로 교체한다 const updated = posts.map((x) => x.id === selPost1.id ? { ...x, title: selPost1.title, content: selPost1.content, date: getNow() } : x ) setPosts(updated) // 바뀐 배열을 state에 저장하고 setModiMode(false) // 수정 모드를 끈다 } <div className="post-detail"> {selPost1 && ( <div> {modiMode ? ( <> {/* value를 state와 연결 → 입력창의 내용을 React가 관리한다 */} {/* 입력 → onChange가 새 객체로 selPost1을 바꿈 → 리렌더링 → value에 반영 */} <input type="text" value={selPost1.title} onChange={(e) => setSelPost1({ ...selPost1, title: e.target.value })} /> <textarea value={selPost1.content} onChange={(e) => setSelPost1({ ...selPost1, content: e.target.value })} /> <button className="ModiBtn" onClick={updatePost}>저장</button> </> ) : ( <> <h2>{selPost1.title}</h2> <h2>{selPost1.content}</h2> <p>{selPost1.date}</p> <button className="ModiBtn" onClick={() => setModiMode(true)}>수정</button> </> )} </div> )} </div> 수정 중에는 selPost1 (선택한 글의 복사본)만 바뀌고, 목록( posts )은 저장을 눌러야 바뀐다. 실행해서 확인해 보니 수정 중에는 목록의 제목이 그대로 T1, T2 였고, 저장하자 T1-edit, T2 로 바뀌었다. 저장 전에는 임시 초안을 고치는 셈이다. 돌려 보면서 발견한 문제 두 가지 (1) 저장 직후 상세 화면의 날짜가 옛날 값이다. 저장하면 posts 에는 새 날짜가 들어가지만, 상세 화면이 보여 주는 selPost1 에는 새 날짜가 반영되지 않는다. 목록의 날짜는 갱신됐는데( old1 → 현재 시각) 상세의 날짜는 old1 그대로였다. 다시 그 글을 클릭해야 맞춰진다. 저장할 때 같이 갱신해 주면 된다. setSelPost1({ ...selPost1, date }) // updatePost 안에서 상세용 객체도 같이 갱신 Day.3, Day.4에서 정리한 것처럼 id만 저장하고 posts.find(...) 로 꺼내 쓰는 방식 이면 이 문제 자체가 생기지 않는다. 다만 이 방식은 수정 중인 초안을 별도 state로 따로 들고 있어야 한다. (2) 수정 모드에서 다른 글을 클릭하면 수정 모드가 유지된다. T1 을 수정 중에 T2 를 클릭하면, T2 의 상세가 아니라 T2 의 내용이 담긴 입력창 이 뜬다(입력창 값이 T2 였다). modiMode 가 그대로 true 이기 때문이다. 글을 선택하는 함수에서 수정 모드를 꺼 주면 된다. const selPost = (post) => { setSelPost1(post) setModiMode(false) } 참고로 필기 코드의 updatePost 함수 안에서 또 const updatePost = posts.map(...) 로 같은 이름의 변수를 만들고 있었다. 문법상 오류는 아니지만 바깥 함수 이름을 안쪽이 가려서 읽기 어려워서, 위 코드에서는 updated 로 바꿨다. Day.4에서 짚은 내용은 이 코드에도 그대로 남아 있다. 삭제 버튼이 클릭 가능한 div 안쪽에 있어서 e.stopPropagation() 이 필요하고, id: posts.length + 1 은 삭제 후 id가 겹칠 수 있다. CSS 스타일은 Board.css 로 따로 빼서 import "./Board.css" 로 불러온다. CSS는 직접 다 외워서 쓰기보다 AI를 적극 활용하라 는 조언을 들었고, 이 파일도 그렇게 만들었다. 핵심은 이 정도다. * { margin: 0; padding: 0; box-sizing: border-box; } /* 기본 여백 제거 */ .board-app { padding: 20px; max-width: 600px; margin: 0 auto; /* 가운데 정렬 */ border: 2px solid pink; border-radius: 10px; } .board-item { border: 1px solid gray; margin: 10px 0; padding: 10px; cursor: pointer; border-radius: 5px; transition: 0.2s; } .board-item:hover { background-color: #ffe6f0; } /* 마우스를 올리면 색 변경 */ .post-detail textarea { height: 200px; } box-sizing: border-box 는 padding 과 border 를 width 에 포함해서 계산하게 해서, 입력창을 width: 100% 로 줘도 삐져나오지 않게 한다. 2. useRef: 렌더링과 상관없이 값을 들고 있기 useRef(초기값) 은 { current: 초기값 } 모양의 객체 를 만들어 준다. 이 객체는 다시 렌더링되어도 같은 객체가 유지 되고, current 를 바꿔도 리렌더링이 일어나지 않는다. useState 와 비교하면 이렇다. useState useRef 값을 바꾸면 리렌더링 (화면 갱신) 리렌더링 없음 렌더링 사이에 값 유지 O O 바꾸는 방법 setXxx(새값) ref.current = 새값 값이 바뀌어도 화면은 그대로 const inRef = useRef(1) <button onClick={() => { inRef.current++ console.log(inRef.current) }}>버튼 1 증가</button> 버튼을 두 번 누르면 콘솔에 2 , 3 이 찍힌다. 값은 올라가고 있지만 컴포넌트 함수는 처음 한 번 말고는 다시 실행되지 않았다(렌더링 횟수 1). 화면에 {inRef.current} 를 그려 놓았어도 다음에 렌더링될 때까지는 안 바뀐다. 이전 값을 기억하는 패턴 const cntRef = useRef() // 초기값 없음 → undefined const [init, setInit] = useState(0) useEffect(() => { cntRef.current = init }, [init]) return ( <div> <p>{init}</p> <p>{cntRef.current}</p> <button onClick={() => setInit(init + 1)}>state 1증가</button> </div> ) 버튼을 누를 때마다 화면의 두 숫자가 이렇게 바뀐다. (처음) 0, (비어 있음) 1회 클릭 1, 0 2회 클릭 2, 1 3회 클릭 3, 2 state 가 바뀌어 컴포넌트가 다시 그려질 때 {cntRef.current} 는 아직 이전 값 을 들고 있다. useEffect 는 렌더링이 끝난 뒤에 실행되기 때문이다. 순서는 이렇다. 버튼 클릭 → init 이 1로 바뀜 컴포넌트가 다시 실행되면서 화면을 그린다. 이때 cntRef.current 는 아직 0 이다. 화면이 그려진 뒤에 useEffect 가 실행되어 cntRef.current 를 1 로 바꾼다. (리렌더링은 안 일어나서 화면에는 반영되지 않는다.) 그래서 현재 값과 바로 직전 값 을 나란히 보여 주는 패턴으로 쓸 수 있다. 필기에서는 초기값 없이 useRef() 로 만들었는데, 이 값은 undefined 다. 필기에는 "undefined는 (null, false, 0, "")와 같다"고 적었는데, 모두 falsy (조건문에서 거짓으로 취급)라는 점은 같지만 같은 값은 아니다. useRef() 는 undefined , useRef(null) 은 null 이 들어간다. 참고로 React 공식 문서는 렌더링 중에 ref.current 를 읽어서 화면에 그리는 것 을 권하지 않는다. 위 코드는 "ref는 리렌더링을 일으키지 않는다"는 동작을 눈으로 보려는 실험용이다. state와 ref를 같이 쓸 때 const ref = useRef() const [num, setNum] = useState(0) useEffect(() => { ref.current = num }, [num]) const increase = () => { ref.current++ } // ref만 올린다 버튼은 두 개다. 1증가 는 state 를, ref증가 는 ref 만 올린다. 실행해 보면 이렇다. 1증가 클릭 → 화면: 1, 0 ref증가 클릭 → 화면: 1, 0 (ref는 올랐지만 화면 그대로) ref증가 클릭 → 화면: 1, 0 1증가 클릭 → 화면: 2, 3 ← 리렌더링되는 순간에야 ref에 쌓인 값이 보인다 ref는 바뀌어도 화면을 갱신하지 못하고, 어떤 이유로든 다른 곳에서 리렌더링이 일어나야 그제야 쌓인 값이 화면에 보인다. 타이머 id를 ref에 저장하고 cleanup 하기 useRef 가 가장 많이 쓰이는 곳 중 하나가 타이머 id 보관 이다. const [num, setNum] = useState(0) const ref = useRef(null) useEffect(() => { ref.current = setInterval(() => { setNum((n) => n + 1) // 1초마다 숫자를 올린다 → 1초마다 리렌더링 }, 1000) // cleanup 함수: useEffect 안에서 return하는 함수 return () => clearInterval(ref.current) }, []) return <div>{num}</div> setInterval 은 타이머의 id를 돌려준다. 나중에 clearInterval(id) 로 멈추려면 그 id를 기억해야 하고, 값이 바뀌어도 화면은 상관없으니 ref 에 저장한다. 의존성 배열이 [] 라서 처음 한 번만 타이머를 만든다. setNum((n) => n + 1) 처럼 함수형 업데이트 를 쓴 이유는 Day.2에서 한 것과 같다. 이 effect는 처음 한 번만 만들어져서 안쪽의 num 이 처음 값(0)에 고정 된다. setNum(num + 1) 로 쓰면 계속 0 + 1 만 설정하게 된다. cleanup 함수 는 컴포넌트가 화면에서 사라질 때(언마운트) 실행되어 타이머, 이벤트 리스너처럼 메모리를 차지하는 것들을 정리한다. 필기에는 "언마운트될 때 실행"이라고만 적었는데, 보충하면 의존성 배열의 값이 바뀌어 effect가 다시 실행되기 직전에도 이전 effect의 cleanup이 먼저 실행된다. cleanup이 정말 필요한지 확인해 봤다. 개발 모드의 StrictMode 는 effect를 한 번 실행 → cleanup → 다시 실행 해서 정리가 제대로 짜여 있는지 검사한다. 200ms 간격 타이머로 1.05초 뒤 숫자를 비교했다. 1.05초 뒤 숫자 cleanup 있음 5 (정상) cleanup 없음 10 (타이머가 2개 돌아서 2배 빠름) cleanup을 빼면 첫 번째 타이머가 멈추지 않고 남아 있어서 숫자가 두 배로 올라갔다. 타이머를 만들었으면 정리하는 코드도 같이 쓴다. 3. 학생 정보 관리: 폼 하나로 추가와 수정 Day.4에서는 입력창 state 를 각각 만들었다. 이번에는 입력창이 여러 개일 때 state 하나(객체)로 묶어서 관리하고, 폼과 목록을 컴포넌트로 나눴다. 구조는 이렇다. App (students, selected state + 추가/수정/삭제 함수) ├─ StudentForm (입력 폼, 입력값 state) └─ StudentList (목록, 수정/삭제 버튼) App: 상태와 동작을 한곳에 const App = () => { const [students, setStudents] = useState([]) const [selected, setSelected] = useState(null) // 수정하려고 고른 학생 const onAddUpdate = (student) => { if (student.id) { // 수정: id가 같으면 폼에서 받은 새 객체로 교체, 아니면 기존 객체 유지 setStudents((stu) => stu.map((x) => (x.id === student.id ? student : x))) } else { // 추가: 이전 배열을 복사하고, 폼에서 받은 값(name, age, major)에 고유 id를 붙여 넣는다 setStudents((stu) => [...stu, { ...student, id: Date.now() }]) } setSelected(null) } const onEdit = (stu) => { setSelected(stu) } const onDelete = (id) => { setStudents((stu) => stu.filter((x) => x.id !== id)) } return ( <div> <h2>학생 정보 관리</h2> <StudentForm onSubmit={onAddUpdate} selected={selected} /> <StudentList students={students} onEdit={onEdit} onDelete={onDelete} /> </div> ) } 추가인지 수정인지는 student.id 가 있는지로 구분한다. 새로 입력한 학생에는 id가 없고, 목록에서 골라 온 학생에는 id가 있다. setStudents((stu) => ...) : set 함수에 함수를 넘기면 "현재 최신 state"를 인자로 받는다. Day.2의 함수형 업데이트와 같다. StudentForm: 입력 하나하나가 아니라 객체로 const StudentForm = ({ onSubmit, selected }) => { const [student, setStudent] = useState({ name: '', age: '', major: '' }) const handleChange = (e) => { const { name, value } = e.target // e.target의 name 속성값과 value를 꺼내서 쓴다 setStudent((stu) => ({ ...stu, [name]: value, // 계산된 속성명: name 변수의 '값'이 키가 된다 })) } const handleSubmit = (e) => { e.preventDefault() // form의 기본 동작(페이지 새로고침)을 막는다 if (!student.name) return // 이름이 비어 있으면 무시 onSubmit(student) // 부모가 내려준 함수에 입력한 객체를 넘긴다 setStudent({ name: '', age: '', major: '' }) // 입력 칸 초기화 } // selected가 바뀔 때마다: 선택한 학생이 있으면 폼에 채우고, 없으면 빈 폼으로 useEffect(() => { setStudent(selected || { name: '', age: '', major: '' }) }, [selected]) return ( <form onSubmit={handleSubmit}> <input placeholder="이름" value={student.name} name="name" onChange={handleChange} /> <input placeholder="나이" value={student.age} name="age" onChange={handleChange} /> <input placeholder="전공" value={student.major} name="major" onChange={handleChange} /> <button type="submit">{student.id ? '수정' : '추가'}</button> </form> ) } 여기서 새로 배운 것들이다. name 속성 + [name]: value : 필기에서 헷갈렸던 부분이다. const { name, value } = e.target 의 name 은 객체의 키 이름이 아니라 <input name="age"> 처럼 태그에 적어 둔 name 속성값 이다. 입력창에 name="age" 를 붙여 두면 handleChange 하나로 세 칸을 전부 처리한다. [name]: value 는 대괄호 안의 변수 값을 키로 쓰는 문법(계산된 속성명)이라서, name 이 'age' 이면 { age: value } 가 된다. <form onSubmit> + e.preventDefault() : 폼을 제출하면 브라우저가 기본적으로 페이지를 새로고침한다. 이걸 막아야 React 화면이 유지된다. 버튼에 type="submit" 을 두면 Enter 키로도 제출 된다. useEffect(..., [selected]) : 부모에서 selected 가 바뀌면 폼의 내용을 그 학생 값으로 맞춘다. 선택이 null 이 되면 빈 폼으로 돌아간다. 버튼 글자는 student.id 가 있느냐(수정 모드인지)로 바뀐다. StudentList const StudentList = ({ students, onEdit, onDelete }) => ( <ul> {students.map((stu) => ( <li key={stu.id}> {stu.name}|{stu.age}|{stu.major} <button onClick={() => onEdit(stu)}>수정</button> <button onClick={() => onDelete(stu.id)}>삭제</button> </li> ))} </ul> ) 실행해 보니 흐름은 이랬다. 이름을 비운 채 제출 → 목록 0개 (무시됨) kim / 20 입력 후 제출 → 목록: kim|20, 폼 비워짐 수정 클릭 → 폼에 kim, 20이 채워지고 버튼이 '수정'으로 바뀜 나이를 21로 바꿔 제출 → 목록: kim|21, 버
React_Day.3 자식 컴포넌트에 state 와 setter를 같이 내려주는 법, useEffect 의 의존성 배열, 배열 find 로 하나만 골라 보여주기, 그리고 클릭한 항목의 상세를 띄우는 게시판 구조까지 정리한 날. Day.2에서 props로 값을 부모에서 자식으로 내려보내는 것까지 했다. 오늘은 값뿐 아니라 값을 바꾸는 함수(setter)도 props로 내려보내서 자식이 부모의 state를 바꾸게 만든다. 그 과정에서 쓰는 useEffect 와 find 도 같이 익혔다. 1. children children = 태그와 태그 사이에 들어 있는 무언가 Day.2에서 본 props.children 을 한 줄로 다시 정리했다. <Card>여기</Card> 처럼 여는 태그와 닫는 태그 사이에 넣은 내용이 자식 컴포넌트에서 children 이라는 이름으로 넘어온다. 2. setter를 props로 내려보내기: 좋아요 / 싫어요 투표 자식( HateButton )이 버튼을 갖고, 부모( App )가 숫자를 갖는 구조다. 숫자( good , bad )는 부모의 state 이고, 자식은 그걸 props로 받아서 보여주기만 하다가 , 버튼을 누르면 부모가 내려준 setter 를 호출한다. // App.jsx (부모) import React, { useEffect, useState } from 'react' import HateButton from './HateButton' const App = () => { const [good, setGood] = useState(0) const [bad, setBad] = useState(0) useEffect(() => { console.log('투표수가 변경되었습니다') }, [good, bad]) // good, bad 둘 중 하나라도 바뀔 때마다 useEffect가 실행된다. return ( <div> {/* 좌측은 자식이 갖는 이름, 우측은 부모의 값. 그냥 이름=값 이라고 생각하면 된다. */} <HateButton good={good} bad={bad} setGood={setGood} setBad={setBad} /> <br /><br /> 좋아요:{good}<br /> 싫어요:{bad} </div> ) } export default App // HateButton.jsx (자식) import React from 'react' const HateButton = ({ good, bad, setGood, setBad }) => { return ( <div> <button onClick={() => setGood(good + 1)}>👍{good}</button> <button onClick={() => setBad(bad + 1)}>😡{bad}</button> </div> ) } export default HateButton 흐름은 이렇다. 처음에는 화면에 0 이 표시된다. 자식의 버튼을 눌러 setGood 을 호출하면 부모의 good 값이 바뀐다. state가 바뀌면 App 이 다시 렌더링되고, {good} 자리에 새 값이 반영된다. 자식에게 내려가는 good 도 새 값이 된다. useEffect 도 실행된다. 직접 실행해서 좋아요 2번, 싫어요 1번을 눌러 보니 화면은 좋아요:2 싫어요:1 이 되었다. 여기서 기억할 점은 state는 부모 한 곳에만 있다 는 것이다. 자식은 자기만의 숫자를 따로 갖지 않고, 부모의 것을 빌려 쓴다. 이렇게 하면 부모가 두 숫자를 한꺼번에 알고 있어서 나중에 "좋아요 비율 계산" 같은 걸 부모에서 바로 할 수 있다. (이런 패턴을 흔히 "state 끌어올리기"라고 부른다.) 이름이 같아서 헷갈리는 부분 good={good} 에서 왼쪽은 자식이 받을 props 이름 , 오른쪽은 부모가 가진 변수 다. 이름을 같게 맞춰 두면 자식에서 ({ good, bad, setGood, setBad }) 로 구조 분해해서 바로 쓸 수 있어서 편하다. 이름을 다르게 줘도 동작하지만 자식에서 받는 이름도 그에 맞춰야 한다. 3. useEffect: 값이 바뀔 때 실행하기 useEffect(() => { console.log('투표수가 변경되었습니다') }, [good, bad]) 첫 번째 인자: 실행할 함수 두 번째 인자: 의존성 배열 . 여기에 적은 값 중 하나라도 바뀌면 함수가 다시 실행된다. 배열을 어떻게 쓰느냐에 따라 실행 시점이 달라진다. 두 번째 인자 실행 시점 생략 렌더링될 때마다 [] 처음 한 번만 [good, bad] 처음 한 번 + good 또는 bad 가 바뀔 때마다 필기에는 "바뀔 때마다 실행된다"고만 적었는데, 실제로 돌려 보면 처음 화면이 그려질 때도 한 번 실행된다. effect good=0 bad=0 ← 첫 렌더링 직후 (아직 아무것도 안 눌렀다) effect good=1 bad=0 ← 👍 첫 번째 클릭 effect good=2 bad=0 ← 👍 두 번째 클릭 effect good=2 bad=1 ← 😡 클릭 그래서 "투표수가 변경되었습니다"라는 메시지가 투표하기 전에도 한 번 찍힌다. 정말 "변경됐을 때만" 하고 싶다면 첫 실행인지 구분하는 처리를 따로 해야 한다. 또 하나, 개발 모드에서 <React.StrictMode> 로 감싸져 있으면 첫 실행이 두 번 찍힌다. 이것도 실제로 확인했다. 개발 중에 부작용(effect)이 안전하게 짜여 있는지 검사하려고 React가 일부러 두 번 돌리는 것이고, 배포 빌드에서는 한 번만 실행된다. 로그가 두 번 찍힌다고 코드가 잘못된 게 아니다. 4. 배열 find: 조건에 맞는 첫 번째 하나 찾기 map 이 배열 전체를 돌면서 새 배열을 만든다면, find 는 조건에 맞는 첫 번째 요소 하나 를 돌려준다. const people = [ { name: 'A', age: 25 }, { name: 'B', age: 20 }, { name: 'C', age: 11 }, { name: 'D', age: 63 }, ] const n = people.find((p) => p.age > 20) console.log(n) // { name: 'A', age: 25 } 나이가 20 초과인 사람은 A 와 D 두 명이지만, find 는 처음 찾은 A 에서 멈춘다. 둘 다 필요하면 filter 를 쓴다. people.filter((p) => p.age > 20) // [{A}, {D}] 조건에 맞는 전부를 배열로 숫자 배열로도 해 보았다. const arr = [1, 3, 5, 7] arr.find((a) => a > 10) // undefined ← 조건에 맞는 게 없으면 undefined arr.find((a) => a <= 5) // 1 ← 5 이하 중 '첫 번째'. 1, 3, 5가 다 해당되지만 1에서 멈춘다 arr.findIndex((a) => a > 10) // -1 ← 위치를 알려주는 findIndex는 못 찾으면 -1 주의할 점은 못 찾으면 undefined 가 나온다 는 것이다. 이 값을 그대로 undefined.name 처럼 쓰면 에러가 나므로, 찾은 결과가 없을 수도 있는 상황에서는 확인을 한 번 거쳐야 한다. (5번에서 selPost1 && ... 로 확인하는 것도 같은 이유다.) 필기의 콜백 변수 이름이 바깥 배열과 같은 pro / pro1 식이라 헷갈릴 수 있어서, 위 예시는 콜백 변수를 p 로 짧게 바꿨다. 바깥 변수와 같은 이름을 쓰면 안쪽이 바깥을 가려서(shadowing) 읽기 어려워진다. 5. 버튼으로 사용자 고르기: find + 자식에 객체 통째로 넘기기 사용자 목록 버튼을 누르면 그 사람의 카드가 아래에 표시되는 예제다. 저장하는 것은 객체가 아니라 id 하나 다. // App.jsx import React, { useState } from 'react' import ProCard from './ProCard' const App = () => { const user = [ { id: 1, name: '사용자A', age: 14, job: '개발자' }, { id: 2, name: '사용자B', age: 22, job: '교수' }, { id: 3, name: '사용자C', age: 33, job: '디자이너' }, { id: 4, name: '사용자D', age: 41, job: '경비원' }, ] const [selectUserId, setSelectUserId] = useState(1) // 내가 클릭한 id와 user의 id가 같은 사람을 찾는다. const selUser = user.find((u) => u.id === selectUserId) return ( <div> {user.map((a) => { return ( <button key={a.id} onClick={() => setSelectUserId(a.id)}> {a.name} </button> ) })} <ProCard user1={selUser} /> </div> ) } export default App // ProCard.jsx import React from 'react' const ProCard = ({ user1 }) => { return ( <div> <h1>{user1.name} {user1.age} {user1.job}</h1> </div> ) } export default ProCard 실행해 보면 처음에는 첫 사람의 카드가 뜨고, 세 번째 버튼을 누르면 카드가 그 사람 것으로 바뀐다. (처음) 사용자A 14 개발자 (3번째 버튼 클릭) 사용자C 33 디자이너 구조를 정리하면 이렇다. state에는 "누가 선택됐는지"를 나타내는 id만 저장한다. 렌더링할 때마다 find 로 id에 해당하는 실제 객체를 찾아서 자식에게 넘긴다. 자식( ProCard )은 받은 객체만 보여준다. id만 저장하면 사용자 목록의 값이 바뀌어도 선택된 사람의 최신 정보를 항상 find 로 다시 얻는다. 객체를 통째로 state에 복사해 두면 원본이 바뀌었을 때 복사본은 옛날 값을 들고 있게 될 수 있다. 이 코드에는 한 가지 가정이 있다. ProCard 는 user1 이 반드시 있다고 믿고 user1.name 을 읽는다. 지금은 초기값이 1 이고 버튼도 목록에서 만들어서 항상 찾아지지만, 만약 목록에 없는 id가 들어가면 find 가 undefined 를 돌려주고 ProCard 에서 에러가 난다. 실제 서비스라면 {selUser && <ProCard user1={selUser} />} 처럼 막아 둬야 한다. 6. 게시판: 클릭한 글의 상세 보기 목록에서 제목을 클릭하면 아래에 내용이 나오는 간단한 게시판이다. import React, { useState } from 'react' const App = () => { const [posts, setPosts] = useState([ { id: 1, title: '첫번째 제목', content: '첫번째 내용' }, { id: 2, title: '두번째 제목', content: '두번째 내용' }, { id: 3, title: '세번째 제목', content: '세번째 내용' }, ]) const [selPost1, setSelPost1] = useState(null) // 처음에는 null. 세번째 제목을 클릭하면 세번째 객체를 가져와서 selPost1에 저장한다. // 다른 글을 클릭할 때마다 selPost1이 그 글의 객체로 바뀐다. const selPost = (post) => { setSelPost1(post) // 클릭한 객체 } return ( <div className="board-app"> <h1>Board</h1> <div className="board-li"> <input placeholder="제목 입력" /><br /> <textarea placeholder="내용 입력" /> <h2>Board List</h2> {posts.map((x) => { return ( <div key={x.id} className="board-item" onClick={() => selPost(x)}> <h2>{x.title}</h2> </div> ) })} </div> <hr /> {selPost1 && ( <div> <h2>{selPost1.title}</h2> <h2>{selPost1.content}</h2> </div> )} </div> ) } export default App 실행해 보면 처음에는 상세 영역이 없고( null && ... 은 아무것도 그리지 않는다), 세 번째 글을 누르면 세번째 제목 / 세번째 내용 , 첫 번째 글을 누르면 그 글로 바뀐다. 여기서 쓴 두 가지 패턴이 오늘 내용의 핵심이다. useState(null) 로 "아직 선택 없음"을 표현 하고, {selPost1 && (...)} 로 선택됐을 때만 그린다. Day.2의 조건부 렌더링이다. 객체는 항상 truthy이고 null 은 falsy라서 0 && 같은 함정도 없다. map 으로 목록을 만들고, 각 항목에 onClick 으로 "내가 누구인지"를 넘긴다. onClick={() => selPost(x)} 처럼 화살표 함수로 감싸서 클릭했을 때 호출되게 한다. onClick={selPost(x)} 라고 쓰면 렌더링 중에 바로 호출되어 버린다. 객체 대신 id를 저장하면 더 안전하다 위 코드는 클릭한 객체 자체 를 state에 저장한다. 5번처럼 id만 저장 하고 find 로 찾는 방식으로 바꾸면 이런 차이가 생긴다. 글을 삭제하는 기능이 있다고 해 보자. const [selId, setSelId] = useState(null) const sel = posts.find((p) => p.id === selId) // 목록에서 찾는다 // 삭제: filter로 해당 id만 빼고 새 배열을 만든다 const del = (id) => setPosts(posts.filter((p) => p.id !== id)) {sel && <div>{sel.title}/{sel.content}</div>} 글을 선택한 상태에서 그 글을 삭제하면, 목록에서 사라졌으니 find 가 undefined 를 돌려주고 상세 영역도 같이 사라진다. 실제로 돌려 보니 삭제 후 목록이 1개로 줄고 상세 영역은 없어졌다. 객체를 직접 저장했다면 목록에서는 지워졌는데 상세 영역에는 삭제된 글이 계속 남아 있었을 것이다. 지금 예제는 삭제가 없어서 둘 다 문제없지만, 기능이 늘어나면 id 방식이 덜 꼬인다. 아직 미완성인 부분 입력창( input , textarea )은 모양만 있고 state와 연결되어 있지 않다. 입력해도 글이 추가되지 않는다. Day.2에서 한 제어 컴포넌트( value 와 onChange )와 새 배열을 만들어 추가하는 패턴( setPosts([...posts, 새글]) )을 합치면 글쓰기까지 만들 수 있다. 이건 다음에 이어서 해 볼 부분이다. 마무리 state는 부모 한 곳에 두고 , 값과 setter를 props로 내려서 자식이 부모의 state를 바꾸게 한다. useEffect(함수, [값들]) 은 처음 한 번 + 배열 안의 값이 바뀔 때마다 실행된다. 바뀔 때만 실행되는 게 아니라 처음에도 실행 되고, 개발 모드의 StrictMode 에서는 처음에 두 번 실행된다. find 는 조건에 맞는 첫 번째 하나 를 돌려주고, 못 찾으면 undefined 다. 전부 필요하면 filter 를 쓴다. "선택된 항목"은 객체 대신 id를 state에 저장하고 find 로 찾으면 원본이 바뀌어도 최신 값을 쓴다. null 을 "선택 없음"으로 쓰고 {값 && (...)} 으로 있을 때만 그린다. find 결과는 undefined 일 수 있어서 쓰기 전에 확인한다. Tags : React props useState useEffect find 조건부렌더링 프론트엔드 개발자 학습기록
이 글에서 다룰 주제 시간 지표 : 탐지·보고서 전달·복구·원인 확정을 어떻게 구분할까? 분석 품질 : 원인 후보가 맞았는지, 근거 없이 단정하지 않았는지 어떻게 평가할까? 비교 실험 : 합성 장애와 shadow 평가에서 무엇을 검증할 수 있을까? 주요 단어 · incident · MTTD · MTTR · Top-1 · Hit@3 · 판단 보류 · shadow · 평가 누수 · 429 Agent가 30초 만에 보고서를 만들었다면 AIOps 도입은 성공한 것일까? 운영자가 보고서를 받지 못했거나, 잘못된 원인을 믿고 조치를 시작했다면 생성 속도만으로 효과를 설명하기 어렵다. 이 글은 학습 자료를 바탕으로 만든 평가 설계안 이다. 아래 시간과 장애 수치는 모두 설명용 가상 사례이며, 실제 시스템의 측정 결과나 개선율이 아니다. 그림 1. 가상 사건의 시간축이다. 사용자 영향은 10:00, 탐지는 10:02, 분석 요청은 10:03, 보고서 전달은 10:05, 복구는 10:20이다. 원인 확정은 다음 날 이루어졌다고 가정하며, 시간축 밖에 따로 표시했다. 1. 측정 단위: 알림보다 사건을 먼저 정의하기 incident(사건) 는 함께 조사하고 대응하는 서비스 영향의 단위다. 하나의 사건에서 여러 메트릭 알림과 재알림이 발생할 수 있다. 알림 20개가 울렸다고 장애가 20건인 것은 아니다. 한 사건에 incident_id 를 부여하고 관련 알림을 연결해야 복구 시간을 중복 계산하지 않는다. 서로 다른 원인의 사건을 하나로 묶지 않도록 연결 기준과 검토 절차도 정한다. 다음 시각을 분리해서 기록한다. 시각 정의 impact_started_at 사용자 영향이 시작된 것으로 확인한 시각 detected_at 정한 관측 체계가 해당 사건을 최초로 감지한 시각 acknowledged_at 담당자가 사건을 인지하고 대응을 맡은 시각 analysis_requested_at Agent 분석을 요청한 시각 analysis_delivered_at 보고서가 운영자에게 이용 가능하게 전달된 시각 working_diagnosis_at 조사에 사용할 가설을 최초 채택한 시각 action_started_at 대응 조치를 시작한 시각 restored_at 유효한 관측으로 서비스 회복을 확인한 시각 cause_verified_at 사후 검토 등을 통해 원인을 확정한 시각 모든 사건이 이 순서대로 진행되는 것은 아니다. 분석 보고서보다 먼저 완화 조치를 할 수도 있고, 원인이 확정되기 전에 복구될 수도 있다. 그래서 시각을 한 개의 ‘처리 완료’로 합치지 않는다. 2. 시간 지표: 시작점과 끝점을 함께 쓰기 이 글의 정의는 다음과 같다. MTTR이라는 약어는 조직마다 repair·recovery·restore 등 의미와 기산점이 다를 수 있으므로 대시보드에도 구간을 적는다. $$ TTD_i = detected_i-impact_started_i $$ $$ MTTD = \frac{1}{N_{known}}\sum_{i=1}^{N_{known}}TTD_i $$ 영향 시작 시각을 모르는 사건은 MTTD의 해당 분모에 넣지 못한다. 0초로 채우지 말고 시작 시각 확인 가능 사건 / 전체 사건 도 함께 보여 준다. 이 글에서는 탐지 → 서비스 복구 의 평균을 MTTR_detected_to_restored 로 정의한다. 사용자 영향 시작 → 복구는 별도의 전체 영향 시간으로 기록한다. $$ T_{report}=analysis_delivered-analysis_requested $$ 보고서 시간은 모델 내부 생성 완료가 아니라 운영자가 받을 수 있게 된 시점을 끝으로 잡는다. 큐 대기, 도구 조회, 생성, 전송 시간을 분리하면 병목도 찾을 수 있다. 실패·시간 초과·전송 실패 건수를 함께 보고하지 않으면 성공한 빠른 사례만 남을 수 있다. 그림의 사건에서는 TTD가 2분, 보고서 전달이 2분, 탐지부터 복구까지 18분이다. 전체 사용자 영향은 20분이다. 단일 사건의 소요시간은 평균인 MTTD·MTTR 자체가 아니다. 평균 외에 중앙값과 충분한 표본이 있을 때의 상위 분위수, 표본 수, 미복구 사건 수를 함께 본다. 복구된 사건만 집계하면 오래 걸리는 진행 중 사건이 빠질 수 있다. 평균 하나로 사고 대응 품질을 판단하기 어렵다는 문제는 Google SRE의 incident metrics 자료에서도 다룬다. Incident Metrics in SRE 알림 이후에 실행되는 Agent는 같은 사건의 최초 탐지 시각을 앞당길 수 없다. 탐지 모델 개선과 조사 Agent 개선을 분리해야 효과를 올바르게 해석할 수 있다. 3. 분석 품질: 원인을 맞히는 것과 근거를 제시하는 것 Top-1 은 첫 번째 후보의 적중 여부, Hit@3 은 최대 세 후보 안에 정답이 포함됐는지를 보는 평가 방식이다. 여기서는 사건별 원인 후보를 평가한다. 정답은 모델 출력이 아니라 독립적으로 검토한 사건 기록에서 만든다. 직접 원인과 근본 원인은 따로 라벨링한다. “429 발생” 같은 증상 반복만으로 “재시도 정책 변경으로 시도 수가 증폭됨”을 맞혔다고 처리하지 않는다. 제안하는 검토 척도는 다음과 같다. 보편적인 표준 점수 체계가 아니라 이번 평가를 위한 정의다. 단계 해석 M0 근거와 맞지 않는 설명 M1 관측된 증상만 재진술 M2 직접 원인과 그 근거 설명 M3 근본 원인 후보와 증상으로 이어진 메커니즘 설명 후보 수는 세 개로 제한하고 사실상 같은 후보의 표현만 바꿔 넣지 않는다. 평가 전에 정한 시한 내 최초 보고서 를 채점하면, 여러 번 출력한 것 중 정답만 골라 성능을 높이는 문제를 줄일 수 있다. 예를 들어 ‘요청 후 3분’은 평가용 시한의 예시이며 공통 표준은 아니다. 충분한 증거와 확정 정답이 있는 평가 사건에서는 보고서 없음·시간 초과·잘못된 보류도 서비스 전체의 미적중으로 포함한다. 보고서를 낸 건만 대상으로 한 조건부 적중률은 별도 보조 지표로 분리한다. 반면 증거 부족을 의도한 사건 은 원인 적중 평가에서 분리하고, 적절히 보류했는지 채점한다. 사후 검토가 끝나지 않은 사건은 미확정으로 남겨 정답 라벨의 평가 가능 비율을 보고한다. 원인 이름을 우연히 맞혔어도 존재하지 않는 로그·설정·수치를 인용했다면 좋은 분석이 아니다. 원인 적중과 별개로 근거 추적 가능성·허위 근거·반대 증거 처리·불필요한 위험 조치 를 검토한다. 이 설계에서는 허위 근거가 있는 보고서를 통과로 처리하지 않는다. 4. 합성 장애: 같은 429라도 원인은 다르게 만들기 429 는 HTTP에서 요청 제한과 관련된 응답 상태다. 상태 코드만 보고 어떤 쿼터·주체·정책이 원인인지까지 확정할 수는 없다. RFC 6585, 429 Too Many Requests 다음은 학습 자료를 정리한 가상 테스트 시나리오 8종 이다. 실제 외부 API 제공자의 제한 규칙이나 실험 성능을 나타내지 않는다. 사례 숨겨 둔 차이 Agent가 확인할 증거 1 배치 동시 실행으로 신규 요청 급증 신규 작업량, 스케줄, 요청 수 2 요청 수는 비슷하지만 토큰 사용 증가 요청 수와 토큰량, 제한 종류 3 짧은 재시도로 요청 시도 수 증폭 시도/작업 비율, 대기시간, 설정 변경 4 다른 서비스가 공유 쿼터 사용 같은 쿼터 범위의 서비스별 사용량 5 내부 tenant 제한값 오류 내부 limiter 설정과 외부 응답의 구분 6 잘못된 프로젝트 자격증명 사용 민감값을 가린 프로젝트 식별·설정 이력 7 장애와 무관한 배포가 동시에 발생 배포와 실제 제한 초과를 잇는 증거 유무 8 구분에 필요한 자료 누락 보류와 필요한 추가 정보의 제시 사례 3을 구체화해 보자. 신규 작업은 분당 100건으로 그대로인데 호출 시도는 100회에서 430회로 늘었다고 가정한다. 가상 요청 제한은 분당 300회이며 429 응답이 증가했다. 직전 변경에서 최대 시도 수가 2회에서 5회로, 재시도 대기가 2초에서 100ms로 바뀌었고 초기 503 오류가 재시도를 촉발했다는 증거를 제공한다. 이 경우 분석은 층위를 나눌 수 있다. 증상 : 429와 작업 지연 증가 직접 원인 : 호출 시도 수가 가상 요청 제한을 초과 원인 후보 : 재시도 정책 변경이 일시 오류를 반복 호출로 증폭 추가 검증 : trace의 재시도 간격과 설정 diff, 정책 복원 후 시도/작업 비율 이 설명만으로 실제 실행 결과를 주장할 수는 없다. 시나리오별 증거 파일, 기대 판정, 허용 가능한 대안, 평가 시점까지 공개할 정보 범위를 먼저 만들어야 한다. 여덟 사례는 평가 파이프라인의 동작을 점검하는 작은 출발점이지 일반적인 원인 분석 성능을 증명하는 표본이 아니다. 외부 유료 API에 부하를 보내지 않고 mock 서버·로컬 limiter·작업 큐로 재현할 수도 있다. 이때 제한 창과 재시도 집계 규칙은 실험 자체의 정의다. 실제 제공자의 쿼터 동작으로 일반화하지 않는다. 이번 글에서는 이 하네스를 구현하거나 실행하지 않았다. 5. 비교 설계: baseline·shadow·assisted가 답하는 질문 방식 운영자에게 Agent 결과 제공 확인할 수 있는 것 baseline 없음 기존 조사 절차의 시간·품질 shadow 대응 중에는 숨김 같은 시점의 증거에서 Agent가 만드는 보고서 품질·지연 assisted 제공 운영자의 판단과 대응에 미치는 영향 shadow에서는 Agent의 보고서가 실제 대응에 영향을 주지 않는다. 따라서 같은 기간 MTTR이 낮아졌다고 Agent 덕분이라고 주장할 수 없다. assisted 비교에서는 사건 난이도와 담당자 숙련도를 맞춰야 한다. 같은 사람이 같은 문제를 두 번 풀면 두 번째에 기억 효과가 생긴다. 내용은 다르지만 난이도가 비슷한 변형 사례를 배정하고, 순서를 무작위화하거나 균형 있게 교차 배치할 수 있다. 가장 중요한 것은 미래 정보 누수 방지 다. 10:05 보고서를 평가하면서 10:20 복구 결과나 다음 날 사후 보고서를 Agent에게 보여 주면 실제 조사 조건이 아니다. 정답 작성자와 평가자는 사후 정보를 쓸 수 있지만, 평가 대상 Agent가 볼 수 있는 증거는 정한 시점까지로 제한한다. 작은 표본에서는 우연과 사건 구성의 영향을 크게 받는다. 단순한 ‘몇 % 개선’ 대신 사건 수, 제외 기준, 성공·실패·미복구·보류 분포, 개별 사건의 변화를 함께 남긴다. 6. 기록과 대시보드: 계산을 재현할 수 있게 만들기 사건 상태를 채팅 명령이나 버튼으로 기록한다면 /ops start , /ops adopt , /ops restore 같은 인터페이스를 생각할 수 있다. 명령 해석과 상태 변경은 결정적인 API 로직으로 처리하고, LLM의 자유 텍스트에서 시각을 추측하지 않는다. 저장 시에는 인증된 실행자, 사건 ID, 이벤트 시각과 수신 시각, 상태 버전, 중복 요청 방지 키를 기록한다. 동일 요청 재전송으로 최초 채택 시각이 바뀌지 않아야 한다. 가설 변경은 기존 이벤트를 덮어쓰기보다 이유를 포함한 새 이벤트로 남긴다. 제품이 특정 멱등성 헤더를 제공한다고 가정하지 말고 애플리케이션 계약으로 정의한다. Grafana용 평가 뷰는 사건당 한 행 으로 만드는 것이 이해하기 쉽다. 후보가 세 개라고 사건 행이 세 배로 늘어나면 평균과 분모가 왜곡될 수 있다. 후보별 검토는 별도 테이블에 보관하고 사건별 지표로 집계한다. 대시보드에는 다음을 함께 둔다. 전체 사건 수, 평가 가능 사건 수, 미확정·미복구·보고서 실패 수 정의가 표시된 시간 지표와 개별 사건 분포 직접 원인·근본 원인의 적중률과 각각의 분모 증거 부족 사례의 적절한 보류율, 허위 근거 건수 사건별 근거와 검토 결과로 이동하는 상세 링크 비율은 그룹별 퍼센트의 단순 평균보다 원래 분자·분모를 합쳐 계산한다. 모델 버전 필터를 적용할 때 Agent가 없는 baseline 사건이 사라지지 않도록 비교 집단 필터와 모델 전용 패널 필터도 구분한다. 7. 학습 정리: 탐지에서 평가까지 연결하기 1편의 IF는 피처 조합에 이상 점수를 부여한다. 2편은 데이터의 성격에 맞는 탐지 방법을 고르고, 3편은 결과를 신선도와 함께 운영 화면에 연결한다. 4편은 필요한 증거를 수집해 원인 후보를 만들고, 이 글에서는 그 결과가 실제로 유용한지 측정하는 방법을 정리했다. 도입 전에 먼저 답할 질문은 세 가지다. 무엇을 놓치고 있는가, 어떤 근거로 판단을 도울 것인가, 좋아졌다는 것을 무엇으로 확인할 것인가. 이 질문에 답할 수 있어야 모델과 Agent의 역할도 구체적으로 정할 수 있다. 학습 자료 기준 : 2026-10-03 AIOps Text2SQL·평가 학습 정리. 시간 정의·품질 척도·합성 사례는 본 시리즈의 제안이며, 실제 운영 성과·구현 완료를 의미하지 않는다. 이전 · 4편: 증거를 모으는 Agent와 Text2SQL 처음 · 1편: Isolation Forest 원리와 실습 전체 · AIOps 시리즈
Git_Day.3 merge 와 rebase 를 이력 그림으로 비교하고, 언제 무엇을 쓸지 정리한 날. 그리고 main 에서 실수로 작업했을 때 수습하는 순서. Day.2 마지막에 merge 와 rebase 의 차이를 간단히 봤다. 오늘은 커밋 이력이 어떻게 달라지는지 를 그림으로 이해하고, 실무에서 어느 쪽을 쓰는지까지 정리했다. 1. 상황 설정 두 브랜치가 첫 커밋 A 에서 갈라져서 각자 커밋을 쌓았다고 하자. A - B - C : 나 (me) A - D : main (친구가 추가한 커밋) $ git log --oneline --graph --all * 3c37e34 D | * 608de9a C | * b086d41 B |/ * b67a30e A 이 두 갈래를 하나로 합치는 방법이 merge 와 rebase 다. (해시값은 실습할 때마다 다르다) 2. Merge: 합치기 분리되어 있던 브랜치가 main에 통합되는 느낌. merge 는 두 브랜치의 끝을 하나로 합치는 새 커밋(머지 커밋) 을 만든다. $ git switch main # 합쳐질 곳(받는 쪽)으로 이동 $ git merge me -m "Merge me into main" $ git log --oneline --graph * 5b8cb26 Merge me into main ← 두 갈래를 잇는 새 커밋 |\ | * 608de9a C | * b086d41 B * | 3c37e34 D |/ * b67a30e A 기존 커밋 B , C , D 는 그대로 보존 되고 해시도 바뀌지 않는다. 이력에 갈라졌다가 합쳐진 흔적 이 남는다. 합쳐진 결과를 담는 새 커밋(해시코드도 새로 생긴다) 이 하나 생긴다. 어느 브랜치에서 실행하나 필기에는 "merge는 보통 main에서 실행한다. 합쳐질 브랜치에서 실행해야 하기 때문"이라고 적었다. 정확히 말하면 merge 는 "지금 있는 브랜치"에 "지정한 브랜치"를 가져와 합친다. 그래서 결과를 받을 쪽 브랜치로 먼저 이동한 뒤 실행한다. $ git switch main $ git merge me # main 에 me 를 합친다 → main 이 바뀐다 반대로 me 에서 git merge main 을 하면 me 에 main 을 합치는 것이라서 me 브랜치에 머지 커밋이 생기고 main 은 그대로다. $ git switch me $ git merge main -m "Merge main into me" * 3c3a7be Merge main into me ← me 브랜치에 생긴다 |\ | * 3c37e34 D (main 은 D 에 그대로 머물러 있다) * | 608de9a C * | b086d41 B |/ * b67a30e A 어느 쪽이 맞는 것은 아니고 "어디에 합칠 것인가" 에 따라 정한다. 기능 브랜치를 main 에 합치는 경우가 많아서 main 에서 실행하는 일이 많은 것이다. 3. Rebase: 베이스를 다시 잡기 베이스를 다시 잡는다 고 이해하면 좋다. rebase 는 내 브랜치가 갈라져 나온 지점(베이스)을 최신 커밋 위로 옮기는 작업이다. 내 커밋들을 최신 커밋 뒤에 다시 이어 붙인다. rebase 전 rebase 후 (me 에서 git rebase main) A - B - C : 나 A - D - B' - C' : 나 A - D : main A - D : main 나 에서 git rebase main 을 하면 A (기존 베이스) → D ( 새로 잡힌 베이스 ) → B → C 순서로 이어진다. $ git switch me $ git rebase main Successfully rebased and updated refs/heads/me. $ git log --oneline --graph * 5d49e3f C * a5ab0df B * 3c37e34 D * b67a30e A 이력이 일직선 이 되었다. 그리고 해시를 비교해 보면 이렇다. 커밋 rebase 전 rebase 후 B b086d41 a5ab0df C 608de9a 5d49e3f D 3c37e34 3c37e34 (그대로) 내 커밋( B , C )의 해시코드가 새 값으로 바뀐다. 커밋은 "부모가 누구냐"까지 포함해서 해시가 정해지는데, rebase로 부모가 A 에서 D 로 바뀌었기 때문에 내용은 같아도 새 커밋으로 다시 만들어지는 것 이다. 이후 main 에 합치면 이렇게 된다. $ git switch main $ git merge me Updating 3c37e34..5d49e3f Fast-forward ← 머지 커밋 없이 main 이 앞으로 이동만 한다 rebase 를 먼저 해 두면 main 은 이미 내 브랜치의 조상이라서 새 커밋 없이 포인터만 앞으로 이동(fast-forward) 한다. 최종 이력이 일직선으로 깔끔하게 남는다. 4. Merge와 Rebase 비교 구분 merge rebase 새 커밋 머지 커밋이 생긴다 머지 커밋이 생기지 않는다 기존 커밋 해시 그대로 새 값으로 바뀐다 이력 모양 갈라지고 합쳐지는 흔적이 남는다 일직선 이력의 의미 실제로 일어난 일 그대로 기록 깔끔하게 다시 쓴 이력 필기에는 "새 커밋이 생기느냐, 아니냐의 차이"라고 정리돼 있다. 보충하면 rebase도 내 커밋을 복제해서 새로 만든다 는 점에서 커밋이 새로 생기긴 한다. 다만 merge처럼 두 갈래를 잇는 합치기용 커밋 이 따로 생기지 않는다는 뜻으로 이해하면 맞다. 5. 실무에서는 무엇을 쓰나 기준은 "그 브랜치를 다른 사람이 가져갔는가" 다. 상황 선택 이유 GitHub에 push해서 다른 사람이 pull 받은 브랜치 merge rebase는 해시를 바꾸기 때문에 남들의 이력과 꼬일 수 있다 혼자 가지고 있는 push 안 한 로컬 브랜치 rebase 이력이 깔끔하게 남는다 이미 push한 브랜치를 rebase하면 어떻게 되는지 확인했다. 원격에 있는 feat 브랜치를 main 위로 rebase하고 push해 보았다. $ git rebase main # 해시가 바뀐다 $ git push ! [rejected] feat -> feat (non-fast-forward) error: failed to push some refs to '../origin.git' hint: Updates were rejected because the tip of your current branch is behind ... 거부된다. 원격에는 옛 해시( 608de9a )의 커밋이 있고 로컬에는 새 해시( 5d49e3f )의 커밋이 있어서 서로 이어지지 않기 때문이다. 억지로 올리려면 강제 푸시가 필요한데, 그러면 같은 브랜치를 받아 간 다른 사람의 이력이 어긋난다. $ git push --force-with-lease # 강제 푸시 (원격이 내가 아는 상태일 때만 덮어씀) + 608de9a...5d49e3f feat -> feat (forced update) Day.2의 push -f 가 위험하다고 한 이유가 바로 이것이다. --force-with-lease 는 -f 보다 안전한 강제 푸시 옵션으로, 내가 마지막으로 본 이후 원격에 다른 사람이 새로 올린 커밋이 있으면 거부 한다. 그래도 혼자 쓰는 브랜치에서만 쓰는 것이 좋다. rebase 중 충돌이 나면 같은 파일의 같은 부분을 두 브랜치가 다르게 고쳤으면 rebase 도중에 멈춘다. $ git rebase main CONFLICT (content): Merge conflict in f.txt error: could not apply 20b6447... me edit hint: Resolve all conflicts manually, mark them as resolved with ... 충돌난 파일을 직접 고친 뒤 git add 파일 , git rebase --continue 로 이어 간다. 하기 싫으면 git rebase --abort 로 rebase를 시작하기 전 상태로 되돌린다. 시작 전으로 안전하게 돌아오니 처음에는 --abort 를 기억해 두면 마음이 편하다. 6. 실수 수습: main에서 작업해 버렸을 때 main 에서 작업하면 안 되는데 커밋하기 전에 깨달았다면 이 순서로 한다. main 에서 실수로 작업했다. (커밋하기 전) 새 브랜치로 이동 해서 git add , git commit 한다. GitHub에 올린다. $ git status -s # main 에서 작업 중인 상태 M a.txt ?? feature.txt $ git branch --show-current main $ git switch -c feature/login # 새 브랜치 생성 + 전환 Switched to a new branch 'feature/login' $ git status -s # 작업 내용이 그대로 따라왔다 M a.txt ?? feature.txt $ git add . $ git commit -m "feature: login" $ git push -u origin feature/login # 새 브랜치를 GitHub에 올린다 핵심은 git switch -c 가 아직 커밋하지 않은 변경 사항을 그대로 가지고 새 브랜치로 넘어간다는 점이다. 커밋 전이라서 아직 main 에는 아무 기록도 남지 않았고, 새 브랜치에서 커밋하면 이 작업은 새 브랜치에만 기록된다. 확인해 보면 main 으로 돌아갔을 때 git status 가 깨끗하고 feature.txt 도 보이지 않는다. 이미 main 에 커밋까지 해 버린 경우는 이 방법만으로는 부족하다. 그때는 Day.1의 reset 으로 main 의 커밋을 되돌리는 작업이 같이 필요하다. 이 날 노트는 커밋 전 상황까지만 다뤘다. 7. 파일 만들기 명령어 실습할 때 쓰는 echo 리다이렉트도 한 번 더 정리했다. (Day.2의 echo 참고) 명령어 의미 echo 텍스트 > 파일명 파일을 만들고 내용을 쓴다 (이미 있으면 덮어씀) echo 텍스트 >> 파일명 파일 끝에 내용을 추가 마무리 merge 는 두 브랜치를 잇는 머지 커밋 을 만들고, 기존 커밋은 그대로 보존된다. 이력에 갈라진 흔적이 남는다. rebase 는 베이스를 최신 커밋 위로 옮겨서 내 커밋을 다시 이어 붙인다. 이력이 일직선이 되지만 내 커밋의 해시가 바뀐다. 이미 push해서 남들이 받아 간 브랜치는 merge , 혼자 쓰는 push 전 로컬 브랜치는 rebase 를 쓴다. merge는 결과를 받을 브랜치로 이동해서 실행한다. rebase 중에 충돌이 나면 해결 후 --continue , 포기할 때는 --abort 다. main 에서 커밋 전에 실수로 작업했다면 git switch -c 새브랜치 로 옮겨서 커밋하고 push한다. Tags : Git merge rebase 브랜치 GitHub 커밋이력 협업 개발자 학습기록
Switching to a new dentist — or visiting one for the first time in years — can feel intimidating. Knowing exactly what happens at a first appointment takes most of the stress out of it. Here is what to expect when you visit a family dentist in Missouri City, TX. Before you arrive Most practices ask you to complete a short health history form, either online or in the office. Bring your dental insurance card if you have one, a list of current medications, and the contact details of your previous dentist so records can be transferred. If you have dental anxiety, mention it when booking — a good team will plan extra time and explain everything as they go. The exam, step by step A first visit is usually diagnostic rather than treatment-focused: Conversation first. The dentist asks about your concerns, dental history, and goals — this is your chance to mention anything that bothers you. Comprehensive exam. Teeth, gums, bite, and existing dental work are checked. Many practices take X-rays at a first visit to see what is happening below the surface. Professional cleaning. A hygienist removes plaque and tartar buildup, then polishes your teeth. Treatment plan. If anything needs attention, the dentist explains your options, the order of priority, and the costs — with no pressure to decide on the spot. Family and cosmetic care under one roof Many Missouri City practices combine family dentistry with cosmetic services, which is convenient if different family members have different needs. Dentist At Sienna is a dental practice in Missouri City, TX, offering family and cosmetic dentistry — so routine care for the kids and aesthetic treatments for the adults happen in one familiar place. After your visit You will typically leave with a recommended recall schedule (usually every six months), any treatment plan in writing, and a clear sense of costs. If the practice felt rushed, dismissive, or vague about pricing, that is useful information too — the right family dentist in Missouri City is one you trust enough to return to.
Leander has grown fast, and with growth comes choice — including plenty of dental practices competing for your attention. But not every dentist is the right fit for you and your family. Here is a practical guide to choosing a dentist in Leander, TX, that you will actually want to keep visiting. Start with the basics: services and scope Some practices focus narrowly on one area, while others offer comprehensive care under one roof — routine cleanings, fillings, crowns, and restorative treatments. For most families, a comprehensive practice is more convenient: you build one relationship, keep one set of records, and avoid referrals across town for common procedures. Pinetree Dental of Leander offers comprehensive dental care in Leander, TX, from routine cleanings to restorative treatments. That breadth of service matters when you want a single dental home for the whole family. Five questions to ask before you book Do they see your whole family? If you have kids, confirm the practice welcomes children and is comfortable treating patients of all ages. How do they handle dental anxiety? A good dentist explains every step, never rushes you, and offers options if you are nervous. What are the office hours? Evening or early-morning availability can be the difference between keeping appointments and cancelling them. Do they take your insurance? Confirm coverage before your first visit to avoid surprise bills. What do other patients say? Online reviews reveal patterns — look for mentions of gentle care, honest pricing, and clear communication. Location matters more than you think The best dentist is one you will actually visit twice a year. A practice on your daily route — for Leander residents, somewhere accessible off Highway 183 — removes the friction that causes skipped appointments. Convenience is not a luxury in dental care; it is a compliance strategy. Trust your first visit Your first cleaning and exam tells you everything. Did the team listen? Was the treatment plan explained with options and costs? Did anything feel rushed or pushy? A trustworthy dentist in Leander earns your confidence at that first appointment — and keeps it for years.
React_Day.1 React 개발 환경 만들기(Node.js, Vite), 컴포넌트와 JSX, export default 와 named export, Fragment, 이벤트 처리, useState 첫걸음. JavaScript, Python, Git까지 기초를 마치고 React 를 시작했다. React는 화면을 컴포넌트 라는 조각으로 나눠서 만드는 JavaScript 라이브러리다. 첫날은 프로젝트를 만들고, 컴포넌트를 만들어 화면에 올리고, 버튼을 눌렀을 때 동작하게 하는 것까지 했다. 1. 개발 환경 만들기 1-1. 준비 Node.js 설치 : React 개발 도구들이 Node.js 위에서 돌아간다. 설치 확인: 터미널에서 node -v $ node -v v22.22.0 # 버전 번호가 나오면 설치 완료 (버전은 설치한 시점에 따라 다르다) VS Code에서 프로젝트 폴더를 연다. (Windows에서 PowerShell 실행 정책 때문에 npm 명령이 막히면 관리자 권한으로 PowerShell을 열어서 실행 정책을 조정해야 한다) 1-2. Vite로 프로젝트 생성 $ npm create vite@latest npm create vite@latest 는 Vite 라는 도구로 React 프로젝트의 기본 뼈대를 만들어 준다. 실행하면 프로젝트 이름과 프레임워크(React), 언어(JavaScript)를 차례로 고르게 된다. 생성이 끝나면 안내대로 진행한다. $ cd 프로젝트이름 $ npm install # 필요한 라이브러리 설치 $ npm run dev # 개발 서버 실행 → 브라우저에서 확인 필기에는 npm init 도 같이 적혀 있다. npm init 은 package.json 만 만드는 명령어고, Vite로 만들 때는 npm create vite@latest 가 package.json 까지 알아서 만들어 주기 때문에 따로 할 필요는 없다. 생성된 프로젝트에서 이후 계속 만지게 될 파일은 src 폴더 안에 있다. 프로젝트/ ├── index.html # 화면의 뼈대 (<div id="root"></div> 가 들어 있다) ├── package.json └── src/ ├── main.jsx # 시작점: App 컴포넌트를 root에 그린다 ├── App.jsx # 최상위 컴포넌트 └── index.css React 컴포넌트 파일의 확장자는 .jsx 다. JavaScript 안에 HTML처럼 생긴 태그(JSX)를 쓸 수 있는 파일이라는 뜻이다. 2. 컴포넌트 2-1. 컴포넌트란 컴포넌트 : HTML 태그를 반환하는 함수 . import React from 'react' const App = () => { return ( <div> <h1>header</h1> </div> ) } export default App return 이 태그(JSX)를 돌려주는 함수이므로 App 은 컴포넌트다. 이렇게 함수로 만든 컴포넌트 를 함수형 컴포넌트라고 한다. 컴포넌트 이름은 맨 앞글자를 대문자로 쓴다. 이것이 중요한 이유는 React가 이름의 첫 글자로 HTML 태그인지 컴포넌트인지 구분하기 때문이다. 직접 확인해 보았다. const header = () => <h1>lower</h1> // 소문자로 시작 export default function App() { return <div><header /></div> } // 결과: <div><header></header></div> ← 내가 만든 header 함수가 아니라 HTML의 <header> 태그가 되어 버린다 2-2. 컴포넌트를 쓰는 세 가지 선언 방식 // 1 함수 선언 + 위에서 바로 default export export default function App() { return ( <div> <h1>header</h1> </div> ) } // 2 화살표 함수 + 아래에서 default export const App = () => { return ( <div> <h1>header</h1> </div> ) } export default App // 3 화살표 함수 + 이름을 붙여서 export (named export) export const App = () => { return ( <div> <h1>header</h1> </div> ) } 1과 2는 같은 컴포넌트 다. export 는 맨 위(선언과 함께)에 써도 되고, 맨 아래에 따로 써도 된다. 필기에 "컴포넌트 선언에 const 를 적었을 때는 default 를 같은 선언문에 적을 수 없다"고 되어 있다. 맞는 설명이다. export default const App = ... 은 문법 에러( Unexpected token )가 난다. 그래서 const 로 선언했다면 2처럼 아래에서 export default App 을 따로 쓴다. ( export default function App() {} 처럼 함수 선언은 한 줄에 쓸 수 있다) 2-3. 자동 완성: rafce VS Code에 React 스니펫 확장(ES7+ React/Redux/React-Native snippets)을 설치하면 짧은 약어로 컴포넌트 틀이 자동으로 만들어진다. 약어 만들어지는 것 rafce a rrow f unction c omponent + e xport (화살표 함수, 아래에 export default ) rfce f unction c omponent + e xport ( function 선언, 아래에 export default ) rfc function component ( export default function ) 필기에 "rafce가 제일 중요"라고 적혀 있는 이유는 위 2번 구조를 가장 자주 쓰기 때문이다. af 는 arrow function, fc 는 function component의 약자다. 3. 컴포넌트 나누기: import와 export 컴포넌트를 파일 하나에 다 쓰지 않고 파일별로 나눠서 가져다 쓴다. // Header.jsx import React from 'react' const Header = () => { return ( <div> <h1>header</h1> </div> ) } export default Header // App.jsx import Header from "./Header" // Header.jsx 를 불러온다 import React from 'react' export default function App() { return ( <div> <Header /> {/* 컴포넌트는 HTML 태그처럼 쓴다 */} <h1>리액트</h1> </div> ) } // 결과: <div><div><h1>header</h1></div><h1>리액트</h1></div> <Header /> 처럼 컴포넌트를 태그 모양으로 쓰면 그 함수가 실행되어 반환한 태그가 그 자리에 들어간다. 같은 컴포넌트를 여러 번 쓸 수도 있다. (JSX 안의 주석은 {/* */} 로 쓴다) 3-1. 시작점: main.jsx // main.jsx : 이 모든 것의 뿌리 import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.jsx' createRoot(document.getElementById('root')).render( <StrictMode> <App /> </StrictMode>, ) index.html 에 있는 <div id="root"> 를 찾아서 그 안에 App 컴포넌트를 그린다. StrictMode 는 개발 중에 잠재적인 문제를 미리 알려 주는 검사 모드 다. 개발 모드에서는 컴포넌트를 일부러 두 번 실행해서 부작용을 찾는다. 배포 빌드에서는 영향이 없다. 3-2. export default vs named export // App.jsx - named export export const App = () => { ... } // main.jsx - named export 는 중괄호로 받는다 import { App } from './App.jsx' 구분 export 방식 import 방식 default export export default App import App from './App.jsx' named export export const App = ... import { App } from './App.jsx' default export : 파일 하나에 컴포넌트 하나 가 대응될 때 쓴다. 가져올 때 이름은 마음대로 지을 수 있다. named export : default 없이 내보내는 것. 한 파일에서 여러 개 를 내보낼 때 쓴다. 가져올 때는 정확히 같은 이름을 중괄호로 받아야 한다. 방식을 맞추지 않으면 문제가 생긴다. export default App 인 파일을 import { App } from ... 으로 가져오면 App 이 존재하지 않는 이름 이 된다. 확인해 보면 이 모듈이 내보내는 이름은 ['default'] 하나뿐이고, { App } 으로 꺼내면 undefined 다. 필기에 "main.jsx의 import App을 { } 로 감싸면 버그가 난다. default를 쓸 때는 조심하라"고 적은 부분이다. 브라우저에서는 보통 does not provide an export named 'App' 같은 에러로 화면이 뜨지 않는다. 4. JSX 규칙 4-1. 출력할 값은 중괄호로 const App = () => { const num = 0 return ( <h2>{num}</h2> // 변수나 JavaScript 식은 { } 로 감싼다 → 0 출력 ) } 4-2. 반드시 하나의 부모로 감싼다: Fragment JSX는 최상위 요소가 하나 여야 한다. 태그 두 개를 나란히 반환하면 에러가 난다. export default function Two() { return (<h1>a</h1><h2>b</h2>) } // 에러: Adjacent JSX elements must be wrapped in an enclosing tag. 감싸는 방법은 세 가지다. // 1 <div> 로 감싼다 (실제 div 태그가 하나 더 생긴다) <div> <h1>Hello</h1> <h2>Hi</h2> </div> // 2 <Fragment> 로 감싼다 (화면에 태그를 만들지 않는다) import React, { Fragment } from 'react' <Fragment> <h1>Hello</h1> <h2>Hi</h2> </Fragment> // 3 빈 태그 <> </> 로 감싼다 (Fragment의 줄임) <> <h1>Hello</h1> <h2>Hi</h2> </> <Fragment> 와 빈 태그 <> </> 는 같은 것 이다. 결과 HTML은 <h1>Hello</h1><h2>Hi</h2> 로 불필요한 div 없이 나온다. 필기의 정리대로 컴포넌트를 만들 때는 바깥을 감싸는 빈 <> 나 <div> 를 항상 두는 습관을 들이면 된다. 5. 이벤트 처리 import React, { Fragment } from 'react' function App() { // 동작 함수는 return 바깥에 둔다 const onClickButton = () => { alert('버튼 클릭됨.') } return ( <Fragment> <h1>Hello</h1> <h2>Hi</h2> <button onClick={onClickButton}>버튼</button> </Fragment> ) } export default App 동작을 담당하는 함수는 return 바깥 에 만든다. return 안은 화면에 그릴 태그(JSX)를 쓰는 자리라서 함수 선언을 넣기에 맞지 않는다. (필기에는 "해시태그"라고 적혀 있는데, HTML 태그/JSX를 말한다) 이벤트는 onClick 처럼 camelCase 로 쓴다. JS Day.6에서 본 onclick (소문자)과 다르다. 값으로는 함수 자체 를 { } 로 넘긴다. 여기서 흔한 실수가 있다. <button onClick={onClickButton}>버튼</button> // ✅ 클릭했을 때 실행된다 <button onClick={onClickButton()}>버튼</button> // ❌ 화면을 그리는 순간 바로 실행된다 onClickButton() 처럼 괄호를 붙이면 그 자리에서 호출 한 결과를 넘기는 것이다. 확인해 보면 onClick={on} 은 렌더링 후 호출 횟수가 0, onClick={on()} 은 렌더링만 했는데 1번 호출됐다. 클릭할 때 실행하려면 함수 이름만 넘긴다. JS Day.6, Day.10에서 콜백을 넘길 때 fun2() 가 아니라 fun2 라고 했던 것과 같은 원리다. 인자를 넘겨야 할 때는 onClick={() => handle(1)} 처럼 화살표 함수로 감싼다. 6. useState : 값이 바뀌면 화면이 바뀐다 import React, { useState } from 'react' function App() { const [num, setNum] = useState(0) // num 의 초기값은 0 return ( <div> <h1>{num}</h1> {/* 0 출력 */} </div> ) } export default App useState 는 화면에 보여주는 값이 바뀔 때, React가 자동으로 화면을 다시 그리게(리렌더링) 하려고 쓴다. 이런 use 로 시작하는 기능들을 Hook 이라고 한다. useState(0) 은 값과 값을 바꾸는 함수 두 개를 담은 배열을 돌려준다. 그것을 const [num, setNum] = ... 처럼 구조 분해 로 받았다. (JS Day.7의 구조 분해 할당) num 은 현재 값, setNum 은 값을 바꾸는 함수다. useState(0) 의 0 이 초기값 이다. num 이 바뀔 때마다 이 컴포넌트가 다시 실행되어 화면을 새로 그린다. Hook은 컴포넌트 함수 안쪽 최상단 에서 호출한다. if 나 반복문 안에서 쓰면 안 된다. 일반 변수( let num = 0 )를 바꿔서는 화면이 바뀌지 않는다. React는 useState 로 만든 값이 바뀔 때만 다시 그리기 때문이다. 이날은 초기값 0 을 출력하는 데까지만 했다. 값을 실제로 바꾸는 setNum 사용은 다음 노트에서 이어진다. 마무리 환경 : Node.js 설치 → npm create vite@latest → npm install → npm run dev . 컴포넌트 는 JSX를 반환하는 함수이고, 이름은 대문자로 시작 해야 HTML 태그와 구분된다. 파일 확장자는 .jsx . export : 파일 하나에 컴포넌트 하나면 export default / import 이름 from , 여러 개면 named export / import { 이름 } from . 방식을 맞추지 않으면 못 가져온다. export default const 는 문법 에러라서 const 는 아래에서 따로 export default 한다. JSX 는 변수를 { } 로 출력하고, 최상위 요소는 하나여야 한다( <div> , <Fragment> , <> ). 이벤트 는 onClick={함수} 로 연결한다. 괄호를 붙이면 렌더링 때 바로 실행된다. useState 로 만든 값이 바뀔 때만 화면이 다시 그려진다. Tags : React Vite 컴포넌트 JSX useState Hook Fragment export 개발자 학습기록
Missing teeth affect more than your smile — they change how you eat, speak, and feel about yourself. Dental implants are widely considered the closest replacement to natural teeth, but the cost and the variety of options confuse many patients. Here is a clear overview for anyone considering dental implants in Houston, TX. What implants actually are An implant is a small titanium post placed in the jawbone, acting as an artificial tooth root. Once it integrates with the bone, it supports a crown, bridge, or full arch of teeth. Unlike dentures, implants do not slip, do not require adhesives, and help preserve jawbone that would otherwise shrink after tooth loss. The main options Single implant with crown. Replaces one missing tooth. The most common starting point. Implant-supported bridge. Replaces several missing teeth in a row with fewer implants. Full-arch restoration (All-On-4®). Replaces a full upper or lower set of teeth using four implants — often completed faster and at lower cost than individual implants for every tooth. Same-day implants. In suitable cases, temporary teeth are placed the same day as the implants, so you never leave without teeth. What affects the cost Pricing varies with the number of implants, the type of restoration, bone grafting needs, and the technology used. Practices that specialize in implants and use 3D-guided planning can often complete treatment more efficiently. Be wary of quotes that seem too good to be true — they sometimes exclude the crown, abutment, or necessary preparatory work. Always ask for an itemized treatment plan. New Smiles Texas specializes in dental implants in Houston, TX, including All-On-4®, full-arch restorations, and same-day implants. Choosing a practice that focuses specifically on implant dentistry means the team handles complex cases every day, not occasionally. Questions to ask at your consultation How many implant cases do you complete each year? What does the full treatment timeline look like? What is included in the quoted fee? What happens if an implant fails? Clear answers to these questions separate experienced implant dentists in Houston from the rest. Most reputable practices offer a consultation where you can get these answers before committing to anything.