[Spring] 김영한 스프링 강의 씹어먹기2

송병훈·2022년 9월 17일
post-thumbnail

강의 링크


회원 관리 예제


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

아직 DB가 미정인 상황이라 가정하고, 인터페이스로 구현클래스를 변경할 수 있도록 설계.
개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용.


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


1. Member.java 클래스 생성

이 클래스를 틀로 하여 수많은 Member 객체(인스턴스)들이 생성된다.

  • private로 id와 name을 생성한다.
    그 이유는 외부에서 함부로 변경하면 안 되는 값이기 때문이다.

  • getter, setter를 생성한다.
    그 이유는 get, set을 통해 id, name에 값을 넣거나 가져오도록 하기 위합이다.

package hello.hellospring.domain;

public class Member {

    private Long id;    // 시스템이 정하는 임의의 값
    private String name;    // 고객의 이름

    // ★★ getter setter 단축키 Alt + Insert
    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 인터페이스 생성

인터페이스는 이 프로젝트에서 사용할 메소드들을 간략하게 알려주는 역할을 한다.

  • 어떤 역할을 수행하는 메소드를 생성할 건지
  • 그 메소드의 이름은 무엇으로 할 건지
  • 이 메소드에는 어떤 매개변수가 들어가는지
  • 이 메소드는 데이터를 어떤 타입으로 반환하는지

인터페이스를 상속(implements)받는 클래스에서는
메소드들을 Override하여 비즈니스 로직을 구체화한다.

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

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

public interface MemberRepository {

    /** 회원을 저장하는 메소드 **/
    Member save(Member member);
    
    /** id 또는 name으로 회원정보를 가져오는 메소드 **/
    Optional<Member> findById(Long id);
    Optional<Member> findByName(String name);

    /** 회원정보 전체 List를 가져오는 메소드 **/
    List<Member> findAll();
}

Optional이란 무엇인가?

반환되는 값이 Null일 경우, Null을 그대로 반환하지 않고 Optional로 감싼다.
요즘에는 Optional로 데이터를 감싸는 방식을 선호한다.


3. MemoryMemberRepository 클래스 생성

MemberRepository 인터페이스를 상속받는 클래스를 생성한다.
인터페이스의 메소드를 Override 한다.
여기서부터 코드가 복잡해보이기 시작한다.
그러나 그리 복잡하지 않다.

복잡해 보이기만 할 뿐!!

package hello.hellospring.repository;

import hello.hellospring.domain.Member; // Member Class를 import했다.

import java.util.*;

// MemberRepository Interface를 구현할 클래스를 만들자
public class MemoryMemberRepository implements MemberRepository{    // Ait + Enter 로 Interface에 있는 모든 메소드들을 가져올 수 있다.

    // 데이터를 저장할 공간을 만들자
    private static Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L;

    @Override
    // 회원 저장 메소드
    public Member save(Member member) {     // member 객체 안에 name은 저장되어 넘어온 것이라 보면 된다.
        member.setId(++sequence);   // member 객체의 id에 시퀀스를 1증가시켜 저장
        store.put(member.getId(), member);
        // Map<Long, Member>타입의 Long자리에 1증가시켰던 시퀀스를,
        // Member자리에 member객체를 저장한다. (1증가된 시퀀스와, 원래 있던 이름이 있음)

        return member;  // 그리고 member 객체를 반환한다.
    }

    @Override
    // id로 회원 찾는 메서드 -> Optional<Member>으로 반환한다.
    public Optional<Member> findById(Long id) {
        return Optional.ofNullable(store.get(id));
        //Null이 반환될 가능성이 있으면 Optional.ofNullable()로 감싸준다
    }

    @Override
    // name으로 회원 찾는 메서드 -> Optional<Member>으로 반환한다.
    public Optional<Member> findByName(String name) {
        return store.values().stream().filter(member -> member.getName().equals(name))
                .findAny();
        // member객체에 있는 name이랑 매개변수로 넘어온 name이 같은지 확인하여
        // 일치하는 경우에만 필터링이 되고, 그 중에서 Member객체를 찾으면 반환이 된다.
        // 찾았는데 없으면 Optional에 null이 포함되어 반환된다.
    }

    @Override
    // 모든 회원 List를 조회하는 메소드 -> List<Member>로 반환한다.
    public List<Member> findAll() {
        return new ArrayList<>(store.values());
        // 리스트를 많이 쓴다. 위처럼 반환하면 된다.
        // values()는 member다.
    }

    // store객체를 비우도록 하는 메소드
    public void clearStore() {
        store.clear();
    }
} // 이제 이것이 잘 동작하는지 확인하기 위해 testcase를 작성한다.
  • public Member save(Member member) {}
    1. name이 저장되어있는 Member 객체 {id: , name: ???} 를 매개변수로 받는다.
    2. 시퀀스를 증가시켜 매개변수로 받은 member객체에 id를 저장한다. {id: 1, name: ???}
    1. Map<Long, Member> 타입으로 선언된 store변수의 Long자리에 id를, Member자리에 member객체를 넣는다.
    2. 그리고 member객체를 반환한다.
  • public Optional<Member\> findById(Long id) {}
    1. id를 매개변수로 받는다.
    2. 이 id값을 갖고있는 store변수의 Member객체를 받아온다.
    3. Optional로 반환한다.
  • public Optional<Member\> findByName(String name) {}
    1. name을 매개변수로 받는다.
    2. member객체에 있는 name이랑 매개변수로 넘어온 name이 같은지 확인하여
      일치하는 경우에만 필터링이 되고, 그 중에서 Member객체를 찾으면 반환이 된다.
    3. Optional로 반환한다.
  • public List<Member\> findAll() {}
    store에 저장되어 있는 데이터들을 ArrayList형식으로 반환한다.
  • public void clearStore() {}
    store변수에 저장되어있는 데이터를 비운다.


테스트케이스 작성


테스트 할 때 자바의 main메서드를 통해서 실행하거나, 웹 애플리케이션의 Controller를 통해서 테스트를 실행한다. 이러한 방법은 준비하고 실행하는데 오래 걸리고, 반복 실행하기 어렵고, 여러 테스트를 한 번에 실행하기 어렵다는 단점이 있다. 자바는 이런 문제를 보완하기 위해 JUnit이라는 프레임워크로 테스트를 진행한다.

MemoryMemberRepositoryTest 코드 1

package hello.hellospring.repository;

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

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

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

class MemoryMemberRepositoryTest {

    // MemoryMemberRepository 클래스 타입으로 repository 객체 생성
    MemoryMemberRepository repository = new MemoryMemberRepository();

    @AfterEach
    // 메소드가 끝날 때마다 동작하게 하는 어노테이션이다. 이것을 통해 객체를 비워주자.
    public void afterEach() {
        repository.clearStore();    // 객체를 비우는 메소드
    }

    @Test
    //Test 어노테이션을 붙이면 이제부터 실행할 수 있다.
    public void save() {
        Member member = new Member();   // Member 클래스타입으로 member 객체 생성
        member.setName("spring");   // member객체에 spring이라는 name값을 저장

        repository.save(member);
        // MemoryMemberRepository 클래스의 save()메소드를 통해, name이 저장되어 있는 Member타입의 객체를 매개변수로하여
        // id는 시퀀스를 따라 증가하여 id를 추가적으로 저장하고, Member타입으로 저장된 객체를 반환한다.
        // 현재 member 상태: {id:1, name:"spring"} -> repository객체에 저장됨

        Member result = repository.findById(member.getId()).get();
        //repository.findById은 id를 매개변수로 받아, Optional<Member>으로 반환한다.
        //get()함수를 이용해 Optional을 한 번 까서 Member 타입으로 저장했다.

//        System.out.println(result);
//        System.out.println(member);
//        System.out.println("result = " + (result == member));
        // 계속 이렇게 System.out.println으로 출력해서 볼수만은 없다.
        // 이것을 보완하기 위해 Assert라는 기능을 사용하자.

//        Assertions.assertEquals(member, result);  // member와 result 값을 비교함 // org.junit.jupiter.api.Assertions
        assertThat(member).isEqualTo(result);   // member와 result 값을 비교함 // org.assertj.core.api.Assertions.*
        // ★★ Alt+Enter로 static import하면 8행과 같은 코드가 생기고, Assertions를 입력하지 않아도 된다.

        //실무에서는 testcase가 막히면, 다음 단계로 못 넘어가게 막는다.
    }
  • MemoryMemberRepository repository = new MemoryMemberRepository();
    MemoryMemberRepository 클래스를 테스트하는 것이므로
    MemoryMemberRepository 타입의 변수를 생성한다.
  • @AfterEach은 메소드 하나하나 끝날 때마다 해당 메소드를 실행시키는 어노테이션이다.
    Test에서 객체를 비워주기 위해 MemoryMemberRepository 클래스에서 clearStore()메소드를 만들었었다. 이것을 사용하면 된다.
  • public void save() {}
  1. @Test 어노테이션이 붙어 테스트 코드 임을 나타낸다.
  2. member 인스턴스를 생성하고 "spring" 문자열을 인스턴스의 name에 저장한다.
    Member member = new Member();
    member.setName("spring");
  3. repository.save(member)
    MemoryMemberRepository클래스의 save() 메서드를 통해 name만 있는 member에 id도 대응시켜 저장한다.
  4. Member result = repository.findById(member.getId()).get();
    getter로 꺼내온 id를 매개변수로 findById() 메소드를 실행하면 Optional<Member>가 반환되는데,
    get()으로 Optional을 벗겨내어 Member 타입으로 반환된 것을 result에 저장한다.
    (매개변수로 사용된 id가 저장된 Member 객체가 반환되었다.)
  5. assertThat(member).isEqualTo(result)
    Member 타입의 두 인스턴스 member, result 가 같은지 확인하기 위해 "org.assertj.core.api.Assertions.*" 를
    static으로 import하여 Assertions 클래스의 assertThat() 메소드를 사용했다.
  6. member 인스턴스의 id가 저장된 Member객체를 반환하여 result에 저장한 것이므로 같다.
  7. 같다면 save()가 잘 된 것이므로 테스트 성공

MemoryMemberRepositoryTest 코드 2

    @Test
    public void findByName() {
        Member member1 = new Member();
        member1.setName("spring1");
        repository.save(member1);   
        // 시퀀스가 증가한 id, spring1인 name이 저장된 객체 member1가 repository객체에 저장된다.

        // ★★ Shift + F6: 중복되는 변수명 한 번에 고치기
        Member member2 = new Member();
        member2.setName("spring2");
        repository.save(member2);
        // 시퀀스가 증가한 id, spring2인 name이 저장된 객체 member2가 repository객체에 저장된다.

        Member result = repository.findByName("spring1").get();
        //repository.findByName은 name을 매개변수로 받아, Optional<Member>으로 반환한다.
        //get()함수를 이용해 Optional을 한 번 까서 Member 타입으로 저장했다.

        assertThat(result).isEqualTo(member1); // member1와 result 값을 비교함
    }

    @Test
    public void findAll() {
        Member member1 = new Member();
        member1.setName("spring1");
        repository.save(member1);
        // 시퀀스가 증가한 id, spring1인 name이 저장된 객체 member1가 repository객체에 저장된다.

        // ★★ Shift + F6: 중복되는 변수명 한 번에 고치기
        Member member2 = new Member();
        member2.setName("spring2");
        repository.save(member2);
        // 시퀀스가 증가한 id, spring2인 name이 저장된 객체 member2가 repository객체에 저장된다.

        List<Member> result = repository.findAll();
        // repository.findAll()메소드는 List<Member> 타입으로 반환한다.

        assertThat(result.size()).isEqualTo(2);
        // result의 개수만 보기위해 size()함수를 사용함
        // 여기서 repository에 저장된 객체는 member1, member2 뿐이므로 2개가 저장되어있다.

    }
  • public void findByName() {}
  1. @Test 어노테이션이 붙어 테스트 코드 임을 나타낸다.
  2. Member 타입의 인스턴스 두 개를 만들어, spring1, spring2 문자열을 각각의 name에 저장한다.
    그리고 save() 메소드를 통해 각각의 인스턴스에 id도 저장한다.
  3. Member result = repository.findByName("spring1").get();
    name을 통해 Optional<Member>를 반환시키는 findByName() 메소드에서
    spring1이 저장된 Member 객체를 찾아 반환시키는데,
    이것을 get()으로 벗겨내어 Member 객체 그대로 result에 저장한다.
  4. assertThat(result).isEqualTo(member1);
    Member 타입의 두 인스턴스 result, member1 가 같은지 확인하기 위해 "org.assertj.core.api.Assertions.*" 를
    static으로 import하여 Assertions 클래스의 assertThat() 메소드를 사용했다.
  5. 두 인스턴스가 같다면 findByName()이 잘 동작한 것이므로 테스트 성공.
  • public void findAll() {}
  1. @Test 어노테이션이 붙어 테스트 코드 임을 나타낸다.
  2. Member 타입의 인스턴스 두 개를 만들어 spring1, spring2 문자열을 각각의 name에 저장한다.
    그리고 save() 메소드를 통해 각각의 인스턴스에 id도 저장한다.
  3. List<Member> result = repository.findAll();
    findAll() 메소드를 실행하여 MemoryMemberRepository 클래스의 store에 저장되어 있는 Map 형식의 데이터들을
    ArrayList형식으로 반환하여 List 타입의 result에 저장한다.
  4. assertThat(result.size()).isEqualTo(2);
    현재 생성 및 저장된 객체는 member1, member2 두 개뿐이다. 그래서 List도 두 개밖에 없다.
    이 List의 개수를 나타내는 size()가 2와 같으므로 테스트 성공.

테스트에 대하여...

테스트는 순서 보장이 안 되기에
테스트 메소드 역시 순서에 상관없이 따로 동작하도록 설계해야 한다.
여기서는 findAll()먼저 실행이 되었는데, 여기서 이미 spring1, spring2가 name으로 repository에 저장되었다.
그래서 findByName() 할 때 다른 객체가 나오면서 에러가 발생했다.

그렇다면 @AfterEach을 통해서 각 테스트가 끝난 후에 객체를 비워줄 필요가 있다.
그러므로 MemoryMemberRepository 클래스에 객체를 비워주는 메소드를 
public void clearStore() {store.clear();}
만들어서 @AfterEach 안에 작성한다.

테스트 클래스를 먼저 작성하여 틀을 만든 후에
실제 비즈니스 로직 클래스를 작성하는 것을
"테스트 주도 개발 TDD" 라고 한다.


회원 서비스 개발


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 {    // join 같은 비즈니스에 가까운 용어를 써야 한다.
// ★★ Ctrl + Shift + T -> Create New Test: 테스트를 더욱 편하게 만들 수 있다.
//    private final MemberRepository memberRepository = new MemoryMemberRepository();
    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {   // ★★ Alt + Insert -> Constructor
        this.memberRepository = memberRepository;
        // memberRepository를 외부에서 받아서 객체가 생성되도록 한다. = DI(Dependency Injection)
    }

    /**
     * 회원가입
     */
    public Long join(Member member) {
        // 같은 이름의 중복 회원X
        validateDuplicateMember(member);    // 중복 회원 검증
        memberRepository.save(member);
        return member.getId();
    }

    private void validateDuplicateMember(Member member) {
        // ★★ 위에 적었다가, Ctrl+Alt+Shift+T 누르고 method 검색하여 Extract Method하면 드래그한 부분이 메소드로 빠져나온다.
        memberRepository.findByName(member.getName())
                .ifPresent(m -> {
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                 });
        // Optional로 반환되기에 바로 ifPresint()를 사용할 수 있다.
    }

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

    /**
     * 한 명의 회원 조회
     */
    public Optional<Member> findOne(Long memberId) {
    	return memberRepository.findById(memberId);
    }
    
} //class End
  • private final MemberRepository memberRepository;
    클래스타입의 객체변수명만 지정해놓고, new로 인스턴스를 생성하지는 않았다.
public MemberService(MemberRepository memberRepository) {
	this.memberRepository = memberRepository;
}

memberRepository를 외부에서 받아서 객체가 생성되도록 한다. = DI(Dependency Injection)


회원 서비스 테스트


MemberServiceTest 코드 1

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import org.assertj.core.api.Assertions;
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 클래스의 개체를 생성함
//    MemberService memberService = new MemberService();
    MemberService memberService;

    // 메소드 클린을 위해 MemoryMemberRepository 클래스의 객체를 생성하고 @AfterEach 사용함
//    MemoryMemberRepository memberRepository = new MemoryMemberRepository();
    MemoryMemberRepository memberRepository;

    @BeforeEach
    public void beforeEach() {  // 테스트를 실행할 때마다 새로운 객체를 생성해준다.
        memberRepository = new MemoryMemberRepository();
        // 여기서 생성된 MemoryMemberRepository 객체가 동일하게 사용된다.

        memberService = new MemberService(memberRepository);
    }

    @AfterEach  // 메소드가 끝날 때마다 동작하게 하는 어노테이션이다. 이것을 통해 객체를 비워주자.
    public void afterEach() {
        memberRepository.clearStore();    // 객체를 비우는 메소드 clearStore()
    }
MemberService memberService;
MemoryMemberRepository memberRepository;

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

MemberService, MemoryMemberRepository 클래스타입의 객체변수명만 지어놓은 상태에서
beforeEach() 메소드를 통해 매 테스트마다 memberRepository 객체를 생성하고,
(@BeforeEach는 메소드 시작 전마다 해당 메소드가 실행되도록 하는 어노테이션이다.)
이 객체를 MemberService()의 매개변수 삼아서 memberService 객체를 생성한다.

  • memberService 클래스에서는 DI로 memberRepository 객체를 만든다.
public class MemberService {

  private final MemberRepository memberRepository;

  public MemberService(MemberRepository memberRepository) {
      this.memberRepository = memberRepository;		// DI
  }
  ...
}

즉, "memberRepository클래스의 메소드를 사용할 수 있는 memberService 클래스타입의 객체"가
매 테스트 메소드마다 새로 생성되는 것이다.


MemberServiceTest 코드 2

    @Test
    void 회원가입() {   // 테스트코드는 실제 코드에 포함되지 않기에 메소드명을 한글로 적어도 된다.
        //given - 이러한 상황이 주어졌을 때
        Member member = new Member();   // Member 객체를 먼저 생성한다.
        member.setName("hello");        // Member 객체에 name을 저장한다.
        // member 상태: {id: , name:"hello"}

        //when - 이 기능이 실행되어
        Long saveId = memberService.join(member); // ★★ Ctrl+Alt+V로 변수 생성을 빠르게 할 수 있다.
        // join메소드: member객체에 저장된 name을 이용하여 시퀀스를 높인 id를 저장하고,
        // 저장된 id를 받아와 saveId에 저장한다.
        // member 상태: {id: 1, name:"hello"} (name을 통해 id를 저장)
        // saveId: 1

        //then - 결과가 어떻게 나오는지 테스트하여라
        Member findMember = memberService.findOne(saveId).get(); // ★★ Ctrl+Alt+V로 변수 생성을 빠르게 할 수 있다.
        // saveId를 매개변수로, findOne()메소드를 실행하여 Optional<Member>을 받아온다.
        // get()으로 Optional을 벗기고, Member 타입으로 가져와서 findMember에 저장한다. (id와 name 둘 다 있음)
        // findMember 상태: {id: 1, name:"hello"}  (id를 통해 Member 객체를 반환)

        assertThat(member.getName()).isEqualTo(findMember.getName()); // Alt+Enter로 static import
        // member 상태: {id: 1, name:"hello"}
        // findMember 상태: {id: 1, name:"hello"}
    }
  • given - 이러한 상황이 주어졌을 때
  • when - 이 기능이 실행되어
  • then - 결과가 어떻게 나오는지 테스트하여라

이 순서를 기반으로 테스트 코드를 작성하면 작성과 이해가 쉽다.


MemberServiceTest 코드 3

    @Test
    public void 중복_회원_예외(){     // 중복이 있을 때 예외가 발생하는지도 테스트해야 한다.
        //given - 이러한 상황이 주어졌을 때
        Member member1 = new Member();
        member1.setName("spring");  // Member 객체를 만들고, name을 저장

        Member member2 = new Member();
        member2.setName("spring");  // Member 객체를 만들고, name을 저장
        // 똑같은 이름으로 저장하여 중복을 발생시킴

        //when - 이 기능이 실행되어
        memberService.join(member1);
        // join메소드: member1객체에 저장된 name을 이용하여 시퀀스를 높인 id를 저장함

        IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2)); // Ctrl+Alt+V로 변수 생성을 빠르게 할 수 있다.
        // member2 객체를 저장하려고 함으로써 join()메서드 안의 validateDuplicateMember()메서드로 중복확인을 한다.
        // 이때 IllegalStateException 예외가 발생하면서 e에 예외가 저장됨.

		//then - 결과가 어떻게 되는지 테스트하여라
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
        // e에 저장된 예외 메세지가 "이미 존재하는 회원입니다."와 같은지 확인함

/*
        try-catch문 보다 좋은 문법이 있다. (위에)

        try {
            memberService.join(member2);
            fail();
        } catch (IllegalStateException e){
            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
        }
*/

        
    }

    @Test
    void findMembers() {
    }

    @Test
    void findOne() {
    }

예외를 처리하는 방법

  • try-catch문 사용하기
  • assertThrows() 메서드 사용하기 (람다 사용) - 이게 더 간편하다.
profile
성실하고 꼼꼼하게

0개의 댓글