Spring_입문03_회원 관리 예제(백엔드 개발)

20954 단어 SpringSpring

회원 관리 예제 만들기

🔲 비지니스 요구사항 정리

🔲 회원 도메인과 회원 도메인 객체를 저장하고 불러올 수 있는 저장소인 리포지토리 객체 만들기

🔲 회원 리포지토리가 정상 동작하는지 테스트 케이스 만들기

🔲 실제 비지니스 로직이 있는 회원 서비스 만들기

🔲 Junit이라는 테스트 프레임 워크를 이용해 정상적으로 동작하는지 확인하기 위한 회원 서비스 테스트 만들기


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

1.1 비지니스 세팅

🔲 데이터 : 회원 ID, 이름

🔲 기능 : 회원 등록, 조회

스프링의 특성들을 더 잘 설명하기 위해서 개발을 시작해야 하는데 아직 데이터 저장소가 선정되지 않았다는 가상의 시나리오를 전제로 진행한다.


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

일반적인 웹 애플리케이션은 보통 컨트롤러, 서비스, 리포지토리, 도메인 객체로 구성된다.

🔲 컨트롤러 : 웹 MVC or API를 만들거나 할 때의 컨트롤러 역할

🔲 도메인 : 회원, 주문, 쿠폰 처럼 주로 데이터베이스에 저장하고 관리되는 비지니스 도메인 객체

🔲 서비스 클래스 : 핵심 비지니스 로직을 담고 있다. 비지니스 도메인 객체를 이용해 핵심 비지니스 로직이 동작하도록 구현한 객체.

EX ) 회원 중복 가입 불가능

🔲 리포지토리 : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리.


1.3 클래스 의존관계

🔲 MemberService : 회원 비지니스 로직에 있는 회원 서비스

🔲 Interface MemberRepository : 회원을 저장하는 리포지토리는 인터페이스로 설계할 것이다. 왜냐하면 아직 데이터 저장소가 선정되지 않았다는 시나리오로 개발을 진행하기 때문이다.

🔲 Memory MemberRepository : 회원 리포지토리 인터페이스의 구현체는 일단 개발은 해야하니까 우선은 메모리에 넣었다 뺐다 할 수 있는 단순한 메모리 구현체로 만들자. 향후에 구체적인 기술이 선정되면 이거를 정의된 인터페이스를 이용해서 바꿔끼우자.



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

2.1 회원 객체

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;
    }
}

여기서 id는 고객이 정하는 아이디가 아니라 데이터를 구분하기 위해서 시스템이 저장하는 임의의 값이고, name은 고객이 회원가입 할 때 적는 이름이다.


2.2 회원 리포지토리 인터페이스

repository패키지의 MemberRepository.java (MemberRepository 인터페이스)

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();
}

🔲 Member save(Member member) : 회원을 저장하고 저장한 회원 반환
🔲 Optional< Member > findById(Long id) : id로 회원 찾기
🔲 Optional< Member > findByName(String name) : name으로 회원 찾기
🔲 List< Member > findAll() : 지금까지 저장된 모든 회원 리스트를 다 반환

✔ Optional<>
Java8에 들어간 기능. findByName이나 findById등으로 값을 가져올 때 리턴 값이 없는 경우 그 값이 NULL일 수 있다. NULL이 반환될 가능성이 있으면 Optional로 감싸주면 반환 값이 NULL이어도 클라이언트에서 처리할 수 있다.
EX ) Optional.ofNullable(store.get(id));


2.3 회원 리포지토리 메모리 구현체

repository패키지의 MemoryMemberRepository.java (MemberRepository 구현체)

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import java.util.*;

/**
 * 동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려
 */

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());
    }
}

✔ intellij에서 클래스에 인터페이스를 implements하려면 "public class MemoryMemberRepository implements MemberRepository" 위와 같이 작성하고
Alt + Enter를 눌러 Implement methods를 하면 된다.

🔲 private static Map<Long, Member> store = new HashMap<>() : save할때 메모리를 저장하기 위한 공간
🔲 private static long sequence =0L : 0,1,2 ... 이렇게 키 값을 생성해주는 것

🔲 public Member save(Member member)
멤버를 저장할때 store에 넣기 전에 sequence값을 하나 올려 멤버의 아이디 값을 세팅해주고, store에 멤버를 저장하고 리턴.
(이름은 고객이 회원가입 할 때 적은 이름이 그대로 넘어온다.)


🔲 public Optional< Member > findById(Long id)
store.get(id)로 스토어에서 꺼내면 되는데 리턴 값이 NULL일 경우에 대비해 Optional.ofNullable(store.get(id))를 리턴.


🔲 public Optional< Member > findByName(String name)
자바의 lamda이용.

return store.values().stream()
                .filter(member->member.getName().equals(name))
                .findAny();

store를 루프 돌리면서 member.getName()의 리턴 값이 파라미터로 넘어온 name과 같은 경우만 필터링 한다. 값이 하나 찾아지면 걔를 리턴하고, 끝까지 돌렸는데 없으면 Optional에 NULL이 포함돼서 반환된다.


🔲 public List< Member > findAll()
store는 Map인데 findAll의 반환은 List로 되어있다.
new ArrayList<>(store.values());

구현은 끝났는데 이게 제대로 동작하는지 어떻게 검증해보지?
-> 테스트 케이스를 작성하자!



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

3.1 테스트 케이스의 필요성

방금 만든 회원 리포지토리가 내가 원하는대로 정상적으로 동작할까?

개발한 기능을 실행해서 테스트 할 때 자바의 main 메서드를 통해서 실행하거나, 웹 애플리케이션의 컨트롤러를 통해서 해당 기능을 실행해볼 수 있다.

하지만 이러한 방법은

1 ) 준비하고 실행하는데 오래 걸린다.

2 ) 반복적으로 실행하기 어렵다.

3 ) 여러 테스트를 한꺼번에 실행하기 어렵다.

위와 같은 단점이 있다.

이러한 문제들을 해결하기 위해 자바에서는 Junit이라는 프레임 워크로 테스트 코드를 만들어서 테스트 코드 자체를 실행한다.


3.2 테스트 코드 작성법

✔ main이 아닌 test에 똑같은 패키지를 만든다. 이때 테스트할 파일은 기존의 파일명 뒤에 "Test"를 붙인다. 이 클래스는 굳이 public으로 안해도 된다.
EX ) public class MemoryMemberRepository -> class MemoryMemberRepositoryTest

테스트 코드를 작성할 때는, 메서드 위에

@Test

를 붙여주면 "import org.junit.jupiter.api.Test;"를 임포트 해주고 그 메서드를 실행할 수 있도록 해준다. 이때 각각의 메서드별로 실행할 수 있고, 클래스 레벨에서 실행하면 모든 메서드를 한꺼번에 실행할 수 있으며 패키지 레벨에서 실행하면 전체 클래스를 테스트 해 볼 수 있다.

🔲 save() 테스트

@Test
public void save(){
	Member member = new Member();
	member.setName("Sangjun");

	repository.save(member);

	Member result = repository.findById(member.getId()).get();
	System.out.println("result = "+(result==member));
}

이 테스트는 위에서 저장한 member와 DB에서 꺼낸거랑 똑같으면 참이기에 실행하면 "result = true"가 출력된다. 그런데 매번 이렇게 출력으로 확인해 볼 수는 없다. 그래서 Assertions 라는 기능이 있다.

Assertions.assertEquals(expected(기대 값), actual(실제 값));

우리가 기대하는 건 위에서 저장했던 member가 findById했을 때 result로 나와야 한다. 따라서

Assertions.assertEquals(member,result);

위와 같이 출력 대신에 "org.junit.jupiter"가 제공하는 Assertions를 이용하도록 테스트 코드를 수정하자.

@Test
public void save(){
	Member member = new Member();
	member.setName("Sangjun");

	repository.save(member);

	Member result = repository.findById(member.getId()).get();
	Assertions.assertEquals(member, result);
}

이 테스트 코드를 실행해보면 이전의 코드와는 달리 출력되는 값은 없지만 아래와 같이 save()에 녹색 불이 뜬다.

만약 Assertions.assertEquals()안의 두 값이 다르다면 save()에 빨간 불이 뜬다. 아래는 실제 값에 result가 아닌 null을 넣었을 때의 결과이다.

이렇게 테스트 하는 방법이 있고, 요즘에는 위의 방법이 아닌 "org.assertj.core"가 제공하는 Assertions을 많이 사용한다. 위의 .assertEquals()와는 달리 이 Assertions는

Assertions.assertThat(기대 값).isEqualTo(실제 값)

이라는 문법을 쓴다. 따라서 아래와 같이 쓰면 된다.

Assertions.assertThat(member).isEqualTo(result)

여기서 추가로 Assertions를 Alt + Enter를 눌러 static import 해줌으로써 상단에 "import static org.assertj.core.api.Assertions.*;"가 추가되고 Assertions를 생략하고 assertThat(member).isEqualTo(result)만을 작성해도 된다.

assertThat(member).isEqualTo(result)

save 테스트 코드

@Test
public void save(){
	Member member = new Member();
	member.setName("Sangjun");

	repository.save(member);

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

🔲 findByName() 테스트

findByName 테스트 코드

@Test
public void findByName(){
	Member member1 = new Member();  
    member1.setName("Sangjun");   
    repository.save(member1);
   
   	Member member2 = new Member();   
   	member2.setName("Sanghyuk");   
   	repository.save(member2);
   
   	Member result = repository.findByName("Sangjun").get();
  
  	assertThat(result).isEqualTo(member1);
}

✔ Tip

이름이 같은 값들이 있을 때, Shift + F6을 누르면 그 아래에 있는 이름이 같은 것들의 이름을 한 번에 바꿀 수 있다.


🔲 findAll() 테스트

findAll 테스트 코드

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

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

   	assertThat(result.size()).isEqualTo(2);
}

🔲 @AfterEach

각각의 코드가 잘 실행되던 세 개의 메서드를 클래스 레벨에서 한꺼번에 실행하면 findByName에서 갑자기 에러가 발생한다. 왜냐하면 메서드들간의 테스트 순서가 보장되지 않아 먼저 실행된 메서드에서 Sangjun이 이미 repository에 저장이 되어버렸기 때문에 이전에 저장되었던 다른 객체가 리턴되기 때문이다.

그래서 테스트가 하나 끝나고 나면 리파지토리의 데이터를 깔끔하게 클리어 해줘야 한다.

@AfterEach : 각각의 메서드가 실행이 끝날때마다 어떤 동작을 하는 콜백 메서드

  1. MemoryMemberRepository에 테스트가 끝날 때마다 리파지토리를 깔끔하게 지워주는 메서드 clearStore()를 추가.
public void clearStore(){
	store.clear();
}
  1. MemoryMemberRepositoryTest에 @AfterEach로 clearStore() 해주는 afterEach() 메서드 추가.
@AfterEach
public void afterEach(){
	repository.clearStore();
}

각각의 메서드의 테스트가 끝날때마다 한번씩 리퍼지토리를 초기화해준다. 그러면 순서에 상관이 없어서져서 findByName에서 에러가 발생하지 않는다.

테스트는 서로 실행 순서와 관계 없이, 의존 관계 없이 설계되어야 한다. 그러기 위해서는 하나의 테스트가 끝날때마다 저장소나 공용 데이터들을 깔끔하게 지워줘야 한다.

MemoryMemberRepositoryTest 전체 코드

package hello.hellospring.repository;

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

import java.util.List;

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

class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

    @AfterEach
    public void afterEach(){
        repository.clearStore();
    }

    @Test
    public void save(){
        Member member = new Member();
        member.setName("Sangjun");

        repository.save(member);

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

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

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

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

        assertThat(result).isEqualTo(member1);
    }

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

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

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

        assertThat(result.size()).isEqualTo(2);
    }
}

3.3 테스트 주도 개발

지금은 먼저 MemoryMemberRepository를 개발을 끝낸 다음에 테스트 코드를 작성했다. 그런데 이거를 뒤집어서 테스트 클래스를 먼저 작성한 다음에 MemoryMemberRepository를 작성할 수도 있다. 이렇게 순서를 뒤집어 마치 뭔가를 만들어야 할 때 이거를 검증할 수 있는 틀을 먼저 만들어 놓고 작품이 완성되면 틀에 꽂아보는 것을 "테스트 주도 개발", "TDD" 라고 한다.

테스트 코드 없이 개발하는 것은 거의 불가능하다.



4. 회원 서비스 개발

회원 서비스는 회원 리포지토리랑 도메인을 활용해서 실제 비지니스 로직을 작성하는 것이다.

4.1 회원 가입

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

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

회원 가입을 할 때 같은 이름을 갖는 회원은 가입이 안된다고 전제하면, 회원 가입을 할때, memberRepository에 findByName()을 써서 같은 이름을 갖는 회원이 있는지를 먼저 확인해본다.

이때 result에 값이 있으면 동작하는 ifPresent()를 사용해서 같은 이름을 갖는 회원이 있으면 예외처리를 해준다. 이는 Optional이기 때문에 가능한 것이다.

✔ Tip

1) Optional을 쓸 때, Optional을 바로 반환하는 건 좋지 않다. 권장 사항 : 굳이 변수를 만들어서 리턴하지 말고 바로 ifPresent()를 사용하자.

수정 코드1

public Long join(Member member){
// 같은 이름이 있는 중복 회원 가입 불가.
	memberRepository.findByName(member.getName())
                .ifPresent(m->{
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });

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

2) 위의 수정 코드1은 findByName()으로 해서 로직이 쭉 나온다. 이런 경우에는 메서드로 뽑는게 좋다. 해당 부분을 드래그 해서 리팩토링으로 Extract Method를 선택하면 외부 메서드로 뽑아낼 수 있다.

수정 코드2

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("이미 존재하는 회원입니다.");
                });
}

4.2 전체 회원 조회 / ID로 회원 조회

🔲 전체 회원 조회

public List<Member> findMembers() {
	return memberRepository.findAll();
}

🔲 ID로 회원 조회

public Optional<Member> findOne(Long memberId){
	return memberRepository.findById(memberId);
}

4.3 회원 서비스 전체 코드

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) {
        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);
    }

}



5. 회원 서비스 테스트

✔ Tip

1) 테스트가 필요한 클래스에서 Ctrl + Shift + T를 누르면 "Create new test"를 통해 테스트 코드 파일을 쉽게 생성할 수 있다. 자동 생성된 파일에 Assertions도 자동으로 import되는데, 이 Assertion이 junit에서 지원하는 Assertion이기 때문에 assertThat()을 사용할 수가 없다. 따라서 assertj꺼로 바꿔주자.

2) 테스트 코드는 메서드 명을 과감하게 한글로 바꿔도 된다. 빌드될 때 테스트 코드는 실제 코드에 포함되지 않는다.
EX) join -> 회원가입

5.1 회원 가입 테스트 (Feat. given - when - that 문법)

@Test
void 회원가입() {
	// given
	Member member = new Member();
	member.setName("Sangjun");

	// when : 뭘 검증할 것인가?
	Long saveId = memberService.join(member);

	// then : 검증
	Member findMember = memberService.findOne(saveId).get();
	assertThat(member.getName()).isEqualTo(findMember.getName());
}

그런데 위의 코드는 너무 단순하다. 테스트는 정상 flow도 중요하지만, 예외 flow가 훨씬 더 중요하다. 위의 테스트는 사실상 반쪽짜리 테스트이다. 회원 가입의 핵심은 저장이 되는 것도 중요하지만 중복 회원 검증 로직이 잘 짜여져서 예외 처리가 되는지도 봐야한다.


5.2 회원 가입 테스트2 - 중복 회원 예외

같은 이름의 두 member를 join하면 두 번째 join에서 validate에 걸려서 예외 처리가 되어야 한다.

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

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

	// when : 뭘 검증할 것인가?
	memberService.join(member1);
	try{
		memberService.join(member2);
		fail();
	}catch(IllegalStateException e){
		assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
	}

	// then : 검증
}

try-catch 구문을 통해 코드를 작성하면 예외 처리가 정상적으로 작동함을 확인할 수 있지만 이거 때문에 try-catch를 넣는 대신 더 좋은 문법을 제공한다.

assertThrows(IllegalStateException.class, () -> memberService.join(member2));
"오른쪽의 로직이 실행될 때 왼쪽의 Exception이 발생하는 것을 기대한다."


5.3 @AfterEach & @BeforeEach, DI

🔲 @AfterEach

여기서도 @AfterEach를 이용해 클리어를 해줘야만 한다. 클리어를 해주지 않으면 중복 회원 예외 코드에서 member1을 join 할 때도 에러가 발생한다. 전에 썼던 코드 그대로 가져다가 사용하자.


🔲 DI (Dependency Injection)

그런데 이때, MemberService에서 사용하는 memberRepository랑 테스트 코드에서 만든 memberRepository가 각각 new를 했기 때문에 서로 다른 인스턴스이다. 지금은 Map이 static으로 선언되어있어서 문제가 없지만 static이 아니라면 문제가 생길 수 있다. 그래서 이 둘이 같은 리파지토리를 쓰도록 하기 위해 new로 직접 생성하는게 아니라 외부에서 입력 받도록 하자.

각 테스트를 실행하기 전에 @BeforeEach를 이용해서 MemoryMemberRepository를 만들고 걔를 MemberService에다가 넣어준다.

MemberService.java의 memberRepository : new를 사용하지 않고 생성자를 통해 입력 받도록 수정

private final MemberRepository memberRepository;

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

MemberServiceTest.java의 memberRepository : beforeEach로 생성해 memberService에 주입

MemberService memberService;
MemoryMemberRepository memberRepository;

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

이러면 MemberService 입장에서 MemberRepository를 본인이 직접 new 하지 않고 외부에서 넣어준다. 이런거를 DI(Dependency Injection)이라고 한다.

좋은 웹페이지 즐겨찾기