PHpullh
심층 가이드/Rust/객체지향

RUST · 심층 가이드

Rust 객체지향 완전 정리

구현 상속이 없는 언어에서 struct와 trait, enum을 조합해 캡슐화와 다형성, 불변 객체를 처음부터 다시 만들어 보는 20개 예제로 구성한 안내서입니다.

주제 20개 · 예제 코드 포함 · 최종 수정 2026-09-03 · 작성 pullh 편집팀

Rust에는 클래스가 없습니다. 데이터는 struct에, 동작은 impl 블록에 따로 놓이고, 구현 상속은 아예 제공되지 않습니다. 부모 클래스로 코드를 재사용하던 습관은 두 갈래로 나뉩니다. 공통 동작은 트레이트의 기본 구현으로, 공통 데이터는 필드로 품는 합성으로 갑니다. 가시성도 클래스 단위가 아니라 모듈 단위라서, pub을 붙이지 않은 필드는 같은 모듈 안에서만 보입니다.

기본 클래스 또는 구조체와 생성자와 초기화를 보면 new가 언어 기능이 아니라 그냥 관례상의 연관 함수라는 점이 드러납니다. 인터페이스 또는 trait에서 계약을 분리한 뒤 합성으로 기능 묶기로 상속의 빈자리를 메우고, 다형성에서 제네릭의 정적 디스패치와 dyn Trait의 동적 디스패치를 비교합니다. 상태 표현은 열거형 상태 표현이 맡고, 마지막으로 변경 불가능 업데이트가 소유권과 불변성을 한데 묶습니다.

dyn Trait로 다형성을 만들다 보면 객체 안전성에 걸립니다. 제네릭 타입 매개변수를 받는 메서드나 Self를 반환하는 메서드가 트레이트에 하나라도 있으면 그 트레이트는 트레이트 객체로 만들 수 없습니다. Clone을 상위 트레이트로 요구했다가 컴파일이 막히는 경우가 여기 해당합니다. 해결책은 그런 메서드를 where Self: Sized로 떼어내거나, 애초에 트레이트를 객체용과 제네릭용으로 나누는 것입니다.

01기본 클래스 또는 구조체

User 구조체를 name과 level 두 필드로 선언하고, 구조체 리터럴로 값을 채운 뒤 필드 하나를 출력합니다. 흩어진 데이터를 묶어 이름을 붙이는 첫 단계에서 이 형태를 씁니다. Rust에는 클래스가 없어서 데이터는 struct에, 동작은 분리된 impl 블록에 각각 놓입니다.

Rust code

// 기본 클래스 또는 구조체
struct User { name: String, level: i32 }
fn main() {
    let user = User { name: "Ada".to_string(), level: 1 };
    println!("{}", user.name);
}
알아두면 좋은 점

러스트는 데이터를 struct에, 동작을 별도 impl 블록에 두므로 한 타입에 여러 impl을 나눠 적어도 됩니다.

자주 하는 실수

name: String은 값을 소유하므로 User를 함수에 값으로 넘기면 문자열까지 함께 이동해, 원래 변수를 다시 쓸 수 없게 됩니다.

02생성자와 초기화

생성자 함수를 거치지 않고 구조체 리터럴로 Profile을 만든 다음 {:?}로 통째로 찍습니다. 타입을 정의한 모듈 안에서 값을 만들 때는 이 방식이 가장 짧습니다. 다만 필드가 비공개인 채로 모듈 밖에 노출되면 리터럴 초기화가 막히므로, new 같은 연관 함수를 함께 두게 됩니다.

Rust code

// 생성자와 초기화
#[derive(Debug, Clone)]
struct Profile { name: String, tags: Vec<String> }
fn main() {
    let profile = Profile { name: "Ada".to_string(), tags: vec!["rust".to_string()] };
    println!("{:?}", profile);
}
알아두면 좋은 점

필드를 전부 채우는 구조체 리터럴은 타입을 정의한 모듈 안에서 가장 짧은 생성 방법이고, 빠뜨린 필드가 있으면 컴파일러가 이름을 짚어 줍니다.

자주 하는 실수

필드가 비공개인 채로 모듈 밖에 노출되면 리터럴 초기화가 막히므로, 공개 타입에는 new 같은 연관 함수를 함께 두어야 합니다.

03인스턴스 메서드

제목과 달리 메서드 정의는 없고 user.name처럼 필드를 직접 읽기만 합니다. 동작을 타입에 붙이려면 impl User 블록 안에 fn ...(&self)를 선언해야 합니다. 첫 인자를 &self로 두느냐 self로 두느냐가 호출 뒤에 원래 값을 계속 쓸 수 있는지를 결정합니다.

Rust code

// 인스턴스 메서드
struct User { name: String, level: i32 }
fn main() {
    let user = User { name: "Ada".to_string(), level: 3 };
    println!("{}", user.name);
}
알아두면 좋은 점

메서드를 붙일 때 첫 인자를 &self로 두면 호출 뒤에도 원래 값을 계속 쓸 수 있고, self로 두면 그 호출에서 값이 소비됩니다.

자주 하는 실수

게터를 -> String으로 만들면 부를 때마다 문자열을 복제하게 되므로, 읽기만 하는 접근자는 &str를 돌려주어야 합니다.

04정적 메서드

연관 함수 대신 리터럴로 Profile을 만들고 derive가 붙은 Debug 형식으로 출력합니다. 인스턴스 없이 부르는 함수가 필요하면 impl 안에 self를 받지 않는 함수를 두고 Profile::new()처럼 타입 이름으로 호출합니다. Clone도 파생되어 있지만, 복제하면 Vec<String> 안의 문자열까지 새로 할당됩니다.

Rust code

// 정적 메서드
#[derive(Debug, Clone)]
struct Profile { name: String, tags: Vec<String> }
fn main() {
    let profile = Profile { name: "Ada".to_string(), tags: vec!["rust".to_string()] };
    println!("{:?}", profile);
}
알아두면 좋은 점

self를 받지 않는 함수를 impl 안에 두면 Profile::new()처럼 타입 이름으로 부르는 연관 함수가 됩니다.

자주 하는 실수

derive(Clone)으로 복제하면 Vec<String> 안의 문자열까지 전부 새로 할당되므로, 습관적인 clone()이 만만찮은 비용이 됩니다.

05상속 또는 확장

코드에는 필드 두 개짜리 User 하나만 있고 부모-자식 관계가 전혀 나오지 않습니다. Rust는 구조체 상속을 제공하지 않으므로, 기존 타입을 확장할 때는 필드로 품는 합성이나 trait의 기본 구현을 씁니다. trait으로 묶으면 동작은 공유되지만 필드는 물려받지 못한다는 차이가 있습니다.

Rust code

// 상속 또는 확장
struct User { name: String, level: i32 }
fn main() {
    let user = User { name: "Ada".to_string(), level: 5 };
    println!("{}", user.name);
}
알아두면 좋은 점

구조체 상속이 없으므로 기존 타입을 확장할 때는 필드로 품는 합성을 쓰거나, 공통 동작을 트레이트의 기본 구현으로 내려 둡니다.

자주 하는 실수

트레이트로 묶으면 동작은 공유되지만 필드는 물려받지 못하므로, 공통 데이터가 필요하면 접근용 메서드를 트레이트에 함께 선언해야 합니다.

06인터페이스 또는 trait

직접 작성한 trait은 없고 #[derive(Debug, Clone)]이 표준 trait 구현을 자동으로 만들어 냅니다. 값을 로그로 찍거나 복제해야 할 때 이 한 줄이면 충분합니다. derive는 컴파일러가 impl Debug for Profile을 대신 써 주는 것이라, 필드 중 하나라도 해당 trait을 구현하지 않으면 컴파일이 막힙니다.

Rust code

// 인터페이스 또는 trait
#[derive(Debug, Clone)]
struct Profile { name: String, tags: Vec<String> }
fn main() {
    let profile = Profile { name: "Ada".to_string(), tags: vec!["rust".to_string()] };
    println!("{:?}", profile);
}
알아두면 좋은 점

derive는 컴파일러가 impl Debug for Profile을 대신 써 주는 것이라, 필드가 늘어나도 출력 코드를 손볼 필요가 없습니다.

자주 하는 실수

필드 중 하나라도 해당 트레이트를 구현하지 않으면 derive가 컴파일 오류를 내므로, 외부 타입을 필드로 품을 때는 지원 여부를 먼저 확인해야 합니다.

07캡슐화

User의 두 필드에 pub이 붙어 있지 않아, 같은 모듈 안에서만 user.name에 닿을 수 있습니다. 내부 표현을 감추고 싶을 때 이 기본 상태를 그대로 두면 됩니다. Rust의 가시성은 타입이 아니라 모듈 단위로 정해지므로, 같은 모듈이라면 다른 타입의 비공개 필드도 읽힙니다.

Rust code

// 캡슐화
struct User { name: String, level: i32 }
fn main() {
    let user = User { name: "Ada".to_string(), level: 7 };
    println!("{}", user.name);
}
알아두면 좋은 점

러스트의 가시성은 타입이 아니라 모듈 단위라, 같은 모듈 안에서는 다른 타입의 비공개 필드도 그대로 읽을 수 있습니다.

자주 하는 실수

필드를 편의로 pub으로 열어 두면 내부 표현이 곧 공개 API가 되어, 나중에 표현을 바꿀 때 호출부를 전부 고쳐야 합니다.

08합성으로 기능 묶기

Profile이 String과 Vec<String>을 필드로 품은 모습이 Rust에서 말하는 합성의 가장 단순한 형태입니다. 여러 부품을 한 타입 아래 모을 때 이렇게 시작합니다. 품은 타입의 메서드가 자동으로 노출되지는 않아서, 필요한 만큼 감싸는 메서드를 직접 쓰거나 Deref를 구현해야 합니다.

Rust code

// 합성으로 기능 묶기
#[derive(Debug, Clone)]
struct Profile { name: String, tags: Vec<String> }
fn main() {
    let profile = Profile { name: "Ada".to_string(), tags: vec!["rust".to_string()] };
    println!("{:?}", profile);
}
알아두면 좋은 점

여러 부품을 한 타입 아래 모으는 합성은 필드로 품는 것에서 시작하며, 각 부품을 따로 만들어 두면 독립적으로 시험할 수 있습니다.

자주 하는 실수

품은 타입의 메서드가 자동으로 노출되지는 않는데, 편의를 위해 Deref를 구현하면 의도치 않은 메서드까지 함께 새어 나갑니다.

09팩터리 메서드

생성 로직을 함수로 감싸지 않고 main에서 곧바로 User 값을 조립합니다. 만드는 경로가 여러 갈래로 늘어나거나 값 검증이 필요해지면 fn from_level(...) -> User 같은 연관 함수로 옮깁니다. 생성이 실패할 수 있다면 반환 타입을 Result로 두는 편이 곧바로 panic!하는 것보다 다루기 쉽습니다.

Rust code

// 팩터리 메서드
struct User { name: String, level: i32 }
fn main() {
    let user = User { name: "Ada".to_string(), level: 9 };
    println!("{}", user.name);
}
알아두면 좋은 점

생성 경로가 여러 갈래로 늘어나면 연관 함수로 옮겨, 값을 조립하는 규칙과 검증을 타입 옆에 모아 둘 수 있습니다.

자주 하는 실수

생성 중 검증이 실패할 수 있는데 panic!로 끝내면 호출부가 대응할 방법이 없으므로, 반환 타입을 Result로 두어야 합니다.

10열거형 상태 표현

상태를 열거형이 아니라 tags: Vec<String>이라는 문자열 목록으로 담고 있습니다. 값의 종류가 미리 정해져 있다면 enum으로 바꾸는 쪽이 맞습니다. 그러면 match에서 빠뜨린 경우를 컴파일러가 잡아 주고, 문자열 비교와 오타 때문에 조용히 어긋나는 일도 사라집니다.

Rust code

// 열거형 상태 표현
#[derive(Debug, Clone)]
struct Profile { name: String, tags: Vec<String> }
fn main() {
    let profile = Profile { name: "Ada".to_string(), tags: vec!["rust".to_string()] };
    println!("{:?}", profile);
}
알아두면 좋은 점

값의 종류가 미리 정해져 있다면 문자열 목록 대신 enum으로 바꿔, match에서 빠뜨린 경우를 컴파일러가 짚어 주게 할 수 있습니다.

자주 하는 실수

상태를 문자열로 담으면 대소문자만 다른 값이 서로 다른 상태로 취급되어, 오타가 오류 없이 조용히 지나가고 조건이 어긋납니다.

11불변 데이터 객체

let user에 mut이 없어 값을 만든 뒤에는 어떤 필드도 바꿀 수 없습니다. 만들어진 다음 변하지 않아야 하는 데이터를 다룰 때 이 상태를 유지합니다. Rust의 불변성은 필드가 아니라 바인딩에 붙기 때문에, 같은 구조체라도 let mut으로 받으면 전체가 수정 가능해집니다.

Rust code

// 불변 데이터 객체
struct User { name: String, level: i32 }
fn main() {
    let user = User { name: "Ada".to_string(), level: 11 };
    println!("{}", user.name);
}
알아두면 좋은 점

불변성은 필드가 아니라 바인딩에 붙으므로, 같은 구조체라도 let mut으로 받으면 모든 필드를 고칠 수 있게 됩니다.

자주 하는 실수

값을 &mut로 받는 함수가 하나라도 있으면 그 경로로 내부가 바뀌므로, 정말 바뀌지 않아야 한다면 수정 메서드를 애초에 두지 않아야 합니다.

12빌더 패턴

빌더를 거치지 않고 모든 필드를 한 번에 채운 Profile 리터럴입니다. 필드가 적고 전부 필수라면 이 편이 읽기 좋고, 선택 필드가 늘어날 때 빌더로 옮기게 됩니다. Rust에는 기본 인자나 이름 있는 인자가 없어서, 빌더나 Default와 .. 업데이트 문법이 그 자리를 대신합니다.

Rust code

// 빌더 패턴
#[derive(Debug, Clone)]
struct Profile { name: String, tags: Vec<String> }
fn main() {
    let profile = Profile { name: "Ada".to_string(), tags: vec!["rust".to_string()] };
    println!("{:?}", profile);
}
알아두면 좋은 점

필드가 적고 전부 필수라면 리터럴이 더 읽기 좋고, 선택 필드가 늘어나기 시작할 때 Default와 .. 업데이트 문법이 먼저 대안이 됩니다.

자주 하는 실수

빌더를 만들면서 마지막 build()를 잊으면 아무 값도 생기지 않으므로, 빌더 메서드에 #[must_use]를 붙여 경고로 잡는 편이 좋습니다.

13다형성

다루는 타입이 User 하나뿐이라 여러 구현을 갈아 끼우는 다형성이 드러나지 않습니다. 서로 다른 구현을 한 자리에서 쓰려면 제네릭이나 Box<dyn Trait>가 필요합니다. 제네릭은 타입마다 코드를 따로 찍어 내 호출이 정적으로 정해지고, dyn은 가상 테이블을 거치는 대신 다른 타입을 한 Vec에 담게 해 줍니다.

Rust code

// 다형성
struct User { name: String, level: i32 }
fn main() {
    let user = User { name: "Ada".to_string(), level: 13 };
    println!("{}", user.name);
}
알아두면 좋은 점

제네릭은 타입마다 코드를 따로 찍어 내 호출이 정적으로 정해지고, Box<dyn Trait>은 서로 다른 타입을 한 Vec에 담게 해 줍니다.

자주 하는 실수

제네릭 메서드가 있거나 Self를 반환하는 트레이트는 객체 안전하지 않아 dyn으로 만들 수 없고, 그 사실이 쓰려는 시점에야 드러납니다.

14의존성 주입

의존 대상을 밖에서 받는 대신 main이 Profile을 직접 만들어 씁니다. 테스트에서 구현을 바꿔 끼우려면 필요한 동작을 trait으로 뽑아 필드나 인자로 넘겨야 합니다. 제네릭 매개변수로 주입하면 호출이 정적으로 풀려 런타임 비용이 없고, Box<dyn ...>로 받으면 코드가 한 벌만 생성됩니다.

Rust code

// 의존성 주입
#[derive(Debug, Clone)]
struct Profile { name: String, tags: Vec<String> }
fn main() {
    let profile = Profile { name: "Ada".to_string(), tags: vec!["rust".to_string()] };
    println!("{:?}", profile);
}
알아두면 좋은 점

필요한 동작을 트레이트로 뽑아 인자로 받으면 테스트에서 가짜 구현을 끼울 수 있고, 제네릭으로 주입하면 런타임 비용도 붙지 않습니다.

자주 하는 실수

Box<dyn ...>로 받으면 코드가 한 벌만 생성되는 대신 호출마다 가상 테이블을 거치므로, 아주 자주 불리는 경로에서는 차이가 드러납니다.

15값 객체 비교

User에는 PartialEq가 파생되어 있지 않아 ==로 두 값을 견줄 수 없고, 코드도 필드 출력에서 끝납니다. 동등성이 필요하면 #[derive(PartialEq, Eq)]를 더합니다. 파생된 비교는 모든 필드를 순서대로 검사하므로, 식별자만 봐야 하는 타입이라면 직접 impl하는 편이 맞습니다.

Rust code

// 값 객체 비교
struct User { name: String, level: i32 }
fn main() {
    let user = User { name: "Ada".to_string(), level: 15 };
    println!("{}", user.name);
}
알아두면 좋은 점

동등성이 필요하면 #[derive(PartialEq, Eq)]를 더하면 되고, 파생된 비교는 모든 필드를 선언 순서대로 검사합니다.

자주 하는 실수

식별자만 봐야 하는 타입에 파생 비교를 그대로 쓰면 이름이나 갱신 시각까지 비교에 들어가, 같은 대상이 다른 값으로 판정됩니다.

16저장소 객체

저장소 역할을 하는 타입은 없고 Profile 값 하나를 만들어 출력할 뿐입니다. 데이터 접근 계층을 만들 때는 보통 HashMap이나 DB 핸들을 감싼 구조체에 조회와 저장 메서드를 답니다. 없을 수도 있는 조회 결과는 Option으로, 실패할 수 있는 입출력은 Result로 나누어 돌려주는 것이 관례입니다.

Rust code

// 저장소 객체
#[derive(Debug, Clone)]
struct Profile { name: String, tags: Vec<String> }
fn main() {
    let profile = Profile { name: "Ada".to_string(), tags: vec!["rust".to_string()] };
    println!("{:?}", profile);
}
알아두면 좋은 점

저장소는 HashMap이나 데이터베이스 핸들을 감싼 구조체에 조회와 저장 메서드를 다는 형태가 흔하며, 내부 자료구조는 감춰 둡니다.

자주 하는 실수

없을 수도 있는 조회 결과를 Result로만 돌려주면 정상적인 부재와 진짜 오류가 섞이므로, 부재는 Option으로 구분해야 합니다.

17서비스 객체

동작 하나 없이 데이터만 담은 User 구조체가 전부입니다. 여러 단계를 엮어 처리하는 서비스라면 필요한 상태를 필드로 들고 &self 메서드를 모으는 모양이 됩니다. 메서드가 &mut self를 요구하는 순간 두 곳에서 동시에 부를 수 없게 되므로, 내부만 바꾸면 되는 값은 RefCell이나 Mutex 뒤로 넣습니다.

Rust code

// 서비스 객체
struct User { name: String, level: i32 }
fn main() {
    let user = User { name: "Ada".to_string(), level: 17 };
    println!("{}", user.name);
}
알아두면 좋은 점

서비스 메서드를 &self만 받도록 설계하면 여러 곳에서 동시에 부를 수 있고, 안쪽에서 바꿔야 하는 값만 Mutex 뒤에 두면 됩니다.

자주 하는 실수

메서드가 &mut self를 요구하는 순간 그 서비스는 한 번에 한 곳에서만 쓸 수 있게 되어, 호출 관계가 조금만 얽혀도 빌림 오류가 납니다.

18DTO 매핑

변환 코드는 없고 Profile을 만들어 Debug 형식으로 찍습니다. 외부에 노출할 표현과 내부 모델을 나눌 때는 DTO 구조체를 따로 두고 From이나 TryFrom을 구현합니다. From을 구현하면 into()가 따라오지만 원본 값을 소비하므로, 계속 써야 하는 값이라면 참조를 받는 구현을 따로 둡니다.

Rust code

// DTO 매핑
#[derive(Debug, Clone)]
struct Profile { name: String, tags: Vec<String> }
fn main() {
    let profile = Profile { name: "Ada".to_string(), tags: vec!["rust".to_string()] };
    println!("{:?}", profile);
}
알아두면 좋은 점

외부 표현과 내부 모델을 나눌 때는 DTO 구조체를 따로 두고 From이나 TryFrom을 구현해 변환 지점을 한곳에 모읍니다.

자주 하는 실수

From은 원본을 소비하므로 변환 뒤에도 원래 값을 써야 한다면, 참조를 받는 구현을 따로 두지 않으면 이동됨 오류를 만나게 됩니다.

19변경 불가능 업데이트

값을 갱신하는 부분이 없고, 만들어 둔 User를 읽기만 합니다. 원본을 남기고 일부만 다른 사본을 얻으려면 User { level: 20, ..user } 같은 구조체 업데이트 문법을 씁니다. 이때 String처럼 Copy가 아닌 필드는 복사가 아니라 이동되므로 원본을 통째로는 다시 쓸 수 없게 됩니다.

Rust code

// 변경 불가능 업데이트
struct User { name: String, level: i32 }
fn main() {
    let user = User { name: "Ada".to_string(), level: 19 };
    println!("{}", user.name);
}
알아두면 좋은 점

일부만 다른 사본은 User { level: 20, ..user } 형태로 만들 수 있고, 나머지 필드는 원본에서 그대로 가져옵니다.

자주 하는 실수

이 문법에서 String처럼 Copy가 아닌 필드는 복사가 아니라 이동되므로, 새 값을 만든 뒤에는 원본을 통째로 다시 쓸 수 없습니다.

20확장 스타일 동작

남의 타입에 기능을 덧붙이는 코드는 없고 derive와 리터럴 생성만 보입니다. 외부 타입에 메서드를 더하고 싶다면 확장 trait을 정의해 impl MyExt for Vec<String>처럼 구현합니다. trait과 타입 중 적어도 하나는 내 크레이트 것이어야 한다는 고아 규칙 때문에, 둘 다 외부 것이면 새 래퍼 타입이 필요합니다.

Rust code

// 확장 스타일 동작
#[derive(Debug, Clone)]
struct Profile { name: String, tags: Vec<String> }
fn main() {
    let profile = Profile { name: "Ada".to_string(), tags: vec!["rust".to_string()] };
    println!("{:?}", profile);
}
알아두면 좋은 점

외부 타입에 메서드를 더하려면 확장 트레이트를 정의해 구현하면 되고, 그 트레이트를 use로 가져온 모듈에서만 메서드가 보입니다.

자주 하는 실수

고아 규칙 때문에 트레이트와 타입이 둘 다 외부 것이면 구현할 수 없으므로, 그 경우에는 감싸는 새 타입을 하나 만들어야 합니다.

정리하며

  • 상속 대신 트레이트 기본 구현과 필드 합성으로 재사용을 설계합니다
  • new는 언어가 부르는 생성자가 아니라 관례일 뿐이라는 점을 기억합니다
  • 성능이 중요하면 제네릭, 이종 컬렉션이 필요하면 dyn Trait을 고릅니다
  • 트레이트 객체를 쓸 계획이면 제네릭 메서드와 Self 반환을 피합니다

더 깊이 들어가고 싶다면 Rust 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.