티스토리 뷰

728x90
반응형

스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - 김영환 인프런 강의 참고

이전 포스팅 : 2022.12.28 - [프로그래밍 언어 공부/Java] - [Spring] 입문 - 백엔드 개발 흐름_1 익힉기(도메인/리포지토리/서비스/테스트 케이스)

이전 포스팅에 이어서 이번에는 회원 관리 예제의 서비스 로직을 구현해 보겠다.

  1. 비즈니스 요구사항 정리
  2. 회원 도메인과 리포티토리 만들기
  3. 회원 리포지토리 테스트 케이스 작성
  4. 회원 서비스 개발
  5. 회원 서비스 테스트

 

4. 회원 서비스 개발

서비스 클래스는 프로그램의 비즈니스 로직이 구현되는 곳이다.

/service/MemberService.java

package com.example.practice.service;

import java.util.List;
import java.util.Optional;

import com.example.practice.domain.Member;
import com.example.practice.repository.MemberRepository;
import com.example.practice.repository.MemoryMemberRepository;

public class MemberService {
    
    private final MemberRepository memberRepository = new MemoryMemberRepository(); 
    
    /**
     * 회원가입
     */
    public Long join(Member member){

        validateDuplicateMember(member);
        memberRepository.save(member);
        return member.getId();
    }

    private void validateDuplicateMember(Member member){
        memberRepository.findByName(member.getName())
            .ifPresent(m -> {
                throw new IllegalStateException("이미 존재하는 회원입니다.");
            });
    }

    /**
     * 전체 회원 조회
     */
    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

    /**
     * 회원 한 명 조회
     */
    public Optional<Member> findOne(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

어찌보면 리포지토리 로직과 유사하긴 하지만, 메서드 네임이 좀 더 클라이언트에 친화적인 비즈니스 용어다.

코드를 보면 우선 저장소인 MemberRepository는 함부로 수정되면 안되기 때문에 해당 참조 변수를 final로 선언했다.

비즈니스 요구사항에 따라 회원가입과 조회 2가지 로직을 구현했으며, 조회에는 전체 회원 조회와 한 명만을 조회하는 2개의 로직으로 나눴다.

추가적으로 회원가입 시 중복 회원 가입을 방지하기 위해 간단한 검증 함수를 만들어 사용했다.

  • validateDuplicateMember

이 함수는 회원가입 하려는 회원의 이름이 저장소 안에 이미 있으면 IllegalStateException라는 예외를 던진다. 여기서 ifPresent 메서드는 Optional 클래스 내에 정의된 인스턴스 메서드로, Optional 객체가 감싸고 있는 값이 존재하면 대입된 람다식이 실행되고 없으면(null이면) 넘어간다.

memberRepository.findByName(member.getName())Optional 객체를 반환하기 때문에, 같은 이름의 회원이 저장소에 있다면 해당 Member 객체가 Optional에 감싸져서 반환될 것이다. 만약 없으면 null이 감써져서 반환되어 ifPresent가 실행되지 않고 넘어갈 것이다.

5. 회원 서비스 테스트

리포지토리와 마찬가지로 구현한 서비스 클래스 역시 테스트를 통해 검증해야 한다.

/test/java/…/service/MemberServiceTest.java

package com.example.practice.service;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.util.List;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import com.example.practice.domain.Member;
import com.example.practice.repository.MemoryMemberRepository;

public class MemberServiceTest {
    
    MemberService memberService = new MemberService();
    MemoryMemberRepository memberRepository = new MemoryMemberRepository();

    @AfterEach
    public void AfterEach() {
        memberRepository.clearStore();
    }

    @Test
    public void 회원가입() throws Exception {

        //Given
        Member member = new Member();
        member.setName("hello");
        
        //When
        Long saveId = memberService.join(member);

        //Then
        Member findMember = memberRepository.findById(saveId).get();
        assertEquals(member.getName(), findMember.getName());

    }

    @Test
    public void 중복_회원_예외() throws Exception {

        //Given
        Member member1 = new Member();
        member1.setName("spring");

        Member member2 = new Member();
        member2.setName("spring");

        //When
        memberService.join(member1);

        //Then
        IllegalStateException e = assertThrows(IllegalStateException.class,
            () -> memberService.join(member2));

        Assertions.assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
    }

    @Test
    public void 전체_회원_조회() {

        //Given
        Member member1 = new Member();
        member1.setName("spring1");

        Member member2 = new Member();
        member2.setName("spring2");
        
        memberService.join(member1);
        memberService.join(member2);
        
        //When
        List<Member> result = memberService.findMembers();

        //Then
        Assertions.assertThat(result.size()).isEqualTo(2);

    }

    @Test
    public void 회원_한명_조회() {

        //Given
        Member member1 = new Member();
        member1.setName("spring1");

        Long memberId = memberService.join(member1);
        
        //When
        Member result = memberService.findOne(memberId).get();

        //Then
        Assertions.assertThat(result).isEqualTo(member1);
    }
}

리포지토리 테스트 때와 동일하게 서비스 클래스에 구현된 메서드들을 Assertions와 JUnit 라이브러리를 사용해 간단하게 테스트하도록 구현했다. 이때 테스트 메서드 명을 한글로 했는데, 실제 테스트 코드같은 경우에는 빌드 시 어차피 제외되기 때문에, 개발자들의 편의상 한글로 메서드 명을 작성하는 경우가 있다고 한다.

회원가입같은 경우에는 중복 회원을 검증하는 로직이 있기 때문에, 의도적으로 같은 이름을 가진 회원이 가입하는 상황을 만들어 예외도 테스트하는 것이 좋다.

assertThrows는 첫 번째 매개변수 값으로 대입된 예외 클래스가 두 번째 매개변수 값으로 대입된 람다식에서 발생한 예외와 같은 지를 검사하는 메서드다. 이 메서드는 예외를 반환하기 때문에 이를 사용해 예외 메세지도 검증할 수 있다.

생각해 볼 문제

위 테스트 예제에서 생각해볼 문제가 하나 있다. 테스트 클래스의 인스턴스 변수를 2개 선언했다.

  • MemberService memberService = new MemberService();
  • MemoryMemberRepository memberRepository = new MemoryMemberRepository();

MemberServiceTest 클래스는 테스트 시 새로운 서비스와 리포지토리 인스턴스를 생성하겠다는 의미다. 그런데 위로 올라가서 MemberService 클래스를 보면 여기서도 새로운 MemoryMemberRepository를 생성하고 있다.

  • private final MemberRepository memberRepository = new MemoryMemberRepository();

즉, 테스트 코드를 실행하면 MemberService memberService = new MemberService();에 의해 서비스 인스턴스를 생성 시 내부적으로 리포지토리를 하나 생성한다. 그리고 MemoryMemberRepository memberRepository = new MemoryMemberRepository(); 가 실행되면서 새로운 리포지토리를 또 하나 생성해버린다.

테스트에서 사용되고 있는 리포지토리는 서비스 클래스 내에 생성된 리포지토리 인스턴스인데, 개발자 입장에서 테스트 코드에 인스턴스 메서드를 사용해야 하기 때문에 어쩔 수 없이 새로운 리포지토리 인스턴스를 다시 생성해 사용하고 있는 것이다.

그렇다고 MemberRepository memberRepositorypublic으로 선언하는 것은 가장 기본적인 캡슐화를 어기는 짓이다.

그럼에도 오류가 나지 않은 것은 MemoryMemberRepository 내에서 저장소로 사용되고 있는 Map 인스턴스를 static 변수(클래스 변수)로 선언해놨기 때문에 모든 MemoryMemberRepository 인스턴스가 같은 Map 인스턴스를 공유하고 있기 때문이다.

  • private static Map<Long, Member> store = new HashMap<>();

그럼 실제로는 문제되지 않는 것이 아니냐 할 수 있는데, 그렇지 않다. 논리상으로도 다른 인스턴스를 가지고 테스트하는 것은 말이 안되고, 로직이 복잡해 지다 보면 다른 인스턴스를 사용함으로써 어떤 문제가 생길지 모른다. 때문에 이를 해결하기 위해서는 코드를 약간 수정해야 한다.

/service/MemberService.java

// ...
public class MemberService {
    
    // private final MemberRepository memberRepository = new MemoryMemberRepository();
    private final MemberRepository memberRepository;

    // DI를 위한 생성자
    public MemberService(MemberRepository memberRepository){
        this.memberRepository = memberRepository;
    }
//...

위와 같이 MemberService 클래스에서 MemberRepository 인스턴스를 직접 생성하는 것이 아니라 생성자를 통해 인스턴스를 주입받도록 한다. 이러한 방식을 Dependency Injection(DI)라고 한다.

테스트 코드 역시 약간의 수정이 필요하다.

/test/java/…/service/MemberServiceTest.java

// ...
public class MemberServiceTest {
    
    MemberService memberService;
    MemoryMemberRepository memberRepository;

    @BeforeEach
    public void BeforeEach() {
        memberRepository = new MemoryMemberRepository();
        memberService = new MemberService(memberRepository);
    }
// ...

@BeforeEach@AfterEach와 반대 개념인 어노테이션이다. 각각의 테스트 메서드 실행 전에 구현한 로직을 실행시켜 준다.

이로써 같은 리포지토리 인스턴스로 테스트를 수행할 수 있게 됐으며, 각 테스트를 좀 더 독립적으로 수행할 수 있다.

DI에 대한 자세한 내용은 다음 포스팅에서 다루겠다.

 

References

728x90
반응형
댓글