경계 지점에서 정적 타입은 환상에 불과합니다

원문: Mark Seemann, "At the boundaries, static types are illusory"
정적 타입은 유용하지만 한계가 있습니다.
이 블로그를 자주 읽는 분이라면 제가 정적 타입 시스템을 좋아한다는 걸 눈치채셨을 겁니다. C에서 제공되는 정적 타입 같은 게 아닙니다. C의 정적 타입은 대개 너무 많은 방식으로 정수와 포인터 타입을 구별하는 느낌을 줍니다. 좋은 타입 시스템은 단순히 강화된 숫자 타입 그 이상을 의미합니다. C#과 같은 타입 시스템은 실용적이긴 하지만 장황합니다. 저는 대수적 자료형과 좋은 타입 추론을 갖춘 타입 시스템이 가장 유용하다고 생각하는데요. 제가 잘 알고 있는 예로는 F#과 하스켈(Haskell)이 있습니다.
아무리 정적 타입 시스템이 훌륭하더라도 한계는 존재합니다. Hillel Wayne이 이미 한 가지 구분을 설명했지만 여기서는 다른 제한 사항에 초점을 맞추고자 합니다.
애플리케이션 경계 지점
모든 소프트웨어는 '나머지 세계'와 상호작용합니다. 사실상 자신의 프로세스 외부에 존재하는 모든 것과 상호작용합니다. (점점 드물지만) 가끔은 이런 상호작용이 오로지 일부 사용자 인터페이스상으로만 이루어지기도 하는데 갈수록 더 많은 경우 애플리케이션은 어떤 방식으로든 다른 소프트웨어와 상호작용하죠.

여기서 프로세스 내부에 일어나는 일은 후속 논의와 관련이 없다는 점을 강조하기 위하여 애플리케이션 내부를 불투명한 디스크로 그렸습니다. 이 도표에는 몇몇 흔한 트래픽 종류도 포함되어 있습니다. 많은 애플리케이션이 어떤 종류의 데이터베이스에 의존하거나 메시지 (이메일, SMS, 명령, 이벤트 등)를 보냅니다. 이런 트래픽을 애플리케이션이 시작하는 상호작용으로 생각할 수 있지만 많은 시스템은 들어오는 데이터를 수신하고 이에 반응하기도 합니다. 예를 들어 HTTP 트래픽이나 큐에 도착하는 메시지 등이 있습니다.
애플리케이션 경계 지점에 관하여 말할 때 해당 인터페이스 계층에서 무슨 일이 일어나는지를 생각해 봅니다.
애플리케이션은 여러 방식으로 외부 세계와 대화할 수 있습니다. 파일을 읽거나 쓰고, 공유 메모리에 접근하고, 운영체제 API를 호출하고, 네트워크 패킷을 전송하거나 수신하는 등 다양하죠. 일반적으로 고수준의 추상화에 맞추어 프로그래밍을 하지만 최종적으로 애플리케이션은 여러 이진 프로토콜을 처리합니다.
프로토콜
핵심은 이겁니다. 충분히 저수준의 추상화에서 애플리케이션에 들어오고 나가는 데이터는 단순한 바이트 배열일 뿐이며 이보다 더 강력한 정적 타입은 존재하지 않습니다.
고수준의 API가 데이터를 처리하여 정적 타입으로 입출력을 제공한다는 반론을 제기할 수도 있습니다. 텍스트 파일과 상호작용할 때 보통 파일에서 한 줄씩 문자열 목록을 처리하게 됩니다. 아니면 직렬화·역직렬화 API를 사용하여 JSON, XML, 프로토콜 버퍼 또는 다른 전송 형식을 조작할 수도 있습니다. CSV와 같은 경우 매우 간단한 구문 분석기를 직접 작성해야 할 수도 있습니다. 혹은 약간 더 복잡한 무언가 일수도 있고요.
제 의도를 설명하기 위해 JsonSerializer.Deserialize와 같은 API만 한 게 없네요. 이 메서드로 아래와 같은 코드를 작성할 수 있습니다.
let n = JsonSerializer.Deserialize<Name> (json, opts)
그리고 여러분은 이렇게 말할 수도 있겠죠. n은 정적 타이핑되었고 그 타입은 Name이네요! 야호! 그런데 이건 단지 절반의 진실에 불과하다는 걸 깨닫게 됩니다. 그렇지 않나요?
애플리케이션 경계 지점에서 상호작용은 특정 프로토콜을 따를 것으로 기대됩니다. 텍스트 파일을 읽더라도 마찬가지죠. 요즘에는 텍스트 파일이 유니코드로 되어 있을 것이라고 예상하겠지만 레거시 시스템에서 수신한 EBCDIC 인코딩 파일을 처리해야 했던 경험이 있으신가요? 아니면 예상과 다른 코드 페이지를 가진 ASCII 파일은요? 심지어 윈도우를 사용하는데 유닉스 시스템에서 작성된 파일을 받거나 그 반대의 경우는요?
이와 같은 데이터를 올바르게 해석하거나 전송하기 위해서는 프로토콜을 따라야 합니다.
이런 프로토콜은 방금 열거한 문자 인코딩 예시처럼 저수준일 수 있지만 훨씬 더 고수준일 수도 있습니다. 예를 들어 아래와 같은 HTTP 요청을 생각해 보세요.
POST /restaurants/90125/reservations?sig=aco7VV%2Bh5sA3RBtrN8zI8Y9kLKGC60Gm3SioZGosXVE%3D HTTP/1.1
Content-Type: application/json
{
"at": "2021-12-08 20:30",
"email": "snomob@example.com",
"name": "Snow Moe Beal",
"quantity": 1
}
이런 상호작용은 프로토콜을 내포합니다. 이 프로토콜의 일부는 HTTP 요청 본문이 유효한 JSON 문서고, at 속성을 가지며, 해당 속성은 유효한 날짜와 시간을 인코딩하고, quantity는 자연수고, email은 존재한다는 것 등을 의미하죠.
예상되는 입력을 데이터 전송 객체(DTO)로 모델링할 수 있고요.
public sealed class ReservationDto
{
public string? At { get; set; }
public string? Email { get; set; }
public string? Name { get; set; }
public int Quantity { get; set; }
}
DTO를 사용하기 위한 '프로토콜 핸들러' (아래는 ASP.NET Core 액션 메서드)를 설정할 수도 있습니다.
public Task<ActionResult> Post(ReservationDto dto)
정적 타입처럼 보일 수 있겠지만 이건 특정 프로토콜을 가정합니다. 전송 중인 바이트가 해당 프로토콜을 따르지 않으면 무슨 일이 일어날까요?
요점은 언제나 애플리케이션 경계 지점에는 암묵적인 프로토콜이 있으며 이를 얼마만큼 명시적으로 모델링할지 여부를 선택할 수 있다는 점입니다.
타입은 프로토콜의 지름길이 될 수 있을까요
위의 예시에서는 문제 해결을 위하여 특정 정적 타이핑에 의존했습니다. 결국 예상된 입력 형태를 모델링하는 DTO를 정의했습니다. 다른 대안을 선택할 수도 있었죠. JSON 구문 분석기를 써서 명시적으로 JSON DOM을 사용하거나 훨씬 더 저수준으로 쓰이는 Utf8JsonReader를 사용할 수도 있었고 최종적으로 JSON 구문 분석기를 직접 작성할 수도 있었습니다.
JSON 구문 분석기를 처음부터 구현하는 일은 거의 (아니면 전혀?) 없기 때문에 그걸 제가 주장하려는 건 아닙니다. 오히려 제 요지는 기존 API를 활용하여 입출력을 처리할 수 있고 그중 일부 API는 경계 지점에서 일어나는 일이 정적 타이핑이라는 그럴듯한 환상을 제공한다는 점입니다.
이 환상은 부분적으로는 API 특정적이며 언어 특정적이기도 합니다. 예를 들어 닷넷(.NET)에서 JsonSerializer.Deserialize는 어떤 JSON 문자열도 원하는 모델로 항상 역직렬화할 것처럼 보입니다. 당연히 그건 거짓말이죠. 연산이 불가능하면 (즉 입력이 잘못된 형식이면) 함수는 예외를 반환하기 때문입니다. 닷넷 (그리고 많은 다른 언어나 플랫폼)에서는 API 타입으로 실패 방식을 알아낼 수 없습니다. 반대로 aeson의 fromJSON 함수는 역직렬화가 실패할 수 있음을 명시적으로 나타내는 타입을 반환합니다. 하지만 하스켈에서도 이건 대개 관습적인 관례입니다. 왜냐하면 하스켈도 예외를 '지원'하기 때문입니다.
경계 지점에서 정적 타입은 프로토콜의 유용한 지름길이 될 수 있습니다. 정적 타입 (예: DTO)을 선언하고 잘못된 형식의 입력을 처리하는 내장 기능에 의존합니다. 더 선언적인 모델을 대가로 세분화된 제어를 어느 정도 포기합니다.
저는 종종 그렇게 하기로 선택하는데 이런 트레이드오프가 유익하다고 생각하기 때문입니다. 그렇지만 정적 타입이 '전송 중에' 일어나는 일을 완전히 모델링한다는 환상에 빠지지는 않습니다.
역할 반전
지금까지 입력의 유효성 검증을 주로 논의해 왔습니다. 타입이 유효성 검증을 대체할 수 있을까요? 그건 아닙니다만 타입으로 가장 일반적인 유효성 검증 시나리오를 더 쉽게 만들 수 있습니다. 데이터를 반환할 때는 어떻게 될까요?
정적 타입 값을 반환하기로 결정할 수 있습니다. 직렬화 프로그램은 그런 값을 적절한 전송 형식 (JSON이나 XML 아니면 비슷한 형식)으로 정확히 변환할 수 있죠. 수신자는 해당 타입에 관하여 별 관심이 없을 것입니다. 하스켈 값을 반환하는데 데이터를 수신하는 시스템은 파이썬으로 작성될 수도 있으니까요. 또는 C# 객체를 반환하는데 수신자가 자바스크립트일 수도 있습니다.
그러면 정적 타입으로 반환하는 데이터를 모델링할 이유가 없다고 결론을 내려야 할까요? 결코 아닙니다. 왜냐하면 정적 타입으로 출력을 모델링함으로써 전송하는 데이터를 엄격하게 처리할 수 있기 때문입니다. 정적 타입은 일반적으로 '그냥 코드'보다 더 엄격하므로 타입이 쉽게 표현하지 못하는 코너 케이스가 있기 마련입니다. 입력에서는 이게 문제가 될 수 있지만 출력에서는 오직 장점으로만 작용합니다. 즉 출력 퍼널을 좁혀서 시스템을 더 작업하기 쉽게 만드는 것이죠.

이제 역할을 반전시켜 보겠습니다. 애플리케이션이 상호작용을 시작하면 출력을 생성하고 입력을 결과로 받습니다. 이는 데이터베이스 상호작용을 포함합니다. 데이터베이스에서 하나의 행을 생성하거나, 변경하거나, 삭제할 때 데이터를 전송하고 응답을 받습니다.
이 경우에는 포스텔의 법칙을 고려하지 말아야 할까요?

대부분의 사람들은 포스텔의 법칙을 적용하지 않습니다. 특히 객체 관계 매퍼(ORMs)에 의존할 때는 더 그렇죠. 어차피 데이터베이스 행을 모델링하는 정적 타입 (클래스)이 있다면 데이터베이스를 변경할 때 그걸 사용하는 게 문제가 될까요?
아마 아무런 문제가 없을 겁니다. 어쨌든 방금 작성한 내용에 따르면 정적 타입 사용은 여러분이 전송하는 데이터를 엄격하게 처리하는 좋은 방식입니다. 아래는 엔티티 프레임워크를 사용한 예시입니다.
using var db = new RestaurantsContext(ConnectionString);
var dbReservation = new Reservation
{
PublicId = reservation.Id,
RestaurantId = restaurantId,
At = reservation.At,
Name = reservation.Name.ToString(),
Email = reservation.Email.ToString(),
Quantity = reservation.Quantity
};
await db.Reservations.AddAsync(dbReservation);
await db.SaveChangesAsync();
여기서 우리는 정적 타입 Reservation '엔티티'를 데이터베이스에 전송합니다. 정적 타입을 사용하므로 전송하는 데이터를 엄격하게 처리하고 있습니다. 이건 좋은 일이죠.
데이터베이스를 쿼리할 때는 어떨까요? 아래는 이에 관한 전형적인 예시입니다.
public async Task<Restaurants.Reservation?> ReadReservation(int restaurantId, Guid id)
{
using var db = new RestaurantsContext(ConnectionString);
var r = await db.Reservations.FirstOrDefaultAsync(x => x.PublicId == id);
if (r is null)
return null;
return new Restaurants.Reservation(
r.PublicId,
r.At,
new Email(r.Email),
new Name(r.Name),
r.Quantity);
}
여기서는 데이터베이스 행 r을 읽고 의심 없이 도메인 모델로 변환합니다. 이렇게 해도 되는 걸까요? 데이터베이스 스키마가 제 애플리케이션 코드에서 벗어났다면 어떻게 해야 할까요?
저는 관계형 데이터베이스와 특히 ORM에 관련된 많은 고민과 문제는 ORM '엔티티'가 데이터베이스 스키마의 정적 타입 뷰라는 환상에서 비롯되었다고 생각합니다. 일반적으로 엔티티 프레임워크와 같은 ORM을 코드 우선이나 데이터베이스 우선 방식으로 사용할 수 있지만 선택한 방식과 관계없이 데이터베이스에 관하여 경쟁하는 두 가지 '진실'이 있습니다. 데이터베이스 스키마와 엔티티 클래스입니다.
두 가지 관점을 동시에 유지하기 위해서는 훈련이 필요하며 저는 이게 불가능하다고 주장하는 건 아닙니다. 단지 정적 타입이 애플리케이션 경계 지점 밖에 실제로 무엇이 존재하는지에 관하여 어떤 진실도 나타내지 못할 수 있다는 점을 명시적으로 인정하는 게 유익할 수 있다고 제안할 뿐입니다.
타입은 환상입니다
대개 정적 타입이 훌륭하다고 확신하는 진영에 속한 제가 이제는 온통 정적 타입을 비난하는 글을 쓴다는 게 이상하게 보일지도 모릅니다. 마치 갑자기 깨달음을 얻고 입장을 전환한 것처럼 보일 수도 있겠지만 실제로는 그렇지 않습니다. 오히려 저는 암묵적인 걸 명시적으로 만드는 것을 선호합니다. 이를 통해 개념적 경계가 명확해지므로 이해도가 향상될 수 있습니다.
여기서도 같은 경우입니다. 모든 모델은 잘못되었지만 어떤 모델은 유용합니다. 정적 타입도 마찬가지라고 생각합니다.
정적 타입 시스템은 애플리케이션이 어떻게 동작해야 하는지를 모델링하는 유용한 도구입니다. 타입은 런타임에서 사실상 존재하지 않습니다. (단순히 예를 들자면) 닷넷 코드는 타입 정보를 포함한 이진 표현으로 컴파일되더라도 실행이 되면 기계 코드로 JIT 컴파일(Just-in-time compilation)됩니다. 결국에는 레지스터와 메모리 주소일 뿐이거나 더 허무주의적으로 본다면 회로판 위에서 움직이는 전자와 같은 것이죠.
고수준의 추상화에서도 여전히 이렇게 말할 수 있습니다. 적어도 정적 타입 시스템은 규칙과 가정을 캡슐화하는 데 도움이 될 수 있습니다. 예를 들어 C#과 같은 언어에서 아래의 NaturalNumber 클래스와 같은 서술형 타입을 생각해 보세요.
public struct NaturalNumber : IEquatable<NaturalNumber>
{
private readonly int i;
public NaturalNumber(int candidate)
{
if (candidate < 1)
throw new ArgumentOutOfRangeException(
nameof(candidate),
$"The value must be a positive (non-zero) number, but was: {candidate}.");
this.i = candidate;
}
// 다른 여러 구성 요소들이 이어집니다…
이런 클래스는 사실상 자연수가 언제나 양의 정수라는 불변성을 보호합니다. 네. 그건 누군가 아래와 같은 행위를 하기 전까지는 잘 동작하죠.
var n = (NaturalNumber)FormatterServices.GetUninitializedObject(typeof(NaturalNumber));
변수 n은 내부 값이 0입니다. 그렇죠. FormatterServices.GetUninitializedObject는 생성자를 우회합니다. 이건 악랄하지만 실존하는 일입니다. 그리고 적어도 현재 논의에서 타입이 환상이라는 요점을 잘 보여주는 용도로 사용되고 있네요.
이런 결함이 C#에만 있는 건 아닙니다. 다른 언어들에도 비슷한 백도어가 있습니다. 가장 유명한 정적 타입 언어 중 하나인 하스켈에는 unsafePerformIO가 있는데 이 함수는 어떤 비순수 코드를 작성했더라도 별다르게 아무 문제 없이 넘어가는 것처럼 속이는 기능을 제공합니다.
정상 코드 베이스에서는 이런 백도어를 사용하지 않도록 정책을 수립할 수 있습니다 (그리고 수립해야만 합니다). 백도어는 필요하지 않습니다.
타입은 유용한 모델입니다
이 모든 게 마치 타입은 쓸모없다는 주장처럼 보일지도 모르겠네요. 하지만 그건 잘못된 결론 도출입니다. 런타임에서 파이썬 객체나 자바스크립트 함수가 존재하지 않는 것처럼 타입은 런타임상 존재하지 않습니다. (어셈블러를 제외하고) 모든 언어는 추상화입니다. 추상화는 프로그래밍이 더 용이하도록 컴퓨터 명령어를 모델링하는 방식입니다 (기대하는 대로 되기를 바라지만...). 심지어 이건 C에도 해당됩니다. C가 아무리 저수준의 세부 지향 언어로 보이더라도요.
고수준 프로그래밍 언어 (즉 기계 코드나 어셈블러가 아닌 모든 언어)가 유용하다는 것을 인정한다면 타입의 유용성을 배제할 수 없다는 것도 인정해야만 합니다. 이 주장은 취향이 아닌 하나의 논리라는 것에 주목하세요. 제가 여기서 말하는 유일한 주장은 프로그래밍이 유용한 환상에 기반한다는 것입니다. 추상화가 환상이라고 해서 추상화의 유용성까지 거부할 수는 없습니다.
정적 타입 언어에서 타입 시스템은 사실상 충분히 우수하고, 강력하며, 일반적으로 신뢰할 만하므로 근본적인 현실을 무시해도 안전하다고 가정해야 합니다. 말하자면 우리는 컴퓨터에 사용자 인터페이스 역할을 하는 임시적인 진실과 함께 작업합니다.
컴퓨터 프로그램이 결국 타입이 존재하지 않는 프로세서에서 실행되더라도 좋은 컴파일러는 여전히 우리 모델이 합리적으로 보이는지 확인할 수 있습니다. 이를 타입 검사라고 말합니다. 프로그램의 내부 동작을 모델링할 때 타입 검사는 반드시 필요합니다. 대규모의 코드 베이스에서도 컴파일러는 모든 다양한 컴포넌트가 올바르게 구성된 것처럼 보이는지 타입 검사할 수 있습니다. 프로그램이 컴파일된다고 정확한 동작을 보장하지는 않지만 타입 검사를 하지 않으면 코드의 모델이 내부적으로 일관성이 없다는 강력한 증거가 됩니다.
다시 말해서 정적 타입 프로그램의 타입 검사는 동작을 위한 필요조건이지 충분조건은 아닙니다.
프로그램 내부를 고려하는 한 이건 유효합니다. 어떤 언어 플랫폼들은 이 개념에서 한발 더 나아갑니다. 소프트웨어 컴포넌트를 서로 연결하고 타입 검사도 여전히 할 수 있기 때문이죠. 중간 코드는 타입 정보를 유지하므로 닷넷 플랫폼은 이에 관한 좋은 예가 됩니다. 즉 C#, F# 또는 비주얼 베이직 닷넷 컴파일러는 외부 라이브러리가 노출한 API에 대하여 코드의 타입 검사를 할 수 있습니다.
한편 추론 방식을 애플리케이션 경계 지점까지 확장할 수는 없습니다. 경계 지점에서 발생하는 일은 궁극적으로 타입이 없습니다.
그렇다고 경계 지점에서 타입은 무용지물인가요? 전혀 그렇지 않습니다. Alexis King이 이미 이 주제를 저보다 더 잘 다루었는데 핵심은 타입이 입력에 관한 구문 분석 결과를 여전히 효과적으로 포착하는 방법으로 남아 있다는 것입니다. 위에서 이미 논의한 대로 입력에 관한 수신, 처리, 구문 분석 또는 유효성 검증을 프로토콜 구현으로 볼 수 있습니다. 이런 프로토콜은 범용 목적보다는 애플리케이션이나 도메인에 특화된 프로토콜입니다. 그럼에도 여전히 프로토콜로써의 역할을 합니다.
제 식당 샘플 코드 베이스에 관한 입력 유효성 검증을 컴포저블 구문 분석기 집합으로 작성해 본다면 이는 프로토콜의 구현입니다. 시작점은 원시 비트가 아니라 느슨한 정적 타입인 DTO입니다. 다른 경우에는 추상화의 수준을 다르게 선택할 수도 있습니다.
ORM이 도움이 안 된다고 생각하는 (많은) 이유 중 하나는 바로 유용성을 넘어선 환상을 고집하기 때문입니다. 오히려 저는 ADO.NET과 같이 저수준의 API로 데이터베이스와 대화하는 프로토콜 구현하기를 선호합니다.
private static Reservation ReadReservationRow(SqlDataReader rdr)
{
return new Reservation(
(Guid)rdr["PublicId"],
(DateTime)rdr["At"],
new Email((string)rdr["Email"]),
new Name((string)rdr["Name"]),
new NaturalNumber((int)rdr["Quantity"]));
}
이 코드는 포스텔의 법칙을 고려하지 않으므로 특별히 좋은 프로토콜 구현이 아닙니다. 정말로 이 코드는 관대한 독자(Tolerant Reader)가 되어야 합니다. 실제로 그만큼의 입력 반공변성이 가능하지는 않겠지만 적어도 이 코드는 Name 필드가 누락된 경우를 우아하게 처리할 수 있을 것입니다.
이 특정 예시의 요점은 완벽을 의미하지 않습니다. 완벽하지가 않으니까요. 그보다 때로는 저수준의 추상화로 낮추는 것이 현실을 더 정직하게 나타낼 수 있다는 가능성을 제시합니다.
결론
정적 타입은 실제로 존재하지 않는다고 인정하는 게 좋을지도 모르겠습니다. 그럼에도 불구하고 코드 베이스 내부에서 정적 타입 시스템은 강력한 도구가 될 수 있습니다. 좋은 타입 시스템은 컴파일러가 코드의 다양한 부분이 내부적으로 일관성이 있는지를 확인하도록 합니다. 올바른 인수로 프로시저를 호출하고 있나요? 인터페이스에 정의된 모든 메서드를 구현했나요? 합 타입으로 정의된 모든 경우를 처리했나요? 객체를 올바르게 초기화했나요?
유용한 타입 시스템은 이런 일을 위한 것이므로 그 한계도 인지해야 합니다. 컴파일러는 코드 베이스 내부 모델이 타당한지를 검사하지만 런타임에서 일어나는 일은 확인할 수 없습니다.
코드 베이스의 어느 한 부분이 다른 부분에 데이터를 전송하는 한 타입 시스템은 여전히 유용한 정상성 검사를 수행할 수 있지만 런타임에서 애플리케이션에 들어오는 (또는 나가는) 데이터의 경우 정상 여부가 불확실합니다. 입력이 어떻게 보여야 하는지 모델링을 시도할 수 있고 그렇게 하는 게 유용할 수 있습니다. 그러나 현실은 모델과 꼭 같지만은 않다는 걸 인정하는 게 중요합니다.
정적 타입의 컴포저블 구문 분석기를 작성할 수 있습니다. 일부는 상당히 우아하지만 좋은 구문 분석기는 입력의 구문 분석이 오류를 일으킬 가능성을 명시적으로 모델링합니다. 입력이 올바른 형식으로 주어지면 결과는 멋지게 캡슐화된 정적 타입 값을 보여 줍니다. 반면 입력이 잘못된 형식이면 하나 이상의 오류 값이 결과로 나타납니다.
아마도 가장 중요한 메시지는 데이터베이스, 다른 웹 서비스, 파일 시스템 등도 입출력을 수반한다는 것입니다. 여러분이 데이터베이스 쿼리를 시작하거나 웹 서비스 요청을 보내는 코드를 작성하더라도 돌아오는 데이터를 절대적으로 신뢰해야만 할까요?
이 신뢰에 관한 질문은 보안 문제를 의미하지 않습니다. 오히려 시스템은 진화하고 오류는 발생합니다. 외부 시스템과 상호작용할 때마다 그들이 여러분의 시스템과 일치하지 않을 위험은 존재하기 마련입니다. 정적 타입은 이를 방지할 수 없습니다.




