[Spring] 김영한 스프링 입문_Section 3

dO_the_Jeegu·2023년 2월 4일

▶ 회원 관리 예제

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

1. 비즈니스 요구사항

<요구사항>

  • 데이터 : 회원ID, 이름
  • 기능 : 회원 등록, 조회
  • 아직 데이터 저장소가 선정되지 않음
    => DB가 선택되지 않은 상황이라고 가정
    => Interface로 설계

<일반적인 웹 애플리케이션 계층 구조>

  • 컨트롤러 : 웹 MVC의 컨트롤러 역할
  • 서비스 : 핵심 비즈니스 로직 구현
    예) 회원은 중복 가입이 안됨...등
  • 리포지토리 : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
  • 도메인 : 비즈니스 도메인 객체
    예) 회원, 주문, 쿠폰 등등 주로 데이터베이스에 저장하고 관리됨

<클래스 의존관계>

  • 아직 데이터 저장소가 선정되지 않아서 우선 인터페이스로 설계하여 구현 클래스를 변경할 수 있도록 함
  • 데이터 저장소는 RDB, NoSQL 등 다양한 저장소를 고민중인 상황으로 가정
  • 개발을 진행하기 위해 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용

2. 회원 도메인과 리포지토리 만들기

(1) 회원 객체 - Member Class

package hello.hellospring.domain;

public class Member {

    private Long id; //아이디 식별자(데이터 구분을 위해 시스템이 정하는 임의의 값)
    private String name; //이름(회원이 직접 입력한 값)

    //getter, setter 효율에 대한 얘기가 많은데 일단 쉬운 문제기 때문에 단순하게 사용
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

(2) 회원 리포지토리 인터페이스 - MemberRepository Interface

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

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

public interface MemberRepository {
    Member save(Member member); //회원을 저장소에 저장
    //Optional : NPE(NullPointerException) 처리를 도와주는 Wrapper Class
    Optional<Member> findById(Long id); //id로 저장소에서 회원 찾기
    Optional<Member> findByName(String name); //name으로 저장소에서 회원 찾기
    List<Member> findAll(); //지금까지 저장된 모든 회원 리스트를  반환
}

(3) 회원 리포지터리 메모리 구현체 - MemoryMemberRepository

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import java.util.*;

public class MemoryMemberRepository implements MemberRepository{ //구현체

    //원래는 동시성 문제가 있을 수 있어서 공유되는 변수일 때는 ConcurrentHashMap을 써야하지만
    //간단한 예제이므로 HashMap으로 설정
    private static Map<Long, Member> store = new HashMap<>();
    //실무에서는 동시성 문제로 AtomicLong() Wrapping 클래스를 사용
    private static long sequence = 0L; //키 값을 생성

    @Override
    public Member save(Member member) {
        member.setId(++sequence); //id 셋팅
        store.put(member.getId(), member);
        return member;
    }

    @Override
    public Optional<Member> findById(Long id) {
        //결과가 없을(null) 수 있으니 Optional로 감싸기
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public Optional<Member> findByName(String name) {
        return store.values().stream()
                .filter(member -> member.getName().equals(name))
                .findAny(); //member.getName()이 파라미터로 넘어온 name과 같은 경우 반환
    } //끝까지 찾았는데 없으면 Optional에 null이 포함돼서 반환

    @Override
    public List<Member> findAll() {
        return new ArrayList<>(store.values()); //store Map의 value값(=Memeber) 반환
    }
    
    public void clearStore() { //테스트 케이스에서 사용(후술)
        store.clear();
    }
}

3. 회원 리포지토리 테스트 케이스 작성

  • 테스트 케이스 : 내가 원하는대로 정상적으로 동작하는지 검증하는 것. Junit이라는 프라임워크로 테스트를 실행.

(1) findById 테스트

  • 테스트코드 ① - System.out.println()
package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

    @Test
    public void findById() { //동작하는지 확인
        Member member = new Member();
        member.setName("spring");

        repository.save(member); //member를 저장하면 id가 자동 셋팅

        Member result = repository.findById(member.getId()).get();

        System.out.println("result = " + (result == member));

    }
}
  • 결과
❓ 만약 같지 않다면


  • 테스트코드 ② - Assertions.assertEquals()
    • Assertions : org.junit.jupiter.api
package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

public class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

    @Test
    public void findById() { //동작하는지 확인
        Member member = new Member();
        member.setName("spring");

        repository.save(member); //member를 저장하면 id가 자동 셋팅

        Member result = repository.findById(member.getId()).get();

        Assertions.assertEquals(member, result); // member와 result가 똑같은지 확인
    }
}
  • 결과
❓ 만약 같지 않다면


  • 테스트코드 ③ - Assertions.assertThat()
    • Assertions : org.assertj.core.api
package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

public class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

    @Test
    public void findById() { //동작하는지 확인
        Member member = new Member();
        member.setName("spring");

        repository.save(member); //member를 저장하면 id가 자동 셋팅

        Member result = repository.findById(member.getId()).get();

        Assertions.assertThat(result).isEqualTo(member);
    }
}
  • 결과
❓ 만약 같지 않다면

❗ Assertions를 import static으로 설정하면 다음부터 생략 가능
package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*; //import static으로 설정

public class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

    @Test
    public void findById() {
        Member member = new Member();
        member.setName("spring");

        repository.save(member);

        Member result = repository.findById(member.getId()).get();

        assertThat(result).isEqualTo(member); //Assertions 생략 가능
    }
}

(2) findByName 테스트

    @Test
    public void findByName() {
        Member member1 = new Member();
        member1.setName("spring1");
        repository.save(member1);

        Member member2 = new Member();
        member2.setName("spring2");
        repository.save(member2);

        Member result = repository.findByName("spring1").get();

        assertThat(result).isEqualTo(member1);
    }
  • 결과
❗ 만약 같지 않다면

✋ 테스트 케이스는 클래스별/전체클래스 테스트가 가능하다
① 클래스별

② 전체 클래스


(3) findAll 테스트

    @Test
    public void findAll() {
        Member member1 = new Member();
        member1.setName("spring1");
        repository.save(member1);

        Member member2 = new Member();
        member2.setName("spring2");
        repository.save(member2);

        List<Member> result = repository.findAll();

        assertThat(result.size()).isEqualTo(2); //간단히 size만 검증
    }
  • 결과
❓ 만약 같지 않다면


(4) 고려사항 및 최종 코드

: Test Case의 모든 class를 실행해보면 오류가 뜸

  • 원인
    Test 순서는 보장이 안되기 때문에 모든 테스트는 순서와 상관 없이 메서드 별로 따로 동작하게 설계해야 함 => 순서에 의존적으로 설계하면 안됨

    위에서 확인할 수 있듯 findAll() -> findById() -> findByName() 순으로 테스트가 진행되었는데, findAll()에서 이미 spring1, spring2가 저장되었기 때문에 findByName()에서 이전에 저장한 객체가 호출되는 것.

❗ 하나의 Test가 끝날 때 마다 Repository를 깔끔하게 지워주는 코드를 넣어야 함

  • 해결법
    Step1. MemoryMemberRepository classclearStore() 메서드 추가
    public void clearStore() {
        store.clear();
    }

       Step2. MemoryMemberRepositoryTest classafterEach() 메서드 추가

    @AfterEach //각 메서드 실행이 끝날때 마다 동작을 하는 것. 일종의 call back 메서드
    public void afterEach() {
        repository.clearStore();
    }
  • 최종코드
package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.*;

public class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

    @AfterEach //메서드 실행이 끝날때 마다 동작을 하는 것. 일종의 call back 메서드
    public void afterEach() {
        repository.clearStore();
    }

    @Test
    public void findById() { //동작하는지 확인
        Member member = new Member();
        member.setName("spring");

        repository.save(member); //member를 저장하면 id가 자동 셋팅

        Member result = repository.findById(member.getId()).get();

        assertThat(result).isEqualTo(member);
    }

    @Test
    public void findByName() {
        Member member1 = new Member();
        member1.setName("spring1");
        repository.save(member1);

        Member member2 = new Member();
        member2.setName("spring2");
        repository.save(member2);

        Member result = repository.findByName("spring1").get();

        assertThat(result).isEqualTo(member1);
    }

    @Test
    public void findAll() {
        Member member1 = new Member();
        member1.setName("spring1");
        repository.save(member1);

        Member member2 = new Member();
        member2.setName("spring2");
        repository.save(member2);

        List<Member> result = repository.findAll();

        assertThat(result.size()).isEqualTo(2);
    }
}
  • 결과

4. 회원 서비스 개발

  • 회원가입 기본코드
package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;

import java.util.Optional;

public class MemberService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();

    public Long join(Member member) {//회원가입
        //같은 이름이 있는 중복 회원 허용X
        Optional<Member> result = memberRepository.findByName(member.getName());
        //null이 아닌 값이 있으면
        result.ifPresent(m -> {
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        });

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

    }
}

  • 회원가입 리팩토링 ① - Optional<Member> 생략
package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;

public class MemberService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();

    public Long join(Member member) {//회원가입
        //같은 이름이 있는 중복 회원 허용X]
        //findByName 결과가 Optional<Member>이므로 Optional 생략 가능
        memberRepository.findByName(member.getName())
                        .ifPresent(m -> {
                            throw new IllegalStateException("이미 존재하는 회원입니다");
                        });

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

  • 회원가입 리팩토링 ② - throw new 부분 따로 메서드로 빼기
package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.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) { //같은 이름이 있는 중복 회원 허용X
        //findByName 결과가 Optional<Member>이므로 Optional 생략 가능
        memberRepository.findByName(member.getName()) 
                        .ifPresent(m -> {
                            throw new IllegalStateException("이미 존재하는 회원입니다");
                        });
    }
}
❗ 리팩토링 단축키 : Ctrl + Alt + Shift + T

  • MemberService class 최종 코드
package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;

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

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) { //같은 이름이 있는 중복 회원 허용X
        //findByName() 결과가 Optional<Member>이므로 Optional 생략 가능
        memberRepository.findByName(member.getName())
                        .ifPresent(m -> {
                            throw new IllegalStateException("이미 존재하는 회원입니다");
                        });
    }

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

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

5. 회원 서비스 테스트

(1) 회원가입 테스트

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

class MemberServiceTest {

    MemberService memberService = new MemberService();

    @Test
    void 회원가입() { //test는 한글로 바꿔도 무관
        //given : 이런 상황이 주어져서(어떤 데이터를 기반으로 하는지)
        Member member = new Member();
        member.setName("hello");

        //when : 뭔가를 실행했을 때(어떤 것을 검증하는지)
        Long saveId = memberService.join(member); //return값은 저장한 아이디

        //then :  결과가 이렇게 나온다(어디가 검증부인지)
        Member findMember = memberService.findOne(saveId).get();
        assertThat(member.getName()).isEqualTo(findMember.getName());
    }
}

(2) 중복 회원 예외 테스트

  • 예외코드 ① - try-catch
    @Test
    public void 중복_회원_예외() {
        //given
        Member member1 = new Member();
        member1.setName("spring");

        Member member2 = new Member();
        member2.setName("spring"); //member1과 이름 동일

        //when
        memberService.join(member1);
        try {
            //spring 이름이 있는 상태에서 중복으로 가입했을 때
            memberService.join(member2);
            /*
            * 강의에서는 실제 발생할 예외가 발생하지 않을 경우를 방지하기 위해 사용했으나
            * 22년 기준 fail() 기능이 작동하지 않음
            * 여기서 어떻게 해야할지 추가로 찾아봐야 함
            */
            //fail();
        } catch (IllegalStateException e) {
            //실제 예외 발생 시 나오는 메세지가 MemberService Class에서 설정한 메세지대로 나오는지 확인
            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다");
        }
    }
  • 결과
❓ 만약 메세지가 같지 않다면


  • 예외코드 ② - assertThrows()
    @Test
    public void 중복_회원_예외() {
        //given
        Member member1 = new Member();
        member1.setName("spring");

        Member member2 = new Member();
        member2.setName("spring"); //member1과 이름 동일

        //when
        memberService.join(member1);
        IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
    }
  • 결과
❓ 만약 메세지가 같지 않다면


(3) 전체 테스트 코드

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*; //static 없으면 assertThrows 오류남

class MemberServiceTest {

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

    @AfterEach
    public void afterEach() { //memory clear
        memberRepository.clearStore();
    }

    @Test
    void 회원가입() { //test는 한글로 바꿔도 무관
        //given : 이런 상황이 주어져서(어떤 데이터를 기반으로 하는지)
        Member member = new Member();
        //afterEach 메서드 제대로 동작하는지 확인하기 위해 테스트 할 이름 통일
        member.setName("spring");

        //when : 뭔가를 실행했을 때(어떤 것을 검증하는지)
        Long saveId = memberService.join(member); //return값은 저장한 아이디

        //then :  결과가 이렇게 나온다(어디가 검증부인지)
        Member findMember = memberService.findOne(saveId).get();
        assertThat(member.getName()).isEqualTo(findMember.getName());
    }

    @Test
    public void 중복_회원_예외() {
        //given
        Member member1 = new Member();
        member1.setName("spring");

        Member member2 = new Member();
        member2.setName("spring"); //member1과 이름 동일

        //when
        memberService.join(member1);
        IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
    }
}
  • 결과

(4) 고려사항 및 최종 코드

: 현재 MemberService class에 있는 memberRepositoryMemberServiceTest class에 있는 memberRepository객체는 각각 new로 생성되어 서로 다른 객체.

  • MemberService classmemberRepository

  • MemberServiceTest classmemberRepository

  지금은 MemoryMemberRepository classstatic으로 선언해서 문제가 없지만 static이 빠지면 문제가 생기며, static이 있다고 해도 굳이 다른 객체로 쓸 이유가 없음. 애초에 같은 객체로 테스트를 해야하기 때문에 같은 인스턴스를 쓰게끔 설계해야 함.

  • 해결법 : MemberService class에서 직접 객체를 생성하지 않고 외부에서 넣어주도록 바꿈
    => DI(Dependency Injection) 의존성 주입

    Step 1. MemberService classmemberRepository 수정
    public class MemberService { //비즈니스 처리
    
        private final MemberRepository memberRepository;
    
        public MemberService(MemberRepository memberRepository) { //DI(Dependency Injection) 의존성 주입
            this.memberRepository = memberRepository;
        }

    Step 2. MemberServiceTest class에 있는 memberRepository 수정
    class MemberServiceTest {
    
        MemberService memberService;
        MemoryMemberRepository memberRepository;
    
        //동작 하기 전에 넣어줘야 함
        @BeforeEach
        public void beforeEach() { //각 테스트 실행하기 전에 객체를 각각 생성
            memberRepository = new MemoryMemberRepository();
            memberService = new MemberService(memberRepository);
        }
  • 최종코드

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

class MemberServiceTest {

    MemberService memberService;
    MemoryMemberRepository memberRepository;

    //동작 하기 전에 넣어줘야 함
    @BeforeEach
    public void beforeEach() { //각 테스트 실행하기 전에 객체를 각각 생성
        memberRepository = new MemoryMemberRepository();
        memberService = new MemberService(memberRepository);
    }

    @AfterEach
    public void afterEach() { //memory clear
        memberRepository.clearStore();
    }

    @Test
    void 회원가입() { //test는 한글로 바꿔도 무관
        //given : 이런 상황이 주어져서(어떤 데이터를 기반으로 하는지)
        Member member = new Member();
        //afterEach 메서드 제대로 동작하는지 확인하기 위해 테스트 할 이름 통일
        member.setName("spring");

        //when : 뭔가를 실행했을 때(어떤 것을 검증하는지)
        Long saveId = memberService.join(member); //return값은 저장한 아이디

        //then :  결과가 이렇게 나온다(어디가 검증부인지)
        Member findMember = memberService.findOne(saveId).get();
        assertThat(member.getName()).isEqualTo(findMember.getName());
    }

    @Test
    public void 중복_회원_예외() {
        //given
        Member member1 = new Member();
        member1.setName("spring");

        Member member2 = new Member();
        member2.setName("spring"); //member1과 이름 동일

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


❗ Test Class 자동 만들기

Step 1. 해당 class에서 단축키 Ctrl + Shift + T

Step 2. 생성

profile
오지는 갓생 살기

0개의 댓글