다트의 call by sharing이 플러터에서 작동하는 원리에 대한 해설

Posted on

프로그래밍 기술은 소프트웨어 연구에 의해 성립된 개념에 의해 구현됩니다. 함수를 호출한다고 했을때, 매개변수가 넘겨진다면 이 매개변수를 넘겨서 참조하는 방법도 경우에 따라 차이가 있으며 이를 개념화한 것이 언어의 특징으로 규정되어 있습니다. “값에 의한 호출” (call by value) “메모리 주소에 의한 호출” (call by reference) “공유에 의한 호출” (call by sharing) 이 그 개념들입니다.

이들 세가지 개념은 데이터가 함수 범위 내에서 어떻게 접근되고 수정될 수 있는지를 결정하며 프로그램이 어떻게 작동할 것인지를 예측하게 해줍니다.

와 같은 구문이 실행될때 a와 b는 sum()에서 어떤 매커니즘으로 작동할까요?

값에 의한 참조는 호출자의 효력 범위(scope)에 있는 변수의 값만 복사해서 함수 내부의 변수에 대입하는 방식으로 동작합니다. 아래 예처럼 변수명과 타입이 같더라도 메모리 공간이 분리된 다른 변수이고, 이 다른 변수에 값만 복사해서 작동하게 됩니다.

반면 참조에 의한 호출은 호출자의 효력 범위에 있는 변수의 메모리 주소를 함수 내부의 변수에 대입하는 방식으로 작동합니다. 함수 내부에 넘겨진 매개변수는 호출자의 효력 범위에 있는 함수 외부의 변수의 메모리 주소를 가리키기만 하기에, 한 값이 바뀌면 다른 값도 똑같이 바뀌게 됩니다.

값에 의한 호출의 장점은 직관적이라는 것이구요. 지역 변수와 그보다 상위에 있는 스코프의 변수가 서로 다른 값을 가지게 할 수 있다는데 있습니다. 단점은 거대한 배열 변수나, 복잡한 객체를 넘기는 경우, 처리하는 연산에 필요한 시간과 자원이 늘어나서 오버헤드가 있게 된다는 것입니다. 그래서 참조에 의한 호출이 고안되었던 것입니다. 각 변수가 담은 값의 크기가 크더라도 매개변수로 넘겨진 변수는 해당 변수의 메모리 주소만 가리키기에 처리할때의 오버헤드가 줄어듭니다. 값으로 넘기면 같은 크기의 데이터로 중복되지만, 참조로 넘기면 메모리 주소 크기만큼만 차지하니까요.

이를 절충한 공유에 의한 호출(call by sharing)은 매개변수로 넘기는 것은 객체에 대한 참조값입니다. 호출자의 변수와 함수의 매개변수는 각각 별개의 변수이지만 둘다 동일한 객체를 가리키는 참조의 복사본이 됩니다.

객체가 변경 가능한 객체라면 함수는 전달받은 참조를 통해 이 객체의 내부 상태를 바꿀 수 있습니다. 이 변경 사항은 호출자에게도 그대로 보여집니다. call by reference와 유사한 동작입니다. 반면 함수 내에서 매개변수에 새로운 객체를 할당하는 등의 작업이 일어나면 이는 함수 내의 지역 참조 변수가 가리키는 대상만 바꿉니다. 호출자의 원본 변수는 여전히 원래의 객체를 가리키고 있으며, 아무런 영향을 받지 않습니다. call by value와 유사한 작동입니다.

내부적으로 처리될때는

다트는 call by sharing 전략을 씁니다. 다트 언어 설계의 특징은 원시 타입과 객체 타입을 구분하지 않고 모두 객체로 처리됩니다. int, double, bool, String, Function, null까지도 포함됩니다. “이것이 원시 타입이면 이렇게 동작하고, 객체 타입이면 저렇게 동작한다”와 같은 규칙이 없습니다.

다트에서는 변수란 객체의 데이터 자체를 담고 있는 컨테이너가 아니라 메모리에 존재하는 객체 참조를 저장합니다.

var age =”34″; 라는 코드는 age라는 변수가 “34”라는 값을 가진 String 객체의 참조를 담고 있음을 의미합니다.

반면 var age = 34라는 코드는 age라는 변수가 34라는 정수형 값을 가진 int 객체의 참조를 담고 있음을 의미합니다.

다트와 플러터 상에서 이 작동을 보여주는 코드 예시와 설명은 복잡하니 생략하구요. 요점은 다트는 call by sharing 방식을 채택한 언어라, 이에 의한 작동상의 이점이 있다는 것입니다.

int 를 전달하든, List를 전달하든, Function을 전달하든, 그 기저에 깔린 원리는 “참조의 복사본을 전달한다”이고 이는 값에 의한 호출과 참조에 의한 호출의 장점을 모두 갖습니다.

플러터에 적용되면, 선언적 UI에 적용됩니다.

개발자는 특정 상태가 되면 UI가 어떻게 보여야 하는지를 기술하고 프레임워크는 상태 변경에 따른 화면 업데이트를 책임집니다. 이 때 중요한 것은 위젯은 불변 객체라는 것입니다. 상태가 변경되면, 위젯 트리를 수정하는 것이 아니라, 새로운 상태를 반영하는 새로운 위젯 트리가 생성됩니다. 그러면 프레임워크는 이전 트리와 새로운 트리를 비교하여 실제 렌더링에 필요한 최소한의 변경만을 수행합니다. (이 단락은 인공지능 답변 참조)

만약 다트가 call by value만 쓴다면, 부모 위젯이 자식 위젯을 전달할때마다 해당 자식 위젯과 그 하위 트리에 대한 깊은 복사(deep copy)가 필요하게 되어 엄청난 계산 비용을 유발하고 프레임워크의 성능을 파괴했을 것입니다. (이 단락은 인공지능 답변 참조)

하지만 다트는 call by sharing 전략을 채택하기에, 객체 생성시 가벼운 참조만 전달됩니다. 객체 복사 과정이 일어나지 않습니다. 그래서 플러터가 지향하는 선언적 UI의 성능의 이점이 있게 되는 것입니다.

마지막으로 구글 Gemini가 정리한 결론부의 언급을 소개하며 마칩니다.


다트는 명백히 Call-by-Sharing을 사용합니다. 이는 인자로 전달된 객체를 가리키는 참조의 복사본을 함수에 전달하는 방식입니다. 이로 인해 함수는 공유된 객체의 내부 상태를 변경할 수는 있지만, 호출자의 변수 자체가 다른 객체를 가리키도록 재할당할 수는 없습니다.

이 선택은 의도적인 설계의 결과입니다. Call-by-Sharing은 단순한 기술적 구현 세부사항이 아니라, 다트 언어 설계의 핵심 철학을 반영하는 근본적인 결정입니다. 이는 대용량 객체 전달의 성능 문제(Call-by-Value의 단점)와 예측 불가능한 부수 효과(Call-by-Reference의 단점) 사이에서 실용적인 균형을 맞추려는 의도적인 타협점입니다.

언어 모델의 단순성과 일관성을 강화합니다. ‘모든 것은 객체다’라는 원칙과 결합된 단일 매개변수 전달 규칙은 개발자가 데이터 타입에 따라 다른 동작을 암기할 필요가 없게 하여, 언어의 학습 곡선을 낮추고 코드의 예측 가능성을 높입니다.

플러터의 선언적 아키텍처를 가능하게 하는 핵심 동력입니다. 플러터의 불변 위젯과 ‘상태가 변하면 새로 그린다’는 패러다임은, 위젯 객체의 참조를 값싸게 전달할 수 있는 Call-by-Sharing이 없었다면 성능적으로 불가능했을 것입니다. 또한, 단방향 데이터 흐름을 구현하고, 불변 상태 관리 패턴과의 시너지를 통해 고도로 최적화된 UI 업데이트를 실현하는 기술적 기반이 됩니다.

결론적으로, 다트의 저수준 매개변수 전달 방식과 플러터의 고수준 프레임워크 아키텍처는 분리된 개념이 아니라, 서로를 가능하게 하고 강화하는 불가분의 관계에 있습니다. Call-by-Sharing이라는 잘 설계된 언어적 기반 위에서 플러터의 성공적인 반응형, 선언적 UI 아키텍처가 구축될 수 있었던 것입니다. 이 원리를 깊이 이해하는 것은 플러터 개발자가 더 효율적이고, 안정적이며, 예측 가능한 애플리케이션을 설계하고 구현하는 데 있어 필수적인 통찰을 제공합니다.”

오타가 있어서 다시 올립니다.


덧붙이자면 다트에서 call by sharing은 변수의 메모리 공간은 분리되어 있고, 가리키는 메모리 주소 참조값이 복사된다는 것입니다. 그래서 함수 내에서 불변이면, 본래 변수와 값이 유지되고, 함수 내에서 바꾸면 본래 변수와 값이 달라지게 됩니다.

결국 call by reference와 call by value를 처리하는 동등한 원리가 되죠.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다