Go의 비밀스러운 삶: 인터페이스

발행: (2026년 1월 12일 오전 11:53 GMT+9)
13 min read
원문: Dev.to

Source: Dev.to

번역할 텍스트를 제공해 주시겠어요? 현재는 소스 링크만 포함되어 있어 번역할 내용이 없습니다. 텍스트를 알려주시면 한국어로 번역해 드리겠습니다.

14장: 행동의 형태

그 목요일, 보관소는 유난히도 추웠다. 라디에이터는 쉿쉿거리고 삐걱거리며, 백 년 된 벽돌 사이로 스며드는 찬바람과의 싸움에서 점점 지고 있었다.

이선은 무거운 코트를 입고 손가락이 없는 장갑을 낀 채 테이블에 앉아 타이핑하고 있었다. 그는 초췌해 보였다.

“복제 때문이야,” 라고 그는 화면을 바라보며 중얼거렸다. “같은 코드를 두 번 쓰는 느낌이야.”

엘리노어는 작은 접시를 책상 위에 올렸다.

“밀푀유,” 라고 그녀가 말했다. “크림으로 구분된 수천 겹의 페이스트리. 구분되지만 하나로 통합돼 있지.”

이선은 그 페이스트리를 바라보았다.

“내 코드도 그렇게 정리돼 있으면 좋겠어. 이거 봐.”

그는 노트북을 돌렸다.

type Admin struct {
    Name  string
    Level int
}

type Guest struct {
    Name string
}

func SaveAdmin(a Admin) error {
    // Logic to save admin to database...
    return nil
}

func SaveGuest(g Guest) error {
    // Logic to save guest to text file...
    return nil
}

func ProcessAdmin(a Admin) {
    if err := SaveAdmin(a); err != nil {
        log.Println(err)
    }
}

func ProcessGuest(g Guest) {
    if err := SaveGuest(g); err != nil {
        log.Println(err)
    }
}

“두 종류의 사용자가 있어,” 라고 이선이 설명했다. “관리자는 데이터베이스에, 손님은 로그 파일에 저장돼. 그런데 이제 내 매니저가 SuperAdmin을 원하고, 나는 ProcessSuperAdminSaveSuperAdmin을 만들어야 해. 뭔가 잘못된 느낌이 들어.”

“그게 잘못된 이유는 네가 정체성에 집착하고 있기 때문이야,” 라고 엘리노어가 차를 따르며 말했다. “‘이것은 무엇인가?’ 라고 묻고 있지. 관리자인가? 손님인가? 하지만 Process 함수는 그 것이 무엇인지 신경 쓰지 않아. 그 것이 무엇을 하는가에만 관심이 있지.”

그녀는 Save 호출을 가리켰다.

“실제로 필요한 행동은 뭐야?”

“저장하는 거야.”

“정확히. Go에서는 행동을 인터페이스로 표현해.”

엘리노어는 새 파일을 열었다.

“다른 언어에서는 복잡한 계층 구조를 만들 수도 있어. AbstractUserBaseEntity를 상속하듯이. Go에서는 단순히 메서드 집합을 정의하면 돼.”

type Saver interface {
    Save() error
}

“이게 전부야,” 라고 그녀가 말했다. “Save() error 메서드를 가지고 있는 모든 타입은 자동으로 Saver가 돼. implements Saver라고 명시할 필요도, 계약서에 서명할 필요도 없어. 그냥 일을 하면 돼.”

그녀는 이선의 코드를 리팩터링했다:

// 1. 구조체에 행동(메서드) 정의
func (a Admin) Save() error {
    fmt.Println("Saving admin to DB...")
    return nil
}

func (g Guest) Save() error {
    fmt.Println("Writing guest to file...")
    return nil
}

// 2. 인터페이스를 받는 하나의 함수 작성
func ProcessUser(s Saver) {
    // 이 함수는 's'가 Admin인지 Guest인지 모른다.
    // 신경 쓸 필요가 없다. Save()를 호출할 수 있다는 것만 알면 된다.
    if err := s.Save(); err != nil {
        log.Println(err)
    }
}

이선은 눈을 깜빡였다.

“잠깐. Admin이 어디에도 Saver를 언급하지 않았어?”

“아니. 이걸 덕 타이핑이라고 해. 오리처럼 걷고, 오리처럼 꽥꽥거리면 Go는 그걸 오리로 본다. AdminSave 메서드가 있으면 Saver가 되는 거지. 이렇게 하면 코드가 느슨해져. ProcessUser 함수는 이제 Admin이나 Guest에 의존하지 않아. 오직 행동에만 의존하게 돼.”

“그럼 모든 것에 인터페이스를 만들어야 해?” 라고 이선이 밀푀유를 집으며 물었다. “AdminInterface 같은 걸 만들어야 할까?”

“절대 그렇지 않아,” 라고 엘리노어가 단호히 말했다. “그건 자바식 사고 방식이야. Go에는 황금 규칙이 있어: 인터페이스를 받아라, 구조체를 반환하라.”

그녀는 메모지에 적었다.

  • 인터페이스를 받아라: 함수는 필요한 추상 행동(Saver, Reader, Validator)을 요청해야 한다. 이렇게 하면 유연해진다.
  • 구조체를 반환하라: 무언가를 만드는 함수는 구체 타입(*Admin, *File, *Server)을 반환해야 한다.

“왜 그래?” 라고 이선이 물었다.

포스텔의 법칙 때문이야,” 라고 엘리노어가 답했다. “‘당신이 하는 일은 보수적으로, 다른 사람에게서 받는 것은 관대하게.’ 인터페이스를 반환하면 기능을 빼앗아 버리고 데이터를 숨기게 돼. 구조체를 반환하면 …” (이하 계속)

구체적인 구조체를 반환하면, 호출자는 모든 것을 얻게 됩니다. 하지만 인자를 받을 때는 최소한의 동작만 요구해야 합니다.”

그녀는 예시를 입력했다:

// Good: Return the concrete struct (pointer)
func NewAdmin(name string) *Admin {
    return &Admin{Name: name}
}

// Good: Accept the interface
func LogUser(s Saver) {
    s.Save()
}

func main() {
    admin := NewAdmin("Eleanor") // We get a concrete *Admin
    LogUser(admin)               // We pass it to a function expecting a Saver
}

“보였어?” 엘리노어가 줄을 따라가며 말했다. “우리는 구체적인 것을 만들지만, 그것을 추상적인 동작으로 전달해요.”

“뭐든 받아들이고 싶다면 어떻게 해?” 이선이 물었다. “print(anything)처럼?”

“그럼 빈 인터페이스interface{}를 사용하면 됩니다. 최신 Go에서는 any라고 부르죠.”

func PrintAnything(v any) {
    fmt.Println(v)
}

any는 메서드가 전혀 없기 때문에, Go의 모든 타입—정수, 구조체, 포인터—가 모두 이를 만족합니다. 최소한 메서드가 0개이니까요.”

“그거 강력하게 들리네,” 이선이 말했다.

“위험해요,” 엘리노어가 바로잡았다. “any를 사용하면 모든 타입 안전성을 포기하는 겁니다. 컴파일러에게 ‘이게 뭔지 신경 안 써’라고 말하는 것이죠. 남용하지 말고, 정말 데이터에 신경 쓰지 않을 때만 사용하세요. 예를 들어 fmt.Printf나 JSON 직렬화 같은 경우죠.”

이선은 페이스트리를 다 먹었다.

“그럼 큰 인터페이스가 더 좋은 거야? Save, Delete, Update, Validate 같은 메서드를 가진 UserBehavior 같은?”

“아니요. 인터페이스가 클수록 추상화가 약해집니다,” 엘리노어가 말했다. “우리는 단일 메서드 인터페이스를 선호해요: Reader, Writer, Stringer, Saver. 제가 Saver를 요구하면 Admin, Guest, 혹은 CloudBackup도 전달할 수 있죠. 반면에 방대한 UserBehavior를 요구하면 모든 20개의 메서드를 구현한 것만 전달할 수 있어요. 작게 유지하세요.”

그녀는 노트북을 닫았다.

“세상을 정의하려 하지 마, 이선. 지금 당장 필요한 동작만 정의하면 돼.”

이선은 리팩터링된 코드를 바라보았다. 중복된 함수들은 사라지고, 하나의 깔끔한 ProcessUser(s Saver)가 대신했다.

“이건 분리와 관련된 거야,” 그가 깨달았다. “함수는 데이터의 정체성을 알 필요가 없어.”

“맞아요,” 엘리노어가 따뜻한 차를 손에 감싸며 미소 지었다. “예의 바른 소프트웨어 설계죠. ‘당신은 누구인가요?’ 라고 묻지 않아요. 단지 ‘저장할 수 있나요?’ 라고 물을 뿐이에요.”

Implicit Implementation

A struct that implements the required methods fits the interface.
Metaphor: Duck Typing – “If it quacks, it’s a duck.”

인터페이스 정의

인터페이스는 타입을 소비할 때 사용하고, 타입을 정의할 때는 사용하지 마세요.

type Saver interface {
    Save() error
}

황금 규칙

“인터페이스를 받아들이고, 구조체를 반환하라.”

  • Accept: 함수 입력은 인터페이스여야 합니다.

    func Do(r io.Reader) { /* … */ }

    이렇게 하면 호출자는 유연성을 가질 수 있습니다.

  • Return: 함수 출력(특히 생성자)은 구체적인 타입이어야 합니다.

    func New() *MyStruct { /* … */ }

    이렇게 하면 호출자는 반환된 값에 완전하게 접근할 수 있습니다.

작은 인터페이스

  • 예시: io.Reader, fmt.Stringer
  • 작은 인터페이스는 구현하고 조합하기가 더 쉽습니다.

빈 인터페이스 (any)

interface{}   // or the alias `any`
  • 모든 타입을 만족합니다.
  • 컴파일‑타임 타입 검사를 우회하므로 필요할 때만 사용하세요 (예: 일반적인 출력, 컨테이너).

Decoupling

아직 발명하지도 않은 미래 타입과 함께 작동하는 함수를 설계하세요.

func ProcessUser(u User) { /* … */ }

다음 장: 동시성

_Ethan_은 두 가지 일을 동시에 하는 것이 쉽다고 생각한다—_Eleanor_가 레이스 컨디션의 혼돈과 채널의 선을 소개하기 전까지.

About the Author

Aaron Rosetech‑reader.blog의 소프트웨어 엔지니어이자 기술 작가이며 Think Like a Genius의 저자입니다.

Back to Blog

관련 글

더 보기 »

Go에서 우아한 도메인 주도 설계 객체

❓ Go에서 도메인 객체를 어떻게 정의하시나요? Go는 전형적인 객체‑지향 언어가 아닙니다. Domain‑Driven Design(DDD) 같은 개념을 구현하려고 할 때, 예를 들어 En…

Go의 비밀스러운 삶: 테스트

13장: 진리의 테이블 수요일 비가 아카이브 창에 일정한 리듬으로 두드리며 맨해튼 스카이라인을 회색과 슬랫 같은 흐림으로 만들었다.