프로그래밍/OOP

[OOP] interface - Go와 함께 알아보는 인터페이스(error, io package)

:) :) 2024. 9. 30. 14:50

Go 와 함께 알아보는 interface 에 대한 포스팅입니다.

 

 

 


 

 

2010년형 Lenovo Thinkpad T410 Laptop의 측면 RJ-45 이더넷 소켓이다. https://commons.wikimedia.org/wiki/File:RJ-45_Ethernet_socket_on_Lenovo_T410_Laptop.jpg

 

컴퓨터에서 인터페이스란

인터페이스(interface)는

서로 다른 두 개의 장치나 시스템 사이에서

정보나 신호를 주고받는 접점, 혹은 경계면입니다.

 

즉 사용자가 기기를 쉽게 동작하는 데 도움을 주는 시스템입니다.

개인 컴퓨터에서 인터넷 통신을 하기 위해서는

본체에 랜선을 꽂아야 하는 것을 다들 알고 계시겠죠?

 

이 때 개인 컴퓨터를 사용하는 사람은

벽에 끼워져 있는 선에서 부터 흘러나오는 전기적 신호의 규칙을 이해하고 있지 않더라도

외부 네트워크 세상에서 흘러들어오는 정보를 잘 받아올 수 있습니다.

 

네트워크를 타고 흘러들어오는 정보는 “안녕!”과 같은 일반적인 텍스트여도

절대 “안녕”이라는 문자 형태로 전송되어오지 않습니다.

여러 프로토콜을 거쳐 결국에는 1101001010… 같은 이진수 형태로 데이터를 교환하는데요

 

이러한 형태에 대한 규칙을 잘 몰라도

미국에서 보낸 “안녕” 메세지를 내 컴퓨터가 받아

“안녕”이라고 모니터에 잘 띄울 수 있게 해주는 건 인터페이스가 있기 때문입니다.

사용자 모르게 1101001010… 이 “안녕”으로 잘 변환되는 과정 속속에는 여러 인터페이스가 존재합니다.

 

그림은 구형 노트북 측면에 달려있는 랜 포트입니다.

저 포트는 인터넷과 내 컴퓨터의 경계면에 해당한다고 할 수 있습니다.

저 포트는 인터넷과 내 컴퓨터 사이의 인터페이스라고 할 수 있는 것입니다.

 

랜 포트를 통해 사용자는 네트워크의 작동방식을 알지 못하더라도

인터넷을 자유자재로 사용할 수 있습니다.

 

 

 

소프트웨어에서 인터페이스란

조금 더 깊게 들어가볼까요?

 

소프트웨어에도 인터페이스라는 개념이 존재합니다.

이 역시 특정 시스템 간 접점이나 경계면을 의미하는데요!

 

Windows나 Linux같은 운영 체제가 대표적으로 여러 인터페이스를 가지고 있는 시스템입니다.

운영 체제는 메모리나 디스크같은 하드웨어 위에 서식하며

사용자 응용 프로그램(크롬, 노션, 게임, 뮤직 플레이어 등)이

하드웨어 자원을 이용해 잘 실행될 수 있도록 조율하는 역할을 합니다.

 

‘즉 운영 체제는 하드웨어와 사용자 응용 프로그램 사이에서 인터페이스 역할을 하고 있다’라고

볼 수 있는 것입니다.

 

운영 체제 자체에도

운영 체제 - 하드웨어 간 인터페이스,

운영 체제 - 응용 프로그램 간 인터페이스가 존재합니다.

 

 

 

 

객체 지향 프로그래밍에서 인터페이스란

사실 오늘 알아보고자 하는 건

객체 지향 프로그래밍 패턴에서의 인터페이스입니다.

 

이 역시 어떠한 것들 간의 접점 혹은 경계면이라 생각해 볼 수 있을 것 같습니다.

 

여기서 어떠한 것이 무엇일까요?

어떠한 것은 바로 ‘클래스’, ‘객체’입니다.

 

인터페이스를 사용하는 객체들은

인터페이스에 해당하는 특정 메소드나 속성을 구현하도록 강제받습니다.

 

객체들은 인터페이스가 구현하도록 강제하는 ‘특정 메소드’라는

접점을 가지고 있습니다.

 

공통된 것이 생기니, 분류하기에 용이하고

따라서 관리하기에 용이하니

코드에 대한 유지보수와 확장이 쉬워지는 장점이 있습니다.

 

 

 

 

더 자세히 알아보기

https://stackoverflow.com/questions/2866987/what-is-the-definition-of-interface-in-object-oriented-programming

간단하게,

인터페이스는 기본적으로

“뭔가가 일단 필요해…” 라고 말하는 것입니다.

 

A라는 기능을 구현하려면

기능 구현에 대한 필요조건을 걸기 위해

인터페이스라는 개념을 사용하는 것이죠!

 

실 세계 차량들을 객체로 모사한

자동차 클래스, 오토바이 클래스, 트럭 클래스가 있다고 가정해 보겠습니다.

 

이렇게 엔진을 달고 있는 각 차량들은 항상

‘start_engine()’ 액션을 취해야 합니다.

 

따라서 각 클래스는 무조건 start_engine()이라는 메소드를 가지고 있어야 합니다.

 

다만! 차량 특성에 맞게 세부 구현 사항은

각자 다를 수도 있는 것이죠

public interface Vehicle
   {
       // 변수(필드)는 인터페이스에 존재할 수 없습니다
       // 변수는 데이터 저장의 특정 구현을 나타내므로 인터페이스를 통해 얻으려는
       // 추상화 성격에 맞지 않습니다.
       // 오직 function PROTOTYPES(함수 프로토타입)만 가능합니다

       /*
        * "차량"이 되기 위한 것(클래스)은 모두 아래 메소드를 구현해야 한다.
        */
       function start_engine() : void;
   }

이러한 방식을 통해

다형성, 유연함 등의 장점을 얻습니다.

 

 

 

 

Interfaces in Go

Go 언어에서 인터페이스는 특정 메서드 시그니처의 집합을 정의하는 추상 타입입니다.

 

일반적인 객체 지향 언어에서

클래스와 상속을 통해 인터페이스를 다루는 것과 다르게

Go는 상속 같은 것 없이 인터페이스가 요구하는 메소드의 구현을 통해

인터페이스를 다룹니다.

 

따라서 Go는 유연하고 간결한 방식으로 다형성을 구현합니다.

 

다음은 Go 인터페이스의 주요 특징과 사용 방법입니다.

  1. 암묵적 구현: Go에서는 타입이 특정 인터페이스를 구현한다고 명시적으로 선언할 필요가 없습니다. 대신, 타입이 인터페이스에 정의된 모든 메서드를 구현하면 그 인터페이스를 만족한다고 간주됩니다. 이는 코드의 결합도를 낮추고 유연성을 높이는 데 기여합니다.
  2. 메서드 시그니처만 포함: 인터페이스는 메서드의 시그니처만 포함하며, 실제 구현은 없습니다. 따라서 인터페이스는 어떤 동작이 필요한지를 정의할 뿐, 어떻게 구현될지는 해당 타입에 맡깁니다.
  3. 다중 인터페이스 구현 가능: 하나의 타입이 여러 개의 인터페이스를 구현할 수 있습니다. 이는 다양한 컨텍스트에서 동일한 타입을 사용할 수 있게 해줍니다.
  4. 빈 인터페이스: **interface{}**는 메서드가 없는 빈 인터페이스로, 모든 타입이 이를 구현합니다. 이는 다양한 타입을 처리할 수 있는 함수나 메서드를 작성할 때 유용합니다.

 

 

 

 

예시

package main

import "fmt"

// Shape 인터페이스 정의
type Shape interface {
	area() float32
}

// Rectangle 구조체와 메서드 구현
type Rectangle struct {
	length, breadth float32
}

func (r Rectangle) area() float32 {
	return r.length * r.breadth
}

// Triangle 구조체와 메서드 구현
type Triangle struct {
	base, height float32
}

func (t Triangle) area() float32 {
	return 0.5 * t.base * t.height
}

// calculate 함수는 Shape 인터페이스를 인수로 받아 처리
func calculate(s Shape) {
	fmt.Println("Area:", s.area())
}

func main() {
	rect := Rectangle{7, 4}
	tri := Triangle{8, 12}

	calculate(rect)
	calculate(tri)
	
}

위 예제를 보면

모든 도형은 면적을 계산할 수 있는 area() 메소드를 가지고 있어야 합니다.

 

**Rectangle**과 Triangle 구조체는 area 메소드 구현을 통해

각각 Shape 인터페이스를 구현하고 있습니다.

 

결과적으로 calculate 함수는 Shape 타입의 인수를 받아 해당 객체의 면적을 계산합니다.

 

Go의 인터페이스는 이러한 방식으로

다양한 데이터 타입 간의 일관된 동작을 보장하면서도 유연한 설계를 가능하게 합니다.

 

 

 

 

Go에서 많이 사용되는 인터페이스

Stringers

fmt 패키지에 정의되어있는 Stringer 인터페이스는

가장 널리 사용하는 인터페이스 중 하나입니다.

type Stringer interface {
	String() string
}

위처럼, Stringer 인터페이스는 문자열 타입의 리턴값을 가지는 String( ) 메소드를 구현하기만 하면

됩니다.

 

한 type은 Stringer 인터페이스 구현을 통해,

type의 출력값을 원하는 대로 수정할 수 있습니다.

아래 예시를 보면서 이해해볼까요?

 

다음은 ip 주소를 저장하고 출력하는 예시입니다.

package main

import "fmt"

type IPAddr [4]byte // ip 주소의 각 8비트씩을 요소로 담는 32bits(4bytes)배열

func (ipaddr IPAddr) String() string { // stringer 인터페이스를 구현했다.
	return fmt.Sprintf("%v.%v.%v.%v",
		ipaddr[0], ipaddr[1], ipaddr[2], ipaddr[3],
	)
}

func main() {
	hosts := map[string]IPAddr{
		"loopback":  {127, 0, 0, 1},
		"googleDNS": {8, 8, 8, 8},
	}
	for name, ip := range hosts {
		fmt.Printf("%v: %v\\n", name, ip) // IPAddr type 출력에 대해서만 오버라이딩
																		 // %v 포맷이 String() 메소드를 호출함
	}
}

 

 

fmt.Printf 를 통해 hosts의 ip를 출력할 때, ‘x.x.x.x’ 형식으로 알아서 잘 출력되게

IPAddr type에 Stringer인터페이스를 구현해야 합니다.

// 실행 결과
googleDNS: 8.8.8.8
loopback: 127.0.0.1

실행 결과, IPAddr{1, 2, 3, 4} type은 출력 시 "1.2.3.4"처럼 나와야 한다.

 

 

 

다음은 IPAddr의 Stringer 인터페이스 구현입니다.

String( ) 메소드 구현을 통해 Stringer 인터페이스를 구현합니다.

func (ipaddr IPAddr) String() string {
	return fmt.Sprintf("%v.%v.%v.%v",
		ipaddr[0], ipaddr[1], ipaddr[2], ipaddr[3],
	)

String( ) 메소드 구현

 

이 때, String( ) 메소드의

리턴은 Sprintf 함수를 통하는 이유가 있습니다.

Sprintf 함수는 return type이 string 단 한개입니다.

 

Printf 만 해도, 리턴 값이 바이트 수와 에러 넘버로서 벌써 두 개가 되기도 하고, string type이 아닙니다.

 

String( ) 메소드는 return type이 string 형태로 되어야 인터페이스 구현 조건을 만족할 수 있기 때문에

Sprintf 함수를 사용합니다.

for name, ip := range hosts {
		fmt.Printf("%v: %v\\n", name, ip) // IPAddr type 출력에 대해서만 오버라이딩
																		 // %v 포맷이 String() 메소드를 호출함
	}

func main( )

main 함수 내부에서 실제 출력을 진행하는 부분에도 주의점이 있습니다.

특정 struct나 인터페이스의 경우 %v 포맷을 통해야지만

해당 타입이 구현한 String( ) 메소드를 호출합니다.

 

즉, fmt.Printf(”%d”, ip) 이렇게는 제가 위에서 임의로 정의한 출력을 사용하지 못합니다.

 

 

 

 

Errors

Go 프로그램은 error의 값으로 오류 상태를 표현합니다.

Go의 error 역시 interface 구현을 통해 사용자의 입맛에 맞게 재구현할 수 있습니다.

type error interface {
		Error() string
}

이 역시 내장 인터페이스로,

한 type이 Error( ) string 메소드를 구현하기만 한다면

해당 type은 사용자가 임의로 정의한 error 메소드를 가질 수 있습니다.

 

fmt.Stringer 와 마찬가지로 fmt 패키지는 값을 출력할 때 error 인터페이스를 찾습니다.

따라서 error 인터페이스 구현도 출력 형식을 입맛대로 바꾸는 데에 자주 사용합니다.

 

Go에서 함수는 종종 error 값을 반환하기도 합니다.

 

이 때, error 반환값이 nil 이라면

이는 성공을 나타냅니다.

nil이 아닌 error는 실패를 나타냅니다.

 

아래는 예시 코드입니다.

package main

import (
	"fmt"
	"time"
)

type MyError struct { // 사용자 입맛대로의 재구현을 위한 구조체
	When time.Time
	What string
}

func (e *MyError) Error() string {
	return fmt.Sprintf("at %v, %s",
		e.When, e.What)
}

func run() error {
	return &MyError{
		time.Now(),
		"it didn't work",
	}
}

func main() {
	if err := run(); err != nil {
		fmt.Println(err)
	}
}

 


 

Readers (io package)

io package는 ‘데이터 스트림 읽기’를 의미하는

io.Reader 인터페이스를 가지고 있습니다.

 

Go standard library에는 file, network connection, compressor, 암호 등을 포함해

수 많은 인터페이스의 구현이 포함되어있습니다.

 

어쨌든 이 중 io.Reader 인터페이스는 Read 메소드 구현을 통해 구현됩니다.

Read 메소드의 상세한 spec은 아래와 같습니다.

func (T) Read(b []byte) (n int, err error)

Read는 주어진 [ ]byte에 데이터를 채우고

채워넣은 바이트의 수와 에러 값을 반환합니다.

 

데이터 스트림이 종료되면 에러 값으로 io.EOF 를 발생합니다.

(함수를 호출한 쪽에서 io.EOF 에러를 검출해 읽기 작업을 중단할 수 있습니다.)

 

아래 예제는 strings.Reader의 Read 메소드 사용 예시입니다.

package main

import (
	"fmt"
	"io"
	"strings"
)

func main() {
	r := strings.NewReader("Hello, Reader!")

	b := make([]byte, 8)                // 8bytes buffer
	for {
		n, err := r.Read(b)
		fmt.Printf("n = %v err = %v b = %v\\n", n, err, b)
		fmt.Printf("b[:n] = %q\\n", b[:n]) // %q는 문자열을 출력할 때 따옴표로 감싸서 보여줌
		if err == io.EOF {
			break
		}
	}
}

 

 

 

 

 

아래 예제는 입력받은 영어 문자열을 rot13 substitution,

즉 rot13 변환을 통해 변조하는 과정을 Read 메소드에 구현하는 예시입니다.

 

즉, 데이터를 버퍼로 읽어들일 때마다 변환하는 예시입니다.

 

rot13 변환이란,

A 는 N *( 아스키 코드로 N = A + (A+13)%26 )*으로,

N은 A *( A = A + (N - A +13)%26 )*로 변환해

읽어들이는 변조 방식입니다.

package main

import (
	"io"
	"os"
	"strings"
)

type rot13Reader struct {
	r io.Reader
}

func rot13(b byte) byte{
	switch{
	case 'A' <= b && b <= 'Z':
		return 'A' + (b - 'A' + 13)%26
	case 'a' <= b && b <= 'z':
		return 'a' + (b - 'a' + 13)%26
	default:
		return b
	}
}

func (r13 rot13Reader) Read(b []byte) (int, error){
	
	n, err := r13.r.Read(b)
	if err != nil{
		return n, err
	}
	
	// 읽어온 데이터 r13 변환	
	for i:=0; i<n; i++ {
		b[i] = rot13(b[i])	
	}
	
	return n, nil
}

func main() {
	s := strings.NewReader("Lbh penpxrq gur pbqr!")
	r := rot13Reader{s}
	io.Copy(os.Stdout, &r)
}

 

 

 

Recap.

인터페이스는 객체지향 프로그래밍 패러다임의 개념 중 하나입니다.

인터페이스를 통해 프로그램의 유지보수가 쉬워지게 됩니다.

 

Go에는 class라는 개념이 없어 완전한 객체지향이 아니지만,

각 type에 '메소드'라는 개념을 도입해 인터페이스 개념을 사용할 수 있습니다.

 

이를 통해 기존 내장 라이브러리의 인터페이스의

간단한 사용자화가 가능해집니다.

 


 

Ref.

 

인터페이스 (컴퓨팅) - 위키백과, 우리 모두의 백과사전

위키백과, 우리 모두의 백과사전. 인터페이스(interface)는 서로 다른 두 개의 시스템, 장치 사이에서 정보나 신호를 주고받는 경우의 접점이나 경계면이다. 즉, 사용자가 기기를 쉽게 동작시키는

ko.wikipedia.org

 

 

OOP - Interfaces

Interfaces in Object Oriented Programming Languages An interface is a programming structure/syntax that allows the computer to enforce certain properties on an object (class). For example, say we have a car class and a scooter class and a truck class. Each

users.cs.utah.edu

 

 

A Tour of Go

 

go.dev

 

 

The Go Programming Language Specification - The Go Programming Language

 

tip.golang.org