본문으로 건너뛰기
yceffort
PostsSeriesTagsAbout🧪 Research
EN

Tweaks

theme
accent palette
film grain
minimal mode
BACK TO INDEX
◆ ESSAY
--min
--year
KOoriginal

mailMail icongithubtwitter
yceffort
•
© 2026
•
https://yceffort.kr
BACK TO INDEX
◆ ESSAY

타입스크립트의 구조 타이핑

avatar
yceffort
2021-06-10 · 6분
6min
2021year
KOoriginal
typescript

타입스크립트의 타입 체크는 가끔 내가 생각하는 것보다 광범위 해서 생각치도 못한 결과를 만들어 낼 때가 있다. 아래와 같은 코드가 있다고 가정해보자.

interface Vector2D {
  x: number
  y: number
}

function calcLength(v: Vector2D) {
  return Math.sqrt(v.x * v.x + v.y * v.y)
}

그리고 새로운 interface를 만들고, 이를 함수의 값으로 넣었다.

interface Vector2DWithName extends Vector2D {
  name: string
}

const a: Vector2DWithName = {name: 'hi', x: 5, y: 10}
calcLength(a) // works fine

interface Vector2DName {
  name: string
  x: number
  y: number
}

const b: Vector2DName = {name: 'hello', x: 10, y: 10}
calcLength(b) // works fine, too.

Vector2DWithName야 뭐, extends로 그 둘 사이에 관계가 어떻게 어떻게 보였다고 치더라도, Vector2DName과 Vector2D 둘 사이에는 별다른 관계가 선언되거나 한적이 없음에도 정상적으로 작동하는 것을 볼 수 있다.

타입스크립트의 타입시스템은 '구조적으로' 타입이 맞기만 한다면 이를 허용해준다. 여기서 등장한 용어가 바로 structural typing 이다. (뭐 제대로된 번역을 본적이 없어서 그냥 구조적 타이핑이라고 하겠다.)

일반적으로 다른 언어, C#, Java 등에서는 허용하지 않는 방법이다. 그러나 이 때문에 예기치 못한 문제를 만들어 낼 수 있다.

interface Vector3D {
    x: number
    y: number
    z: number
}

function normalize(v: Vector3D) {
    const length = calcLength(v) // z가 고려되지 않음
    return {
        x: v.x / length,
        y: v.y / length,
        z: v.z / length // z의 값이 이상하게 나옴
    }
}

normalize({x:3, y:4, z:5}) // 그러나 에러는 안남

일반적으로 함수를 작성할 때, 함수에서 들어오는 인수가 애초에 원하는 대로 의도한 인수만 가지고 있고, 그 외의 값은 안올 것이라고 기대하고, 그게 일반적이다. 그러나 타입스크립트의 타입 시스템에서는 그렇지 않다. 타입스크립트의 타입은 열려있기 때문이다. 이러한 함정(?) 때문에, 타입스크립트를 처음 접하게 되면 아래와 같은 실수를 많이 범하게 된다.

function calcLengthV1(v: Vector3D) {
  let length = 0
  for (const axis of Object.keys(v)) {
    const coord = v[axis] // Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Vector3D'.
    // No index signature with a parameter of type 'string' was found on type 'Vector3D'.(7053)
    length += Math.abs(coord)
  }
  return length
}

내가 선언한 v의 키 값들은 x,y,z로 잘되어 있고, 이는 모두 string이라 잘 들어가야 한다. 그리고 값들도 number라서 length에 잘 더할 수 있어야 한다. 그런데 왜 에러가 나지?

https://yceffort.kr/2021/05/do-not-use-suppressImplicitAnyIndexErrors 에서 한번 다룬적이 있다.

그러나, 위에서 이야기 한 것처럼 타입스크립트의 타입이 열려 있다는 것을 생각해본다면 타당한 에러다.

const v = {x: 1, y: 2, z: 3, name: 'hi, h i~'}
calcLengthV1(v) // name의 값이 NaN이라서 결과가 NaN으로 뜰 수 있다.

따라서, 위의 코드는 타입스크립트 상에서 이렇게 바뀌어야 정확하게 값을 낼 수 있다.

function calcLengthV2(v: Vector3D) {
  return Math.abs(v.x) + Math.abs(v.y) + Math.abs(v.z)
}

이런 구조적 타이핑은 클래스에서도 재밌는 현상(?) 을 만들어 낸다.

class MadMonster {
  tan: string
  constructor(tan: string) {
    this.tan = tan
  }
}

const hi1 = new MadMonster('hello') // 원래 내가 의도한 코드
const hi2: MadMonster = {tan: 'hello'} // ?!?!

왜 hi2가 MadMonster로 할당이 가능한걸까? MadMonster에는 tan이라는 string 속성이 있다. 추가로, constructor (Object.prototype에서 온) 를 가지고 있는데, 이는 tan이라는 인수를 받기 때문에 구조적으로 일치한다. 그렇다. 구조적 타이핑의 결과 인 것이다.

구조적 타이핑이 꼭 이렇게 거지같기만(?) 한 것은 아니다. 테스트 할 때는 유용하게 사용할 수 있다.

interface Employee {
  name: string
  id: number
}

function getEmployee(db: DB): Employee[] {
  const rows = db.runQuery('SELECT name, id from EMPLOYEES')
  return rows.map((row) => ({name: row[0], id: row[1]}))
}

DB를 테스트하는 코드를 만든다고 가정했을 때, 구조적 타이핑을 활용하면 아래와 같은 방법으로 테스트 할 수 있다.

interface Employee {
  name: string
  id: number
}

interface DB {
  runQuery: (sql: string) => any[]
}

function getEmployee(db: DB): Employee[] {
  const rows = db.runQuery('SELECT name, id from EMPLOYEES')
  return rows.map((row) => ({name: row[0], id: row[1]}))
}

getEmployee에는 runQuery가 존재하는 DB 어댑터를 넣어주면 된다. 위 코드는 프로덕션/테스트를 가지리 않고 잘 동작할 것이다. 구조적 타이핑을 활용하여, 굳이 실제 DB를 구현하지 않아도 테스트 코드를 짤 수 있게 되었다. 굳이 DB를 mocking할 필요가 없다. 추상화한 DB 덕분에, 우리의 로직을 테스트와 프로덕션 상에서 모두 안전하게 작동시킬 수 있다.

결론

  • 자바스크립트는 덕타이핑 특성을 가지고 있고, 타입스크립트는 구조적 타이핑을 활용하여 이 모델을 구현해 냈다고 볼 수 있다.
  • 인터페이스에 할당 되는 값은, 형식적으로 선언되어 있는 속성 이상의 속성을 추가로 가질 수 있다. 즉 열려 있다.
  • 클래스 또한 구조적 타이핑을 따르고 있으므로 조심해야 한다.
  • 구조적 타이핑은 유닛 테스트 시에 유용하다.

관련 글

  • #typescript#oxc#eslint

    typescript@7을 설치하면 벌어지는 일들: 블로그 모노레포 마이그레이션 기록

    pnpm lint가 12분 32초 걸리던 모노레포에 typescript 7.0.2를 넣어봤다. 타입체크는 조용히 지나갔는데 next build가 깨졌고, lint는 크래시했다. eslint와 prettier를 oxlint와 oxfmt로 갈아탄 하루의 연쇄 반응과 전후 실측 기록. 미리 말해두면, 빌드는 빨라지지 않았다.

    2026-08-10·17분
  • #typescript#backend

    Effect 시스템 심층 분석: 모나드에서 Algebraic Effects까지, 그리고 Effect-TS의 선택

    Effect-TS가 대체 뭔데 다들 난리인지 직접 파헤쳐봤다.

    2026-02-20·41분
  • #typescript

    TypeScript에서 switch문의 모든 케이스를 빠짐없이 처리했는지 검사하는 방법

    never 타입을 활용한 exhaustive check 패턴

    2026-01-17·13분
  • #react#typescript

    useEvent에서 useEffectEvent까지: React의 이벤트 핸들러 안정화 여정

    3년 전 RFC가 드디어 빛을 보다

    2025-12-15·24분

새 글을 놓치고 싶지 않으시다면 RSS로 구독해 주세요.

RSS 구독 →

yceffort — 프론트엔드 엔지니어입니다. 발표·기술 자문·기고 문의는 이곳에서 받고 있습니다.

← Back to the blogIssue on GitHub →