Published on

[CS] 변수 톺아보기

Authors
  • avatar
    Name
    Woojin Son
    Twitter

Intro

안녕하세요! 자칭 소프트웨어 잡부 손우진입니다.

현재 Spring Boot나 Nest.js, DJango, React.js 등 수 많은 오픈소스 프레임워크 덕분에 백지부터 소프트웨어를 개발 할 필요가 없어졌어요. 우린 프레임워크가 제공해주는 도구들을 코딩을 통해 가져오고 붙히고 만들고 테스트하죠. '바퀴를 발명하지 말라!' 라는 말 잘 아실거에요. 우리가 만들고자 하는 소프트웨어에 바퀴 역할을 하는 것들 까지 직접 개발 할 필요는 없다는 의미입니다.

게다가 최근엔 AI가 너무 잘 되어 있어서, 때로는 우리가 직접 코딩을 하지 않고 설계만 잘 해두더라도 좋은 소프트웨어가 나오곤 합니다. 저도 개발할 때 Chat.gpt 에게 가끔 코딩을 맡기고는 해요. 이런 환경은 때로는 우리가 잊고 있었던, 또는 모르고 있었던 지식들에 더욱 그림자를 씌웁니다.

우리가 잘 사용하고 있지만, 때로는 블랙박스로 사용하고 있는 지식들은 언젠가 문제를 일으킵니다. 오작동 했을 때 대처할 방법을 모르게 된다면, 생각했던 것 보다 스스로의 표정이 더 일그러지겠죠. AI 에게 코딩을 맡겼다고 한들, 막상 이슈가 생겼을 때 까지 AI에게 도움을 요청할 수 없습니다. 현재로써 생성형 AI는 검색을 도와주는 도구 일 뿐이니까요.

그래서 이번에는 기존에 작성하던 글에 비해 조금 로우레벨로 내려가볼 까 합니다. 변수라는 키워드로 한번 포스팅을 해볼까 해요. 변수란 무엇인 지, 변수가 어떻게 동작하는 지 가볍지만 약간 깊게 다뤄볼까 합니다.

정보(Information) VS 자료(Data)

컴퓨터공학 전공인 분들은 아마 학부 4년간 겪으셨겠지만, 프로그래밍의 핵심은 데이터를 메모리에 적재하고 필요할 때 꺼내 사용하는 것입니다. 아무리 화려한 기술을 쓴다고 해도 해당 틀에서 크게 벗어나지는 않아요.

이 데이터는 크게 정보와 자료로 나뉩니다. 정보 는 의미가 있는 데이터, 자료 는 데이터 그 자체입니다.

우리가 변수를 선언해서 데이터를 할당하면, 변수 그 자체는 정보가 됩니다.

val age : Int = 10

우리가 10이라는 데이터에 age 라는 의미를 부여했기 때문에 코드를 읽는 입장에서는 10이라는 데이터가 나이라는 것을 알 수 있는 셈이죠.

'변수 네이밍이 중요하다.' 라는 말은 단순히 클린코드, 즉 읽기 쉬운 코드를 위해서만은 아닙니다. 컴퓨터 입장에서는 변수 명이 뭐가 되었든 아무런 상관이 없어요. 우리가 변수명을 이상하게 짓는다고 한들 소프트웨어 동작에는 아무런 문제가 없어요. 하지만, 우리는 이 자료 의 의미를 알 수 없죠.

변수는 우리가 의미를 부여한 자료를 의미합니다.

자료형 (Data Type)

자료형은 정수형, 실수형, 문자열 등 데이터의 형태에 따라 나눠집니다. 크게는 정수형, 실수형, 문자열을 많이 쓰죠. 상황에 따라선 바이너리 형태로도 쓰입니다. REST API 요청을 처리하는 과정에서 URL을 할당 해 두었다고 한들, 요청을 처음 받은 입장에서부터 데이터가 무엇인 지 알 수가 없죠. (만약 알았다면 수 많은 악성 요청들을 막을 수 있을겁니다.)

OSI 7 Layer 상에서 우리의 애플리케이션은 가장 윗 단계인 7Layer 에 존재합니다. 통신 프로토콜을 타고 애플리케이션 레이어에서 이 데이터를 분석하려면 처음엔 바이너리 형태로 되어있죠. 바이너리 형태의 데이터를 우리가 받고자 하는 데이터 형태에 맞게 파싱하는데 Spring 에서는 이 과정에서 HttpMessageConverter 가 사용됩니다.

자료형은 흔히 관례적으로 쓰이는 명칭들이 존재합니다. C언어를 공부하던 시절로 한번 되돌아가볼까요?

정수형 자료형은 아래와 같습니다.

자료형크기(byte)최솟값최대값
short2-3276832767
int4-21474836482147483647
long4-21474836482147483647
long long8-92233720368547758089223372036854775807
실수형은 아래와 같습니다.
자료형크기(byte)최솟값최대값
float4-3.4*10^383.4*10^38
double8-1.8*10^3081.8*10^308
long double8-1.8*10^3081.8*10^308
문자형은 아래와 같습니다.
자료형크기(byte)최솟값최대값
char1-128127

이런 내용들은 전공 시간에 많이들 다룹니다. 시험 문제로 내기도 딱 좋죠. 실제로 C언어 시험에서 나왔던 기억이 납니다. 데이터의 범위가 어디까지인 지 아는 건 중요하니까요.

자료형들을 선언해서 사용하다보면 이런 자료형들에 왜 이런 이름이 붙게 되었는 지 호기심이 들곤 합니다. 전 학부시절에 처음에는 상당히 기계적으로 받아들이다가 어느 시점에서 궁금해서 찾아 본 적이 있었습니다.

막간을 이용해 정리 해 볼까요?

정수형 int 는 아실 분은 잘 아시겠지만 Integer 의 준말이죠. Integer 그 자체는 정수라는 의미를 가지는 영어단어입니다.

그럼 short 과 long 은 무슨 의미일까요? 정말 어이없게도 메모리 공간을 적게 (short), 혹은 많이 (long) 차지한다는 의미로 붙었습니다.

정수형은 생각보다 평범합니다. 하지만 실수형은 조금 다릅니다. 우리가 알고있는 실수 (Real) 와 연관 되어보이는 단어가 들어가있지 않습니다.

실수형은 언어에 따라 조금 씩 차이는 나지만 크게 두가지 타입으로 나뉩니다. float 와 double이죠.

float 라는 단어 뜻에는 뜨다 라는 의미가 있습니다. 왜 갑자기 뜬금없이 float 라는 단어가 나왔을까요? 실제로 컴퓨터가 실수를 저장하는 방법으로 부동 소수점 방식을 사용하기 때문입니다.

double 이란 이름은 마찬가지로 조금 어이없게도 float 의 2배의 용량을 저장하기 때문에 double 이라는 이름이 붙었다고 합니다. 마찬가지로 부동 소수점 방식을 사용하죠.

변수가 동작하는 과정?

지금까지의 글에서 자료형 별 이름, 데이터 크기, 범위 등을 알아보았습니다. 이름의 유래도 알아보았죠. 근데 뜬금없이 부동 소수점 이라는 키워드가 나왔습니다. 데이터 타입에 따라 메모리에 데이터가 할당되는 양상은 조금씩 달라집니다.

변수 타입별로 메모리에 할당되는 형태가 조금 씩은 다르지만, 공통적으로 변수는 자료형 마다 할당 된 데이터 크기 만큼의 메모리 용량을 할당받을 수 있습니다.

그럼 공통적으로 변수가 선언되고 데이터가 할당될 때 무슨 일이 벌어질까요? 우선 이 이야기를 하기 전에 애플리케이션의 메모리 구조에 대해 이야기 해 보아야 합니다.

C 메모리 구조JVM 메모리 구조
imageimage

이 영역은 언어마다 조금 씩 다릅니다. C는 크게 코드영역, 데이터영역, 스택영역, 힙 영역으로 나뉩니다. Java 는 메소드 영역, 힙 영역, 스택 영역, PC 레지스터, 네이티브 메모리 등으로 나뉘죠. 여기서 개발자가 관여할 수 있는 영역은 공통적으로 힙 영역입니다. 단, Java의 경우 원시타입 (Primitive) 은 스택 영역에 쌓입니다.

왜 이 이야기가 나왔느냐면, 공통적으로 어떤 언어를 쓰든 변수를 선언한다는 건 애플리케이션 프로세스에 할당 된 메모리의 특정 영역을 사용한다는 의미이기 때문입니다. 변수의 동작 과정에 대해 이야기 하기 전에 정확히 어떤 지점에 할당되는 지를 짚고 가고 싶었어요.

변수에 데이터가 저장되는 과정

val age : Int = 10

이 코드 한줄로 인해 어떤 일이 벌어지는걸까요?

우선 메모리주소가 할당됩니다. 그리고 자료형에 맞는 영역 크기만큼 용량이 할당됩니다. 만약 우리가 자료형에 잘못 된 데이터를 할당한다면 아마 경험 해 보셨겠지만, 컴파일러 단에서 막힐 수 있습니다. 빌드가 전혀 될 수 없죠. 자료형에 맞는 영역 크기 만큼 용량이 할당된다고 했죠. 해당 용량을 초과하는 데이터 혹은 자료형에 맞지 않는 형태의 데이터가 할당되면 심각한 에러가 발생할 수 있습니다.

컴파일러는 코드를 빌드하기 전, 이런 잘못된 데이터 할당 값들을 모두 검사합니다. 어쩐지 이상한 코드를 작성하면 에러가 발생했었죠?

아까 메모리주소가 할당되었다고 했죠? 실제로 컴퓨터는 변수의 이름이 어떻든 그다지 관심이 없습니다. 사람 입장에선 당연히 변수에 의미를 부여하는 게 중요하지만, 컴퓨터는 asdf1, asdf2 이런 식으로 이름을 붙혀도 하등 상관이 없죠. 실제로 소스코드가 컴파일 되면, 변수 이름과 메모리주소와 매핑 된 심볼테이블이 생성됩니다. 그 이후엔 변수에 할당 된 메모리 주소를 통해 데이터에 접근하죠.

Java 를 사용하는 경우엔 GC (Garbage Collector) 가 알아서 사용하지 않는 변수들을 메모리에서 정리합니다. 만약 특정한 인스턴스를 다시 생성하게 된다면, 그 때 다시 메모리에 변수를 할당시킵니다.

요약하면, 변수는 애플리케이션 프로세스에 할당 된 메모리 영역에 할당됩니다. 변수명에 매핑 된 심볼 테이블을 통해 실제 데이터에 접근하게 되고 심볼 테이블엔 메모리주소가 지정 되어있습니다.

만약 배열을 선언한다면 어떤 일이 벌어질까요? 배열 크기가 N, 자료형의 크기가 M 이라면 N * M 만큼의 용량이 메모리에 할당됩니다. 그리고 배열의 가장 첫번째 인자의 메모리 주소가 배열의 이름에 매핑됩니다.

변수 끼리 값을 대입하는 과정

var a : Int = 10
var b : Int = 20
a = b

만약 다른 변수에 다른 변수값을 대입하면 어떤 일이 벌어질까요? Kotlin / Java 기준으로는 원시타입이라면 값 그자체가 복사됩니다. 하지만 클래스 자료형은 참조(주소) 만 복사하게 됩니다. 여기서 주소는 메모리 주소를 의미합니다.

var b = 10
var a = b  // b의 값(10)이 a로 복사됨
a = 20   // a는 20이 되지만, b는 그대로 10

원시타입은 보시다시피 값만 복사되고, 각각의 개체는 독립적입니다.

int b = 10;
int a = b; // b의 값인 10이 a로 복사됨
a = 20;   // a가 20이 되어도 b는 여전히 10

C++ 에서도 마찬가지입니다. 원시타입은 값이 복사되어 각각 독립된 메모리 공간에 데이터가 저장되죠.

data class Person(var name: String)

var b = Person("Alice")
var a = b  // a와 b는 같은 Person 객체를 참조함
a.name = "Bob"

println(b.name)  // "Bob" 출력

객체나 배열 등 참조타입이라면, 객체의 주소값이 대입하고자 하는 변수에 복사됩니다. a와 b는 동일한 객체를 가리키게 되므로 한쪽의 변경이 반영됩니다.

class Person {
public:
    std::string name;
    // 기본 복사 대입 연산자: name 멤버가 std::string이므로 내부적으로 값을 복사함
};

Person b;
b.name = "Alice";
Person a;
a = b;  // a의 name 멤버에 "Alice"가 복사됨

C++에서는 조금 다릅니다. 객체대입에서는 기본적으로는 값 볷사가 이뤄집니다. a와 b가 독립적인 메모리 공간에 저장됩니다. C++ 는 Java Kotlin 과 다르게 JVM와 같은 시스템이 존재하지 않습니다. 모든 메모리 관리를 개발자가 수동으로 해 주어야 하죠. Java Kotlin 처럼 구현하고 싶다면 포인터를 사용해야 합니다.

여기서 포인터란? 메모리 주소값에 직접 접근할 수 있는 변수를 의미합니다.

JVM 에서는 포인터 연산을 지원하지 않습니다. 메모리 관리에 대한 책임을 개발자가 아닌 시스템에 맡기는 구조죠.

극한의 최적화에는 부적합 할 수 있습니다만, 안정적으로 시스템 메모리를 관리할 수 있죠. 마치 수동변속기와 자동변속기의 차이와 일맥상통합니다.

#include <iostream>
#include <string>

class Person {
public:
    std::string name;
    Person(const std::string& name) : name(name) { }
};

int main() {
    Person b("Alice");
    Person* a = &b;  // a는 b의 주소를 저장
    a->name = "Bob";

    std::cout << b.name << std::endl;  // "Bob" 출력
    return 0;
}

참조 연산자를 쓰는 방법도 있죠.

#include <iostream>
#include <string>

class Person {
public:
    std::string name;
    Person(const std::string& name) : name(name) { }
};

int main() {
    Person b("Alice");
    Person& a = b;  // a는 b에 대한 참조
    a.name = "Bob";

    std::cout << b.name << std::endl;  // "Bob" 출력
    return 0;
}

얕은 복사 (Shallow Copy) VS 깊은 복사 (Deep Copy)

지금까지 언급했던 변수 간 값 대입의 형태는 얕은 복사(Shallow Copy) 를 따릅니다. 얕은 복사란, 객체 자체의 필드 값만 복사하고 그 필드가 다른 객체를 가리키고 있다면, 참조 값(메모리 주소)를 복사하는 방법입니다.

즉 원본 객체와 복사된 객체는 같은 참조형 데이터를 공유하게 되죠. 원시 타입은 값 자체가 복사되고, 클래스 등 참조형 자료형은 참조 값(메모리 주소) 만 복사합니다. 그래서 앞서 언급했던 것 처럼 특정 객체에 값이 변경되면 다른 객체에 값이 변경될 수 있죠. 하지만 복사 과정이 빠르고 메모리를 적게 사용한다는 장점이 있습니다. 기본적으로 JVM에서 대입연산은 얕은 복사를 따릅니다.

data class Person(var name: String)

var b = Person("Alice")
var a = b  // a와 b는 같은 Person 객체를 참조함
a.name = "Bob"

println(b.name)  // "Bob" 출력

깊은 복사 (Deep copy) 는 객체 뿐만 아니라 객체가 참조하고 있는 모든 객체들까지 재귀적으로 복사해서 원본과 다른 독립적인 객체를 생성하는 방법입니다. 아예 새로운 객체를 하나 만들어버리는 방법이죠. 원본과 다른 새 객체가 생기는 것을 보장합니다. 하지만 그만큼 메모리 비용이 증가할 수 있죠.

class Person implements Cloneable {
    String name;

    public Person(String name) {
        this.name = name;
    }

    @Override
    protected Person clone() throws CloneNotSupportedException {
        return (Person) super.clone(); // String은 immutable 하므로 얕은 복사해도 무방
    }
}

class Employee implements Cloneable {
    int id;
    Person person;

    public Employee(int id, Person person) {
        this.id = id;
        this.person = person;
    }

    @Override
    protected Employee clone() throws CloneNotSupportedException {
        Employee cloned = (Employee) super.clone();
        // person 객체까지 별도로 복제 (깊은 복사)
        cloned.person = person.clone();
        return cloned;
    }
}

Employee original = new Employee(1, new Person("Alice"));
Employee deepCopy = original.clone();

// deepCopy와 original은 서로 독립적인 person 객체를 가짐
deepCopy.person.name = "Bob";
System.out.println(original.person.name); // "Alice" 출력

Kotlin 에서는 Clonable 을 사용할 수는 있지만, Kotlin 언어 자체에서는 Clonable 인터페이스를 제공하지 않습니다. 아무리 JVM 기반에서 동작한다고 해도 같은 언어는 아니니까요. Kotlin 에서의 Data class는 copy 라는 얕은 복사 메소드를 지원합니다. 하지만 깊은 복사를 구현하려면 직접 구현해야 합니다. Clonable을 써도 되겠지만, Java의 기능이므로 Kotlin 에서는 권장되는 패턴이 아닙니다. 특히 클래스 내에 다른 참조형 데이터, 즉 클래스 형이 존재하는 경우는 copy 메소드는 얕은 복사만 지원하므로 아예 새로운 객체를 생성하고 싶다면 직접 깊은복사 메소드를 구현해야 합니다.

//copy 메소드 예시
data class Person(var name: String)

fun main() {
    val person1 = Person("Alice")
    val person2 = person1.copy()  // person1의 얕은 복사본 생성
    person2.name = "Bob"
    println(person1.name)  // "Alice" 출력
    println(person2.name)  // "Bob" 출력
}

// 깊은복사 예시

data class Address(var city: String)

data class Person(var name: String, var address: Address) {
    fun deepCopy(): Person {
        return Person(name, Address(address.city))
    }
}

fun main() {
    val original = Person("Alice", Address("Seoul"))
    val copy = original.deepCopy()
    copy.address.city = "Busan"

    println("Original city: ${original.address.city}") // "Seoul" 출력
    println("Copy city: ${copy.address.city}")         // "Busan" 출력
}

C++에서도 마찬가지로 기본적으론 얕은복사를 지원합니다. 차이가 있다면, JVM 과 같은 시스템이 존재하지 않으므로 클래스 내에 객체 멤버가 존재한다고 해도 포인터 멤버가 아닌 이상 얕은복사가 아닌 단순 값 복사가 일어납니다.

'값 복사가 일어나므로 깊은복사가 아닌가?' 라는 생각이 들 수도 있겠지만, 객체 내부에 만약 동적 메모리 할당 처럼 복잡한 자원 관리가 필요한 데이터가 있다면 일반적인 기본 복사 방식으론 생각지도 못한 사이드이펙트가 발생할 수 있습니다. 그러므로 C++에서도 마찬가지로 깊은복사가 필요한 경우에는 직접 구현해야 합니다.

함수의 매개변수에 값이 대입되는 경우?

fun increment(x : Int) : Int {
    return x + 1
}

함수 내에 값을 받을 수 있는 변수를 매개변수 (Parameter) 라고 합니다. 매개변수 또한 메모리에 자리를 차지하고 있는 변수입니다. 이 매개변수에 대입되는 값을 인자 (Argument) 라고 부르죠.

Java / Kotlin 과 같은 JVM 기반 언어에서는 값에 의한 전달 (Call By Value) 를 따릅니다. 원시타입은 값만, 참조타입은 참조값 (메모리 주소) 만 복사되죠.

// 원시타입 예시
fun increment(x: Int): Int {
    return x + 1
}

fun main() {
    var a = 10
    val b = increment(a)
    println(a)  // a는 여전히 10, 값이 복사되었으므로 a 자체는 변화 없음
    println(b)  // b는 11
}
// 참조 타입 예시
data class Person(var name: String)

fun changeName(person: Person) {
    person.name = "Changed"
}

fun main() {
    val alice = Person("Alice")
    changeName(alice)
    println(alice.name) // "Changed"가 출력됨
}

그럼 값에 의한 전달은 얕은 복사냐? 라고 생각해보면 조금 다릅니다. 물론 동작은 얕은 복사와 동일합니다. 하지만 매개변수 그 자체가 참조하는 객체와 동일하게 취급된다는 점에서 조금 다릅니다.

JVM 기반 언어에서는 메모리 관리가 개발자의 영역이 아니라고 앞서 언급했었죠. JVM 언어에서는 오직 값에 의한 전달 (Call By Value) 만 지원합니다. C/C++ 에서는 참조에 의한 전달 (Call By Reference) 를 지원합니다.

// 값에 의한 전달 예시
#include <iostream>

void increment(int x) {  // x는 원본 값의 복사본
    x = x + 1;
}

int main() {
    int a = 10;
    increment(a);
    std::cout << "a = " << a << std::endl;  // a는 여전히 10
    return 0;
}

기본적으론 C++ 도 마찬가지로 값에 의한 참조를 지원합니다. 하지만 C/C++는 포인터, 즉 직접 메모리 주소를 참조하는 변수를 지원합니다.

// 참조에 의한 전달 예시
#include <iostream>

void increment(int& x) {  // x는 a의 참조
    x = x + 1;
}

int main() {
    int a = 10;
    increment(a);
    std::cout << "a = " << a << std::endl;  // a는 11로 변경됨
    return 0;
}

해당 변수를 참조하고 있는 메모리 주소를 직접 전달함으로써 매개변수의 인자값으로 전달 된 변수를 직접적으로 조정할 수 있습니다.

Java / Kotlin 에서 참조타입은 그러면 참조에 의한 전달을 지원하는 게 아닌가? 라는 의문이 들 수도 있습니다. 하지만 JVM 기반 언어들은 참조에 의한 전달을 지원하지 않습니다. 만약 그게 가능했다면 아래와 같은 코드에서 함수 내 변수 재할당이 외부 변수에 영향을 미쳤을 것입니다.

fun reassign(person: Person) {
    // 만약 call by reference였다면,
    // person 자체를 다른 객체로 재할당하는 것이 외부에도 반영될 수 있음
    person = Person("New")  // 컴파일 오류 발생: 파라미터는 val처럼 동작하기 때문
}

이게 불가능한 이유는 Java 에서는 파라미터가 지역변수 (final 처럼 동작) 로 취급됩니다. Kotlin 에서는 읽기 전용 (Val) 으로 취급되죠. 그러므로 외부 변수와 직접적인 바인딩이 이뤄지지는 않습니다. Java에서는 재할당 한다고 해도 원본에 아무런 영향이 없는 반면, Kotlin 은 컴파일 에러가 발생합니다.

변수가 메모리에 저장되어있는 형태

그럼 변수는 실제로 메모리에 어떤 형태로 저장되어있을까요?

정수

int 를 기준으로 생각 해 보면 4Byte (32Bit) 만큼의 메모리를 할당받습니다. 해당 32Bit는 0 또는 1의 값을 가지죠. 그리고 시스템의 엔디안(Endian) 에 따라 데이터가 저장되는 위치가 차이가 납니다.

Windows11 64Bit 시스템에서는 리틀 엔디안(Little Endian) 을 따르니 가장 낮은 Byte가 메모리의 낮은 주소에 저장됩니다. 기본적으로 x86-64 아키텍처는 리틀 엔디안을 따르니까요. 만약 빅 엔디안을 따른다면, 가장 높은 Byte가 메모리의 낮은 주소에 저장됩니다.

Apple 의 현재 애플실리콘 아키텍처는 ARM 아키텍처 기반입니다. 마찬가지로 리틀 엔디안을 따르고 있습니다. 단, ARM 아키텍처리틀 엔디안과 빅 엔디안을 모두 지원합니다. x86은 그렇지 않죠.

예를 들어 int a = 5; 라고 선언한다면, 32Bit 만큼의 메모리가 할당될겁니다. 64Bit 시스템이라고 해도 int 의 크기가 64Bit로 변경되지는 않습니다. 그러므로 00000000 00000000 00000000 00000101 입니다. 메모리 내에서는 리틀 엔디안을 따르므로 05 00 00 00 (16진수입니다.) 로 저장됩니다.

만약에 음수가 저장된다면 2의 보수법으로 저장됩니다. 여기서 2의 보수란, 어떤 수에 더했을 때 2가 되는 수입니다. 예를 들어 7의 10의 보수는 3이겠죠 7 + 3 은 10이니까요. 왜 이진수에서는 음수를 2의 보수로 표현할까요? 왜냐하면 2진수에서는 2로 인해 자리올림이 일어나기 때문입니다. 숫자가 0과 1밖에 없고 2부터는 자리가 올라갑니다.

5를 2진수로 표현하면 0101이죠. 5의 2의 보수는 1011 입니다. 값 자체는 5긴 하지만, 음수니까 0101로 표현할 수 없죠. 2의 보수를 구하는 방법은 간단합니다. 모든 Bit를 반대로 전환 (1의 보수로 변환) 한 후 1을 더해주면 됩니다. 그러므로 0101의 2의 보수는 1011 이죠.

보수법에 대해 조금 더 딥하게 이야기하고 넘어가자면, X에 X의 N의 보수를 더한다면 N이 됩니다. 3과 7을 더하면 10이 되는 셈이죠.

하지만 보수는 해당 숫자 그자체보단, 대상 숫자의 음수로 사용됩니다. 3의 10의 보수가 7이지만, 7이 -3으로 처리가 되는 셈이죠.

10진수의 세상에서는, 그리고 우리가 살아가는 현재 세상에서는 잘 와닫지 않을 수도 있습니다.

2진수에서는 크게 세 가지 음수 표현 방법이 있습니다. 부호 절대값, 1의 보수, 2의 보수죠.

부호 절대값은 마지막 Bit (가장 왼쪽) 를 부호 Bit로 취급합니다. 1의 보수는 그냥 모든 Bit를 반전시키죠. 이 두가지 방법에는 큰 이슈가 있습니다. 바로 0에 대해 부호를 지정할 수 있다는 것이죠. 정수 0은 0일 뿐입니다. 음수가 아니죠.

정수론의 관점에서는 0은 음수도 양수도 아닙니다. 양수의 정의가 0보다 큰 수, 음수의 정의가 0보다 작은 수니까요. 2의 보수는 이 관점에서는 자유롭습니다. 1의 보수에서 1을 더한 값이니 0의 1의 보수에 1을 더하면 결국 모든 Bit가 0으로 변합니다. 간단하게 4Bit 만 가지고 생각해보면 1111 에 1을 더하면 자리올림이 맨 왼쪽까지 진행되다가 오버플로우가 발생해서 마지막 자리는 버려지고 0000이 되니까요.

2의 보수는 또한 연산자를 복잡하게 처리 할 필요가 없다는 장점이 있습니다. X의 2의 보수는 X의 음수니까요. 예시를 하나 보겠습니다. 100 - 75 를 계산해보겠습니다. 100은 2진수로 01100100 이고 75의 2의 보수는 10110101이죠

      01100100
   +) 10110101
  -----------
     100011001

말씀 드렸다시피 마지막 Bit는 오버플로우 처리되어 버려집니다. 가장 왼쪽의 Bit를 버린 00011001은 10진수로 변환하면 25고, 이는 계산하고자 하는 식의 결과와 같습니다.

실수

정수는 여기까지 다뤘으니 이제 실수를 다뤄볼까요?

실수는 조금 까다롭습니다. 컴퓨터 데이터는 기본적으로 전기 신호인 Bit 입니다. 그러므로 정수는 자릿수 내에선 충분히 구현이 가능합니다만, 실수는 그렇지 않습니다. 실수의 소수점 아래는 사실 명확하게 떨어지지 않는 경우가 많죠. 99.9 와 같이 딱 떨어지는 실수가 존재하지만 파이 처럼 3.1415926535... 형태로 무한하게 떨어지는 경우도 있죠. 이런 경우엔 사실 10진수로도 근삿값으로 처리합니다.

컴퓨터에서는 크게 고정 소수점(Fixed Point), 부동 소수점(Float Point) 이라는 방식이 존재합니다. 고정 소수점은 간단합니다. 특정 Bit를 기준으로 정수와 소수를 구분하죠. 8Bit로 실수를 구현한다면 앞의 4Bit는 정수, 뒤의 4Bit는 소수로 구분하죠. 1010.011110100111 로 단순하게 나타낼 수 있죠. 구현은 간단하고, 연산도 빠릅니다. 하지만 표현하고자 하는 수의 범위가 제한적이라는 단점이 존재합니다. 결국 어느 시점에서 Bit를 끊어먹어야 하니까요. 하지만 안쓰이는 건 아니고 고속, 고효율 연산이 필요한 임베디드 분야에 많이 쓰입니다. 연구용으로도 많이 쓰이죠.

대부분의 범용적인 프로그래밍 언어는 기본적으로는 부동소수점을 지원합니다. 여기서 부동은 이름에서 알 수 있듯이 떠다닌다는 의미죠. 소수점의 위치를 고정하지 않고, 그 위치를 나타내는 수를 따로 적는 방법입니다. 유효 숫자를 나타내는 가수(Fraction) , 소수점의 위치를 나타내는 지수(Exponent)로 나누어 수를 표현합니다.

image

IEEE (전기전자공학자협회) 에서 개발한 IEEE 754 표준에 따르면, 부동 소수점 표현은 크게 세 부분으로 구성됩니다. 최 상위 비트는 부호를 표시하고, 지수부와 가수부로 분리되죠.

Float (32Bit)Double (64Bit) 은 지수부와 가수부의 데이터 용량 차이로 나뉩니다. Float 는 부호 1Bit, 지수 8Bit, 가수 23Bit 로 나뉘고 Double은 부호 1Bit, 지수 11Bit, 가수 52Bit로 나뉘죠. 여기서 공통적으로 지수부는 Bias 형태로 저장됩니다. Float에서는 127, Double 에서는 1023의 Bias 를 가지고 지수값에 미리 정해진 Bias 값을 더해서 저장합니다.

왜 Bias 형태로 저장할까요? 부동소수점 수는 보통 다음과 같은 형태로 저장됩니다.

(1)sign×1.fraction×2exponent(-1)^{sign} \times 1.fraction \times 2^{exponent}

여기서 실제 지수는 음수일 수도, 양수일 수도 있죠. 만약 지수를 부호 있는 정수로 저장한다면, 음수와 양수를 모두 표현해야 하므로 하드웨어 설계나 비교 연산이 복잡해질 수 있습니다.

Bias 형태로 지수를 저장하면, 실제 지수에 일정 상수를 더해서 저장되는 값은 항상 양수가 됩니다. 그러므로 저장 된 지수를 부호 없는 정수로 처리할 수 있고, 정렬이나 비교가 간단해집니다.

문자

문자는 어떻게 저장될까요? char 는 C에서는 1Byte (8Bit), JVM 에서는 2Byte(16Bit) 로 정의됩니다. JVM 이 2Byte 인 이유는, Unicode 를 기본적으로 지원하기 때문입니다. char 변수값에 할당 된 메모리에는 문자에 해당하는 ASCII, Unicode 값이 저장됩니다. 예를 들어 A는 ASCII 로 65(16진수로 0x41) 이니까 메모리 상엔 0x41 로 저장되어있죠. Unicode 에서는 0x0041 이죠.

단일 Byte 이므로 엔디안 문제는 발생하지 않습니다.

Outro

이번 포스팅에서는 정수의 의미, 동작과정, 형태 별 데이터 저장 방법 등을 알아보았습니다. 하나의 포스팅으로 담기엔 조금 방대한 내용이었다고 생각이 듭니다. 하지만 정말 단순해보이는 변수 하나에도 이렇게 많은 지식들이 쌓여있죠.

개인적으로도 작성하면서 좋은 공부가 되었습니다. 오랫동안 놓고 잊고있던 C++를 간만에 찾아보기도 했네요. 재밌게 읽으셨으면 좋겠네요. 만약 피드백 하실 사항이 있으시다면 얼마든지 편하게 부탁드립니다 😊

Reference

https://www.tcpschool.com/c/c_memory_structure https://www.javatpoint.com/memory-management-in-java https://ko.wikipedia.org/wiki/2%EC%9D%98_%EB%B3%B4%EC%88%98 https://ko.wikipedia.org/wiki/%EB%B6%80%EB%8F%99%EC%86%8C%EC%88%98%EC%A0%90