Refactor(client): PromptInput 컴포넌트 리디자인 적용 - #272
Conversation
🎨 Storybook 배포 완료PR 작성자: @twossu |
jm8468
left a comment
There was a problem hiding this comment.
전역 스크롤은 별도 PR에서 진행하는거 좋네용
수고하셨어요!
| value, | ||
| onChange, | ||
| selectedOptionId, | ||
| onOptionSelect, | ||
| onSubmit, | ||
| disabled = false, | ||
| selectedMemos = [], | ||
| onRemoveMemo, | ||
| isDragOver = false, |
There was a problem hiding this comment.
props에 대해 다시 고민해보면 어떨까용??
컴포넌트의 props를 설계할 때 고려되는 기준 중 하나는 API를 어느 컴포넌트에서 호출할 것인가 에요.
동시에 API의 호출 위치를 결정하는 기준 중 하나는 관련된 것은 가장 가까이 있어야 한다 에요.
이 PromptInput 내에는 선택된 메모를 관리하고 / textarea에 내용을 입력하고 / 제출 버튼을 통해 내용을 전송하고 있어요.
현재 스웨거에서 어떤 값을 담아 요청을 보내야 하는지 확인하면 props를 줄일 수 있다고 생각해요!
There was a problem hiding this comment.
요 코멘트에 대한 내용만은 아니고 코드리뷰 전체에서 적용할 수 있는 내용인데, 저도 도움이 됐어서 공유드립니당
리뷰를 보고 특정 방식으로 진행하겠다는 생각이 서더라도, 불확실하거나 리뷰어가 함께 고민해주길 바라는 부분은 코드로 작성하기 전에 미리 공유해주셔도 굿굿입니다!
코드로 작성하기 전에 티키타카로 합의하고 커밋하면 리뷰어가 납득하기 쉽고 편해져요.
반대로 리뷰에 대해 혼자 판단해서 반영하고 커밋을 올리면 리뷰어가 그 반영 커밋의 의도를 확인하는 데에도 시간이 들어요
-> 결과적으로 시간을 아끼려던 선택이 더 느려지게 돼요
There was a problem hiding this comment.
refactor: PromptInput 컴포넌트를 AiPrompt에 통합하고 props 정리
좋은 기준 감사합니다!!! 말씀하신 기준으로 다시 보니 value, onChange, selectedOptionId, onOptionSelect는 결국 PromptInput 내부에서 텍스트 입력과 옵션 선택을 관리하는 데만 쓰이고 있어서 굳이 상위 컴포넌트에서 내려받을 필요가 없네요. 그래서 useState로 컴포넌트 내부에서 관리하도록 옮기고 제출 시점에 필요한 값만 onSubmit으로 넘기도록 정리했습니다!
There was a problem hiding this comment.
굿굿!!!
onSubmit도 props로 사용하지 않아도 될 것 같은데 한 번 확인해주시겠어여??
스웨거를 보면
요청 시 포함할 데이터는 아래와 같아요
- chatRoomId : AI 대화 세션 ID
- userPrompt : 사용자의 질문
- option : AI 처리 전략 (MERGE, STRUCTURE, SUMMARY)
- memoIds : 참조할 메모 ID 목록
MemoInput 컴포넌트 내부에서 userPrompt, option, memoIds를 관리하고 있어서 props로 받지 않아도 내부에서 해결할 수 있어 보여요.
There was a problem hiding this comment.
상위 컴포넌트에서 조합해서 쓰려면 제출 트리거가 필요할 것 같아서 onSubmit으로 필요할 것 같아요!
이 컴포넌트에서 API를 직접 호출하는 구조로 바꾸면 성공/실패에 따른 messages 리스트 관리 여러 mutation을 합친 isLoading 같은 걸 PromptInput이 추가로 알아야 해서 오히려 결합도가 올라갈 것 같아요.
그래서 onSubmit으로 요청에 필요한 값(userPrompt, option)만 올려주도록 했는데 제가 질문을 잘못 이해한 것이라면 알려주세요!
There was a problem hiding this comment.
레포를 좀 찾아봤는데, use-ai-prompt 훅에서 mutation 관련 로직이 합쳐져 있었군요!
저희가 각자 알고 있는 코드 맥락이 달라서 한번 더 질문드리게 됐던 것 같아요
그렇다면 윤하님 의견대로 가면 좋을 것 같습니다 :D
jogpfls
left a comment
There was a problem hiding this comment.
구현하시느라 수고하셨어요 !! 어려우셨을텐데,, 역시 윤하리드님이네요,, 👍🏻
궁금한 점이랑 확인하면 좋을 부분 코멘트로 남겼으니까 확인 부탁드려요 !
| }, [value]); | ||
|
|
||
| const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => { | ||
| if (e.key !== 'Enter' || e.shiftKey) return; |
There was a problem hiding this comment.
| if (e.key !== 'Enter' || e.shiftKey) return; | |
| if (e.key !== 'Enter' || e.shiftKey || event.nativeEvent.isComposing) return; |
지연님 PR에서도 언급하긴 했는데 한글은 IEM 조합 문자라서, 한글이 조합중이라고 판단하면 조합 완료, 키 누름 완료, 이렇게 2번 인식해서 엔터키를 눌렀을 때 2번 전송이 될 수 있어요 ! 그래서 event.nativeEvent.isComposing 이걸 처리해줘야 한 번만 전송될거에요 !
There was a problem hiding this comment.
덜덜 지연님 PR에서 코멘트 보고 "오 신기하네" 하고 그냥 지나갔었는데 생각해보니 저도 같은 상황이었네요 ㅎㅎㅎㅎ
input에 이런 방어 로직을 넣어야 한다는 걸 이제야 알았어요. 이 로직 꼭 기억해두고 잊지 않을게요 🙏
| useLayoutEffect(() => { | ||
| const textarea = textareaRef.current; | ||
| if (!textarea) return; | ||
|
|
||
| textarea.style.height = 'auto'; | ||
| textarea.style.height = `${textarea.scrollHeight}px`; | ||
| }, [value]); |
There was a problem hiding this comment.
나중에,,,,,,,, height을 측정해서 height을 return 하는 공용 유틸로 빼도 좋을 것 같아용 !!
There was a problem hiding this comment.
근데 useLayoutEffect 말고 textarea에서 row 속성을 사용하거나 최대 8줄을 사용해서 css로 처리하는 방법은 없을까요 ?? JW
There was a problem hiding this comment.
나중에 높이를 측정하는 곳이 있다면 공용 으로 빼면 좋을 것 같아욥!
왜 useLayoutreffect로 처리했냐며언! row랑 CSS는 정적인 값이라 지금 몇 줄인지 계산할 수 없어서 JS로 측정하도록 처리했습니다!
저도 다른 방법이 있을지 자동 조절 되는textarea 구현하기 여러 사람들 블로그를 보니깐 다들 이렇게 구현했더라고요!
| <div className={styles.footer}> | ||
| <PromptOption | ||
| selectedOptionId={selectedOptionId} | ||
| onOptionSelect={setSelectedOptionId} |
There was a problem hiding this comment.
setter 함수를 바로 넘기고 있는데 여기서는 큰 문제가 되진 않지만 한 번쯤 알면 좋을 것 같아서 이야기해봅니다 !
재사용 컴포넌트에서는 setter 함수를 넘겨주는건 의논이 많이 갈리는 방법이에요 ! ! 물론 여기는 재사용 컴포넌트는 아니라서 큰 문제는 안되지만요 !
자식 컴포넌트가 부모 컴포넌트의 정보를 많이 알게 되는 문제도 있고, 나중에 요구되는 기능이 있어서 넘겨야하는 함수 하나가 추가된다면 새로운 함수를 만들어야 한다는 단점이 있어요. 물론 setter함수를 그대로 넘기지 않는다면 의미없는 함수 래핑이 된다며 setter 함수를 props로 바로 넘겨주는 것에 있어서 문제가 안된다는 의견도 있고요
레퍼런스: https://matanbobi.dev/posts/stop-passing-setter-functions-to-components
근데 제가 안에 PromptOption 함수를 보니까 안에서 한 번 더 래핑해주고 있더라고요
//prompt-option.tsx
const handleSelect = (id: string) => {
if (selectedOptionId === id) return;
onOptionSelect(id);
};차라리 여기 파일에서 위에 로직을 처리해주시면 좋은데 어떻게 생각하실까용가리?
There was a problem hiding this comment.
헉 매번 상위에서 로직을 관리할 수 있도록 신경 쓰려고 했는데 prompt-item의 상위가 PromptOption이니까 여기서 처리하면 되겠다고 단순하게 생각해버렸네요!
지금은 재사용 컴포넌트가 아니지, 코드 통일성과 제 습관 개선을 위해 PromptInput에서 처리하도록 수정했습니다!
또 지금은 재사용 컴포넌트가 아니더라도 나중에 재사용하게 되는 경우가 종종 있으니 미리 수정해두는 게 좋을 것 같아요.
|
|
||
| interface PromptInputValueType { | ||
| text: string; | ||
| selectedOptionId: string | null; |
There was a problem hiding this comment.
위에서
type OptionType = 'MERGE' | 'SUMMARY' | 'STRUCTURE';처리 해주고
| selectedOptionId: string | null; | |
| selectedOptionId: OptionType; |
이 타입 사용하면 어떨까용 ??
이번 스프린트부터 라디오처럼 셋 중 하나는 항상 선택되어 있어야 하는 사양으로 바뀌어서
위와 같은 상황이라면 null이 필요없고 셋 중에 하나 강제하면 될 것 같은데 null 타입이 필요할까요 ?!
안에 prompt-option에서도 위와 관련해서 타입 반영해주면 좋을 것 같아요 !!
There was a problem hiding this comment.
prompt-input에서 타입을 스키마의 값을 바로 받아서 사용하도록 수정하면서 같이 수정완료했습니다1!
|
|
||
| interface PromptInputValueType { | ||
| text: string; | ||
| selectedOptionId: string | null; |
There was a problem hiding this comment.
뭔가 뒤에 id를 붙히니까 숫자?같은게 올 것 같은데,, 저만 그런가요 ?? 저만 그런거일수도 있어서,,, 이거는 윤하님 기호에 맞게 생각해주십쇼,,
There was a problem hiding this comment.
prompt-input에서 타입을 스키마의 값을 바로 받아서 사용하도록 수정하면서 같이 수정완료했습니다1!
| }: PromptInputProps) => { | ||
| const textareaRef = useRef<HTMLTextAreaElement>(null); | ||
| const [value, setValue] = useState(''); | ||
| const [selectedOptionId, setSelectedOptionId] = useState<string | null>( |
There was a problem hiding this comment.
| const [selectedOptionId, setSelectedOptionId] = useState<string | null>( | |
| const [selectedOptionId, setSelectedOptionId] = useState<OptionType>( |
There was a problem hiding this comment.
prompt-input에서 타입을 스키마의 값을 바로 받아서 사용하도록 수정하면서 같이 수정완료했습니다1!
refactor: PromptInput 컴포넌트의 타입을 스키마 기반으로 정리
refactor: PromptInput/PromptOption의 option 타입을 PromptInputValueType 기…
📌 Summary
AI 패널의 프롬프트 입력 영역(
PromptInput/PromptOption/SelectedMemo)을ai-panel폴더로 재배치하고 선택된 메모 노출/삭제 textarea 자동 높이 조정 등 UX 디테일을 다듬은 리팩터링입니다. 스타일 측면에서는 border 렌더링 방식을 outline → inset box-shadow로 바꿔 레이아웃 흔들림 없이 focus 상태를 표현하도록 정리했습니다.📚 Tasks
PromptInput,PromptOption을ai-prompt/components→ai-panel/prompt-input으로 이동selectedMemosCount(숫자) →selectedMemos(배열) +onRemoveMemo로 교체, 메모 칩 노출·개별 삭제·가로 스크롤 지원canSend) 단순화PromptOption재클릭 시 선택 해제 로직 제거, sm 버튼 비활성 배경색 수정, Tooltip 줄바꿈 방지handleX→onX로 통일🔍 Describe
구조 이동
지연님의 selected-memo PR 구조를 참고해서
PromptInput,PromptOption을ai-panel폴더로 옮겨 AI 관련 패널 컴포넌트들을 한 곳에서 관리하도록 정리했습니다.선택된 메모 UI
기존에는
selectedMemosCount숫자만 받아서 개수만 표시했는데 실제 메모 제목 노출·개별 삭제가 필요해져서selectedMemos: {id, title}[]+onRemoveMemo로 교체했습니다. 각 메모는SelectedMemo칩으로 렌더링되고 개수가 많아지면 줄바꿈 대신 가로 스크롤됩니다.textarea 자동 높이
입력 내용이 길어지면
min-height(6.6rem) ~ max-height(19.2rem)범위 내에서 textarea 높이가 자동으로 늘어나도록 했습니다.전송 가능 조건 정리
메모를 선택하지 않아도 전송 가능하도록 바꾼 이유: 이번 스프린트부터 ai-panel이 메모를 하나도 선택하지 않은 상태에서도 열릴 수 있게 되어서 텍스트 입력만 있으면 전송 가능하도록
canSend = hasValue && !disabled로 단순화했습니다.handleSend내부에서도 한 번 더 가드해서 disabled 상태에서 우발적으로 제출되는 걸 막았습니다.PromptOption재클릭 시 선택 해제(toggle-off) 제거(
prompt-option.tsx의handleSelect)토글-오프 로직을 제거한 이유: 원래는 선택된 옵션을 다시 클릭하면 선택이 풀리는 로직이 있었는데 이번 스프린트부터 라디오처럼 셋 중 하나는 항상 선택되어 있어야 하는 사양으로 바뀌어서 같은 옵션 재클릭은 무시하고 다른 옵션을 클릭할 때만 전환되도록 단순화했습니다.
border → inset box-shadow 전환 이유
어떻게 발견하게 됐는지

prompt-input사이즈를 피그마랑 비교하는데 자꾸 +2px, +4px 이런 식으로 오차가 나더라고요. "이거 1px짜리 선들이 겹쳐서 계산되는 거 아닐까?" 싶어서 피그마를 다시 들여다봤는데 전역에box-sizing: border-box가 이미 세팅되어 있더라고요?! (전 이게 세팅되어 있는 줄도 모르고 그냥 "선 안쪽으로 잘 그려지네~" 하면서 편하게 살아왔던 것 같습니다 ㅎㅎ)근데 왜 전역 설정이 있는데도 선이 밖으로 그려졌을까
찾아보니
border-box가 적용돼 있어도 border가 진짜 안쪽으로 그려지려면 그 요소의 크기(width/height)가 고정값으로 지정돼 있어야 한다고 하더라고요. 이거 진짜 처음 알았어요! 그동안은 그냥 border 주고 크기 안 맞으면 padding으로만 조절했었는데 왜 자꾸 커지나 했더니 이런 이유가 있었네요.그래서 컴포넌트별로 이렇게 적용했습니다
selected-memo,prompt-option은 width/height를 지정해두고 그대로border를 썼어요prompt-option-item은 크기가 고정인데도 왜box-shadow를 썼냐면 아이콘 자체 크기가 32px인데 selected 상태 아이템 크기도 똑같이 32px이어서 그 안에 진짜 border를 그리면 아이콘이 안 들어맞더라고요. 그래서 여기도 안쪽 선을 쓸 수밖에 없었습니다.prompt-input은 입력 글자 수·선택된 메모 유무에 따라 세로 크기가 계속 바뀌어서 아예 크기를 고정할 수가 없었어요. 다른 방법 없나 찾아봤는데 대부분 "크기 고정하고 border 쓰기" 아니면 "box-shadow 쓰기" 둘 중 하나더라고요. 그래서 결국boxShadow: inset 0 0 0 1px color로 갔습니다.그래도 아직 고민되는 부분
box-shadow로 하니까 뭔가 살짝 어색한 느낌이 있어서 다른 방법 없나 더 찾아봤는데 못 찾았어요... 혹시 좋은 방법 아시면 알려주세요!
콜백 prop 네이밍 컨벤션 정리
handleSubmit,handleOptionSelect처럼 prop 이름에handle접두사가 붙어 있던 걸onSubmit,onOptionSelect로 바꿨습니다. "실제 로직을 가진 함수는 사용하는 쪽에서handleX로 두고 컴포넌트가 외부에서 받는 이벤트 prop은onX로 둔다"는 팀 컨벤션(#249 리뷰 코멘트)에 맞춘 변경입니다.👀 To Reviewer
input 분량이 넘어가면 스크롤이 생기는데,브라우저 기본 스크롤이 서비스 디자인과 어울리지 않아서 디자이너분들께 커스텀 스크롤을 받았습니다. 앞으로 다른 곳에서도 스크롤이 많이 필요할 것 같은데 전역 스타일로 만드는 작업은 별도 PR에서 진행하는 것은 어떡신가요!
📸 Screenshot
2026-07-31.12.35.39.mov
2026-08-02.6.37.49.mov