RUST · 심층 가이드
Rust 패턴 완전 정리
빌더와 전략, 옵저버, 상태 패턴부터 저장소·미들웨어 체인·의존성 주입까지 Rust에서 20가지 설계 패턴을 어떻게 바꿔 쓰는지 정리했습니다.
고전 디자인 패턴 상당수는 클래스 상속을 전제로 씌어졌는데 Rust에는 상속이 없습니다. 그래서 패턴을 그대로 옮기는 대신 트레이트와 합성으로 다시 쓰게 됩니다. 이 과정에서 어떤 패턴은 훨씬 단순해지고, 옵저버처럼 상호 참조가 필요한 패턴은 오히려 까다로워집니다. 패턴 이름보다 무엇을 분리하려는 패턴인가를 먼저 보는 편이 유용합니다.
빌더 패턴과 설정 객체는 Rust에서 가장 자연스럽게 자리 잡은 조합이라 여기서 시작하는 편이 좋습니다. 전략 패턴과 어댑터는 트레이트 하나로 정리되는 반면, 옵저버는 여러 곳에서 같은 대상을 참조해야 해서 Rc와 RefCell이 등장합니다. 상태 패턴은 열거형으로 표현하는 길과 타입 자체로 상태를 강제하는 길이 갈리므로 두 방식을 비교하며 읽으십시오.
정적 디스패치와 동적 디스패치의 선택이 조용한 갈림길입니다. 제네릭은 타입마다 코드를 찍어 내 빠르지만 컬렉션에 서로 다른 구현을 섞어 담을 수 없고, dyn Trait은 섞어 담을 수 있는 대신 트레이트가 객체 안전 조건을 만족해야 합니다. 제네릭 메서드나 Self를 반환하는 메서드가 있으면 그 트레이트는 dyn으로 쓸 수 없습니다.
01설정 객체
Discount trait과 그것을 구현한 무필드 구조체 Percent가 있고, apply(100)이 정수 연산으로 90을 돌려줍니다. 할인율 같은 설정을 값이 아니라 타입으로 굳혀 둘 때 이 모양이 나옵니다. value * 9 / 10처럼 곱셈을 먼저 두어야 정수 나눗셈의 버림 손실이 줄고, 순서를 바꾸면 결과가 달라집니다.
Rust code
// 설정 객체
trait Discount { fn apply(&self, value: i32) -> i32; }
struct Percent;
impl Discount for Percent { fn apply(&self, value: i32) -> i32 { value * 9 / 10 } }
fn main() {
println!("{}", Percent.apply(100));
}value * 9 / 10처럼 곱셈을 먼저 두면 정수 나눗셈에서 버려지는 값이 줄어들고, 순서를 바꾸면 같은 입력에도 결과가 달라집니다.
값이 큰 입력에서는 value * 9가 먼저 i32 범위를 넘길 수 있으므로, 금액을 다룬다면 더 넓은 타입이나 checked_mul이 필요합니다.
02전략 패턴
HashMap을 만든 뒤 entry(2).or_insert("user")로 값이 없을 때만 채우고 cache[&2]로 다시 읽습니다. 기본값을 한 번만 심어 두고 싶을 때 쓰는 형태입니다. 키가 이미 있으면 덮어쓰지 않는다는 점이 insert와의 차이이며, entry는 키를 소유권째 가져가므로 String 키라면 매번 할당이 생깁니다.
Rust code
// 전략 패턴
use std::collections::HashMap;
fn main() {
let mut cache = HashMap::new();
cache.entry(2).or_insert("user");
println!("{}", cache[&2]);
}entry(k).or_insert(v)는 키가 없을 때만 값을 채우고 이미 있으면 그대로 두므로, 기본값을 한 번만 심어 두는 데 알맞습니다.
같은 자리에 insert를 쓰면 기존 값을 덮어써 버리므로, 먼저 들어온 값을 지켜야 하는 캐시에서는 결과가 달라집니다.
03옵저버
알림을 받는 쪽도 등록 절차도 없이, Percent가 Discount를 구현한 호출 한 번으로 끝납니다. 관찰자를 여러 개 붙이려면 Vec<Box<dyn Observer>>처럼 trait 객체 목록을 들고 순회하게 됩니다. 이때 통지를 받은 쪽이 자신을 등록한 대상을 다시 빌리면 RefCell이 런타임에 패닉을 냅니다.
Rust code
// 옵저버
trait Discount { fn apply(&self, value: i32) -> i32; }
struct Percent;
impl Discount for Percent { fn apply(&self, value: i32) -> i32 { value * 9 / 10 } }
fn main() {
println!("{}", Percent.apply(100));
}관찰자를 여러 개 붙이려면 Vec<Box<dyn Observer>>로 목록을 들고 차례로 통지하는 형태가 되며, 등록과 통지가 자연스럽게 분리됩니다.
통지를 받은 쪽이 자신을 등록한 목록을 다시 빌리면 RefCell이 런타임에 패닉을 내므로, 통지 중의 등록 변경은 나중에 몰아 처리해야 합니다.
04데코레이터
무언가를 감싸는 계층 없이 HashMap을 직접 조작해 기본값을 넣고 값을 꺼냅니다. 기존 동작 위에 캐싱 같은 층을 덧씌우려면 이 맵을 필드로 가진 구조체를 만들어 같은 trait을 구현합니다. or_insert의 인자는 키 존재 여부와 상관없이 미리 평가되므로, 값 계산이 비싸면 or_insert_with로 미뤄야 합니다.
Rust code
// 데코레이터
use std::collections::HashMap;
fn main() {
let mut cache = HashMap::new();
cache.entry(4).or_insert("user");
println!("{}", cache[&4]);
}기존 동작 위에 캐싱 같은 층을 덧씌우려면 이 맵을 필드로 가진 구조체를 만들어 같은 트레이트를 구현하면 호출부가 그대로 유지됩니다.
or_insert의 인자는 키가 있든 없든 먼저 평가되므로, 값 계산이 비싸다면 or_insert_with로 미루지 않으면 캐시가 무의미해집니다.
05어댑터
Percent는 필드가 하나도 없는 유닛 구조체이고, Discount가 요구하는 apply만 채워 인터페이스를 만족시킵니다. 기존 계산을 다른 인터페이스에 맞춰 끼울 때 이런 얇은 구현체를 둡니다. 유닛 구조체는 크기가 0이라 값으로 넘겨도 실제로 옮겨지는 바이트가 없습니다.
Rust code
// 어댑터
trait Discount { fn apply(&self, value: i32) -> i32; }
struct Percent;
impl Discount for Percent { fn apply(&self, value: i32) -> i32 { value * 9 / 10 } }
fn main() {
println!("{}", Percent.apply(100));
}Percent처럼 필드가 없는 유닛 구조체는 크기가 0이라 값으로 넘겨도 실제로 옮겨지는 바이트가 없습니다.
이런 얇은 구현체에 상태를 슬쩍 넣기 시작하면 더 이상 크기가 0이 아니게 되어, 값으로 복사하던 자리마다 비용이 생깁니다.
06커맨드
명령을 값으로 다루는 부분은 없고 맵 갱신 한 줄이 전부이며, 결과로 user가 출력됩니다. 실행할 동작을 나중에 꺼내 쓰려면 클로저를 Box<dyn Fn()>로 담아 맵에 등록하는 구조가 됩니다. 클로저가 주변 변수를 캡처하면 수명이 얽히므로 move로 소유권을 넘겨 담는 경우가 많습니다.
Rust code
// 커맨드
use std::collections::HashMap;
fn main() {
let mut cache = HashMap::new();
cache.entry(6).or_insert("user");
println!("{}", cache[&6]);
}실행할 동작을 나중에 꺼내 쓰려면 클로저를 Box<dyn Fn()>으로 담아 맵에 등록하고, 키로 골라 부르는 형태가 됩니다.
클로저가 주변 변수를 빌려 캡처하면 맵보다 먼저 사라질 수 있어 수명 오류가 나므로, move로 소유권을 넘겨 담아야 합니다.
07상태 패턴
상태 전이는 나오지 않고 Discount 구현체 하나를 바로 호출합니다. 상태마다 동작이 갈린다면 각 상태를 별개 타입으로 만들고 같은 trait을 구현하는 것이 이 코드의 자연스러운 확장입니다. 전이 메서드가 self를 소비해 새 상태를 반환하게 두면, 지나간 상태를 실수로 다시 쓰는 일을 컴파일러가 막아 줍니다.
Rust code
// 상태 패턴
trait Discount { fn apply(&self, value: i32) -> i32; }
struct Percent;
impl Discount for Percent { fn apply(&self, value: i32) -> i32 { value * 9 / 10 } }
fn main() {
println!("{}", Percent.apply(100));
}상태마다 별개 타입을 만들고 같은 트레이트를 구현하면, 상태별로 허용되는 동작을 타입 수준에서 나눌 수 있습니다.
전이 메서드가 &self만 받으면 지나간 상태를 계속 들고 쓸 수 있으므로, self를 소비해 새 상태를 돌려주도록 만들어야 오용이 막힙니다.
08팩터리 메서드
생성 함수는 없고 HashMap::new()로 빈 맵을 만든 다음 키 하나를 등록합니다. 입력에 따라 다른 구현을 만들어 주고 싶다면 키를 받아 Box<dyn Trait>를 돌려주는 함수를 두게 됩니다. 반환할 구체 타입이 갈래마다 다르면 impl Trait으로는 묶이지 않으므로 trait 객체가 필요합니다.
Rust code
// 팩터리 메서드
use std::collections::HashMap;
fn main() {
let mut cache = HashMap::new();
cache.entry(8).or_insert("user");
println!("{}", cache[&8]);
}입력에 따라 다른 구현을 만들어 주려면 키를 받아 Box<dyn Trait>를 돌려주는 함수를 두면 호출부가 구체 타입을 몰라도 됩니다.
갈래마다 반환할 구체 타입이 다르면 impl Trait으로는 묶이지 않으므로, 트레이트 객체로 바꾸지 않는 한 컴파일이 막힙니다.
09빌더 패턴
Percent에는 설정할 필드가 없어서 조립 단계 자체가 필요 없는 구현입니다. 옵션이 여럿 생기면 self를 받아 Self를 돌려주는 메서드 체인으로 늘려 갑니다. 마지막 build()를 부르지 않으면 아무 일도 일어나지 않는데, #[must_use]를 붙여 두면 그 실수를 컴파일 경고로 잡을 수 있습니다.
Rust code
// 빌더 패턴
trait Discount { fn apply(&self, value: i32) -> i32; }
struct Percent;
impl Discount for Percent { fn apply(&self, value: i32) -> i32 { value * 9 / 10 } }
fn main() {
println!("{}", Percent.apply(100));
}옵션이 여럿 생기면 self를 받아 Self를 돌려주는 메서드 체인으로 늘려 가고, 마지막에 완성된 값을 만들어 냅니다.
체인 도중 반환값을 버리면 지금까지 설정한 내용이 조용히 사라지므로, 빌더 메서드에는 미사용 경고를 붙여 실수를 드러내야 합니다.
10캐시 래퍼
변수 이름 그대로 HashMap을 캐시처럼 써서, 키가 비어 있을 때만 값을 채우고 곧바로 조회합니다. 계산 결과를 재사용할 때 가장 먼저 꺼내는 형태입니다. 다만 표준 HashMap에는 만료도 용량 상한도 없어서, 오래 도는 프로세스에 그대로 두면 메모리가 계속 늘어납니다.
Rust code
// 캐시 래퍼
use std::collections::HashMap;
fn main() {
let mut cache = HashMap::new();
cache.entry(10).or_insert("user");
println!("{}", cache[&10]);
}계산 결과를 재사용할 때는 키가 비어 있을 때만 채우는 이 형태가 가장 단순하고, 조회와 삽입이 해시 한 번으로 끝납니다.
표준 HashMap에는 만료도 용량 상한도 없어, 오래 도는 프로세스에 그대로 두면 캐시가 계속 자라 메모리를 잠식합니다.
11retry 유틸
재시도 흐름은 없고 apply가 한 번 불려 90이 출력됩니다. 이 함수는 실패할 수 없는 순수 계산이라 재시도 대상이 아니며, 재시도는 Result를 돌려주는 입출력 쪽에 붙습니다. 횟수 상한과 점점 늘어나는 대기 시간을 함께 두지 않으면 장애가 났을 때 오히려 부하를 키웁니다.
Rust code
// retry 유틸
trait Discount { fn apply(&self, value: i32) -> i32; }
struct Percent;
impl Discount for Percent { fn apply(&self, value: i32) -> i32 { value * 9 / 10 } }
fn main() {
println!("{}", Percent.apply(100));
}재시도는 Result를 돌려주는 입출력 쪽에 붙이는 것이며, 이 apply처럼 실패할 수 없는 순수 계산은 대상이 아닙니다.
횟수 상한과 점점 늘어나는 대기 시간을 두지 않고 재시도하면, 장애가 났을 때 상대에게 요청을 몰아 주어 상황을 더 나쁘게 만듭니다.
12미들웨어 체인
체인을 구성하는 부분 없이 맵에 항목 하나를 넣고 즉시 조회합니다. 요청 처리 단계를 이어 붙이려면 다음 단계를 필드로 들고 있는 타입을 겹겹이 쌓는 방식이 흔합니다. 이 중첩을 제네릭으로 만들면 타입 이름이 끝없이 길어지고, trait 객체로 만들면 단계마다 동적 디스패치를 거칩니다.
Rust code
// 미들웨어 체인
use std::collections::HashMap;
fn main() {
let mut cache = HashMap::new();
cache.entry(12).or_insert("user");
println!("{}", cache[&12]);
}요청 처리 단계를 이어 붙일 때는 다음 단계를 필드로 들고 있는 타입을 겹겹이 쌓아, 각 단계를 독립적으로 갈아 끼울 수 있게 만듭니다.
이 중첩을 제네릭으로 쌓으면 타입 이름이 끝없이 길어져 오류 메시지를 읽기 어려워지고, 트레이트 객체로 바꾸면 단계마다 동적 디스패치가 붙습니다.
13검증기 조합
검사 규칙 여럿을 엮는 대신, 규칙 하나를 Discount 구현으로 표현했습니다. 여러 규칙을 모을 때는 같은 trait을 구현한 값들을 벡터에 담아 차례로 적용합니다. 첫 실패에서 멈출지 오류를 모두 모을지에 따라 반환 타입이 Result<_, E>가 되기도 하고 Vec<E>가 되기도 합니다.
Rust code
// 검증기 조합
trait Discount { fn apply(&self, value: i32) -> i32; }
struct Percent;
impl Discount for Percent { fn apply(&self, value: i32) -> i32 { value * 9 / 10 } }
fn main() {
println!("{}", Percent.apply(100));
}같은 트레이트를 구현한 규칙들을 벡터에 담아 차례로 적용하면, 규칙을 늘리는 일이 목록에 값을 하나 넣는 것으로 끝납니다.
첫 실패에서 멈추도록 이으면 사용자는 문제를 하나씩만 보게 되므로, 전부 보여 줘야 한다면 오류를 Vec에 모으도록 반환 타입을 바꿔야 합니다.
14포매터 파이프
문자열 변환 파이프는 없고 &str 값을 맵에 넣었다가 꺼내 출력합니다. 리터럴 "user"는 &'static str이라 소유권 고민 없이 맵에 담깁니다. 런타임에 만들어진 문자열을 넣으려면 값 타입을 String으로 바꿔야 하고, 그때부터는 항목마다 힙 할당이 따라옵니다.
Rust code
// 포매터 파이프
use std::collections::HashMap;
fn main() {
let mut cache = HashMap::new();
cache.entry(14).or_insert("user");
println!("{}", cache[&14]);
}리터럴 "user"는 &'static str이라 프로그램이 끝날 때까지 살아 있어, 수명 걱정 없이 맵에 담을 수 있습니다.
런타임에 만든 문자열을 담으려면 값 타입을 String으로 바꿔야 하고, 그때부터는 항목마다 힙 할당이 따라붙습니다.
15페이지네이션
페이지 계산은 어디에도 없고 정수 하나를 받아 변환하는 apply 호출만 보입니다. 목록을 나눠 보여 줄 때는 offset과 limit을 받아 슬라이스를 잘라 내는 함수가 됩니다. 범위를 벗어난 인덱스는 &v[a..b]에서 곧바로 패닉이므로, 값을 신뢰할 수 없다면 get(a..b)로 Option을 받는 편이 안전합니다.
Rust code
// 페이지네이션
trait Discount { fn apply(&self, value: i32) -> i32; }
struct Percent;
impl Discount for Percent { fn apply(&self, value: i32) -> i32 { value * 9 / 10 } }
fn main() {
println!("{}", Percent.apply(100));
}목록을 나눠 보여 줄 때는 시작 위치와 개수를 받아 슬라이스를 잘라 내며, 끝을 len()과 비교해 잘라 두면 마지막 페이지도 안전합니다.
슬라이스 범위가 길이를 넘으면 그 자리에서 패닉하므로, 페이지 번호가 외부 입력이라면 get이 돌려주는 Option으로 받아야 합니다.
16데이터 매퍼
키에서 값으로 가는 대응을 HashMap에 담고 인덱싱으로 읽어 옵니다. 고정된 변환 표를 다룰 때 손이 먼저 가는 형태입니다. cache[&16] 같은 인덱싱은 키가 없으면 패닉으로 끝나므로, 바깥에서 들어온 값을 키로 쓴다면 get이 돌려주는 Option을 처리해야 합니다.
Rust code
// 데이터 매퍼
use std::collections::HashMap;
fn main() {
let mut cache = HashMap::new();
cache.entry(16).or_insert("user");
println!("{}", cache[&16]);
}고정된 변환 표는 HashMap에 담아 두면 분기문이 늘어나지 않고, 표만 고쳐 대응 관계를 바꿀 수 있습니다.
cache[&16] 같은 인덱싱은 키가 없으면 패닉으로 끝나므로, 바깥에서 들어온 값을 키로 쓴다면 get이 주는 Option을 처리해야 합니다.
17저장소 패턴
Discount처럼 동작 시그니처만 정한 trait은 저장소 추상화에도 그대로 쓰이는 골격입니다. 여기서는 구현체가 Percent 하나뿐이고 데이터 접근은 없습니다. 저장소 trait의 메서드를 &self만 받도록 설계하면 여러 곳에서 공유하기 쉬워지고, 안쪽 가변 상태는 Mutex로 감춰 두면 됩니다.
Rust code
// 저장소 패턴
trait Discount { fn apply(&self, value: i32) -> i32; }
struct Percent;
impl Discount for Percent { fn apply(&self, value: i32) -> i32 { value * 9 / 10 } }
fn main() {
println!("{}", Percent.apply(100));
}저장소 트레이트의 메서드를 &self만 받도록 두면 여러 곳에서 공유하기 쉬워지고, 안쪽 가변 상태는 Mutex로 감춰 두면 됩니다.
트레이트 시그니처에 구체적인 데이터베이스 타입이 새어 나오면 메모리 구현으로 갈아 끼울 수 없게 되어, 테스트가 실제 연결에 묶입니다.
18의존성 주입 컨테이너
컨테이너라 할 만한 것 없이 맵 하나에 이름과 값을 등록해 두는 최소 형태입니다. 실제 DI 컨테이너를 흉내 내려면 담기는 타입이 제각각이라 Box<dyn Any> 같은 것을 넣고 꺼낼 때 다시 캐스팅해야 합니다. 그 순간 타입 오류가 컴파일 시점에서 런타임으로 밀려나므로, Rust에서는 그냥 인자로 넘기는 쪽이 더 흔합니다.
Rust code
// 의존성 주입 컨테이너
use std::collections::HashMap;
fn main() {
let mut cache = HashMap::new();
cache.entry(18).or_insert("user");
println!("{}", cache[&18]);
}러스트에서는 의존 대상을 컨테이너에서 꺼내기보다 생성 시점에 인자로 넘기는 편이 흔하고, 그렇게 하면 연결이 컴파일 시점에 확인됩니다.
Box<dyn Any>에 담았다가 꺼낼 때 캐스팅하는 구조는 타입 오류를 런타임으로 미루므로, 등록을 빠뜨려도 실행해 봐야 알게 됩니다.
19템플릿 메서드
trait이 apply의 모양만 정하고 실제 계산은 Percent가 채웁니다. 전체 흐름은 공통으로 두고 일부만 갈아 끼우고 싶다면 trait 메서드에 기본 구현을 적어 두면 됩니다. 기본 구현은 구현체가 재정의하지 않을 때만 쓰이며, 안에서 다른 필수 메서드를 부르는 식으로 순서를 고정할 수 있습니다.
Rust code
// 템플릿 메서드
trait Discount { fn apply(&self, value: i32) -> i32; }
struct Percent;
impl Discount for Percent { fn apply(&self, value: i32) -> i32 { value * 9 / 10 } }
fn main() {
println!("{}", Percent.apply(100));
}공통 흐름은 트레이트 메서드의 기본 구현에 적고 달라지는 부분만 필수 메서드로 남기면, 구현체는 빈칸만 채우면 됩니다.
기본 구현은 구현체가 재정의하지 않을 때만 쓰이므로, 실수로 같은 이름을 덮어쓰면 정해 둔 호출 순서가 조용히 무시됩니다.
20파사드
하위 절차를 감추는 대신 맵 초기화와 조회가 main에 그대로 드러나 있습니다. 여러 단계를 하나의 진입점으로 묶고 싶을 때 이런 코드를 구조체 메서드 뒤로 옮깁니다. 그렇게 감싸면 내부 자료구조를 pub으로 노출하지 않아도 되어, 나중에 HashMap을 다른 것으로 바꿔도 호출부가 그대로입니다.
Rust code
// 파사드
use std::collections::HashMap;
fn main() {
let mut cache = HashMap::new();
cache.entry(20).or_insert("user");
println!("{}", cache[&20]);
}여러 단계를 구조체 메서드 뒤로 옮기면 내부 자료구조를 공개하지 않아도 되어, 나중에 HashMap을 다른 것으로 바꿔도 호출부가 그대로입니다.
감싸는 층을 두면서 내부 맵을 pub으로 함께 노출하면 우회 경로가 생겨, 파사드가 보장하려던 순서나 검증이 지켜지지 않습니다.
정리하며
- 상속이 없으므로 패턴은 트레이트와 합성으로 다시 씁니다
- 전략·어댑터는 트레이트 하나로 단순해지지만 옵저버는 Rc와 RefCell을 부릅니다
- 상태는 열거형으로 표현하거나 타입으로 강제하는 두 갈래가 있습니다
- dyn Trait은 객체 안전 조건을 만족해야 하며 제네릭 메서드는 그것을 깹니다
더 깊이 들어가고 싶다면 Rust 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.