완벽한 구조체 분해(destructuring)를 찬양하는 이유
Rust 개발자가 구조체 필드를 분해할 때 일부 생략 기호(..)를 배제하고 모든 필드를 명시적으로 나열해야 하는 이유를 설명합니다. 새로운 필드가 추가될 때 컴파일러가 누락을 감지하게 하여 소프트웨어 유지보수성과 안전성을 크게 높일 수 있기 때문입니다.
내가 Rust를 배울 때 가장 답답했던 점 중 하나는 구조체(struct)를 분해(destructuring)할 때 모든 필드를 나열하거나 .. 문법을 사용해야 한다는 것이었다. 이를 설명하기 위해, 마침 이 글을 폭염 속에서 쓰고 있으니 다음과 같은 구조체가 있다고 가정해 보자:
struct WeatherReading {
station_id: String,
recorded_at: DateTime
(참고: 간결함을 위해 생략했지만, recorded_at을 제외한 모든 필드는 뉴타입(newtype)이어야 한다.)
이제 station_id와 recorded_at만 가져오고 싶다면, 왜 다음과 같이 작성해야만 할까?
let WeatherReading { station_id, recorded_at, .. } = weather_reading;
TypeScript라면 다음과 같이 간단히 쓸 수 있다:
const { station_id, recorded_at } = weather_reading;
Haskell에서도 마찬가지다:
-- NamedFieldPuns 확장을 사용할 경우 let WeatherReading { station_id, recorded_at, } = weather_reading
-- 또는 RecordWildCards를 사용할 경우 let WeatherReading { .. } = weather_reading
그렇게 큰 불편함은 아니었지만, 이러한 이유로 나는 구조체 분해 대신 weather_reading.station_id / weather_reading.recorded_at 처럼 점(.) 문법으로 필드에 접근하는 방식을 선호했다.
하지만 지난 몇 달 동안, 나는 소프트웨어 유지보수를 더 안전하게 만들어준다는 이유로 점(.) 문법을 통한 필드 접근보다 구조체 분해를 강하게 선호하게 되었다.
예를 들어, 위험한 날씨를 감지하는 다음 함수를 보자:
fn is_dangerous(weather_reading: &WeatherReading) -> bool { if weather_reading.temperature > 40.0 { // 섭씨(°C)라고 가정. 화씨 104°F 또는 313.15K return true; } if weather_reading.humidity < 5.0 { // 백분율(%)이라고 가정 return true; } if weather_reading.pressure < 960.0 { // hPa이라고 가정. 28.35 inHg return true; } return false; }
이 함수는 꽤 위험하다! 그 이유를 알기 위해, 이제 일부 관측소에 풍속계가 설치되어 풍속을 기록할 수 있다고 가정해 보자. 그래서 WeatherReading 구조체에 새로운 wind_speed 필드를 추가했다:
struct WeatherReading {
station_id: String,
recorded_at: DateTime
다 됐다! 배포해버리자! 아차. is_dangerous 함수를 업데이트하는 것을 잊지 않았는가? 분명 강풍 역시 위험한 상황으로 보고되어야 할 것이다. 하지만 안타깝게도 Rust 컴파일러는 우리에게 이 사실을 경고해주지 않는다.
다행히도, 만약 함수를 다음과 같이 작성했다면 우리는 경고를 받을 수 있었을 것이다:
fn is_dangerous( WeatherReading { station_id: _, recorded_at: _, temperature, humidity, pressure, }: &WeatherReading ) -> bool { if *temperature > 40.0 { // 섭씨(°C)라고 가정. 화씨 104°F 또는 313.15K return true; } if *humidity < 5.0 { // 백분율(%)이라고 가정 return true; } if *pressure < 960.0 { // hPa이라고 가정. 28.35 inHg return true; } return false; }
이렇게 작성했다면 다음과 같은 컴파일 에러를 마주했을 것이다: error[E0027]: pattern does not mention field 'wind_speed' (패턴이 'wind_speed' 필드를 언급하지 않음).
이 기법이 작동하려면 사용하지 않는 필드조차 명시적으로 작성해야 하며, 절대 .. 패턴을 사용하고 싶은 유혹에 넘어가서는 안 된다는 점을 기억하라.
Rust에서는 self를 인수 목록에서 바로 분해할 수 없기 때문에, 만약 is_dangerous가 메서드로 작성되었다면 약간의 추가 작업이 필요하다:
impl WeatherReading { fn is_dangerous(&self) -> bool { let WeatherReading { station_id: _, recorded_at: _, temperature, humidity, pressure, } = self; if *temperature > 40.0 { // 섭씨(°C)라고 가정. 화씨 104°F 또는 313.15K return true; } if *humidity < 5.0 { // 백분율(%)이라고 가정 return true; } if *pressure < 960.0 { // hPa이라고 가정. 28.35 inHg return true; } return false; } }
나는 CRUD 웹 서비스의 여러 계층(데이터 액세스, 비즈니스 로직, API) 간에 From 트레이트를 구현할 때 이 기법이 특히 유용하다는 것을 알게 되었다. 한 계층에 필드를 추가하면 컴파일러가 강제로 그 필드가 다른 계층으로 전파되어야 하는지 여부를 결정하게 만들기 때문이다.
또 다른 장점은, 만약 항상 특정 필드 그룹만 함께 분해되고 나머지는 무시되는 것을 본다면, 이는 그 필드들을 별도의 구조체로 추출해야 한다는 좋은 신호가 될 수 있다는 것이다.
TypeScript를 사용 중이라면 Required 타입을 활용한 트릭이 있다. Haskell의 경우, 놀랍게도 현재는 해결책이 없지만 관련 제안(proposal)은 존재한다.