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

Java/Kotlin Spring 개발자의 Go 전환기 #5: @Autowired가 없다고? - DI Container의 마법에서 명시적 주입으로

by prographer J 2025. 12. 7.
728x90

"지금 모든 라우팅이 main.go에 있는데 이게 스프링하고 다르게 느껴져"


📌 이 글의 핵심

  • Spring의 @Autowired, Component Scan vs Go의 수동 의존성 주입(DI)
  • DI Container의 런타임 마법 vs 컴파일 타임 명시성 비교
  • main.go 구조화 패턴과 Google Wire를 활용한 보일러플레이트 해결
  • MSA 전환 시 명시적 의존성 그래프의 실전 활용

🎯 이런 분들께 추천합니다

  • Spring IoC Container에 익숙한 Java 백엔드 개발자
  • Go의 긴 main.go 파일이 불편한 개발자
  • 의존성 주입 패턴을 Go에서 구현하고 싶은 아키텍트

⏱️ 읽는 시간: 약 13분


Spring Boot 프로젝트의 main 메서드를 보면:

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
        // 끝! 3줄로 끝!
    }
}

놀랍도록 간결합니다. 모든 Bean 생성, 의존성 주입, 라우팅이 자동으로 처리됩니다.

그런데 Go 프로젝트의 main.go를 보면:

func main() {
    // 설정 로드
    cfg, _ := config.Load()

    // DB 연결
    db, _ := postgres.NewDatabase(cfg.Database.URL, cfg.Database.MaxConnections)
    defer db.Close()

    // 의존성 수동 생성
    jwtService := jwt.NewService(cfg.JWT.Secret, cfg.JWT.Expiration)
    userRepo := postgres.NewUserRepository(db.Pool)
    authProvider := infraAuth.NewMockAuthProvider()

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

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

    // Middleware 생성
    authMiddleware := httpMiddleware.NewAuthMiddleware(jwtService)

    // Router 생성
    appRouter := router.NewRouter(authHandler, authMiddleware)
    r := appRouter.Setup()

    // 서버 시작
    srv := &http.Server{
        Addr:    ":" + cfg.Server.Port,
        Handler: r,
    }

    if err := srv.ListenAndServe(); err != nil {
        logger.Fatal("Server failed", zap.Error(err))
    }
}

 

"뭐야... 이게 다 뭐야? Spring은 3줄인데 Go는 30줄이야!"

 

이 글은 Spring의 DI Container 마법과 Go의 명시적 의존성 주입을 비교합니다.


Spring의 마법: DI Container

Component Scan의 마법

Spring Boot는 시작할 때 자동으로 모든 Bean을 찾아서 등록합니다.

@SpringBootApplication  // ← 이 하나로 모든 마법 시작
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

 

@SpringBootApplication이 하는 일:

  1. @ComponentScan - 모든 @Component, @Service, @Repository 스캔
  2. @EnableAutoConfiguration - 자동 설정 활성화
  3. @Configuration - Bean 정의 가능

런타임에 일어나는 일:

1. 클래스패스 스캔 (모든 .class 파일 읽기)
   └─> @Component, @Service, @Repository 찾기

2. Bean Definition 생성
   └─> 각 Bean의 의존성 파악

3. Dependency Graph 구성
   └─> A는 B에 의존, B는 C에 의존...

4. Bean 생성 순서 결정
   └─> 의존성 역순으로 생성 (C → B → A)

5. 실제 Bean 인스턴스 생성
   └─> Reflection으로 생성자 호출

6. 의존성 주입
   └─> @Autowired 필드/생성자에 주입

7. Post Processor 실행
   └─> AOP Proxy 생성, @PostConstruct 호출

이 모든 게 런타임에, 자동으로 일어납니다!

@Autowired의 마법

@RestController
public class AuthController {

    @Autowired
    private LoginUseCase loginUseCase;
    //      ↑
    // Spring이 자동으로 주입!
    // 어디서? ApplicationContext에서!

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

개발자가 하는 일:

  1. @Autowired 선언
  2. 끝!

Spring이 하는 일:

  1. LoginUseCase 타입의 Bean 찾기
  2. ApplicationContext에서 조회
  3. Reflection으로 필드에 주입
  4. 없으면? NoSuchBeanDefinitionException
  5. 여러 개면? NoUniqueBeanDefinitionException

 

Go의 현실: 명시적 의존성 주입

마법은 없다

Go에는 DI Container가 없습니다. 모든 것을 명시적으로 작성합니다.

func main() {
    // 1. Config 로드 (명시적)
    cfg, err := config.Load()
    if err != nil {
        fmt.Printf("Failed to load config: %v\n", err)
        os.Exit(1)
    }

    // 2. DB 연결 (명시적)
    db, err := postgres.NewDatabase(cfg.Database.URL, cfg.Database.MaxConnections)
    if err != nil {
        logger.Fatal("Failed to connect to database", zap.Error(err))
    }
    defer db.Close()

    // 3. 의존성 트리를 아래부터 위로 구성 (명시적)

    // Layer 1: Infrastructure
    jwtService := jwt.NewService(cfg.JWT.Secret, cfg.JWT.Expiration)
    userRepo := postgres.NewUserRepository(db.Pool)
    authProvider := infraAuth.NewMockAuthProvider()

    // Layer 2: Use Cases
    loginUC := authUC.NewLoginUseCase(userRepo, authProvider, jwtService)

    // Layer 3: Handlers
    authHandler := handler.NewAuthHandler(loginUC)

    // Layer 4: Middleware
    authMiddleware := httpMiddleware.NewAuthMiddleware(jwtService)

    // Layer 5: Router
    appRouter := router.NewRouter(authHandler, authMiddleware)
    r := appRouter.Setup()

    // 6. 서버 시작 (명시적)
    srv := &http.Server{
        Addr:    ":" + cfg.Server.Port,
        Handler: r,
    }

    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        logger.Fatal("Server failed", zap.Error(err))
    }
}

모든 의존성을 개발자가 직접 생성하고 주입합니다.


비교: Spring vs Go 의존성 주입

예제: LoginUseCase 생성

Spring:

// LoginUseCase.java
@Service
public class LoginUseCase {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private AuthProvider authProvider;

    @Autowired
    private JwtService jwtService;

    public LoginResult execute(String email, String password) {
        // 비즈니스 로직
    }
}

// AuthController.java
@RestController
public class AuthController {

    @Autowired
    private LoginUseCase loginUseCase;  // 자동 주입!

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

// Application.java
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
        // LoginUseCase와 AuthController가 자동으로 생성되고 연결됨!
    }
}

 

Go:

// login.go
package auth

type LoginUseCase struct {
    userRepo     user.Repository
    authProvider auth.Provider
    jwtService   jwt.TokenService
}

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

func (uc *LoginUseCase) Execute(ctx context.Context, email, password string) (*LoginResult, error) {
    // 비즈니스 로직
}

// auth_handler.go
package handler

type AuthHandler struct {
    loginUC auth.LoginExecutor
}

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

func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
    // HTTP 처리
}

// main.go
func main() {
    // 수동으로 의존성 트리 구성
    db := connectDB()

    userRepo := postgres.NewUserRepository(db.Pool)
    authProvider := infraAuth.NewMockAuthProvider()
    jwtService := jwt.NewService(secretKey, expiration)

    loginUC := auth.NewLoginUseCase(userRepo, authProvider, jwtService)
    authHandler := handler.NewAuthHandler(loginUC)

    // ...
}

 


Spring 방식의 장단점

✅ 장점

1. 편리함

@Autowired
private LoginUseCase loginUseCase;
// 끝! 끝!

 

2. 자동 생명주기 관리

@Service
public class CacheService {

    @PostConstruct
    public void init() {
        // Bean 생성 후 자동 호출
    }

    @PreDestroy
    public void cleanup() {
        // 애플리케이션 종료 시 자동 호출
    }
}

 

3. AOP 등 고급 기능

@Transactional  // ← Spring이 Proxy 생성
public void updateUser(User user) {
    // 트랜잭션 자동 관리
}

❌ 단점

1. 런타임 오류

@Service
public class LoginUseCase {

    @Autowired
    private UserRepositry userRepository;  // 오타! (Repository → Repositry)

    // 컴파일 성공! ✅
    // 런타임 에러! ❌
    // → NoSuchBeanDefinitionException
}

 

2. 느린 시작 속도

Spring Boot 시작 시간: 5-10초
- Component Scan
- Bean Definition 생성
- Dependency Graph 구성
- Reflection으로 Bean 생성
- AOP Proxy 생성
- ...

 

3. 마법에 대한 의존

@Autowired
private SomeService someService;

// 질문:
// 1. SomeService는 어디서 오나요?
// 2. 어떤 구현체가 주입되나요?
// 3. 주입 실패 시 어떻게 되나요?
//
// 답: Spring한테 물어보세요 🤷

 

4. 숨겨진 의존성

@RestController
public class UserController {

    @Autowired private UserService userService;
    @Autowired private EmailService emailService;
    @Autowired private LogService logService;
    @Autowired private CacheService cacheService;
    @Autowired private MetricService metricService;
    // ... 10개 더

    // 이 Controller가 얼마나 많은 의존성을 가지는지
    // 생성자를 보지 않으면 모름
}

 

Go 방식의 장단점

✅ 장점

1. 컴파일 타임 검증

userRepo := postgres.NewUserRepository(db.Pool)
loginUC := auth.NewLoginUseCase(userRepo, authProvider, jwtService)
//                              ↑         ↑            ↑
// 하나라도 타입이 안 맞으면?
// → 컴파일 에러!

 

2. 명시적 의존성 그래프

func main() {
    // 의존성 트리가 코드로 보임
    db := connectDB()                   // Level 1
    userRepo := NewUserRepository(db)   // Level 2
    loginUC := NewLoginUseCase(userRepo) // Level 3
    handler := NewAuthHandler(loginUC)  // Level 4

    // 의존성 방향이 명확: DB → Repo → UseCase → Handler
}

 

3. 빠른 시작

Go 서버 시작 시간: < 1초
- Reflection 없음
- Bean Scan 없음
- Proxy 생성 없음
- 그냥 함수 호출!

 

4. 테스트 용이

func TestLoginUseCase(t *testing.T) {
    // Mock 직접 생성
    mockRepo := &MockUserRepository{}
    mockAuth := &MockAuthProvider{}
    mockJWT := &MockJWTService{}

    // 직접 주입
    uc := NewLoginUseCase(mockRepo, mockAuth, mockJWT)

    // DI Container 필요 없음!
}

❌ 단점

1. 보일러플레이트

// 새로운 의존성 추가 시...

// 1. UseCase에 필드 추가
type LoginUseCase struct {
    userRepo     user.Repository
    authProvider auth.Provider
    jwtService   jwt.TokenService
    emailService email.Service  // ← 추가
}

// 2. 생성자에 파라미터 추가
func NewLoginUseCase(
    userRepo user.Repository,
    authProvider auth.Provider,
    jwtService jwt.TokenService,
    emailService email.Service,  // ← 추가
) *LoginUseCase {
    return &LoginUseCase{
        userRepo:     userRepo,
        authProvider: authProvider,
        jwtService:   jwtService,
        emailService: emailService,  // ← 추가
    }
}

// 3. main.go에서 주입 코드 수정
func main() {
    // ...
    emailService := email.NewService(smtpConfig)  // ← 추가
    loginUC := auth.NewLoginUseCase(
        userRepo,
        authProvider,
        jwtService,
        emailService,  // ← 추가
    )
    // ...
}

 

Spring이라면? @Autowired 하나면 끝!

 

2. 큰 main.go

func main() {
    // 30-50줄의 의존성 생성 코드
    // 프로젝트가 커질수록 길어짐
}

 

3. 순환 의존성 수동 해결

// A가 B에 의존, B가 A에 의존
// → 컴파일 에러!
// → 직접 리팩토링 필요

 


실전 패턴: main.go 구조화

문제: main.go가 너무 길어진다

func main() {
    // 50줄의 의존성 생성 코드...
    // 읽기 힘듦!
}

해결책 1: 레이어별 함수 분리

// main.go
func main() {
    cfg := loadConfig()
    db := connectDatabase(cfg)
    defer db.Close()

    // 레이어별로 함수 분리
    infrastructure := setupInfrastructure(cfg, db)
    useCases := setupUseCases(infrastructure)
    handlers := setupHandlers(useCases)
    router := setupRouter(handlers, infrastructure.AuthMiddleware)

    startServer(cfg, router)
}

// Infrastructure Layer 초기화
func setupInfrastructure(cfg *config.Config, db *postgres.Database) *Infrastructure {
    return &Infrastructure{
        JWTService:     jwt.NewService(cfg.JWT.Secret, cfg.JWT.Expiration),
        UserRepo:       postgres.NewUserRepository(db.Pool),
        AuthProvider:   infraAuth.NewMockAuthProvider(),
        AuthMiddleware: httpMiddleware.NewAuthMiddleware(...),
    }
}

type Infrastructure struct {
    JWTService     *jwt.Service
    UserRepo       user.Repository
    AuthProvider   auth.Provider
    AuthMiddleware *httpMiddleware.AuthMiddleware
}

// Use Cases Layer 초기화
func setupUseCases(infra *Infrastructure) *UseCases {
    return &UseCases{
        LoginUC: authUC.NewLoginUseCase(
            infra.UserRepo,
            infra.AuthProvider,
            infra.JWTService,
        ),
    }
}

type UseCases struct {
    LoginUC *authUC.LoginUseCase
}

// Handlers Layer 초기화
func setupHandlers(uc *UseCases) *Handlers {
    return &Handlers{
        AuthHandler: handler.NewAuthHandler(uc.LoginUC),
    }
}

type Handlers struct {
    AuthHandler *handler.AuthHandler
}

// Router 초기화
func setupRouter(h *Handlers, authMW *httpMiddleware.AuthMiddleware) *chi.Mux {
    return router.NewRouter(h.AuthHandler, authMW).Setup()
}

해결책 2: Wire (Google의 DI 도구)

Google이 만든 코드 생성 도구입니다.

// wire.go
//go:build wireinject
// +build wireinject

package main

import (
    "github.com/google/wire"
)

func InitializeServer(cfg *config.Config) (*Server, error) {
    wire.Build(
        // Infrastructure
        postgres.NewDatabase,
        postgres.NewUserRepository,
        jwt.NewService,
        infraAuth.NewMockAuthProvider,

        // Use Cases
        authUC.NewLoginUseCase,

        // Handlers
        handler.NewAuthHandler,

        // Middleware
        httpMiddleware.NewAuthMiddleware,

        // Router
        router.NewRouter,

        // Server
        NewServer,
    )
    return nil, nil
}

 

Wire가 자동으로 main.go 생성!

$ wire
# wire_gen.go 자동 생성

# wire_gen.go (자동 생성됨)
func InitializeServer(cfg *config.Config) (*Server, error) {
    db := postgres.NewDatabase(cfg.Database.URL)
    userRepo := postgres.NewUserRepository(db.Pool)
    jwtService := jwt.NewService(cfg.JWT.Secret, cfg.JWT.Expiration)
    authProvider := infraAuth.NewMockAuthProvider()
    loginUC := authUC.NewLoginUseCase(userRepo, authProvider, jwtService)
    authHandler := handler.NewAuthHandler(loginUC)
    // ...
    return server, nil
}

Spring의 편리함 + Go의 명시성!


MSA 전환 시 장점

Spring의 문제

// Monolith
@SpringBootApplication
public class MonolithApplication {
    public static void main(String[] args) {
        SpringApplication.run(MonolithApplication.class, args);
        // 모든 Bean이 자동으로 로드됨
        // → auth, user, project, rbac 모두!
    }
}

// MSA 전환 시
@SpringBootApplication
@ComponentScan(basePackages = "com.example.auth")  // ← auth만 스캔하도록 설정 필요
public class AuthServiceApplication {
    // 설정 복잡...
}

Go의 장점

// Monolith
func main() {
    // Infrastructure
    authProvider := setupAuthInfra()
    userRepo := setupUserInfra()
    projectRepo := setupProjectInfra()

    // Use Cases
    loginUC := setupLoginUC(authProvider, userRepo)
    createProjectUC := setupCreateProjectUC(projectRepo)

    // ...
}

// MSA 전환: auth-service
func main() {
    // auth 관련만 복사-붙여넣기
    authProvider := setupAuthInfra()
    userRepo := setupUserInfra()
    loginUC := setupLoginUC(authProvider, userRepo)
    // 끝!
}

// MSA 전환: project-service
func main() {
    // project 관련만 복사-붙여넣기
    projectRepo := setupProjectInfra()
    createProjectUC := setupCreateProjectUC(projectRepo)
    // 끝!
}

의존성이 명시적이므로 분리가 쉽습니다!


정리: Trade-off

구분 Spring DI Container Go 명시적 주입
편리성 ⭐⭐⭐⭐⭐ (@Autowired 끝) ⭐ (수동 작성)
명시성 ⭐⭐ (숨겨진 의존성) ⭐⭐⭐⭐⭐ (코드로 보임)
컴파일 검증 ⭐⭐ (런타임 에러) ⭐⭐⭐⭐⭐ (컴파일 에러)
시작 속도 ⭐ (5-10초) ⭐⭐⭐⭐⭐ (<1초)
테스트 ⭐⭐⭐ (Mock 프레임워크) ⭐⭐⭐⭐ (직접 주입)
MSA 분리 ⭐⭐⭐ (설정 변경) ⭐⭐⭐⭐⭐ (코드 복사)

 

실전 선택 가이드

Spring DI가 좋은 경우:

  • ✅ 대규모 모놀리스
  • ✅ 많은 Bean (100개 이상)
  • ✅ AOP, Transaction 등 고급 기능 필요
  • ✅ 레거시 프레임워크 통합

Go 명시적 주입이 좋은 경우:

  • ✅ MSA 또는 MSA 전환 예정
  • ✅ 빠른 시작 속도 중요
  • ✅ 명시적 의존성 선호
  • 우리 프로젝트 ← Monolith → MSA

 

마치며: 마법 vs 명시성

Spring을 쓰다가 Go로 오면 처음에는 불편합니다.

"왜 이렇게 다 직접 써야 해? @Autowired 하나면 끝인데!"

하지만 1개월 후:

"main.go를 보면 전체 의존성 구조가 한눈에 보이네?"

3개월 후:

"MSA로 분리할 때 main.go 일부만 복사하면 되네!"

6개월 후:

"Spring으로 돌아가면 의존성이 어디서 오는지 모르겠어..."

Go는 마법을 거부하고, 명시성을 선택했습니다.

그 선택이 불편해 보이지만,
복잡도가 증가할수록 그 가치가 빛납니다.

 

다음 편 예고:

"Schema-per-Domain 전략 - PostgreSQL로 MSA 준비하기"

반응형

댓글