본문 바로가기
Java/Spring입문

Spring 입문(6) 회원관리 예제 - 백엔드 개발-1(테스트케이스작성까지)

by 이쟝 2023. 4. 20.
인프런의 김영한님의 스프링입문 강의를 듣고 정리한 내용입니다.
1. 비즈니스 요구사항 정리
2. 회원 도메인과 레포지토리 만들기
3. 회원 레포지토리 테스트 케이스 작성
4. 회원 서비스 개발 
5. 회원 서비스 테스트

1. 비즈니스 요구사항 정리

데이터: 회원ID, 이름
기능: 회원 등록, 조회
아직 데이터 저장소가 선정되지 않음(가상의 시나리오)

 

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

 

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

 

클래스 의존관계

 

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

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

1. domain 패키지(폴더) 생성 뒤 Member.java 클래스 생성

src > main> java > hello > hellospring > domain > Member.java

Member클래스에 사용할 변수를 담는다.
package hello.hellospring.domain;

public class Member {

    private Long id;     // 임의의 값(시스템이 저장하는 값)
    private String name; 

    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. repository 패키지(폴더) 생성 뒤 MemberRepository 인터페이스 생성

src > main> java > hello > hellospring > repository > MemberRepository interface

회원 객체를 저장하는 저장소를 인터페이스로 생성한다.

레포지토리의 네가지 기능 만들기(save, findById, findByname, findAll)
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<Member> findById(Long id);
    Optional<Member> findByName(String name);
    List<Member> findAll();
}
save: 회원을 저장
findById: ID로 회원을 찾는다. 
findByName: name으로 회원을 찾는다.
findAll: 회원 정보를 조회한다. 

Optional은 java8에서 추가된 기능으로써, findById 나 findByName 등으로 회원의 정보를 가져왔을 때, 정보가 null 일 수가 있는데, 이 null을 처리하는 데 있어서 null을 그대로 반환하는 대신 Optional을 이용하여 한번 감싸준 후 반환하는 방식을 많이 쓴다.  (Optional 안에 있는 메서드를 사용할 수 있다.)

 

3. MemberRepository 인터페이스를 구현할 구현체 생성(MemoryMemberRepository.java 클래스)

src > main> java > hello > hellospring >  repository > MemoryMemberRepository.java

인터페이스를 구현할 클래스를 생성한다. (MemberRepository에 작성된 save, findById, findByName, findAll 메서드의 구현부를 작성)
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import java.util.*;

public class MemoryMemberRepository implements MemberRepository{

    private static Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L;

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

    @Override
    public Optional<Member> findById(Long id) {
        return Optional.ofNullable(store.get(id)); 
    }

    @Override
    public Optional<Member> findByName(String name) {
        return store.values().stream()
                .filter(member -> member.getName().equals(name))
                .findAny();
    }

    @Override
    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }
	
    // 테스트 케이스를 위한 콜백함수 작성(테스트케이스가 끝날때마다 호출된다.) 
    public void clearStore(){
        store.clear();
    }
}
더보기
private static Map<Long membe> store = new HashMap<>();

 - 실무에서는 동시성 문제가 있어서 이렇게 공유되는 변수일때는 ConcurrentHashMap을 써야 하는데 예제니까 단순히 HashMap을 쓰도록 한다.

private static long sequence = 0L;

- sequence: 순차적으로 정수 값을 자동으로 생성하는 객체(0,1,2 이렇게 키 값을 생성해준다.)

-> 동시성 문제를 해결하기 위해 atomlong 등을 써야 하는데 예제니까 단순히 long을 사용한다.

 

save( )

회원정보를 store Map에 저장 후 member를 반환한다.
    @Override
    public Member save(Member member) {
        member.setId(++sequence);           // 아이디 세팅
        store.put(member.getId(), member);  // store(map)에 저장
        return member;                      // 결과를 반환
    }

 

findById( )

인자(매개변수)로 받은 id와 일치하는 값을 store Map에서 꺼내서 반환한다. 
@Override
public Optional<Member> findById(Long id) {
    return Optional.ofNullable(store.get(id)); // map.get()
}
return store.get(id);​

 

이렇게 한다면 결과가 없을 경우 null이 반환될 수 있기 때문에 이것을 Optional.ofNullable( )로 감싸주면 
null이어도 반환하여 (클라이언트단에서) 조치를 할 수 있다.

null일 가능성이 있다면 Optional.ofNullable( ) 사용하기!!!

 

findByName( )

filter( ) 내의 람다식을 통해, 인자로 받은 name과 member.getName( )이 일치하는지 비교 후 일치하는게 하나라도 있다면 반환한다. 
@Override
public Optional<Member> findByName(String name) {
    return store.values().stream()
            .filter(member -> member.getName().equals(name))
            .findAny();
}
filter내의 람다식을 통해, 매개변수로 받은 name과 member.getName()이 일치하는 지 확인한다.
Map의 값들을 스트림으로 필터링해서 하나라도 맞으면 반환한다. store.values(). stream(). filter(). findAny();
-> 루프로 돌리면서 하나라도 찾아지면 결과를 Optional로 반환하고, 하나도 못 찾는다면 optional에 null이 포함되어 반환된다.

Stream에서 어떤 조건에 일치하는 요소(element) 1개를 찾을 때, findAny( )와 findFirst( )를 사용
1. findAny( ) : Stream에서 가장 먼저 탐색되는 요소를 리턴
2. findFirst( ) : 조건에 일치하는 요소들 중에 Stream에서 순서가 가장 앞에 있는 요소를 리턴, 조건에 일치하는 요소가 없으면 empty 리턴

 

findAll( )

strore에 있는 모든 member들을 반환한다. (map에 있는 values)
@Override
public List<Member> findAll() {
    return new ArrayList<>(store.values());
}
더보기

실무에서 자바 할 때 루프 돌리기가 편하기 때문에 List를 많이 사용한다.


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

  • 개발한 기능을 실행해서 테스트할 때 자바의 main 메서드를 통해서 실행하거나, 웹 애플리케이션의 컨트롤러를 통해서 해당 기능을 실행한다. 
  • 준비하고, 실행하는 데 오래 걸리고, 반복 실행하기 어렵고, 여러 테스트를 한 번에 실행하기 어렵다는 단점이 있다.
  • JUnit이라는 프레임워크로 테스트를 실행해서 이러한 문제를 해결한다.

1. test폴더에 repository 패키지(폴더)를 생성한 뒤 MemoryMemberRepositoryTest.java class 작성

src > test > java > hello > hellospring >  repository > MemoryMemberRepositoryTest.java

테스트 케이스 할 때는 @Test(import org.junit.jupiter.api.Test) 어노테이션을 꼭 붙여줘야 한다. 
package hello.hellospring.repository;
import org.junit.jupiter.api.Test;      //@Test
import org.junit.jupiter.api.AfterEach; // @AfterEach

class MemoryMemberRepositoryTest { // 굳이 public으로 하지 않아도 된다.)

    // MemoryMemberRepository 객체를 생성한다.
    MemoryMemberRepository repository = new MemoryMemberRepository();
    
    @Test  
    public void save(){
    }
    
    @Test
    public void findByName(){
    }
    
    @Test
    public void findAll(){
    }
    
    // 테스트가 끝날때마다 지워주는 메서드
    @AfterEach 
    public void afterEach(){
        repository.clearStore();
    }
}

 

save( )

Member객체와 repository(MemoryMemberRepository)를 이용해서 테스트를 해본다.
	@Test
        public void save(){
        Member member = new Member();  
        member.setName("Spring");      // 멤버 객체 이름을 Spring으로 생성
        repository.save(member);       // MemoryMemberRepository에 이름을 저장

        Member actual = repository.findById(member.getId()).get();
        // import static org.assertj.core.api.Assertions.*;
        // == Assertions.assertEquals(member, actual);
        assertThat(actual).isEqualTo(member);
        
    }
Member result = repository.findById(member.getId()).get();
System.out.println("result = " + (result == member));  // (actual == expect)

 

- findById의 반환 타입이 Optional이기 때문에 Optional에서 값을 꺼낼 때는 get() 사용(테스트 코드니까 get으로, 원래는 좋은 방법 x)- member와 repository에서 꺼낸 member(result)가 일치하는지 확인할 수 있다. 
- 이렇게 출력해서 볼 수 있지만 매번 글자로 볼 수 없기 때문에 Assertions를 사용한다. 

org.junit.jupiter.api.Assertiuons; // (expected, actual)

- 기대하는 값은 member이고, 실제 값은 result(repository에 저장된 Spring이라는 이름)

Assertions.assertEquals(member, null); // (expected, actual)

- 오류!!(Expected는 member인데, Actual은 null값이기 때문이다.)

 

assertThat(actual).isEqualTo(expected) // assertThat(actual).isEqualTo(expected)

- 위에 Assertions은 org.junit.jupiter.api에서 사용 이 식은 org.assertj.core.api에서 구현

assertThat(actual). isEqualTo(expected) // assertThat(actual).isEqualTo(expected)

- Add on demand static import for 'org.assertj.core.api.Assertions'를 클릭하면 static으로 import 할 수 있다.
-> (command + Enter / alt + Enter)
- 그러면 Assertions 없이 assertThat메서드만 코드에 써도 된다. 

*실무에서는 이것을 빌드 툴과 엮어서 테스트 케이스가 통과하지 않으면 다음 단계로 못 넘어가게 한다!*

 

findByName( )

2명의 이름을 member에 저장하고, result객체와 각 member를 비교하는 테스트이다.
현재 Spring2이라는 이름의 회원이 result객체이기 때문에, result와 member2는 같다.
 @Test
    public void findByName(){
        Member member1 = new Member();
        member1.setName("Spring1");
        repository.save(member1);

        // 좀더 정교한 테스트를 위해서 (변수이름 바꾸기: 드래그+shift+F6)
        Member member2 = new Member();
        member2.setName("Spring2");
        repository.save(member2);

        Member actual = repository.findByName("Spring2").get();
        // Member actual = repository.findByName(member2.getName()).get();
        Assertions.assertEquals(member, actual);
        //assertThat(actual).isEqualTo(member2);
    }
Member result = repository.findByName("Spring2").get( );
assertThat(result).isEqualTo(member1);
Spring2과 member1을 비교시
Member result = repository.findByName("Spring1").get( );
assertThat(result).isEqualTo(member1);
Spring1과 member1를 비교
Member result = repository.findByName("Spring1").get( );
assertThat(result).isEqualTo(member2);​
Spring1과 member2를 비교

 

findAll( ) 

Member 객체 2개를 저장 후, result List에 findAll 메서드를 통해 저장된 result의 사이즈가 2개인지 확인한다.
-> 멤버를 2개를 만들었으니 size가 2가 나와야 한다.
    @Test
    public void findAll(){
        Member member1 = new Member();
        member1.setName("Spring1");
        repository.save(member1);

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

        List<Member> result = repository.findAll();
        assertThat(result.size()).isEqualTo(2);
    }

만약 result에 어떤 값이 담겼는지 보려면? => 디버깅 하기

더보기
디버깅 실행할 코드

디버깅할 코드를 체크한 다음 디버깅 돌리기!

member1의 name와 id를 볼 수 있고, member2의 name와 id를 볼 수 있다.

테스트 케이스의 순서는 보장이 안되기 때문에 모든 테스트는 순서와 상관없이 메서드 별로 다 따로 동작하도록 설계를 해야 한다.(순서 의존적으로 설계하면 안 된다.)

오류가 나는 이유는 findAll( )과 findByName( )에서의 변수 이름이 같기 때문이다. 이때 의존관계없이 테스트가 끝날 때마다 콜백 함수(@AfterEach)를 이용해서 Data를 Clear 해줘야 한다. 

MemoryMemberRepository에서 clearStore( )를 가져와서 afterEach( ) 메서드에서 실행되도록 한다.
    @AfterEach  // import org.junit.jupiter.api.AfterEach;
    public void afterEach(){
        repository.clearStore();
    }

 

- 이제까지는 구현 클래스 Repository를 작성 후, 맞는지 검증하는 과정으로 작성했는데, 이것과 반대로 테스트 케이스를 먼저 작성 후 그다음에 Repository를 작성할 수도 있다. 
- 이 방법을 TDD(Test-Driven Development), 즉 테스트 주도 개발이라고 한다. (틀을 만들고 그다음 구현 클래스)
- 테스트 케이스는 필수!!!!!!