
"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
핵심 특징:
- 📁 물리적 완전 분리:
src/main과src/test - 🔄 논리적 동일: 패키지명은 동일 (
com.example.myapp.usecase) - 🔧 빌드 도구 관리: 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 패턴을 보면:
- 테스트 파일로 인식
go build시 자동 제외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.ymlvsapplication-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의 철학을 받아들이면,
더 명시적이고, 더 빠르고, 더 유지보수하기 쉬운 코드를 만들 수 있습니다.
댓글