[강좌]스프링 입문 - 3. 회원 관리 예제 - 백엔드 개발

Kevin·2022년 5월 2일
0

이 글은 인프런에서 김영한 님의 "스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술"을 수강 후 개인적으로 공부한 내용을 정리한 블로거의 게시글을 공부용으로 재정리한 글입니다.

이전 강의 에서는 웹을 개발하는 크게 3가지 방법인 정적 컨텐츠, MVC 템플릿 엔진, API에 대해 알아보았다. 이번 강의에서는 간단한 회원 관리 예제를 작성하며 백엔드 개발을 학습한다.

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

개발에 앞서 비즈니스 요구사항을 정리한다.
이번 강의에서 만들 예제는 회원 ID와 이름이 데이터로 저장되고, 회원 등록과 조회의 기능이 있는 간단한 예제인다. 데이터 저장소(DB)는 아직 정해지지 않았다고 가정하며 진행한다.
다음 이미지는 일반적인 웹 애플리케이션의 계층 구조를 보여준다.

컨트롤러는 지난 강의에서 살펴본 것처럼 웹 MVC패턴에서의 컨트롤러 역할을 하고, 서비스에는 회원 가입시 중복 가입 불가능과 같이 핵심 비즈니스 로직이 구현됩니다. 리포지토리는 데이터베이스에 접근하기 위한 것으로, 도메인 객체를 DB에 저장하고 관리하는 역할을 하게된다. 도메인은 이 예제에서의 회원과 같은 비즈니스 도메인 객체를 의미한다. 리포지토리에 의해 DB에 저장, 관리된다.
아래 이미지는 클래스들의 의존관계를 나타낸다.

아직 데이터 저장소가 정해지지 않은 초기 단계이므로, 나중에 쉽게 대체할 수 있도록 인터페이스를 설계한다. 예제 개발을 위해 구현체로는 비교적 가벼운 메모리 기반 데이터 저장소(MemoryMemberRepository)를 사용한다.

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

회원 도메인을 작성한다.
domain 폴더를 만들고 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을 멤버변수로 갖는다. 여기서 id는 회원이 설정하는 것이 아닌 시스템이 데이터를 구분하기 위해 임의로 부여하는 id이다. name은 회원이 회원가입 시 입력한다.
각 멤버변수는 getter와 setter를 갖는다.

다음은 회원 레포지토리의 인터페이스를 작성한다.
repostory폴더를 만들고 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함수, id로 Member 객체를 검색하는 findById함수, name으로 Member 객체를 검색하는 findByName함수와 모든 회원 리스트를 반환하는 findAll함수의 인터페이스를 작성한다.
Optional은 Java8에 들어있는 기능으로, id나 name을 기준으로 Member 객체를 찾아 반환할 때 null이 반환되는 것을 처리하기 위한 것이다.

다음은 회원 레포지토리 메모리 구현체를 작성한다.
repository 폴더 안에 MemberRepostory 인터페이스를 구현한 MemoryMemberRepository로 자바 파일을 생성한다.

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

구현체에서는 저장된 회원들의 id(key)와 Member 객체(Value)를 맵으로 저장할 HashMap으로 구현된 store map을 가지고, sequence는 store의 key값을 생성하는 변수로 동작합니다.
save함수는 Member객체를 받아 sequence를 1증가시키고 이를 id로 설정, Member의 id와 Member 객체를 store map에 저장하고 저장된 member 객체를 반환합니다.
findById 함수는 store에서 id가 key값인 member 객체를 Optional로 감싸 반환합니다. 위와 같이 Optional로 감싸 반환하면 getter로 반환된 값이 null이더라도 처리가 가능합니다.
findByName함수는 저장된 Member 객체를 담고 있는 store map의 모든 value를 돌며 member 객체의 name이 입력받은 name과 같다면(filter)그 Member객체를 반환합니다. 이 때 반환되는 Member 객체도 Optional로 감싸 반환하면 마찬가지로 null이 반환될 때에도 처리가 가능합니다.
findAll 함수는 store에 저장되어 있는 모든 Member객체들을 List형태로 반환합니다.
clelarStore 함수는 가입한 회원들의 저장소인 store의 내용을 모두 삭제합니다.

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

위의 회원 리포지토리 메모리 구현체가 정상적으로 동작하는 지를 테스트합니다.
자바에서 개발한 기능을 테스트하는 방법에는 main 메서드를 실행하거나 웹 애플리캐이션의 컨트롤러를 이용하는 방법이 있습니다. 하지만 이러한 방법에는 준비하고 실행하는데 오래 걸리고, 반복 실행하기가 어려우며, 여러 테스트를 한꺼번에 실행하기가 어렵다는 단점이 있고, 자바에는 JUnit이라는 유닛 테스트 프레임워크로 테스트를 실행하여 이러한 문제를 해결합니다.
test/java/프로젝트명 아래에 repository 폴더를 작성하고(위에서 작성한 폴더명과 동일)MemorymemberRepositoryTest(위의 구현체 이름+Test)파일을 작성합니다.

package hello.hellospring.repository;

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

import java.util.List;

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

class MemoryMemberRepositoryTest {
    MemoryMemberRepository repository = new MemoryMemberRepository();

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

        //when
        repository.save(member);

        //then
        Member result = repository.findById(member.getId()).get();
        //System.out.println("result = "+(result==member));
        //assertEquals(member,null);
        assertThat(member).isEqualTo(result);
    }

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

        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 어노테이션을 작성하면 테스트 유닛이 생성된다.
테스트를 위해 메모리 구현체인 MemoryMemberRepository 객체를 repostiory 로 생성한다.
save함수의 동작을 테스트 하기 위해 Member객체를 생성, name을 spring으로 세팅한다.
member에 저장된 id로 findById함수를 통해 레포지토리에 저장된 Member객체를 꺼내고 (get()), 꺼낸 Member 객체(result)가 save를 통해 저장했던 member와 동일한 객체인지 검증한다. 위와 같은 코드를 작성하고 실행하면 정상적으로 테스트가 진행, 종료되는 것을 확인할 수 있다.(테스트 성공). 만약 실행 중 result와 member가 동일한 Member객체가 아니라면 테스트 수행 후 error를 발생시킨다. (테스트 실패)

findByName함수의 동작을 테스트 하기 위해 새로운 Member 객체 2개를 생성한다. 각각의 객체는 member1, member2이며, name은 spring, spring2로 세팅한다.
생성된 두 Member 객체를 save 함수를 이용해 리포지토리에 저장한다.
리포지토리에서 spring1이 name인 Member객체를 검색하기 위해 findByName함수를 사용합니다.

반환된 객체 result와 member1이 동일한 객체인지 검증합니다. 위와 같은 코드를 작성하고 실행하면 정상적으로 테스트가 진행, 종료됩니다.(테스트 성공) 만약 findByName함수에 매개변수를 spring1이 아니라 spring2를 넘겨주었다면 result(member2)와 member1이 동일하지 않아 error가 발생합니다.(테스트 실패)

findAll 함수의 동작을 테스트 하기 위해 Member객체를 2개 이상 생성(member1, member2), name을 각각 spring1, spring2로 세팅 후 save함수를 이용해 레포지토리에 저장합니다.

findAll함수를 이용해 리포지토리에 저장된 모든 회원 객체 정보를 리스트 형태로 반환받습니다.

반환받은 리스트(result)에 들어있는 멤버 수가 총 2개인지 검증합니다.
이때, error가 발생할 수 있습니다.

위와 같이 코드를 작성하고 각 테스트 유닛을 한꺼번에 테스트하면 save함수와 findByName함수의 동작을 검증하기 위해 작성했던 테스트도 함께 진행됩니다. 단, 각 테스트의 순서는 보장되지 않습니다. 한번에 여러 테스트를 진행하며 메모리 DB에 직전 테스트의 결과가 남아 이전 테스트로 인해 다음 테스트가 실패할 가능성이 있습니다.

테스트는 각각 독립적으로 실행되어야 하며, 테스트의 순서에 의존관계가 있는 테스트는 좋은 테스트가 아닙니다.
즉, 각 유닛의 테스트가 끝나면, 다른 테스트 케이스에 영향이 가지 않도록 메모리를 지워야 합니다.

@AfterEach를 사용하면 한번에 여러 테스트를 실행할 때 각 테스트가 종료될 때마다 이 기능을 실행합니다.

이 기능을 사용하기 위해 다음 코드를 추가합니다.

import org.junit.jupiter.api.AfterEach;
class MemoryMemberRepositoryTest {
    @AfterEach
    public void afterEach(){
        repository.clearStore();
    }
}```

여기서는 각 테스트가 실행된 후 clearStore함수를 사용하여 메모리 DB에 저장된 데이터를 삭제합니다.

위와 같은 코드를 추가한 후 다시 테스트를 실행시키면 모든 테스트가 error 없이 정상적으로 동작하는 것을 확인할 수 있습니다.

프로젝트가 커지고 다른사람과 함께 개발을 작업하게 되는 경우, 테스트를 진행하는 과정은 반드시 필요합니다.

추가적으로 이번 예제에서는 개발을 진행 후 테스트 케이스를 작성하고 테스트하는 방식으로 진행되었습니다.

이와는 반대로 테스트 케이스를 먼저 만들고 이에 맞추어 구현을 진행하는 방식을 테스트 주도 개발(TDD; Test-driven Development)이라고 합니다. 미리 틀을 만들고 틀에 맞추어 개발을 진행하는 방식을 말합니다.

4.회원 서비스 개발

비즈니스 로직을 구현하는 회원 서비스를 개발한다.
리포지토리에서 '저장(save)','검색(findBy~)'과 같이 데이터를 DB에 넣고 빼는 작업이 구현되었다면, 서비스에서는 '회원가입', '전체 회원 조회'와 같은 비즈니스 작업을 구현한다.
service폴더를 생성하고 MemberService 파일을 생성한다.
리포지토리에서 데이터를 DB에 넣고 빼는 직관적인 함수명을 사용했던 것과는 다르게 서비스에서는 함수명을 네이밍 할 때 비즈니스 용어 등 비즈니스와 연관되도록 이름을 설계합니다. (예: join(회원가입을 수행하는 함수))

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
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);
    }
}
profile
성장해나가는 개발자입니다.

0개의 댓글