728x90

"Spring도 Clean Architecture 쓰는데, Go는 패키지 구조가 왜 이렇게 다르죠?"
📌 이 글의 핵심
- 같은 Clean Architecture, 다른 패키지 표현 방식
- Spring의
domain/usecase/interfaces/infrastructurevs 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로 전환할 때 혼란스러운 부분:
- 패키지 명명 차이
- Spring:
domain/usecase/interfaces/infrastructure - Go:
domain/usecase/interface/infrastructure - → 이름만 약간 다를 뿐 (
interfacesvsinterface), 역할은 동일
- Spring:
- DI 방식 차이
- Spring:
@Autowired,@Bean으로 자동 주입 - Go:
main.go에서 명시적 조립 - → 철학만 다를 뿐, 의존성 방향은 동일
- Spring:
- UseCase 구현 표현
- Spring: 인터페이스와 구현 클래스 분리 (
LoginUseCase.java+LoginUseCaseImpl.java) - Go: 구현 구조체 + 인터페이스 파일 (
login.go+interface.go) - → 표현 방식만 다를 뿐, 원칙은 동일
- Spring: 인터페이스와 구현 클래스 분리 (
결론
Clean Architecture는 언어의 문제가 아닙니다.
- Spring에서도 Clean Architecture 가능
- Go에서도 Clean Architecture 가능
- 핵심은 Domain 중심 사고와 의존성 방향 규칙
프로젝트가 커질수록, MSA 전환을 고려할수록,
Clean Architecture의 가치가 빛납니다.
반응형
댓글