본문 바로가기
개발의 기록/Server

Java/Kotlin Spring 개발자의 Go 전환기 #4: implements가 없다고? - 암묵적 인터페이스와 리시버의 세계

by prographer J 2025. 12. 3.
728x90

"user.Repository와 user_repository.go는 어떻게 연결되는 거야? 자바처럼 명시적인 선언이 없잖아!"

 

📌 이 글의 핵심

  • Java의 명시적 implements vs Go의 암묵적 구조적 타이핑(Structural Typing)
  • Receiver 메서드(리시버) 개념: Value Receiver vs Pointer Receiver 완벽 이해
  • Dependency Inversion Principle을 Go에서 구현하는 실전 패턴
  • Duck Typing의 안전한 정적 타입 버전으로서의 Go 인터페이스

🎯 이런 분들께 추천합니다

  • Java implements 키워드에 익숙한 백엔드 개발자
  • Go의 암묵적 인터페이스 구현이 낯선 개발자
  • SOLID 원칙을 Go에서 적용하고 싶은 아키텍트

⏱️ 읽는 시간: 약 12분


Go 코드를 처음 봤을 때, 저는 혼란스러웠습니다.

// internal/domain/user/repository.go
package user

type Repository interface {
    Create(ctx context.Context, user *User) error
    FindByEmail(ctx context.Context, email string) (*User, error)
}

// internal/infrastructure/persistence/postgres/user_repository.go
package postgres

type UserRepository struct {
    db *pgxpool.Pool
}

func (r *UserRepository) Create(ctx context.Context, u *user.User) error {
    // 구현...
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*user.User, error) {
    // 구현...
}

 

"어? implements 키워드가 없는데 어떻게 인터페이스를 구현하는 거지?"

그리고 이상한 문법도 보입니다:

func (r *UserRepository) Create(...) error {
    //  ↑ 이게 뭐지?
}

 

"(r *UserRepository)가 뭔가요? 왜 함수 이름 앞에 있죠?"

이 글은 Java/Kotlin Spring 개발자가 가장 혼란스러워하는 Go의 암묵적 인터페이스와 리시버를 다룹니다.


Java/Spring의 명시적 세계

Java에서의 인터페이스 구현

Java는 모든 것이 명시적입니다.

// domain/UserRepository.java
package com.example.domain;

public interface UserRepository {
    void create(User user);
    Optional<User> findByEmail(String email);
}

// infrastructure/UserRepositoryImpl.java
package com.example.infrastructure;

@Repository
public class UserRepositoryImpl implements UserRepository {
    //                            ↑
    //                   명시적 implements 선언

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Override  // ← 명시적 오버라이드 표시
    public void create(User user) {
        jdbcTemplate.update(
            "INSERT INTO users (email, name) VALUES (?, ?)",
            user.getEmail(), user.getName()
        );
    }

    @Override
    public Optional<User> findByEmail(String email) {
        return jdbcTemplate.query(
            "SELECT * FROM users WHERE email = ?",
            new Object[]{email},
            new UserRowMapper()
        ).stream().findFirst();
    }
}

 

명시적인 것들:

  1. implements UserRepository - 인터페이스 구현 선언
  2. @Override - 메서드 오버라이드 표시
  3. @Repository - Spring에게 Bean임을 알림
  4. @Autowired - 의존성 주입 요청

컴파일러가 확인하는 것:

  • implements 선언이 있는가?
  • ✅ 인터페이스의 모든 메서드를 구현했는가?
  • ✅ 메서드 시그니처가 정확히 일치하는가?

하나라도 빠지면? 컴파일 에러.


Go의 암묵적 세계

Go에서의 인터페이스 구현

Go는 완전히 다른 접근을 합니다.

// internal/domain/user/repository.go
package user

type Repository interface {
    Create(ctx context.Context, user *User) error
    FindByEmail(ctx context.Context, email string) (*User, error)
}

// internal/infrastructure/persistence/postgres/user_repository.go
package postgres

type UserRepository struct {
    db *pgxpool.Pool
}

// 어디에도 "implements"가 없음!
func (r *UserRepository) Create(ctx context.Context, u *user.User) error {
    query := `INSERT INTO users (email, name) VALUES ($1, $2)`
    _, err := r.db.Exec(ctx, query, u.Email, u.Name)
    return err
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*user.User, error) {
    query := `SELECT email, name FROM users WHERE email = $1`
    var u user.User
    err := r.db.QueryRow(ctx, query, email).Scan(&u.Email, &u.Name)
    return &u, err
}

 

명시적 선언이 없습니다!

  • implements 키워드 없음
  • @Override 없음
  • ❌ 어떤 annotation도 없음

"그럼 어떻게 작동하는 건데?"


Go의 비밀: Structural Typing (구조적 타이핑)

Duck Typing의 컴파일 타임 버전

Go는 "오리처럼 걷고, 오리처럼 소리 내면, 그건 오리다" 철학을 따릅니다.

// 인터페이스 정의
type Repository interface {
    Create(ctx context.Context, user *User) error
    FindByEmail(ctx context.Context, email string) (*User, error)
}

// 구현체
type UserRepository struct {
    db *pgxpool.Pool
}

func (r *UserRepository) Create(ctx context.Context, u *User) error { ... }
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*User, error) { ... }

// 사용
var repo user.Repository = &postgres.UserRepository{db: pool}
//           ↑
// 컴파일러가 자동으로 확인:
// "UserRepository가 Repository의 모든 메서드를 가지고 있나?"
// → YES → 인터페이스 구현 인정!

컴파일러의 체크리스트:

  1. Create(ctx, *User) error 메서드가 있는가?
  2. FindByEmail(ctx, string) (*User, error) 메서드가 있는가?
  3. ✅ 시그니처가 정확히 일치하는가?

모두 YES라면 → 자동으로 인터페이스 구현으로 인정!

명시적 확인하기

"정말 구현했는지 확실히 하고 싶어!"라면:

// 컴파일 타임에 검증
var _ user.Repository = (*UserRepository)(nil)
//  ↑                    ↑
//  버리는 변수          타입 변환 시도
//
// UserRepository가 user.Repository를 구현하지 않았다면?
// → 컴파일 에러!

 

이 한 줄로 컴파일 타임에 인터페이스 구현을 검증할 수 있습니다.


리시버(Receiver): Go의 메서드 정의 방식

Java의 메서드 vs Go의 리시버

Java:

public class UserRepository {
    private JdbcTemplate db;

    public void create(User user) {
        //      ↑
        // 메서드는 클래스 안에 정의
        this.db.update("...");
        // ↑
        // this로 자신의 필드 접근
    }
}

 

Go:

type UserRepository struct {
    db *pgxpool.Pool
}

func (r *UserRepository) Create(ctx context.Context, u *user.User) error {
//    ↑               ↑
//    리시버          메서드 이름
//
// 메서드는 타입 "밖"에서 정의
    _, err := r.db.Exec(ctx, "...")
    //        ↑
    //        r로 자신의 필드 접근 (this 대신)
    return err
}

리시버란?

리시버는 "이 함수가 어떤 타입에 속하는지" 지정하는 방법입니다.

func (r *UserRepository) Create(...) error
//   ↑
//   리시버: 이 메서드는 *UserRepository 타입에 속함
//
//   r: 리시버 변수명 (this 같은 역할, 원하는 이름 사용 가능)
//   *UserRepository: 리시버 타입

Java와 비교:

Java Go
this.db r.db
클래스 안에서 정의 타입 밖에서 정의
this 자동 제공 리시버 변수명 직접 지정

값 리시버 vs 포인터 리시버

Go는 두 가지 리시버 방식이 있습니다.

 

포인터 리시버 (*UserRepository):

func (r *UserRepository) Create(ctx context.Context, u *user.User) error {
//    ↑
//    포인터
    r.db.Exec(ctx, "...")  // 원본 수정 가능
    return nil
}

 

값 리시버 (UserRepository):

func (r UserRepository) GetDBInfo() string {
//    ↑
//    값 (복사본)
    return r.db.Config().ConnString()  // 읽기만
}

 

언제 어떤 걸 쓸까?

상황 리시버 타입
상태 변경 포인터 *T
큰 구조체 포인터 *T (복사 비용)
읽기만  T
작은 구조체 (1-2 필드)  T
일관성 중요 하나로 통일 

 

실전 팁:

// ✅ 좋은 예: 모두 포인터 리시버로 통일
func (r *UserRepository) Create(...) error { }
func (r *UserRepository) FindByEmail(...) (*User, error) { }
func (r *UserRepository) Update(...) error { }

// ❌ 나쁜 예: 섞어 쓰기
func (r *UserRepository) Create(...) error { }
func (r UserRepository) FindByEmail(...) (*User, error) { }  // 섞임!

 

일반적으로 포인터 리시버를 기본으로 사용하세요.


실전 예제: Dependency Inversion Principle

Java/Spring 방식

// Domain Layer
public interface UserRepository {
    void create(User user);
    Optional<User> findByEmail(String email);
}

// Use Case Layer
@Service
public class LoginUseCase {
    @Autowired
    private UserRepository userRepository;  // 인터페이스에 의존

    public LoginResult execute(String email, String password) {
        Optional<User> user = userRepository.findByEmail(email);
        // ...
    }
}

// Infrastructure Layer
@Repository
public class PostgresUserRepository implements UserRepository {
    //                                  ↑
    //                          명시적 implements

    @Override
    public void create(User user) { }

    @Override
    public Optional<User> findByEmail(String email) { }
}

// Main
@Configuration
public class AppConfig {
    @Bean
    public UserRepository userRepository(DataSource ds) {
        return new PostgresUserRepository(ds);
        // Spring이 자동으로 주입
    }
}

Go 방식

// Domain Layer
package user

type Repository interface {
    Create(ctx context.Context, user *User) error
    FindByEmail(ctx context.Context, email string) (*User, error)
}

// Use Case Layer
package auth

type LoginUseCase struct {
    userRepo user.Repository  // 인터페이스에 의존 (Java와 동일)
}

func NewLoginUseCase(userRepo user.Repository) *LoginUseCase {
    return &LoginUseCase{userRepo: userRepo}
}

func (uc *LoginUseCase) Execute(ctx context.Context, email, password string) (*LoginResult, error) {
    user, err := uc.userRepo.FindByEmail(ctx, email)
    // ...
}

// Infrastructure Layer
package postgres

type UserRepository struct {
    db *pgxpool.Pool
}

// 어디에도 "implements user.Repository" 없음!
func (r *UserRepository) Create(ctx context.Context, u *user.User) error {
    // 구현...
}

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*user.User, error) {
    // 구현...
}

// Main
func main() {
    db := connectDB()

    // 명시적 의존성 주입
    userRepo := postgres.NewUserRepository(db)
    loginUC := auth.NewLoginUseCase(userRepo)
    //                              ↑
    // 컴파일러가 자동 확인:
    // userRepo가 user.Repository 인터페이스를 구현하는가?
    // → YES (모든 메서드 시그니처 일치)
    // → 컴파일 성공!
}

 


왜 Go는 암묵적 인터페이스를 선택했을까?

이유 1: 의존성 역전 (Dependency Inversion)

Java의 문제:

// infrastructure/PostgresUserRepository.java
package com.example.infrastructure;

import com.example.domain.UserRepository;  // ← Domain을 import!

@Repository
public class PostgresUserRepository implements UserRepository {
    // Infrastructure가 Domain에 의존 ✅
    // 하지만 Domain 패키지도 알아야 함 ⚠️
}

 

Go의 해결:

// infrastructure/postgres/user_repository.go
package postgres

// Domain의 user.Repository를 import할 필요 없음!
// 메서드만 일치하면 됨

type UserRepository struct {
    db *pgxpool.Pool
}

func (r *UserRepository) Create(ctx context.Context, u *user.User) error {
    // user.User는 import하지만
    // user.Repository 인터페이스는 몰라도 됨!
}

// 나중에 Domain에서 인터페이스를 정의해도
// Infrastructure 코드 수정 불필요!

Domain이 나중에 인터페이스를 추가해도, Infrastructure는 모릅니다.

이유 2: 테스트 용이성

Java에서 Mock 만들기:

// 테스트용 Mock 클래스 필요
public class MockUserRepository implements UserRepository {
    //                          ↑
    //                  인터페이스 알아야 함

    @Override
    public void create(User user) {
        // Mock 구현
    }

    @Override
    public Optional<User> findByEmail(String email) {
        return Optional.of(new User("test@test.com"));
    }
}

// 또는 Mockito
@Mock
UserRepository mockRepo;

 

Go에서 Mock 만들기:

// 테스트 파일
package auth

type MockUserRepository struct {
    CreateFunc      func(ctx context.Context, u *user.User) error
    FindByEmailFunc func(ctx context.Context, email string) (*user.User, error)
}

// user.Repository 인터페이스를 몰라도 됨!
// 메서드만 맞추면 자동으로 구현 인정
func (m *MockUserRepository) Create(ctx context.Context, u *user.User) error {
    return m.CreateFunc(ctx, u)
}

func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*user.User, error) {
    return m.FindByEmailFunc(ctx, email)
}

// 테스트에서 사용
func TestLogin(t *testing.T) {
    mockRepo := &MockUserRepository{
        FindByEmailFunc: func(ctx context.Context, email string) (*user.User, error) {
            return &user.User{Email: "test@test.com"}, nil
        },
    }

    uc := NewLoginUseCase(mockRepo)  // 자동으로 인터페이스 구현 인정!
}

이유 3: 진화 가능성

Java: 인터페이스 변경 = 모든 구현체 수정

// Domain 레이어에서 메서드 추가
public interface UserRepository {
    void create(User user);
    Optional<User> findByEmail(String email);
    void delete(String id);  // ← 새로 추가
}

// 컴파일 에러!
// PostgresUserRepository는 delete() 미구현
// MockUserRepository도 delete() 미구현
// 모든 구현체를 동시에 수정해야 함 😱

 

Go: 필요한 메서드만 정의

// 작은 인터페이스들
type UserCreator interface {
    Create(ctx context.Context, user *User) error
}

type UserFinder interface {
    FindByEmail(ctx context.Context, email string) (*User, error)
}

// 조합 가능
type UserRepository interface {
    UserCreator
    UserFinder
}

// LoginUseCase는 Finder만 필요
type LoginUseCase struct {
    userFinder UserFinder  // Repository 전체가 아닌 필요한 것만!
}

// PostgresUserRepository는 Create, FindByEmail만 구현하면
// 자동으로 UserCreator, UserFinder, UserRepository 모두 구현!

리시버의 실전 패턴

패턴 1: Constructor 함수

Go에는 생성자가 없습니다. 대신 New 함수를 사용합니다.

type UserRepository struct {
    db *pgxpool.Pool
}

// Constructor 함수 (관례: New + 타입명)
func NewUserRepository(db *pgxpool.Pool) *UserRepository {
    return &UserRepository{
        db: db,
    }
}

// 사용
repo := postgres.NewUserRepository(dbPool)

 

Java와 비교:

// Java
public class UserRepository {
    private JdbcTemplate db;

    // 생성자
    public UserRepository(JdbcTemplate db) {
        this.db = db;
    }
}

UserRepository repo = new UserRepository(jdbcTemplate);

패턴 2: 여러 파일에 메서드 분산

Go는 같은 패키지 내에서 여러 파일에 메서드를 나눌 수 있습니다.

// user_repository.go
package postgres

type UserRepository struct {
    db *pgxpool.Pool
}

func NewUserRepository(db *pgxpool.Pool) *UserRepository {
    return &UserRepository{db: db}
}

// user_repository_create.go (같은 패키지)
package postgres

func (r *UserRepository) Create(ctx context.Context, u *user.User) error {
    // Create 구현
}

// user_repository_find.go (같은 패키지)
package postgres

func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*user.User, error) {
    // FindByEmail 구현
}

 

Java에서는 불가능합니다! (모든 메서드가 한 클래스 파일 안에)

패턴 3: 타입 별칭과 리시버

// 기본 타입에 메서드 추가
type UserID string

func (id UserID) IsValid() bool {
    return len(id) == 36  // UUID 검증
}

// 사용
userID := UserID("user-123")
if userID.IsValid() {
    // ...
}

 

Java는 String을 확장할 수 없지만, Go는 가능!


 

함정과 주의사항

함정 1: 인터페이스는 작게

❌ 나쁜 예: 거대한 인터페이스

type UserRepository interface {
    Create(...)
    FindByID(...)
    FindByEmail(...)
    FindAll(...)
    Update(...)
    Delete(...)
    Count(...)
    ExistsByEmail(...)
    // ... 20개 메서드
}

 

✅ 좋은 예: 작은 인터페이스

type UserCreator interface {
    Create(ctx context.Context, user *User) error
}

type UserFinder interface {
    FindByEmail(ctx context.Context, email string) (*User, error)
}

// 필요시 조합
type UserRepository interface {
    UserCreator
    UserFinder
}

Interface Segregation Principle (인터페이스 분리 원칙)

함정 2: 포인터 리시버 일관성

❌ 나쁜 예: 섞어 쓰기

func (r *UserRepository) Create(...) error { }  // 포인터
func (r UserRepository) FindByEmail(...) (*User, error) { }  // 값!

// 문제:
repo := &UserRepository{db: pool}
repo.Create(...)     // ✅ 작동
repo.FindByEmail(...)  // ✅ 작동하지만 복사 발생!

var finder UserFinder = repo  // ❌ 컴파일 에러 가능!

 

✅ 좋은 예: 포인터로 통일

func (r *UserRepository) Create(...) error { }
func (r *UserRepository) FindByEmail(...) (*User, error) { }

함정 3: nil 리시버

type UserRepository struct {
    db *pgxpool.Pool
}

func (r *UserRepository) Create(ctx context.Context, u *user.User) error {
    // r이 nil이면?
    _, err := r.db.Exec(...)  // 💥 panic!
    return err
}

// 안전하게
func (r *UserRepository) Create(ctx context.Context, u *user.User) error {
    if r == nil || r.db == nil {
        return errors.New("repository not initialized")
    }
    _, err := r.db.Exec(...)
    return err
}

정리: Java vs Go

구분 Java/Spring Go
인터페이스 구현 implements 명시 암묵적 (메서드 일치)
검증 시점 컴파일 타임 컴파일 타임
의존성 방향 양방향 (import 필요) 단방향 (Domain만)
메서드 정의 클래스 안 타입 밖 (리시버)
인터페이스 크기 큰 편 작은 편
Mock 생성 프레임워크 의존 수동 구현 용이

실전 체크리스트

인터페이스 설계:

  • 인터페이스는 작게 (1-3 메서드)
  • Domain 레이어에 인터페이스 정의
  • Infrastructure는 인터페이스 몰라도 됨

리시버 사용:

  • 포인터 리시버로 통일 (*T)
  • 리시버 변수명 일관성 (보통 타입의 첫 글자)
  • nil 체크 필요 시 추가

컴파일 타임 검증:

  • var _ Interface = (*Impl)(nil) 추가

마치며: 명시성 vs 유연성

Java는 명시적입니다:

  • "내가 이 인터페이스를 구현한다고 선언한다!"
  • 명확하지만, 경직됨

Go는 유연합니다:

  • "필요한 메서드가 있으면 자동으로 인정!"
  • 느슨하지만, 진화 가능

처음에는 "명시적 선언이 없어서 불안하다"고 느낄 수 있습니다.

하지만 3개월 후에는 이렇게 생각하게 됩니다:

"인터페이스는 사용자가 정의한다. 구현체가 아니라."

이것이 바로 Go의 철학입니다.

 

다음 편 예고:

"@Autowired가 없다고? - Spring DI Container vs Go의 명시적 의존성 주입"

반응형

댓글