Trait Views: 상속 없이 JavaScript에서 동작 노출하기
Source: Dev.to
위에 제공된 소스 링크 외에 번역할 텍스트를 알려주시면, 해당 내용을 한국어로 번역해 드리겠습니다.
Introduction
JavaScript는 행동을 공유하는 다양한 방법을 제공합니다: 상속, 믹스인, 컴포지션, 인터페이스(TypeScript를 통한).
하지만 규모가 큰 시스템에서는 이들 모두가 어느 정도 한계에 부딪히게 됩니다.
- Inheritance는 경직됩니다.
- Mixins는 상태가 새어나가는 경향이 있습니다.
- 원시 객체를 전달하면 노출되는 표면 영역이 너무 넓어집니다.
JavaScript에 부족한 것은 다음과 같은 방식을 말할 수 있는 방법입니다:
“이 객체에 대한 뷰를 제공하고, 특정 기능을 노출하며, 기본 동작을 포함하되—객체 자체는 변경하지 않음.”
이 글에서는 Trait Views를 소개합니다: Rust 트레이트에서 영감을 받아 JavaScript 객체 모델에 맞게 조정한 런타임 패턴.
이는 언어 제안이 아니며 기존 패턴을 대체하려는 것도 아닙니다. 누락된 추상화를 탐구하는 시도입니다.
문제
간단한 상황을 생각해 보세요: 객체가 하나 있는데, 이를 observable(관찰 가능)하게 취급하고 싶습니다.
Observable기본 클래스를 상속받는 방식이 아닙니다.- 메서드를 섞어 넣는 방식이 아닙니다.
- 객체 자체를 전달하고 모두가 “제대로” 사용하도록 믿는 방식도 아닙니다.
다음과 같이 말하고 싶습니다:
“시스템의 이 부분에서는 이 객체를 오직 observable 로만 봐야 합니다.”
JavaScript는 이를 표현할 수 있는 기본적인 방법을 제공하지 않습니다.
Trait Views — 아이디어
Trait View는 다른 객체의 특정 동작을 노출하는 파생 객체이다. 그것은:
- 원본 객체가 아니다
- 그 복사본도 아니다
- 그에 적용된 믹스인도 아니다
이는 프로젝션이다.
객체에 trait를 추가하는 것이 아니다.
그 객체의 view를 파생한다.
최소 예시: Observable
class Observable {
static from = trait(Observable)
observe(): number {
return 0
}
// Note this one is optional
observeTwice?(): number {
return this.observe() * 2
}
}
이 트레이트는 다음을 정의합니다:
- 하나의 핵심 동작:
observe - 그 위에 구축된 하나의 기본 동작:
observeTwice
이제 완전히 무관한 객체가 있습니다:
class Sensor implements Observable {
value = 21
observe(): number {
return this.value
}
}
implements Observable 절은 중요합니다. Observable이 절대로 확장되지 않더라도, TypeScript는 다음을 강제합니다:
- 모든 필수 메서드가 존재함
- 메서드 시그니처가 호환됨
- 리팩터링이 타입‑안전하게 유지됨
이는 Trait Views가 “어두운 곳에서의 덕 타이핑”이 아니라 구조적으로 타입이 지정되고 컴파일 시점에 검사된다는 의미입니다.
const sensor = new Sensor()
Observable.from(sensor).observeTwice() // 42
여기서 일어난 일은 미묘하지만 중요합니다.
Sensor는 그대로 유지됩니다.- 메서드가 복사되지 않았습니다.
- 상속이 도입되지 않았습니다.
대신 Observable.from(sensor)는 강하게 타입이 지정된 뷰를 생성하여 sensor에 관찰 가능한 동작을 노출합니다. 여기에는 원래 객체에는 없던 기본 로직도 포함됩니다. 원본 객체는 관찰 가능하지 않으며, 뷰가 관찰 가능하게 됩니다.
Stateless vs Stateful Trait Views
Trait Views는 두 가지 모드로 존재할 수 있습니다.
Stateless views Observable.from(...) → Stateless
무상태(stateless) 트레이트 뷰:
- 상태를 소유하지 않음
- 자체 생성자는 의도적으로 사용되지 않으며 호출되지 않음
- 인스턴스당 캐시되며 안정성을 위해 고정됨
- 모든 동작을 원본 객체에 위임함
TypeScript 관점에서:
- 모든 트레이트 메서드가 존재함이 보장됩니다
- 트레이트가 소유한 속성은 옵셔널 상태를 유지합니다
무상태 모드에서는 트레이트가 소유한 속성이 의도적으로 옵셔널로 타입 지정됩니다. 이는 구현 객체가 동일한 이름의 getter를 제공할 경우, 트레이트 상태를 구체화하는 것이 잘못될 수 있음을 대비한 것입니다.
개념적으로 이는 Rust의 빌린 트레이트 객체(&dyn Trait)와 가깝습니다: 기존 상태 위에 안정적인 인터페이스를 제공합니다.
Stateful views Observable.from(...) → Stateful
유상태(stateful) 트레이트 뷰:
- 자신만의 내부 상태를 소유
- 자체 생성자와 매개변수를 가짐
Observable.from(object, param1, param2, …)를 통해 명시적으로 생성됨- 캐시되지 않음
타이핑 관점에서:
- 모든 트레이트 메서드가 존재함
- 모든 트레이트 속성이 존재함이 보장됨
이를 통해 트레이트가 원본 객체를 오염시키지 않으면서 상태를 보유할 수 있습니다.
두 모드가 존재하는 이유는 서로 다른 문제를 해결하기 위해서입니다. 옵션을 사용하여 특정 트레이트에 대해 모드를 선택할 수 있습니다:
class MyTrait {
static from = trait(MyTrait, { stateful: true });
public myState?: number = 42;
constructor(myParameter: number) {}
}
트레이트 뷰는 능력 경계로서
지금까지 트레이트 뷰는 행동을 공유하는 방법처럼 보였습니다.
하지만 트레이트 뷰는 표면 영역을 줄여줍니다.
class Disposable {
static from = trait(Disposable)
dispose(): void {
console.log("default dispose")
}
}
class Resource implements Disposable {
secret = "do not touch"
disposed = false
dispose() {
this.disposed = true
}
dangerousOperation() {
console.log(this.secret)
}
}
const resource = new Resource()
const disposable = Disposable.from(resource)
disposable.dispose() // OK
disposable.dangerousOperation() // ❌ 접근 불가
트레이트 뷰는 오직 하나의 능력만 노출합니다: 폐기(disposal).
원본 객체는 많은 메서드와 상태, 불변 조건을 가질 수 있지만, 뷰는 의도적으로 보이는 것을 제한합니다. 객체 자체를 전달하는 대신 그 객체가 할 수 있는 일만을 전달합니다.
트레이트 뷰는 단순히 재사용을 위한 것이 아니라 투영에 의한 캡슐화를 위한 것입니다.
Source: …
작동 방식 (개념적으로)
높은 수준에서 trait view는:
- 메서드 조회를 위해 trait 프로토타입을 사용합니다.
- 호출을 기본 대상 객체(상태가 없는 경우) 또는 자체 내부 상태(상태가 있는 경우)로 전달합니다.
trait(...)에 의해 생성된 정적from헬퍼를 통해 생성됩니다.- 상태가 없는 경우 대상당 캐시될 수 있어, 동일성 보장을 합니다.
(구현 세부 사항은 생략했으며, 관찰 가능한 API와 타입‑레벨 보장에 초점을 맞추었습니다.)
기본 동작
- 재정의된 메서드를 원본 객체에 바인딩합니다.
- 선택적으로 접근자를 바인딩합니다.
- 상태가 없는 view를 캐시합니다(가비지 컬렉션을 방해하지 않도록 약하게).
- 안정성을 위해 상태가 없는 view를 고정합니다.
원본 객체는 절대 변경되지 않습니다.
trait view는 명확히 정의된 인터페이스를 가진 별도의 객체입니다.
이것이 Rust 트레이트와 비교했을 때
Trait Views는 Rust 트레이트에서 영감을 받았지만, 동일하지는 않습니다.
유사점
- 행동 지향 추상화
- 기본 메서드
- 안정적인 인터페이스를 통한 동적 디스패치
차이점
- 해결이 컴파일 타임이 아니라 런타임에 이루어집니다
- 일관성(coherence)이나 고아(orphan) 규칙이 없습니다
- 오버라이드는 이름 기반입니다
- TypeScript는 모든 보장을 표현할 수 없습니다
이것은 패턴의 결함이 아니라 JavaScript의 동적 특성에 기인한 결과입니다.
Trait Views는 유사한 사용성을 목표로 하며, 동일한 의미론을 추구하지는 않습니다.
왜 함수만 바인딩하지 않을까?
언뜻 보면 Trait View는 사소해 보일 수 있습니다. 결국 새 객체를 만들고 몇몇 메서드를 수동으로 바인딩하면 되니까요.
차이는 런타임에 일어나는 일이 아니라 무엇을 모델링하고 있는가에 있습니다.
Trait View가 제공하는 것:
- 일관된 추상화
- 객체 상태와 트레이트 행동 사이의 명확한 경계
- 안정적인 타입 표면
- 선택적인 캐싱 및 동결 보장
- 코드베이스 전체에 공유되는 정신 모델
함수를 수동으로 바인딩하는 것은 국부적인 문제를 해결합니다.
Trait View는 시스템적인 문제를 해결하는 것을 목표로 합니다.
편리함보다는 의도를 명확히 표현하는 데 더 중점을 둡니다.
기존 라이브러리와의 비교
여러 라이브러리가 이미 JavaScript와 TypeScript에서 트레이트를 탐구하고 있습니다. Trait Views는 이를 대체하기 위한 것이 아니라 다른 초점을 목표로 합니다.
@traits-ts/core
- 클래스에 여러 기본 기능을 확장할 수 있는 트레이트(또는 믹스인) 기능을 제공합니다.
- TypeScript의 타입 시스템과 일반
class extends메커니즘을 활용합니다. - 트레이트 동작을 새로운 클래스 계층으로 결합하며 컴파일 시 타입 안전성을 보장합니다.
사용 사례: 트레이트가 타입 정의 시점에 알려지고 적용되는 정적 컴포지션.
Trait Views는 정적 컴포지션이 아니라 기존 객체에 대한 런타임 적응 및 인스턴스별 뷰에 초점을 맞춥니다.
traits.js
- 트레이트 컴포지션을 위한 전용 라이브러리로, 고전적인 재사용 가능한 행동 단위를 기반으로 합니다.
- 하나 이상의 트레이트를 단일 복합 트레이트로 구성하고, 결합된 행동을 가진 객체를 생성할 수 있습니다.
사용 사례: 생성 시점에 결합된 행동을 가진 새로운 객체를 만드는 경우.
Trait Views는 반대 관점을 취합니다: 트레이트에서 객체를 파생시켜 기존 객체에 대한 뷰를 변형 없이 생성합니다.
트레이드오프 및 제한 사항
- Trait Views는 런타임 리플렉션에 의존합니다.
- getter가 부작용이 없다고 가정합니다.
- 모든 형태의 몽키패치를 방지할 수 없습니다.
- API 설계에 있어 규율이 필요합니다.
이 패턴은 모든 상황에 적합한 것은 아닙니다. Trait Views는 특히 다음에 잘 맞습니다:
- 엔진 및 시뮬레이션
- ECS 스타일 아키텍처
- 표면 제어가 중요한 능력 기반 API 또는 시스템
이들은 단순 CRUD 애플리케이션이나 UI 중심 코드베이스에는 적합하지 않습니다.
결론 — 그리고 열린 질문
Trait Views는 새로운 언어 기능이 아닙니다. 이것은 패턴이며 — JavaScript에서 동작을 다르게 생각하는 방법입니다.
이들은 다음 사이 어딘가에 위치합니다:
- Rust 스타일 트레이트
- 역량 기반 설계
- 런타임 객체 뷰
현재 단계에서 Trait Views는 실험입니다. 아직 공개 라이브러리는 없으며 — 아이디어와 구현, 그리고 일련의 트레이드오프만 존재합니다.
이것이 여러분에게 공감된다면
- 사용자로서
- 라이브러리 작성자로서
- 언어 설계에 관심이 있는 사람으로서
그럼 피드백이 중요합니다.
아마도 이미 다른 방식으로 유사한 문제를 해결했을 수 있으며, 그 접근 방식을 공유하고 싶을 수도 있습니다.
- 이것을 패턴으로 유지해야 할까요?
- 작은 실험적 라이브러리로 만들어야 할까요?
- 아니면 특수 시스템을 위한 내부 도구로 남겨야 할까요?
저는 커뮤니티가 어떻게 생각하는지 진심으로 듣고 싶습니다.