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

Java/Kotlin Spring 개발자의 Go 전환기 #7: Spring과 Go의 Clean Architecture 패키지 구조 비교

by prographer J 2025. 12. 14.
728x90

"Spring도 Clean Architecture 쓰는데, Go는 패키지 구조가 왜 이렇게 다르죠?"

 

 

📌 이 글의 핵심

  • 같은 Clean Architecture, 다른 패키지 표현 방식
  • Spring의 domain/usecase/interfaces/infrastructure vs Go의 domain/usecase/interface/infrastructure
  • 의존성 방향 규칙: 바깥쪽 → 안쪽(Domain)으로 향하는 설계 원칙
  • Domain 레이어가 인터페이스를 정의하는 Dependency Inversion 실전 적용
  • DI 방식 차이: Spring Container vs Go 명시적 조립

🎯 이런 분들께 추천합니다

  • Spring에서 Clean Architecture를 사용해본 개발자
  • 동일한 아키텍처를 Go에서 구현할 때 패키지 구조 차이가 궁금한 분
  • 도메인 중심 설계(Domain-Driven Design)를 Go로 적용하려는 팀

⏱️ 읽는 시간: 약 12분


Clean Architecture란?

Robert C. Martin (Uncle Bob)이 제안한 아키텍처 패턴:

┌─────────────────────────────────────┐
│        External Interfaces          │  ← Framework, UI, DB
├─────────────────────────────────────┤
│     Interface Adapters (Gateway)    │  ← Controllers, Presenters
├─────────────────────────────────────┤
│      Application Business Rules     │  ← Use Cases
├─────────────────────────────────────┤
│    Enterprise Business Rules        │  ← Entities (Domain)
└─────────────────────────────────────┘

핵심 원칙:

  • 의존성 규칙: 바깥쪽 → 안쪽으로만 의존 (안쪽은 바깥을 모름)
  • 도메인 중심: 비즈니스 로직이 프레임워크에 독립적
  • 인터페이스로 분리: 구현체는 외부, 인터페이스는 내부

 

Spring Clean Architecture 패키지 구조

패키지 구조 예시

src/main/java/com/example/myapp/
├── domain/                  # Domain Layer (가장 안쪽)
│   ├── user/
│   │   ├── User.java                    # Entity
│   │   ├── UserRepository.java          # Repository 인터페이스 (도메인이 정의!)
│   │   └── UserService.java             # Domain Service
│   └── auth/
│       └── AuthenticationProvider.java  # 인터페이스
│
├── usecase/                 # Use Case Layer (Application Business Rules)
│   ├── auth/
│   │   ├── LoginUseCase.java            # UseCase 인터페이스
│   │   └── LoginUseCaseImpl.java        # UseCase 구현
│   └── user/
│       ├── GetUserUseCase.java
│       └── GetUserUseCaseImpl.java
│
├── interfaces/              # Interface Adapters Layer
│   └── web/
│       ├── controller/
│       │   └── AuthController.java      # HTTP Controller
│       └── dto/
│           ├── LoginRequest.java
│           └── LoginResponse.java
│
└── infrastructure/          # Frameworks & Drivers Layer
    ├── persistence/
    │   ├── UserJpaRepository.java       # Spring Data JPA Repository
    │   ├── UserRepositoryImpl.java      # domain.UserRepository 구현
    │   └── entity/
    │       └── UserEntity.java          # JPA Entity (Domain과 분리!)
    └── config/
        ├── SecurityConfig.java
        └── DatabaseConfig.java

의존성 방향

Controller (Interface Adapters)
    ↓ depends on
LoginUseCase (인터페이스)
    ↑ implements
LoginUseCaseImpl (Use Case)
    ↓ depends on
UserRepository (Domain - 인터페이스)
    ↑ implements
UserRepositoryImpl (Infrastructure)

 

핵심:

  • Domain이 인터페이스 정의 (UserRepository, AuthenticationProvider)
  • UseCase가 Domain 인터페이스 사용
  • Infrastructure가 Domain 인터페이스 구현

Spring Clean Architecture 예제 코드

// 1. Domain: 인터페이스 정의 (가장 안쪽)
// domain/user/UserRepository.java
package com.example.myapp.domain.user;

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

// 2. UseCase: 비즈니스 로직
// usecase/auth/LoginUseCase.java (인터페이스)
package com.example.myapp.usecase.auth;

public interface LoginUseCase {
    LoginResult execute(String email, String password);
}

// usecase/auth/LoginUseCaseImpl.java (구현)
package com.example.myapp.usecase.auth;

import com.example.myapp.domain.user.User;
import com.example.myapp.domain.user.UserRepository;  // Domain 인터페이스

@Service
public class LoginUseCaseImpl implements LoginUseCase {
    private final UserRepository userRepository;  // 인터페이스에 의존!
    private final AuthenticationProvider authProvider;
    private final JwtService jwtService;

    public LoginUseCaseImpl(
        UserRepository userRepository,
        AuthenticationProvider authProvider,
        JwtService jwtService
    ) {
        this.userRepository = userRepository;
        this.authProvider = authProvider;
        this.jwtService = jwtService;
    }

    @Override
    public LoginResult execute(String email, String password) {
        // 1. 인증
        UserInfo userInfo = authProvider.authenticate(email, password);

        // 2. 사용자 조회 또는 생성
        User user = userRepository.findByEmail(email)
            .orElseGet(() -> createUser(userInfo));

        // 3. 토큰 생성
        String token = jwtService.generateToken(user.getId(), user.getEmail());

        return new LoginResult(token, user);
    }
}

// 3. Infrastructure: Repository 구현
// infrastructure/persistence/UserRepositoryImpl.java
package com.example.myapp.infrastructure.persistence;

import com.example.myapp.domain.user.User;
import com.example.myapp.domain.user.UserRepository;  // Domain 인터페이스 구현

@Repository
public class UserRepositoryImpl implements UserRepository {
    private final UserJpaRepository jpaRepository;

    public UserRepositoryImpl(UserJpaRepository jpaRepository) {
        this.jpaRepository = jpaRepository;
    }

    @Override
    public Optional<User> findByEmail(String email) {
        return jpaRepository.findByEmail(email)
            .map(this::toDomain);  // JPA Entity → Domain Entity 변환
    }

    private User toDomain(UserEntity entity) {
        return new User(entity.getId(), entity.getEmail(), entity.getName());
    }
}

// infrastructure/persistence/UserJpaRepository.java
package com.example.myapp.infrastructure.persistence;

interface UserJpaRepository extends JpaRepository<UserEntity, String> {
    Optional<UserEntity> findByEmail(String email);
}

// 4. Interface Adapters: Controller
// interfaces/web/controller/AuthController.java
package com.example.myapp.interfaces.web.controller;

import com.example.myapp.usecase.auth.LoginUseCase;  // UseCase에 의존

@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
    private final LoginUseCase loginUseCase;  // 인터페이스에 의존!

    public AuthController(LoginUseCase loginUseCase) {
        this.loginUseCase = loginUseCase;
    }

    @PostMapping("/login")
    public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
        LoginResult result = loginUseCase.execute(
            request.getEmail(),
            request.getPassword()
        );
        return ResponseEntity.ok(new LoginResponse(result));
    }
}

 

Go Clean Architecture 패키지 구조

패키지 구조 예시

backend/
├── cmd/
│   └── api/
│       └── main.go              # 애플리케이션 진입점 (DI)
│
├── internal/                    # 외부에서 import 불가
│   ├── domain/                  # Domain Layer (가장 안쪽)
│   │   ├── user/
│   │   │   ├── user.go         # User 엔티티
│   │   │   └── repository.go   # Repository 인터페이스 정의
│   │   └── auth/
│   │       └── provider.go     # AuthProvider 인터페이스
│   │
│   ├── usecase/                 # Application Layer (Use Cases)
│   │   ├── auth/
│   │   │   ├── login.go        # LoginUseCase
│   │   │   ├── login_test.go
│   │   │   └── interface.go    # LoginExecutor 인터페이스
│   │   └── user/
│   │       └── get_user.go
│   │
│   ├── interface/               # Interface Adapters (Inbound)
│   │   └── http/
│   │       ├── router/
│   │       │   ├── router.go   # 라우터 설정
│   │       │   └── auth_routes.go
│   │       ├── handler/
│   │       │   ├── auth_handler.go      # HTTP → UseCase
│   │       │   └── auth_handler_test.go
│   │       ├── middleware/
│   │       │   └── auth_middleware.go
│   │       └── dto/
│   │           ├── request.go
│   │           └── response.go
│   │
│   └── infrastructure/          # Infrastructure Layer (Outbound)
│       ├── postgres/
│       │   ├── database.go
│       │   ├── user_repository.go       # Repository 구현
│       │   ├── user_repository_test.go
│       │   └── models/
│       │       └── user.go              # GORM Model (Domain과 분리!)
│       └── auth/
│           └── mock_provider.go         # AuthProvider 구현
│
└── pkg/                         # 공용 유틸리티 (외부 import 가능)
    ├── errors/
    ├── logger/
    └── jwt/

Go Clean Architecture 예제 코드

// 1. Domain: 인터페이스 정의 (가장 안쪽)
// internal/domain/user/user.go
package user

import "time"

type User struct {
    ID        string
    Email     string
    Name      string
    Provider  string
    CreatedAt time.Time
    UpdatedAt time.Time
}

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

import "context"

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

// internal/domain/auth/provider.go
package auth

import "context"

type UserInfo struct {
    Email string
    Name  string
}

// AuthProvider 인터페이스 (도메인이 정의)
type Provider interface {
    Authenticate(ctx context.Context, email, password string) (*UserInfo, error)
}

// 2. UseCase: 비즈니스 로직
// internal/usecase/auth/login.go
package auth

import (
    "context"
    "myapp/internal/domain/user"
    domainAuth "myapp/internal/domain/auth"
    "myapp/pkg/jwt"
)

type LoginUseCase struct {
    userRepo     user.Repository      // 도메인 인터페이스
    authProvider domainAuth.Provider  // 도메인 인터페이스
    jwtService   jwt.TokenService
}

func NewLoginUseCase(
    userRepo user.Repository,
    authProvider domainAuth.Provider,
    jwtService jwt.TokenService,
) *LoginUseCase {
    return &LoginUseCase{
        userRepo:     userRepo,
        authProvider: authProvider,
        jwtService:   jwtService,
    }
}

type LoginResult struct {
    Token string
    User  *user.User
}

func (uc *LoginUseCase) Execute(ctx context.Context, email, password string) (*LoginResult, error) {
    // 1. 인증
    userInfo, err := uc.authProvider.Authenticate(ctx, email, password)
    if err != nil {
        return nil, err
    }

    // 2. 사용자 조회 또는 생성
    existingUser, err := uc.userRepo.FindByEmail(ctx, email)
    if err != nil {
        // 사용자 생성
        newUser := &user.User{
            Email:    userInfo.Email,
            Name:     userInfo.Name,
            Provider: "local",
        }
        if err := uc.userRepo.Create(ctx, newUser); err != nil {
            return nil, err
        }
        existingUser = newUser
    }

    // 3. 토큰 생성
    token, err := uc.jwtService.GenerateToken(existingUser.ID, existingUser.Email, existingUser.Provider)
    if err != nil {
        return nil, err
    }

    return &LoginResult{
        Token: token,
        User:  existingUser,
    }, nil
}

// internal/usecase/auth/interface.go
package auth

import "context"

// Handler가 의존할 인터페이스
type LoginExecutor interface {
    Execute(ctx context.Context, email, password string) (*LoginResult, error)
}

// 3. Interface Layer (HTTP)
// internal/interface/http/dto/auth.go
package dto

type LoginRequest struct {
    Email    string `json:"email"`
    Password string `json:"password"`
}

type LoginResponse struct {
    Token string       `json:"token"`
    User  UserResponse `json:"user"`
}

// internal/interface/http/handler/auth_handler.go
package handler

import (
    "encoding/json"
    "net/http"
    "myapp/internal/interface/http/dto"
    "myapp/internal/usecase/auth"
)

type AuthHandler struct {
    loginUC auth.LoginExecutor  // UseCase 인터페이스에 의존
}

func NewAuthHandler(loginUC auth.LoginExecutor) *AuthHandler {
    return &AuthHandler{loginUC: loginUC}
}

func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
    var req dto.LoginRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        dto.RespondError(w, err)
        return
    }

    result, err := h.loginUC.Execute(r.Context(), req.Email, req.Password)
    if err != nil {
        dto.RespondError(w, err)
        return
    }

    response := dto.LoginResponse{
        Token: result.Token,
        User: dto.UserResponse{
            ID:    result.User.ID,
            Email: result.User.Email,
            Name:  result.User.Name,
        },
    }

    dto.RespondJSON(w, http.StatusOK, response)
}

// 4. Infrastructure (구현체)
// internal/infrastructure/postgres/user_repository.go
package postgres

import (
    "context"
    "gorm.io/gorm"
    "myapp/internal/domain/user"
    "myapp/internal/infrastructure/postgres/models"
)

type UserRepository struct {
    db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *UserRepository {
    return &UserRepository{db: db}
}

// user.Repository 인터페이스 구현
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*user.User, error) {
    var model models.User

    err := r.db.WithContext(ctx).
        Where("email = ?", email).
        First(&model).Error

    if err != nil {
        if err == gorm.ErrRecordNotFound {
            return nil, errors.ErrUserNotFound
        }
        return nil, err
    }

    return model.ToDomain(), nil
}

// internal/infrastructure/postgres/models/user.go
package models

import (
    "time"
    domainUser "myapp/internal/domain/user"
)

type User struct {
    ID        string    `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
    Email     string    `gorm:"uniqueIndex;not null;size:255"`
    Name      string    `gorm:"size:255"`
    Provider  string    `gorm:"size:50;not null"`
    CreatedAt time.Time `gorm:"autoCreateTime"`
    UpdatedAt time.Time `gorm:"autoUpdateTime"`
}

func (User) TableName() string {
    return "users"
}

// GORM Model → Domain Entity 변환
func (m *User) ToDomain() *domainUser.User {
    return &domainUser.User{
        ID:        m.ID,
        Email:     m.Email,
        Name:      m.Name,
        Provider:  m.Provider,
        CreatedAt: m.CreatedAt,
        UpdatedAt: m.UpdatedAt,
    }
}

// Domain Entity → GORM Model 변환
func FromDomainUser(u *domainUser.User) *User {
    return &User{
        ID:        u.ID,
        Email:     u.Email,
        Name:      u.Name,
        Provider:  u.Provider,
        CreatedAt: u.CreatedAt,
        UpdatedAt: u.UpdatedAt,
    }
}

// 5. Main (의존성 조립)
// cmd/api/main.go
package main

import (
    "myapp/internal/infrastructure/postgres"
    infraAuth "myapp/internal/infrastructure/auth"
    "myapp/internal/usecase/auth"
    "myapp/internal/interface/http/handler"
    "myapp/internal/interface/http/router"
    "myapp/pkg/jwt"
)

func main() {
    // Infrastructure 생성
    db := postgres.NewDatabase(dbURL, maxConns)
    userRepo := postgres.NewUserRepository(db.DB)
    authProvider := infraAuth.NewMockAuthProvider()
    jwtService := jwt.NewService(secret, expiration)

    // UseCase 생성
    loginUC := auth.NewLoginUseCase(userRepo, authProvider, jwtService)

    // Handler 생성
    authHandler := handler.NewAuthHandler(loginUC)

    // Router 설정
    r := router.NewRouter(authHandler)

    // 서버 시작
    http.ListenAndServe(":8080", r.Setup())
}

Spring Clean Architecture vs Go Clean Architecture: 패키지 구조 비교

핵심: 둘 다 Clean Architecture이지만, 패키지 표현 방식이 다릅니다.

레이어 매핑

Clean Architecture 레이어 Spring 패키지 Go 패키지
Entities (가장 안쪽) domain/user/ internal/domain/user/
Use Cases usecase/auth/ internal/usecase/auth/
Interface Adapters (Web) interfaces/web/controller/ internal/interface/http/handler/
Interface Adapters (DB) infrastructure/persistence/ internal/infrastructure/postgres/
Frameworks & Drivers infrastructure/config/ pkg/, external libs
Main (DI) @Configuration classes cmd/api/main.go

패키지 구조 비교

Spring Clean Architecture:

src/main/java/com/example/
├── domain/
│   └── user/
│       ├── User.java           # Domain Entity
│       └── UserRepository.java # 인터페이스 (도메인이 정의)
│
├── usecase/
│   └── auth/
│       ├── LoginUseCase.java       # UseCase 인터페이스
│       └── LoginUseCaseImpl.java   # UseCase 구현
│
├── interfaces/
│   └── web/
│       ├── controller/
│       │   └── AuthController.java
│       └── dto/
│           └── LoginRequest.java
│
└── infrastructure/
    ├── persistence/
    │   ├── UserRepositoryImpl.java  # domain.UserRepository 구현
    │   └── entity/
    │       └── UserEntity.java      # JPA Entity
    └── config/
        └── DatabaseConfig.java

Go Clean Architecture:

internal/
├── domain/
│   └── user/
│       ├── user.go          # Domain Entity
│       └── repository.go    # 인터페이스 (도메인이 정의)
│
├── usecase/
│   └── auth/
│       ├── login.go         # UseCase 구현
│       └── interface.go     # LoginExecutor 인터페이스
│
├── interface/
│   └── http/
│       ├── handler/
│       │   └── auth_handler.go
│       └── dto/
│           └── request.go
│
└── infrastructure/
    └── postgres/
        ├── user_repository.go  # user.Repository 구현
        └── models/
            └── user.go         # GORM Model

주요 차이점

구분 Spring Go
패키지 명명 domain/usecase/interfaces/infrastructure domain/usecase/interface/infrastructure
UseCase 표현 인터페이스 + 구현 분리
(LoginUseCase.java + LoginUseCaseImpl.java)
인터페이스 파일로 표현 (interface.go)
레이어 구분 interfaces/web vs infrastructure/persistence interface/http vs infrastructure/postgres
DI 방식 Spring Container (@Autowired) 명시적 생성자 주입 (main.go)
패키지 가시성 public/private internal/ vs pkg/
Entity 분리 Domain Entity vs JPA Entity Domain Entity vs GORM Model

의존성 규칙: Spring과 Go 모두 동일

Clean Architecture의 의존성 규칙 (공통)

바깥 → 안쪽 방향으로만 의존:

Handler/Controller (바깥)
    ↓ depends on
UseCase 인터페이스
    ↑ implements
UseCase 구현
    ↓ depends on
Repository 인터페이스 (Domain)
    ↑ implements
Repository 구현 (바깥)

각 레이어가 import하는 것

Domain (가장 안쪽)

Spring:

package com.example.myapp.domain.user;

// ✅ 아무것도 import 안 함 (표준 라이브러리만)
import java.time.LocalDateTime;

Go:

package user

// ✅ 아무것도 import 안 함 (표준 라이브러리만)
import (
    "context"
    "time"
)

UseCase/Application

Spring:

package com.example.myapp.usecase.auth;

// ✅ Domain만 import
import com.example.myapp.domain.user.User;
import com.example.myapp.domain.user.UserRepository;

Go:

package auth

// ✅ Domain만 import
import (
    "context"
    "myapp/internal/domain/user"
    "myapp/internal/domain/auth"
    "myapp/pkg/jwt"  // 공용 유틸리티 OK
)

Interface/Adapter (Web)

Spring:

package com.example.myapp.interfaces.web.controller;

// ✅ UseCase만 import (Domain 직접 참조 최소화)
import com.example.myapp.usecase.auth.LoginUseCase;

Go:

package handler

// ✅ UseCase, DTO import (Domain 직접 참조 최소화)
import (
    "myapp/internal/usecase/auth"
    "myapp/internal/interface/http/dto"
)

Infrastructure

Spring:

package com.example.myapp.infrastructure.persistence;

// ✅ Domain 인터페이스 구현을 위해 import
import com.example.myapp.domain.user.User;
import com.example.myapp.domain.user.UserRepository;
import org.springframework.data.jpa.repository.JpaRepository;

Go:

package postgres

// ✅ Domain 인터페이스 구현을 위해 import
import (
    "myapp/internal/domain/user"
    "gorm.io/gorm"
)

 


 

Spring vs Go Clean Architecture: 언어별 구현 차이점

핵심 요약: 아키텍처 원칙은 동일하지만, 언어 특성에 따라 구현 방식이 다릅니다.

1. 인터페이스 위치 (동일)

Spring Clean Architecture:

// domain/user/UserRepository.java (도메인이 정의!)
package com.example.myapp.domain.user;

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

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

@Repository
public class UserRepositoryImpl implements UserRepository {
    // domain.UserRepository 구현
}

Go Clean Architecture:

// internal/domain/user/repository.go (도메인이 정의!)
package user

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

// internal/infrastructure/postgres/user_repository.go (구현체)
package postgres

// user.Repository 인터페이스 구현
type UserRepository struct { }

공통점:

  • ✅ 둘 다 Domain 레이어가 인터페이스 소유
  • ✅ 둘 다 Infrastructure가 Domain 인터페이스 구현
  • ✅ Dependency Inversion 원칙 준수

2. 의존성 주입 (DI) 방식

Spring Clean Architecture (Container 기반):

// @Configuration으로 Bean 등록
@Configuration
public class ApplicationConfig {

    @Bean
    public UserRepository userRepository(UserJpaRepository jpaRepo) {
        return new UserRepositoryImpl(jpaRepo);  // Repository 구현 생성
    }

    @Bean
    public LoginUseCase loginUseCase(UserRepository userRepo, AuthProvider authProvider) {
        return new LoginUseCaseImpl(userRepo, authProvider);  // UseCase 구현 생성
    }
}

// Controller는 자동 주입
@RestController
public class AuthController {
    private final LoginUseCase loginUseCase;  // Spring이 자동 주입

    public AuthController(LoginUseCase loginUseCase) {
        this.loginUseCase = loginUseCase;
    }
}

Go Clean Architecture (명시적 생성):

// cmd/api/main.go에서 수동으로 조립
func main() {
    // Infrastructure 생성
    db := postgres.NewDatabase(dbURL, maxConns)
    userRepo := postgres.NewUserRepository(db.DB)  // Repository 구현 생성
    authProvider := auth.NewMockAuthProvider()

    // UseCase 생성
    loginUC := auth.NewLoginUseCase(userRepo, authProvider, jwtService)

    // Handler 생성
    authHandler := handler.NewAuthHandler(loginUC)

    // Router 설정
    r := router.NewRouter(authHandler)
    http.ListenAndServe(":8080", r.Setup())
}

차이점:

  • Spring: Container가 자동으로 의존성 해결 (@Autowired, @Bean)
  • Go: main.go에서 명시적으로 의존성 조립

3. 테스트 용이성

Spring:

@ExtendWith(MockitoExtension.class)
class LoginServiceTest {
    @Mock
    private UserRepository userRepository;  // Mockito로 Mock 생성

    @Mock
    private AuthProvider authProvider;

    @InjectMocks
    private LoginService loginService;  // 자동 주입

    @Test
    void testLogin() {
        when(authProvider.authenticate(email, password))
            .thenReturn(userInfo);

        LoginResult result = loginService.execute(command);

        assertThat(result.getToken()).isNotNull();
    }
}

Go:

func TestLoginUseCase(t *testing.T) {
    // 직접 Mock 주입 (testify/mock 사용)
    mockRepo := &MockUserRepository{}
    mockAuth := &MockAuthProvider{}
    jwtService := jwt.NewService("secret", time.Hour)

    uc := NewLoginUseCase(mockRepo, mockAuth, jwtService)

    // Given
    mockAuth.On("Authenticate", ctx, email, password).Return(userInfo, nil)
    mockRepo.On("FindByEmail", ctx, email).Return(nil, errors.ErrUserNotFound)
    mockRepo.On("Create", ctx, mock.Anything).Return(nil)

    // When
    result, err := uc.Execute(ctx, email, password)

    // Then
    assert.NoError(t, err)
    assert.NotNil(t, result)
    mockAuth.AssertExpectations(t)
    mockRepo.AssertExpectations(t)
}

 

MSA 전환 시 장점

Spring Clean Architecture

Monolith:
src/main/java/com/example/
├── domain/
│   ├── user/
│   └── auth/
├── usecase/
│   ├── auth/
│   └── user/
└── infrastructure/
    └── persistence/

→ MSA 전환 시 도메인 단위로 추출
  (domain/auth + usecase/auth + infrastructure 일부)

Go Clean Architecture

Monolith:
internal/
├── domain/
│   ├── user/
│   └── auth/
├── usecase/
│   ├── auth/
│   └── user/
└── infrastructure/
    └── postgres/

→ MSA 전환 시 도메인 단위로 통째로 추출
  (domain/auth + usecase/auth + infrastructure 일부)

 

공통점:

  • 도메인 경계가 명확
  • 패키지 단위로 독립적
  • 인터페이스가 도메인에 있어서 이동 용이

 

실전 팁

1. 패키지 네이밍 규칙 (Go)

// ❌ 나쁜 예: 레이어 이름으로 패키지
internal/
├── handlers/
├── services/
└── repositories/

// ✅ 좋은 예: 도메인 이름으로 패키지
internal/
├── domain/
│   ├── user/
│   └── auth/
├── usecase/
│   ├── user/
│   └── auth/

2. 순환 참조 방지

// ❌ 나쁜 예: 순환 참조
package user
import "myapp/internal/usecase/auth"  // user → auth

package auth
import "myapp/internal/domain/user"  // auth → user
// 순환 참조 발생!

// ✅ 좋은 예: 인터페이스로 분리
// user 패키지는 auth를 import하지 않음
// auth 패키지만 user를 import

3. internal vs pkg (Go)

// internal/: 외부 프로젝트에서 import 불가
internal/
├── domain/
├── usecase/
└── infrastructure/

// pkg/: 외부 프로젝트에서 import 가능
pkg/
├── errors/
├── logger/
└── jwt/

규칙:

  • 비즈니스 로직 → internal/
  • 재사용 가능한 유틸리티 → pkg/

정리

Spring Clean Architecture vs Go Clean Architecture

구분 Spring GO
아키텍처 원칙 ✅ Clean Architecture ✅ Clean Architecture
의존성 방향 ✅ 바깥→안쪽 (Domain 중심) ✅ 바깥→안쪽 (Domain 중심)
인터페이스 위치 ✅ Domain이 정의 ✅ Domain이 정의
패키지 명명 domain/usecase/interfaces/infrastructure domain/usecase/interface/infrastructure
DI 방식 Spring Container 명시적 조립 (main.go)
Entity 분리 Domain Entity vs JPA Entity Domain Entity vs GORM Model
UseCase 표현 인터페이스 + 구현 분리 인터페이스 파일 (interface.go)

 

중요한 결론:

  • 아키텍처 원칙은 동일 (Clean Architecture)
  • 패키지 표현 방식이 다름 (Spring vs Go 언어 특성)
  • 의존성 방향은 동일 (바깥→안쪽)

Spring Clean Architecture

장점:

  • ✅ Domain이 프레임워크 독립적
  • ✅ 인터페이스 분리로 교체 가능성 확보
  • ✅ MSA 전환 용이 (도메인 단위 분리)
  • ✅ Spring Container의 DI 혜택

단점:

  • ❌ 레이어 간 명확한 분리로 인한 복잡도 증가
  • ❌ 팀 전체의 아키텍처 이해 필요
  • ❌ Domain Entity vs JPA Entity 분리 시 보일러플레이트

Go Clean Architecture

장점:

  • ✅ Domain 중심 설계
  • ✅ 프레임워크 독립성 (표준 라이브러리 사용)
  • ✅ 테스트 용이 (명시적 DI)
  • ✅ MSA 전환 용이 (패키지 = 서비스 단위)

단점:

  • ❌ 초기 학습 곡선
  • ❌ 수동 DI (main.go에서 직접 조립)
  • ❌ 보일러플레이트 (Domain/Model 분리)

 

마치며: 아키텍처는 언어가 아니라 선택의 문제

이 글의 핵심 메시지를 다시 강조하면:

중요한 깨달음

✅ 올바른 이해:

  • Spring에서도 Clean Architecture 구현 가능
  • Go에서도 Clean Architecture 구현 가능
  • 아키텍처는 언어가 아니라 팀의 선택

Spring Clean Architecture를 이미 사용 중이라면?

Go로 전환할 때 혼란스러운 부분:

  1. 패키지 명명 차이
    • Spring: domain/usecase/interfaces/infrastructure
    • Go: domain/usecase/interface/infrastructure
    • → 이름만 약간 다를 뿐 (interfaces vs interface), 역할은 동일
  2. DI 방식 차이
    • Spring: @Autowired, @Bean으로 자동 주입
    • Go: main.go에서 명시적 조립
    • → 철학만 다를 뿐, 의존성 방향은 동일
  3. UseCase 구현 표현
    • Spring: 인터페이스와 구현 클래스 분리 (LoginUseCase.java + LoginUseCaseImpl.java)
    • Go: 구현 구조체 + 인터페이스 파일 (login.go + interface.go)
    • → 표현 방식만 다를 뿐, 원칙은 동일

결론

Clean Architecture는 언어의 문제가 아닙니다.

  • Spring에서도 Clean Architecture 가능
  • Go에서도 Clean Architecture 가능
  • 핵심은 Domain 중심 사고의존성 방향 규칙

프로젝트가 커질수록, MSA 전환을 고려할수록,
Clean Architecture의 가치가 빛납니다.

 

 

반응형

댓글