[Spring Boot] [2] 3. 회원관리 예제 - 백엔드 개발

윤경·2021년 8월 5일
2

Spring Boot

목록 보기
12/79
post-thumbnail

1️⃣ 비즈니스 요구사항 정리

✔️ 목표

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

간단하게 비즈니스 요구사항
데이터: 회원 ID, 이름
기능: 회원 등록, 조회
그리고 가상의 시나리오로 아직 데이터 저장소가 선정되지 않았다고 가정.

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

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

클래스 의존 관계

  • 아직 데이터 저장소가 선정되지 않았기 때문에 우선 인터페이스로 구현 클래스를 변경할 수 있도록 회원 저장은 interface로 구현할 것. (나중에 선정이 되면 바꿔 끼우기 위함. 바꿔끼우기 위해서는 interface가 필요하기 때문)
  • 데이터 저장소는 RDB, NoSQL 등등 다양한 저장소를 고민중인 상황으로 가정.
  • 개발을 진행하기 위해 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용.

2️⃣ 회원 도메인과 레퍼지토리 만들기

우선 main/java/com.example.hellospring/ 디렉토리 안에 domain 이라는 이름의 패키지 생성

⬇️ 그 안에 Member라는 java class 생성 후 아래의 코드 작성

package com.example.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;
    }
}

그리고 다시 domain과 같은 위치에 repository 패키지 생성 (회원 객체를 저장할 저장소)
⬇️ 패키지에 MemberRepository interface를 생성한 뒤 아래와 같은 코드 작성

package com.example.hellospring.repository;

import com.example.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();
}

(Optional은 java 8에 들어간 기능. 간단하게 findById 이런식으로 가져오는데 이 내용이 null일 수 있음. 이때 그대로 Null을 반환하지 않고 optional로 감싸 반환하는 방식을 씀.)

save하면 회원 정보가 저장됨.
그 다음부터는 findById나 findByName으로 회원 정보를 찾아올 수 있음.
findAll로 지금까지 저장된 모든 회원 정보를 불러올 수 있음. (list를 모두 반환)

이제,
Repository 패키지에 MemoryMemberRepository java class를 생성

public class MemoryMemberRepository implements MemberRepository {
이렇게 implements를 해주고 generate로 overide method를 선택하여 모두 생성시켜줌.

⬇️ 그리고 아래와 같이 코드 작성

package com.example.hellospring.repository;

import com.example.hellospring.domain.Member;

import java.util.*;

public class MemoryMemberRepository implements  MemberRepository{

    private static Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L; // sequnece는 0, 1, 2 이렇게 그냥 key 값을 생성해주는

    @Override
    public Member save(Member member) {
        // store에 넣기 전에 sequence값을 증가시켜 Id값을 설정해줌.
        // 이전에 Id는 시스템이 정해준다고 했는데 우리는 여기서 정해주는 것.
        member.setId(++sequence);
        // map에 저장이 됨.
        store.put(member.getId(), member);
        return member;
    }

    @Override
    // store에서 꺼내오면 됨.
    public Optional<Member> findById(Long id) {
        // store.get(id)라고만 하면 null이 반환될 가능성이 있는데 이걸 그대로 반환하게되므로
        // Optional로 null을 감싸 반환.
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public Optional<Member> findByName(String name) {
        return store.values().stream()
                // parameter로 넘어온 name과 같은지 비교. 그 중에서 찾으면 반환.
                .filter(member -> member.getName().equals(name))
                // findAny()는 하나라도 찾는 것.
                // 맵을 돌면서 하나 찾아지면 그냥 반환하고 끝까지 못 찾으면 optional에 null이 포함이 돼서 반환.
                .findAny();
    }

    @Override
    public List<Member> findAll() {
        // java에서 실무할 때 list를 많이 씀.
        // store에 있는 values가 멤버들.
        return new ArrayList<>(store.values());
    }
}

3️⃣ 회원 레퍼지토리 테스트 케이스 작성

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

test/java/com.example.hellospring 디렉토리 안에 repository 라는 패키지를 생성.
그 안에 MemoryMemberRepositoryTest 라는 java class 생성

test1

⬇️ 아래와 같이 코드 작성

package com.example.hellospring.repository;

import com.example.hellospring.domain.Member;
import org.junit.jupiter.api.Test;

public class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

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

        repository.save(member);

        // 반환타입이 optional이기 때문에 .get()으로 꺼낼 수 있음.
        // get으로 그냥 꺼내는 것이 좋은 방법은 아님.
        Member result = repository.findById(member.getId()).get();
        System.out.println("result = " + (result == member));
    }
}

해당 버튼으로 run 시켜 결과를 확인

❗️ 그렇지만 이렇게 직접 글자로 볼 순 없으니까 assert라는 기능을 이용

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

⬆️ 위의 코드를 ⬇️ 아래와 같이 변경해줌.
단, 이때 이것을 씀.

Assertions.assertEquals(member, result);

그리고 실행시켜보면
⬆️ 아무 결과도 출력되지는 않지만 이렇게 녹색 체크가 뜬 것을 확인.

⬇️ 실패 케이스
코드를 이렇게 바꾸어 실행시켜보면 이렇게 에러 뱉는 모습을 확인 😦

그런데 이제 위와 다르게 Assertions ⬆️ 이걸로 선택 (이게 더 편하게 사용할 수 있음)

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

⬆️ 이 코드를 추가하여 (원래 Assertions.assertThat()... 이지만)

assertThat(member).isEqualTo(result);

⬆️ 이렇게 간단하게 쓸 수 있음.

실행 결과는 위의 코드와 같이 성공한 것을 확인할 수 있음. 물론 result대신 null을 넣는다면 위와 같이 에러를 확인할 수 있음. (생략)

test2

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

⬇️ 실행결과

⬇️ 실패 케이스

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

에서 spring1을 spring2로 수정해보기. 이때 isEqualTo로 member1을 넣었기 때문에 에러가 뜨는 것이 맞음.
⬇️ 결과

📌 test1, test2를 둘 다 실행하고 싶다면 test 중 해당 파일 자체를 실행(나는 이때 test파일 자체를 run했기 때문에 더 뜨는 것)

test3

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

⬇️ 결과 에러 케이스를 확인하려면 ➡️ 여기서 isEqualTo(3)으로 바꾸면 당연히 에러

test시 주의사항 (‼️중요)

⬇️ 이제 파일 자체를 실행 시켜보자 이렇게 에러 발생

📌 그 이유는 지금 실행 순서를 보면 findAll() ➡️ findByName() ➡️ save() 인데 실행 순서는 보장이 안되어 있음. 즉, 순서와 상관없이 메소드가 따로 동작할 수 있도록 설계해야 함. 순서에 의존하도록 설계하면 절!대 안 됨.
findAll()이 실행될 때 이미 spring1, spring2 를 저장해버렸는데 그 뒤에 또 똑같은 spring1, 2를 쓰니까 이때 저장한 spring1, 2가 아닌 그 전에 저장했던 spring1, 2 가 나오게 되어버린 것.
그러므로 테스트가 하나 끝나고 나면 다음 것이 제대로 동작하도록 clear 과정이 필요.

⬇️ MemoryMemberRepository.java 에 아래와 같은 코드를 추가

    public void clearStore() {
        store.clear();
    }

⬇️ 그리고 다시 MemoryMemberRepositoryTest.java의 repository 객체 생성 바로 밑에 아래의 코드 추가 (위치가 헷갈리면 아래의 전체 코드를 참조)

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

이렇게되면 테스트를 하나씩 마칠 때마다 레퍼지토리를 지우게 됨. (즉, 순서와 상관이 없어짐)

@AfterEach: 한번에 여러 테스트를 실행하면 DB에 직전 테스트의 결과가 남을 수 있음. 이렇게되면 이전 테스트 때문에 다음 테스트가 실패할 가능성이 있음. @AfterEach를 사용하면 각 테스트가 종료될 때마다 이 기능을 실행하게 함. 여기서는 메모리 DB에 저장된 데이터를 삭제함.

테스트는 각각 독립적으로 실행되어야 함. 테스트 순서에 의존 관계가 있는 것은 좋은 테스트 ❌

⬇️ 수정 후 결과

📌 그런데 지금과 반대로 테스트 케이스를 먼저 작성한 후 MemoryMemberRepository를 작성할 수도 있음. 이를 테스트 주도 개발이라고 함. (TDD: Test Driven Development. 예를 들어 별을 만든다고 할 때 별 모양 틀을 먼저 만들어놓고 별을 만든 후 틀에 별이 맞는지 확인하는.)

테스트는 실무에서 빠질 수 없음. 그만큼 중요하기 때문에 테스트에 대해서는 깊게 공부가 필요!!

test 전체 코드

package com.example.hellospring.repository;

import com.example.hellospring.domain.Member;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

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

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

public class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

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

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

        repository.save(member);

        // 반환타입이 optional이기 때문에 .get()으로 꺼낼 수 있음.
        // get으로 그냥 꺼내는 것이 좋은 방법은 아님.
        Member result = repository.findById(member.getId()).get();
        // System.out.println("result = " + (result == member));

        // Assertions.assertEquals(member, null);
        assertThat(member).isEqualTo(result);
    }

    @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️⃣ 회원 서비스 개발

src/main/java/com.example.hellospring 아래 service 패키지 생성

service 안에 MemberService라는 java class 생성

(알고 지나갈 요구사항 - 같은 이름을 가진 회원은 가입 불가)

    private final MemberRepository memberRepository = new MemoryMemberRepository();

⬆️ 코드를 추가한 다음 ⬇️ 아래의 코드도 입력 (회원가입)

회원가입

    // 회원가입
    public Long join(Member member) {
        // 같은 이름을 가진 중복 회원은 가입 불가
        Optional<Member> result = memberRepository.findByName(member.getName());
        // 멤버가 존재하는지 확인 후 존재한다면 "이미 존재하는 회원입니다." 출력
        result.ifPresent(m -> {
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        });

        memberRepository.save(member);

        return member.getId();
    }

ifPresent 이런 것도 optional로 감싸주었기 때문에 사용할 수 있었던 것. 참고로 orElseGet() (값이 있으면 꺼내고 없으면 이러한 메소드를 실행해라) 이런걸 주로 많이 씀.

‼️ 물론 코드를 작성하며 빨간 줄이 뜨면 import는 기본적으로 해주어야 함 ‼️

📌 (참고) 위의 코드를

        // 같은 이름을 가진 중복 회원은 가입 불가
        Optional<Member> result = memberRepository.findByName(member.getName());
        // 멤버가 존재하는지 확인 후 존재한다면 "이미 존재하는 회원입니다." 출력
        result.ifPresent(m -> {
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        });

⬇️ 아래와 같이 간단하게 작성할 수 있음 (같은 기능을 하는 코드)

        // 같은 이름을 가진 중복 회원은 가입 불가
        memberRepository.findByName(member.getName())
                // 멤버가 존재하는지 확인 후 존재한다면 "이미 존재하는 회원입니다." 출력
                .ifPresent(m -> {
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });

⬆️ 이 부분을 이렇게 드래그 한 뒤 (mac기준) control+T 단축키를 눌러 ⬆️ method 검색 후 extract method 선택

    private void validateDuplicateMember(Member member) {
        memberRepository.findByName(member.getName())
                // 멤버가 존재하는지 확인 후 존재한다면 "이미 존재하는 회원입니다." 출력
                .ifPresent(m -> {
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });
    }

⬆️ 이름은 validateDuplicateMember 로 하면 위와 같은 코드가 생성
⬇️ 그리고 save 윗줄을 아래와 같이 수정해줌

        validateDuplicateMember(member);

📌 (참고) repository는 단순히 저장소에 넣었다 뺐다 하는 단순한 이름으로 지었지만 service는 이름을 비즈니스에 가깝게 지음. 보통 service class는 비즈니스에 가까운 이름을 지어야 함. 그래야 개발할 때 개발자든 기획자든 쉽게 매칭시킬 수 있음.

회원조회

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

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

5️⃣ 회원 서비스 테스트

예전에는 test 디렉토리에서 패키지를 만들어 테스트를 진행했었음.

더 편한 방법: 테스트 할 클래스에서 command + shift + T 를 누르면
⬇️ 아래와 같은 창이 뜸. ⬇️ 클릭하고 이렇게 체크박스를 선택해 준 뒤 OK ⬇️ 그럼 이렇게 자동으로 패키지와 파일이 생성

📌 테스트 코드는 과감하게 한글로 작성하여도 됨(직관적)

given - when - then 주석을 적극 활용하도록
코드가 길어질수록 유용함.

⬇️ 알아서 생성된 파일(코드)에 아래의 코드 추가

    MemberService memberService = new MemberService();

회원가입 test

⬇️ 그리고 join을 회원가입이라고 수정해 코드를 작성

    @Test
    void 회원가입() {
        // given (무언가가 주어지고)
        Member member = new Member();
        member.setName("hello");

        // when (이것을 실행했을 때)
        Long saveId = memberService.join(member);

        // then (결과는 이것이 나와야 한다)
        Member findMember = memberService.findOne(saveId).get();
        Assertions.assertThat(member.getName()).isEqualTo(findMember.getName());
    }

➡️ 이렇게 코드를 작성하고 Assertions. 여기서 (mac기준) option+return 을 눌러 해당 옵션 선택.

⬇️ 그러면 아래와 같이 그 줄의 코드가 다음과 같이 바뀔 것임.

	assertThat(member.getName()).isEqualTo(findMember.getName());

⬇️ 결과 (코드를 잘 짰기 때문에 성공할 수밖에 없음)

중복 회원 예외 test

📌 test는 성공 케이스도 중요하지만 예외가 잘 작동하는지 확인하는 것이 중요

    @Test
    // 중복회원 검증 로직이 잘 작동하는가 확인
    public void 중복_회원_예외() throws Exception {
        // given
        Member member1 = new Member();
        member1.setName("spring");

        Member member2 = new Member();
        // 일부러 멤버2도 똑같이 이름을 설정
        member2.setName("spring");

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

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

        // then
    }

(설명) ⬇️

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

⬆️ 는 memberService.join(member2)를 했을 때 오는 예외를 받은 것이 e.

⬇️ 결과

clear 과정 필요

‼️ 이제 이도 마찬가지로 clear 과정이 필요함!!

    MemoryMemberRepository memberRepository = new MemoryMemberRepository();
    @AfterEach
    public void afterEach() {
        memberRepository.clearStore();
    }

⬆️ 위의 코드 추가해주기

📌 (참고) control + R 키를 누르면 저번에 실행했던 것을 실행시킬 수 있음

문제점

🧐 여기서 test 케이스에 있는 MemoryMemberRepository memberRepository = new MemoryMemberRepository(); 이것과 MemoryMemberRepository에 있는 private static Map<Long, Member> store = new HashMap<>(); 와 각각 인스턴스가 되어 문제가 생길 수 있음. (지금이야 static으로 되어있지만 그게 아닐경우 완전히 다른 DB 가 되어버림)

즉, 어찌됐든 같은 것으로 테스트하는 것이 맞는건데 다른 repository가 테스트 되고 있다는 것.

✔️ 이제 같은 인스턴스를 사용할 수 있도록 코드를 수정해주자.

MemberService.java에서

    private final MemberRepository memberRepository = new MemoryMemberRepository();

⬆️ 이 코드를 아래처럼 바꾼뒤 constructor를 생성시키면 public 저 코드가 자동 생성됨.

    private final MemberRepository memberRepository;

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

➡️ 이렇게 되면 새로운 memberrepository를 직접 생성하는 것이 아니라 외부에서 넣어주도록 코드를 바꿔준 것이 됨.

이제 다시 MemberServiceTest.java로 가서

    MemberService memberService;
    MemoryMemberRepository memberRepository;

⬆️ 이렇게 코드를 수정해주고

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

⬆️ 이 코드를 추가 작성해주면 됨

📌 결론: 같은 memory repository 를 사용하도록 해주었음

test 전체 코드

package com.example.hellospring.service;

import com.example.hellospring.domain.Member;
import com.example.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 java.util.Optional;

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() {
        memberRepository.clearStore();
    }

    @Test
    void 회원가입() {
        // given (무언가가 주어지고)
        Member member = new Member();
        member.setName("hello");

        // when (이것을 실행했을 때)
        Long saveId = memberService.join(member);

        // then (결과는 이것이 나와야 한다)
        Member findMember = memberService.findOne(saveId).get();
        assertThat(member.getName()).isEqualTo(findMember.getName());
    }

    @Test
    // 중복회원 검증 로직이 잘 작동하는가 확인
    public void 중복_회원_예외() throws Exception {
        // given
        Member member1 = new Member();
        member1.setName("spring");

        Member member2 = new Member();
        // 일부러 멤버2도 똑같이 이름을 설정
        member2.setName("spring");

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

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

        // then
    }

    @Test
    void findMembers() {
    }

    @Test
    void findOne() {
    }
}

와 벌써 내용이 어렵네

profile
개발 바보 이사 중

0개의 댓글