Skip to content

Conversation

@cskime
Copy link
Collaborator

@cskime cskime commented Sep 21, 2025

요구사항

할 일 수정

  • 할 일 항목을 클릭한 후 항목 수정이 가능합니다.
  • 항목 이름을 수정할 수 있습니다.
  • 할 일 상태(진행/완료)를 수정할 수 있습니다.
  • 메모를 추가할 수 있습니다.
  • 이미지(최대 1개)를 첨부할 수 있습니다.
    • 이미지 파일 이름은 영어로만 이루어져야 합니다.
    • 파일 크기는 5MB 이하여야 합니다.
  • 수정 완료 버튼을 클릭하면 수정 사항이 반영되고, 할 일 목록 페이지로 이동합니다.

할 일 삭제

  • 삭제하기 버튼을 클릭하면 할 일 삭제가 가능하며, 삭제 후 할 일 목록 페이지로 이동합니다.

주요 변경사항

  • Sprint 9에서 JavaScript로 작성한 코드를 TypeScript로 전환했습니다.
  • Sprint 9에 이어서 상세 page 및 할 일 수정 기능을 구현합니다.
  • 이번 미션에서는 반응형을 크게 고려하지 않고 기능 개발에 집중했습니다.

스크린샷

image

멘토에게

  • <input/> 요소의 width를 입력된 text에 딱 맞게 조절하는 방법
    • 할 일 제목을 즉시 수정하기 위해 <input/> tag를 사용할 때, <input/> 요소의 최소 너비가 고정되어 있어서 text가 가운데 정렬되지 않는 문제가 있습니다.
      image
    • field-sizing이라는 CSS 속성을 content로 설정하면 간단하게 해결되지만, 이 속성은 아직 공식 스펙이 아닌 것 같습니다.
      image
    • JavaScript를 사용하지 않고는 아직까지 이것을 구현하는 방법이 딱히 없는것인지 궁금합니다.
  • Page 전환 delay 문제
    • Home page(/)와 상세 페이지(/items/:id) 간에 page를 전환할 때 체감할 수 있을 정도의 delay가 발생합니다.
    • 개발자 도구의 Network 탭에서 확인해 보면 HTML과 JS bundle을 로드하는 데 시간이 오래 걸리는 것 같습니다.
      • 최초 home page(/) 접근 시 HTML을 로드하는 데에 약 0.9초 소요
        image
      • 상세 page(/items/:id) 접근 시 JS bundle을 로드하는 데에 약 0.8초 소요
        image
    • 왜 이런 delay가 발생하는 것인지, 그리고 이 시간을 어떻게 줄일 수 있을지 궁금합니다.
      • 지금은 두 page를 모두 SSR 방식으로 렌더링하도록 구현했는데 ISR 방식을 사용해야 하는 타이밍인걸까요?
  • TypeScript 사용 시 type 정의를 import할 때 type keyword 사용
    • Type 정의를 export하고 다른 module에서 import할 때 type keyword를 사용하면 JavaScript 변환 시 type import 코드는 컴파일 과정에서 제외된다는 내용을 보면 type keyword를 항상 사용해야 할 것 같습니다. (관련 글)
    • 그런데, type keyword는 자동완성이 되지 않아서 일일이 붙여주어야 해서 번거롭다는 느낌을 받았습니다.
    • Type import를 할 때 type keyword를 꼭 붙여주어야 할까요? type keyword도 자동완성 되도록 설정하는 방법이 있을까요?

@cskime cskime requested a review from kiJu2 September 21, 2025 04:36
@cskime cskime self-assigned this Sep 21, 2025
@cskime cskime added the 매운맛🔥 뒤는 없습니다. 그냥 필터 없이 말해주세요. 책임은 제가 집니다. label Sep 21, 2025
@kiJu2
Copy link
Collaborator

kiJu2 commented Sep 23, 2025

스프리트 미션 하시느라 수고 많으셨어요.
학습에 도움 되실 수 있게 꼼꼼히 리뷰 하도록 해보겠습니다. 😊

Comment on lines +4 to +42
export default function TodoDetailTitle({
name,
isCompleted,
onNameChange,
onCompletedChange,
}: {
name: string;
isCompleted: boolean;
onNameChange: (newName: string) => void;
onCompletedChange: (newCompleted: boolean) => void;
}) {
let className = styles.todoDetailTitle;
if (isCompleted) {
className += ` ${styles.checked}`;
}

const checkImage = isCompleted
? "/images/checkbox-checked.svg"
: "/images/checkbox.svg";

const handleClick = () => {
onCompletedChange(!isCompleted);
};

const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const span = document.createElement("span");
span.textContent = event.target.value;
onNameChange(event.target.value);
};

return (
<div className={className}>
<div className={styles.checkImage} onClick={handleClick}>
<img src={checkImage} alt="check" />
</div>
<input className={styles.title} value={name} onChange={handleChange} />
</div>
);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<input/> 요소의 width를 입력된 text에 딱 맞게 조절하는 방법

  • 할 일 제목을 즉시 수정하기 위해 <input/> tag를 사용할 때, <input/> 요소의 최소 너비가 고정되어 있어서 text가 가운데 정렬되지 않는 문제가 있습니다.

    image

  • field-sizing이라는 CSS 속성을 content로 설정하면 간단하게 해결되지만, 이 속성은 아직 공식 스펙이 아닌 것 같습니다.

    image

  • JavaScript를 사용하지 않고는 아직까지 이것을 구현하는 방법이 딱히 없는것인지 궁금합니다.


에 대한 답변드립니다 !


image

말씀주신대로 field-sizing은 다른 브라우저에서 제대로 지원이 안되는 것으로 보이네요.
파이어폭스를 켜서 실험해보니 동작되지 않는 이슈가 있었습니다.

처음에는 다음과 같은 해결 방안을 생각했습니다:

      <div
        className={styles.title}
        value={name}
        onChange={handleChange}
        contenteditable="true"
        role="textbox"
      >{value}</div>

근데 위와 같은 방법으로 하면 onChange 및 폼 제출에 대한 접근성도 떨어질 것으로 우려가 되는군요.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

숨겨진 요소로 width를 계산하면 어떨까?

css로만 문제를 해결하려 했으나 마땅히 해결할 수 있는 방안은 없을 것으로 판단되었습니다.
현재 구조가 어차피 키 입력 시 리렌더링이 되고 있고 리렌더링 될 때 width를 계산하여 가변적으로 조절해주면 기존과 큰 성능 이슈 없이 해결할 수 있을 것으로 판단하였습니다. 🤔

그런데, input은 기본적으로 width가 고정되어 있잖아요?
따라서 inputwidth가 출력되었을 때를 예상하여 같은 스타일을 가진 숨겨진 미러가 있으면 어떨까요?(마치 이미지 업로드를 커스텀하는 것처럼요 !)

Comment on lines +14 to +18
}) {
let className = styles.todoDetailTitle;
if (isCompleted) {
className += ` ${styles.checked}`;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(이어서) 해당 코드에 inputRefspanRef 그리고 useEffect를 추가해줍니다.

Suggested change
}) {
let className = styles.todoDetailTitle;
if (isCompleted) {
className += ` ${styles.checked}`;
}
}) {
const spanRef = useRef<HTMLSpanElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (spanRef.current && inputRef.current) {
const newWidth = spanRef.current.offsetWidth + 4; // 4는 여유 px
inputRef.current.style.width = `${newWidth}px`;
}
}, [inputRef.current?.value, spanRef.current]);
let className = styles.todoDetailTitle;
if (isCompleted) {
className += ` ${styles.checked}`;
}

Comment on lines +34 to +41
return (
<div className={className}>
<div className={styles.checkImage} onClick={handleClick}>
<img src={checkImage} alt="check" />
</div>
<input className={styles.title} value={name} onChange={handleChange} />
</div>
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(이어서) 그리고 렌더링 코드에 미러를 추가합니다.

Suggested change
return (
<div className={className}>
<div className={styles.checkImage} onClick={handleClick}>
<img src={checkImage} alt="check" />
</div>
<input className={styles.title} value={name} onChange={handleChange} />
</div>
);
return (
<div className={className}>
<div className={styles.checkImage} onClick={handleClick}>
<img src={checkImage} alt="check" />
</div>
<div className={styles.title}>
<input
ref={inputRef}
value={name}
onChange={handleChange}
/>
<span ref={spanRef} className={styles.mirror}>
{name}
</span>
</div>
</div>
);

⚠️ 미러 요소는 input의 결과값 똑같은 스타일로 만들어줍니다 !

Comment on lines +17 to +26
.todoDetailTitle input {
field-sizing: content;
padding: 0;
background: none;
border: none;
outline: none;
font-size: 20px;
font-weight: 700;
line-height: 100%;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(이어서) 그리고 추가된 .mirror의 스타일을 똑같이 만들어줍니다

Suggested change
.todoDetailTitle input {
field-sizing: content;
padding: 0;
background: none;
border: none;
outline: none;
font-size: 20px;
font-weight: 700;
line-height: 100%;
}
.todoDetailTitle input,
.mirror {
padding: 0;
margin: 0;
background: none;
border: none;
outline: none;
font-family: inherit;
font-size: 20px;
font-weight: 700;
line-height: 100%;
text-decoration: inherit;
}

여기서 width에 영향을 받을만한 몇 가지 스타일을 추가했습니다. ✨

그리고 mirror는 유저에게 보이면 안되므로 다음 코드를 추가해줍니다:

.mirror {
  position: absolute;
  visibility: hidden;
  white-space: pre;
  pointer-events: none;
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이제 input을 가변적인 크기로 스타일링 할 수 있게 됩니다 💪

2025-09-23.11.47.47.mov

혹시 모르니 수정된 코드 전문 첨부드릴게요 !:

diff --git a/my-app/components/todo/todo-detail-title.module.css b/my-app/components/todo/todo-detail-title.module.css
index f7d328d..688fb61 100644
--- a/my-app/components/todo/todo-detail-title.module.css
+++ b/my-app/components/todo/todo-detail-title.module.css
@@ -14,15 +14,24 @@
   background-color: var(--color-violet-200);
 }

-.todoDetailTitle input {
-  field-sizing: content;
+.todoDetailTitle input,
+.mirror {
   padding: 0;
+  margin: 0;
   background: none;
   border: none;
   outline: none;
+  font-family: inherit;
   font-size: 20px;
   font-weight: 700;
   line-height: 100%;
+  text-decoration: inherit;
+}
+
+.title {
+  position: relative;
+  text-align: center;
+  min-width: 2ch;
 }

 .checkImage {
@@ -35,3 +44,10 @@
   width: 100%;
   height: 100%;
 }
+
+.mirror {
+  position: absolute;
+  visibility: hidden;
+  white-space: pre;
+  pointer-events: none;
+}
diff --git a/my-app/components/todo/todo-detail-title.tsx b/my-app/components/todo/todo-detail-title.tsx
index 5bc34ed..3f870f8 100644
--- a/my-app/components/todo/todo-detail-title.tsx
+++ b/my-app/components/todo/todo-detail-title.tsx
@@ -1,4 +1,4 @@
-import { ChangeEvent } from "react";
+import { ChangeEvent, useEffect, useRef } from "react";
 import styles from "./todo-detail-title.module.css";

 export default function TodoDetailTitle({
@@ -12,6 +12,17 @@ export default function TodoDetailTitle({
   onNameChange: (newName: string) => void;
   onCompletedChange: (newCompleted: boolean) => void;
 }) {
+  const spanRef = useRef<HTMLSpanElement>(null);
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  useEffect(() => {
+    if (spanRef.current && inputRef.current) {
+      const newWidth = spanRef.current.offsetWidth + 4; // 4는 여유 px
+      inputRef.current.style.width = `${newWidth}px`;
+    }
+
+  }, [inputRef.current?.value, spanRef.current]);
+
   let className = styles.todoDetailTitle;
   if (isCompleted) {
     className += ` ${styles.checked}`;
@@ -36,7 +47,16 @@ export default function TodoDetailTitle({
       <div className={styles.checkImage} onClick={handleClick}>
         <img src={checkImage} alt="check" />
       </div>
-      <input className={styles.title} value={name} onChange={handleChange} />
+      <div className={styles.title}>
+        <input
+          ref={inputRef}
+          value={name}
+          onChange={handleChange}
+        />
+        <span ref={spanRef} className={styles.mirror}>
+          {name}
+        </span>
+      </div>
     </div>
   );
 }

Comment on lines +28 to +32
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const span = document.createElement("span");
span.textContent = event.target.value;
onNameChange(event.target.value);
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

span을 생성하기는 했는데 왜 생성했는지 잘 모르겠군요 !

Suggested change
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const span = document.createElement("span");
span.textContent = event.target.value;
onNameChange(event.target.value);
};
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
onNameChange(event.target.value);
};

아마 저랑 비슷한 아이디어로 해결해보려 하셨던걸까요? 😉

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ㅎㅎ... 맞습니다. 삭제하는걸 까먹었네요

Comment on lines +4 to +23

export default function TodoDetailImagePreview({
imageUrl,
onChange,
}: {
imageUrl?: string;
onChange: (file: File) => void;
}) {
const [previewUrl, setPreviewUrl] = useState<string | undefined | null>(
imageUrl
);

const hasPreview = useMemo(() => {
return previewUrl !== "" && previewUrl != null;
}, [previewUrl]);

const handlePreviewChanged = (file: File | null, reset: () => void) => {
if (!file) return;

const maxSize = 5 * 1024 * 1024;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

상수는 컴포넌트 바깥에 선언해볼 수 있습니다 😉

Suggested change
export default function TodoDetailImagePreview({
imageUrl,
onChange,
}: {
imageUrl?: string;
onChange: (file: File) => void;
}) {
const [previewUrl, setPreviewUrl] = useState<string | undefined | null>(
imageUrl
);
const hasPreview = useMemo(() => {
return previewUrl !== "" && previewUrl != null;
}, [previewUrl]);
const handlePreviewChanged = (file: File | null, reset: () => void) => {
if (!file) return;
const maxSize = 5 * 1024 * 1024;
const MAX_FILE_SIZE = 5 * 1024 * 1024;
export default function TodoDetailImagePreview({
imageUrl,
onChange,
}: {
imageUrl?: string;
onChange: (file: File) => void;
}) {
const [previewUrl, setPreviewUrl] = useState<string | undefined | null>(
imageUrl
);
const hasPreview = useMemo(() => {
return previewUrl !== "" && previewUrl != null;
}, [previewUrl]);
const handlePreviewChanged = (file: File | null, reset: () => void) => {
if (!file) return;

핸들러가 실행될 때 마다 재선언이 될 것이며, 컴포넌트의 상태나 props를 참조하고 있지 않으므로(= 컴포넌트 내의 값을 참조하지 않으므로) 바깥에 선언해볼 수 있습니다 😊

Comment on lines +4 to +44
export async function getTodos(): Promise<Todo[]> {
try {
const client = new HttpClient();
const items = await client.get("items");
return items;
} catch (error) {
return [];
}
}

export async function getTodo(id: number): Promise<Todo | null> {
try {
const client = new HttpClient();
const item = await client.get(`items/${id}`);
return item;
} catch (error) {
return null;
}
}

export async function addTodo(name: string): Promise<Todo | null> {
try {
const client = new HttpClient();
const newItem = await client.post(`items`, { name });
return newItem;
} catch (error) {
return null;
}
}

export async function toggleTodo(todo: Todo): Promise<Todo | null> {
try {
const client = new HttpClient();
const updatedItem = await client.patch(`items/${todo.id}`, {
isCompleted: !todo.isCompleted,
});
return updatedItem;
} catch (error) {
return null;
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(제안) 굿굿. 여전히 깔끔한 API 함수군요. 다만, 예외 처리된 부분을 한 번 살펴볼까요?

현재 에러가 발생하여도 호출부(컴포넌트)에서는 "정상 동작 되는 것"으로 인식하게 될 것으로 보여요.
이렇게 되면 사용자에게 네트워킹 관련 에러나 서버로부터 받은 피드백 등을 출력해주기 어려울거예요.

따라서, catch에서 throw를 해보면 어떨까요?
API 함수는 API와 관련된 예외처리(로깅 등)을 하고 throw를 던져서 호출부에서 catch할 수 있도록 만들어볼 수 있을거예요.
만약 이렇게 한다면 프로덕트에서 사용하던 toastmodal도 수월하게 사용해볼 수 있겠네요 😉

@kiJu2
Copy link
Collaborator

kiJu2 commented Sep 23, 2025

Page 전환 delay 문제

  • Home page(/)와 상세 페이지(/items/:id) 간에 page를 전환할 때 체감할 수 있을 정도의 delay가 발생합니다.
  • 개발자 도구의 Network 탭에서 확인해 보면 HTML과 JS bundle을 로드하는 데 시간이 오래 걸리는 것 같습니다.
    • 최초 home page(/) 접근 시 HTML을 로드하는 데에 약 0.9초 소요

      image

    • 상세 page(/items/:id) 접근 시 JS bundle을 로드하는 데에 약 0.8초 소요

      image

  • 왜 이런 delay가 발생하는 것인지, 그리고 이 시간을 어떻게 줄일 수 있을지 궁금합니다.
    • 지금은 두 page를 모두 SSR 방식으로 렌더링하도록 구현했는데 ISR 방식을 사용해야 하는 타이밍인걸까요?

크으... 지금 타이밍에 정말 너무너무 좋은 고민이네요.
현재 구조는 SSR인데 코드잇과 통신하여 결과값을 받아오는 시간이 필요할 수 밖에 없습니다.

App router의 경우 fetch를 확장해줘서 캐싱을 할 수 있기는 한데 페이지 라우터에서는 ISR으로 해결해야 할 것 같군요. 😉

혹은 브라우저 단에 캐싱을 할 수 있는 React QuerySWR을 활용하여 성능을 높일 수 있습니다.
또 다른 방법을 Prefetch를 활용할 수 있구요.

노드 레벨(NextJs 서버)에서 캐싱을 하려면 Redis를 세팅해두는 방법
또 다른 방법으로 네트워크 계층에서 캐싱을 하는 방법이 있는데, 역방향 프록시 레벨에서 캐싱을 하는 방법이 일반적이며 Cache-Control로 전달할 수 있습니다.
예를 들어 Vercel이나
자체적으로 구축하신다면 CloudFront을 활용해볼 수 있습니다.

리버스 프록시?: 클라이언트의 요청을 대신 받아 내부의 여러 서버로 분산하여 전달하고, 응답을 클라이언트에게 전달하는 서버 앞단에 위치하는 프록시 서버.

프리페치나 ISR을 적용하는게 가장 쉬워보이네요.
아니면 백엔드의 결과값을 노드단에서 캐싱하시고 싶으시면 어플리케이션 레벨에서 직접 인메모리 캐시를 적용해보시는 것도..? 참솔님이라면 해볼만한 도전과제인 것 같네요 😉

@kiJu2
Copy link
Collaborator

kiJu2 commented Sep 23, 2025

TypeScript 사용 시 type 정의를 import할 때 type keyword 사용

  • Type 정의를 export하고 다른 module에서 import할 때 type keyword를 사용하면 JavaScript 변환 시 type import 코드는 컴파일 과정에서 제외된다는 내용을 보면 type keyword를 항상 사용해야 할 것 같습니다. (관련 글)
  • 그런데, type keyword는 자동완성이 되지 않아서 일일이 붙여주어야 해서 번거롭다는 느낌을 받았습니다.
  • Type import를 할 때 type keyword를 꼭 붙여주어야 할까요? type keyword도 자동완성 되도록 설정하는 방법이 있을까요?

아쉽게도.. 넵.. 제가 아는 선에서는 type은 명시할 수 밖에 없지 않을까 싶습니다.
만약 이게 가능하려면 다음 코드가 불가능해져야 되지 않을까 싶습니다 🤔:

const text = "Hello";
type text = string;

두 선언문이 식별되어야 할텐데, 아쉽게도 현재로서는 type을 두지 않고서는 식별할 수 없겠군요.

@kiJu2
Copy link
Collaborator

kiJu2 commented Sep 23, 2025

수고하셨습니다 참솔님 ! 이번 미션도 멋지게 해내셨네요 ! 😉
CSS가 참 간단한데 시간은 오래잡아 먹는 것 같아요 😅
간단하게 끝날 줄 알았는데 저도 1시간 정도 고민했던 것 같습니다.

덕분에 좋은 꼼수(?) 하나 알아가네요 💪
다른 방법을 찾게 된다면 공유해주시면 좋을 것 같아요 😊

이번 미션 수행하시느라 정말 수고 많으셨어요 참솔님 ~~!

@kiJu2 kiJu2 merged commit a73c817 into codeit-bootcamp-frontend:Next-김참솔 Sep 23, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

매운맛🔥 뒤는 없습니다. 그냥 필터 없이 말해주세요. 책임은 제가 집니다.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants