이미지 로딩 중...

Rust 입문 가이드 20 메서드(Method) 정의하기 (impl 블록) - 슬라이드 1/10
A

AI Generated

2025. 11. 13. · 3 Views

Rust 입문 가이드 20 메서드(Method) 정의하기 (impl 블록)

Rust에서 구조체와 열거형에 메서드를 정의하는 방법을 배웁니다. impl 블록을 사용하여 데이터와 기능을 하나로 묶는 방법, self 키워드의 다양한 형태, 그리고 연관 함수까지 실무에서 바로 활용할 수 있는 내용을 다룹니다.


목차

  1. 기본 메서드 정의 - impl 블록으로 구조체에 기능 추가하기
  2. self의 세 가지 형태 - 소유권, 불변 참조, 가변 참조
  3. 연관 함수(Associated Functions) - new와 생성자 패턴
  4. 메서드 체이닝 - 빌더 패턴과 유창한 인터페이스
  5. 여러 impl 블록과 제네릭 - 조건부 메서드 구현
  6. pub 키워드와 메서드 가시성 - 캡슐화와 정보 은닉
  7. 메서드와 연관 함수의 차이 - self의 유무로 구분
  8. 구조체와 열거형의 메서드 - 데이터 타입에 기능 추가
  9. 생성자 패턴 모음 - new, default, builder의 실전 활용

1. 기본 메서드 정의 - impl 블록으로 구조체에 기능 추가하기

시작하며

여러분이 게임 캐릭터를 나타내는 구조체를 만들었다고 상상해보세요. 캐릭터의 체력, 공격력, 방어력 같은 데이터는 있지만, 실제로 공격하거나 방어하는 기능은 어떻게 추가하나요?

일반 함수를 만들어서 구조체를 매개변수로 받는 방법도 있지만, 그러면 관련된 함수들이 코드 곳곳에 흩어지게 됩니다. 이런 문제는 객체지향 프로그래밍에서 흔히 발생합니다.

데이터와 그 데이터를 다루는 기능이 분리되면 코드의 응집도가 떨어지고, 유지보수가 어려워집니다. 다른 개발자가 코드를 읽을 때도 어떤 함수가 어떤 구조체와 관련있는지 파악하기 힘들어집니다.

바로 이럴 때 필요한 것이 impl 블록입니다. impl을 사용하면 구조체와 관련된 모든 기능을 한 곳에 모아서 정의할 수 있고, 마치 그 구조체가 "자신만의 기능"을 가진 것처럼 코드를 작성할 수 있습니다.

개요

간단히 말해서, impl 블록은 구조체나 열거형에 메서드를 추가하는 Rust의 방법입니다. 실무에서 복잡한 비즈니스 로직을 다룰 때, 데이터 구조만 정의하는 것으로는 충분하지 않습니다.

예를 들어, 사용자 정보를 담은 구조체가 있다면, 비밀번호 검증, 이메일 형식 확인, 권한 체크 같은 기능들이 필요합니다. 이런 기능들을 impl 블록 안에 메서드로 정의하면, 관련 로직이 한 곳에 모여 코드의 가독성과 유지보수성이 크게 향상됩니다.

기존에는 validate_user_password(user: &User, password: &str) 같은 자유 함수를 만들었다면, 이제는 user.validate_password(password)처럼 더 자연스럽고 직관적인 코드를 작성할 수 있습니다. impl 블록의 핵심 특징은 첫째, 데이터와 기능의 결합(캡슐화), 둘째, 메서드 체이닝을 통한 유창한 인터페이스 구성, 셋째, 네임스페이스를 통한 코드 조직화입니다.

이러한 특징들이 중요한 이유는 대규모 프로젝트에서 코드를 체계적으로 관리하고, 팀원들과 협업할 때 일관된 코드 스타일을 유지할 수 있게 해주기 때문입니다.

코드 예제

struct Rectangle {
    width: u32,
    height: u32,
}

// impl 블록: Rectangle 구조체에 메서드 추가
impl Rectangle {
    // 메서드 정의: 첫 번째 매개변수는 항상 self
    fn area(&self) -> u32 {
        // self를 통해 구조체의 필드에 접근
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        // 다른 사각형이 이 안에 들어갈 수 있는지 확인
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect = Rectangle { width: 30, height: 50 };
    // 메서드 호출: 점(.) 표기법 사용
    println!("면적: {}", rect.area());
}

설명

이것이 하는 일: impl 블록은 특정 타입(여기서는 Rectangle)에 대한 메서드들을 정의하는 영역을 만듭니다. 이 블록 안에 정의된 모든 함수는 그 타입의 "메서드"가 되어, 해당 타입의 인스턴스에서 호출할 수 있게 됩니다.

첫 번째 단계로, impl Rectangle { }이라는 블록을 선언합니다. 이것은 "이제부터 Rectangle 타입을 위한 기능들을 정의하겠다"는 의미입니다.

이렇게 하는 이유는 관련된 기능들을 논리적으로 그룹화하고, Rust 컴파일러에게 이 메서드들이 어떤 타입에 속하는지 알려주기 위함입니다. 그 다음으로, area(&self)라는 메서드를 정의합니다.

여기서 &self는 특별한 의미를 가집니다. self는 이 메서드를 호출한 인스턴스 자체를 의미하며, &는 불변 참조를 의미합니다.

rect.area()를 호출하면, rect가 자동으로 &self 매개변수로 전달됩니다. 내부적으로 self.width와 self.height에 접근하여 면적을 계산하고 반환합니다.

can_hold 메서드는 더 복잡한 예시입니다. &self 외에 다른 매개변수(&Rectangle)를 추가로 받습니다.

이 메서드는 현재 사각형(self)이 다른 사각형(other)을 포함할 수 있는지 검사합니다. self의 너비와 높이를 other의 것과 비교하여 불린 값을 반환합니다.

여러분이 이 코드를 사용하면 깔끔하고 직관적인 API를 만들 수 있습니다. rect.area()는 "사각형 rect의 면적을 구해"라고 읽히므로 코드의 의도가 명확합니다.

또한 관련된 모든 메서드가 impl 블록에 모여있어, Rectangle이 제공하는 기능을 한눈에 파악할 수 있습니다. IDE의 자동완성 기능도 더 잘 작동하여 개발 생산성이 향상됩니다.

실전 팁

💡 메서드 이름은 구조체의 필드 이름과 같을 수 있습니다. 예를 들어 fn width(&self) -> u32 { self.width }처럼 게터 메서드를 만들 수 있으며, 이는 필드를 private으로 만들고 접근을 제어할 때 유용합니다.

💡 여러 개의 impl 블록을 같은 타입에 대해 정의할 수 있습니다. 하지만 가독성을 위해 대부분의 경우 하나의 impl 블록에 모든 메서드를 모아두는 것이 좋습니다. 여러 블록으로 나누는 것은 제네릭 구현을 분리할 때 주로 사용합니다.

💡 메서드 내에서 self의 필드를 직접 수정하려면 &mut self를 사용해야 합니다. 불변 참조인 &self로는 읽기만 가능하므로, 상태를 변경하는 메서드는 항상 가변 참조를 받아야 합니다.

💡 디버깅할 때는 #[derive(Debug)]를 구조체에 추가하고, println!("{:?}", rect)로 출력하면 구조체의 모든 필드를 확인할 수 있습니다. 더 보기 좋은 출력을 원한다면 {:#?}를 사용하세요.

💡 메서드 체이닝을 구현하려면 메서드가 self를 반환하도록 만드세요. 예: fn update(mut self, width: u32) -> Self { self.width = width; self }


2. self의 세 가지 형태 - 소유권, 불변 참조, 가변 참조

시작하며

여러분이 은행 계좌 시스템을 만든다고 생각해보세요. 계좌 잔액을 조회하는 기능, 입금하는 기능, 그리고 계좌를 완전히 닫는 기능이 필요합니다.

이 세 가지 기능은 각각 데이터를 다루는 방식이 다릅니다. 조회는 데이터를 읽기만 하고, 입금은 데이터를 수정하며, 계좌 폐쇄는 데이터 자체를 소비합니다.

Rust에서는 이런 다양한 상황을 소유권 시스템으로 안전하게 처리합니다. 잘못된 self 형태를 사용하면 컴파일 에러가 발생하여, 런타임에 발생할 수 있는 위험한 버그를 미리 방지합니다.

예를 들어, 계좌를 닫은 후에 입금을 시도하는 실수를 컴파일 시점에 잡아낼 수 있습니다. 바로 이럴 때 필요한 것이 self의 세 가지 형태입니다.

&self, &mut self, self를 상황에 맞게 선택하면, 메모리 안전성을 보장하면서도 효율적인 코드를 작성할 수 있습니다.

개요

간단히 말해서, self는 메서드를 호출한 인스턴스를 나타내며, 세 가지 형태로 사용할 수 있습니다: &self(불변 참조), &mut self(가변 참조), self(소유권 이동). 실무에서 API를 설계할 때, 어떤 메서드가 데이터를 변경하는지, 소비하는지를 명확히 표현하는 것이 중요합니다.

&self를 사용하면 "이 메서드는 읽기만 합니다"라는 의도를 전달하고, 여러 곳에서 동시에 호출해도 안전함을 보장합니다. &mut self는 "이 메서드는 상태를 변경합니다"라는 신호이며, self는 "이 메서드 이후에는 이 객체를 사용할 수 없습니다"라는 강력한 메시지를 전달합니다.

예를 들어, 파일을 닫거나, 네트워크 연결을 종료하거나, 빌더 패턴을 완성할 때 self를 사용합니다. 기존 C++에서는 const 키워드로 메서드의 불변성을 표현했다면, Rust에서는 소유권 시스템을 통해 더 강력하고 안전한 보장을 제공합니다.

컴파일러가 자동으로 검증하므로 런타임 오버헤드가 없습니다. self 형태 선택의 핵심 특징은 첫째, 컴파일 타임 안전성(잘못된 사용은 컴파일 에러), 둘째, 명확한 의도 전달(API 사용자가 메서드의 동작을 쉽게 이해), 셋째, 최적화 가능성(불필요한 복사 방지)입니다.

이러한 특징들이 중요한 이유는 대규모 시스템에서 버그를 사전에 방지하고, 성능을 최적화하며, 코드 리뷰 시 의도를 명확히 전달할 수 있기 때문입니다.

코드 예제

struct BankAccount {
    balance: f64,
    account_number: String,
}

impl BankAccount {
    // &self: 불변 참조 - 읽기만 함
    fn check_balance(&self) -> f64 {
        self.balance
    }

    // &mut self: 가변 참조 - 상태를 변경함
    fn deposit(&mut self, amount: f64) {
        self.balance += amount;
    }

    // self: 소유권 이동 - 객체를 소비함
    fn close_account(self) -> String {
        println!("계좌 {} 폐쇄, 최종 잔액: {}", self.account_number, self.balance);
        // 계좌 번호를 반환하고, self는 소비됨
        self.account_number
    }
}

fn main() {
    let mut account = BankAccount {
        balance: 1000.0,
        account_number: String::from("12345")
    };

    println!("잔액: {}", account.check_balance()); // OK
    account.deposit(500.0); // OK: &mut self
    let acc_num = account.close_account(); // account 소유권 이동
    // account.check_balance(); // 컴파일 에러! account는 이미 소비됨
}

설명

이것이 하는 일: 메서드의 self 매개변수 형태를 통해 그 메서드가 인스턴스를 어떻게 다루는지를 타입 시스템으로 명시합니다. 컴파일러는 이 정보를 사용하여 소유권 규칙을 강제하고, 메모리 안전성을 보장합니다.

첫 번째 단계로, check_balance(&self)는 불변 참조를 받습니다. 이것은 메서드가 account를 빌려서 읽기만 한다는 의미입니다.

account의 소유권은 여전히 main 함수에 있으며, 메서드 호출 후에도 account를 계속 사용할 수 있습니다. 여러 개의 불변 참조가 동시에 존재할 수 있으므로, check_balance를 여러 번 연속으로 호출해도 문제없습니다.

이렇게 하는 이유는 읽기 작업은 데이터를 변경하지 않으므로 동시에 여러 곳에서 접근해도 안전하기 때문입니다. 그 다음으로, deposit(&mut self)는 가변 참조를 받습니다.

이것은 메서드가 balance를 수정할 수 있다는 의미입니다. 하지만 가변 참조는 한 번에 하나만 존재할 수 있다는 Rust의 규칙에 따라, deposit을 호출하는 동안 다른 코드가 account에 접근할 수 없습니다.

내부적으로 self.balance += amount를 실행하여 잔액을 변경합니다. account는 반드시 mut 키워드로 선언되어야 하며, 그렇지 않으면 deposit을 호출할 수 없습니다.

세 번째 단계로, close_account(self)는 소유권을 가져옵니다. 이 메서드가 호출되면 account의 소유권이 메서드 내부로 이동하며, 메서드가 종료될 때 self가 드롭됩니다.

이것은 "계좌를 닫으면 더 이상 사용할 수 없다"는 비즈니스 로직을 타입 시스템으로 표현한 것입니다. 메서드 호출 후 account를 사용하려고 하면 컴파일 에러가 발생하여, 닫힌 계좌를 실수로 사용하는 것을 방지합니다.

여러분이 이 패턴을 사용하면 API의 의도가 명확해지고, 잘못된 사용을 컴파일 시점에 방지할 수 있습니다. 예를 들어, 파일 핸들을 다룰 때 close(self)로 정의하면, 닫힌 파일에 쓰기를 시도하는 버그를 원천적으로 차단할 수 있습니다.

또한 빌더 패턴에서 메서드 체이닝을 구현할 때 self를 반환하여 유창한 인터페이스를 만들 수 있습니다. 메모리 관리도 자동으로 처리되어, C++의 스마트 포인터보다 사용이 간단하면서도 안전합니다.

실전 팁

💡 대부분의 경우 &self를 사용하세요. 메서드가 데이터를 읽기만 한다면 불변 참조가 가장 안전하고 효율적입니다. 의심스러울 때는 &self로 시작하고, 필요할 때만 &mut self로 변경하세요.

💡 메서드가 self를 소비하는 경우는 드뭅니다. 주로 빌더 패턴, 리소스 정리(close, shutdown), 타입 변환(into_inner) 등 특별한 경우에만 사용합니다. 일반적인 비즈니스 로직에서는 참조를 사용하는 것이 더 자연스럽습니다.

💡 &mut self 메서드를 연속으로 호출할 때는 주의하세요. Rust는 자동으로 재차용(reborrow)을 처리하지만, 명시적으로 분리하는 것이 때로는 더 명확합니다. 예: account.deposit(100.0); account.deposit(200.0);

💡 self를 반환하는 메서드는 메서드 체이닝을 가능하게 합니다. Builder::new().width(100).height(200).build() 같은 패턴이 가능해지며, 이는 설정이 많은 객체를 생성할 때 매우 유용합니다.

💡 성능이 중요한 경우, self: Box<Self>나 self: Rc<Self> 같은 스마트 포인터를 사용할 수도 있습니다. 하지만 이는 고급 기법이므로, 기본적으로는 참조를 사용하는 것이 좋습니다.


3. 연관 함수(Associated Functions) - new와 생성자 패턴

시작하며

여러분이 게임 캐릭터를 생성하는 코드를 작성한다고 상상해보세요. Character { health: 100, mana: 50, level: 1 }처럼 구조체 리터럴로 만들 수 있지만, 이 방법에는 문제가 있습니다.

초기값을 잘못 설정하거나, 복잡한 초기화 로직이 필요한 경우 코드가 중복되고 실수하기 쉬워집니다. 실제 프로젝트에서는 객체 생성 시 유효성 검증, 기본값 설정, 의존성 주입 같은 복잡한 로직이 필요합니다.

예를 들어, 데이터베이스 연결을 생성할 때 URL 파싱, 커넥션 풀 설정, 권한 확인 등을 수행해야 합니다. 이런 로직을 생성자 함수로 캡슐화하지 않으면, 객체를 만드는 코드가 프로젝트 곳곳에 흩어져 유지보수가 어려워집니다.

바로 이럴 때 필요한 것이 연관 함수입니다. 특히 new 같은 생성자 패턴을 사용하면, 객체 생성의 복잡성을 숨기고 일관된 인터페이스를 제공할 수 있습니다.

개요

간단히 말해서, 연관 함수는 self 매개변수가 없는 impl 블록 내의 함수로, 타입 자체와 연관되어 있지만 특정 인스턴스와는 무관합니다. 실무에서 가장 흔한 연관 함수는 new입니다.

Rust에는 다른 언어의 생성자(constructor) 문법이 없기 때문에, 관례적으로 new라는 이름의 연관 함수를 정의하여 인스턴스를 생성합니다. 예를 들어, String::from(), Vec::new(), HashMap::with_capacity() 모두 연관 함수입니다.

이런 함수들은 객체 생성의 복잡한 로직을 캡슐화하고, 항상 올바른 상태의 객체를 반환하도록 보장합니다. 기존에는 Character { ...

}로 직접 생성했다면, 이제는 Character::new()로 생성할 수 있습니다. new 내부에서 기본값 설정, 유효성 검증, 리소스 할당 등을 처리하므로, 사용자는 복잡한 초기화 로직을 신경 쓰지 않아도 됩니다.

연관 함수의 핵심 특징은 첫째, 네임스페이스 제공(타입 이름으로 함수를 그룹화), 둘째, 생성자 역할(new, default, with_capacity 등), 셋째, 유틸리티 함수(from, parse, builder 등)입니다. 이러한 특징들이 중요한 이유는 코드의 조직화, API의 일관성, 타입 안전성을 동시에 제공하기 때문입니다.

코드 예제

struct User {
    username: String,
    email: String,
    active: bool,
    sign_in_count: u64,
}

impl User {
    // 연관 함수: self가 없음, Self 타입 반환
    fn new(username: String, email: String) -> Self {
        // 복잡한 초기화 로직을 캡슐화
        Self {
            username,
            email,
            active: true, // 기본값 설정
            sign_in_count: 0,
        }
    }

    // 다른 생성 패턴: 이메일 검증 포함
    fn with_validation(username: String, email: String) -> Result<Self, String> {
        if email.contains('@') {
            Ok(Self::new(username, email))
        } else {
            Err(String::from("유효하지 않은 이메일"))
        }
    }
}

fn main() {
    // :: 연산자로 연관 함수 호출
    let user = User::new(
        String::from("john_doe"),
        String::from("john@example.com")
    );

    // Result를 반환하는 생성자
    let user2 = User::with_validation(
        String::from("jane"),
        String::from("jane@example.com")
    ).unwrap();
}

설명

이것이 하는 일: 연관 함수는 특정 타입에 속하지만 인스턴스가 필요 없는 함수를 정의합니다. 주로 객체 생성, 타입 변환, 유틸리티 기능을 제공하며, 타입 네임스페이스를 활용하여 함수들을 논리적으로 그룹화합니다.

첫 번째 단계로, fn new(username: String, email: String) -> Self를 정의합니다. 이 함수는 self 매개변수가 없으므로 연관 함수입니다.

Self는 impl 블록이 적용되는 타입(여기서는 User)의 별칭으로, 타입 이름이 변경되어도 코드를 수정할 필요가 없어 유지보수에 유리합니다. 이렇게 하는 이유는 생성자 로직을 한 곳에 모아 일관성을 보장하고, 사용자가 복잡한 초기화를 신경 쓰지 않도록 하기 위함입니다.

그 다음으로, 함수 내부에서 Self { ... }로 구조체 인스턴스를 생성합니다.

username과 email은 매개변수로 받고, active와 sign_in_count는 합리적인 기본값으로 설정합니다. 이것은 "새 사용자는 활성 상태이며 로그인 횟수는 0"이라는 비즈니스 규칙을 코드로 표현한 것입니다.

모든 User 생성이 이 함수를 거치므로, 규칙을 변경할 때 한 곳만 수정하면 됩니다. with_validation 함수는 더 고급 패턴을 보여줍니다.

Result<Self, String>을 반환하여 생성 실패를 처리할 수 있게 합니다. email.contains('@')로 간단한 유효성 검증을 수행하고, 통과하면 Ok(Self::new(...))로 성공적인 생성을 반환하고, 실패하면 Err(...)로 에러 메시지를 반환합니다.

실무에서는 더 정교한 검증 로직(정규표현식, 데이터베이스 조회 등)을 추가할 수 있습니다. 여러분이 이 패턴을 사용하면 객체 생성을 표준화하고, 항상 올바른 상태의 객체를 만들 수 있습니다.

User::new()는 모든 Rust 개발자가 이해하는 관례이므로, 팀 협업 시 커뮤니케이션이 원활해집니다. 또한 나중에 생성 로직을 변경할 때(예: 로깅 추가, 초기값 변경) 사용하는 코드를 수정할 필요가 없습니다.

빌더 패턴, 팩토리 패턴 같은 고급 디자인 패턴도 연관 함수를 기반으로 구현됩니다.

실전 팁

💡 new는 Rust의 관례일 뿐 특별한 문법이 아닙니다. 실패할 수 있는 생성자는 new 대신 try_new나 with_* 같은 이름을 사용하여 의도를 명확히 하세요. 예: File::open()은 실패할 수 있으므로 Result를 반환합니다.

💡 여러 생성 방식이 필요하면 여러 연관 함수를 만드세요. new() (기본), default() (모든 필드 기본값), from_json() (JSON에서 생성), builder() (빌더 패턴 시작) 등으로 명확히 구분할 수 있습니다.

💡 Default trait를 구현하면 User::default()로 기본값 인스턴스를 만들 수 있습니다. #[derive(Default)]를 사용하거나, impl Default for User { fn default() -> Self { Self::new(...) } }로 직접 구현하세요.

💡 Self를 반환 타입으로 사용하면 코드가 더 유연해집니다. 나중에 타입 이름을 변경해도 impl 블록 내부는 수정할 필요가 없으며, 제네릭과 함께 사용할 때도 편리합니다.

💡 복잡한 객체 생성에는 빌더 패턴을 고려하세요. UserBuilder::new().username("john").email("john@example.com").build() 형태로, 선택적 매개변수가 많을 때 매우 유용합니다.


4. 메서드 체이닝 - 빌더 패턴과 유창한 인터페이스

시작하며

여러분이 HTTP 요청을 보내는 코드를 작성한다고 상상해보세요. URL을 설정하고, 헤더를 추가하고, 타임아웃을 지정하고, 인증 정보를 넣는 등 많은 설정이 필요합니다.

각 설정을 위해 별도의 함수를 호출하고 중간 결과를 변수에 저장하는 방식은 장황하고 읽기 어렵습니다. 실무에서는 설정이 많은 객체를 다룰 때가 많습니다.

데이터베이스 쿼리 빌더, UI 컴포넌트 구성, 설정 객체 생성 등이 대표적인 예입니다. 각 단계마다 임시 변수를 만들거나 여러 줄에 걸쳐 설정하면, 코드가 지저분해지고 실수하기 쉬워집니다.

특히 불변성을 유지하면서 여러 설정을 적용하는 것은 더욱 복잡합니다. 바로 이럴 때 필요한 것이 메서드 체이닝입니다.

각 메서드가 self를 반환하도록 설계하면, builder.method1().method2().method3()처럼 메서드 호출을 연결할 수 있어 코드가 간결하고 읽기 쉬워집니다.

개요

간단히 말해서, 메서드 체이닝은 메서드가 self를 반환하여 여러 메서드 호출을 한 줄로 연결하는 패턴입니다. 실무에서 빌더 패턴을 구현할 때 메서드 체이닝이 핵심입니다.

예를 들어, 복잡한 SQL 쿼리를 만들 때 query().select("*").from("users").where_clause("age > 18").limit(10).execute() 같은 방식으로 작성하면, 쿼리의 구조가 한눈에 들어옵니다. Rust의 표준 라이브러리도 이 패턴을 광범위하게 사용합니다.

Iterator의 map().filter().collect()가 대표적인 예입니다. 기존에는 각 메서드를 호출한 후 결과를 변수에 저장하고 다음 메서드에 전달했다면, 이제는 점(.) 연산자로 계속 연결할 수 있습니다.

이것은 코드의 가독성을 크게 향상시키고, 중간 변수를 제거하여 실수를 줄입니다. 메서드 체이닝의 핵심 특징은 첫째, 유창한 인터페이스(fluent interface) 제공으로 읽기 쉬운 코드 작성, 둘째, 불변 패턴 지원(각 메서드가 새 인스턴스 반환 가능), 셋째, 타입 안전성(컴파일러가 메서드 순서와 타입 검증)입니다.

이러한 특징들이 중요한 이유는 복잡한 API를 직관적으로 만들고, 실수를 줄이며, DSL(Domain Specific Language) 같은 표현력 높은 코드를 작성할 수 있기 때문입니다.

코드 예제

struct HttpRequest {
    url: String,
    method: String,
    headers: Vec<(String, String)>,
    timeout: u64,
}

impl HttpRequest {
    // 기본 생성자
    fn new(url: String) -> Self {
        Self {
            url,
            method: String::from("GET"),
            headers: Vec::new(),
            timeout: 30,
        }
    }

    // 메서드 체이닝을 위해 self를 반환
    fn method(mut self, method: String) -> Self {
        self.method = method;
        self // self를 반환하여 체이닝 가능
    }

    fn header(mut self, key: String, value: String) -> Self {
        self.headers.push((key, value));
        self
    }

    fn timeout(mut self, seconds: u64) -> Self {
        self.timeout = seconds;
        self
    }

    // 최종 실행 메서드
    fn send(&self) {
        println!("요청: {} {} (타임아웃: {}초)", self.method, self.url, self.timeout);
    }
}

fn main() {
    // 메서드 체이닝으로 간결한 코드 작성
    let request = HttpRequest::new(String::from("https://api.example.com"))
        .method(String::from("POST"))
        .header(String::from("Content-Type"), String::from("application/json"))
        .header(String::from("Authorization"), String::from("Bearer token123"))
        .timeout(60);

    request.send();
}

설명

이것이 하는 일: 메서드 체이닝은 객체 설정을 연속된 메서드 호출로 표현하여, 코드를 선언적이고 읽기 쉽게 만듭니다. 각 메서드는 객체를 수정한 후 그 객체를 반환하므로, 다음 메서드가 계속 호출될 수 있습니다.

첫 번째 단계로, fn method(mut self, method: String) -> Self를 정의합니다. 여기서 핵심은 세 가지입니다.

mut self는 소유권을 가져오면서 수정 가능하게 만들고, 내부에서 self.method = method로 필드를 변경한 후, self를 반환합니다. 이렇게 하는 이유는 호출자가 반환된 self로 다음 메서드를 호출할 수 있게 하기 위함입니다.

mut self를 사용하는 이유는 불필요한 복사를 피하고, 소유권을 명확히 하기 위함입니다. 그 다음으로, header 메서드는 여러 번 호출될 수 있도록 설계되었습니다.

self.headers.push(...)로 헤더를 추가한 후 self를 반환하므로, .header(...).header(...) 형태로 여러 헤더를 추가할 수 있습니다. Vec을 사용하여 가변 개수의 헤더를 저장하며, 각 헤더는 (키, 값) 튜플로 표현됩니다.

이 패턴은 선택적이고 반복 가능한 설정을 다룰 때 매우 유용합니다. timeout 메서드도 같은 패턴을 따르며, 최종적으로 send 메서드는 &self를 받습니다.

이것은 체이닝의 끝을 의미합니다. send는 실제 작업을 수행하는 "종결 메서드"이므로, 소유권을 가져오지 않고 참조만 받습니다.

이렇게 하면 같은 request로 여러 번 send를 호출할 수 있습니다. 여러분이 이 패턴을 사용하면 복잡한 객체 구성을 직관적으로 표현할 수 있습니다.

HttpRequest::new(url).method("POST").header(...).timeout(60) 같은 코드는 마치 자연어처럼 읽히며, 각 단계가 명확하게 드러납니다. 중간 변수가 없으므로 변수 이름을 고민할 필요도 없고, 실수로 잘못된 변수를 사용할 위험도 없습니다.

또한 타입 상태 패턴(typestate pattern)과 결합하면, 컴파일 타임에 메서드 호출 순서를 강제할 수도 있습니다(예: build()를 호출하기 전에 반드시 set_url()을 호출하도록).

실전 팁

💡 체이닝 메서드는 mut self를 받고 self를 반환하는 것이 일반적이지만, &mut self를 받고 &mut Self를 반환하는 방식도 있습니다. 전자는 소유권을 이동시키고, 후자는 참조를 유지합니다. 사용 상황에 맞게 선택하세요.

💡 빌더의 마지막 메서드(보통 build()나 send())는 소유권을 가져오거나 참조만 받습니다. 만약 build(self) -> Result<FinalType>처럼 정의하면, 빌더를 재사용할 수 없지만 실수로 여러 번 빌드하는 것을 방지할 수 있습니다.

💡 복잡한 빌더는 별도의 Builder 구조체를 만드세요. HttpRequestBuilder::new().method(...).build() -> HttpRequest 형태로, 빌더와 최종 객체를 분리하면 각각의 역할이 명확해집니다.

💡 필수 설정과 선택적 설정을 구분하세요. 필수 설정은 new()의 매개변수로 받고, 선택적 설정은 체이닝 메서드로 제공하면, API가 오용되기 어렵습니다.

💡 타입 상태 패턴을 사용하면 컴파일 타임 검증을 강화할 수 있습니다. RequestBuilder<NoUrl>, RequestBuilder<HasUrl> 같은 제네릭 타입으로 상태를 표현하여, URL 없이 빌드하는 것을 컴파일 에러로 만들 수 있습니다.


5. 여러 impl 블록과 제네릭 - 조건부 메서드 구현

시작하며

여러분이 옵셔널 값을 담는 컨테이너를 만든다고 상상해보세요. 모든 타입의 값을 담을 수 있어야 하지만, 특정 타입(예: 숫자)에만 제공되는 특별한 메서드(예: sum)가 필요합니다.

모든 타입에 sum을 제공하면 타입 에러가 발생하고, 타입마다 별도의 구조체를 만들면 코드 중복이 심해집니다. 실무에서는 제네릭 타입을 다루면서도 특정 조건에서만 메서드를 제공해야 하는 경우가 많습니다.

예를 들어, Vec<T>는 모든 타입에 push를 제공하지만, Vec<u8>에만 write_all 같은 바이트 특화 메서드를 제공합니다. 또는 Result<T, E>는 map을 제공하지만, E가 From<OtherError>를 구현할 때만 map_err가 유용합니다.

바로 이럴 때 필요한 것이 조건부 impl 블록입니다. 같은 타입에 대해 여러 impl 블록을 정의하되, 각 블록에 다른 trait bound를 적용하면, 타입 매개변수의 특성에 따라 다른 메서드를 제공할 수 있습니다.

개요

간단히 말해서, 하나의 타입에 대해 여러 impl 블록을 작성할 수 있으며, 제네릭과 trait bound를 사용하여 조건부로 메서드를 제공할 수 있습니다. 실무에서 제네릭 라이브러리를 작성할 때 이 패턴이 필수적입니다.

예를 들어, 커스텀 컬렉션을 만들 때 모든 타입에 공통 메서드(insert, remove)를 제공하면서, Clone을 구현한 타입에만 duplicate를 제공하고, Ord를 구현한 타입에만 sort를 제공할 수 있습니다. 이렇게 하면 API가 타입 안전하면서도 유연해집니다.

기존에는 모든 메서드를 하나의 impl 블록에 넣고 runtime 체크를 했다면, Rust에서는 컴파일 타임에 타입 시스템으로 검증합니다. Container<String>은 sort를 가지지만, Container<Vec<u8>>은 sort를 가지지 않는 식으로, 타입에 따라 사용 가능한 메서드가 자동으로 결정됩니다.

조건부 impl의 핵심 특징은 첫째, 타입 안전성(잘못된 타입에 메서드 호출 시 컴파일 에러), 둘째, 코드 재사용(제네릭으로 공통 로직 공유), 셋째, 유연성(trait bound로 정밀한 제어)입니다. 이러한 특징들이 중요한 이유는 라이브러리 설계 시 사용자에게 명확한 API를 제공하고, 런타임 에러를 컴파일 타임으로 옮기며, 제로 비용 추상화를 달성할 수 있기 때문입니다.

코드 예제

use std::fmt::Display;

// 제네릭 구조체
struct Container<T> {
    value: T,
}

// 모든 타입 T에 대한 impl 블록
impl<T> Container<T> {
    fn new(value: T) -> Self {
        Self { value }
    }

    fn get(&self) -> &T {
        &self.value
    }
}

// Display trait를 구현한 T에만 제공되는 메서드
impl<T: Display> Container<T> {
    fn print(&self) {
        println!("값: {}", self.value);
    }
}

// Clone을 구현한 T에만 제공되는 메서드
impl<T: Clone> Container<T> {
    fn duplicate(&self) -> Container<T> {
        Container {
            value: self.value.clone(),
        }
    }
}

fn main() {
    let num_container = Container::new(42);
    num_container.print(); // i32는 Display 구현 → 가능
    let dup = num_container.duplicate(); // i32는 Clone 구현 → 가능

    let vec_container = Container::new(vec![1, 2, 3]);
    // vec_container.print(); // Vec는 Display 미구현 → 컴파일 에러
    let dup2 = vec_container.duplicate(); // Vec는 Clone 구현 → 가능
}

설명

이것이 하는 일: 제네릭 타입에 대해 기본 메서드를 제공하면서, 추가 trait bound를 만족하는 경우에만 특화된 메서드를 제공합니다. 컴파일러는 타입 매개변수가 어떤 trait를 구현하는지 확인하여, 사용 가능한 메서드를 결정합니다.

첫 번째 단계로, impl<T> Container<T> { }를 정의합니다. 이것은 T가 어떤 타입이든 상관없이 new와 get 메서드를 제공한다는 의미입니다.

<T>는 타입 매개변수이며, Container<i32>, Container<String>, Container<Vec<u8>> 등 모든 경우에 이 메서드들을 사용할 수 있습니다. 이렇게 하는 이유는 기본적인 기능은 모든 타입에 공통적으로 제공하기 위함입니다.

그 다음으로, impl<T: Display> Container<T> { }는 "T가 Display trait를 구현할 때만"이라는 조건을 추가합니다. print 메서드 내부에서 self.value를 포맷팅하려면 Display가 필요하므로, 이 trait bound는 타입 안전성을 보장합니다.

i32, String, f64 같은 타입은 Display를 구현하므로 print를 사용할 수 있지만, Vec<T>는 Display를 구현하지 않으므로 컴파일 에러가 발생합니다. 이것은 런타임 에러가 아닌 컴파일 타임 에러이므로, 배포 전에 문제를 발견할 수 있습니다.

세 번째 블록인 impl<T: Clone> Container<T> { }는 Clone을 구현한 타입에만 duplicate를 제공합니다. self.value.clone()을 호출하려면 T가 Clone을 구현해야 하므로, 이 제약은 필수적입니다.

대부분의 기본 타입과 표준 컬렉션은 Clone을 구현하므로, 실무에서 널리 사용할 수 있습니다. 여러분이 이 패턴을 사용하면 유연하고 타입 안전한 제네릭 API를 설계할 수 있습니다.

사용자는 자신의 타입이 어떤 메서드를 사용할 수 있는지 IDE의 자동완성으로 즉시 알 수 있으며, 잘못된 사용은 컴파일러가 친절한 에러 메시지로 알려줍니다. 예를 들어, "print를 호출하려면 Display를 구현하세요"라는 메시지를 받습니다.

또한 여러 trait bound를 조합하여(예: impl<T: Clone + Display + Ord>) 더욱 정밀한 API를 만들 수 있으며, 이는 Rust의 제로 비용 추상화 철학과 완벽히 일치합니다.

실전 팁

💡 여러 impl 블록을 사용할 때는 논리적으로 관련된 메서드끼리 그룹화하세요. "모든 타입용", "Display 구현 타입용", "숫자 타입용" 처럼 명확한 기준으로 나누면 코드를 이해하기 쉽습니다.

💡 trait bound를 과도하게 사용하지 마세요. Debug, Clone, Display 같은 일반적인 trait는 괜찮지만, 너무 많은 trait를 요구하면 사용자가 불편해집니다. 필요한 최소한의 trait만 요구하세요.

💡 where 절을 사용하면 복잡한 trait bound를 더 읽기 쉽게 작성할 수 있습니다. impl<T> Container<T> where T: Clone + Display + Debug { } 형태로, bound가 많을 때 유용합니다.

💡 제네릭 라이브러리를 작성할 때는 문서에 어떤 trait bound가 필요한지 명확히 기재하세요. rustdoc은 자동으로 trait bound를 표시하지만, 왜 그 trait가 필요한지 설명을 추가하면 사용자가 이해하기 쉽습니다.

💡 조건부 trait 구현(blanket implementation)과 조합하면 더욱 강력합니다. impl<T: Display> ToString for Container<T> { } 처럼, 다른 trait를 조건부로 구현할 수 있으며, 이는 Rust 표준 라이브러리에서 광범위하게 사용되는 고급 패턴입니다.


6. pub 키워드와 메서드 가시성 - 캡슐화와 정보 은닉

시작하며

여러분이 라이브러리를 만들고 있다고 상상해보세요. 내부적으로 사용하는 헬퍼 메서드들이 있고, 사용자에게 공개할 공개 API도 있습니다.

모든 메서드를 공개하면 사용자가 내부 구현에 의존하게 되어, 나중에 리팩토링할 때 하위 호환성이 깨집니다. 반대로 필요한 메서드를 비공개로 만들면 라이브러리를 사용할 수 없게 됩니다.

실무에서는 API 설계 시 무엇을 공개하고 무엇을 숨길지 신중히 결정해야 합니다. 예를 들어, 데이터베이스 커넥션 풀을 구현할 때, get_connection() 같은 공개 메서드는 필요하지만, cleanup_stale_connections() 같은 내부 메서드는 사용자가 직접 호출하면 안 됩니다.

잘못된 가시성 설정은 보안 취약점, 버그, API 불안정성을 야기합니다. 바로 이럴 때 필요한 것이 pub 키워드를 통한 가시성 제어입니다.

Rust는 기본적으로 모든 것이 비공개이므로, 의도적으로 공개할 것만 pub으로 표시하여 안전한 API를 설계할 수 있습니다.

개요

간단히 말해서, pub 키워드는 구조체의 필드와 메서드를 모듈 외부에서 접근 가능하게 만드는 가시성 제어자입니다. 실무에서 라이브러리를 만들 때, public API는 사용자와의 계약입니다.

한번 공개하면 하위 호환성을 유지해야 하므로, 신중하게 선택해야 합니다. 예를 들어, HTTP 클라이언트 라이브러리를 만든다면, Client::get(), Client::post() 같은 메서드는 pub으로 공개하지만, 내부 커넥션 관리 메서드는 비공개로 유지합니다.

이렇게 하면 내부 구현을 자유롭게 변경하면서도 사용자 코드는 영향받지 않습니다. 기존 Java나 C++에서는 public, private, protected 같은 키워드를 사용했다면, Rust에서는 pub과 기본 비공개(private)만으로 대부분을 처리합니다.

pub(crate), pub(super) 같은 세밀한 제어도 가능합니다. 가시성 제어의 핵심 특징은 첫째, 캡슐화(내부 구현 숨기기), 둘째, API 안정성(공개 API와 내부 구현 분리), 셋째, 네임스페이스 관리(모듈 시스템과 통합)입니다.

이러한 특징들이 중요한 이유는 유지보수 가능한 코드베이스를 만들고, 사용자에게 명확한 계약을 제공하며, 내부 리팩토링의 자유를 보장하기 때문입니다.

코드 예제

// lib.rs 또는 별도 모듈
pub struct BankAccount {
    pub account_number: String, // 공개 필드
    balance: f64, // 비공개 필드 (외부 접근 불가)
}

impl BankAccount {
    // 공개 생성자
    pub fn new(account_number: String, initial_balance: f64) -> Self {
        Self {
            account_number,
            balance: initial_balance,
        }
    }

    // 공개 메서드: 외부에서 호출 가능
    pub fn deposit(&mut self, amount: f64) {
        if self.validate_amount(amount) {
            self.balance += amount;
        }
    }

    // 공개 메서드
    pub fn balance(&self) -> f64 {
        self.balance
    }

    // 비공개 메서드: 모듈 내부에서만 사용
    fn validate_amount(&self, amount: f64) -> bool {
        amount > 0.0 && amount < 1_000_000.0
    }

    // 비공개 메서드: 내부 로직
    fn log_transaction(&self, msg: &str) {
        println!("[INTERNAL] {}", msg);
    }
}

fn main() {
    let mut account = BankAccount::new(String::from("12345"), 1000.0);
    account.deposit(500.0); // OK: 공개 메서드
    println!("잔액: {}", account.balance()); // OK: 공개 메서드
    // println!("{}", account.balance); // 컴파일 에러: balance 필드는 비공개
    // account.validate_amount(100.0); // 컴파일 에러: 비공개 메서드
}

설명

이것이 하는 일: pub 키워드는 모듈의 경계를 넘어 접근할 수 있는지를 제어합니다. 구조체의 필드와 impl 블록의 메서드에 개별적으로 적용할 수 있으며, 이를 통해 공개 인터페이스와 내부 구현을 명확히 분리합니다.

첫 번째 단계로, pub struct BankAccount를 선언합니다. 이것은 구조체 자체를 공개하여, 다른 모듈에서 BankAccount라는 타입을 사용할 수 있게 합니다.

하지만 구조체를 공개했다고 해서 모든 필드가 공개되는 것은 아닙니다. account_number는 pub이므로 외부에서 읽을 수 있지만, balance는 pub이 없으므로 비공개입니다.

이렇게 하는 이유는 계좌 번호는 읽어도 괜찮지만, 잔액은 메서드를 통해서만 조회하고 수정하도록 강제하기 위함입니다. 그 다음으로, pub fn new()와 pub fn deposit()은 공개 메서드입니다.

이것들은 라이브러리의 공개 API로, 사용자 문서에 나타나고, 외부 코드에서 호출할 수 있습니다. new는 생성자 역할을 하며, deposit은 잔액을 변경하는 안전한 방법을 제공합니다.

내부적으로 deposit은 validate_amount를 호출하여 입금액을 검증합니다. 이 검증 로직은 구현 세부사항이므로 비공개 메서드로 만들었습니다.

validate_amount와 log_transaction은 pub이 없으므로 비공개 메서드입니다. 이것들은 BankAccount의 내부 로직을 구성하지만, 사용자가 직접 호출할 필요가 없습니다.

만약 공개했다면, 사용자가 validate_amount를 직접 호출하여 내부 검증 로직에 의존할 수 있고, 나중에 검증 방식을 변경하면 사용자 코드가 깨질 수 있습니다. 비공개로 유지하면 자유롭게 리팩토링할 수 있습니다.

여러분이 이 패턴을 사용하면 안전하고 유지보수 가능한 API를 설계할 수 있습니다. 사용자는 문서화된 공개 메서드만 보므로, 무엇을 사용해야 할지 명확합니다.

내부 메서드를 실수로 호출하면 컴파일 에러가 발생하여, 잘못된 사용을 방지합니다. 라이브러리 개발자는 비공개 메서드를 마음껏 변경할 수 있어, 리팩토링이 자유롭고 코드 품질을 지속적으로 개선할 수 있습니다.

또한 pub(crate)를 사용하면 같은 크레이트 내에서만 공개하여, 중간 수준의 가시성을 제공할 수도 있습니다.

실전 팁

💡 기본적으로 모든 것을 비공개로 시작하세요. 사용자가 필요로 하는 것이 명확해질 때만 pub을 추가하는 방식이 안전합니다. 한번 공개하면 제거하기 어렵지만, 비공개를 공개로 바꾸는 것은 쉽습니다.

💡 필드는 가능한 한 비공개로 유지하고, getter/setter 메서드를 제공하세요. 이렇게 하면 나중에 검증 로직, 로깅, 캐싱 등을 추가할 수 있습니다. 예: pub fn balance(&self) -> f64 { self.balance }

💡 pub(crate)를 사용하면 같은 크레이트(라이브러리) 내의 다른 모듈에서만 접근 가능합니다. 내부 모듈 간 통신에는 유용하지만 외부에는 숨기고 싶을 때 사용하세요.

💡 새 버전을 릴리스할 때 비공개 항목 변경은 major 버전 업데이트가 아니지만, 공개 API 변경은 semver에 따라 처리해야 합니다. 이것이 pub을 신중히 사용해야 하는 이유입니다.

💡 내부 테스트를 위해 비공개 메서드를 테스트하려면, 같은 모듈 내에 #[cfg(test)] mod tests { }를 만드세요. 테스트 모듈은 같은 모듈의 비공개 항목에 접근할 수 있습니다.


7. 메서드와 연관 함수의 차이 - self의 유무로 구분

시작하며

여러분이 Rust 코드를 읽다가 String::from()과 my_string.len()을 보았다고 상상해보세요. 둘 다 String 타입과 관련있지만, 호출 방식이 다릅니다.

from은 String::from()처럼 타입 이름으로 호출하고, len은 my_string.len()처럼 인스턴스로 호출합니다. 이 차이는 무엇이며, 언제 어떤 것을 사용해야 할까요?

실무에서 코드를 작성하다 보면 "이 기능은 특정 인스턴스에 속하는가, 아니면 타입 전체에 속하는가"를 결정해야 하는 순간이 많습니다. 예를 들어, 설정 파일을 파싱하는 기능은 Config 타입의 연관 함수(Config::from_file())로 만드는 것이 자연스럽지만, 설정값을 조회하는 기능은 인스턴스 메서드(config.get("key"))로 만드는 것이 적절합니다.

바로 이럴 때 필요한 것이 메서드와 연관 함수의 명확한 구분입니다. self 매개변수의 유무로 이 둘을 구분하고, 각각의 용도를 이해하면 더 직관적이고 관용적인(idiomatic) Rust 코드를 작성할 수 있습니다.

개요

간단히 말해서, 메서드는 self 매개변수를 가지며 인스턴스에서 호출되고, 연관 함수는 self가 없으며 타입 이름으로 호출됩니다. 실무에서 이 구분은 API 설계의 명확성에 직접적인 영향을 줍니다.

메서드는 "이 특정 인스턴스로 무엇을 할 것인가"를 표현하고, 연관 함수는 "이 타입과 관련된 일반적인 작업"을 표현합니다. 예를 들어, Vec::new()는 새 벡터를 만드는 연관 함수이고, vec.push(item)은 특정 벡터에 아이템을 추가하는 메서드입니다.

이 구분이 명확하면 코드를 읽는 사람이 즉시 "아, 이것은 새로운 것을 만드는 거구나" 또는 "아, 이것은 기존 것을 수정하는 거구나"를 이해할 수 있습니다. 기존 객체지향 언어에서는 static method(정적 메서드)와 instance method(인스턴스 메서드)로 구분했다면, Rust에서는 연관 함수와 메서드로 표현합니다.

개념은 유사하지만, Rust는 소유권 시스템과 통합되어 더 안전합니다. 이 구분의 핵심 특징은 첫째, 의미의 명확성(생성 vs 조작), 둘째, 호출 문법의 차이(Type::function() vs instance.method()), 셋째, 네임스페이스 역할(타입별로 함수 그룹화)입니다.

이러한 특징들이 중요한 이유는 코드의 의도를 명확히 전달하고, API를 일관되게 설계하며, 타입 시스템의 이점을 최대한 활용할 수 있기 때문입니다.

코드 예제

struct Point {
    x: f64,
    y: f64,
}

impl Point {
    // 연관 함수: self 없음, 타입으로 호출
    fn new(x: f64, y: f64) -> Self {
        Self { x, y }
    }

    // 연관 함수: 원점을 생성
    fn origin() -> Self {
        Self { x: 0.0, y: 0.0 }
    }

    // 연관 함수: 두 점 사이의 거리 계산
    fn distance_between(p1: &Point, p2: &Point) -> f64 {
        let dx = p1.x - p2.x;
        let dy = p1.y - p2.y;
        (dx * dx + dy * dy).sqrt()
    }

    // 메서드: &self 있음, 인스턴스로 호출
    fn distance_from_origin(&self) -> f64 {
        (self.x * self.x + self.y * self.y).sqrt()
    }

    // 메서드: 점을 이동
    fn move_by(&mut self, dx: f64, dy: f64) {
        self.x += dx;
        self.y += dy;
    }
}

fn main() {
    // 연관 함수 호출: Type::function()
    let p1 = Point::new(3.0, 4.0);
    let origin = Point::origin();

    // 메서드 호출: instance.method()
    println!("원점으로부터 거리: {}", p1.distance_from_origin());

    // 연관 함수: 두 인스턴스를 매개변수로
    let dist = Point::distance_between(&p1, &origin);
    println!("두 점 사이 거리: {}", dist);

    let mut p2 = Point::new(1.0, 1.0);
    p2.move_by(2.0, 3.0); // 메서드: 자신을 수정
}

설명

이것이 하는 일: 메서드와 연관 함수를 구분하여, 인스턴스 관련 작업과 타입 수준 작업을 명확히 분리합니다. 컴파일러는 호출 문법(점 표기법 vs 이중 콜론)과 self 매개변수 유무로 어떤 것인지 판단합니다.

첫 번째 단계로, fn new(x: f64, y: f64) -> Self는 연관 함수입니다. self 매개변수가 없으므로 특정 Point 인스턴스와 무관하며, Point::new()처럼 타입 이름으로 호출합니다.

이것은 "새로운 Point를 만들어라"는 의미이며, 생성자 역할을 합니다. 이렇게 하는 이유는 아직 인스턴스가 없는 상태에서 인스턴스를 만드는 작업이므로, self가 있을 수 없기 때문입니다.

origin()도 연관 함수로, 매개변수가 전혀 없지만 Point::origin()으로 호출하여 (0, 0) 위치의 점을 생성합니다. 이것은 "특별한 Point를 만드는 팩토리 메서드" 역할을 하며, 여러 생성 패턴을 제공하는 관용적인 방법입니다.

사용자는 Point::new(0.0, 0.0) 대신 더 의미 있는 Point::origin()을 사용할 수 있습니다. distance_between은 흥미로운 예시입니다.

이것은 연관 함수이지만 두 개의 Point 참조를 매개변수로 받습니다. Point::distance_between(&p1, &p2)로 호출하며, "두 점 사이의 거리"라는 개념이 특정 점에 속하지 않고 Point 타입 전체의 기능이기 때문에 연관 함수로 설계했습니다.

대안으로 p1.distance_to(&p2) 같은 메서드로 만들 수도 있지만, 의미상 대칭적인 작업은 연관 함수가 더 적절합니다. 메서드인 distance_from_origin(&self)은 특정 점의 속성을 계산합니다.

self를 통해 자신의 x, y에 접근하며, p1.distance_from_origin()처럼 인스턴스에서 호출합니다. move_by(&mut self, ...)는 자신을 수정하는 메서드로, 가변 참조를 받아 x, y를 변경합니다.

여러분이 이 구분을 이해하면 더 자연스러운 API를 설계할 수 있습니다. 생성, 파싱, 변환 같은 작업은 연관 함수로, 조회, 수정, 계산 같은 작업은 메서드로 만드세요.

예를 들어, Config::from_file() (연관 함수), config.get("key") (메서드), config.set("key", value) (메서드)가 자연스럽습니다. 또한 타입 이름으로 호출하는 연관 함수는 네임스페이스 역할도 하여, 전역 함수가 난무하는 것을 방지하고 코드를 조직화합니다.

실전 팁

💡 생성자는 항상 연관 함수로 만드세요. new, default, from_, with_ 같은 이름을 사용하여, 다양한 생성 패턴을 제공할 수 있습니다. 예: Point::new(), Point::origin(), Point::from_polar()

💡 유틸리티 함수도 연관 함수로 만들면 네임스페이스를 제공합니다. parse_json(data) 보다 Data::parse_json(data)가 더 명확하며, 여러 타입의 파싱 함수를 구분할 수 있습니다.

💡 메서드는 self의 형태(&self, &mut self, self)를 신중히 선택하세요. 읽기 전용이면 &self, 수정하면 &mut self, 소비하면 self를 사용합니다. 이것은 API의 의도를 명확히 전달합니다.

💡 대칭적인 작업은 연관 함수로 고려하세요. a.compare(b)보다 Type::compare(a, b)가 더 공평해 보이는 경우가 있습니다. 하지만 대부분의 경우 메서드가 더 자연스럽습니다.

💡 Rust 표준 라이브러리의 명명 관례를 따르세요. new (기본 생성자), default (기본값), from (변환), into (소비 변환), as_* (참조 변환), to_* (복사 변환) 등은 Rust 커뮤니티에서 널리 사용되는 패턴입니다.


8. 구조체와 열거형의 메서드 - 데이터 타입에 기능 추가

시작하며

여러분이 상태 머신을 구현한다고 상상해보세요. 연결 상태를 나타내는 enum ConnectionState { Connected, Connecting, Disconnected }가 있습니다.

각 상태에서 할 수 있는 작업이 다르고, 상태 전환 로직도 필요합니다. 이런 로직을 어디에 구현해야 할까요?

match 문을 사용하는 자유 함수를 만들 수도 있지만, 그러면 상태와 관련된 코드가 흩어집니다. 실무에서는 복잡한 비즈니스 로직을 데이터 구조와 함께 관리해야 합니다.

예를 들어, HTTP 요청의 상태(대기, 진행 중, 완료, 실패)를 나타내는 열거형이 있다면, 각 상태에서 타임아웃 확인, 재시도, 취소 같은 작업이 필요합니다. 이런 기능을 열거형과 분리하면 코드가 복잡해지고, 새로운 상태를 추가할 때 여러 곳을 수정해야 합니다.

바로 이럴 때 필요한 것이 열거형에 메서드를 추가하는 것입니다. 구조체뿐만 아니라 열거형에도 impl 블록을 사용할 수 있으며, 이를 통해 데이터와 그 데이터를 다루는 로직을 하나로 묶을 수 있습니다.

개요

간단히 말해서, 구조체와 열거형 모두 impl 블록을 통해 메서드를 가질 수 있으며, 이는 데이터와 로직을 결합하는 Rust의 핵심 패턴입니다. 실무에서 열거형 메서드는 상태 머신, 에러 처리, AST(추상 구문 트리) 같은 패턴에서 필수적입니다.

예를 들어, Result<T, E>는 열거형이지만 map, and_then, unwrap 같은 수많은 메서드를 제공하여 강력한 에러 처리 기능을 제공합니다. Option<T>도 마찬가지로 열거형이지만 is_some, map, unwrap_or 등으로 널 값을 안전하게 다룹니다.

여러분의 커스텀 열거형에도 같은 패턴을 적용할 수 있습니다. 기존에는 switch/case 문으로 열거형을 처리하는 자유 함수를 만들었다면, Rust에서는 열거형 자체에 메서드를 추가하여 더 객체지향적이고 캡슐화된 설계를 할 수 있습니다.

match 표현식은 여전히 사용하지만, impl 블록 내부에 숨겨서 사용자는 깔끔한 메서드만 호출하면 됩니다. 열거형 메서드의 핵심 특징은 첫째, 패턴 매칭과 결합(match로 variant 처리), 둘째, 상태별 로직 캡슐화(각 variant의 동작 정의), 셋째, 타입 안전성(잘못된 상태 접근 방지)입니다.

이러한 특징들이 중요한 이유는 복잡한 상태 로직을 안전하게 관리하고, 코드 중복을 줄이며, 새로운 상태 추가 시 컴파일러가 빠진 부분을 알려주기 때문입니다.

코드 예제

// 메시지 타입을 나타내는 열거형
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(u8, u8, u8),
}

// 열거형에 메서드 추가
impl Message {
    // 연관 함수: 편리한 생성자들
    fn move_to(x: i32, y: i32) -> Self {
        Message::Move { x, y }
    }

    fn write_text(text: &str) -> Self {
        Message::Write(String::from(text))
    }

    // 메서드: 메시지 처리
    fn process(&self) {
        match self {
            Message::Quit => {
                println!("프로그램 종료");
            }
            Message::Move { x, y } => {
                println!("위치 이동: ({}, {})", x, y);
            }
            Message::Write(text) => {
                println!("텍스트 출력: {}", text);
            }
            Message::ChangeColor(r, g, b) => {
                println!("색상 변경: RGB({}, {}, {})", r, g, b);
            }
        }
    }

    // 메서드: 메시지가 움직임인지 확인
    fn is_move(&self) -> bool {
        matches!(self, Message::Move { .. })
    }
}

fn main() {
    // 연관 함수로 열거형 생성
    let msg1 = Message::move_to(10, 20);
    let msg2 = Message::write_text("안녕하세요");
    let msg3 = Message::Quit;

    // 메서드 호출
    msg1.process();
    msg2.process();

    if msg1.is_move() {
        println!("이것은 이동 메시지입니다");
    }
}

설명

이것이 하는 일: 열거형에 메서드를 추가하여 variant별 로직을 캡슐화하고, 데이터와 동작을 하나의 타입으로 결합합니다. 사용자는 내부의 match 로직을 신경 쓰지 않고 메서드만 호출하면 됩니다.

첫 번째 단계로, enum Message { ... }를 정의합니다.

이 열거형은 네 가지 variant를 가지며, 각각 다른 데이터를 저장합니다. Quit는 데이터가 없고, Move는 명명된 필드를, Write는 튜플 형태를, ChangeColor는 세 개의 u8 값을 가집니다.

이렇게 하는 이유는 서로 다른 종류의 메시지를 하나의 타입으로 표현하기 위함입니다. 그 다음으로, impl Message { }를 정의합니다.

구조체 impl과 문법은 동일하지만, 내부 메서드에서 self가 어떤 variant인지 확인하는 로직이 추가됩니다. move_to와 write_text는 연관 함수로, 특정 variant를 생성하는 편리한 팩토리 메서드입니다.

Message::Move { x: 10, y: 20 } 대신 Message::move_to(10, 20)를 사용할 수 있어 더 간결합니다. process(&self) 메서드는 핵심 패턴을 보여줍니다.

match self를 사용하여 현재 인스턴스가 어떤 variant인지 확인하고, 각각에 맞는 동작을 수행합니다. 예를 들어, Move variant면 x, y 값을 추출하여 출력하고, Write variant면 내부 String을 출력합니다.

이것은 "이 메시지를 처리해라"는 추상적인 인터페이스를 제공하며, 호출자는 내부 variant를 몰라도 됩니다. is_move 메서드는 matches!

매크로를 사용하는 편리한 패턴입니다. matches!(self, Message::Move { ..

})는 self가 Move variant인지 불린 값으로 반환합니다. 이런 is_* 메서드는 Option의 is_some(), Result의 is_ok()와 같은 관례이며, 조건 확인에 유용합니다.

여러분이 이 패턴을 사용하면 복잡한 열거형 로직을 깔끔하게 관리할 수 있습니다. 상태 머신을 구현할 때 enum State { Idle, Running, Paused }에 state.start(), state.pause(), state.can_resume() 같은 메서드를 추가하여, 상태 전환 로직을 캡슐화할 수 있습니다.

파서를 만들 때 enum Token { Number(i64), String(String), Symbol(char) }에 token.is_number(), token.as_string() 같은 메서드를 추가하여 타입 안전한 접근을 제공할 수 있습니다. 또한 새로운 variant를 추가할 때, 모든 match 표현식에서 컴파일러가 빠진 부분을 경고해주므로 안전합니다.

실전 팁

💡 열거형 메서드에서 variant의 내부 데이터를 추출하려면 as_* 또는 into_* 메서드를 만드세요. 예: fn as_move(&self) -> Option<(i32, i32)> { if let Message::Move { x, y } = self { Some((*x, *y)) } else { None } }

💡 is_* 메서드를 제공하여 variant 확인을 쉽게 만드세요. msg.is_quit(), msg.is_move() 같은 메서드는 가독성을 크게 향상시키며, matches! 매크로로 간단히 구현할 수 있습니다.

💡 variant별 특화 로직이 복잡하면, 각 variant를 처리하는 private 헬퍼 메서드를 만드세요. process는 match로 분기만 하고, 실제 로직은 process_move, process_write 같은 별도 메서드로 분리하면 코드가 깔끔해집니다.

💡 Option과 Result의 메서드를 참고하세요. map, and_then, unwrap_or_else 같은 함수형 프로그래밍 스타일의 메서드는 열거형을 다루는 강력한 패턴이며, 여러분의 커스텀 열거형에도 적용할 수 있습니다.

💡 trait를 구현하면 더욱 강력해집니다. Display, Debug, PartialEq 같은 표준 trait를 열거형에 구현하면, println!, assert_eq! 같은 기능을 자동으로 사용할 수 있습니다. 대부분은 #[derive(...)]로 자동 구현 가능합니다.


9. 생성자 패턴 모음 - new, default, builder의 실전 활용

시작하며

여러분이 복잡한 설정을 가진 HTTP 클라이언트를 만든다고 상상해보세요. 기본 설정으로 빠르게 시작하고 싶은 사용자도 있고, 타임아웃, 리트라이, 인증 등을 세밀하게 조정하고 싶은 사용자도 있습니다.

하나의 생성자만 제공하면 모든 매개변수를 받아야 하므로 사용이 불편하고, 반대로 생성자가 너무 많으면 어떤 것을 사용해야 할지 혼란스럽습니다. 실무에서는 다양한 사용 사례를 지원하는 유연한 객체 생성이 필요합니다.

예를 들어, 데이터베이스 커넥션 풀을 만들 때, 개발 환경에서는 기본값으로 빠르게 시작하고, 프로덕션에서는 최대 연결 수, 타임아웃, 재연결 정책 등을 정밀하게 설정해야 합니다. 또한 설정 파일이나 환경 변수에서 값을 읽어와 생성하는 경우도 흔합니다.

바로 이럴 때 필요한 것이 다양한 생성자 패턴입니다. new (기본), default (모든 기본값), from_* (변환), builder (빌더 패턴) 같은 관례를 따르면, 사용자가 상황에 맞는 생성 방법을 쉽게 선택할 수 있습니다.

개요

간단히 말해서, Rust의 생성자 패턴은 new, default, from_, with_, builder 같은 연관 함수를 조합하여 다양한 객체 생성 방법을 제공하는 관례입니다. 실무에서 Rust 표준 라이브러리와 인기 크레이트들은 일관된 생성자 명명 규칙을 따릅니다.

Vec::new()는 빈 벡터, Vec::with_capacity(n)은 미리 할당된 벡터, String::from()은 변환, PathBuf::default()는 기본값을 제공합니다. 이런 관례를 따르면 사용자가 문서를 읽지 않아도 직관적으로 사용할 수 있습니다.

예를 들어, 여러분이 HttpClient::new(), HttpClient::with_timeout(30), HttpClient::builder() 같은 API를 제공하면, Rust 개발자라면 즉시 이해할 수 있습니다. 기존 Java나 C++에서는 생성자 오버로딩으로 여러 생성 방법을 제공했다면, Rust에서는 다른 이름의 연관 함수로 명확히 구분합니다.

이것은 이름으로 의도를 표현하므로 더 명확하고, 컴파일 에러 메시지도 이해하기 쉽습니다. 생성자 패턴의 핵심 특징은 첫째, 명확한 의도 전달(이름으로 생성 방법 표현), 둘째, 유연성(다양한 사용 사례 지원), 셋째, 일관성(커뮤니티 관례 따르기)입니다.

이러한 특징들이 중요한 이유는 API 학습 곡선을 낮추고, 실수를 줄이며, 코드 리뷰와 유지보수를 쉽게 만들기 때문입니다.

코드 예제

use std::time::Duration;

struct HttpClient {
    base_url: String,
    timeout: Duration,
    max_retries: u32,
    auth_token: Option<String>,
}

impl HttpClient {
    // 1. new: 가장 기본적인 생성자
    fn new(base_url: String) -> Self {
        Self {
            base_url,
            timeout: Duration::from_secs(30),
            max_retries: 3,
            auth_token: None,
        }
    }

    // 2. with_*: 특정 설정을 강조하는 생성자
    fn with_timeout(base_url: String, timeout: Duration) -> Self {
        Self {
            base_url,
            timeout,
            max_retries: 3,
            auth_token: None,
        }
    }

    // 3. builder: 복잡한 설정을 위한 빌더 시작
    fn builder(base_url: String) -> HttpClientBuilder {
        HttpClientBuilder::new(base_url)
    }
}

// Default trait 구현: Type::default()로 호출 가능
impl Default for HttpClient {
    fn default() -> Self {
        Self::new(String::from("http://localhost:8080"))
    }
}

// 빌더 구조체
struct HttpClientBuilder {
    base_url: String,
    timeout: Duration,
    max_retries: u32,
    auth_token: Option<String>,
}

impl HttpClientBuilder {
    fn new(base_url: String) -> Self {
        Self {
            base_url,
            timeout: Duration::from_secs(30),
            max_retries: 3,
            auth_token: None,
        }
    }

    fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    fn max_retries(mut self, max_retries: u32) -> Self {
        self.max_retries = max_retries;
        self
    }

    fn auth_token(mut self, token: String) -> Self {
        self.auth_token = Some(token);
        self
    }

    fn build(self) -> HttpClient {
        HttpClient {
            base_url: self.base_url,
            timeout: self.timeout,
            max_retries: self.max_retries,
            auth_token: self.auth_token,
        }
    }
}

fn main() {
    // 패턴 1: 기본 생성
    let client1 = HttpClient::new(String::from("https://api.example.com"));

    // 패턴 2: Default trait 사용
    let client2 = HttpClient::default();

    // 패턴 3: 특정 설정 강조
    let client3 = HttpClient::with_timeout(
        String::from("https://api.example.com"),
        Duration::from_secs(60)
    );

    // 패턴 4: 빌더 패턴 - 복잡한 설정
    let client4 = HttpClient::builder(String::from("https://api.example.com"))
        .timeout(Duration::from_secs(120))
        .max_retries(5)
        .auth_token(String::from("secret_token"))
        .build();
}

설명

이것이 하는 일: 여러 생성자 패턴을 제공하여 사용자가 상황에 맞게 객체를 생성할 수 있게 합니다. 간단한 경우는 new, 기본값 사용 시 default, 많은 설정이 필요하면 builder를 사용하는 식으로, API가 사용 사례별로 최적화됩니다.

첫 번째 단계로, fn new(base_url: String) -> Self는 가장 일반적인 생성자입니다. 필수 매개변수(base_url)만 받고 나머지는 합리적인 기본값으로 설정합니다.

이것은 "빠르게 시작하기" 패턴으로, 사용자가 최소한의 정보만 제공하면 작동하는 객체를 얻습니다. 이렇게 하는 이유는 간단한 사용 사례에서는 모든 설정을 신경 쓰지 않아도 되도록 하기 위함입니다.

그 다음으로, impl Default for HttpClient는 표준 Default trait를 구현합니다. 이것은 HttpClient::default()로 호출할 수 있으며, 모든 필드를 기본값으로 채웁니다.

여기서는 localhost URL을 기본값으로 사용했지만, 실무에서는 환경에 따라 달라질 수 있습니다. Default를 구현하면 struct 초기화 시 ..Default::default() 문법을 사용할 수 있고, 제네릭 코드에서 T::default()로 인스턴스를 만들 수 있어 유용합니다.

with_timeout은 "이 특정 설정이 중요한 경우"를 위한 전용 생성자입니다. new와 비슷하지만 timeout을 명시적으로 받습니다.

이런 with_* 패턴은 한두 개의 설정만 기본값과 다른 경우에 유용하며, 이름으로 의도를 명확히 전달합니다. 예를 들어, Vec::with_capacity(n)은 "미리 메모리를 할당한 벡터"임을 즉시 알 수 있습니다.

빌더 패턴은 가장 유연한 방법입니다. HttpClientBuilder라는 별도 구조체를 만들고, 메서드 체이닝으로 설정을 추가한 후 build()로 최종 객체를 생성합니다.

이것은 설정이 많고, 대부분이 선택적인 경우에 이상적입니다. client.builder(url).timeout(...).auth_token(...).build() 같은 코드는 읽기 쉽고, 필요한 설정만 지정할 수 있습니다.

빌더의 각 메서드는 mut self를 받고 self를 반환하여 체이닝을 가능하게 합니다. 여러분이 이런 패턴을 사용하면 API가 사용하기 쉬워지고, 다양한 사용 사례를 우아하게 지원할 수 있습니다.

테스트 코드에서는 default()로 빠르게 인스턴스를 만들고, 프로덕션에서는 builder()로 정밀하게 설정하며, 간단한 스크립트에서는 new()만으로 충분합니다. 또한 이런 관례는 Rust 커뮤니티 전체에서 사용되므로, 다른 라이브러리를 참고하기도 쉽고, 여러분의 코드도 다른 개발자들이 쉽게 이해할 수 있습니다.

실전 팁

💡 new는 실패하지 않아야 합니다. 만약 생성이 실패할 수 있다면 try_new() -> Result<Self, Error>를 사용하세요. 예: File::open()은 실패할 수 있으므로 Result를 반환합니다.

💡 빌더 패턴은 필수 설정과 선택적 설정을 구분하세요. 필수 설정은 builder(required_param)의 매개변수로, 선택적 설정은 체이닝 메서드로 제공하면 API 오용을 방지할 수 있습니다.

💡 derive_builder 크레이트를 사용하면 빌더 패턴을 자동으로 생성할 수 있습니다. #[derive(Builder)]만 추가하면 되므로, 보일러플레이트 코드를 줄일 수 있습니다.

💡 from, from_*, into 메서드는 타입 변환용입니다. From<T> trait를 구현하면 .into()를 자동으로 사용할 수 있어, 여러 타입에서 변환이 가능해집니다.

💡 복잡한 빌더는 타입 상태 패턴을 고려하세요. Builder<NoUrl>, Builder<HasUrl> 같은 제네릭 상태로, 필수 설정을 빠뜨리면 컴파일 에러를 발생시켜 더욱 안전합니다.


#Rust#Method#impl#self#AssociatedFunctions#프로그래밍언어

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.