웹 프로그래밍/공부, 연습

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

 

이 글은 인프런에서 김영한 님의 "스프링 입문 - 코드로 배우는 스프링 부트, 웹 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;
    }
}

회원 객체 도메인은 idname을 멤버변수로 갖습니다. 여기서 id는 회원이 설정하는 것이 아닌 시스템이 데이터를 구분하기 위해 임의로 부여하는 id입니다. name은 회원이 회원가입 시 입력합니다.

각 멤버 변수는 getter와 setter를 갖습니다.

 

 

다음은 회원 레포지토리의 인터페이스를 작성합니다.

repository폴더를 만들고 MememberRepository라고 인터페이스를 생성합니다.

 

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 폴더 안에 MemberRepository 인터페이스를 구현한 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형태로 반환합니다. 

clearStore 함수는 가입한 회원들의 저장소인 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 객체를 repository로 생성합니다.

 

save함수의 동작을 테스트 하기 위해 Member객체를 생성, name을 spring으로 세팅합니다.

 

레포지토리에 생성된 Member객체를 save함수를 사용해 저장합니다.

 

member에 저장된 id로 findById함수를 통해 레포지토리에 저장된 Member객체를 꺼내고(get()), 꺼낸 Member 객체(result)가 save를 통해 저장했던 member와 동일한 객체인지 검증합니다. 위와 같은 코드를 작성하고 실행하면 정상적으로 테스트가 진행, 종료되는 것을 확인할 수 있습니다(테스트 성공). 만약 실행 중 result와 member가 동일한 Member객체가 아니라면 테스트 수행 후 error를 발생시킵니다(테스트 실패).

 

findByName함수의 동작을 테스트 하기 위해 새로운 Member객체 2개를 생성합니다. 각각의 객체는 member1, member2이며, name은 spring1, 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);
    }
}

서비스 로직을 구현하기 위해서도 리포지토리가 필요합니다. MemoryMemberRepository를 memberRepository로 생성합니다.

 

join은 회원가입을 수행하는 함수입니다. 회원가입은 중복해서 할 수 없다는 로직을 구현하기 위해 member객체를 validateDuplicateMember로 중복 회원인지 검증 후 리포지토리에 member 객체를 save함수를 이용해 저장하고 생성된 회원의 id를 반환합니다.

validateDuplicateMember에서는 리포지토리에 이미 같은 이름으로 등록된 회원이 있는지 확인 후 같은 이름을 가진 회원이 존재한다면 에러메시지를 예외로 날리고 중단하는 함수입니다.

 

findMembers함수는 전체 회원을 조회합니다. 리포지토리의 findAll함수를 사용하여 리스트 형태로 저장소에 등록된 모든 회원 정보를 반환합니다.

 

findOne함수는 member의 id를 가지고 특정한 한 회원을 조회합니다. 레포지토리 findById 함수를 사용해 반환된 Member객체를 Optional로 감싸 반환합니다.

 

 

5. 회원 서비스 테스트

 

회원 서비스가 제대로 동작하는지 검증하기 위한 테스트 코드를 작성합니다.

 

위에서 작성한 MemberService에서 Ctrl + Shift + T를 통해 바로 테스트 파일을 생성할 수 있습니다.

test/java/service 폴더에 MemberServiceTest이름으로 테스트 파일이 생성됩니다.

 

참고로 테스트 코드를 작성할 때는 메소드를 알아보기 쉽도록 한글 등으로 작성해도 됩니다. (테스트 코드는 빌드시 포함되지 않습니다.)

 

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

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

class MemberServiceTest {
    MemberService memberService = new MemberService();
    MemoryMemberRepository memberRepository = new MemoryMemberRepository();

    @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();
        assertEquals(member.getName(),findMember.getName());
    }

    @Test
    void 중복_회원_예외(){
        //given
        Member member1 = new Member();
        member.setName("spring");

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

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

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

테스트를 통한 검증을 위해 MemberService와 MemoryMemberRepository를 memberService와 memberReposiory로 생성합니다.

 

회원가입()은 서비스의 join함수의 일반적인 회원가입을 검증하는 테스트입니다.

회원가입을 수행하기 위해 Member 객체 member를 생성하고 이름을 hello로 세팅합니다.

 

memberService의 join을 사용해 member 객체를 넘겨 회원가입을 진행하고 saveId로 회원가입된 객체의 id를 반환받습니다.

 

리포지토리의 findById를 사용해 가입 후 받은 id인 saveId로 찾은 Member 객체(findMember)의 이름과 hello가 동일한지 검증합니다. 테스트를 수행하면 error 없이 테스트가 수행되는 것을 확인할 수 있습니다.(테스트 성공)

 

 

중복_회원_예외() 함수는 중복 회원이 회원가입을 시도할 때 정상적으로 에러 메시지를 출력하는지를 검증하는 테스트입니다. 

테스트를 수행하기 위해 이름이 spring으로 같은 Member 객체 member1, member2를 생성합니다.

 

memberService의 join을 이용해 member1이 회원 가입합니다.

assertThrows를 사용하면 특정 로직을 실행 시 그에 해당하는 특정한 예외가 발생하는지를 검증할 수 있습니다.

이를 사용하여 member1과 같은 이름을 가진 member2가 회원가입을 시도하는 로직을 실행할 때, IllegalStateException의 예외가 발생하는지 검증합니다.(단순히 try ~ catch문을 사용하여 확인할 수도 있습니다.) 반환된 예외를 e로 받아 예외 e의 메시지가 터져야 하는 에러의 메시지와 동일한 메시지인지를 확인합니다.

 

테스트를 실행하면 중복회원가입으로 예외가 발생하고, 테스트가 정상 작동함을 확인할 수 있습니다.

 

레포지토리 테스트에서 살펴보았듯이, @AfterEach가 없다면 각 테스트가 실행 후 메모리 DB에 데이터가 쌓이기 때문에 회원가입() 테스트에서 member의 name이 spring이라면 error가 발생할 수 있습니다. 각 테스트 후 메모리 DB에 저장된 데이터를 삭제하기 위해  @AfterEach를 사용해 clearStore으로 리포지토리의 데이터를 지워줍니다.

 

 

하지만, 아직 문제가 남아있습니다. 

기존 우리가 사용하고 있는 서비스는 new로 메모리 레포지토리를 직접 생성합니다.

 

 

테스트에서 또한 MemberService와 MemoryMemberRepository를 직접 new로 생성하면서, 테스트에서 생성한 레포지토리와 MemberService에서 사용하는 레포지토리가 같은 레포지토리가 아닙니다. 즉, 각 레포지토리가 다른 인스턴스이기 때문에 내용물이 달리지는 등 문제가 발생할 수 있습니다. 

 

현재 Test 수행시 서비스와 리포지토리 모두 new로 생성됩니다

 

이 문제를 해결하기 위해 회원 서비스의 코드를 변경합니다. 

 

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

 

수정된 회원 서비스는 레포지토리를 멤버 변수로 갖고 있고, 멤버 서비스가 생성될 때(테스트 시) 외부에서 레포지토리를 매개변수로 제공받아 멤버 변수인 레포지토리에 넣어줍니다. 이렇게 수정하면 테스트에 사용되는 레포지토리와 회원 서비스에서 사용하는 레포지토리가 동일한 인스턴스를 사용합니다. 이것을 우리는 DI(Dependency Injection)가 가능하도록 변경하였다고 합니다. 의존성 주입(DI; Dependency Injection)이란 하나의 객체가 다른 객체의 의존성을 제공하는 테크닉을 의미합니다. (클라이언트가 어떤 서비스를 사용할 것인지 지정하는 대신, 클라이언트에게 무슨 서비스를 사용할 것인지를 말해주는 것을 의미합니다.)

 

회원 서비스를 위와 같이 수정하면, 테스트 코드 또한 다음과 같이 수정해야 합니다.

 

import org.junit.jupiter.api.BeforeEach;

class MemberServiceTest {
    MemberService memberService;
    MemoryMemberRepository memberRepository;

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

테스트 클래스는 회원 서비스와 레포지토리를 멤버 변수로 갖습니다. 

@BeforeEach는 @AfterEach의 작동방식과 동일하게 각 테스트 실행 전에 호출됩니다. 이 기능으로 테스트가 각각 수행될 때마다 레포지토리를 생성하고 회원 서비스를 생성 시 생성한 레포지토리를 함께 넘겨줍니다. 이렇게 생성된 레포지토리를 넘기면서 의존관계를 새로 맺어 각 테스트가 서로 영향이 없도록 합니다.

 

모든 수정을 마치고 테스트를 진행하면 error 없이 테스트가 종료됨을 확인할 수 있습니다.

 

 

이번 강의에서는 회원 관리 예제를 작성하며 백엔드 개발을 학습해 보았습니다.

다음 강의에서는 스프링 빈과 의존관계에 대해 학습합니다.