
"지금 모든 라우팅이 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이 하는 일:
@ComponentScan- 모든@Component,@Service,@Repository스캔@EnableAutoConfiguration- 자동 설정 활성화@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));
}
}
개발자가 하는 일:
@Autowired선언- 끝!
Spring이 하는 일:
- LoginUseCase 타입의 Bean 찾기
- ApplicationContext에서 조회
- Reflection으로 필드에 주입
- 없으면? NoSuchBeanDefinitionException
- 여러 개면? 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 준비하기"
댓글