
"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();
}
}
명시적인 것들:
implements UserRepository- 인터페이스 구현 선언@Override- 메서드 오버라이드 표시@Repository- Spring에게 Bean임을 알림@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 → 인터페이스 구현 인정!
컴파일러의 체크리스트:
- ✅
Create(ctx, *User) error메서드가 있는가? - ✅
FindByEmail(ctx, string) (*User, error)메서드가 있는가? - ✅ 시그니처가 정확히 일치하는가?
모두 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의 명시적 의존성 주입"
댓글