Skip to main content
입문React 공통 모델 · 10

React 공용 컴포넌트 계약: 내부 구조 대신 사용 의도를 props로 표현하기

계정 삭제 행동을 범용 클릭 요소로 노출했을 때 생기는 실패를 관찰하고, 같은 의도를 Web과 React Native의 올바른 기본 요소로 번역한다.

마지막 검증 재검증 정책: 제품 버전 의존: 새 주요 버전마다 재검증
목차
표준·구현·측정·해석 표시는 무엇인가요?
  • 표준웹 표준이나 언어 명세가 정한 동작
  • 구현특정 기술이나 브라우저가 실제로 구현한 동작
  • 측정명시한 환경에서 직접 실행해 관찰한 결과
  • 해석앞선 근거에서 도출한 설계 판단
  • 미확인아직 공식 근거나 재현 결과를 확인하지 못한 내용

30초 요약

공용 컴포넌트의 props에는 호출자가 무엇을 하려는지와 어떤 상태인지 드러낸다. HTML 태그, React Native 요소, 키보드 처리나 플랫폼 스타일처럼 어떻게 구현하는지는 내부에 둔다. 그러면 같은 onConfirm 의도를 Web의 onClick과 Native의 onPress로 각각 올바르게 번역할 수 있다.

이 글을 관통하는 상황: 마우스로는 삭제되는데 키보드와 대기 상태는 깨진다

계정 삭제 행동을 여러 화면에서 재사용하려고 다음과 같은 범용 Clickable을 만들었다고 하자. 이 코드는 실패를 관찰하기 위한 Web 예제다. 코드를 읽기 전에 낯선 속성의 역할부터 정리한다.

  • <div>는 내용을 묶는 일반 HTML 요소라서 버튼 활성화 동작이 없다.
  • role="button"은 요소의 목적이 버튼이라고 화면 읽기 도구 같은 보조 기술에 전달한다.
  • tabIndex={0}은 일반 div를 Tab 키로 이동하는 키보드 초점 순서에 넣는다.
  • aria-disabled는 보조 기술에 비활성 상태를 알리지만 클릭 함수 실행을 직접 막지는 않는다.

즉 호출자는 일반 상자를 버튼처럼 만들기 위해 목적, 초점, 비활성 정보와 클릭 사건을 직접 조합한다.

import { useState } from 'react'
 
function Clickable({ role, tabIndex, ariaDisabled, onClick, color, children }) {
  return (
    <div
      role={role}
      tabIndex={tabIndex}
      aria-disabled={ariaDisabled}
      onClick={onClick}
      style={{ color, cursor: 'pointer' }}
    >
      {children}
    </div>
  )
}
 
export default function App() {
  const [pending, setPending] = useState(false)
  const [count, setCount] = useState(0)
 
  function handleDelete() {
    setCount(value => value + 1)
  }
 
  return (
    <main>
      <label>
        <input
          type="checkbox"
          checked={pending}
          onChange={event => setPending(event.target.checked)}
        />
        삭제 요청 중
      </label>
      <Clickable
        role="button"
        tabIndex={0}
        ariaDisabled={pending}
        onClick={handleDelete}
        color="crimson"
      >
        계정 삭제
      </Clickable>
      <output>삭제 호출: {count}회</output>
    </main>
  )
}

먼저 결과를 예측한다. 마우스로 누르면 횟수가 늘어난다. 하지만 삭제 요청 중을 선택해도 클릭하면 계속 늘어난다.

Tab으로 초점을 옮긴 뒤 Enter나 Space를 눌러도 기본 활성화 동작이 없어서 횟수가 늘지 않는다.

호출자는 role, tabIndex, 키보드 사건과 비활성 가드를 모두 알아야 한다. Clickable은 이 값을 전달할 자리만 제공하므로, 같은 버튼 동작을 호출자마다 다시 조립하게 만든 셈이다.

빨간 div는 button 계약을 만들지 않는다

버튼의 색은 동작을 보장하지 않는다. 빨간색은 위험한 행동을 암시할 수 있지만 키보드 활성화, 비활성 상태와 접근 가능한 이름은 별도 계약이다. Web에서 명령을 실행한다면 먼저 HTML의 <button>을 선택하고 외형을 스타일로 바꾸는 편이 이 경계를 지키기 쉽다.

폼 안의 <button>type을 생략하면 제출 버튼으로 동작할 수 있다. 이 사례의 삭제 행동은 폼 제출이 아니므로 내부 구현이 type="button"까지 책임져야 한다.

계약은 선택, 보장, 내부 구현을 나눈다

삭제 행동의 계약을 다음 세 칸으로 나눠 본다.

구분이 사례에서 보이는 것
호출자가 선택삭제 요청 중인지 나타내는 pending, 확인 뒤 실행할 onConfirm
컴포넌트가 보장의미 있는 이름, 활성화, 요청 중 중복 실행 차단, 상태에 맞는 문구
내부에 감춤<button> 또는 Pressable, onClick 또는 onPress, 역할과 플랫폼 스타일

여기서 pending은 단순한 회색 스타일 선택이 아니다. 같은 삭제를 다시 요청하면 안 되는 상태라는 의미를 담고, 컴포넌트는 이 값으로 실행 차단과 화면 문구를 함께 맞춘다.

같은 의도를 각 플랫폼의 기본 요소로 번역한다

Web 구현은 공개 onConfirm을 HTML 버튼의 onClick으로 연결한다.

import { useState } from 'react'
 
function DeleteAccountAction({ pending = false, onConfirm }) {
  return (
    <button
      type="button"
      disabled={pending}
      onClick={onConfirm}
      style={{ color: 'crimson' }}
    >
      {pending ? '삭제 중…' : '계정 삭제'}
    </button>
  )
}
 
export default function App() {
  const [pending, setPending] = useState(false)
  const [count, setCount] = useState(0)
 
  return (
    <main>
      <label>
        <input
          type="checkbox"
          checked={pending}
          onChange={event => setPending(event.target.checked)}
        />
        삭제 요청 중
      </label>
      <DeleteAccountAction
        pending={pending}
        onConfirm={() => setCount(value => value + 1)}
      />
      <output>삭제 호출: {count}회</output>
    </main>
  )
}

호출자는 button, type, disabled, onClick을 조합하지 않는다. pendingonConfirm만 전달한다. 키보드와 마우스로 같은 행동이 실행되고, pending일 때 두 입력 모두 실행되지 않는 것이 검증 기준이다.

React Native 구현도 같은 공개 계약을 받되 플랫폼 요소는 별도로 둔다.

import { Pressable, StyleSheet, Text } from 'react-native'
 
function DeleteAccountAction({ pending = false, onConfirm }) {
  return (
    <Pressable
      accessibilityRole="button"
      accessibilityState={{ disabled: pending }}
      disabled={pending}
      onPress={onConfirm}
      style={({ pressed }) => [
        styles.action,
        pressed && styles.pressed,
        pending && styles.disabled,
      ]}
    >
      <Text style={styles.label}>
        {pending ? '삭제 중…' : '계정 삭제'}
      </Text>
    </Pressable>
  )
}
 
const styles = StyleSheet.create({
  action: { padding: 12 },
  pressed: { opacity: 0.7 },
  disabled: { opacity: 0.4 },
  label: { color: 'crimson' },
})

같은 파일이나 같은 렌더링 코드를 공유해야 한다는 뜻은 아니다. Web과 Native 구현을 각 패키지에 두고, 소비자가 의존할 의미가 실제로 같을 때만 props의 이름과 보장사항을 맞춘다.

공용화는 코드가 아니라 보장사항이 같을 때 한다

공용 컴포넌트는 props가 적을수록 무조건 좋은 것도, 모든 내부 속성을 전달할수록 유연한 것도 아니다. 다음 순서로 경계를 정한다.

  1. 반복되는 호출부에서 사용자가 하려는 행동과 상태를 적는다.
  2. 컴포넌트가 모든 호출부에서 지켜야 할 보장사항을 적는다.
  3. 그 보장에 필요한 최소 선택만 props로 공개한다.
  4. 플랫폼 요소와 스타일은 각 구현 안에 둔다.
  5. 새 요구가 생기면 기존 의미의 확장인지 다른 컴포넌트인지 다시 판단한다.

예를 들어 길게 누르기와 짧게 누르기가 서로 다른 행동인 Native 전용 제스처라면 단일 onConfirm 계약으로 숨기면 정보가 사라진다. Web과 Native의 제품 행동과 검증 기준이 달라진 것이므로 플랫폼별 컴포넌트로 나누는 편이 정확하다.

반대로 단순한 배치용 상자처럼 목적이 내부 요소의 속성을 안전하게 전달하는 것이라면 일부 속성 전달 자체가 계약일 수 있다. 다만 모든 props를 무조건 펼쳐 전달하면 type, disabled, 역할처럼 컴포넌트가 지키려던 보장을 호출자가 우회할 수 있는지 검토한다.

같은 삭제 행동을 전후로 검증한다

Web 교정 전후에는 다음 순서로 같은 행동을 비교한다.

  1. 계정 삭제에 초점을 옮기고 Enter와 Space가 각각 한 번의 삭제를 실행하는지 확인한다.
  2. 마우스 클릭도 한 번의 삭제만 실행하는지 확인한다.
  3. 삭제 요청 중을 선택한 뒤 키보드와 마우스 모두 횟수를 늘리지 않는지 확인한다.
  4. 버튼 이름이 삭제 중…으로 바뀌어 현재 상태를 드러내는지 확인한다.

Native에서는 실제 시뮬레이터나 기기에서 누름과 비활성 동작을 확인하고, VoiceOver 또는 TalkBack으로 요소가 버튼이며 비활성 상태라고 전달되는지 확인한다. 같은 props 이름만 확인하는 테스트로는 플랫폼 상호작용까지 증명할 수 없다.

문제       마우스로는 삭제되지만 키보드와 요청 중 차단은 동작하지 않음
잘못된 판단 빨간 div에 role과 tabIndex를 주면 button과 같은 계약이 됨
관찰       pending 중 클릭 횟수가 늘고 Enter와 Space는 활성화하지 못함
원인       Clickable 계약이 플랫폼 속성과 동작 조립 책임을 호출자에게 넘김
행동       pending과 onConfirm을 공개하고 플랫폼 기본 요소로 번역
검증       Web 키보드·클릭과 Native 누름·보조 기술에서 같은 보장 확인

풀 리퀘스트에서는 다음을 확인한다.

  • prop 이름이 색이나 태그보다 사용자의 행동과 상태를 설명하는가?
  • 호출자가 알아야 할 선택과 컴포넌트가 반드시 지킬 보장을 구분했는가?
  • Web과 Native 구현이 각 플랫폼의 올바른 기본 요소를 사용하는가?
  • 비활성 상태가 외형뿐 아니라 실제 행동과 보조 기술 정보에도 반영되는가?
  • 모든 내부 props를 전달해 핵심 보장을 우회할 수 있게 만들지는 않았는가?
  • 플랫폼별 제품 행동이 달라졌는데 억지로 같은 계약에 넣지는 않았는가?

기억할 질문은 하나다.

호출자가 하려는 일을 묻고 있는가, 내부 요소를 조립하게 하고 있는가?

다음 글에서는 입력값의 기준을 React가 기억할지, 브라우저나 Native 입력 요소가 기억할지 판단하는 제어와 비제어 입력을 살펴본다.

Active recall

기억에서 꺼내 보기

답을 쓰지 않아도 됩니다. 먼저 머릿속으로 답한 뒤 펼쳐서 정답·이유·흔한 오해를 비교하세요.

  1. 01공용 컴포넌트 계약에서 호출자의 선택, 컴포넌트의 보장과 내부 구현은 어떻게 구분하는가?

    정답

    호출자는 pending과 onConfirm 같은 사용 의도를 전달하고, 컴포넌트는 활성화와 비활성 동작을 보장하며, button이나 Pressable과 플랫폼 스타일은 내부에 둔다.

    왜 그런가

    공개 계약을 내부 요소의 속성 목록과 분리하면 구현을 바꿔도 호출자가 의존하는 의미를 유지할 수 있다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  2. 02role과 tabIndex를 준 div가 계정 삭제 버튼의 안전한 기본 구현이 아닌 이유는 무엇인가?

    정답

    초점과 역할 일부만 더할 뿐 기본 button의 Enter와 Space 활성화, disabled 동작을 자동으로 얻지 못해 호출자나 래퍼가 다시 구현해야 하기 때문이다.

    왜 그런가

    시각적 모양이나 role 하나는 플랫폼 기본 요소가 제공하는 전체 상호작용 계약과 같지 않다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  3. 03같은 확인 행동을 Web과 React Native에서 공개 onConfirm prop으로 받을 때 내부에서는 어디에 연결하는가?

    정답

    Web 구현은 HTML button의 onClick에, React Native 구현은 Pressable의 onPress에 연결한다.

    왜 그런가

    사용자 정의 컴포넌트의 이벤트 이름은 제품 행동을 표현할 수 있고 각 렌더러 구현이 플랫폼 사건으로 번역한다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

  4. 04Web과 Native 구현의 요구가 달라질 때 언제 같은 계약을 유지하고 언제 컴포넌트를 나누는가?

    정답

    사용자에게 약속할 행동과 상태가 같고 각 구현이 이를 지킬 수 있으면 계약을 유지한다. 제스처나 접근성 의미처럼 제품 행동 자체가 달라지면 플랫폼별 계약으로 나눈다.

    왜 그런가

    코드 모양의 유사성이 아니라 소비자가 기대하는 의미와 검증 항목이 같은지가 공용 경계의 기준이다.

    관련 설명 다시 읽기
    지금 어느 정도 기억났나요?

    선택한 상태와 다음 복습일은 이 브라우저에만 저장됩니다.

출처와 검증 범위

아래 날짜는 링크를 마지막으로 열어 본 날입니다. 문서 상단의 검증일은 글의 설명과 적용 범위를 다시 확인한 날입니다.

  1. Passing Props to a ComponentReact · 공식 문서 · 확인 2026-08-09
  2. Responding to EventsReact · 공식 문서 · 확인 2026-08-09
  3. button - The Button elementMDN Web Docs · 공식 문서 · 확인 2026-08-09
  4. Button PatternW3C Web Accessibility Initiative · 표준 · 확인 2026-08-09
  5. aria-disabledMDN Web Docs · 공식 문서 · 확인 2026-08-09
  6. PressableReact Native · 공식 문서 · 확인 2026-08-09
  7. AccessibilityReact Native · 공식 문서 · 확인 2026-08-09
이 문서의 마지막까지 읽었습니다.