자바 클래스, 인터페이스와 러스트의 구조체, 메소드, 트레이트 방식 비교

자바(Java)는 모든 메소드를 클래스 내부에 정의합니다. 그러나, 러스트(Rust)는 데이터 구조(Struct)와 메소드(Impl block)가 물리적으로 분리되어 있습니다. 구조체 안에는 데이터만 정의하고, 별도의 impl 블록에서 그 구조체에 대한 메소드를 구현합니다.

Ⅰ. 데이터 구조, 메소드 구현 방식 비교

1. 자바: 클래스 내부에 메소드

자바(Java)는 모든 메소드를 클래스 내부에 정의합니다. 이는 객체 지향 프로그래밍(OOP)의 특징으로, 데이터(필드)와 행동(메소드)이 하나의 단위(클래스)에 밀접하게 결합(cohesion)되어 있습니다.

2. 러스트: 구조체와 메소드의 분리

러스트(Rust)에서는 데이터 구조(Struct)와 메소드(Impl block)가 물리적으로 분리되어 있습니다. 구조체 안에는 데이터만 정의하고, 별도의 impl 블록에서 그 구조체에 대한 메소드를 구현합니다.

3. 장단점

구분자바(메소드=클래스 내부)러스트(메소드=구조체 외부)
응집성데이터와 행동이 하나에 묶임데이터와 행동이 분리되어 명확
확장성상속 등 OOP 구조 확장 용이Trait, 여러 impl로 유연하게 확장
가독성클래스 안에서 한 번에 파악 가능데이터 정의와 메소드 구현 분리
유연성하나의 클래스가 하나의 메소드 집합같은 구조체에 여러 impl, trait 구현 가능
재사용성상속, 인터페이스로 구현Trait 등으로 다양한 방식의 재사용
관리의 용이성대형 클래스에서 복잡해질 수 있음관련 없는 메소드를 구조체와 별도 구현 가능
코드 조직화OOP 방식(클래스-중심)데이터 중심(struct), 행동 분리(impl, trait)
  • 자바는 객체 중심의 응집력, 개발 및 이해 용이성이 강점이나, 큰 클래스가 복잡해지거나 상속 구조의 한계 등이 있습니다.
  • 러스트는 데이터와 행동의 분리로 역할이 명확하며, trait 기반의 확장성과 안전성이 강점이지만, 초보자에겐 코드 연관성 파악이 다소 불편할 수 있습니다.

Ⅱ. 예제를 통한 비교

1. 자바(Java)

자바에서는 데이터(필드)와 메소드가 한 클래스 내부에 정의되어 객체 지향 프로그래밍 패러다임에 맞춰 응집되어 있습니다.

public class Point {
private int x;
private int y;

// 생성자
public Point(int x, int y) {
this.x = x;
this.y = y;
}

// 메소드: 두 점 사이 거리 계산
public double distance(Point other) {
int dx = this.x - other.x;
int dy = this.y - other.y;
return Math.sqrt(dx * dx + dy * dy);
}

// getter 메소드
public int getX() { return x; }
public int getY() { return y; }
}

// 사용
public class Main {
public static void main(String[] args) {
Point p1 = new Point(0, 0);
Point p2 = new Point(3, 4);
System.out.println(p1.distance(p2)); // 출력: 5.0
}
}
  • 징: Point 클래스 내부에 데이터(x, y)와 동작(거리 계산 메소드)을 모두 포함.
  • 장점: 객체 지향적 응집성(cohesion) 강화, 한 곳에서 모든 관련 기능 파악 가능.
  • 단점: 클래스가 커질수록 복잡성 증가, 상속 구조 제한 등.

2. 러스트(Rust)

러스트는 데이터 구조체(struct)와 메소드 구현부(impl 블록)를 분리해서 작성합니다. 이로써 역할 분리가 명확해지고, 여러 impl 블록과 trait을 통해 유연하게 확장할 수 있습니다.

// 데이터 정의: 구조체는 필드만 가짐
struct Point {
x: i32,
y: i32,
}

// 메소드 구현: impl 블록에서 정의
impl Point {
// 연관 함수(정적 메서드와 유사)
fn new(x: i32, y: i32) -> Point {
Point { x, y }
}

// 메소드: 두 점 사이 거리 계산
fn distance(&self, other: &Point) -> f64 {
let dx = (self.x - other.x) as f64;
let dy = (self.y - other.y) as f64;
(dx.powi(2) + dy.powi(2)).sqrt()
}
}

fn main() {
let p1 = Point::new(0, 0);
let p2 = Point::new(3, 4);
println!("{}", p1.distance(&p2)); // 출력: 5.0
}
  • 특징: struct는 데이터만 선언, 메소드는 별도의 impl 블록에서 구현.
  • 장점: 데이터와 행동이 분리되어 역할 명확, 여러 impl이나 trait로 기능 확장 유리, 컴파일타임 안전성 증대.
  • 단점: 관련 데이터와 메소드가 코드상 분리되어 있어 한눈에 파악하기 어려울 수 있음, 전통적인 OOP 방식과 차이 있음.

3. 비교 표

항목자바(Java)러스트(Rust)
구조클래스 내부에 데이터와 메소드가 함께 있음구조체(데이터)와 impl 블록(메소드)로 분리
작성 방식한 클래스 파일 내에서 모든 정의struct로 데이터 정의, 별도 impl로 메소드 구현
확장성상속과 인터페이스 기반 확장 (단일 상속)여러 impl 블록과 trait 조합으로 유연하고 다중 확장 가능
가독성관련 데이터와 메소드가 한 곳에 있어 파악 용이데이터와 메소드가 분리되어 코드가 흩어질 수 있음
안전성런타임 검사 및 가비지 컬렉션컴파일 타임 소유권 및 빌림 검사로 메모리 안전성 강화
메모리참조 타입 중심, 힙 할당 및 가비지 컬렉션 필요값 타입 중심, 명확한 메모리 제어 및 성능 최적화 가능
객체 지향전통적인 OOP 완전 지원클래스는 없으나 trait로 인터페이스 역할 및 객체지향 유사 기능 제공

러스트의 구조체+impl 방식은 자바와는 다르게 데이터와 메소드가 분리되어 있지만, impl 블록 내에서 메소드를 묶어 객체 지향적 프로그래밍의 많은 특징을 흉내 낼 수 있습니다. trait를 활용하면 인터페이스 역할도 하며, 상속 대신 다중 trait 구현으로 유연하게 기능을 확장할 수 있습니다.

Ⅲ . 자바의 상속과 인터페이스 구조와 러스트의 고급 Trait(트레이트) 및 패턴 비교

자바의 상속과 인터페이스 구조와 러스트의 고급 Trait(트레이트) 및 패턴을 심도 있게 비교 설명하면 다음과 같습니다.

1. 자바의 상속과 인터페이스 구조

가. 상속 (Inheritance)

  • 클래스 간에 “is-a” 관계를 표현하는 가장 기본적인 메커니즘입니다.
  • 한 클래스가 다른 클래스(부모 클래스)를 상속받아 멤버 변수와 메소드를 재사용하거나 오버라이드할 수 있습니다.
  • 단일 상속만 지원하여 다중 상속의 복잡성을 회피합니다.
  • 상속 구조가 깊어지면 유지보수가 어려워지고, 부모 클래스 변경 시 자식 클래스에 의도치 않은 영향이 발생할 수 있음.

나. 인터페이스 (Interface)

  • 여러 클래스가 구현해야 하는 메소드의 명세(계약)를 정의합니다.
  • 자바 8부터는 디폴트 메소드 구현도 지원하여 일부 기능적 확장 가능.
  • 인터페이스의 다중 구현이 가능해 상속 단점을 보완하고 다형성 제공.
  • 인터페이스는 구현 메소드가 없거나 기본 구현만 제공하므로, 설계 시 유연성을 줌.
interface Flyer {
void fly();
}
class Bird implements Flyer {
public void fly() {
System.out.println("Bird is flying");
}
}
class Airplane implements Flyer {
public void fly() {
System.out.println("Airplane is flying");
}
}

2. 러스트의 고급 Trait 및 패턴

가. Trait(트레이트) 개념

  • 러스트의 트레이트는 특정 기능을 구현하도록 강제하는 인터페이스 역할을 합니다.
  • 타입에 따라 여러 트레이트를 다중으로 구현할 수 있어 매우 유연합니다.
  • 트레이트 내에 메소드 기본 구현(default method)을 제공해 부분 구현도 가능.
  • Trait는 다중 상속을 대체하며, 조합(composition) 및 다형성을 지원합니다.

나. 고급 트레이트 특징

(1) 동일 메소드명을 가진 다중 트레이트 구현 (충돌 해결)

  • 예를 들어, 같은 이름 fly() 메소드를 가진 서로 다른 트레이트 Pilot, Wizard를 Human 타입에 모두 구현 가능.
  • 호출 시 Pilot::fly(&human), Wizard::fly(&human)처럼 트레이트 이름을 명시해 충돌 해결.
trait Pilot {
fn fly(&self);
}
trait Wizard {
fn fly(&self);
}
struct Human;

impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
}

impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
}

impl Human {
fn fly(&self) {
println!("*waving arms furiously*");
}
}

fn main() {
let person = Human;
Pilot::fly(&person); // This is your captain speaking.
Wizard::fly(&person); // Up!
person.fly(); // *waving arms furiously*
}

(2) 슈퍼 트레이트 (Super-traits)

  • 한 트레이트가 다른 트레이트의 구현을 전제로 하는 경우 사용.
  • 예: OutlinePrint 트레이트가 Display 트레이트를 반드시 구현한 경우에만 구현 가능하도록 제한.
use std::fmt::Display;

trait OutlinePrint: Display {
fn outline_print(&self) {
// Display 기능 이용해 출력하는 구현
println!("*{}*", self);
}
}

(3) 제네릭과 트레이트 바운드 (Trait Bounds)

  • 함수, 구조체, 열거형 등에서 트레이트를 타입 매개변수 조건으로 지정하여 유연한 재사용성 제공.
fn print_info<T: Display + Clone>(item: T) {
println!("{}", item);
}

(4) Newtype 패턴

  • 외부 타입에 대해 새로운 타입을 만든 뒤 트레이트를 구현해 동작을 확장하거나 수정하는 패턴.
  • 원래 타입의 구현과 격리돼 안전성과 캡슐화 유지에 도움.

다. 자바 상속-인터페이스와 러스트 Trait 패턴 비교

특징자바 (상속, 인터페이스)러스트 (고급 Trait 패턴)
다중 상속 지원 여부클래스는 단일 상속, 인터페이스 다중 구현 가능다중 트레이트 구현 완전 지원
메소드 충돌 처리인터페이스 디폴트 메소드 충돌은 명시적 오버라이딩 필요트레이트별로 명시적 호출(TraitName::method)로 충돌 처리
구현 분리 여부상속 기반으로 구현 내용 일부 공유, 부모-자식 직접 연관데이터와 행동 완전 분리, 여러 impl 블록과 트레이트로 유연한 구현
확장성 및 유연성인터페이스와 추상 클래스 활용 가능, 런타임 다형성 제공트레이트 조합 및 바운드로 더 강력하고 유연한 제네릭 기반 다형성 가능
안전성런타임에 예외 발생 가능, 컴파일 타임 타입 검사 제한컴파일 타임 엄격한 타입 검사 및 소유권 규칙으로 높은 안전성 보장
메모리 구조클래스는 힙에 할당, 가비지 컬렉션 필요구조체는 값 타입(STACK 기반), 트레이트 객체는 런타임에 크기를 모르는 타입 표현 지원

라. 러스트 Trait를 활용한 고급 설계 패턴 예시

(1) Trait 객체 (Trait Objects)

  • 런타임에 다양한 타입을 동적으로 처리하고자 할 때 사용.
  • Box<dyn Trait> 형태로 포인터에 담아 추상화 제공.
trait Animal {
fn speak(&self);
}

struct Dog;
struct Cat;

impl Animal for Dog {
fn speak(&self) {
println!("Woof!");
}
}

impl Animal for Cat {
fn speak(&self) {
println!("Meow!");
}
}

fn make_animal_speak(animal: Box<dyn Animal>) {
animal.speak();
}

fn main() {
let dog = Box::new(Dog);
let cat = Box::new(Cat);

make_animal_speak(dog);
make_animal_speak(cat);
}

(2) 제네릭과 Trait Bound를 이용한 추상화

fn print_animal_speak<T: Animal>(animal: T) {
animal.speak();
}

(3) Newtype 패턴

struct Wrapper(Vec<String>);

impl std::fmt::Display for Wrapper {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}

5. 러스트 Trait 객체와 동적 디스패치(Dynamic Dispatch)

가. 기본 개념

  • 러스트의 Trait 객체는 런타임에 다양한 타입들을 추상화하고 동적으로 호출할 수 있게 하는 기능입니다.
  • 구체적인 타입을 명시하지 않고 특정 트레이트를 구현한 여러 객체를 하나의 타입으로 사용할 때 유용합니다.
  • dyn Trait 키워드로 표현하며, 이때 메소드 호출은 컴파일 타임이 아닌 런타임에 결정(동적 디스패치)됩니다.

나. 장점

  • 다양한 타입에 대해 동일 인터페이스 역할을 수행할 수 있게 하며, 실행 시점에 적절한 메소드가 호출됩니다.
  • 유연한 설계와 다형성 제공.

다. 단점

  • 동적 디스패치로 인해 약간의 성능 오버헤드 발생.
  • 컴파일 타임에 타입 크기를 알 수 없으므로 보통 Box<dyn Trait>, &dyn Trait 형태로 사용.

예제 코드

trait Animal {
fn speak(&self);
}

struct Dog;

impl Animal for Dog {
fn speak(&self) {
println!("Woof!");
}
}

struct Cat;

impl Animal for Cat {
fn speak(&self) {
println!("Meow!");
}
}

fn animal_speak(animal: &dyn Animal) {
animal.speak();
}

fn main() {
let dog = Dog;
let cat = Cat;

animal_speak(&dog); // Woof!
animal_speak(&cat); // Meow!
}
  • animal_speak 함수는 타입이 &dyn Animal로, 호출하는 객체가 Dog이든 Cat이든 런타임에 올바른 speak 메서드를 호출합니다.

요약

  • 자바의 상속과 인터페이스는 클래스 중심의 단일 상속과 다중 인터페이스 구현으로 객체 지향 프로그래밍 패러다임을 완성하며, 런타임 다형성을 제공합니다.
  • 러스트의 트레이트는 다중 상속을 대체하고, 유연한 기능 확장과 컴파일 타임 안전성을 제공하며, 타입별로 여러 트레이트를 조합해 강력한 추상화와 다형성을 구현합니다.
  • 러스트에서 트레이트는 충돌 해결, 슈퍼 트레이트, 제네릭 바운드, 트레이트 객체 등 다양한 고급 패턴으로 복잡한 설계를 수행할 수 있습니다.
  • 두 언어 모두 장단점이 있으므로, 팀의 경험과 프로젝트 요구사항에 따라 적합한 방식을 선택하는 것이 중요합니다.

Rust의 이터레이터(Iterator)

Rust에서 이터레이터(iterator)는 값을 순회(iterate)할 수 있도록 해주는 강력하고 유연한 추상화입니다. 이터레이터는 반복 가능한 값을 하나씩 꺼내면서 작업을 수행할 때 사용되며, 지연 평가(lazy evaluation)를 통해 성능도 뛰어납니다.

Rust에서 지연 평가(lazy evaluation)는 계산이 필요한 시점까지 연산을 연기하는 전략입니다. 즉, 값을 즉시 계산하는 대신, 해당 값이 필요할 때까지 계산을 미루는 방식입니다. 이를 통해 불필요한 계산을 방지하고 성능을 향상시킬 수 있습니다.

1. 기본 개념

Rust에서 이터레이터는 Iterator 트레잇을 구현한 타입입니다. 이 트레잇은 next() 메서드를 정의합니다.

pub trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}
  • next()는 Option Enum 형식으로 반환하므로, Some(value)를 반환하다가, 더 이상 값이 없으면 None을 반환합니다.
  • for 루프는 내부적으로 이 next()를 호출하여 동작합니다.

가. 예제 1: 기본 사용

fn main() {
    let v = vec![10, 20, 30];
    let mut iter = v.iter(); // 불변 참조로 이터레이터 생성

    while let Some(x) = iter.next() {
        println!("값: {x}");
    }
}
let v = vec![10, 20, 30]
  • v는 정수형 벡터입니다.
  • 즉, Vec 타입이고 [10, 20, 30]이라는 세 개의 요소를 가지고 있습니다.
let mut iter = v.iter()
  • v.iter()는 벡터 v의 각 요소에 대한 불변 참조 (&i32)를 반환하는 이터레이터를 생성합니다.
  • 즉, iter는 &10, &20, &30을 순서대로 반환할 준비가 된 상태입니다.
  • iter는 가변 변수로 선언되었습니다(mut) → .next()를 호출할 때 이터레이터 내부 상태를 바꾸기 때문입니다.

while let Some(x) = iter.next() { … }
  • .next()는 이터레이터에서 다음 값을 하나씩 꺼냅니다.
  • 반환값은 Option<&i32>입니다.
  • 값이 있으면 Some(&값)
  • 끝나면 None
  • while let Some(x)는 Some일 때 루프를 돌고, None이면 종료됩니다.
println!(“값: {x}”);
  • x는 &i32 타입이므로 10, 20, 30이 참조 형태로 출력됩니다.
  • println!은 참조를 자동으로 역참조해서 출력해주기 때문에 따로 *x를 쓰지 않아도 됩니다.

나. 예제 2: for 루프 사용

fn main() {
    let v = vec![1, 2, 3];

    for num in v.iter() {
        println!("num = {num}");
    }
}

while문과 아래가 다릅니다.

for num in v.iter()
  • v.iter()는 불변 참조 이터레이터를 생성합니다.
    • 즉, &1, &2, &3을 순서대로반환합니다.
  • for 루프는 이터레이터의 .next()를 자동으로 반복 호출하여 값을 하나씩 꺼냅니다.
  • 변수 num의 타입은 &i32입니다 (참조).
  • v.iter()는 벡터를 소유하지 않고 참만 하므로, v는 이루프 이후에도 여전히 사용 가능합니다.
  • println!(“num = {num}”);에 따라 1,2,3이 출력됩니다.

2. 소비(consuming) 어댑터

이터레이터를 사용해 데이터를 소모하는 메서드입니다.

  • .sum(): 합계 반환
  • .count(): 요소 개수
  • .collect(): 컬렉션으로 변환
fn main() {
    let v = vec![1, 2, 3, 4, 5];
    let sum: i32 = v.iter().sum();
    println!("합계: {}", sum);
}

여기서 특이한 점은 sum 다음에 i32라고 타입이 명시되어 있다는 점입니다.

: i32를 빼고 실행하면 “type annotations needed”라고 에러가 발생하는데,

iter().sum()의 반환 형식을 지정하지 않으면 에러 발생

sum이 &i32를 받아서 더할 수는 있지만, 반환값의 형식을 추론할 수 없기 때문에 안정성과 명확성을 추구하는 Rust가 에러를 발생시키는 것입니다.

3. 변형(transforming) 어댑터

이터레이터에서 새로운 이터레이터를 생성하지만 실제 순회는 for, collect 등으로 실행되기 전까지 지연 평가됩니다.

  • .map(): 각 요소를 변형
  • .filter(): 조건에 맞는 요소만 남김

가. map() 예제

fn main() {
    let v = vec![1, 2, 3, 4];

    let doubled: Vec<i32> = v.iter()
        .map(|x| x * 2)
        .collect();

    println!("{doubled:?}"); // [2, 4, 6, 8]
}
v.iter()
  • 벡터 v에 대해 불변 참조 이터레이터를 생성합니다.
  • 반환 타입은 impl Iterator<Item = &i32> → 각 요소는 &1, &2, &3, &4.
.map(|x| x * 2)
  • map은 이터레이터의 각 항목에 closure를 적용해 새로운 이터레이터를 만듭니다.
  • 여기서 x는 &i32이므로, x * 2는 실제로는 *x * 2와 같은 의미입니다.
  • 즉, 값은 다음과 같이 변합니다:
  • &1 → 1 * 2 → 2
  • &2 → 2 * 2 → 4
  • x는 &i32이기 때문에 직접 곱하려면 *x * 2라고 해야 하지만, Rust는 x * 2를 보면 자동으로 역참조(*x) 해주기 때문에 생략 가능합니다.

.collect()
  • 이터레이터 결과를 컨테이너 타입(여기서는 Vec)으로 수집합니다.
  • 이 부분에서 타입 추론이 불가능할 수 있기 때문에, doubled: Vec<i32>로 타입을 명시했습니다.

println!(“{doubled:?}”);
  • {:?}는 벡터를 디버그 형식으로 출력해줍니다.
  • 출력 결과는 [2, 4, 6, 8]입니다.

나. filter() 예제

fn main() {
    let v = vec![1, 2, 3, 4, 5];

    let even: Vec<_> = v.into_iter()
        .filter(|x| x % 2 == 0)
        .collect();

    println!("{:?}", even); // [2, 4]
}

다른 것은 같고 filter 부분만 다른데, x를 2로 나눴을 때 나머지가 0인 것을 % 연산자(나머지 연산자)로 구해서 해당되는 것만 collect(수집)하는 것입니다.

4. 소유권과 이터레이터

이터레이터는 다음 세 가지 방식으로 만들 수 있습니다.

메서드설명
.iter()불변 참조 이터레이터
.iter.mut()가변 참조 이터레이터
.into_iter()소유권을 이동하는 이터레이터
fn main() {
    let mut v = vec![1, 2, 3];

    for x in v.iter_mut() {
        *x *= 10;
    }

    println!("{:?}", v); // [10, 20, 30]
}

vector 변수 v를 가변 참조로 선언한 다음,
값을 하나씩 꺼내서 10을 곱한 다음 x에 저장하므로 v 변수가 변경됩니다.
이후 벡터 변수 v를 디버그 포맷으로 출력합니다.

5. 사용자 정의 이터레이터

직접 구조체에 Iterator 트레잇을 구현하여 사용자 정의 이터레이터를 만들 수도 있습니다.

struct Counter {
    count: usize,
}

impl Counter {
    fn new() -> Self {
        Counter { count: 0 }
    }
}

impl Iterator for Counter {
    type Item = usize;

    fn next(&mut self) -> Option<Self::Item> {
        self.count += 1;
        if self.count <= 5 {
            Some(self.count)
        } else {
            None
        }
    }
}

fn main() {
    let c = Counter::new();
    for i in c {
        println!("{}", i);
    }
}
가. 구조체 정의
struct Counter {
    count: usize,
}

usize 타입의 count 필드를 가진 Counter 구조체를 정의합니다.

나. Counter의 new 메소드 구현
impl Counter {
    fn new() -> Self {
        Counter { count: 0 }
    }
}

Counter 구조체의 fn new() 메소드를 정의하는데, count 필드의 값을 0으로 초기화합니다.

다. Counter를 위한 Iterator 트레잇 구현
impl Iterator for Counter {
    type Item = usize;

    fn next(&mut self) -> Option<Self::Item> {
        self.count += 1;
        if self.count <= 5 {
            Some(self.count)
        } else {
            None
        }
    }
}

Counter 구조체를 위해 Iterator 트레잇을 구현하는데
fn new() 메소드에서 반환값은 Item인데 usize 형식이고,
매번 next()가 호출될 때 count를 1씩 증가시키고, 5보다 작거나 같으면 Some(count)을 반환하고, 그렇지 않으면 None을 반환하여 반복을 종료합니다.

라. 메인 함수
fn main() {
    let c = Counter::new();
    for i in c {
        println!("{}", i);
    }
}

Count 구조체의 count 필드를 0으로 초기화한 후 c에 넣고
c를 1씩 증가시키면서 실행하는데 5까지만 출력하고 종료합니다.
Counter::new()로 만든 c는 Iterator를 구현하고 있기 때문에 for 루프에서 사용 가능합니다.

6. 정리

  • Iterator는 next()를 통해 요소를 하나씩 반환합니다.
  • .map, .filter 등은 지연 평가(lazy evaluation) 방식으로 동작합니다.
  • .collect()나 for 루프 등을 통해 실제로 실행됩니다.
  • 반복 가능한 자료형은 대부분 이터레이터를 제공합니다 (Vec, HashMap, Range 등)
  • Rust의 함수형 프로그래밍 스타일을 구성하는 핵심 개념입니다.

제네릭(Generic)과 트레잇(Trait)

Rust는 코드의 재사용성과 타입 안전성을 동시에 보장하기 위해 제네릭(Generic)과 트레잇(Trait)을 사용합니다. 이 개념은 Rust의 함수, 구조체, 열거형 등 다양한 곳에 적용되는데, 제네릭은 일반화된 타입을 의미하고, Trait은 공통된 행위 또는 특성을 의미합니다.


1. 제네릭(Generic)

가. 형식별 별도 함수

제네릭은 “특정 타입에 얽매이지 않고 다양한 타입에 대해 작동할 수 있도록 하는 일반화된 타입”을 의미합니다. 따라서, 타입에 의존하지 않는 유연한 코드를 작성할 수 있도록 도와줍니다.

아래 코드는 정수 타입 i32에 대해서는 largest_i32 함수를 사용하고, char에 대해서는 largest_char 함수를 따로 적용하고 있습니다.

fn largest_i32(list: &[i32]) -> i32 {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> char {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&numbers);
    println!("The largest number is {}", result);

    let chars = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&chars);
    println!("The largest char is {}", result);
}

위 코드를 실행하면 아래와 같이 “가장 큰 수는 100, 가장 큰 문자는 y(번역)”라고 화면에 표시됩니다.

i32 정수와 char에 따라 다른 함수 적용

나. 제네릭 타입의 하나의 함수

위와 같이 i32와 char라는 데이터 타입에 따라 다른 함수를 하나의 함수로 합치기 위해 T라는 제네릭을 이용했습니다.

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];
    for &item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];
    let result = largest(&numbers);
    println!("The largest number is {}", result);
    let chars = vec!['y', 'm', 'a', 'q'];

    let result = largest(&chars);
    println!("The largest char is {}", result);
}

위 코드를 실행하면
The largest number is 100
The largest char is y
가 화면에 출력되는데,

Generic T를 정의하는 구문에서 : PartialOrd + Copy를 제거하고 실행하면

제너릭 사용시 PartialOrd 트레잇을 사용하지 않아 에러 발생

error[E0369]: binary operation > cannot be applied to type T
(에러 E0369: 이진 동작인 > 는 T 타입에 적용될 수 없습니다)
란 에러 메시지가 표시되고,

그 아래 T 다음에 “: std:cmp::PartialOrd를 추가하라고 합니다.

PartialOrd는 두 값을 비교하여 크거나 작거나 같은지 부분적으로만 결정할 수 있는 경우를 위해 설계되었습니다.
예를 들어 부동 소수점 숫자(floating-point numbers)의 경우 NaN (Not a Number) 값이 존재하기 때문에 완전한 비교가 불가능합니다.
Ord는 두 값을 항상 비교할 수 있는 경우를 위해 설계되었습니다.

그래서 T 다음에 PartialOrd만 추가하고 Copy는 빼고 실행하면

제너릭 사용시 Copy 트레잇을 사용하지 않아 에러 발생

let mut largest = list[0];의 list[0]에서
error[E0508]: cannot move out of type [T], a non-copy slice
(복사할 수 없는 슬라이스인 `[T]` 유형에서 이동할 수 없습니다)
란 에러가 발생하고,

let mut largest = &list[0];에서는
type ‘T’가 Copy trait를 구현하지 않았기 때문에 move가 일어났는데, 여기서 data가 move될 수 없다고 합니다.

정수나 문자 타입에 공통적으로 적용하기 위해 제네릭 타입을 적용하는 것은 좋은데, 적용하기 위해 추가해야 할 요소들이 많습니다. 그리고, 두 개의 Trait을 연결하기 위해서는 +를 사용헸습니다.


다. 구조체에 한 가지 제네릭 적용

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let int_point = Point { x: 1, y: 2 };
    let float_point = Point { x: 1.0, y: 2.0 };

    println!("({:.1}, {:.1})", int_point.x, int_point.y);
    println!("({:.1}, {:.1})", float_point.x, float_point.y);
}
  • 위 코드의 경우 타입이 T 하나뿐이 없기 때문에, 타입이 한 가지인 경우만 적용가능합니다. 따라서 Point 구조체를 정의할 때 i32 또는 f64 타입 한 가지로만 지정했습니다.
  • 그리고, {}라고 하면 실수인 경우에도 1로 출력이 돼서 {:.1}로 출력 형식을 변경했습니다.

라. 구조체에 두 가지 제네릭 적용

  • 아래와 같이 하나의 구조체에 여러 타입, T와 U를 지정할 수 있습니다. 이 경우 T나 U는 특별한 의미가 있는 것이 아니고, 타입이 다르다는 것을 의미합니다.
struct Mixed<T, U> {
    x: T,
    y: U,
}

위에서 두 가지 타입을 지정했으므로 아래와 같이 i32와 f64를 같이하거나 달리해서 지정할 수 있습니다.

제너릭으로 두 가지 타입 사용

위 코드를 실행하면
(1, 2)
(1.0, 2)
식으로 화면에 표시됩니다.


2. 트레잇(Trait)

트레잇은 공통된 동작을 정의하는 인터페이스입니다. 특정 타입이 트레잇을 구현하면, 해당 트레잇의 메서드를 사용할 수 있습니다.

가. 예제 코드

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
    author: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} by {}", self.title, self.author)
    }
}

fn main() {
    let article = Article {
        title: String::from("Rust 배우기"),
        author: String::from("홍길동"),
    };

    println!("요약: {}", article.summarize());
}

(1) trait 트레잇 이름 { 메소드 선언 }

  • trait 키워드로 트레잇, 여기서는 Summary 트레잇을 정의하는데, 함수는 함수명과 반환 타입만 지정했고, 구체적인 동작은 정의되지 않았습니다.

(2) 구조체 정의

  • struct Article { }은 Article이란 구조체를 title과 author 모두 String으로 정의했습니다.

(3) impl 트레잇명 for type명

  • Trait을 구현하는 방식은 impl Trait for Type으로서
    impl Summary for Article이라고 적은 다음
    중괄호 안에서 fn summarize로 메소드를 구현하는데,
    인수는 &self로 자기 자신, 여기서는 Article을 받고,
    반환 형식은 위에서 지정했듯이 String 타입이며,
    구체적으로 실행하는 것은
    format!(“{} by {}”, self.title, self.author)으로
    self.title by self.author란 문자열을 생성합니다.

(4) main 함수에서 구조체 생성 및 트레잇 메소드 실행

  • main 함수에서는 구조체를 생성한 다음 aricle.summarize로 title과 author를 이용한 문자열을 만든(format) 후 println!로 화면에 출력합니다.
  • 따라서, 위 코드를 실행하면 아래와 같이 “요약 : Rust 배우기 by 홍길동”이 출력됩니다.
트레잇 정의(trait 트레잇명)와 구현(impl trait for type)을 실행한 결과

나. 트레잇 바운드(Trait Bound)

함수 인자에서 트레잇을 사용하는 방법은 다음과 같이 세 가지가 있습니다.

(1) 케이스 1

item을 impl Summary로 지정하고, main 함수에서 notify 함수의 인수로 aritcle을 입력하는 것입니다.

fn notify(item: impl Summary) {
    println!("알림: {}", item.summarize());
}

예제는 아래와 같습니다.

기존 코드에 위 fn notify를 추가하고,
main함수에서 println!(“요약: {}”, article.summarize());을 주석 처리하고, notify(article);을 추가하면 됩니다.

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
    author: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} by {}", self.title, self.author)
    }
}

fn notify(item: impl Summary) {
    println!("New notification: {}", item.summarize());
}

fn main() {
    let article = Article {
        title: String::from("Rust 배우기"),
        author: String::from("홍길동"),
    };

    notify(article);
    // println!("요약: {}", article.summarize());
}

(2) 케이스 2

아래는 “notify 함수를 정의하는데, 제네릭 타입 T는 Summary 트레잇을 구현한 타입으로 제한하고, 함수의 매개변수 item은 T 타입이다”라는 의미입니다. 가장 많이 사용하는 형식입니다.

fn notify<T: Summary>(item: T) {
    println!("알림: {}", item.summarize());
}

위 코드를 활용한 코드는 아래와 같이 Generic에서 설명한 코드와 비슷합니다. 케이스 1과 다른 점은 article을 참조 형식(&article)으로 전달하는 것입니다.

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    title: String,
    author: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} by {}", self.title, self.author)
    }
}

fn notify<T: Summary>(item: &T) {
    println!("New notification: {}", item.summarize());
}

fn main() {
    let article = Article {
        title: String::from("Rust 배우기"),
        author: String::from("홍길동"),
    };

    notify(&article);
    // println!("요약: {}", article.summarize());
}

(3) 케이스 3

여러 트레잇을 조합할 수도 있습니다.

fn process<T: Summary + Clone>(item: T) {
    println!("처리: {}", item.summarize());
}

구조체가 Clone이 가능하도록 Struct 구조체 선언문 위에
#[derive(Clone)] 문이 있어야 하고,
main함수에서 notify의 인수를 aricle로 지정하면 됩니다.

trait Summary {
    fn summarize(&self) -> String;
}

#[derive(Clone)]
struct Article {
    title: String,
    author: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} by {}", self.title, self.author)
    }
}

fn notify<T: Summary + Clone>(item: T) {
    println!("New notification: {}", item.summarize());
}

fn main() {
    let article = Article {
        title: String::from("Rust 배우기"),
        author: String::from("홍길동"),
    };

    notify(article);
    // println!("요약: {}", article.summarize());
}

다. 트레잇 객체 (Trait Object)

정확한 타입을 모르더라도 트레잇이 구현된 어떤 타입이든 받아들이고 싶을 때는 다음과 같이 dyn 키워드를 사용합니다.

fn notify(item: &dyn Summary) {
    println!("알림: {}", item.summarize());
}
  • 런타임 시 어떤 타입인지 결정됩니다 (동적 디스패치).
  • 반드시 참조(&) 형태로만 사용 가능합니다.
trait Summary {
    fn summarize(&self) -> String;
}

// #[derive(Clone)]
struct Article {
    title: String,
    author: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} by {}", self.title, self.author)
    }
}

fn notify(item: &dyn Summary) {
    println!("New notification: {}", item.summarize());
}

fn main() {
    let article = Article {
        title: String::from("Rust 배우기"),
        author: String::from("홍길동"),
    };

    notify(&article);
    // println!("요약: {}", article.summarize());
}

🧠 요약

개념설명
Generic여러 타입에 대해 재사용 가능한 코드
Trait공통 기능 정의, 타입에 메서드 제공
Trait Bound함수나 구조체에 트레잇 구현 타입만 허용
impl Trait간결하게 바운드 지정 가능
dyn Trait트레잇 객체, 런타임에 타입이 결정됨

구조체 (Structs)

Rust에서 복잡한 데이터를 다루기 위해 사용하는 기본 단위가 바로 구조체(struct)입니다. 구조체는 여러 개의 관련된 데이터를 하나의 타입으로 묶어 표현할 수 있도록 해줍니다. Rust는 구조체와 메서드(impl 블록)를 통해 모듈화, 캡슐화, 데이터 모델링이 가능합니다.


1. 기본 구조체 정의 및 사용

가장 기본적인 구조체는 struct 다음에 구조체 이름을 쓰고, 중괄호 안에 필드의 이름과 타입을 :으로 연결해 선언합니다.

Rust의 구조체 이름 규칙은 대문자 카멜 케이스(Camel case)입니다. 예를 들어 User, MySruct와 같이 단어 시작을 대문자로 하고, _를 사용할 수 있으며 숫자로 시작할 수 없고, 공백이 포함되면 안됩니다.

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

사용할땐 struct의 인스턴스를 생성합니다.

일반 변수 선언할 때와 마찬가지로 let 키워드를 사용하고, 그 다음에 인스턴스 이름을 적고, = 구조체 이름을 적은 다음 중괄호안에 필드의 이름과 값을, key: value 쌍으로 아래와 같이 입력합니다. 구조체는 모든 필드의 타입이 명확해야 합니다.

fn main() {
    let user1 = User {
        username: String::from("alice"),
        email: String::from("alice@example.com"),
        active: true,
    };

    println!("username: {}", user1.username);
}
  • 문자열(String)은 “alice”와 같이 큰따옴표 안에 입력한다고 되는 것이 아니며, String::from(“alice”)라고 하거나, “alice”.to_string()으로 입력해야 합니다.
  • bool(논리값)도 true, false와 같이 모두 소문자로 표기합니다.
  • 필드 값을 끝까지 입력하고, 쉼표가 있어도 문제가 없습니다.
  • 구조체 인스턴스는 tuple과 마찬가지로 . 연산자(notation)로 접근할 수 있습니다.
  • Rust는 사용하는 기호도 여러가지가 사용돼서 복잡합니다.

지금까지 나온 것이
변수의 형식은 : 다음에 표시하고,
println다음에 !를 붙여야 하며,
match 패턴의 경우 => 을 사용해서 실행 코드를 지정하고, else를 _로 표시하며,
숫자 입력시 천단위 구분 기호로 _를 사용하고,
char를 입력할 때는 작은 따옴표, String을 입력할 때는 큰따옴표,
반환 값의 타입을 지정할 때는 ->,
loop label은 ‘로 시작하며,
참조를 표시할 때는 &를 사용하고,
튜플과 구조체의 값을 지정할 때는 .을 사용합니다.


2. 구조체는 소유권을 가진다

Rust에서 구조체는 일반 변수처럼 소유권을 가집니다. 즉, 구조체를 다른 변수로 이동시키면 원래 변수는 더 이상 사용할 수 없습니다.

let user2 = user1; // user1의 소유권이 user2로 이동
// println!("{}", user1.email); // 오류!

필드 하나만 이동하는 경우도 마찬가지입니다.

    let username = user1.username; // 소유권 이동 (user1.username에 대한 소유권은 종료됨)

    // user1.username은 더 이상 유효하지 않음, username 변수가 소유권을 갖게 됨

    println!("username: {}", username);

일부 필드를 참조로 처리하거나 클론(clone)을 사용해야 합니다.

let username = &user1.username;
또는
let username = user1.username.clone();

3. 기존 구조체 인스턴스로 새 구조체 인스턴스 생성하기

구조체 인스턴스를 만들 때 기존 구조체를 기반으로 일부 필드만 바꾸고 싶은 경우, 다음과 같이 .. 문법을 사용하여 나머지는 (user2와) 동일하다고 할 수 있습니다:

let user3 = User {
    email: String::from("bob@example.com"),
    ..user2
};

단, user2는 이후 더 이상 사용할 수 없습니다. 그 이유는 username, email과 active 필드의 소유권이 user3에게로 넘어갔기 때문입니다.

또한 ..user2라고 나머지 필드는 똑같다고 할 때 맨 뒤에 ,를 붙이면 안됩니다. 구조체 정의할 때는 ,로 끝나도 되는 것과 구분됩니다.


4. 튜플 구조체 (Tuple Struct)

필드의 이름이 없고 형식만 있는 구조체도 정의할 수 있습니다. 이를 튜플 구조체라고 하며, 단순한 데이터 묶음에 유용합니다. 구조체 이름 다음이 중괄호가 아니라 소괄호인 것도 다릅니다.

struct Color(i32, i32, i32);

fn main() {
    let red = Color(255, 0, 0);
    println!("Red: {}, {}, {}", red.0, red.1, red.2);
}


5. 유사 유닛 구조체 (Unit-like Struct)

필드가 없는 구조체도 정의할 수 있습니다. 이를 유닛 구조체라고 하며, 마치 빈 enum처럼 동작하거나 타입 태깅 등에 사용됩니다.

struct Marker;

fn main() {
    let _m = Marker;
}

이런 구조체는 메모리를 차지하지 않으며, 값 자체보다 타입에 의미를 둘 때 사용됩니다.


6. 구조체에 메서드 구현

Rust는 구조체에 메서드(method)를 추가할 수 있습니다. impl 블록을 통해 구조체에 동작을 부여할 수 있습니다.

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

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

&self는 해당 메서드가 구조체 인스턴스를 참조로 빌려서 사용한다는 뜻입니다.

fn main() {
    let rect = Rectangle { width: 30, height: 50 };
    println!("면적: {}", rect.area());
}

impl 블록 안에는 여러 메서드(함수)를 정의할 수 있으며, 정적 메서드(fn new, 생성자 역할)는 다음처럼 작성합니다:

impl Rectangle {
    fn new(width: u32, height: u32) -> Self {
        Self { width, height }
    }
}

위와 같이 생성자를 선언한 경우 아래와 같이 Rectangle::new 다음의 괄호 안에 필드 이름을 입력할 필요 없이 너비와 높이만을 입력해서 인스턴스를 만들 수 있으며 , 면적을 계산하는 것은 같습니다.

    let rect1 = Rectangle::new(10, 20);
    println!("rect1 면적: {}", rect1.area());

7. 디버깅을위한 #[derive(Debug)]

구조체를 println!으로 출력하려면 Debug 트레이트를 구현해야 합니다.

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let p = Point { x: 3, y: 7 };
    println!("{:?}", p);
}

위에서 {:?} 포맷은 Debug 형식 출력을 의미하며, 결과는 Point { x: 3, y: 7 }처럼 구조체의 필드 이름과 값을 포함한 형태로 출력됩니다.

그러나, 아래와 같이 #[derive(Debug)]를 주석 처리하고 실행하면 “Point가 {:?}를 사용해서 포맷될 수 없다”는 에러 메시지가 표시됩니다.


마무리

Rust의 구조체는 단순한 데이터 묶음을 넘어서, 로직과 상태를 함께 표현할 수 있는 강력한 도구입니다. 구조체를 메서드와 함께 사용하면 객체지향적 모델도 자연스럽게 구현할 수 있으며, 안전하고 구조화된 데이터 설계가 가능합니다.