구조체, 메소드 & 인터페이스

이 챕터의 모든 코드는 여기에서 확인할 수 있다.

높이와 너비가 주어진 사각형의 둘레를 계산하는 기하학 코드가 필요하다고 가정한다. Perimeter(width float64, height float64) 함수를 작성할 수 있다. 여기서 float64123.45 같은 부동 소수점 수에 대한 것이다.

지금쯤이면 TDD 주기가 꽤 익숙할 것이다.

테스트부터 작성하기

func TestPerimeter(t *testing.T) {
    got := Perimeter(10.0, 10.0)
    want := 40.0

    if got != want {
        t.Errorf("got %.2f want %.2f", got, want)
    }
}

새로운 형식의 문자열을 보면 ffloat64에 대한 것이고, .2는 소수점 2자리 출력을 의미한다.

테스트 실행해보기

./shapes_test.go:6:9: undefined: Perimeter

컴파일이 되는 최소한의 코드를 작성하고, 테스트 실패 출력을 확인하기

func Perimeter(width float64, height float64) float64 {
    return 0
}

결과 shapes_test.go:10: got 0.00 want 40.00 가 출력된다.

테스트를 통과하는 최소한의 코드 작성하기

func Perimeter(width float64, height float64) float64 {
    return 2 * (width + height)
}

지금까지는 정말 쉽다. 이제 직사각형의 면적을 반환하는 Area(width, height float64) 함수를 만들어 본다.

TDD 주기에 따라 직접 작성한다.

이와 같은 코드로 테스트가 끝나야 한다.

func TestPerimeter(t *testing.T) {
    got := Perimeter(10.0, 10.0)
    want := 40.0

    if got != want {
        t.Errorf("got %.2f want %.2f", got, want)
    }
}

func TestArea(t *testing.T) {
    got := Area(12.0, 6.0)
    want := 72.0

    if got != want {
        t.Errorf("got %.2f want %.2f", got, want)
    }
}
func Perimeter(width float64, height float64) float64 {
    return 2 * (width + height)
}

func Area(width float64, height float64) float64 {
    return width * height
}

리팩터링 하기

코드는 제대로 작동하지만, 직사각형에 대한 명시적인 내용이 없다. 부주의한 개발자가 삼각형의 너비와 높이를 이러한 함수에 사용 할 수 있는데, 함수가 잘못된 값을 반환할 것이다.

RectangleArea와 같이 기능을 좀 더 구체적으로 지정할 수 있다. 더 나은 해결책은 이 개념을 캡슐화하는 Rectangle이라고 불리는 자기만의 type 을 정의하는 것이다.

struct를 사용해서 간단한 유형을 만들 수 있다. A struct는 데이터를 저장할 수 있는 명명된 필드의 집합이다.

이와 같이 struct를 선언한다.

type Rectangle struct {
    Width float64
    Height float64
}

이제 일반 float64 대신 Rectangle을 사용하도록 코드를 리팩터링한다.

func TestPerimeter(t *testing.T) {
    rectangle := Rectangle{10.0, 10.0}
    got := Perimeter(rectangle)
    want := 40.0

    if got != want {
        t.Errorf("got %.2f want %.2f", got, want)
    }
}

func TestArea(t *testing.T) {
    rectangle := Rectangle{12.0, 6.0}
    got := Area(rectangle)
    want := 72.0

    if got != want {
        t.Errorf("got %.2f want %.2f", got, want)
    }
}

코드를 고치기 전에 테스트를 실행하고, 다음과 같은 유용한 에러를 얻어야 한다.

./shapes_test.go:7:18: not enough arguments in call to Perimeter
    have (Rectangle)
    want (float64, float64)

myStruct.field 구문을 사용하여 구조체 필드에 접근할 수 있다.

두 가지 함수를 변경하여 테스트 코드를 수정한다.

func Perimeter(rectangle Rectangle) float64 {
    return 2 * (rectangle.Width + rectangle.Height)
}

func Area(rectangle Rectangle) float64 {
    return rectangle.Width * rectangle.Height
}

함수에 Rectangle을 전달하는 것이 의도에 더 명확하게 전달하지만 앞으로 배워가는 구조체를 사용하는 것이 더 많은 이점이 있다는것에 동의해주길 바란다.

다음 필요조건은 원에 대한 Area함수를 작성하는 것이다.

테스트부터 작성하기

func TestArea(t *testing.T) {

    t.Run("rectangles", func(t *testing.T) {
        rectangle := Rectangle{12, 6}
        got := Area(rectangle)
        want := 72.0

        if got != want {
            t.Errorf("got %g want %g", got, want)
        }
    })

    t.Run("circles", func(t *testing.T) {
        circle := Circle{10}
        got := Area(circle)
        want := 314.1592653589793

        if got != want {
            t.Errorf("got %g want %g", got, want)
        }
    })

}

보다시피, 'f'가 'g'로 대체되었다. 'f'를 사용하면 정확한 십진수를 알 수가 없고, 'g'를 사용하면 오류메시지에서 완전한 십진수를 얻을 수 있다 (fmt options).

테스트 실행해보기

./shapes_test.go:28:13: undefined: Circle

컴파일이 되는 최소한의 코드를 작성하고, 테스트 실패 출력을 확인하기

Circle 타입을 정의할 필요가 있다.

type Circle struct {
    Radius float64
}

이제 테스트를 다시 실행한다.

./shapes_test.go:29:14: cannot use circle (type Circle) as type Rectangle in argument to Area

일부 프로그래밍 언어를 사용하면 다음과 같은 코드를 사용할 수 있다.

func Area(circle Circle) float64 { ... }
func Area(rectangle Rectangle) float64 { ... }

하지만 Go에서는 사용할 수 없다.

./shapes.go:20:32: Area redeclared in this block

두 가지 방법이 있다.

  • 동일한 이름의 함수를 다른 packages 로 선언할 수 있다. 그래서 새로운 패키지로 Area(Circle)을 만들 수 있지만, 여기서는 너무 과한 느낌이다.

  • 대신 새로 정의된 유형을 methods 정의할 수 있다.

메서드란 무엇인가?

지금까지 functions 을 쓰고 몇가지 방법을 사용해 왔다. t.Errorf를 부를 때 t (testing.T)의 인스턴스에서 Errorf메서드라고 한다.

메서드는 수신기가 있는 함수이다. 메서드 선언은 메서드 이름인 식별자를 메서드에 바인딩하고 메서드를 수신기의 기본 유형과 연결한다.

메서드는 함수와 매우 유사하지만 특정 유형의 인스턴스에서 메서드를 호출해야 호출된다.

Area(rectangle)과 같이 원하는 곳 어디서나 기능을 호출할 수 있는 경우에는 "things"에 대해서만 메서드를 호출할 수 있다.

예를 들어보면 도움이 되므로 먼저 테스트를 변경하여 메서드를 호출한 다음 코드를 수정한다.

func TestArea(t *testing.T) {

    t.Run("rectangles", func(t *testing.T) {
        rectangle := Rectangle{12, 6}
        got := rectangle.Area()
        want := 72.0

        if got != want {
            t.Errorf("got %g want %g", got, want)
        }
    })

    t.Run("circles", func(t *testing.T) {
        circle := Circle{10}
        got := circle.Area()
        want := 314.1592653589793

        if got != want {
            t.Errorf("got %g want %g", got, want)
        }
    })

}

만약 테스트를 실행한다면

./shapes_test.go:19:19: rectangle.Area undefined (type Rectangle has no field or method Area)
./shapes_test.go:29:16: circle.Area undefined (type Circle has no field or method Area)

type Circle has no field or method Area

여기서 컴파일러가 얼마나 훌륭한지 다시 한번 강조하고 싶다. 오류 메시지를 천천히 읽는 것은 매우 중요하기 때문에 장기적으로 도움이 될 것이다.

컴파일이 되는 최소한의 코드를 작성하고, 테스트 실패 출력을 확인하기

유형에 몇가지 메서드를 추가하겠다.

type Rectangle struct {
    Width  float64
    Height float64
}

func (r Rectangle) Area() float64  {
    return 0
}

type Circle struct {
    Radius float64
}

func (c Circle) Area() float64  {
    return 0
}

메서드를 선언하는 구문은 함수와 거의 동일하며, 이는 메서드가 함수와 정말 유사하기 때문이다. 유일한 차이점은 메서드 수신기 func (receiverName ReceiverType) MethodName(args)의 구문이다.

메서드가 해당 유형의 변수에 호출되면 receiverName 변수를 통해 해당 데이터에 대한 참조를 얻는다. 다른 많은 프로그래밍 언어에서 이것은 암시적으로 수행되며 receiverName을 통해 수신기에 접근한다.

수신자 변수를 유형의 첫 번째 문자로 지정하는 것이 Go의 관례이다.

r Rectangle

테스트를 다시 실행하려고 하면 컴파일 오류가 발생한 부분을 제공해야 한다.

테스트를 통과하는 최소한의 코드 작성하기

이제 새로운 방법으로 수정하여 직사각형 테스트를 통과하겠다.

func (r Rectangle) Area() float64  {
    return r.Width * r.Height
}

테스트를 다시 실행하는 경우 직사각형 테스트는 통과 하지만 원 테스트는 여전히 실패한다.

원의 Area 함수가 통과하도록 만들기 위해 math 패키지에서 Pi 상수를 빌려온다. (import 잊지 않아야 한다.).

func (c Circle) Area() float64  {
    return math.Pi * c.Radius * c.Radius
}

리팩터링 하기

테스트에는 몇가지 중복된 것이 있다.

원하는 것은 도형 모음을 가져와서 도형에 대한 Area() 메서드를 호출한 다음 결과를 확인하는 것이다.

RectangleCircle 테스트를 통과할 수 있지만 도형이 아닌 것을 전달하려고 하면 컴파일하지 못하는 일종의 checkArea 기능으로 쓸 수 있기를 바란다.

Go를 사용하면 interfaces를 사용하여 이 의도를 코드화할 수 있다.

Interfaces Go와 같이 정적 형식의 언어에서는 매우 강력한 개념으로, 다양한 유형과 함께 사용할 수 있는 함수를 만들고, 여전히 유형 안전성을 유지하면서 고도로 세분화된 코드를 만들 수 있기 때문이다.

테스트 내용을 리팩터링해서 소개하겠다.

func TestArea(t *testing.T) {

    checkArea := func(t testing.TB, shape Shape, want float64) {
        t.Helper()
        got := shape.Area()
        if got != want {
            t.Errorf("got %g want %g", got, want)
        }
    }

    t.Run("rectangles", func(t *testing.T) {
        rectangle := Rectangle{12, 6}
        checkArea(t, rectangle, 72.0)
    })

    t.Run("circles", func(t *testing.T) {
        circle := Circle{10}
        checkArea(t, circle, 314.1592653589793)
    })

}

다른 연습문제처럼 도우미 기능을 만들고 있는데 이번에는 도형을 반환하라고 한다. 만약 도형이 아닌 것으로 부르려고 한다면, 컴파일하지 못할 것이다.

어떤 것이 도형이 되는가? 인터페이스 선언을 사용하는 Shape가 무엇인지 Go에 알려주기만 하면 된다.

type Shape interface {
    Area() float64
}

RectangleCircle을 만들었던 것처럼 새로운 type을 만들고 있지만 이번에는 struct가 아닌 interface 이다.

이것을 코드에 추가하면 테스트는 통과된다.

무엇을 기다리나요?

Go의 interface는 대부분의 다른 프로그래밍 언어의 인터페이스와는 상당히 다르다. 보통은 My type Foo implements interface Bar 라는 코드를 작성해야 한다.

하지만 우리의 경우에는

  • RectangleArea 메서드를 호출하여 float64를 반환하므로 Shape 인터페이스를 만족시킨다.

  • CircleArea 메서드를 호출하여 float64를 반환하므로 Shape 인터페이스를 만족시킨다.

  • string에 해당하는 메서드가 없으므로 인터페이스를 만족하지 않는다.

  • 기타 등등

Go에서 인터페이스 자료형은 암시적 이다. 전달하는 유형이 인터페이스가 요청하는 유형과 일치하면 컴파일 된다.

디커플링(Decoupling)

도우미가 그 도형이 Rectangle인지 Circle인지 Triangle의 도형인지에 대해 어떻게 신경 쓸 필요가 없는지 주목한다. 인터페이스를 선언함으로써 도우미는 구체적인 유형으로부터 분리 되고 단지 그 일을 하는데 필요한 방법을 갖게 된다.

필요한 것만 선언하는 인터페이스를 사용하는 접근 방식은 소프트웨어 설계에서 매우 중요하며, 이후 섹션에서 자세히 설명하겠다.

추가 리팩터링 하기

이제 구조체에 대해 어느 정도 이해했으므로 "테이블 기반 테스트"를 소개하겠다.

테이블 기반 테스트 동일한 방법으로 테스트할 수 있는 테스트 사례 목록을 작성하는 경우 유용하다.

func TestArea(t *testing.T) {

    areaTests := []struct {
        shape Shape
        want  float64
    }{
        {Rectangle{12, 6}, 72.0},
        {Circle{10}, 314.1592653589793},
    }

    for _, tt := range areaTests {
        got := tt.shape.Area()
        if got != tt.want {
            t.Errorf("got %g want %g", got, tt.want)
        }
    }

}

여기서 유일하게 새로운 구문은 "익명 구조" areaTests를 만드는 것이다. shapewant라는 두 개의 필드가 있는 []struct를 사용하여 구조체를 선언한다. 그런 다음 슬라이스를 케이스로 채운다.

다른 슬라이스처럼 테스트를 실행하기 위해 구조체 필드를 사용하여 반복한다.

개발자가 새로운 도형을 도입하고 Area를 구현한 후 테스트 케이스에 추가하는 것은 쉽다. 게다가, 만약 Area에 있는 버그가 발견된다면, 버그를 고치기 전에 새로운 테스트 케이스를 추가하여 테스트하는 것이 매우 쉽다.

테이블 기반 테스트 도구 모음에서 좋은 항목이 될 수 있지만 테스트에서 extra noise가 필요한지 확인해야 한다.

인터페이스의 다양한 구현을 테스트하려는 경우 또는 함수에 전달된 데이터에 테스트가 필요한 여러 가지 요구 사항이 있는 경우 적합한 항목이다.

이 모든 것을 다른 도형, 즉 삼각형을 추가해서 테스트 한다.

테스트부터 작성하기

새로운 도형을 위한 새로운 테스트를 추가하는 것은 매우 쉽다. 코드 {Triangle{12, 6}, 36.0},를 추가하기만 하면 된다.

func TestArea(t *testing.T) {

    areaTests := []struct {
        shape Shape
        want  float64
    }{
        {Rectangle{12, 6}, 72.0},
        {Circle{10}, 314.1592653589793},
        {Triangle{12, 6}, 36.0},
    }

    for _, tt := range areaTests {
        got := tt.shape.Area()
        if got != tt.want {
            t.Errorf("got %g want %g", got, tt.want)
        }
    }

}

테스트 실행해보기

테스트를 계속 실행하여 컴파일러가 솔루션을 안내하도록 한다.

컴파일이 되는 최소한의 코드를 작성하고, 테스트 실패 출력을 확인하기

./shapes_test.go:25:4: undefined: Triangle

아직 Triangle을 정의하지 않았다.

type Triangle struct {
    Base   float64
    Height float64
}

다시 테스트를 시작한다.

./shapes_test.go:25:8: cannot use Triangle literal (type Triangle) as type Shape in field value:
    Triangle does not implement Shape (missing Area method)

Area() 메서드에 Triangle이 없기 때문에 도형으로 사용할 수 없음을 알려주고 있으므로 빈 메서드을 추가하여 테스트한다.

func (t Triangle) Area() float64 {
    return 0
}

마지막으로 코드가 컴파일되고 오류가 발생한다.

shapes_test.go:31: got 0.00 want 36.00

테스트를 통과하는 최소한의 코드 작성하기

func (t Triangle) Area() float64 {
    return (t.Base * t.Height) * 0.5
}

그리고 테스트를 통과했다!

리팩터링 하기

다시 말하지만, 구현은 괜찮지만 테스트 코드는 약간의 개선을 할 수 있다.

값을 입력할때

{Rectangle{12, 6}, 72.0},
{Circle{10}, 314.1592653589793},
{Triangle{12, 6}, 36.0},

모든 숫자가 무엇을 나타내는지는 바로 분명하지 않으며 쉽게 이해될 수 있는 테스트를 목표로 해야 한다.

지금까지 MyStruct{val1, val2} 구조의 인스턴스를 생성하는 구문만 표시되었지만 선택적으로 필드 이름을 지정할 수 있다.

어떻게 구성되는지 본다.

        {shape: Rectangle{Width: 12, Height: 6}, want: 72.0},
        {shape: Circle{Radius: 10}, want: 314.1592653589793},
        {shape: Triangle{Base: 12, Height: 6}, want: 36.0},

예제별 테스트 기반 개발 Kent Beck은 몇 가지 테스트를 반영하고 다음과 같이 주장한다.

테스트는 더 명확하게 말해준다, as if it were an assertion of truth, 작업의 연속이 아니다

(강조)

이제 테스트는 (최소한의 예제 목록) shapes와 areas에 대한 올바른 결과(assertion of truth)를 만든다.

테스트 출력이 유용한지 확인하기

아까 Triangle을 실행하다가 실패한 테스트에서 shapes_test.go:31: got 0.00 want 36.00를 출력했다.

Triangle과 관련 있다는 것은 Triangle을 가지고 작업하고 있었기 때문에 알고 있다. 하지만 테이블에 있는 20개의 케이스 중 하나에서 버그가 시스템에서 발생한다면? 어떤 경우에 실패했는지 개발자가 어떻게 알 수 있을까? 이것은 개발자에게 좋은 경험이 아니다. 실제로 실패한 사례를 찾기 위해 수동으로 검토해야 한다.

오류 메시지를 %#v got %g want %g로 변경할 수 있습니다. %#v 형식 문자열은 필드 값이 있는 구조를 출력하여 개발자가 테스트 중인 속성을 한 눈에 볼 수 있도록 한다.

테스트 사례의 가독성을 높이기 위해 want필드를 hasArea와 같이 좀 더 설명적인 것으로 바꿀 수 있다.

테이블 기반 테스트의 마지막 팁은 t.Run을 사용하고 테스트 케이스 이름을 지정하는 것이다.

각각의 케이스를 t.Run으로 실행하면 케이스 이름을 출력하기 때문에 실패시 테스트 출력이 명확해진다.

--- FAIL: TestArea (0.00s)
    --- FAIL: TestArea/Rectangle (0.00s)
        shapes_test.go:33: main.Rectangle{Width:12, Height:6} got 72.00 want 72.10

또한 go test -run TestArea/Rectangle를 사용하여 특정 테스트를 실행할 수 있다.

다음은 위처럼 출력하는 최종 테스트 코드이다.

func TestArea(t *testing.T) {

    areaTests := []struct {
        name    string
        shape   Shape
        hasArea float64
    }{
        {name: "Rectangle", shape: Rectangle{Width: 12, Height: 6}, hasArea: 72.0},
        {name: "Circle", shape: Circle{Radius: 10}, hasArea: 314.1592653589793},
        {name: "Triangle", shape: Triangle{Base: 12, Height: 6}, hasArea: 36.0},
    }

    for _, tt := range areaTests {
        // using tt.name from the case to use it as the `t.Run` test name
        t.Run(tt.name, func(t *testing.T) {
            got := tt.shape.Area()
            if got != tt.hasArea {
                t.Errorf("%#v got %g want %g", tt.shape, got, tt.hasArea)
            }
        })
    }

}

정리

기본적인 수학 문제에 대한 해결책을 반복하고 테스트에 의해 파생된 언어의 새로운 특징을 배우는 TDD 연습이었다.

  • 관련 데이터를 함께 묶고 코드의 의도를 명확히 할 수 있는 자신만의 데이터 유형을 만들기 위한 구조체 선언

  • 다양한 유형 (파라미터의 다형성)에서 사용할 수 있는 함수를 정의할 수 있도록 인터페이스 선언

  • 데이터 유형에 메서드를 추가하고 인터페이스를 구현하는 메서드 추가

  • 테이블 기반 테스트를 통해 코드를 보다 명확히하고 확장 및 유지 관리하기 쉬움

구조체, 메서드 & 인터페이스는 중요한 장이다. 왜냐하면 이제 자신의 유형을 정의하기 시작했기 때문이다. Go와 같이 정적으로 입력된 언어에서는 이해하기 쉬운 소프트웨어를 만들고, 조립하고, 테스트하기 위해 자신만의 유형을 설계할 수 있는 능력이 필수적이다.

인터페이스는 시스템의 다른 부분으로부터 복잡성을 숨길 수 있는 훌륭한 도구다. 테스트 도우미 code 는 정확한 도형을 알 필요가 없고 단지 그 영역을 "묻는" 방법만 알 필요가 있다.

Go에 익숙해지면 인터페이스와 표준 라이브러리의 실제 강점을 볼 수 있다. 어디에서나 사용되는 표준 라이브러리에 정의된 인터페이스에 대해 배울 수 있으며, 이러한 인터페이스를 자신의 유형에 맞게 구현하면 우수한 많은 기능을 빠르게 재사용할 수 있다.

Last updated