스프링 입문(3) 회원 관리 예제-백엔드 개발

Jiseong Lee·2022년 4월 29일
0
post-thumbnail

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

이번 포스팅에서는 간단한 회원 관리에 대한 비즈니스 요구사항을 실제 코드로 구현해 볼 것이다. 구현 후 테스트 코드까지 작성한다.

비즈니스 요구사항 정리

현재 비즈니스 요구사항은 아래 세가지이다.

  • 데이터 : 회원 ID, 이름
  • 기능 : 회원 등록, 조회
  • 아직 DB 는 선정되지 않았다고 가정

웹 애플리케이션 계층 구조

일반적인 웹 애플리케이션 계층 구조는 다음과 같다. 아래와 같은 구조로 코드를 작성할 예정이다.

  • 컨트롤러 : 웹 MVC의 컨트롤러 역할이다.
  • 서비스 : 핵심적인 비즈니스 로직을 구현한다.
  • 리포지토리 : 실제 DB에 접근하고 도매인 객체를 DB에 저장하고 관리한다.
  • 도메인 : 비즈니스 도메인 객체이다. Ex) 회원, 주문, 쿠폰 등등 (Model 과 비슷한 개념일까..?)

클래스 의존관계 설정

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

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

  1. 회원 객체
    • 비즈니스 요구사항인 id, name 값을 갖는 Member 객체 생성
   package com.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;
       }
   }
  1. 회원 리포지토리 인터페이스
   package com.hello.hellospring.repository;
   
   import com.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();
   
   }
  • Optional : java 8의 기능 중 하나이며, 가져오는 값이 Null 일 때를 대비해 Optional 객체로 감싸서 return 한다.
    • Null 값 return 을 막을 수 있다.
  1. 회원 리포지토리 메모리 구현체
   package com.hello.hellospring.repository;
   
   import com.hello.hellospring.domain.Member;
   import java.util.*;
   
   public class MemoryMemberRepository implements MemberRepository {
   
       // 실무에서는 동시성 문제가 있어서 아래처럼 공유되는 변수일 때는 HasnMap 이 아닌 ConcurrentHashMap 을 쓴다.
       private static Map<Long, Member> store = new HashMap<>();
       // key 값 생성
       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());
       }
   
       // Test code에서 사용
       public void clearStore() {
           store.clear();
       }
   }
  • 실무에서는 동시성 문제를 고려하여 HashMap 대신 ConcurrentHashMap, AtomicLong 사용을 고려한다.

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

개발한 기능을 테스트할 때는 자바의 main 메소드 혹은 컨트롤러를 통해서 실행할 수 있다. 이러한 방법은 몇가지 단점이 있다.

  • 준비하고 실행하는 데 오래 걸림
  • 반복 실행 어려움
  • 여러 테스트를 한번에 실행하기 어려움

따라서 Java에서는 JUnit 이라는 프레임 워크를 사용하여 테스트를 진행한다.

Test 케이스를 먼저 만들고, 이후 비즈니스 로직이나 repository를 구현하는 경우도 있다. 이것이 Test-Driven-Development(TDD) 테스트 주도 개발이다!

Junit 으로 테스트 케이스 작성하기

  1. src/test/java 하위 폴더에 생성한다.
  2. @Test 어노테이션을 메소드 위에 붙여주면 끝!

Test Case 작성 시 유의점

  • 네이밍

    • 보통 Test Class 네이밍은 테스트 하고자 하는 클래스명 뒤에 Test 를 붙인다.
    • 테스트 메소드명은 직관적으로 한글로 적기도 한다. Build 시에 test 코드는 포함되지 않는다.
  • 검증 방법

    • try~catch 를 이용하여 테스트할 수도 있으나, junit 혹은 assertj 를 통해 검증할 수 있다.
      • Junit : Assertions.assertEquals 으로 검증
      • assertj : Assertions.assertThat(비교대상).isEqualTo(결과값) 로 검증
    • 이외에도 많은 메소드들이 존재함
  • 테스트의 독립성

    • 테스트는 각각 독립적으로 실행되어야 한다. 다시 말해서 모든 테스트는 순서 관계없이 모두 정상적으로 동작해야 한다.
      • 테스트 순서에 의존관계가 있는 것은 좋은 테스트가 아니다.
      • 한번에 여러 테스트를 실행했을 때 메모리 DB에 직전 테스트 결과가 남을 수 있기 때문에 @AfterEach 를 사용하여 데이터를 삭제해준다.
        • @AfterEach : 각 테스트가 종료될 때마다 이 기능을 실행한다.
  • Given - When - Then 패턴

    • Given : 테스트에서 구체화하고자 하는 행동을 시작하기 전에 테스트 상태를 설명하는 부분
    • When : 구체화하고자 하는 그 행동
    • Then : 어떤 특정한 행동 때문에 발생할 것으로 예상되는 변화에 대한 설명
package com.hello.hellospring.repository;

import com.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 repo = new MemoryMemberRepository();

    // 각 테스트 메소드들이 끝나면 실행되도록! 여기서는 repo를 초기화해주자
    @AfterEach
    public void afterEach(){
        repo.clearStore();
    }

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

        repo.save(member);

        Member result = repo.findById(member.getId()).get();

//        Assertions.assertEquals(member, result);					> junit
//        Assertions.assertThat(member).isEqualTo(result);  > assertj
        
        // member가 result와 같니?
        assertThat(member).isEqualTo(result);
        
        /* 
           참고 : assertThat 은 static이기 때문에, static import 로 선언해주면 위처럼 Assertions를 안써도 됨
        */
    }

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

        Member member2 = new Member();
        member2.setName("spring2");
        repo.save(member2);

        Member result = repo.findByName("spring1").get();
        assertThat(result).isEqualTo(member1);
    }

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

        Member member2 = new Member();
        member2.setName("spring2");
        repo.save(member2);

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

회원 서비스 개발

회원 리포지토리 개발을 테스트 케이스를 통해 검증을 마쳤다면, 서비스단을 구현한다.

  • join() - 회원가입
  • validateDuplicateMember() - 이름 중복 회원 존재 여부 판단
  • findMembers() - 전체 회원 조회
  • findOne() - 회원 한명 조회
package com.hello.hellospring.service;

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

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

public class MemberService {

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

    /**
     *  회원 가입
     *  같은 이름의 중복 회원 안됨
     */
    public Long join(Member member) {
      
        validateDuplicateMember(member); // 중복 회원 검증
        memberRepository.save(member);
        return member.getId();
    }
  
		/**
		 *  중복 회원 검증
		 *	findByName(member.getName()) 의 Return type 은 Optional 이다.
		 *  Optional 객체는 ifPresent 메소드를 통해 값 존재 여부 판단이 가능하다.
     *  이 코드에서는 이름이 중복되는 경우 Exception 반환한다!
     */
    private void validateDuplicateMember(Member member) {
        memberRepository.findByName(member.getName())
                .ifPresent(m -> {
                    throw new IllegalStateException("이미 존재하는 회원입니다");
                });
    }

    /**
     *  전체 회원 조회
     *  repository 같은 경우는 메소드명 작성 시 데이터 넣고빼는 느낌
     *  service 단 부터는 좀더 비즈니스적인 직관적 네이밍 하자!
     */
    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

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

}

회원 서비스 테스트

회원 리포지토리의 코드가 회원 서비스 코드를 DI(Dependency Injection) 가능하도록 한다.

테스트 코드 작성 시 정상 Flow도 고려해야 하지만, 예외의 경우를 더욱 신경써줘야 한다.

package com.hello.hellospring.service;

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

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

class MemberServiceTest {

//    MemberService memberService = new MemberService();
//    MemoryMemberRepository memberRepository = new MemoryMemberRepository();

    /*
        지금 위의 MemberService 에서 선언된 MemoryMemberRepository 와
        테스트 클래스에 선언된 memberRepository 는 다른 인스턴스이다.
        만약 서비스단의 repository 가 static이 아니라면 에러가 날 수 있다.
        >> Memberservice 에서 memberrepository를 외부에서 넣어주도록 생성자를 만든다. (DI)
     */

    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 : 회원가입 한 id 가 처음에 주어진 id 와 같야?
        Member findMember = memberService.findOne(saveId).get();
        assertThat(member.getName()).isEqualTo(findMember.getName());
    }

    /*
        위 회원가입은 너무 단순함!
        테스트는 정상 flow도 중요하지만 예외 flow도 매우 중요하다.
     */

    @Test
    public void 중복_회원_예외() {
      
        // given >> 이름이 같은 회원 두명이 있다.
        Member member1 = new Member();
        member1.setName("spring");

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

        // when >> 이름 같은 회원 가입해보자
        memberService.join(member1);

        // try catch 로도 검증할 수 있지만, assertThrows를 이용해 보자
        /*
            try {
                memberService.join(member2);
                // 만약 catch에 안걸리고 아래 코드가 수행된다면
                fail();
            } catch (IllegalStateException e) {
                assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다");
            }
        */

        // memberService.join(member2) 실행 시 IllegalStateException이 발생하는가?
        assertThrows(IllegalStateException.class, () -> memberService.join(member2));
        IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다");

    }

}

  
profile
안녕하세요

0개의 댓글