Spring/MVC

[Spring/입문] 회원 관리 예제 - 백엔드 개발

lumana 2024. 4. 29. 03:38

회원관리 예제 - 백엔드 개발

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

비지니스 요구사항

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

일반적인 웹 application layer 구조

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

클래스 의존관계

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

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

  • 회원 객체
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;
      }
 }
  • 회원 리포지토리 인터페이스
    • Optional로 null을 Wrapping하여 반환
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();   
}
  • 회원 리포지토리 메모리 구현체
    • Tip) 옵션 + enter로 implement method 해줌
    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);
                      // 이렇게 하는 이유는 id를 시스템에서 정해주기 때문(클래스 변수)
                      return member;
    
              }
              @Override
              public Optional<Member> findById(Long id) {
    
                      return Optional.ofNullable(store.get(id));
                      // null이 반환될 가능성이 있다면 Optional로 감싸서 반환하면 클라이언트에서 이에 맞춰 처리해준다
    
              }
              @Override
              public List<Member> findAll() {
    
                      return new ArrayList<>(store.values());
    
              }
              @Override
              public Optional<Member> findByName(String name) {
    
                      return store.values().stream()
                                      .filter(member -> member.getName().equals(name))
                                      .findAny();
                      // 찾아서 하나라도 있으면 반환해줌. 끝까지 돌렸는데 없으면 optional에 null이 포함되어 반환된다.
    
              }
              public void clearStore() {
    
                      store.clear();
    
              }
    }

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

  • 메인 메서드를 실행허거나, 웹 application의 컨트롤러를 통해서 해당 기능을 실행하면 준비/실행 시간이 오래걸리고, 반복 실행하기 어렵고, 여러 테스트를 한 번에 실행하기 어렵다는 단점이 존재함
    • JUnit 프레임워크 사용
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 java.util.Optional;
import static org.assertj.core.api.Assertions.*;

class MemoryMemberRepositoryTest {
  MemoryMemberRepository repository = new MemoryMemberRepository();
  @AfterEach
  public void afterEach() {
      repository.clearStore();
  }
  @Test
  public void save() {
//given
      Member member = new Member();
      member.setName("spring");
//when
      repository.save(member);
//then
      Member result = repository.findById(member.getId()).get(); // Optional에서 값을 꺼냄(.get())
      // Assertions.assertEquals(member, null);--> 기대했던 값과 실제 값이 달라 오류 발생
      assertThat(result).isEqualTo(member);
  }
  @Test
  public void findByName() {
//given
      Member member1 = new Member();
      member1.setName("spring1");
      repository.save(member1);
            // 위 세줄을 복붙하고, shift + f6 단축키를 이용하여 rename하자
      Member member2 = new Member();
      member2.setName("spring2");
      repository.save(member2);
//when
      Member result = repository.findByName("spring1").get();
//then
      assertThat(result).isEqualTo(member1);
  }
  @Test
  public void findAll() {
//given
      Member member1 = new Member();
      member1.setName("spring1");
      repository.save(member1);
      Member member2 = new Member();
      member2.setName("spring2");
      repository.save(member2);
//when
      List<Member> result = repository.findAll();
//then
      assertThat(result.size()).isEqualTo(2);
  }
}
  • 그냥 코드를 실행시키면 오류가 발생함
  • 테스트의 순서는 보장이 안됨
    • 순서에 의존적으로 설정하면 절대 안된다
  • 이대로 전체 Test를 돌리면 findAll 실행 후 저장되어있던 spring1과 spring2가 findByName에서 이전에 저장했던 객체가 나오게 되는 것
  • afterEach를 통해 테스트가 한 번 끝날 때 저장소를 지우자

회원 서비스 개발

  • hello.hellospring에 service 패키지 생성 후 MemberService 클래스 작성
package hello.hellospring.service;

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

import java.util.List;
import java.util.Optional;
public class MemberService {
    private final MemberRepository memberRepository;

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

    /*** 회원가입 */
    public Long join(Member member) {
        validateDuplicateMember(member); //중복 회원 검증
        memberRepository.save(member);
        return member.getId();
    }
    private void validateDuplicateMember(Member member) {
        //find By name의 리턴타입이 optional이라서 아래처럼 사용 가능
        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);
    }
}
  • command + option + v : 반환 타입 + 변수 = 값 형태로 만들어줌
    • ex) memberRepository.findByName(member.getName()); 상태에서 저 단축키를 쓰면
    • Optional byName = memberRepository.findByName(member.getName()); 로 바꿔줌
  • 서비스는 보통 비즈니스에 의존적으로 설계를 한다
  • 리포지토리는 서비스보다 단순히 기계적으로 개발에 가까운 용어들을 선택한다.

회원 서비스 테스트

  • 중복 회원 비지니스 로직이 제대로 작동하는지 테스트 케이스를 작성해보자
  • 클래스를 만들고 커맨드 쉬프트 T --> Create New Test --> Junit5, 원하는 클래스 선택하면 껍데기를 만들어줌
  • 커맨드 옵션 / --> 주석처리
  • 테스트 함수는 한글로 만들어도 됨
  • given when then 문법
    • given : 뭔가가 주어짐(데이터)
    • when : 이거를 실행했을 때
    • then : 결과가 이게 나와야 해
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);
                // 따로 만드는 것보다 이렇게 만드는게 좋다
                // 외부에서 메모리 멤버 리포지토리를 넣어주고 있는데, 이런 것을 Dependency Injection이라고 함
    }
    @AfterEach
    public void afterEach() {
        memberRepository.clearStore();
    }
    @Test
    public void 회원가입() throws Exception {
//Given
        Member member = new Member();
        member.setName("hello");
//When
        Long saveId = memberService.join(member);
//Then
        Member findMember = memberRepository.findById(saveId).get();
        assertEquals(member.getName(), findMember.getName());
    }
    @Test
    public void 중복_회원_예외() throws Exception {
//Given
        Member member1 = new Member();
        member1.setName("spring");
        Member member2 = new Member();
        member2.setName("spring");
//When
        memberService.join(member1);
        IllegalStateException e = assertThrows(IllegalStateException.class,
                () -> memberService.join(member2));//예외가 발생해야 한다.
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
    }
}
  • BeforeEach : 각 테스트 실행 전에 호출되어 테스트가 서로 영향받지 않도록 새로운 객체를 생성하고, 의존관계도 맺어준다
public class MemberService {
     private final MemberRepository memberRepository;
     public MemberService(MemberRepository memberRepository) {
         this.memberRepository = memberRepository;
}
... }
  • 회원 서비스 코드를 DI 가능하게 한다