러스트의 경우 처음에 명령 프롬프트 창에서 포트를 열어놓고 Cargo run을 하라고 하여 Python은 포트를 열 필요없이 잘 접속되는데 해서 비교하게 되었습니다. 그리고, 보면 파이썬은 편리한 상태를 만들어 놓았는데, Rust는 처음부터 내가 만들어가야 하는 상황입니다.
Chromedriver가 Path에 있다면 그냥 실행하면 되는데, path가 설정되어 있지 않아, chromedriver.exe가 있는 폴더로 이동해서 실행했습니다.
(2) 브라우저 열기 Rust 코드
(가) Cargo.toml
[dependencies]
thirtyfour = "0.36.1"
tokio = { version = "1", features = ["full"] }
thirtyfour와 tokio 라이브러리를 가져와야 합니다.
(나) main.rs
use thirtyfour::prelude::*;
use tokio;
#[tokio::main]
async fn main() -> WebDriverResult<()> {
// 이미 chromedriver가 9515 포트에서 실행 중이라고 가정
let driver = WebDriver::new("http://localhost:9515", DesiredCapabilities::chrome()).await?;
// 페이지 접속
driver.get("https://www.rust-lang.org").await?;
// 타이틀 가져오기
let title = driver.title().await?;
println!("Page title: {}", title);
// 종료
driver.quit().await?;
Ok(())
}
코드이 내용은 port가 열려 있기 때문에 다시 열 필요는 없고, 9515 포트로 driver를 설정한 다음, rust-lang.org에 접속한 후 title을 가져와서 화면에 출력하는 것입니다.
문제 없이 코드가 실행되고, title이 표시됩니다.
나. 코드로 포트 열기 방식
명령 프롬프트에서 포트를 연 다음 Cargo run을 한다는 것이 이상하므로 Rust에서 포트를 열고, 실행하려면 아래와 같이 코드를 작성하면 됩니다.
Cargo.toml은 동일하고,
main.rs만 아래와 같이 수정하면 됩니다.
use std::process::Command;
use thirtyfour::prelude::*;
use tokio;
#[tokio::main]
async fn main() -> WebDriverResult<()> {
// 실행파일이 있는 디렉토리 경로
let exe_dir = std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let chromedriver_path = exe_dir.join("chromedriver.exe");
println!("chromedriver 경로: {}", chromedriver_path.display());
// chromedriver 실행
let mut child = Command::new(&chromedriver_path)
.arg("--port=9515")
.spawn()
.expect("chromedriver 실행 실패");
// WebDriver 클라이언트 연결
let caps = DesiredCapabilities::chrome();
let driver = WebDriver::new("http://localhost:9515", caps).await?;
driver.get("https://www.rust-lang.org").await?;
println!("현재 페이지 타이틀: {}", driver.title().await?);
// 브라우저 닫기
driver.quit().await?;
// chromedriver 프로세스 종료
child.kill().ok();
Ok(())
}
use std::process::Command;가 추가되었습니다.
main에서 먼저 chromedriver_path를 설정하고,
Command::new를 이용해 포트를 연 다음 child에 저장하고,
caps는 python Selenium에서 사용하는 ChromeOptions 역할입니다. 기본적인 옵션만 설정하는 것입니다. caps.add_arg(“–headless”)?; 등을 추가해서 옵션을 추가할 수 있습니다.
그리고, WebDriver::new로 http://localhost:9515라고 9515포트를 이용해 localhost를 연 다음
driver.get로 https://www.rust-lang.org를 연 다음
driver.title().await?로 title을 가져와서 화면에 출력합니다.
아래는 Visual Studio Code에서 Run한 장면입니다.
Compile과 실행 잘 되고, ChromeDriver was started successfully on port 9515.와
현재 페이지 타이틀 : Rust Programming Language라고 잘 나옵니다.
2. Python을 이용한 웹 접속
가. chrome_connect.py
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
import os
def main():
# 실행 파일이 있는 디렉토리 기준으로 chromedriver.exe 찾기
exe_dir = os.path.dirname(os.path.abspath(__file__))
chromedriver_path = os.path.join(exe_dir, "chromedriver.exe")
# chromedriver.exe 고정 경로
# chromedriver_path = r"C:\android\chromedriver.exe"
# print(f"chromedriver 경로: {chromedriver_path}")
# chromedriver 실행 (포트 지정 없이 내부적으로 관리)
service = Service(chromedriver_path)
options = webdriver.ChromeOptions()
driver = webdriver.Chrome(service=service, options=options)
try:
driver.get("https://www.rust-lang.org")
print(f"현재 페이지 타이틀: {driver.title}")
finally:
driver.quit()
if __name__ == "__main__":
main()
Python은 Cargo.toml과 같은 설정이 필수가 아니고,
바로 python code를 위와 같이 작성하면 됩니다.
실행 결과는 아래와 같습니다.
나. 코드 내용
from selenium import webdriver from selenium.webdriver.chrome.service import Service import os
필요한 selenium과 os 라이브러리를 불러옵니다.
# 실행 파일이 있는 디렉토리 기준으로 chromedriver.exe 찾기
exe_dir = os.path.dirname(os.path.abspath(__file__))
chromedriver_path = os.path.join(exe_dir, "chromedriver.exe")
# chromedriver.exe 고정 경로
# chromedriver_path = r"C:\android\chromedriver.exe"
실행 파일이 있는 폴더의 chromedriver.exe를 chromedriver_path로 설정합니다.
주석 처리한 것처럼 고정 경로로 지정할 수도 있습니다. 여러 프로그램에서 공통적으로 사용할 수 있으므로 PC에서만 작업한다면 이것이 편할 수 있습니다.
print(f"chromedriver 경로: {chromedriver_path}")
chromedriver_path를 화면에 출력합니다.
# chromedriver 실행 (포트 지정 없이 내부적으로 관리) service = Service(chromedriver_path) options = webdriver.ChromeOptions()
Service 메소드를 이용해 service를 생성하고, webdriver.ChromeOptions()로 크롬 설정을 기본으로 합니다.
자바(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)처럼 트레이트 이름을 명시해 충돌 해결.
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)
함수, 구조체, 열거형 등에서 트레이트를 타입 매개변수 조건으로 지정하여 유연한 재사용성 제공.
Rust에서의 attribute(속성)는 컴파일러에게 특정 코드에 대한 추가적인 정보를 제공하여, 코드의 컴파일 방식 또는 동작 방식을 제어하거나, 경고/에러 메시지를 제어하는 데 사용됩니다. derive, allow, warn, cfg, test 등 다양한 종류가 있습니다.
1. attribute의 적용 범위별 종류
Rust의 attribute는 크게 다음과 같은 종류로 나눌 수 있습니다:
종류
형태 예시
설명
Item attribute
#[derive(Debug)]
함수, 구조체 등 개별 항목에 적용
Crate attribute
#![allow(dead_code)]
크레이트 전체에 적용, 보통 파일 상단에 위치
Inner attribute
#![cfg(test)]
모듈 또는 크레이트 내부에 선언, 내부 항목에 영향을 줌
Outer attribute
#[test]
특정 항목(함수 등)에만 적용
2. 주요 attribute
가. 컴파일러 관련 attribute
Attribute
설명
예시
#[allow(…)]
특정 경고를 무시
#[allow(dead_code)]
#[warn(…)]
경고를 표시 (기본값)
#[warn(unused_variables)]
#[deny(…)]
해당 사항이 있으면 컴파일 에러
#[deny(missing_docs)]
#[forbid(…)]
deny보다 강하게 재정의 불가
#[forbid(unsafe_code)]
나. 파생(derive) 관련 attribute
Rust의 많은 기능은 trait을 자동으로 구현해주는 #[derive(…)]를 통해 사용합니다.
“my_feature”라는 기능(feature)이 활성화됐을 때만 my_func() 함수의 정의 자체를 컴파일에 포함시키고, 아니면 아예 없는 코드처럼 무시해버립니다.
마. #[inline] — 인라인 최적화 힌트
(1) 설명: 컴파일러에게 해당 함수를 인라인하도록 유도
(2) 예제:
#[inline(always)] fn fast_add(a: i32, b: i32) -> i32 { a + b }
fn main() { let x = add(3, 4); }
일반 호출: main에서 add 함수로 점프하고, 결과를 받아와서 x에 저장
인라인: 컴파일할 때 add(3, 4)를 그냥 3 + 4로 바꿔서 main 함수 안에 넣어버림
바. #[repr(…)] — 메모리 레이아웃 제어
(1) 설명: 구조체/열거형의 메모리 정렬 방법을 지정(repr은 representation(표현)의 약어)
(2) 종류:
C: C 언어와 동일한 레이아웃
packed: 패딩 없이 압축
transparent: 단일 필드 감싸기
(3) 예제:
#[repr(C)] struct MyStruct { a: u8, b: u32, }
사. #[non_exhaustive] — 미래 확장을 위한 열거형
(1) 설명: 나중에 항목이 더 생길 수 있으니, “지금 있는 것만 가지고 match를 완벽하게 쓰지 마세요“라는 의미입니다.
(2) 예제:
#[non_exhaustive] pub enum Error { Io, Parse, }
이 코드는 다음과 같은 의미를 가집니다.
Error enum은 지금은 Io와 Parse 두 가지 variant만 있지만, 앞으로 새로운 variant가 추가될 수 있으므로 다른 크레이트에서는 이 enum을 match할 때 지금 있는 것만으로 열거하지 못하게 제한하는 것입니다.
그래서 #[non_exhaustive]를 붙이면, 사용자는 반드시 _를 이용한 default arm을 추가해야만 컴파일이 가능하며, 이것은 다른 크레이트가 match를 지금 있는 것만으로 exhaustively(완전하게) 작성하면, 나중에 enum에 새로운 variant를 추가했을 때 컴파일이 깨질 수 있기 때문입니다.
위 코드는 important_result 함수의 반환 값을 사용하지 않아 warning(경고)이 발생하므로 아래와 같은 식으로 수정해야 합니다.
fn main() {
let result = important_result(); // OK
if let Err(e) = result {
println!("에러 발생: {}", e);
}
}
자. #[macro_use], #[macro_export] — 매크로 관련
(1) 설명: 외부 crate의 매크로를 가져오거나 내보낼 때 사용
(2) 예제:
// 외부 매크로 사용 #[macro_use] extern crate log;
// 매크로 정의 및 export #[macro_export] macro_rules! hello { () => { println!("Hello, macro!"); }; }
#[macro_use] : 외부 크레이트 log에서 정의된 매크로(log!, info!, warn! 등)들을 이 파일 안에서 직접 사용할 수 있도록 가져오라는 의미로서, 예전 Rust 스타일 (Rust 2018 이전)의 방식이며, 최신 Rust (2018 edition 이후)에서는 use log::info; 식으로 가져오는 게 일반적입니다.
#[macro_export] : 이 매크로를 다른 모듈/크레이트에서 사용할 수 있게 공개(export) 하겠다는 의미입니다. 따라서, 이 매크로를 crate_name::hello!() 식으로 다른 crate에서도 쓸 수 있습니다.
Rust에는 소유권, 참조, 라이프타임 등 고유한 용어들이 있는데 이외에도 생소하거나 중요한 용어인 variant, field, pattern, match arm, block, scope, associated type, attribute에 대해서 살펴 보겠습니다.
1. variant
정의: enum에서 각각의 경우(상태/종류)를 의미.
예시:
enum Color { Red, Blue, Green, } let c = Color::Red; // Red는 Color 타입의 variant
여기서 Red, Blue, Green이 각각 variant이다.
2. field
정의: struct(또는 enum의 variant)에 속하는 개별 데이터 항목.
예시:
struct Point { x: i32, // field y: i32, // field } let p = Point { x: 1, y: 2 };
x와 y가 각각 field이고, i32는 각각의 field의 타입(type of the field 또는 필드형/필드 타입)입니다.
enum Message { Quit, // 필드 없음 Move { x: i32, y: i32 }, // 필드 x, y가 있는 구조체 스타일 Write(String), // 이름 없는 튜플 스타일의 필드 String ChangeColor(i32, i32, i32), // 이름 없는 튜플 스타일의 필드 i32 }
Message enum의 variant가 갖는 값 또는 구조가 곧 해당 variant의 “필드”입니다. 구조체 스타일인 경우는 필드 개념이 동일하며, 튜플 형식인 경우는 구조체와 달리 필드가 없지만 값이 필드가 됩니다.
enum은 이 필드(데이터) 덕분에, variant별로 타입에 따라 다양한 정보를 유연하게 표현할 수 있습니다.
3. pattern
정의: 값을 구조적으로 분해 처리하기 위한 형태. match, let, 함수 매개 변수 등에서 사용합니다.
예시:
// struct Point 정의 struct Point { x: i32, y: i32, }
fn main() { // 튜플 패턴 예제 let (a, b) = (1, 2); println!("a = {}, b = {}", a, b);
// 구조체 및 구조체 패턴 매칭 예제 let p = Point { x: 10, y: 20 }; match p { Point { x, y } => println!("({}, {})", x, y), } }
패턴을 이용해 복잡한 데이터를 쉽게 분해할 수 있다.
let (a, b) = (1, 2); => 오른쪽 (1, 2)는 타입이 (i32, i32)인 튜플이며, 이 값을 튜플로 받아서, 첫 번째 요소는 변수 a에, 두 번째 요소는 변수 b에 바인딩해줘”라는 뜻입니다.
match p { Point { x, y } => println!(“({}, {})”, x, y), } 에서 Point { x, y }는 매칭 대상(p)이 해당 구조와 일치하는지 검사하고, 일치한다면 그 필드값을 변수로 분해해주는 패턴 역할을 합니다. 따라서 match에서 구조체 내부 값을 분해하고 싶으면 항상 이런 형태의 패턴을 사용하게 됩니다.
4. match arm
정의: match 구문의 각분기(패턴 + 처리 블록).
예시:
let n = 3; match n { 1 => println!("One"), // arm 1 2 | 3 | 5 => println!("Prime"), // arm 2 _ => println!("Other"), // arm 3 }
각각이 match arm이며, 패턴과 실행할 코드 블록으로 구성되어 있다.
match arm에서의 패턴(pattern)은, 매칭 대상이 되는 값이 어떤 구조나 값을 가지고 있는지 비교하고, 해당 구조와 일치하면 그 arm의 코드를 실행하도록 하는 역할을 합니다. 즉, match 구문의 각 arm(갈래, 분기)은 패턴 => 실행코드 형태로 이루어지며, 패턴은 값의 형태를 설명하거나 내부 값을 분해하는 구조입니다
4. block
정의: 중괄호 {}로 둘러싸인 코드 구역. 블록은 표현식이며 값과 타입을 가진다.
예시:
let result = { let x = 2; x * x // 마지막 표현식이 결과값이 됨 }; // result = 4
블록 내에서 선언된 변수는 해당 블록에서만 유효하다.
5. scope
정의: 변수나 아이템이 유효한 코드의 범위.
예시:
fn main() { let x = 10; // x는 main 함수 블록(scope)에서만 유효 }
scope이 끝나면 변수는 더 이상 쓸 수 없다.
가. block과 scope 비교
(1) block
코드에서 중괄호 {}로 둘러싸인 부분 자체를 block이라고 합니다.
예시:rust{ let a = 1; println!("{a}"); }
이 부분 전체가 block입니다.
(2) scope
scope는 어떤 변수(또는 아이템)를 ‘볼 수 있고 사용할 수 있는 코드의 범위’입니다.
scope는 보통 block에 의해 결정되지만, 완전히 같지는 않습니다.
모든 block은 새로운 scope를 열지만,
scope의 개념은 block 외에도 함수, 모듈, crate 등 더 넓거나 좁게 적용될 수 있습니다.
(3) 차이점 및 예제
block: { ... }로 감싸진 모든 코드 덩어리를 의미.
scope: 그 안에서 선언된 변수나 아이템이 유효한 코드의 범위.
모든 block이 scope를 정의하지만, scope는 더 넓은 개념입니다.
예시:
fn main() { // main 함수 block, 여기가 scope 시작 let outer = 10; // 'outer'의 scope는 main 함수 전체
{ // 새로운 block 시작, 이 안이 block scope let inner = 20; // 'inner'의 scope는 이 중괄호 안 println!("{}, {}", outer, inner); } // inner는 여기서 scope 종료
Rust의 slice(슬라이스)는 배열, 벡터, 문자열 등 메모리상에 연속적으로 저장된 데이터의 일부 구간을 참조하는 타입이고, 컬렉션 참조는 컬렉션(배열 포함) 전체에 대한 참조입니다. 그러나, 모두 소유권을 갖지 않고 데이터를 참조하는 것은 같지만, 그 목적과 내부 구조, 사용 방식에 중요한 차이가 있습니다.
1. 슬라이스(Slice)
가.기본 개념
슬라이스는 배열, 벡터, 문자열 등 메모리상에 연속적으로 저장된 데이터의 일부 구간을 참조하는 타입으로, &[T] 또는 &str(문자열 슬라이스)와 같이 참조 형태로 표현된다.
슬라이스는 원본 데이터의 소유권을 이동시키지 않으며, 원본 데이터가 유효할 때만 사용할 수 있다.
슬라이스는 연속된 메모리 블록의 시작 주소와 길이 정보를 함께 저장하는 fat pointer(두 개의 값: 포인터 + 길이)이다.
슬라이스는 항상 연속적인 데이터만 참조할 수 있습니다.
나.문법과 사용법
슬라이스는 [start..end] 범위 문법을 사용합니다.
start는 포함, end는 포함하지 않습니다(즉, 반 열린 구간). let a = [1, 2, 3, 4, 5]; let slice = &a[1..4]; // [2, 3, 4]
start나 end를 생략하면 처음이나 끝을 의미합니다. &a[..3] → 처음부터 3번째 전까지 &a[2..] → 2번째부터 끝까지(끝 포함) &a[..] → 전체
다. 문자열 슬라이스
문자열 슬라이스는 String이나 문자열 리터럴의 일부를 참조하며 타입은 &str 입니다.
문자열 일부 참조 let s = String::from(“hello world”); let hello = &s[0..5]; // “hello” let world = &s[6..11]; // “world”
문자열 리터럴 let s = “hello, world”; // &’static str let part: &str = &s[0..5]; // “hello” 부분만 참조 “hello” 자체도 &str 타입의 슬라이스이므로, 문자열 리터럴의 일부도 참조할 수 있습니다.
라. 특징과 주의점
슬라이스는 참조이므로, 원본 데이터가 유효해야 한다. 원본 데이터가 변경(예: String의 clear 등)되면 기존 슬라이스는 무효화되어 컴파일 에러가 발생합다.
슬라이스는 소유권과 라이프타임 개념을 이해하는 데 중요한 역할을 합니다. -> 아래 ‘3. 슬라이스와 라이프타임’에서 설명
슬라이스는 함수의 파라미터로 자주 사용됩니다.
fn main() {
let s = "hello, world";
println!("First two elements: {:?}", first_two(s));
}
fn first_two(slice: &str) -> &str {
if slice.len() < 2 {
panic!("Slice must have at least two elements");
}
&slice[0..2]
}
위 코드에서 s는 문자열 리터럴인 문자열 슬라이스이므로 함수로 넘길 때는 &를 안붙여도 되지만(붙여도 됨), 함수에서 받을 때는 타입을 &str으로 지정하고, 반환도 &slice[0..2]이므로&str 타입으로 지정해야 합니다.
실행 결과는 First two elements: “he”입니다.
2. 컬렉션 참조
가. 개념
컬렉션 참조는 컬렉션 전체에 대한 참조입니다. 예를 들어, &Vec, &String, &[T; N] 등입니다. 여기서 &[T; N]은 배열 전체에 대한 참조입니다. 엄격하게 말하면 배열은 컬렉션은 아니지만 “배열 전체를 참조한다”는 측면에서 컬렉션 참조에 포함해서 설명합니다.
이 참조는 해당 컬렉션 타입의 모든 메서드와 속성을 사용할 수 있게 해줍니다.
컬렉션 참조는 그 자체가 소유한 데이터 전체를 가리키며, 슬라이스처럼 부분만을 가리키지는 않습니다.
예시: let v = vec![1, 2, 3]; let r = &v; // Vec 전체에 대한 참조
벡터 컬렉션 참조의 간단한 예시는 다음과 같습니다.
fn main() { let v = vec![100, 32, 57]; // Vec<i32> 생성 // 벡터에 대한 불변 참조를 사용하여 각 요소 출력 for i in &v { println!("{}", i); } }
여기서 &v는 Vec 타입의 벡터에 대한 불변 참조(&Vec)입니다.
for i in &v 구문은 벡터의 각 요소에 대한 참조(&i32)를 순회하며 안전하게 접근합니다.
fn main() { let data = [10, 20, 30]; print_array(&data); // &[i32; 3] 타입의 참조 전달 }
위 예제에서 print_array 함수는 크기가 3인 i32 배열에 대한 참조(&[i32; 3])를 인자로 받습니다.
&data는 [i32; 3] 타입 배열 전체에 대한 참조입니다.
다. 슬라이스와 주요 차이점 비교
구분
슬라이스 (&[T], &str)
컬렉션 참조 (&Vec<T>, &String)
대상
컬렉션의 일부(연속 구간)
컬렉션 전체
내부 구조
포인터 + 길이 (fat pointer)
포인터 (컬렉션 구조체 전체)
메서드 사용
슬라이스 관련 메서드만 사용 가능
컬렉션의 모든 메서드 사용 가능(벡터의 len(), push() 등
용도
부분 참조, 함수 인자 등
전체 참조, 컬렉션의 메서드 활용
동적 크기
길이가 런타임에 결정됨
컬렉션 타입에 따라 다름
예시
&arr[1..3], &vec[..], &s[2..]
&vec, &arr, &String
&[T; N]은 배열 전체를 참조하고, &[T]는 슬라이스로, 배열의 일부 구간(혹은 전체)을 참조할 수 있습니다. 예를 들어, &arr[1..3]은 &[T] 타입(슬라이스)이지만, &arr은 &[T; N] 타입(컬렉션 참조)입니다. 이처럼 &[T; N]은 정확히 N개 원소를 가진 배열 전체에 대한 참조입니다.
또한 &str과 &String은 문자열 슬라이스와 문자열 참조로 다르지만, &String이 &str으로 자동 변환이 가능하기 때문에 함수 인자로는 &str이 더 범용적이고 권장됩니다.
3. 슬라이스와 라이프타임
가. 개념
Rust에서 라이프타임(lifetime)은 참조가 유효한 기간을 명확하게 지정하는 개념으로, 참조의 유효성에 직접적인 영향을 미칩니다. 라이프타임이 중요한 이유와 그 영향은 다음과같습니다.
댕글링 참조 방지 라이프타임의 가장 큰 목적은 댕글링 참조(dangling reference)를 방지하는 것입니다. 댕글링 참조란, 이미 스코프를 벗어나 소멸된 데이터를 참조하는 상황을 의미합니다. Rust는 각 참조자의 라이프타임을 추적하여, 참조가 원본 데이터보다 오래 살아남을 수 없도록 컴파일 타임에 강제합니다.
안전한 메모리 접근 보장 라이프타임을 통해 참조자가 항상 유효한 메모리만 가리키도록 보장합니다. 예를 들어, 아래와 같은 코드는 컴파일되지 않습니다.
let r; // r의 라이프타임 시작 { let x = 5; // x의 라이프타임 r = &x; // r이 x를 참조 } // x의 라이프타임 종료, r은 더 이상 유효하지 않음 println!(“{}”, r); // 컴파일 에러! Rust는 위 코드에서 r이 x보다 오래 살아남으려 하므로 컴파일 에러를 발생시킵니다.
함수와 구조체에서의 명확한 라이프타임 관계 여러 참조가 얽히는 함수나 구조체에서는, 각각의 참조가 얼마나 오래 유효해야 하는지 명확히 지정해야 합니다. 라이프타임 파라미터를 명시함으로써, 함수가 반환하는 참조가 입력 참조자 중 어느 것과 연관되어 있는지 컴파일러가 알 수 있습니다.
암묵적 추론과 명시적 지정 대부분의 경우 Rust는 라이프타임을 자동으로 추론하지만, 여러 참조가 얽히거나 복잡한 관계가 있을 때는 명시적으로 라이프타임을 지정해야 합니다. 이를 통해 참조 유효성에 대한 컴파일러의 보장이 더욱 강력해집니다.
나. 예제 1
Rust에서 슬라이스의 라이프타임이 중요한 이유를 보여주는 대표적인 예제는, 두 개의 문자열 슬라이스 중 더 긴 쪽을 반환하는 함수입니다. 이 예제를 통해 슬라이스의 라이프타임을 명확히 지정하지 않으면 컴파일 에러가 발생하고, 올바르게 지정하면 안전하게 참조를 반환할 수 있음을 알 수 있습니다.
fn main() { let string1 = String::from("abcd"); let string2 = "xyz"; let result = longest(string1.as_str(), string2); println!("The longest string is {}", result); }
// 라이프타임 명시가 반드시 필요! fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } }
이 함수 시그니처에서 ‘a라는 라이프타임 파라미터를 명시했습니다.
이 뜻은, x와 y 모두 최소한 ‘a만큼 살아 있어야 하며, 반환되는 슬라이스도 ‘a만큼 살아 있어야 한다는 의미입니다.
실제로 함수가 호출될 때, Rust는 x와 y의 라이프타임 중 더 짧은 쪽에 맞춰 반환값의 라이프타임을 제한합니다. 따라서, ‘a와 ‘b를 사용해서 fn longest<‘a, ‘b>(x: &’a str, y: &’b str) -> &’a str 라고 하면 x가 반환될 수도 있고 y가 반환될 수도 있기 때문에 에러가 발생합니다.
또한 라이프타임을 명시하지 않아도, Rust는 반환되는 참조가 입력 참조자 중 어느 쪽과 연관되어 있는지 알 수 없어 컴파일 오류가 발생합니다. 따라서, 라이프타임을 ‘a로 통일한 것입니다.
이처럼, 슬라이스의 라이프타임을 명확히 지정하지 않으면 댕글링 참조가 생길 수 있고, Rust는 이를 컴파일 타임에 방지합니다. 따라서 라이프타임 명시는 슬라이스가 안전하게 사용될 수 있는 핵심적인 장치입니다.
위 코드에서 as_str()은 String 타입을 &str 타입으로 변환하는 메소드입니다.
다. 예제2
fn main() { let result; { let string1 = String::from("abc"); let string2 = String::from("xyz"); result = longest(string1.as_str(), string2.as_str()); // string1, string2는 여기서 소멸 } println!("The longest string is {}", result); // 컴파일 에러! }
위 코드는 result가 내부 스코프의 데이터인 string1 또는 string2의 슬라이스를 참조하게 되므로, 컴파일 에러를 발생시켜 댕글링 참조를 차단합니다.
Rust는 안정성과 신뢰성을 강조하는 언어답게 테스트를 매우 중요하게 여깁니다. 표준 라이브러리에는 테스트를 쉽게 작성하고 실행할 수 있도록 test 프레임워크가 기본 내장되어 있으며, cargo test 명령어로 손쉽게 실행할 수 있습니다. 오늘은 단위 테스트, 통합 테스트, 그리고 테스트 관련 어노테이션 및 모범 사례에 대해 알아보겠습니다.
1. 기본적인 단위 테스트 구조
Rust의 테스트는 일반적으로 소스 파일 내에 다음과 같은 구조로 작성됩니다.
// 실제 함수 pub fn add(a: i32, b: i32) -> i32 { a + b }
mod tests { … }: 테스트용 하위 모듈을 의미합니다. 실제 코드와 분리된 테스트 코드입니다.
use super::*;: 상위 모듈(super)의 add 함수를 불러오는 것입니다. 이 줄을 주석처리하면 “add함수가 없다”고 합니다.
#[test]: 테스트 함수임을 표시합니다.
test_add 함수를 만들어서 add(2, 3)의 결과와 5가 같은지 비교해서 맞으면 ok, 다르면 failed가 발생합니다. assert_eq!: 기대값과 실제값이 같은지 검사합니다.
가. 실행 방법
cargo test
cargo는 프로젝트 전체를 빌드하고 #[test] 함수들을 자동으로 실행합니다.
나. lib.rs 파일 작성 및 실행
(1) assert_eq!의 결과 두 값이 같은 경우
src 폴더 아래에 lib.rs란 파일로 작성했습니다.
day21 폴더에서 cargo test를 실행하니(VS Code를 보면 함수 위에 Run Test가 있으므로 이걸 눌러도 됨) 컴파일되고 실행되는데 먼저 unittests src\lib.rs라고 표시되어 lib.rs의 test annotation을 먼저 실행하고, 그 다음은 main.rs를 실행합니다.
lib.rs의 테스트 결과는 test tests::test_add … ok라고 표시되고, 그 아래 test result: ok. 1 passed; 0 failed… 등 내용이 표시되는데,
main.rs의 테스트 결과는 test 어노테이션이 없어서 0으로 표시됩니다.
그리고, assert_eq! 구문만 실행되고, main.rs에 있는 println!(“Hello, world!”);문은 실행되지 않습니다. lib.rs에 println!문이 있어도 실행되지 않습니다.
cargo test –lib라고 하면 lib.rs의 test annotation만 실행하고, main.rs에 test 어노테이션이 있는지 체크하지 않습니다.
(2) assert_eq!의 결과 두 값이 다른 경우
add(2, 3)의 결과는 5인데, 6과 비교하면 두 값이 다르므로 FAILED라고 결과가 표시되고,
그 아래를 보면 thread ‘tests::test_add’ panicked at day21\src\lib.rs:12:9:라고 panic이 발생했고, 왼쪽은 5, 오른쪽은 6으로 “왼쪽과 오른쪽이 같다는 단언(주장)이 실패했다”고 설명합니다.
2. 다양한 assert 매크로
Rust에서는 다음과 같은 다양한 테스트 도구를 제공합니다.
매크로
설명
assert!
조건이 true인지 확인
assert_eq!
두 값이 같은지 확인
assert_ne!
두 값이 다른지 확인(not equal)
dbg!
디버깅 용도로 값을 출력
panic!
강제로 실패시키기
예제:
#[test] fn test_assertions() { let a = 10; let b = 20; assert!(a < b); assert_eq!(a + b, 30); assert_ne!(a, b); }
위 코드를 cargo test –lib로 실행하면 모든 결과가 True이므로 test result: ok인데, 세 번이 아니라 한 번만 표시되고,
assert!(a > b); assert_eq!(a + b, 40); 으로 수정해서 2개를 False로 만든 후 실행하면 a > b에서 panic이 되기 때문에 a + b와 40을 비교하는 부분은 가지도 못하고 끝납니다.
따라서, a < b로 수정하고 실행하면 a + b = 40에서 실패가 발생합니다.
3. 실패하는 테스트
테스트가 실패했을 경우에는 어떤 테스트가 어떤 이유로 실패했는지 친절히 알려줍니다. 예를 들어:
Rust 언어 개발환경 세팅에 대해 알아보겠습니다. Rust는 떠오르고 있는 안전하고 강력한 언어로서 Rust는 C++의 단점을 보완하기 위해 최근에 탄생한 언어이며, 개발자 사이에 많은 인기를 얻고 있는 언어입니다. 그러나, 소유자, 빌림, 생명주기 등 이전에 없던 새로운 개념 때문에 배우기 어려운 언어로 알려지고 있습니다.
Rust 언어 개발환경 세팅에 대해 알아보겠습니다. Rust 언어는 시스템 프로그래밍 분야에서 메모리 안전성, 속도, 병렬성 문제를 해결하기 위해 Mozilla가 개발했고, 개발자 사이에 많은 인기를 얻고 있는 언어입니다. 그러나, 소유자, 빌림, 생명주기 등 이전에 없던 새로운 개념 때문에 배우기 어려운 언어로 알려지고 있습니다.
2025년 5월의 티오베 인덱스에 의하면 개발 언어 중 1위는 파이썬이고, Rust는 19위입니다. 그러나, PYPL의 인기 언어 순위에서 Rust는 8위로 높습니다.
가. Visual Studio(권장) 또는 Microsoft C++ Build Tools 설치
파이썬은 파이썬만 설치하고 Visual Studio Code에서 바로 프로그램을 작성할 수 있는데, Rust는 컴파일 언어이기 때문에 컴파일 환경 세팅이 필요합니다.