1. 들어가며: 도메인에 생명 불어넣기
지난 포스팅에서 User(회원)와 Account(계좌)라는 뼈대(Entity)를 만들고, 이를 DB와 연결하는 창고지기(Repository)까지 고용했습니다. 하지만 아직 이들은 가만히 있을 뿐, 아무런 일도 하지 못합니다.
이번 포스팅에서는 실제 회원가입, 로그인 같은 핵심 기능을 수행하는 **Service 계층(Business Logic)**을 구현하고, 이를 검증하는 통합 테스트 과정을 기록합니다. 특히 이 과정에서 고민했던 아키텍처 구조와 테스트 전략에 대해 깊이 있게 다뤄봅니다.
2. Service 계층 구현 (UserService)
서비스 계층은 컨트롤러의 요청을 받아 트랜잭션을 관리하고, 도메인 객체들의 순서를 조정하는 "사령관" 역할을 합니다.
2-1. 의존성 주입과 트랜잭션 관리
@Service
@Transactional(readOnly = true) // (1) 읽기 전용 모드 (성능 최적화)
@RequiredArgsConstructor // (2) 생성자 주입 (Lombok)
public class UserService {
private final UserRepository userRepository;
private final AccountRepository accountRepository;
// ... 로직 구현 ...
}
💡 기술적 포인트
- (1) @Transactional(readOnly = true): 클래스 레벨에는 읽기 전용 트랜잭션을 걸어 조회 성능을 높였습니다. 데이터 변경이 필요한 메서드(join)에만 따로 @Transactional을 붙여 덮어쓰는 전략을 사용했습니다.
- (2) 생성자 주입 (DI): 필드 주입(@Autowired) 대신 생성자 주입을 사용했습니다. Lombok의 @RequiredArgsConstructor를 사용하면 final이 붙은 필드의 생성자를 자동으로 만들어주어 코드가 간결해지고, 테스트 시 가짜 객체(Mock)를 주입하기 쉬워집니다.
2-2. 회원가입 로직 (원자성 보장)
이번 프로젝트의 핵심 규칙은 **"회원은 가입과 동시에 1개의 주식 계좌를 갖는다"**입니다.
@Transactional // 쓰기 권한 부여
public Long join(String email, String name, String password) {
// 1. 중복 검사
if (userRepository.findByEmail(email).isPresent()) {
throw new IllegalStateException("이미 존재하는 회원입니다.");
}
// 2. 회원 저장
User user = new User(email, password, name);
userRepository.save(user);
// 3. 계좌 자동 생성 및 연결 (핵심!)
Account account = new Account(user);
accountRepository.save(account);
return user.getId();
}
🤔 왜 @Transactional이 필수인가? 만약 User는 저장됐는데, 모종의 이유로 Account 저장에 실패한다면? DB에는 **"계좌 없는 유령 회원"**이 남게 됩니다. 이를 방지하기 위해 두 작업을 하나의 트랜잭션으로 묶어, 하나라도 실패하면 전체 롤백(All or Nothing) 되도록 구현했습니다.
3. JPA 쿼리 메서드 활용 (Refactoring)
초기에는 계좌를 찾을 때 findAll()로 다 가져온 뒤 Java Stream으로 필터링하려 했습니다. 하지만 이는 데이터가 많아질수록 성능이 급격히 저하되는 방식입니다.
Spring Data JPA의 쿼리 메서드(Query Method) 기능을 활용해 리포지토리에 메서드 한 줄을 추가함으로써 이를 해결했습니다.
// AccountRepository.java
public interface AccountRepository extends JpaRepository<Account, Long> {
// Select * From account Where user_id = ? 쿼리가 자동 생성됨
Optional<Account> findByUserId(Long userId);
}
이렇게 하면 DB 레벨에서 WHERE 절을 통해 데이터를 필터링해서 가져오므로 성능과 가독성 모두를 잡을 수 있습니다.
4. 서비스 통합 테스트 (Integration Test)
백엔드 개발의 꽃은 테스트입니다. 실제 DB(Docker MySQL)와 연동하여 로직이 정상 동작하는지 검증했습니다.
@SpringBootTest
@Transactional // 테스트 종료 후 데이터 롤백 (DB 오염 방지)
class UserServiceTest {
@Autowired UserService userService;
@Autowired UserRepository userRepository;
@Autowired AccountRepository accountRepository;
@Test
@DisplayName("회원가입 시 유저와 계좌가 동시에 생성되어야 한다")
void joinTest() {
// Given
Long userId = userService.join("test@test.com", "테스터", "1234");
// When
User foundUser = userRepository.findById(userId).get();
// 리팩토링한 메서드 사용!
Account foundAccount = accountRepository.findByUserId(userId).get();
// Then (AssertJ)
assertThat(foundUser.getEmail()).isEqualTo("test@test.com");
assertThat(foundAccount.getBalance()).isEqualTo(0L);
}
@Test
@DisplayName("중복 가입 시 예외가 발생해야 한다")
void duplicateTest() {
// Given
userService.join("dup@test.com", "A", "1234");
// When & Then (JUnit 5)
assertThrows(IllegalStateException.class, () -> {
userService.join("dup@test.com", "B", "1234");
});
}
}
5. 기술적 회고 (Deep Dive) 💡
이번 단계를 진행하며 고민했던 아키텍처와 테스트 도구에 대해 정리합니다.
Q1. assertThat vs assertThrows?
테스트 코드에서 두 가지 검증 방식을 혼용했습니다.
- assertThrows (JUnit 5): "에러가 터져야 성공"인 로직(예: 중복 가입 시도)을 검증할 때 사용합니다.
- assertThat (AssertJ): 값의 일치 여부를 검증할 때 사용합니다. JUnit의 assertEquals보다 문장이 자연스럽고(가독성), 자동완성 지원이 강력하여 실무 표준으로 자리 잡고 있습니다.
Q2. 테스트 코드에서는 왜 @Autowired를 썼는가?
실무 코드(UserService)에서는 생성자 주입을 지향하지만, 테스트 코드에서는 편의성을 위해 필드 주입(@Autowired)을 사용했습니다. @SpringBootTest가 실행될 때 스프링 컨테이너에 등록된 빈들을 테스트 클래스로 **주입(Injection)**받기 위함입니다.
Q3. Repository 인터페이스는 어떻게 빈으로 등록되는가?
UserService에는 @Service가 있어 빈으로 등록되는 것이 명확하지만, UserRepository는 어노테이션이 없어 의문이 들었습니다. 확인 결과, Spring Data JPA는 JpaRepository를 상속받은 인터페이스를 발견하면, 실행 시점(Runtime)에 동적 프록시(Dynamic Proxy) 객체를 생성하여 자동으로 빈으로 등록해 준다는 사실을 알게 되었습니다. 따라서 별도의 @Repository 어노테이션이 필요 없습니다.
Q4. @SpringBootTest를 실행하면 빈(Bean)이 중복 등록되는 것 아닌가?
메인 애플리케이션(StockApplication)이 실행될 때 스프링 컨테이너가 생성되는데, 테스트 코드에서 @SpringBootTest를 붙이면 컨테이너가 또 생성되어 **충돌하거나 리소스 낭비(중복)**가 발생하지 않을까 우려되었습니다.
확인 결과, 이는 **'중복'이 아니라 '격리(Isolation)'**의 개념이었습니다.
- Main 실행: 실제 서비스용 ApplicationContext(컨테이너)가 생성됩니다.
- Test 실행: 이와는 완전히 별개로, 테스트만을 위한 **새로운 ApplicationContext**가 생성됩니다.
즉, 두 컨테이너는 서로 다른 메모리 공간을 사용하는 별개의 세상입니다. 덕분에 테스트 환경에서 DB를 초기화하거나 데이터를 조작해도, 실제 운영 중인 애플리케이션에는 전혀 영향을 주지 않는 안전한 테스트 환경이 보장된다는 것을 알게 되었습니다.
6. 마치며
이제 회원(User)과 계좌(Account) 시스템이 튼튼하게 구축되었습니다. 다음 포스팅에서는 이 프로젝트의 진짜 주인공인 **'주식 종목(Stock)'**을 생성하고, 실시간 시세 데이터를 다루는 과정을 기록해 보겠습니다.
'개발 공부 > 프로젝트' 카테고리의 다른 글
| [사이드 프로젝트] 가상 주식 거래소 만들기 (5) - 스케줄러를 이용한 데이터 자동화와 REST API 구현 (0) | 2025.12.02 |
|---|---|
| 남의 코드 복붙은 그만! 업비트 공식 문서 씹어먹기 (WebClient 연동) (0) | 2025.12.02 |
| [사이드 프로젝트] 가상 주식 거래소 만들기 (4) - WebClient를 활용한 실시간 시세 수집과 데이터 동기화 (0) | 2025.12.01 |
| [사이드 프로젝트] 가상 주식 거래소 만들기 (2) - 도메인 설계와 JPA Entity의 기술적 고민들 (0) | 2025.11.24 |
| [사이드 프로젝트] '가상 주식 거래소' 만들기 (1) - 프로젝트 선정 이유와 환경 설정 (0) | 2025.11.20 |