토스 Frontend Fundamentals에서 진행한 모의고사 2회차에 참여했습니다. 모의 고사는 회의실 예약 과제를 2시간 동안 리팩토링하는 과제였습니다. 이전에 토스 Pre 코스에서 배웠던 것들을 활용할 기회라고 생각했고, 과제에 대한 강의까지 있다고 하니!! 바로 신청해서 진행을 했습니다 ㅎㅎ 먼저 과제를 어떻게 풀었는지를 간단하게 작성하고, 강의 내용도 정리해서 공유하고자 합니다.


리팩토링 과정에서 중요하게 생각하는 부분들

코드가 UI/기획서와 1대1로 대응하는가

  • 코드는 빠르게 변경 가능해야 하고, 코드를 보았을 때 어떤 형태인지 그리고 어떤 작업을 하는지 예측 가능해야 합니다.
  • 그럼 어떻게 코드가 UI 그리고 기획서와 1대1 대응하는지 쉽게 알 수 있을까?
    • 내가 고객 센터 상담원이라고 생각하고 코드를 설명했을 때, 기획서와 일치하는지, 고객이 충분히 잘 이해할 수 있는 정도로 추상화 되었는지를 점검합니다.
    • 잘 설명이 된다면 어느 정도 UI 그리고 기획서와 1대1 대응이 되고 있다고 생각합니다.
    • 즉 기획서의 문장이 코드에 그대로 있어야 합니다.
  • UI랑 코드가 1대1 대응이 된다는 것은 props가 통로가 아닌 인터페이스로서의 역할을 하고 있다는 것입니다. 즉 인터페이스를 봤을 때, 어떤 UI인지 예측 가능하다는 것이죠 -> 즉 시점이동 없이 코드가 예측 가능해야 한다는 것이고, 외부만 봐도 코드에 대한 이해가 되기 때문에 코드의 내부가 궁금하지 않는 형태가 되는 것입니다.

 

최대한 관심사가 동일한 것끼리 가까이 두자 -> 응집도를 최대한 높이자

  • 위에서 이야기 한 시점 이동 비용과 관련있는 이야기입니다. 그 UI에서 처리하는 작업에 대한 코드가 최대한 가까이 있어야 이 코드가 어떤 일을 하는지 바로 이해할 수 있습니다.
  • 간단하게 예를 들면, useQuery나 useMutation을 사용할 때, 보통 위에서 선언하고 작업 로직까지 작성한 후 그 작업 로직을 가지고 있는 핸들러를 아래에서 많이 사용합니다. 그렇게 되면 UI의 형태와 위치를 보여주는 컴포넌트까지 가는 데에도 위를 다 확인하고 내려야 하고, 아래에서 컴포넌트를 보면서 로직을 확인하는 과정에서 시점 이동 비용이 발생하게 됩니다.
  • 그래서 가능하다면 queryOptions 등을 사용해서 컴포넌트에서 어떻게 호출하는지를 바로 확인할 수 있도록하여 관심사가 동일한 것끼리 최대한 뭉쳐 놓는 것입니다.
  • 이렇게 뭉쳐 놓고, 뭉쳐 놓은 것들을 분리하는 이득이 시점 이동 비용보다 크다면, 분리를 하는 것입니다.

 

그렇다면 추상화를 한다면 어떻게 할 것인가? -> 안에서 밖으로 추상화하자 (what과 how를 파악하고 본질인 what을 밖으로! 세부사항인 how를 안으로 넣자!)

  • 핵심적인 질문: ~ 이 코드는 애초에 무엇을 하려는 코드인가요?
  • 무엇을 하려는 코드인가? -> 이 코드를 작성한 목적은 무엇일까
  • 결국 추상화된 코드는 해당 코드를 작성한 목적, 즉 해당 코드를 보여주려고 했던 요구사항을 나타내야 합니다.
  • 코드를 모두 펼쳐보고, 요구사항을 나타내는 what을 찾아서 외부에 드러내고, 그 외의 how는 내부에 넣어야 합니다.

 


어떤 절차적인 방법을 사용할 것인가?

방법

-> 뭉치기 (이미 뭉쳐져 있음)
-> 이래도 되나 싶을 정도로 펼치기
(뭉쳐지는 것을 참지 못하고 패턴을 알기 전에 코드를 분리하며 궁극적인 추상으로 나아가지 못하는 경우가 많음)
-> 패턴 파악하기
-> 분리하기
-> 자연스럽게 나오는 이름 (=> 추상화)

 

상세 설명

1. 뭉쳐놓을 용기를 내라 (심리적 허용)
        ↓
2. "이래도 되나?" 싶을 정도까지 펼쳐라
        ↓
3. 패턴이 보일 것이다 (인간의 기본 능력)
        ↓
4. 그때 분리하라
        ↓
5. 이름이 자연스럽게 나온다
  • page에서 컴포넌트를 하나씩 펼쳐보자
  • 같은 패턴이 보인다 -> 추상화를 하자
  • 그리고 안에서 밖으로 코드를 보내자
  • 뭔가 정답이 없다 ? -> 요구사항을 보자

 

이루고자 하는 것

  • 해석의 여지가 없고, 고객의 언어로 이야기하는 코드 짜고자 합니다.
  • UI의 대응에서 끝나는 것이 아니라, 그 코드가 어떤 코드인지, 고객한테 어떤 형태로 보여주고 싶었는지에 집중하여 본질이 보여지는 코드로 리팩토링 하겠습니다.

과제 수행 과정

ReservationStatusPage 분석

기획서 기준 관심사 4가지

1. 날짜를 선택한다
2. 해당 날짜의 예약 현황을 타임라인으로 본다 (블록 클릭 → 상세)
3. 내 예약 목록을 본다 + 취소할 수 있다
4. "예약하기" 버튼으로 예약 페이지로 이동한다
 

현재 문제

  • 4가지 관심사가 하나의 파일(약 480줄)에 how까지 전부 펼쳐져 있다.
  • 데이터 페칭(useQuery, useMutation)이 파일 상단에 몰려 있고, 실제 사용하는 UI와 멀리 떨어져 있어 시점 이동 비용이 발생한다.

리팩토링 후 목표하는 페이지 모습

export function ReservationStatusPage() {
  const [date, setDate] = useState(formatDate(new Date()));

  return (
    <div>
      <Top.Top03>회의실 예약</Top.Top03>
      <SuccessMessage />
      <DatePicker value={date} onChange={setDate} />
      <ReservationTimeline date={date} />
      <MyReservations />
      <Button onClick={() => navigate('/booking')}>예약하기</Button>
    </div>
  );
}

-> 근데 해당 상태는 너무 추상화가 심하게 된 상태긴하다!

-> 진행하면서 유연하게 바꿀 예정..!

 

분리 대상

컴포넌트what (외부에 드러남)how (내부에 숨겨짐)

DatePicker value, onChange input[type=date], min 제한, 스타일링
ReservationTimeline date rooms/reservations 페칭, 시간→퍼센트 변환, 블록 배치, 툴팁 토글
MyReservations - myReservations 페칭, cancelMutation, confirm, 빈 상태
SuccessMessage - location.state 처리, 메시지 타입별 스타일

 

RoomBookingPage 분석

기획서 기준 관심사 4가지

1. 뒤로가기 → 예약 현황으로 돌아간다
2. 예약 조건을 입력한다 (날짜, 시간, 인원, 장비, 선호 층) + URL 쿼리 동기화
3. 조건에 맞는 예약 가능 회의실 목록을 본다 + 하나를 선택한다
4. 예약을 확정한다 (성공 → 이동, 실패 → 에러)
 

현재 문제

  • state 10개가 파일 상단에 나열되어 있고, 관심사가 다른 state가 섞여 있다.
    • selectedRoomId는 회의실 목록의 관심사인데 필터 state들 사이에 있다.
    • errorMessage는 예약 확정의 관심사인데 역시 섞여 있다.
  • 필터링/정렬 로직 20줄이 렌더링 사이에 인라인으로 존재한다.

 

분리 대상

컴포넌트what (외부에 드러남)how (내부에 숨겨짐)

FilterPanel filters(결과값) 제공 6개 state 관리, URL 쿼리 동기화, 검증 로직, handleFilterChange
AvailableRoomList filters를 받아 표시 rooms/reservations 페칭, 필터링·정렬, selectedRoomId, createMutation, 에러 처리

 

핵심 설계 포인트

FilterPanel → AvailableRoomList로 데이터가 흐르는 구조. 현재 코드가 이미 URL 쿼리 동기화를 하고 있으므로, FilterPanel이 URL을 업데이트하고 AvailableRoomList가 URL에서 읽는 구조가 자연스럽다. 링크 공유 기능도 유지된다.

 


작업 기록

1. DatePicker 분리

파일: src/pages/components/DatePicker.tsx

Before — ReservationStatusPage 내부에 how가 노출 (44줄)

<div css={css`padding: 0 24px;`}>
  <Text ...>날짜 선택</Text>
  <Spacing size={16} />
  <div css={css`display: flex; flex-direction: column; gap: 6px;`}>
    <input
      type="date" value={date} min={formatDate(new Date())}
      onChange={e => setDate(e.target.value)}
      aria-label="날짜"
      css={css`/* 스타일 15줄 */`}
    />
  </div>
</div>
 

After — what만 노출

<div css={css`padding: 0 24px;`}>
  <Text ...>날짜 선택</Text>
  <Spacing size={16} />
  <DatePicker value={date} onChange={setDate} />
</div>
 

판단 근거

  • props(value, onChange)만으로 "날짜를 선택하는 컴포넌트"임을 알 수 있다.
  • 과거 날짜 방지(min), input 스타일링은 DatePicker의 내부 관심사다.
  • RoomBookingPage에도 동일한 날짜 input이 있으므로 재사용 가능하다.

 

2. Timeline 분리 (Compound Component)

파일: src/pages/components/ReservationTimeline.tsx

처음에 <ReservationTimeline date={date} /> 형태로 데이터 페칭까지 통째로 감싸는 방향을 고려했지만, 이렇게 하면 외부에서 UI가 어떤 형태인지 예측이 불가능하다는 문제가 있었다. props가 date 하나뿐이면 인터페이스가 아니라 그냥 통로일 뿐이다.

고민 과정

  1. <ReservationTimeline date={date} /> — 너무 추상화됨. 외부에서 UI 형태 예측 불가.
  2. <ReservationTimeline rooms={rooms} reservations={reservations} /> — 여전히 "타임라인이 어떤 구조인지" 안 보임.
  3. Compound Component — 시간 헤더, 회의실 행, 예약 블록이라는 UI 구조가 외부에서 읽힘. 채택.

Before — 175줄의 how가 페이지에 노출

{/* 시간→퍼센트 계산, 블록 절대 배치, 툴팁 토글, 스타일링이 전부 인라인 */}
<div css={css`background: ...; border-radius: 14px; padding: 16px;`}>
  <div> {/* 시간 헤더 */}
    {HOUR_LABELS.map(t => {
      const left = (timeToMinutes(t) / TOTAL_MINUTES) * 100;
      return <Text css={css`position: absolute; left: ${left}%; ...`}>{t.slice(0, 2)}</Text>
    })}
  </div>
  {rooms.map(room => {
    const roomReservations = reservations.filter(...);
    return (
      <div> {/* 회의실 행 */}
        <Text>{room.name}</Text>
        <div> {/* 바 영역 */}
          {roomReservations.map(res => {
            const left = (timeToMinutes(res.start) / TOTAL_MINUTES) * 100;
            const width = ...;
            return (
              <div css={css`position: absolute; left: ${left}%; width: ${width}%; ...`}>
                <div role="button" onClick={...} css={css`/* 블록 스타일 */`} />
                {isActive && <div role="tooltip" css={css`/* 툴팁 스타일 15줄 */`}>...</div>}
              </div>
            );
          })}
        </div>
      </div>
    );
  })}
</div>
 

After — UI 구조가 읽히는 22줄

<Timeline>
  <Timeline.HourHeader />
  {rooms.map(room => {
    const roomReservations = reservations.filter(r => r.roomId === room.id);
    return (
      <Timeline.RoomRow key={room.id} roomName={room.name}>
        {roomReservations.map(res => (
          <Timeline.ReservationBlock
            key={res.id}
            roomName={room.name}
            start={res.start}
            end={res.end}
            attendees={res.attendees}
            equipment={res.equipment}
          />
        ))}
      </Timeline.RoomRow>
    );
  })}
</Timeline>

 

판단 근거

  • 외부에서 UI 구조가 읽힌다: "시간 헤더가 있고, 회의실별 행이 있고, 각 행에 예약 블록이 있다."
  • ReservationBlock의 props(start, end, attendees, equipment)로 블록이 무엇을 보여주는지 예측 가능하다.
  • how(시간→퍼센트 변환, 블록 절대 배치, 툴팁 토글/스타일링)는 각 서브 컴포넌트 내부에 숨겨졌다.
  • 툴팁 토글 state(isOpen)는 각 ReservationBlock이 자체적으로 관리한다. Context 없이 단순하게 유지했다.
  • 페이지에서 제거된 코드: TIME_SLOTS, HOUR_LABELS, TIMELINE_START/END, TOTAL_MINUTES, timeToMinutes, activeReservation state.

 

3. MyReservations + ReservationCard 분리 (Render Props)

파일: src/pages/components/MyReservations.tsx, src/pages/components/ReservationCard.tsx

핵심 문제: 내 예약 관련 코드(데이터 페칭, 취소 뮤테이션, 메시지 state, getRoomName)가 페이지 상단에 흩어져 있어서, 실제 UI와의 시점 이동 비용이 크다.

고민 과정

  1. useMyReservations 훅으로 분리 — 훅이 페이지 상단에 선언되므로 UI와 여전히 멀어진다. 응집도 문제 해결 안 됨.
  2. <MyReservations /> 통째로 감싸기 — 외부에서 UI 형태(헤더, 카드 목록, 빈 상태)가 안 보인다.
  3. Render Props  MyReservations가 데이터를 제공하고, UI 구조는 페이지에서 조합. 응집도와 가시성 모두 확보. 채택.

관심사 분리: 메시지

message state가 두 출처에서 사용되고 있었다:

  • 예약 성공 메시지(location.state) → 페이지의 관심사 (페이지 간 이동에서 발생)
  • 취소 성공/실패 메시지 → MyReservations의 관심사 (취소 동작의 결과)

이를 분리하여 페이지는 successMessage만, MyReservations는 cancelMessage만 관리한다.

Before — 데이터와 UI가 분산 (페이지 상단 + 하단 100줄)

// 페이지 상단: 데이터 페칭 + 뮤테이션 + 핸들러가 나열
const { data: myReservationList } = useQuery(['myReservations'], ...);
const cancelMutation = useMutation(...);
const handleCancel = async (id) => { ... setMessage(...) };
const getRoomName = (roomId) => rooms.find(...)?.name;

// 한참 아래: UI에서 사용
{myReservationList.map(res => (
  <div css={css`/* 카드 스타일 */`}>
    <ListRow contents={...} right={<Button onClick={...}>취소</Button>} />
  </div>
))}
 

After — 데이터와 UI가 한 블록 안에서 응집

<MyReservations>
  {({ myReservations, getRoomName, handleCancel, cancelMessage }) => (
    <>
      {cancelMessage && <div>...</div>}

      <Text>내 예약</Text> <Text>{myReservations.length}건</Text>

      {myReservations.length === 0 ? (
        <Text>예약 내역이 없습니다.</Text>
      ) : (
        myReservations.map(res => (
          <ReservationCard
            key={res.id}
            roomName={getRoomName(res.roomId)}
            date={res.date}
            time={`${res.start}~${res.end}`}
            attendees={res.attendees}
            equipment={res.equipment}
            onCancel={() => handleCancel(res.id)}
          />
        ))
      )}
    </>
  )}
</MyReservations>
 

판단 근거

  • render props로 데이터와 UI가 같은 블록 안에 위치 — 시점 이동 없음.
  • UI 구조(메시지 배너, 헤더+건수, 빈 상태, 카드 목록)가 페이지에서 그대로 읽힌다.
  • ReservationCard의 props(roomName, date, time, attendees, equipment, onCancel)로 카드가 무엇을 보여주는지 예측 가능.
  • how(데이터 페칭, 취소 뮤테이션, 메시지 관리, roomId→이름 변환)는 MyReservations 내부에 숨겨짐.
  • 카드 스타일링, ListRow 조합, confirm 다이얼로그는 ReservationCard 내부에 숨겨짐.
  • 페이지에서 제거된 코드: queryClient, useMutation, cancelMutation, handleCancel, getRoomName, EQUIPMENT_LABELS, getMyReservations import.

오버 엔지니어링 판단: 카드 목록 래퍼

카드 목록의 레이아웃 div(display: flex; flex-direction: column; gap: 10px)도 how에 해당하지만, ReservationCard.List 같은 래퍼로 추상화하는 것은 이득보다 비용이 크다. 이 div 하나 때문에 시점 이동이 발생하거나 코드 이해가 어려워지지 않으므로 그대로 둔다.

 

4. RoomBookingPage — URL을 Single Source of Truth로 전환

파일: src/pages/RoomBookingPage/index.tsx

핵심 문제: 필터 state 6개가 각각 useState로 선언되고, useEffect로 URL 쿼리 파라미터와 양방향 동기화하고 있었다. state와 URL이라는 두 출처가 존재하여 동기화 코드가 필요했고, 이 동기화 자체가 불필요한 how였다.

Before — state와 URL 이중 관리 + useEffect 동기화

const [date, setDate] = useState(searchParams.get('date') || formatDate(new Date()));
const [startTime, setStartTime] = useState(searchParams.get('startTime') || '');
const [endTime, setEndTime] = useState(searchParams.get('endTime') || '');
const [attendees, setAttendees] = useState(Number(searchParams.get('attendees')) || 1);
const [equipment, setEquipment] = useState<string[]>(
  searchParams.get('equipment') ? searchParams.get('equipment')!.split(',').filter(Boolean) : []
);
const [preferredFloor, setPreferredFloor] = useState<number | null>(
  searchParams.get('floor') ? Number(searchParams.get('floor')) : null
);

// state → URL 동기화
useEffect(() => {
  const params: Record<string, string> = {};
  if (date) params.date = date;
  if (startTime) params.startTime = startTime;
  // ... 6개 필드 전부 수동 매핑
  setSearchParams(params, { replace: true });
}, [date, startTime, endTime, attendees, equipment, preferredFloor, setSearchParams]);

// 필터 변경마다 호출
const handleFilterChange = () => {
  setSelectedRoomId(null);
  setErrorMessage(null);
};

// onChange마다 setState + handleFilterChange 두 번 호출
onChange={e => { setStartTime(e.target.value); handleFilterChange(); }}
 

After — URL이 유일한 진실의 원천

// URL에서 직접 파생 — useState 없음
const date = searchParams.get('date') || formatDate(new Date());
const startTime = searchParams.get('startTime') || '';
const endTime = searchParams.get('endTime') || '';
const attendees = Number(searchParams.get('attendees')) || 1;
const equipment = searchParams.get('equipment')?.split(',').filter(Boolean) ?? [];
const preferredFloor = searchParams.get('floor') ? Number(searchParams.get('floor')) : null;

// URL 업데이트 + 선택 초기화를 하나로 통합
const updateFilter = (key: string, value: string) => {
  setSearchParams(prev => {
    const next = new URLSearchParams(prev);
    if (value) next.set(key, value);
    else next.delete(key);
    return next;
  }, { replace: true });
  setSelectedRoomId(null);
  setErrorMessage(null);
};

// onChange가 단순해짐
onChange={e => updateFilter('startTime', e.target.value)}
 

판단 근거

  • useEffect 제거 — state→URL 동기화라는 간접 경로가 사라졌다.
  • handleFilterChange 제거 — updateFilter에 선택 초기화가 통합되었다.
  • 6개 useState → 순수 파생값 — URL이 유일한 출처이므로 state 중복이 없다.
  • selectedRoomId와 errorMessage만 useState로 남아있다. 이 둘은 URL에 저장할 필요가 없는 UI 인터랙션 상태이므로 적절하다.
  • 기존 동작(링크 공유, 뒤로가기 시 필터 유지)이 그대로 유지된다.

 

5. Chip, NumberInput 분리 — 입력 컴포넌트 추상화 레벨 통일

파일: src/pages/components/Chip.tsx, src/pages/components/NumberInput.tsx

핵심 문제: 페이지에 사용되는 입력 컴포넌트의 추상화 레벨이 불균일했다. Select는 tosslib에서 제공되어 CSS가 숨겨져 있고, DatePicker도 분리했지만, <input type="number">와 장비 토글 <button>은 CSS가 그대로 노출되어 있었다.

고민 과정

  1. ChipGroup  options, selected, onChange를 받아 내부에서 렌더링. 하지만 어떤 칩이 있는지 외부에서 안 보인다. 기각.
  2. 개별 Chip — CSS만 숨기고 현재 구조(map, toggle 로직)는 그대로 유지. 외부에서 각 칩이 보인다. 채택.

Before — CSS가 인라인으로 노출

{/* number input: CSS 7줄 인라인 */}
<input
  type="number" min={1} value={attendees}
  onChange={e => { ... }}
  css={css`
    box-sizing: border-box; font-size: 16px; font-weight: 500; ...
    background-color: ${colors.grey50}; border-radius: 12px; ...
    &:focus { border-color: ${colors.blue500}; }
  `}
/>

{/* 장비 토글: selected/unselected 분기 스타일 인라인 */}
<button
  aria-pressed={selected}
  css={css`
    border: 1px solid ${selected ? colors.blue500 : colors.grey200};
    background: ${selected ? colors.blue50 : colors.grey50};
    color: ${selected ? colors.blue600 : colors.grey700};
    &:hover { border-color: ${selected ? colors.blue500 : colors.grey400}; }
  `}
>
  {EQUIPMENT_LABELS[eq]}
</button>
 

After — CSS가 컴포넌트 안으로, 구조는 그대로

<NumberInput
  value={attendees}
  min={1}
  onChange={v => { ... }}
  aria-label="참석 인원"
/>

<Chip
  selected={selected}
  onClick={() => { ... }}
  aria-label={EQUIPMENT_LABELS[eq]}
>
  {EQUIPMENT_LABELS[eq]}
</Chip>
 

판단 근거

  • NumberInput의 props(value, min, onChange)로 "숫자 입력"임을 예측할 수 있다. CSS는 DatePicker, Select와 동일한 레벨로 내부에 숨겨졌다.
  • Chip의 props(selected, onClick, children)로 "선택 가능한 칩"임을 예측할 수 있다. selected/unselected 스타일 분기는 내부 관심사다.
  • 장비 목록의 map, toggle 로직은 페이지에 그대로 남아있어서 어떤 칩이 있고 클릭하면 어떻게 동작하는지 외부에서 읽힌다.
  • ChipGroup으로 묶지 않은 이유: 묶으면 어떤 칩이 렌더링되는지 외부에서 보이지 않는다. ChipGroup의 options props는 인터페이스가 아니라 데이터 통로일 뿐이다.

 

6. validateFilters, getAvailableRooms 순수 함수 추출 + RoomCard 분리

파일: src/pages/RoomBookingPage/index.tsx (순수 함수), src/pages/components/RoomCard.tsx

핵심 문제: 입력 검증 로직과 필터링/정렬 로직이 컴포넌트 본문에 인라인으로 존재하여, 페이지가 "what"이 아닌 "how"를 읽게 만들고 있었다. 회의실 카드도 selected/unselected CSS + ListRow 조합이 노출되어 있었다.

Before — 검증·필터링 로직이 인라인으로 노출

// 입력 검증: 조건 분기가 인라인
let validationError: string | null = null;
const hasTimeInputs = startTime !== '' && endTime !== '';
if (hasTimeInputs) {
  if (endTime <= startTime) {
    validationError = '종료 시간은 시작 시간보다 늦어야 합니다.';
  } else if (attendees < 1) {
    validationError = '참석 인원은 1명 이상이어야 합니다.';
  }
}

// 필터링: 5가지 조건 + 정렬이 18줄에 걸쳐 인라인
const availableRooms = isFilterComplete
  ? rooms
      .filter(room => {
        if (room.capacity < attendees) return false;
        if (!equipment.every(...)) return false;
        if (preferredFloor !== null && ...) return false;
        const hasConflict = reservations.some(...);
        ...
      })
      .sort(...)
  : [];

// 회의실 카드: CSS + ListRow 조합이 인라인
<div css={css`border: 2px solid ${isSelected ? colors.blue500 : ...}; ...`}>
  <ListRow contents={<ListRow.Text2Rows top={room.name} bottom={...} />} ... />
</div>
 

After — what만 보임

// 검증: "필터를 검증한다"
const validationError = hasTimeInputs ? validateFilters({ startTime, endTime, attendees }) : null;

// 필터링: "가능한 회의실을 가져온다"
const availableRooms = isFilterComplete
  ? getAvailableRooms(rooms, reservations, { date, startTime, endTime, attendees, equipment, preferredFloor })
  : [];

// 카드: props로 UI 예측 가능
<RoomCard
  name={room.name}
  floor={room.floor}
  capacity={room.capacity}
  equipment={room.equipment}
  selected={selectedRoomId === room.id}
  onClick={() => setSelectedRoomId(room.id)}
/>
 

판단 근거

  • validateFilters와 getAvailableRooms는 순수 함수로, 컴포넌트에 의존하지 않는다. 페이지에서는 함수 이름만으로 "무엇을 하는지" 읽힌다.
  • RoomCard의 props(name, floor, capacity, equipment, selected, onClick)로 "이름, 층, 인원, 장비를 보여주는 선택 가능한 카드"임을 예측할 수 있다. room 객체를 통째로 넘기지 않은 이유: 내부에서 어떤 필드를 쓰는지 궁금해지기 때문이다.
  • CSS(selected/unselected 스타일) + ListRow 조합은 RoomCard 내부에 숨겨졌다.

강의 내용

 

1부는 한재엽님, 2부는 문동욱님이 해설을 진행해주셨습니다.

예상과 달리 비즈니스 로직이나 구현 세부사항 이야기부터 시작하지 않았습니다. 재엽님은 화면을 보고 이상적인 인터페이스를 먼저 그려보는 데서 출발하셨습니다.

 

기억할 필요 없는 코드를 만들어야 한다

해설 초반에 나온 말인데, 이게 이후 내용을 전부 관통하는 전제였습니다.

인간의 뇌 RAM은 5~7청크밖에 안 됩니다. 그래서 코드를 "읽는다"기보다 이름과 구조를 보고 다음에 뭐가 나올지 예측하며 훑습니다. HTML 500줄은 따라갈 수 있는 이유가 길이가 짧아서가 아니라, 시각적으로 형상이 그려지기 때문입니다. JS 100줄이 어려운 건 머릿속에서 실행 흐름을 직접 시뮬레이션해야 하기 때문이고요.

이 말을 듣고 나서 내가 왜 어떤 코드를 읽기 어렵다고 느끼는지가 더 선명해졌습니다. 읽기 어려운 코드란 결국 예측이 계속 빗나가는 코드입니다. 이름이 역할을 드러내지 않거나, 상태의 출처가 여러 군데 흩어져 있거나, 인터페이스가 호출자의 맥락을 너무 많이 품고 있을 때 그 예측이 깨집니다.

 

인터페이스를 먼저 짠다 — 연민이 생기기 전에

해설에서 제안한 접근법이 흥미로웠습니다. 기존 코드를 먼저 읽으면 그 구조에 끌려갑니다. 그래서 요구사항과 UI만 보고 이상적인 인터페이스를 먼저 정의한 다음, 기존 코드와의 gap을 좁혀나가는 방식을 제안하셨습니다.

다만 솔직히 말하면, 인터페이스를 먼저 짜면 분리가 먼저 만들어지면서 쓸모없는 분리를 판단하기 어려울 것 같다는 두려움이 생긴다고 하셨고 써본면서 감을 잡아볼 예정이라고 하셨습니다.

 

Props 이름 하나가 말해주는 것들

// ❌
<DatePicker date={date} setDate={setDate} />

// ✅
<DatePicker value={date} onChange={handleDateChange} />

 

setDate를 직접 받으면 "부모에 date라는 state가 있고 그걸 set한다"는 맥락이 노출됩니다. 나중에 API를 호출하거나 콘솔 로그를 찍어야 하는 상황이 오면 이름이 이미 틀려버립니다.

 

해설에서 조언한 방향은 HTML 인터페이스를 따라가라는 것이었습니다. value, onChange만으로도 컴포넌트 이름이 이미 맥락을 설명하고 있으니 date를 props 이름에서 반복할 필요가 없습니다. 예측 가능성과 재사용성이 동시에 올라갑니다.

 

같은 맥락에서 함수 이름도 마찬가지입니다. navigateWithMessage는 navigate라는 이름이지만 history state에 메시지를 숨겨 넘기는 사이드 이펙트가 있습니다. handle~, on~ 접두어는 이벤트 연결 지점을 표현할 뿐, 함수가 실제로 하는 일을 숨깁니다.

 

코로케이션과 숨은 의존성

setMessage를 props로 계속 내려주는 패턴에 대한 이야기도 있었습니다. 메시지가 표시되는 곳은 바깥인데 트리거는 깊은 안쪽에 있으면, 거리가 너무 멀어서 흐름을 따라가기 어렵습니다. 사용처와 호출부는 가까울수록 좋습니다.

 

<MyReservation reservation={reservation} />처럼 도메인 언어가 반복되는 것도 냄새라고 하셨습니다. 데이터를 props로 넘긴다면 UI만 담당하는 순수 컴포넌트여야 하는데, 실제로는 내부에 API 호출이나 브라우저 API 의존성이 숨어있는 경우가 많습니다. 이런 숨은 의존성은 테스트할 때 터지는 주요 원인이 됩니다.

 

추상화도 마찬가지입니다. 너무 많이 숨기면 사이드 이펙트를 감지하기 어렵고, 기대가 깨졌을 때 더 혼란스럽습니다. 호출하는 쪽이 알아야 할 것은 인터페이스로 드러내야 합니다.

 


마무리

해설 강의를 바탕으로 기존에 모호하게 느껴졌던 부분들이 명확해졌습니다. 코드를 짜는 과정에서 어떤 부분들을 더 신경써야 하는지 그리고 어떤 부분들을 주의해야 하는지 알게되어 매우 의미가 있는 시간이었습니다. 제일 기억에 남는 부분은 예측성과 숨겨야 하는 부분과 드러내야 하는 부분의 기준입니다. 

 

예측성은 단순히 "이름을 잘 짓자"는 이야기가 아니었습니다. 코드를 읽는 사람이 파일을 열기 전에 이미 무엇이 나올지 그릴 수 있어야 한다는 것이었습니다. 이름, 구조, 인터페이스 모두가 그 예측을 뒷받침해야 하고, 그 예측이 한 번이라도 빗나가는 순간 읽는 사람의 뇌는 추가적인 비용을 치르게 됩니다. setDate를 props로 받는 순간 "이 컴포넌트는 부모의 state 구조를 알고 있어야 한다"는 기대가 생기고, 그 기대가 깨지면 혼란이 시작되는 것처럼요.

 

숨겨야 하는 것과 드러내야 하는 것의 기준도 마찬가지였습니다. how는 안으로, what은 밖으로라는 원칙이 막연하게 느껴졌는데, 해설을 듣고 나서 기준이 좀 더 선명해졌습니다. 호출하는 쪽이 알아야 동작을 예측할 수 있는 것은 드러내야 하고, 호출하는 쪽이 알지 않아도 되는 세부 구현은 숨겨야 한다는 것입니다. 너무 많이 숨기면 사이드 이펙트를 감지하기 어렵고, 너무 많이 드러내면 읽는 사람이 기억해야 할 것이 늘어납니다. 그 균형을 잡는 기준이 결국 "호출하는 쪽의 예측"이었습니다.

 

해당 생각을 기반으로 꾸준히 연습하여 좋은 코드에 대한 나만의 기준을 올바르게 설정하고, 좋은 코드를 짜는 개발자로 성장하고자 합니다! 긴 글 읽어주셔서 감사합니다 :)

+ Recent posts