디지털 명함 에디터 프로젝트를 진행하면서 Konva 라이브러리를 기반으로 사용자가 캔버스에 요소를 추가하고 조작하는 기능을 개발하고 있었다. 이때 React의 forwardRef를 활용하여 Rect, Circle, Group 등 다양한 Konva 노드 컴포넌트에 ref를 연결하여 해당 노드의 인스턴스에 접근하고자 했다. 처음에는 간단할 것이라 예상했다. 하지만 예상과는 달리 TypeScript와의 타입 호환성 문제로 인해 적잖은 어려움을 겪었다. 이 과정에서 겪었던 타입 오류와 해결 과정을 상세히 기록해두면, 앞으로 Konva와 React, TypeScript를 함께 사용하는 다른 개발자들에게 실질적인 도움이 될 수 있을 것이라는 생각이 들어 이 글을 작성하게 되었다.
Konva 노드를 forwardRef로 감싸는 것은 일반적인 React 컴포넌트와 크게 다르지 않을 것이라 생각했다. 그래서 아래와 같이 코드를 작성했다.
import Konva from 'konva';
import React, { forwardRef } from 'react';
interface ElementsCanvasElementProps {
element: {
// ... element properties
elementType: string;
width?: number;
height?: number;
fill?: string;
x: number;
y: number;
points?: number[];
};
// ... other props
}
// 처음 시도했던 코드
const ElementsCanvasElement = forwardRef<Konva.Node, ElementsCanvasElementProps>(
({ element, ...rest }, ref) => {
if (element.elementType === 'rectangle') {
return (
<Rect
ref={ref} // 여기서 타입 오류 발생!
x={element.x}
y={element.y}
width={element.width}
height={element.height}
fill={element.fill}
draggable
{...rest}
/>
);
}
// ... 다른 요소 타입들
return null; // 기본 반환값
}
);
하지만 이 코드는 다음과 같은 TypeScript 오류를 발생시켰다.
Type 'ForwardedRef<Konva.Node<Konva.NodeConfig>>' is not assignable to type 'LegacyRef<Konva.Rect> | undefined'.
Type 'Konva.Node<Konva.NodeConfig>' is not assignable to type 'Konva.Rect'.
Property 'getWidth' is missing in type 'Node<NodeConfig>' but required in type 'Rect'.ts(2322)
오류 메시지의 핵심은 forwardRef에 제네릭으로 넘긴 Konva.Node 타입과 실제 Rect 컴포넌트가 요구하는 Konva.Rect 타입이 일치하지 않는다는 것이었다. Konva.Node는 모든 Konva 노드의 기본 클래스이지만, Rect는 getWidth, getHeight 등 자신만의 속성과 메서드를 가진 구체적인 클래스이기 때문에 호환되지 않았던 것이다.
심지어 아래와 같이 하나의 컴포넌트 내에서 조건부 렌더링을 통해 Group과 Rect를 동시에 반환하려고 할 때도 비슷한 문제가 발생했다.
// Group과 Rect를 동시에 처리하려 할 때의 문제 예시
const ElementsCanvasElement = forwardRef<Konva.Node, ElementsCanvasElementProps>(
({ element, ...rest }, ref) => {
if (element.elementType === 'group') {
return <Group ref={ref} {...rest} />; // ref는 Konva.Group 타입을 기대
}
if (element.elementType === 'rectangle') {
return <Rect ref={ref} {...rest} />; // ref는 Konva.Rect 타입을 기대
}
// ...
}
);
하나의 ref를 전달받아 내부 요소의 타입에 따라 Konva.Group에도 할당하고 Konva.Rect에도 할당해야 하는 상황인데, forwardRef의 제네릭 타입 (Konva.Node)과 실제 할당하려는 요소의 구체적인 타입 (Konva.Group, Konva.Rect) 간의 불일치가 계속해서 문제를 일으켰다.
문제의 근본 원인은 React의 forwardRef 타입 시스템과 react-konva가 내부적으로 사용하는 Konva의 클래스 타입 시스템 간의 불일치였다.
React.forwardRef<T, P>에서 제네릭 T는 전달될 ref의 타입을 의미한다. 우리는 모든 Konva 노드의 기본 타입인 Konva.Node를 여기에 지정했다.
하지만 react-konva의 Rect, Circle, Group 등의 컴포넌트는 내부적으로 실제 Konva 클래스(Konva.Rect, Konva.Circle, Konva.Group)의 인스턴스를 생성한다. 이 컴포넌트들의 ref prop은 해당 구체적인 Konva 클래스 타입을 기대한다.
따라서 Konva.Node 타입으로 정의된 ref를 Konva.Rect나 Konva.Group 타입을 기대하는 곳에 직접 할당하려고 하니 TypeScript 컴파일러가 타입 오류를 발생시킨 것이다.
코드를 간결하게 만들기 위해 공통 속성을 분리하려는 시도 역시 이 문제에 부딪혔다.
// 공통 속성 분리 시도 (실패)
const ElementsCanvasElement = forwardRef<Konva.Node, ElementsCanvasElementProps>(
({ element, ...rest }, ref) => {
const commonProps = {
...rest,
x: element.x,
y: element.y,
draggable: true,
ref: ref, // 이 ref 타입(Konva.Node)이 문제!
};
if (element.elementType === 'rectangle') {
return <Rect {...commonProps} width={element.width} height={element.height} />; // 에러: ref 타입 불일치
}
if (element.elementType === 'group') {
return <Group {...commonProps} />; // 마찬가지로 에러 발생
}
// ...
}
);
ref의 타입을 Konva.Node로 받으면 Group, Rect에 각각 할당할 수 없고, 반대로 Konva.Group으로 받으면 Rect에는 할당할 수 없는, 진퇴양난의 상황이었다.
여러 시도 끝에 찾은 해결책은, 다소 투박해 보일 수 있지만 ref를 전달하는 시점에 해당 Konva 노드의 구체적인 타입으로 명시적으로 캐스팅해주는 것이었다.
const ElementsCanvasElement = forwardRef<Konva.Node, ElementsCanvasElementProps>(
({ element, ...rest }, ref) => {
const commonProps = {
...rest,
x: element.x,
y: element.y,
draggable: true,
};
if (element.elementType === 'rectangle') {
return (
<Rect
{...commonProps}
ref={ref as unknown as React.Ref<Konva.Rect>} // Rect 타입으로 캐스팅
width={element.width}
height={element.height}
/>
);
}
if (element.elementType === 'group') { // 예시: Group을 사용하는 경우
return (
<Group
{...commonProps}
ref={ref as unknown as React.Ref<Konva.Group>} // Group 타입으로 캐스팅
>
{/* Group의 자식 요소들 */}
</Group>
);
}
// ... 다른 도형들
}
);
as unknown as React.Ref<Konva.Rect> 와 같은 형식은 TypeScript의 타입 검사를 우회하는 방식이므로 일반적으로 권장되지는 않는다. 하지만 이 경우에는 React의 forwardRef가 제공하는 제네릭 타입과 Konva 라이브러리가 각 컴포넌트에서 기대하는 구체적인 ref 타입 사이의 간극을 메우기 위한 현실적인 타협점이었다. unknown을 거쳐 캐스팅하는 것은 TypeScript에게 "내가 이 타입 변환에 대해 책임을 지겠다"고 선언하는 것과 같다.
타입 캐스팅으로 문제를 해결했지만, 코드 구조 자체에 대한 고민도 하게 되었다. 모든 요소를 잠재적으로 Group으로 감쌀 수 있도록 설계하는 것은 ref 타입 관리를 불필요하게 복잡하게 만들었다.
Konva.Group은 여러 도형을 묶어 함께 변형(크기 조절, 회전 등)하거나, Konva.Line과 같이 자체적으로 크기(width, height)를 가지지 않는 요소를 감싸 Transformer와 함께 사용하기 위해 필요하다. 하지만 Rect, Circle 등 자체적으로 크기를 가지는 요소는 굳이 Group으로 감쌀 필요가 없는 경우가 많았다.
따라서 다음과 같이 구조를 변경했다.
Line 요소처럼 Group으로 감싸야 하는 명확한 이유가 있는 경우에만 Group을 사용한다. (ref는 Konva.Group으로 캐스팅)
Rect, Circle 등 다른 요소는 Group 없이 직접 렌더링하고, ref도 해당 요소의 타입(Konva.Rect, Konva.Circle 등)으로 직접 캐스팅한다.
const ElementsCanvasElement = forwardRef<Konva.Node, ElementsCanvasElementProps>(
({ element, ...rest }, ref) => {
const commonProps = {
...rest,
x: element.x,
y: element.y,
draggable: true,
};
// Line 요소는 Group으로 감싸 Transformer 적용 등을 용이하게 함
if (element.elementType === 'line') {
return (
<Group {...commonProps} ref={ref as unknown as React.Ref<Konva.Group>}>
<Line points={element.points} stroke="black" strokeWidth={element.strokeWidth} />
</Group>
);
}
// Rectangle 요소는 직접 Rect 컴포넌트 사용
if (element.elementType === 'rectangle') {
return (
<Rect
{...commonProps}
ref={ref as unknown as React.Ref<Konva.Rect>}
width={element.width}
height={element.height}
fill={element.fill}
/>
);
}
// ... 다른 요소 타입에 대한 처리 (Circle, Text 등)
// 각 요소 타입에 맞는 Konva 컴포넌트와 ref 캐스팅 사용
return null; // 해당하는 요소 타입이 없을 경우
}
);
이렇게 구조를 변경함으로써, 각 요소 타입에 필요한 ref 타입이 명확해지고 불필요한 Group 래핑을 줄여 코드의 복잡도를 낮출 수 있었다.
이번 forwardRef 타입 오류 해결 과정을 통해 몇 가지 중요한 점을 다시 한번 깨닫게 되었다.
라이브러리 타입 시스템 이해의 중요성: React의 타입 시스템과 Konva(정확히는 react-konva)의 타입 시스템이 항상 완벽하게 일치하지는 않는다는 점이다. 특히 ref와 같이 인터페이스 역할을 하는 부분에서 이런 불일치가 드러나기 쉽다.
Konva.Node의 역할: Konva.Node는 모든 노드의 추상적인 기본 타입이며, 실제操作(예: getWidth(), radius() 등)을 위해서는 Konva.Rect, Konva.Circle 같은 구체적인 타입 정보가 필요하다는 점이다.
명시적 타입 캐스팅의 필요성: 이상적으로는 타입 캐스팅 없이 타입 추론이 완벽하게 이루어지면 좋겠지만, 현실적으로 라이브러리 간의 타입 정의 차이로 인해 as unknown as T와 같은 명시적 캐스팅이 필요한 경우가 존재한다는 것이다. (물론, 남용은 금물이다.)
구조적 개선의 효과: 단순히 타입 오류를 해결하는 것을 넘어, 왜 Group이 필요한지, 각 요소의 특성은 무엇인지 고민하여 컴포넌트 구조를 개선하는 것이 장기적으로 코드의 유지보수성과 가독성을 높인다는 점이다.
문제 상황 | 원인 분석 | 해결 방법 및 개선 |
---|---|---|
forwardRef<Konva.Node> 와 Rect 의 타입 불일치 | Konva.Node 는 추상 타입, Rect 는 구체 타입 요구 | ref as unknown as React.Ref<Konva.Rect> 로 명시적 캐스팅 |
Group , Rect 등 다양한 요소를 한 컴포넌트에서 처리 시 ref 타입 혼란 | 하나의 ref 를 여러 구체 타입에 할당 불가 | 요소 타입별로 분기하여 각자 맞는 타입으로 캐스팅 |
TypeScript와 Konva의 타입 시스템 간 간극 | 라이브러리 간 타입 정의 방식 차이 | 타입 분기 또는 명시적 타입 캐스팅으로 현실적 타협 |
불필요한 Group 사용으로 인한 복잡도 증가 | 모든 요소를 Group 으로 감쌀 필요 없음 | Line 등 필요한 경우에만 사용하도록 구조 개선 |
Konva를 사용하다 보면 종종 "이건 React 방식과는 조금 다른데?"라는 느낌을 받게 된다. 상태 관리 방식이나 이벤트 처리, 그리고 이번에 겪은 ref 처리 방식 등에서 Konva 고유의 특성이 드러나기 때문이다. 하지만 forwardRef를 이용해 ref를 얻고, Transformer를 붙여 사용자 인터페이스를 만들고, getClientRect() 같은 메서드로 요소의 위치나 크기 정보를 얻는 일련의 과정을 직접 겪고 나면, Konva가 제공하는 자유로운 그래픽 조작 능력의 강력함을 실감하게 된다.
이 글에서 공유한 forwardRef와 TypeScript 타입 문제 해결 경험이, 비슷한 문제로 어려움을 겪고 있는 개발자들에게 막막함을 해소하는 작은 실마리가 되기를 바란다.