TIL 06-02 [Vue] Component State Flow - Props & Emit

김덕협·2026년 6월 2일

TIL

목록 보기
18/41

컴포넌트 간 데이터가 어떻게 흐르는지(상태 흐름)에 대한 정리.
한 줄 요약: 부모는 자식에게 props로 데이터를 내려주고, 자식은 emit으로 부모에게 이벤트를 올려보낸다.


1. Passing Props - 왜 필요한가?

동일한 데이터, 하지만 다른 컴포넌트

  • 한 화면에서 동일한 데이터(예: 프로필 사진)가 여러 위치에서 여러 번 출력되는 경우가 많다.
  • 페이지를 구성하는 컴포넌트가 여러 개일 때, 각 컴포넌트가 개별적으로 동일한 데이터를 관리해야 할까?
  • 만약 그렇게 하면, 사진 하나를 바꿀 때 모든 컴포넌트에 변경 요청을 해야 하는 문제가 생긴다.

💡 결론: "공통된 부모 컴포넌트에서 관리하자"

부모-자식의 상호작용 방식

  • 부모 → 자식: 데이터를 전달 (Pass Props)
  • 자식 → 부모: 자신에게 일어난 일을 알림 (Emit event)
        부모(Parent)
       ↑          ↓
   $emit        props
   (이벤트)     (데이터)
       ↑          ↓
        자식(Child)

2. Props 개념

Props: 부모 컴포넌트로부터 자식 컴포넌트로 데이터를 전달하는 데 사용되는 사용자 지정 특성(attribute)

  • Props는 부모 컴포넌트가 자식 컴포넌트에게 데이터를 전달할 때 사용하는 특별한 속성이다.
  • 데이터는 부모 → 자식 한 방향으로만 흐르며, 자식 컴포넌트는 전달받은 props를 직접 수정해서는 안 된다 (읽기 전용).
  • 이 방식을 통해 재사용 가능한 컴포넌트를 만들고, 부모가 어떤 데이터를 전달하느냐에 따라 자식의 내용과 모습을 다르게 설정할 수 있다.

Props 특징

  • 부모의 데이터가 업데이트되면 자식에게 전달되지만, 그 반대는 불가능하다.
  • 부모 컴포넌트가 업데이트될 때마다 이를 사용하는 자식 컴포넌트의 모든 props가 최신 값으로 업데이트된다.
  • 즉, 부모에서만 변경하고 이를 내려받는 자식은 자연스럽게 갱신된다.

⚠️ 자식 컴포넌트 내부에서 props를 변경하려고 시도해서는 안 되며 불가능하다.

📌 TIP

  • 자식이 부모 속성을 바꾸려면, props를 수정하지 말고 이후에 배울 emit을 이용해서 부모에게 알려야 한다.
  • 객체/배열 props는 자식에서 내부 값을 바꾸면 부모의 원본도 바뀌니 매우 주의해야 한다.

One-Way Data Flow (단방향 데이터 흐름)

  • 모든 props는 자식 속성과 부모 속성 사이에 하향식 단방향 바인딩을 형성한다 (one-way-down binding).
  • 단방향인 이유
    • 하위 컴포넌트가 실수로 상위 컴포넌트의 상태를 변경하여 앱의 데이터 흐름을 이해하기 어렵게 만드는 것을 방지하기 위함 (무한 루프, 디버깅 난이도 상승 등)
    • 데이터 흐름의 "일관성""예측 가능성" 을 높이는 것이 목표

✅ 우리는 일관성 있고 예측 가능한 코드를 짜야 한다.


3. Props 선언

사전 준비

기본 컴포넌트 관계 구조: App > Parent > ParentChild

사전 작업 요약
1. vue 프로젝트 생성
2. 초기 생성된 컴포넌트 모두 삭제 (App.vue 제외)
3. src/assets 내부 파일 모두 삭제
4. main.js에서 import './assets/main.css' 코드 삭제
5. App > Parent > ParentChild 컴포넌트 관계 작성

<!-- App.vue -->
<template>
  <div>
    <Parent />
  </div>
</template>

<script setup>
  import Parent from '@/components/Parent.vue'
</script>
<!-- Parent.vue -->
<template>
  <div>
    <h1>Parent</h1>
    <ParentChild />
  </div>
</template>

<script setup>
  import ParentChild from '@/components/ParentChild.vue'
</script>
<!-- ParentChild.vue -->
<template>
  <div>
    <h2>ParentChild</h2>
  </div>
</template>

<script setup>
</script>

Props 작성 (부모 쪽)

부모 컴포넌트(Parent)에서 자식 컴포넌트(ParentChild)에 보낼 props를 작성한다.

<!-- Parent.vue -->
<template>
  <div>
    <h1>Parent</h1>
    <ParentChild my-msg="message" />
  </div>
</template>
my-msg = "message"
  ↑          ↑
props 이름   props 값

key="value"면 정적
바인딩(v-bind)을해서
:key = "value"면 동적으로 할당 -> 값이 ref로 되어있는 경우

- HTML 속성으로 전달하므로 **kebab-case**(`my-msg`)로 작성한다.

우리는 흐름을 읽을 줄 알아야 한다.

Props 선언 (자식 쪽) - defineProps()

  • 부모가 내려 보낸 props를 사용하려면 자식 컴포넌트에서 명시적인 props 선언이 필요하다.
  • defineProps()를 사용하여 props를 선언한다.
<script setup>
  defineProps()
</script>

💡 defineProps()컴파일러 매크로(내장) 라서 따로 import 할 필요가 없다.

defineProps()에 전달하는 인자의 형태에 따라 선언 방식이 나뉜다.

① "문자열 배열"을 사용한 선언

  • 배열의 문자열 요소로 props를 선언한다.
  • 문자열 요소의 이름이 곧 전달된 props의 이름이 된다.
<!-- ParentChild.vue -->
<script setup>
  defineProps(['myMsg'])
</script>

💡 JavaScript는 kebab-case를 지원하지 않으므로 props 이름을 camelCase(myMsg)로 쓴다.
Vue가 알아서 my-msg(템플릿) ↔ myMsg(스크립트)를 매칭해준다.

② "객체"를 사용한 선언

  • 각 객체 속성의 가 전달받은 props 이름이 된다.
  • 객체 속성의 은 해당 데이터의 타입에 맞는 생성자 함수(String, Number 등)여야 한다. (유효성 검사까지 됨)
<!-- ParentChild.vue -->
<script setup>
  defineProps({
    myMsg: String
  })
</script>

📌 TIP: 가급적 "객체를 사용한 선언" 을 권장한다.

  • 각 prop에 대해 상세한 규칙(유효성 검사)을 설정하여 컴포넌트의 안정성을 높여준다.
  • 객체 구문은 그 자체로 컴포넌트의 설명서 역할을 하므로, 코드를 통해 명확하게 의사소통이 가능하다.

props 데이터 사용

props 선언 후 템플릿에서 반응형 변수와 같은 방식으로 사용하거나, JS에서 props를 객체로 접근할 수 있다.

<!-- ParentChild.vue -->
<template>
  <div>
    <h2>ParentChild</h2>
    <!-- 템플릿에서 활용해야하는 경우 -->
    <p>{{ myMsg }}</p>
  </div>
</template>

<script setup>
  // js에서 props 데이터를 활용해야하는 경우
  const props = defineProps({ myMsg: String })

  console.log(props)
  console.log(props.myMsg)
</script>

4. 한 단계 더 props 내려 보내기 (ParentGrandChild)

ParentChild를 부모로 갖는 ParentGrandChild 컴포넌트를 만들어 props를 한 단계 더 내려보낸다.

1) ParentGrandChild 컴포넌트 생성

<!-- ParentGrandChild.vue -->
<template>
  <div></div>
</template>

<script setup>
</script>

2) ParentChild에 ParentGrandChild 등록

<!-- ParentChild.vue -->
<template>
  <div>
    <h2>ParentChild</h2>
    <p>{{ myMsg }}</p>
    <ParentGrandChild />
  </div>
</template>

<script setup>
  import ParentGrandChild from '@/components/ParentGrandChild.vue'
  defineProps({
    myMsg: String,
  })
</script>

3) Parent로부터 받은 props(myMsg)를 ParentGrandChild에게 전달

<!-- ParentChild.vue -->
<template>
  <div>
    <h2>ParentChild</h2>
    <p>{{ myMsg }}</p>
    <ParentGrandChild :my-msg="myMsg" />
  </div>
</template>

<script setup>
  import ParentGrandChild from '@/components/ParentGrandChild.vue'
  defineProps({
    myMsg: String
  })
</script>

v-bind를 사용한 동적 props (아래 Static/Dynamic props에서 자세히)

4) ParentGrandChild에서 props 선언 후 접근

<!-- ParentGrandChild.vue -->
<template>
  <div>
    <p>{{ myMsg }}</p>
  </div>
</template>

<script setup>
  defineProps({
    myMsg: String,
  })
</script>

5) 출력 결과 확인

  • ParentGrandChild가 받아서 출력하는 props는 Parent에 정의되어 있는 props이며,
  • Parent가 props를 변경할 경우 이를 전달받고 있는 ParentChild, ParentGrandChild에서도 모두 업데이트된다. (단방향 흐름의 자동 갱신)

5. Props 세부사항

5-1. Props Name Casing (이름 컨벤션)

  • 부모 템플릿에서 전달 시 (HTML 속성) → kebab-case (my-msg)
<ParentChild my-msg="message" />
  • 자식 스크립트에서 선언 시 (JavaScript) → camelCase (myMsg)
<template>
  <div>
    <p>{{ myMsg }}</p>
  </div>
</template>

<script setup>
  defineProps({
    myMsg: String,
  })
</script>

5-2. Static Props & Dynamic Props

  • 지금까지 작성한 것은 Static(정적) props.
  • v-bind 를 사용하여 동적으로 할당된 props를 사용할 수 있다.

v-bind: 하나 이상의 속성 또는 컴포넌트 데이터를 표현식에 동적 바인딩

📌 TIP - 동적 할당이 뭐예요?

  • 고정된 값을 전달하는 것이 아닌, 바뀌는 데이터를 연결하는 것.
  • 즉 부모의 데이터와 자식의 속성을 실시간으로 연결하는 것을 의미한다.

예시) Parent에 Dynamic props 정의하고, ParentChild에서 선언 및 출력

<!-- Parent.vue -->
<template>
  <div>
    <ParentChild
      my-msg="message"
      :dynamic-props="name"
    />
  </div>
</template>

<script setup>
  import { ref } from 'vue'
  const name = ref('Alice')
</script>
<!-- ParentChild.vue -->
<template>
  <div>
    <p>{{ myMsg }}</p>
    <p>{{ dynamicProps }}</p>
  </div>
</template>

<script setup>
  defineProps({
    myMsg: String,
    dynamicProps: String,
  })
</script>
  • my-msg는 정적 문자열 "message"가 그대로 출력.
  • :dynamic-propsname ref 값인 "Alice"가 동적으로 출력.

5-3. v-for와 함께 사용하여 반복 요소를 props로 전달하기

1) ParentItem 컴포넌트 생성 및 Parent의 하위 컴포넌트로 등록

<!-- ParentItem.vue -->
<template>
  <div>
  </div>
</template>

<script setup>
</script>
<!-- Parent.vue -->
<template>
  <div>
    <ParentItem />
  </div>
</template>

<script setup>
  import ParentItem from '@/components/ParentItem.vue'
</script>

2) 데이터 정의 및 v-for 디렉티브의 반복 요소로 활용 → 각 반복 요소를 props로 내려 보내기

<!-- Parent.vue -->
<script setup>
  const items = ref([
    { id: 1, name: '사과' },
    { id: 2, name: '바나나' },
    { id: 3, name: '딸기' }
  ])
</script>
<!-- Parent.vue -->
<template>
  <div>
    <ParentItem
      v-for="item in items"
      :key="item.id"
      :my-prop="item"
    />
  </div>
</template>
<!-- ParentItem.vue -->
<template>
  <div>
    <p>{{ myProp.id }}</p>
    <p>{{ myProp.name }}</p>
  </div>
</template>

<script setup>
  defineProps({
    myProp: Object
  })
</script>

3) props 선언 및 출력 결과 확인

  • items 배열의 각 객체가 myProp으로 전달되어 id, name이 반복 출력된다.
    (1 / 사과, 2 / 바나나, 3 / 딸기)

6. Component events - Emit

다시 보는 데이터 흐름

  • 부모는 자식에게 데이터를 전달 (Pass Props)
  • 자식은 자신에게 일어난 일을 부모에게 알림 (Emit event)

$emit(): 자식 컴포넌트가 이벤트를 발생시켜 부모 컴포넌트로 데이터를 전달하는 메서드

  • emit은 자식 컴포넌트가 부모 컴포넌트에게 특정 이벤트가 발생했음을 알리고 데이터를 전달하는 기능이다.
  • '내려가는' 데이터 흐름인 Props와 반대로 '올라가는' 이벤트를 만들어, 컴포넌트 간의 완전한 상호작용을 가능하게 한다.

emit 메서드 문법

$emit(event, ...args)
  • 자식 컴포넌트가 이벤트를 발생시켜 부모 컴포넌트에게 신호를 보내고 데이터를 전달하는 기능.
  • event: 커스텀 이벤트 이름
  • args: 추가 인자

📌 TIP - $ 표기

  • Vue 인스턴스의 내부 변수들을 가리킨다.
  • Life cycle hooks, 인스턴스 메서드 등 내부 특정 속성에 접근할 때 사용한다.

이벤트 발신 및 수신 (Emitting and Listening to Events)

  1. $emit을 사용하여 템플릿 표현식에서 직접 사용자 정의 이벤트를 발신
<!-- 자식 컴포넌트 -->
<button @click="$emit('someEvent')">클릭</button>
  1. 부모 컴포넌트에서는 v-on(또는 @)을 사용하여 이벤트를 수신
<!-- 부모 컴포넌트 -->
<ParentComp @some-event="someCallback" />

실제 적용 예시

(1/2) ParentChild에서 someEvent 이벤트 발신

<!-- ParentChild.vue -->
<template>
  <div>
    <button @click="$emit('someEvent')">클릭</button>
  </div>
</template>

(2/2) 부모 Parent는 v-on으로 이벤트를 수신 → 수신 후 처리할 콜백함수 호출

<!-- Parent.vue -->
<template>
  <div>
    <ParentChild
      @some-event="someCallback"
      my-msg="message"
      :dynamic-props="name" />
  </div>
</template>

<script setup>
  const someCallback = function () {
    console.log('ParentChild가 발신한 이벤트를 수신했어요.')
  }
</script>

7. emit 이벤트 선언 - defineEmits()

  • defineEmits()를 사용하여 발신할 이벤트를 선언한다.
  • props와 마찬가지로 defineEmits()에 작성하는 인자의 데이터 타입에 따라 선언 방식이 나뉜다.
    • 배열
    • 객체 (가급적 객체를 활용한 선언을 추천)
  • defineEmits()<script setup> 내에서 이벤트를 발신하기 위한 emit 함수를 반환한다.
    (템플릿의 $emit과 달리 <script setup>에서는 직접 접근할 수 없기 때문)

emit 이벤트 선언 활용 (추가 버튼 작성)

<!-- ParentChild.vue -->
<template>
  <div>
    <button @click="buttonClick">클릭</button>
  </div>
</template>

<script setup>
  // emit 이벤트 선언 (배열방식)
  const emit = defineEmits(['someEvent'])

  const buttonClick = function () {
    emit('someEvent')
  }
</script>
<!-- Parent.vue -->
<template>
  <div>
    <ParentChild
      @some-event="someCallback"
      my-msg="message"
      :dynamic-props="name" />
  </div>
</template>

<script setup>
  const someCallback = function () {
    console.log('ParentChild가 발신한 이벤트를 수신했어요.')
  }
</script>

8. 이벤트 인자 (Event Arguments)

1) ParentChild에서 이벤트를 발신하여 Parent로 추가 인자 전달하기

<!-- 자식 컴포넌트 -->
const emit = defineEmits(['emitArgs'])

const emitArgs = function () {
  emit('emitArgs', 1, 2, 3)
}

2) ParentChild에서 발신한 이벤트를 Parent에서 수신

// 부모 컴포넌트
const getNumbers = function (...args) {
  console.log(args)
  console.log(`ParentChild가 전달한 추가인자 ${args}를 수신했어요.`)
}

활용 전체 코드

<!-- ParentChild.vue -->
<template>
  <div>
    <button @click="emitArgs">추가 인자 전달</button>
  </div>
</template>

<script setup>
  const emit = defineEmits(['emitArgs'])

  const emitArgs = function () {
    emit('emitArgs', 1, 2, 3)
  }
</script>
<!-- Parent.vue -->
<template>
  <div>
    <ParentChild
      @emit-args="getNumbers"
    />
  </div>
</template>

<script setup>
  import { ref } from 'vue'
  import ParentChild from '@/components/ParentChild.vue'

  const getNumbers = function (...args) {
    console.log(args)
    console.log(`ParentChild가 전달한 추가인자 ${args}를 수신했어요.`)
  }
</script>
  • 결과: 콘솔에 (3) [1, 2, 3]ParentChild가 전달한 추가인자 1,2,3를 수신했어요.가 출력됨.

9. 이벤트 세부사항 - Event Name Casing

  • 선언 및 발신 시 → camelCase (someEvent)
  • 부모 컴포넌트에서 수신 시 → kebab-case (some-event)
<button @click="$emit('someEvent')">클릭</button>

<script setup>
  const emit = defineEmits(['someEvent'])
  emit('someEvent')
</script>
<ParentChild @some-event="..." />

(props의 casing 규칙과 동일한 흐름: JS는 camelCase, HTML 템플릿은 kebab-case)


10. emit 이벤트 실습 - 자식이 부모의 데이터 변경 요청하기

💡 핵심: 이벤트는 한 번에 최상단으로 올라가는 게 아니라 step-by-step으로 단계마다 발신/수신을 거쳐 올라간다.

목표: 최하단 컴포넌트 ParentGrandChild에서 Parent 컴포넌트의 name 변수 변경 요청하기

전달 경로: ParentGrandChild → ParentChild → Parent (emit이 한 단계씩 올라감)

1) ParentGrandChild에서 이름 변경을 요청하는 이벤트 발신

<!-- ParentGrandChild.vue -->
<template>
  <div>
    <button @click="updateName">이름 변경</button>
  </div>
</template>

<script setup>
  const emit = defineEmits(['updateName'])
  const updateName = function () {
    emit('updateName')
  }
</script>

2) ParentChild가 이벤트 수신 후, 다시 이름 변경을 요청하는 이벤트 발신 (중계)

<!-- ParentChild.vue -->
<template>
  <div>
    <ParentGrandChild :my-msg="myMsg" @update-name="updateName" />
  </div>
</template>

<script setup>
  import ParentGrandChild from '@/components/ParentGrandChild.vue'

  const emit = defineEmits(['someEvent', 'emitArgs', 'updateName'])

  const updateName = function () {
    emit('updateName')
  }
</script>

3) Parent가 이벤트 수신 후 이름 변수 변경 메서드 호출

(해당 변수를 props로 받는 모든 곳에서 자동 업데이트)

<!-- Parent.vue -->
<template>
  <div>
    <ParentChild @update-name="updateName" />
  </div>
</template>

<script setup>
  import { ref } from 'vue'
  import ParentChild from '@/components/ParentChild.vue'

  const name = ref('Alice')
  const updateName = function () {
    name.value = 'Bella'
  }
</script>

4) 버튼 클릭 후 결과 확인

  • "이름 변경" 버튼 클릭 시 → nameAliceBella로 변경되고,
  • name을 props로 받는 모든 컴포넌트의 출력이 자동으로 갱신된다.

11. 참고

11-1. 정적 & 동적 props 주의사항

  • 첫 번째는 정적 props로 문자열 "1"을 전달.
  • 두 번째는 동적 props로 숫자 1을 전달.
<!-- 1. 정적: 문자열 "1"이 전달됨 -->
<SomeComponent num-props="1" />

<!-- 2. 동적(v-bind): 숫자 1(Number)이 전달됨 -->
<SomeComponent :num-props="1" />

⚠️ 같은 1처럼 보여도, v-bind(:)가 붙으면 JS 표현식으로 평가되어 숫자 타입으로 전달된다. 정적은 그냥 문자열.

11-2. Props & Emit 객체 선언 문법

Props 선언 시 "객체 선언 문법"을 권장하는 이유

  • 컴포넌트의 의도를 명확히 하여 가독성을 높이고, 다른 개발자가 잘못된 타입의 데이터를 전달했을 때 콘솔에 경고를 출력하여 실수를 방지.
  • 추가로 props에 대한 유효성 검사로써 활용 가능.
defineProps({
  // 여러 타입 허용
  propB: [String, Number],
  // 문자열 필수
  propC: {
    type: String,
    required: true
  },
  // 기본 값을 가지는 숫자형
  propD: {
    type: Number,
    default: 10
  },
  // ...
})

emit 이벤트도 "객체 선언 문법"으로 작성 가능

  • emit 이벤트 또한 객체 구문으로 선언된 경우 유효성을 검사할 수 있다.
const emit = defineEmits({
  // 유효성 검사 없음
  click: null,
  // submit 이벤트 유효성 검사
  submit: ({ email, password }) => {
    if (email && password) {
      return true
    } else {
      console.warn('submit 이벤트가 옳지 않음')
      return false   // 이벤트는 정상 발신되지만 Warning이 출력됨
    }
  }
})

const submitForm = function (email, password) {
  emit('submit', { email, password })
}

⚠️ 유효성 검사에서 false를 반환해도 이벤트 자체는 정상 발신되며, 콘솔에 Warning만 출력된다.


📌 핵심 요약

구분방향키워드Casing (템플릿/HTML)Casing (스크립트/JS)
Props부모 → 자식 (하향)defineProps(), v-bindkebab-case (my-msg)camelCase (myMsg)
Emit자식 → 부모 (상향)$emit, defineEmits(), v-on/@kebab-case (@some-event)camelCase (someEvent)
  • Props는 읽기 전용(단방향). 자식에서 직접 수정 ❌ → 바꾸려면 emit으로 부모에게 요청.
  • 데이터 흐름을 단방향으로 강제하여 일관성예측 가능성을 확보한다.
  • props/emit 모두 객체 선언 문법을 사용하면 유효성 검사가 가능해 안정성이 올라간다.
  • emit은 step-by-step으로 한 단계씩 위로 전파된다.
profile
뭘봐

0개의 댓글