[사이드 프로젝트] 디지털 굿즈 플랫폼 개발기 #4 - Service 계층 구현과 Mockito 단위 테스트

지난 포스팅에서 Testcontainers를 이용해 데이터 접근 계층(Repository)의 신뢰성을 확보했다. 이제는 이 데이터를 가공하여 실제 비즈니스 로직을 수행하는 서비스(Service) 계층을 구현할 차례다.


1. DTO 설계: Entity를 밖으로 노출하지 마라

서비스 계층의 첫 단추는 데이터를 주고받을 그릇(DTO)을 만드는 것이다. Entity를 컨트롤러 파라미터로 직접 받으면, 원치 않는 필드가 변경되거나 스펙 변경 시 DB 구조까지 흔들리는 부작용이 있다.

Java 17의 record를 사용하여 불변(Immutable) DTO를 만들었다.

public record MemberSignupRequest(
        @NotBlank String email,
        @NotBlank String password,
        @NotBlank String nickname
) {
    // DTO -> Entity 변환 로직
    public User toEntity(String encodedPassword) {
        return User.builder()
                .email(this.email)
                .password(encodedPassword) // 암호화된 비밀번호 주입
                .nickname(this.nickname)
                .role(UserRole.USER)
                .build();
    }
}

toEntity 메서드에서 비밀번호를 평문으로 넣지 않고, encodedPassword를 인자로 받도록 설계했다. 암호화의 책임은 DTO가 아니라 서비스에게 있기 때문이다.


2. 커스텀 예외: "에러도 비즈니스다"

"이미 가입된 이메일입니다" 라는 상황은 시스템 오류가 아니라, 우리가 의도한 비즈니스 로직의 일부다. 이를 RuntimeException이나 IllegalArgumentException으로 퉁치지 않고, 명확한 의미를 부여하기 위해 커스텀 예외를 만들었다.

  • ErrorCode: 에러 메시지와 HTTP 상태 코드를 관리하는 Enum
  • BusinessException: 비즈니스 로직 전용 예외 클래스
if (userRepository.existsByEmail(email)) {
    // 500 에러가 아닌, 409 Conflict와 함께 명확한 메시지 전달
    throw new BusinessException(ErrorCode.DUPLICATE_EMAIL);
}

3. Service 구현: 비즈니스 로직의 사령탑

UserService는 트랜잭션을 관리하고, Repository와 도구(PasswordEncoder)를 조립하여 흐름을 제어한다.

import com.creators.photocard.global.exception.BusinessException;
import com.creators.photocard.global.exception.ErrorCode;
import com.creators.photocard.member.domain.User;
import com.creators.photocard.member.dto.MemberSignupRequest;
import com.creators.photocard.member.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    /**
     * 회원가입 비즈니스 로직
     */
    @Transactional
    public Long signup(MemberSignupRequest request){
        validateDuplicateEmail(request.email());

        String encodedPassword = passwordEncoder.encode(request.password());

        User newUser = request.toEntity(encodedPassword);

        User savedUser = userRepository.save(newUser);

        return savedUser.getId();
    }

    private void validateDuplicateEmail(String email) {
        if (userRepository.existsByEmail(email)) {
            // 아까 만든 커스텀 예외 사용!
            throw new BusinessException(ErrorCode.DUPLICATE_EMAIL);
        }
    }
}

여기서 PasswordEncoder는 인터페이스다. 서비스는 어떤 암호화 알고리즘(BCrypt, SHA...)을 쓰는지 알 필요가 없다. 이는 느슨한 결합(Loose Coupling)을 유지해 준다.


4. 테스트 전략의 변화: Testcontainers vs Mockito

지난번 Repository 테스트에서는 Testcontainers를 사용했다. DB와의 정합성이 중요했기 때문이다. 하지만 Service 테스트는 다르다.

  • 목적: "중복 이메일일 때 예외가 터지는가?", "비밀번호 암호화 메서드가 호출되는가?" (로직 검증)
  • 문제: Testcontainers를 쓰면 테스트 하나 돌리는 데 수 초가 걸린다.

그래서 이번엔 Mockito를 도입했다. DB도, 스프링도 띄우지 않고 가짜 객체(Mock)만으로 테스트한다.

@ExtendWith(MockitoExtension.class) // 스프링 X, Mockito 엔진 사용
class MemberServiceTest {

    @InjectMocks MemberService memberService; // 테스트 대상
    @Mock UserRepository userRepository;      // 가짜 객체
    @Mock PasswordEncoder passwordEncoder;    // 가짜 객체

    @Test
    void 회원가입_성공_테스트() {
        // given: 가짜들이 어떻게 행동할지 정의 (Stubbing)
        given(userRepository.existsByEmail(any())).willReturn(false);
        given(passwordEncoder.encode(any())).willReturn("encoded_pw");
        
        // when
        memberService.signup(request);

        // then: 리포지토리의 save가 1번 호출되었는지 검증
        verify(userRepository, times(1)).save(any());
    }
}

결과: 테스트 실행 시간이 5초 → 0.1초로 줄어들었다. 비즈니스 로직은 이렇게 가볍고 빠른 단위 테스트로 검증하는 것이 효율적이다.


5. 마무리는 Security Config

앱을 실행하니 PasswordEncoder 빈을 찾을 수 없다는 에러가 떴다. 인터페이스만 쓰고 구현체를 등록하지 않았기 때문이다. SecurityConfig를 작성하여 BCryptPasswordEncoder를 빈으로 등록하고, 회원가입 API 경로를 열어주며 설정을 마쳤다.


이렇게 구현은 마쳤지만 중간에 예외처리를 커스텀으로 만드는 부분과 mock을 이용해 테스트 코드를 만드는 부분은 연습이 더 필요할 것 같다. 아예 따로 해당 부분만 다루는 글을 작성해야 할 것 같다.

이제 Repository(데이터)Service(로직) 계층이 모두 완성되었다. 다음 단계는 이 기능을 외부 세상과 연결하는 Controller(API) 계층 구현이다. JSON으로 데이터를 주고받는 REST API를 설계하고, Postman으로 실제 회원가입 요청을 날려볼 것이다.