본문으로 건너뛰기

23.07.09

오늘 한 일

  • Typescript 스터디 회의
  • Rust 스터디공부 (소유권)
  • Gloddy 면담

하루 요약

  • 15:00 ~ 18:00 / 19:00 ~ 21:00 카공

  • SLASH 21 - 실무에서 바로 쓰는 Frontend Clean Code


Typescript 스터디 회의

타입스크립트를 쓰고 있긴 하지만 기본부터 확실하게 다지고 싶어서 스터디를 하기로 했다.

쉽게 시작하는 타입스크립트 책을 기반으로 스터디를 진행할 예정이다.

스터디 형식은 매주 월요일 12시에 오프라인으로 모여서 같이 읽으며 공부하고 토론하는 방식으로 진행할 예정이다.

스터디원은 나, 민세림, 송은수 총 3명 이다.

Rust 스터디

소유권에 대해 공부했다.

개념이 살짝 생소하긴 했지만 공식문서에서 쉽게 설명하고 있어서 재밌게 공부했다.

소유권이 뭔가요?

러스트 프로그램의 메모리 관리법을 지배하는 규칙 모음

소유권 (ownership) 이라는 시스템을 만들고, 컴파일러가 컴파일 중에 검사할 여러 규칙을 정해 메모리를 관리하는 방식.

이 규칙 중 하나라도 위반하면 프로그램은 컴파일되지 않는다. 소유권 기능들의 어떤 것도 런타임 비용이 발생하지 않는다.

소유권의 주 목표: 힙 데이터 관리

소유권 규칙

  • 각각의 값은 해당 값의 오너(owner)가 있다.
  • 한번에 하나의 오너만 존재할 수 있다.
  • 오너가 스코프 밖으로 벗어나면, 값은 버려진다(dropped).

변수의 스코프

내가 알고있는 자바스크립트의 스코프와 비슷한 듯 하다.

String 타입

이 타입은 힙에 할당된 데이터를 다룸 -> 컴파일 타임에 크기를 알 수 없는 텍스트를 다룰 수 있음.

from 함수를 사용하여 String 타입을 만들 수 있다.

let s = String::from("hello");

String 문자열은 변경 가능하다.

let mut s = String::from("hello");
s.push_str(", world!"); // push_str() 함수는 리터럴을 인자로 받는다.
println!("{}", s); // hello, world!

::이 뭐지?

:: 문법은 연관 함수(associated function)를 호출하는데 사용되는 문법이다. 연관 함수는 특정 타입의 인스턴스를 생성하지 않고도 호출할 수 있는 함수이다.

Method & Associated Function

메서드는 항상 첫번째 파라미터가 self라는 파라미터를 갖는다. self는 메서드를 호출한 인스턴스를 가리킨다.

하지만, 연관 함수는 self 파라미터를 갖지 않는다. 대신, :: 네임스페이스 연산자를 사용하여 특정 구성 요소의 네임스페이스에 연결된 함수에 접근할 수 있다.

메모리와 할당

문자열 리터럴일 때는 컴파일 타임에 크기를 알 수 있기 때문에, 컴파일 타임에 크기를 알 수 있는 데이터는 스택에 할당된다.

하지만, String 타입은 컴파일 타임에 크기를 알 수 없기 때문에, 힙에 할당된다.

힙에 할당된 데이터의 해제

Rust에는 가비지 컬렉터(garbage collector)가 없다.

GC가 없는 대부분의 언어에서는 할당받은 메모리가 필요 없어지는 지점을 프로그래머가 직접 찾아 메모리 해제 코드를 작성해야 한다. 하지만 프로그래머가 놓칠 수도 있고, 잘못 작성할 수도 있다.

이 문제를 Rust는 변수가 자신이 소속된 스코프를 벗어나는 순간 자동으로 메모리를 해제하는 방식으로 해결한다.

{
let s = String::from("hello"); // s는 이 지점부터 유효하다.

// s를 이용한 작업을 수행한다.
} // 이 스코프는 끝났고, s는 더 이상 유효하지 않다. Rust는 여기서 s의 메모리를 자동으로 해제한다.

변수가 스코프 밖으로 벗어나는 순간 Rust는 drop이라는 함수를 호출한다. 이 함수는 컴파일러가 자동으로 호출해준다.

C++에서는 이러한 메모리 관리를 RAII(Resource Acquisition Is Initialization)라고 부른다.

이동

let x = 5;
let y = x;

5를 x에 바인딩하고, x 값의 복사본을 만들어 y에 바인딩하시오 라는 의미이다.

그럼 x, y 두 변수가 생기고 각각의 값은 5가 되며 스택에 푸시된다.

let s1 = String::from("hello");
let s2 = s1;

이번엔 전혀 다른 방식으로 동작한다.

String 타입은 스택에 포인터, 길이, 용량 세 개의 값을 저장한다.

  • 길이: String의 내용이 현재 사용하는 메모리를 바이트 단위로 나타낸 것
  • 용량: 메모리 할당자가 String에 할당한 메모리 양

String 타입은 힙에 할당된 데이터를 가리키는 포인터를 가지고 있다.

let s2 = s1; 이 코드가 실행되면, 스택에 있는 s1의 값인 포인터, 길이, 용량이 복사되어 s2에 저장된다. 이때, s1s2는 같은 힙 데이터를 가리키고 있다.

그럼 이 상황에서 스코프 밖으로 벗어난다면 어떻게 될까?

만약 각각 메모리를 해제한다면, 중복 해제(double free) 에러가 발생할 것이다. 이는 메모리 안정성 버그 중 하나이며, 보안을 취약하게 만드는 원인이 된다.

그래서 Rust는 let s2 = s1; 뒤로는 s1이 더 이상 유효하지 않다고 판단한다.

fn main() {
let s1 = String::from("hello");
let s2 = s1;

println!("{}, world!", s1);
}

이 코드를 실행하면, 컴파일 에러가 발생한다.

error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:28
|

다른 프로그래밍 언어에서는 얕은 복사, 깊은 복사 같은 일이 발생하지만, Rust에서는 기존의 변수를 무효화 하기 때문에 이를 이동(move)이라 하고, 위의 코드는 s1s2로 이동했다고 표현한다.

그럼 스코프 밖으로 벗어났을 때 s2만 유효하니 s2만 메모리를 해제하면 된다!

러스트는 절대 자동으로 깊은 복사로 데이터를 복사하지 않는다.

클론

String 타입은 clone 메서드를 제공한다. 이 메서드를 호출하면 힙 데이터를 복사하고, 그 복사본을 가리키는 String을 새로 만든다.

fn main() {
let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2); // s1 = hello, s2 = hello
}

이번엔 s1s2가 서로 다른 힙 데이터를 가리키고 있다.

복사

그럼 스택에 있는 데이터는 어떻게 될까?

let x = 5;
let y = x;

clone을 호출하지 않았지만, 이 코드는 스택에 있는 데이터를 복사한다.

정수형 등 컴파일 타임에 크기가 결정되는 타입은 모두 스택에 저장되기 때문이다.

스택에 저장되니, 복사본을 빠르게 만들 수 있어서 굳이 x를 무효화하지 않고도 y를 만들 수 있다. 그래서 여기선 clone을 호출하지 않아도 된다.

Rust에는 Copy 트레이트라는 특별한 어노테이션이 있다. 이 트레이트를 구현한 타입은 스택에 있는 데이터를 복사할 때 clone을 호출하지 않아도 된다.

하지만 구현하려는 타입 중 Drop 트레이트를 구현한 타입이 있다면, Copy 트레이트를 구현할 수 없다.

즉, 스코프 밖으로 벗어났을 때 특정 동작이 요구되는 타입에 Copy 트레이트를 추가하면 컴파일 에러가 발생한다.

Copy 가능한 타입 목록 중 일부

  • 모든 정수형 타입
  • bool
  • char
  • 튜플 (단, 모든 요소가 Copy 가능해야 한다) (ex: (i32, i32) 가능, (i32, String) 불가능)

소유권과 함수

변수에 값을 대입할 때와 유사하다.

함수에 변수를 전달하면 대입 연산과 마찬가지로 이동(move)이나 복사(clone)가 발생한다.

fn main() {
let s = String::from("hello"); // s가 스코프 안으로 들어왔다.

takes_ownership(s); // s의 값이 함수 안으로 이동했다...
// ... 이제 더 이상 유효하지 않다.

let x = 5; // x가 스코프 안으로 들어왔다.

makes_copy(x); // x도 함수 안으로 이동했다.
// i32는 Copy가 되므로, 이후에도 x를 계속 사용해도 된다.
} // 여기서 x가 스코프 밖으로 나가고, s도 나간다. 하지만 s는 이미 이동되었으므로, 별다른 일이 발생하지 않는다.

fn takes_ownership(some_string: String) { // some_string이 스코프 안으로 들어왔다.
println!("{}", some_string);
} // 여기서 some_string이 스코프 밖으로 벗어났고 `drop`이 호출된다. 메모리는 해제된다.

fn makes_copy(some_integer: i32) { // some_integer이 스코프 안으로 들어왔다.
println!("{}", some_integer);
} // 여기서 some_integer가 스코프 밖으로 벗어났다. 별다른 일은 없다.

반환값과 스코프

함수의 반환값도 변수와 마찬가지로 이동(move)이나 복사(clone)가 발생한다.

fn main() {
let s1 = gives_ownership(); // gives_ownership은 반환값을 s1에게 이동시킨다.

let s2 = String::from("hello"); // s2가 스코프 안으로 들어왔다.

let s3 = takes_and_gives_back(s2); // s2는 takes_and_gives_back 안으로 이동되었고, 반환값도 s3으로 이동되었다.
} // 여기서 s3가 스코프 밖으로 벗어났고, drop이 호출된다. s2도 스코프 밖으로 벗어났지만, 이미 이동되었으므로 아무 일도 일어나지 않는다.

fn gives_ownership() -> String { // gives_ownership은 반환값을 호출한 쪽으로 이동시킨다.
let some_string = String::from("hello"); // some_string이 스코프 안으로 들어왔다.

some_string // some_string이 반환되고, 호출한 쪽의 함수로 이동된다.
}

// takes_and_gives_back은 String을 하나 받아서 다른 하나를 반환한다.

fn takes_and_gives_back(a_string: String) -> String { // a_string이 스코프 안으로 들어왔다.
a_string // a_string이 반환되고, 호출한 쪽의 함수로 이동된다.
}

참조와 대여

함수 호출 이후에도 변수를 계속 사용하고 싶다면 어떻게 해야 할까?

이때 참조(reference)를 사용한다.

C에서 포인터와 유사하다. &를 사용해서 참조를 만들 수 있다.

fn main() {
let s1 = String::from("hello");

let len = calculate_length(&s1);

println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize { // s는 String에 대한 참조다.
s.len()
} // 여기서 s는 스코프 밖으로 벗어났다. 하지만 참조는 값이 아니므로 아무 일도 일어나지 않는다.

참조의 반대는 역참조(dereferencing)이다. *를 사용한다.

스코프가 끝나도 해당 변수에 소유권이 없어서 버려지지 않는다. 참조자를 만드는 행위를 대여(borrow)라고 한다.

변수가 기본적으로 불변성을 지니듯, 참조자는 기본적으로 불변이다. 참조자를 가지고 값을 변경할 수 없다.

가변 참조자

참조자를 가변 참조자로 만들면 참조자가 가리키는 값을 변경할 수 있다.

fn main() {
let mut s = String::from("hello");

change(&mut s);
}

fn change(some_string: &mut String) {
some_string.push_str(", world");
}

다만, 어떤 값에 대한 가변 참조자가 있으면, 그 값에 대한 참조자는 여러 개 만들 수 없다.

이런 제약은 값의 변경에 대한 제어가 원활하도록 해준다. 이 덕분에 Rust에서는 컴파일 타임에 데이터 경합(data race)를 방지할 수 있다.

데이터 경합 : 두 개 이상의 포인터가 같은 데이터에 접근하고, 그 중 적어도 하나의 포인터가 데이터를 쓰는 경우를 말한다.

Rust에서는 데이터 경합이 발생할 가능성이 있으면 컴파일을 거부한다! 👍

중괄호로 새로운 스코프를 만들어 가변 참조자를 여러 개 만들면서 동시에 존재하는 상황을 만들 수 있다.

let mut s = String::from("hello");

{
let r1 = &mut s;

} // r1은 여기서 스코프 밖으로 벗어났으므로, 이제 더 이상 사용할 수 없다.

let r2 = &mut s;

가변 참조자와 불변 참조자를 동시에 가질 수도 없다.

불변 참조자를 사용하는 쪽에서는 사용 중에 값이 변경되지 않는다는 것을 보장받는다.

불변 참조자는 읽는 것만 가능하다. 그래서 여러 개의 불변 참조자를 만들어도 문제가 되지 않는다.

해당 참조자가 마지막으로 사용자 부분까지 참조자가 유효하기 때문에 다음과 같은 코드는 에러가 발생하지 않는다.

let mut s = String::from("hello");

let r1 = &s; // 문제없음
let r2 = &s; // 문제없음
println!("{} and {}", r1, r2);
// r1과 r2는 더 이상 사용되지 않음

let r3 = &mut s; // 문제없음
println!("{}", r3);

댕글링 참조

댕글링 참조(dangling reference)는 참조자가 유효하지 않은 메모리를 가리키는 것을 말한다.

fn dangle() -> &String { // dangle은 String에 대한 참조자를 반환한다.
let s = String::from("hello"); // s는 새로운 String이다.

&s // String s의 참조자를 반환한다.
} // 여기서 s는 스코프 밖으로 벗어났고, 메모리는 해제되었지만, 참조자는 여전히 반환값을 가리키고 있다. 문제 발생!

이 경우엔 직접 반환해야 한다.

슬라이스

슬라이스(slice)는 컬렉션 전체가 아닌 컬렉션의 연속된 일련의 요소들을 참조하는 참조자다.

슬라이스는 참조자의 일종으로서 소유권을 가지지 않는다.

let s = String::from("hello world");

let hello = &s[0..5];
let world = &s[6..11];

println!("{} {}", hello, world); // hello world

clear 함수는 String의 길이를 변경해야 하니 가변 참조자가 필요하다. println!String의 길이를 변경하지 않으니 불변 참조자가 필요하다.

그 지점까지 불변 참조자는 계속 활성화되어 있어야 한다.

fn main() {
let mut s = String::from("hello world");

let word = first_word(&s); // word의 타입은 &str이다.

s.clear(); // String을 비운다.

println!("the first word is: {}", word); // word는 여기서 유효하지 않다! 에러 발생!
}

fn first_word(s: &String) -> &str { // &String을 받아서 &str을 반환한다.
let bytes = s.as_bytes(); // 바이트로 변환한다.

for (i, &item) in bytes.iter().enumerate() { // iter() 메서드는 컬렉션의 각 요소를 반복한다.
if item == b' ' { // 공백을 찾는다.
return &s[0..i]; // 공백을 찾으면 &s[0..i]를 반환한다.
}
}

&s[..] // 공백이 없으면 &s[..]를 반환한다.
}

내일 할 일

  • Gloddy 개발
  • CS 스터디 공부