Spring/스프링 입문

[Section 3] 회원 관리

jiuuu 2023. 3. 25. 00:35

 비즈니스 요구사항

 

- 데이터: 회원 ID, 이름

- 기능: 회원 등록, 조회

- 아직 데이터 저장소가 선정되지 않음 (가상의 시나리오)

 

[일반 웹 web application 계층 구조]

web app 계층 구조

- 컨트롤러: 웹 MVC의 컨트롤러 구현

- 서비스: 핵심 비즈니스 로직 구현

- 리포지토리: database에 접근, 도메인 객체를 DB에 저장하고 관리

- 도메인: 비즈니스 도메인 객체 

  ex) 회원, 주문, 쿠폰 등 주로 DB에 저장하고 관리됨

 

 * 도메인의 사전적 정의는 "A domain is a field of study that defines a set of common requirements, terminology, and functionality for any software program constructed to solve a problem in the area of computer programming, known as domain engineering."이다. 즉, 컴퓨터 프로그래밍으로 문제를 해결하기 위해 만들 소프트웨어 프로그램을 위한 요구사항, 용어, 기능을 정의하는 학문 영역이 도메인 공학이라는 것이다. 

 간단하게 표현하자면 해결하고자 하는 문제의 영역을 도메인이라 한다. 예시로 인스타그램과 같은 SNS를 만든다고 했을 때, 게시물, 댓글, 좋아요 등을 도메인이라 할 수 있다. 

 

[클래스 의존관계]

- 아직 데이터 저장소가 선정되지 않아, 먼저 인터페이스(MemberRepository)로 구현 클래스를 변경할 수 있도록 설계

- 데이터 저장소는 RDB, NoSQL 등 다양항 저장소 고민중인 상황 가정

- 초기 개발 단계에서는 구현체(MemoryMemberRepository)로 가벼운 메모리 기반 데이터 저장소 사용

 

 

 

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

 

[회원 객체]

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

 

[회원 레포지토리 인터페이스]

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

- Optional<Member> findById(Long id) : NULL 그대로 반환하여 처리하지 않고 Optional로 감싸서 반환한다.

- findAll() : 지금까지 저장해온 모든 회원 리스트 반환

 

[회원 리포지토리 메모리 구현체]

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.springframework.stereotype.Repository;

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

- Optional.ofNullable : NULL일 수도 있으니 이것으로 감싸서 처리

- findByName: findAny해도 없으면 optional로 감싸서 반환

 

 

 

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

 

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

 

[회원 리퍼지토리 메모리 구현체 테스트]

package hello.hellospring.repoisotry;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
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("spring");

        repository.save(member);

        Member result = repository.findById(member.getId()).get();
        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);
    }




}

- @AfterEach: test 하나가 끝나면 DB에 저장된 data(공용 저장) clear. <- 이걸 하지 않으면 class 작동시켰을 때 error가 남. test 실행 순서와 관계없이 모두 잘 작동해야함.

- 테스트 주도 개발(TDD): test를 먼저 만들고 구현 class(MemoryMemberRepository)를 만들어서 돌려 보는 것. 마치 별, 세모 모양 틀을 만들고 그곳에 맞는지 끼워서 확인해보는 것과 비슷함. 하지만 여기서 사용한 방식은 구현 class 먼저 만들고 test 작성했기에 TDD가 아님. 

 

 

 

 회원 서비스 개발

 

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

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

}

 

 

 

 회원 서비스 테스트

 

위에서는 회원 서비스가 회원 리포지토리를 직접 생성하게 했다.

 private final MemberRepository memberRepository = new MemoryMemberRepository;

 

여기서는 회원 리포지토리의 코드가 회원 서비스 코드를 DI(Dependency Injection, 의존성 주입) 가능하게 변경한다. 

 

 * DI란?

 어떤 객체가 사용하는 의존 객체를 직접 만들어 사용하는게 아니라, 주입 받아 사용하는 방법. 외부에서 두 객체 간의 관계를 결정해주는 디자인 패턴으로, 인터페이스(MemberRepository)를 사이에 둬서 클래스 레벨에서는 의존관계가 고정되지 않도록 하고 런타임 시에 관계를 동적으로 주입하여 유연성을 확보하고 결합도를 낮출 수 있게 해준다. 즉, 강하게 결합된 클래스들을 분리하고, 애플리케이션 실행 시점에 객체 간의 관계를 결정해 줌으로써 결합도를 낮추고 유연성을 확보해준다. 

 

private final MemberRepository memberRepository;


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

 

<ctrl+shift+t> : test 생성 단축키 -> 아래 코드 자동 추가

 

[회원 서비스 테스트]

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.AssertionsForInterfaceTypes.assertThat;
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("spring");
        
        //when
        Long saveId = memberService.join(member);

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

    }
    @Test
    public void 중복_회원_예외(){
        //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("이미 존재하는 회원입니다.");

        /*
        try{
            memberService.join(member2);
            fail();
        }catch (IllegalStateException e){
            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
        }
 */
        //then

    }

    @Test
    void findMembers() {
    }

    @Test
    void findOne() {
    }
}

- @BeforeEach: 각 테스트 전에 호출. 테스트가 서로 영향이 없도록 항상 새로운 객체를 생성하고, 의존 관계도 새로 맺어준다.

- build 시 실제 코드에 포함되지 않으므로, test는 과감히 한글로 바꾸어도 된다. 

- Given: 주어져서, When: 이걸 실행했을 때, Then: 이게 나와야 한다.