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

Java/Kotlin Spring 개발자의 Go 전환기 #3: 테스트 파일이 왜 같은 폴더에 있죠?

by prographer J 2025. 11. 30.
728x90

 

"src/test 폴더는 어디 갔죠? 왜 테스트가 프로덕션 코드 옆에 붙어있어요?"

 

📌 이 글의 핵심

  • Spring의 src/test 분리 vs Go의 *_test.go 동일 디렉토리 패턴
  • 패키지 응집도 관점에서 본 테스트 파일 배치 전략
  • Given-When-Then 패턴을 Go 테스트에 적용하는 실전 방법
  • White-box vs Black-box 테스트 선택 기준

🎯 이런 분들께 추천합니다

  • Spring Boot의 src/main/test 구조에 익숙한 Java 개발자
  • Go의 테스트 파일 구조가 혼란스러운 백엔드 개발자
  • 테스트 주도 개발(TDD)을 Go에서 실천하고 싶은 분

⏱️ 읽는 시간: 약 10분


Go 프로젝트를 처음 열었을 때의 당혹감을 아직도 기억합니다.

internal/usecase/auth/
├── login.go           # 실제 코드
├── login_test.go      # 테스트 코드 ???
├── logout.go
├── logout_test.go
└── mocks_test.go

"이게 뭐야? 파일이 왜 이렇게 섞여 있지?"

 

Spring에서는 당연하게도 이렇게 생겼죠:

src/
├── main/java/com/example/auth/
│   ├── LoginUseCase.java
│   └── LogoutUseCase.java
└── test/java/com/example/auth/
    ├── LoginUseCaseTest.java
    └── LogoutUseCaseTest.java

깔끔하게 분리되어 있습니다. 왜 Go는 이렇게 지저분할까요?

먼저 Spring 방식을 이해하자

Spring의 테스트 디렉토리 구조

project/
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── com/example/myapp/
│   │   │       ├── domain/
│   │   │       │   └── User.java
│   │   │       ├── usecase/
│   │   │       │   └── LoginUseCase.java
│   │   │       └── handler/
│   │   │           └── AuthController.java
│   │   └── resources/
│   │       └── application.yml
│   └── test/
│       ├── java/
│       │   └── com/example/myapp/
│       │       ├── domain/
│       │       │   └── UserTest.java
│       │       ├── usecase/
│       │       │   └── LoginUseCaseTest.java
│       │       └── handler/
│       │           └── AuthControllerTest.java
│       └── resources/
│           └── application-test.yml
└── pom.xml

 

핵심 특징:

  1. 📁 물리적 완전 분리: src/mainsrc/test
  2. 🔄 논리적 동일: 패키지명은 동일 (com.example.myapp.usecase)
  3. 🔧 빌드 도구 관리: Maven/Gradle이 테스트 스코프 처리

Spring 방식의 장점

<!-- Maven pom.xml -->
<build>
    <sourceDirectory>src/main/java</sourceDirectory>
    <testSourceDirectory>src/test/java</testSourceDirectory>
</build>

<dependencies>
    <!-- 프로덕션 의존성 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- 테스트 전용 의존성 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>  <!-- ← 명시적 스코프 -->
    </dependency>
</dependencies>

명확한 분리: 코드와 테스트 완전 분리
시각적 구분: 초보자도 직관적으로 이해
리소스 분리: application.yml vs application-test.yml
대규모 팀: 테스트 전담 팀이 독립적으로 작업 가능

Go의 충격적인 접근법

Go의 테스트 파일 위치

internal/usecase/auth/
├── login.go           # package auth
├── login_test.go      # package auth (같은 패키지!)
├── logout.go
├── logout_test.go
├── interface.go
└── mocks_test.go

모든 테스트 파일이 구현 파일과 같은 디렉토리.

# 별도 설정 없이 그냥 실행
go test ./...

# *_test.go 파일은 빌드 시 자동으로 제외됨
go build  # ← login_test.go는 절대 포함 안 됨

왜 Go는 이렇게 설계했을까?

이유 1: "패키지 = 디렉토리" 규칙

Java는 패키지와 디렉토리가 독립적입니다:

// 경로: src/main/java/com/example/auth/LoginUseCase.java
package com.example.auth;  // 패키지명 자유

// 경로: src/test/java/com/example/auth/LoginUseCaseTest.java
package com.example.auth;  // 같은 패키지, 다른 디렉토리 ✅

Go는 패키지 = 디렉토리명 (강제):

// 경로: internal/usecase/auth/login.go
package auth  // 반드시 디렉토리명과 동일

// 만약 테스트를 다른 디렉토리에 둔다면?
// → 다른 패키지가 되어 버림
// → Private 함수/변수 접근 불가!

Go의 1 디렉토리 = 1 패키지 규칙 때문에 같은 디렉토리에 둘 수밖에 없습니다.

이유 2: Private 멤버 테스트

Java (같은 패키지면 접근 가능):

// src/main/java/com/example/auth/LoginUseCase.java
package com.example.auth;

public class LoginUseCase {
    private UserRepository userRepo;  // private
    // ...
}

// src/test/java/com/example/auth/LoginUseCaseTest.java
package com.example.auth;  // 같은 패키지

@Test
void test() {
    LoginUseCase uc = new LoginUseCase();
    // userRepo는 private이지만 같은 패키지라 리플렉션 가능
}

 

Go (같은 패키지만 접근 가능):

// internal/usecase/auth/login.go
package auth

type LoginUseCase struct {
    userRepo     user.Repository  // 소문자 = private
    authProvider auth.Provider
}

// internal/usecase/auth/login_test.go
package auth  // 같은 패키지여야 접근 가능!

func TestLogin(t *testing.T) {
    uc := &LoginUseCase{
        userRepo: mockRepo,  // ✅ 같은 패키지라 접근 가능
    }
}

 

만약 테스트를 다른 디렉토리에 둔다면:

// internal/usecase/auth/login.go
package auth

// internal/usecase/auth_test/login_test.go  (다른 디렉토리)
package auth_test  // 다른 패키지가 됨

func TestLogin(t *testing.T) {
    uc := &auth.LoginUseCase{
        userRepo: mockRepo,  // ❌ 컴파일 에러! (private 접근 불가)
    }
}

이유 3: 빌드 시스템의 단순함

Java (명시적 설정 필요):

<!-- pom.xml: 200+ 줄 -->
<project>
    <build>
        <sourceDirectory>src/main/java</sourceDirectory>
        <testSourceDirectory>src/test/java</testSourceDirectory>
        <resources>
            <resource>
                <directory>src/main/resources</directory>
            </resource>
        </resources>
        <testResources>
            <testResource>
                <directory>src/test/resources</directory>
            </testResource>
        </testResources>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <configuration>
                    <includes>
                        <include>**/*Test.java</include>
                    </includes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Go (설정 파일 0개):

# 그냥 실행
go test ./...

# *_test.go 파일 → 테스트로 자동 인식
# 빌드 시 자동으로 제외
# 끝!

Go 컴파일러는 *_test.go 패턴을 보면:

  1. 테스트 파일로 인식
  2. go build 시 자동 제외
  3. go test 시만 컴파일

설정이 불필요합니다. 컨벤션이 곧 설정입니다.

실전 비교: 같은 기능 테스트하기

요구사항: LoginUseCase 단위 테스트

Spring 버전:

// src/main/java/com/example/usecase/LoginUseCase.java
package com.example.usecase;

@Service
public class LoginUseCase {
    @Autowired
    private UserRepository userRepository;

    @Autowired
    private AuthProvider authProvider;

    @Autowired
    private JwtService jwtService;

    public LoginResult execute(String email, String password) {
        // 1. Authenticate
        UserInfo userInfo = authProvider.authenticate(email, password);

        // 2. Find or create user
        User user = userRepository.findByEmail(email)
            .orElseGet(() -> createUser(userInfo));

        // 3. Generate token
        String token = jwtService.generateToken(user.getId(), user.getEmail());

        return new LoginResult(token, user);
    }
}

// src/test/java/com/example/usecase/LoginUseCaseTest.java
package com.example.usecase;

@ExtendWith(MockitoExtension.class)
class LoginUseCaseTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private AuthProvider authProvider;

    @Mock
    private JwtService jwtService;

    @InjectMocks
    private LoginUseCase loginUseCase;

    @Test
    @DisplayName("기존 사용자 로그인 성공 시 토큰 반환")
    void successfulLoginReturnsToken() {
        // Given
        String email = "test@example.com";
        String password = "password123";

        UserInfo userInfo = new UserInfo(email, "Test User");
        User existingUser = new User("user-123", email, "Test User");

        when(authProvider.authenticate(email, password)).thenReturn(userInfo);
        when(userRepository.findByEmail(email)).thenReturn(Optional.of(existingUser));
        when(jwtService.generateToken("user-123", email)).thenReturn("jwt-token-123");

        // When
        LoginResult result = loginUseCase.execute(email, password);

        // Then
        assertThat(result.getToken()).isEqualTo("jwt-token-123");
        assertThat(result.getUser().getId()).isEqualTo("user-123");

        verify(authProvider).authenticate(email, password);
        verify(userRepository).findByEmail(email);
        verify(jwtService).generateToken("user-123", email);
    }
}

 

Go 버전:

// internal/usecase/auth/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) {
    // 1. Authenticate
    userInfo, err := uc.authProvider.Authenticate(ctx, email, password)
    if err != nil {
        return nil, err
    }

    // 2. Find or create user
    existingUser, err := uc.userRepo.FindByEmail(ctx, email)
    if err != nil && err != errors.ErrUserNotFound {
        return nil, err
    }

    var u *user.User
    if existingUser == nil {
        newUser := &user.User{
            Email: userInfo.Email,
            Name:  userInfo.Name,
        }
        if err := uc.userRepo.Create(ctx, newUser); err != nil {
            return nil, err
        }
        u = newUser
    } else {
        u = existingUser
    }

    // 3. Generate token
    token, err := uc.jwtService.GenerateToken(u.ID, u.Email, u.Provider)
    if err != nil {
        return nil, err
    }

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

// internal/usecase/auth/login_test.go (같은 디렉토리!)
package auth

import (
    "context"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func TestLoginUseCase_Execute(t *testing.T) {
    t.Run("successful login with existing user returns user and token", func(t *testing.T) {
        // Given
        ctx := context.Background()
        email := "test@example.com"
        password := "password123"

        mockUserRepo := new(MockUserRepository)
        mockAuthProvider := new(MockAuthProvider)
        mockJWT := new(MockJWTService)

        existingUser := &user.User{
            ID:    "user-123",
            Email: email,
            Name:  "Test User",
        }

        authInfo := &auth.UserInfo{Email: email, Name: "Test User"}

        mockAuthProvider.On("Authenticate", ctx, email, password).Return(authInfo, nil)
        mockUserRepo.On("FindByEmail", ctx, email).Return(existingUser, nil)
        mockJWT.On("GenerateToken", existingUser.ID, existingUser.Email, existingUser.Provider).
            Return("jwt-token-123", nil)

        uc := NewLoginUseCase(mockUserRepo, mockAuthProvider, mockJWT)

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

        // Then
        assert.NoError(t, err)
        assert.NotNil(t, result)
        assert.Equal(t, "jwt-token-123", result.Token)
        assert.Equal(t, existingUser, result.User)

        mockAuthProvider.AssertExpectations(t)
        mockUserRepo.AssertExpectations(t)
        mockJWT.AssertExpectations(t)
    })
}

 

핵심 차이점:

측면 Spring  
위치 다른 디렉토리 (src/test) 같은 디렉토리
Mock 위치 같은 테스트 파일 mocks_test.go 분리
설정 @ExtendWith, @Mock 없음 (명시적 생성)
Given-When-Then 주석 t.Run() 이름

 

파일이 많아지면? 관리 팁

문제: 파일 목록이 2배가 됨

$ ls internal/usecase/auth/
interface.go
login.go
login_test.go
logout.go
logout_test.go
mocks_test.go
refresh.go
refresh_test.go

8개 파일이 한 디렉토리에...

해결책 1: IDE 필터링

VSCode 설정 (.vscode/settings.json):

{
  "files.exclude": {
    "**/*_test.go": false  // false: 보이기, true: 숨기기
  },
  "explorer.fileNesting.enabled": true,
  "explorer.fileNesting.patterns": {
    "*.go": "${capture}_test.go"  // login.go 아래에 login_test.go 중첩
  }
}

 

GoLand 설정:

  • Settings → File Nesting
  • Pattern: *.go*_test.go

해결책 2: 파일명 컨벤션

# 실제 코드
login.go           # 300 lines
logout.go          # 200 lines
refresh.go         # 150 lines

# 테스트 (더 길어도 OK!)
login_test.go      # 500 lines (6개 시나리오)
logout_test.go     # 300 lines
refresh_test.go    # 250 lines

# Mock은 하나로 통합
mocks_test.go      # 모든 테스트가 공유

해결책 3: Black-box vs White-box 테스트

Go는 두 가지 테스트 스타일을 지원합니다.

White-box (같은 패키지):

// login_test.go
package auth  // 같은 패키지

func TestLogin(t *testing.T) {
    uc := &LoginUseCase{
        userRepo: mockRepo,  // ✅ private 필드 접근 가능
    }
}

 

Black-box (다른 패키지):

// login_test.go
package auth_test  // _test 접미사

import "example.com/myapp/internal/usecase/auth"

func TestLogin(t *testing.T) {
    uc := auth.NewLoginUseCase(mockRepo, mockAuth, mockJWT)
    // ✅ Public API만 테스트 (외부 사용자 관점)
}

Black-box 테스트는 별도 디렉토리처럼 보이지만 같은 디렉토리입니다!

장단점 최종 정리

Spring 방식의 장단점

장점:

  • ✅ 시각적 명확성 (코드와 테스트 물리적 분리)
  • ✅ 대규모 팀에 유리 (테스트 팀 독립 작업)
  • ✅ 리소스 분리 용이 (application.yml vs application-test.yml)
  • ✅ 초보자 친화적

단점:

  • ❌ 디렉토리 구조 중복 (src/main과 src/test가 미러링)
  • ❌ 파일 전환 비용 (다른 디렉토리 트리 탐색)
  • ❌ 설정 파일 복잡 (Maven/Gradle 200+ 줄)
  • ❌ IDE 단축키 의존 (Ctrl+Shift+T 필수)

Go 방식의 장단점

장점:

  • ✅ 파일 전환 불필요 (코드와 테스트 바로 옆)
  • ✅ 설정 Zero (컨벤션이 곧 설정)
  • ✅ Private 멤버 테스트 용이
  • ✅ 패키지 응집도 높음 (테스트도 패키지의 일부)

단점:

  • ❌ 파일 목록 길어짐 (파일 개수 2배)
  • ❌ 시각적 혼란 (초보자)
  • ❌ 테스트만 보기 어려움 (각 디렉토리 탐색 필요)

MSA 전환 시 진가를 발휘

우리 프로젝트처럼 모놀리스 → MSA 전환을 고려한다면, Go 방식이 유리합니다.

Spring 방식:

monolith/
├── src/main/java/com/example/
│   ├── auth/
│   └── user/
└── src/test/java/com/example/
    ├── auth/
    └── user/

→ MSA 전환 시 코드와 테스트를 따로 복사해야 함

Go 방식:

monolith/
├── internal/usecase/auth/
│   ├── login.go
│   └── login_test.go
└── internal/usecase/user/
    ├── get_user.go
    └── get_user_test.go

→ MSA 전환 시 디렉토리 통째로 복사

패키지 = 서비스 단위이므로, 테스트도 함께 이동합니다.

# auth-service 생성
cp -r internal/usecase/auth auth-service/
# ← 코드와 테스트가 함께 복사됨!

실전 워크플로우

개발 시

# 1. 기능 구현
vim internal/usecase/auth/login.go

# 2. 바로 옆에서 테스트 작성 (파일 전환 불필요)
vim internal/usecase/auth/login_test.go

# 3. 즉시 실행
go test ./internal/usecase/auth/

CI/CD

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-go@v4

      - name: Run tests
        run: go test ./... -coverprofile=coverage.out

      - name: Check coverage
        run: go tool cover -func=coverage.out

별도 설정 없이 모든 테스트 자동 실행.

마치며: 관점의 전환

Spring Boot에서 Go 언어로 넘어올 때 가장 어려운 것은 기술이 아니라 관점의 전환입니다.

Spring의 철학:

  • "Convention over Configuration" (관례가 설정을 이긴다)
  • "물리적 분리가 논리적 분리" (src/main vs src/test)

Go의 철학:

  • "Explicit over Implicit" (명시적이 암묵적보다 낫다)
  • "패키지가 곧 응집 단위" (코드+테스트+문서 함께)

처음 3주는 불편했습니다.

  • "왜 테스트가 코드 옆에?"
  • "파일 찾기 힘들어!"
  • "지저분해 보여..."

하지만 3개월 후:

  • "코드 보다가 바로 테스트 확인, 편하네?"
  • "패키지 단위로 독립적이라 MSA 전환이 쉽네!"
  • "설정 파일 없어서 빌드 빠르다!"

Go의 방식은 처음엔 낯설지만, 곧 익숙해집니다.

그리고 익숙해지면, Spring으로 돌아가기 싫어집니다.

Go는 Spring과 다른 철학을 가진 언어입니다.

Spring의 편리함을 기대하면 실망합니다.
하지만 Go의 철학을 받아들이면,
더 명시적이고, 더 빠르고, 더 유지보수하기 쉬운 코드를 만들 수 있습니다.

 

반응형

댓글