본문 바로가기

프로그래밍 언어

[Things of Go] 얕은 복사와 깊은 복사

해당 글은 독자가 Go언어의 기본적인 문법과 포인터 혹은 참조 자료형의 개념에 대해 알고 있다고 가정한다.

변수 복사

프로그래밍 언어를 어느 정도 다룰 줄 알게 되면 항상 유의해야 하는 부분이 있다. 바로 변수의 복사이다. 변수의 복사라고 거창하게 말했지만 한 변수값을 다른 변수값에 할당하는 것이다.

var a int = 3
var b int = a //복제

위에 예시로 든 int형같이 기본 자료형은 위와 같이 할당문으로 복사해도 별 문제가 되지 않는다. 그러나 자료형이 참조형일 때는 이야기가 다르다. 예시를 들기 위해 다음의 클래스를 준비했다.

package main

import (
    "bytes"
    "encoding/gob"
    "fmt"
)

type Pos interface {
    Print()
    Copy() Pos
    Move(x, y, z int)
    Rename(string)
}

type pos struct {
    X, Y, Z int
    Name    string
}

func NewPos(name string, x, y, z int) Pos {
    p := new(pos)
    p.Name = name
    p.X = x
    p.Y = y
    p.Z = z
    return p
}

func (p *pos) Print() {
    fmt.Printf("%s (%d, %d, %d)\n", p.Name, p.X, p.Y, p.Z)
}

func (p *pos) Copy() Pos {
// 코드 입력
}

func (p *pos) Move(x, y, z int) {
    p.X = x
    p.Y = y
    p.Z = z
}

func (p *pos) Rename(name string) {
    p.Name = name
}

얕은 복사

얕은 복사(Shallow Copy)는 해당 변수의 주소값을 복사하는 것을 의미한다. 주소값을 복사한다는 것은 결국 같은 객체를 가리킨다는 것이다. 즉, 한 쪽의 객체를 변경하면 다른 쪽 역시 그 주소의 객체를 가리키기 때문에 영향을 받게 된다. 이를 구현하는 것은 쉽다. 참조형 변수(배열, 포인터, 클래스 객체)를 단순 할당문 형태로 복사하면 얕은 복사가 된다.

func main() {
    p1 := NewPos("wall", 0, 0, 0)
    p1.Print()

    p2 := p1
    p2.Move(1,0,0)

    p1.Print()
    p2.Print()
}
wall (0, 0, 0)
wall (1, 0, 0)
wall (1, 0, 0)

p2의 멤버 X만 바꿨지만 p1의 멤버 X까지 바뀐 모습이다.(사실 이런 표현은 적절치 않다. 애초에 p1의 멤버 X와 p2의 멤버 X는 표현만 다른 같은 변수이다.)

깊은 복사

깊은 복사(Deep Copy)는 해당 변수의 주소에 있는 값을 복사하는 것을 의미한다. 이 때의 복사한 객체는 새로운 주소값을 가지며 복사된 이후로 복사한 객체와 복사된 객체는 별개의 객체가 된다. 즉, 한 쪽의 객체를 변경해도 다른 쪽은 이미 별개의 객체이기 때문에 영향을 받지 않게 된다. 깊은 복사는 언어 차원에서 지원하지 않기 때문에 메소드로 직접 구현해야 한다.

Go언어에서 깊은 복사를 구현하는 데에는 두가지 방법이 있다.

내부 변수 할당

정석적인 방법이다. 내부 변수에 접근해 하나씩 할당하면 된다.

func (p *pos) Copy() Pos {
	copied := NewPos(p.Name, p.X, p.Y, p.Z)
	return copied
}

해당 방법은 직관적이다. 객체를 생성해서 그 객체에 복사할 객체의 값들을 넘겨줄 뿐이다. 하지만 해당 방법은 몇가지 문제가 있다. 우선 객체의 크기가 커질 수록 해야하는 작업이 많아진다. 위처럼 작은 객체야 그나마 낫지만 더 큰 객체, 혹은 여러 객체를 포함하는 객체는 코드가 더욱 길어진다. 게다가 생성 함수에 없는 변수들은 따로 복사되도록 처리해야 한다. 마지막으로 생성 함수가 달라지면 해당 함수 역시 달라져야 한다. 즉, 유연하지 않은 방법이다.

직렬화

직렬화는 객체 등의 변수를 임의의 형태로 가공하는 것을 말한다. 이 때 변수를 임의의 포맷으로 변환하는 것을 인코딩이라 하고 해당 포맷의 데이터를 다시 변수로 변환하는 것을 디코딩이라고 한다. 복사할 객체를 인코딩하고 해당 데이터를 새로운 객체에 디코딩하여 집어넣음으로써 깊은 복사가 이루어질 수 있다.

func (p *pos) Copy() Pos {
    var b bytes.Buffer
    var result *pos
    //gob, json 등의 포맷으로 직렬화한다.
    e := gob.NewEncoder(&b)
    d := gob.NewDecoder(&b)

    e.Encode(p)
    d.Decode(&result)
    return result
}

위의 방법은 객체의 크기가 커질 수록 더욱 빛을 발한다. 무엇보다도, 클래스 내부 변수가 바뀌더라도 해당 함수는 변경할 필요가 없다. 하지만 위의 방법에는 한가지 결정적인 단점이 있다. 내부 변수를 모두 public으로 선언해야 한다는 것이다.(Go언어에서는 변수의 맨 앞글자가 대문자이면 public으로 선언된다) 내부 변수를 public으로 선언할 때 얻는 위험성은 객체지향을 어느 정도 써봤다면 알 것이다.

사실 Go언어는 interface만으로 변수를 완전히 사용할 수 있다면 내부 변수를 public으로 선언해도 문제가 없도록 설계되었다. 다만 interface만으로 변수를 완전히 사용할 수 있다는 것은 객체지향적으로 설계가 완성되었다는 것이고 여기에는 상당한 경험과 고민이 필요하다. 즉, 직렬화를 통한 깊은 복사를 이용하기 위해서는 완전한 객체지향적인 설계가 기반이 되어야 한다.

결과

func main() {
	p1 := NewPos("wall", 0, 0, 0)
	p1.Print()

	p2 := p1.Copy()
	p2.Move(1, 1, 1)

	p1.Print()
	p2.Print()
}

 

wall (0, 0, 0)
wall (0, 0, 0)
wall (1, 1, 1)

마치며...

예전에 얕은 복사와 깊은 복사에 관련된 버그 때문에 삽질을 했던 경험이 있다. 어느 변수든 복사하기 이전에 그 변수가 참조형인지, 또 참조형이라면 얕은 복사를 이용할지 깊은 복사를 이용할지에 대해 확인해야 한다.