숑숑이의 개발일기
article thumbnail

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

비즈니스 요구사항 정리

  • 데이터: 회원ID, 이름
  • 기능: 회원 등록, 조회
  • 아직 데이터 저장소가 선정되지 않았으나 개발을 해야하는 상황(가상의 시나리오)

웹 애플리케이션 계층 구조

일반적으로 웹 애플리케이션 계층 구조는 컨트롤러, 서비스, 리포지토리, 도메인, DB로 구성된다.

  • 컨트롤러: 웹 MVC의 컨트롤러 역할, API 개발
  • 서비스: 비즈니스 도메인 객체를 가지고 핵심 비즈니스 로직을 구현한 클래스 (ex:회원의 가입 여부 확인 로직)
  • 리포지토리: 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
  • 도메인: 회원, 주문, 쿠폰 등 주로 데이터베이스에 저장하고 관리되는 비즈니스 도메인 객체

아직 데이터 저장소가 선정되지 않았으므로 인터페이스로 구현 클래스를 변경할 수 있도록 설계한다.

 

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

회원 도메인

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는 고객이 적은 id가 아닌 데이터를 구분하기 위해 시스템이 저장하는 id

 

회원 리포지토리 인터페이스

repository 패키지를 생성후, 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();
}

Optional이란, null 처리 방법 중 하나로 null을 반환하는 메서드에 사용한다. 덕분에 Optional 클래스의 다양한 메서드가 사용가능해진다. 

 

이를 구현한 구현체 MemoryMemberRepository 클래스를 인터페이스의 메서드를 Overriding하여 작성한다.

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import java.util.*;

public class MemoryMemberRepository implements MemberRepository {

    private static Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L;
    // import 단축키 : alt + enter
    @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();
    }
}

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

내가 작성한 클래스가 원하는대로 동작하는지 검증하는 방법 중 하나가 바로 테스트 케이스 작성이다. 코드를 코드로 검증하는 것. 과거 테스트는 자바의 main 메서드를 통해 실행하거나 컨트롤러를 통해 해당 기능을 실행했는데, 이 과정은 오래 걸리고, 반복 실행하기 어렵고, 여러 테스트를 한 번에 실행하기 어렵다는 단점이 있다. 이러한 단점을 해결하기 위해 자바의 JUnit 프레임워크를 사용해 테스트를 해본다.

 

test 폴더에 repository 패키지를 생성한다.

보통 test class는 해당 클래스의 뒤에 Test를 추가해 네이밍한다.

이제 test 코드를 작성한다. test 메서드는 @Test 어노테이션을 지정해준다.

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
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 repository = new MemoryMemberRepository();
    @AfterEach  // 콜백 메서드로 지정
    public void afterEach() {
        repository.clearStore();
    }
    @Test
    public void save()  {
        Member member = new Member();
        member.setName("spring");
        // 줄 바로 내려가기 : ctrl + shift + enter
        repository.save(member);
        Member result = repository.findById(member.getId()).get();	// optional에서 값을 꺼내는 방법. get()

        // 테스트 작성방법 1
        // System.out.println("result = " + (result == member));
        
        // 테스트 작성방법 2
		// Assertions.assertEquals(result, member);

        // 테스트 작성방법 3
        assertThat(member).isEqualTo(result);
    }

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

        // rename : shift + f6
        Member member2 = new Member();
        member2.setName("spring1");
        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("spring1");
        repository.save(member2);

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

Optional 클래스에서 값을 꺼낼때는 get() 메서드를 사용한다. 테스트 코드 작성시에만 해당되며, 실제 코드에서 get() 메서드로 바로 꺼내는 것은 좋지 않은 방법이다.

 

테스트 코드를 실행하는 방법은 아래 사진을 참고한다. 클래스 레벨에서 실행하면 해당 클래스의 전체 테스트 코드가 실행되고, 각 메서드에서 실행하면 해당 메서드만 실행된다.

폴더를 선택후 실행하면, 전체 클래스의 테스트 코드를 실행할 수 있다.

실행 후 결과

테스트 메서드의 실행 순서는 보장이 안된다. 고로 모든 테스트는 메서드 별로 독립적으로 동작하도록 설계해야 한다.

한 번에 여러 테스트 코드를 실행하면, 메모리 DB에 데이터가 남아있다. 테스트가 끝날 때 마다 레포지토리의 데이터를 clear 해주는 메서드를 작성해야 한다.

 

@AfterEach 어노테이션은 해당 메서드를 각 메서드가 끝나고 실행되는 콜백 메서드로 지정한다.

MemoryMemberRepository에 아래의 메서드를 추가한다.

public void clearStore() {
	store.clear();
}

이후 MemoryMemberRepositoryTest에 아래의 코드를 추가하면 테스트 코드가 하나 끝날 때 마다 데이터를 clear한다.

@AfterEach  // 콜백 메서드로 지정
public void afterEach() {
    repository.clearStore();
}

 

위에서 먼저 MemberMemoryRepository를 개발 완료 후 Test 클래스를 작성했는데, 테스트 코드를 먼저 작성 후 개발하는 방식도 있다. 이를 TDD(테스트 주도 개발)이라고 한다.

 

회원 서비스 개발

이제 회원 repository와 domain을 활용해 비즈니스 로직을 작성한다.

service 패키지 생성후 MemberService 클래스를 작성한다.

package hello.hellospring.service;

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

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) {
        // 같은 이름이 있는 중복 회원은 가입 X
        /*Optional<Member> result = memberRepository.findByName(member.getName());
        result.ifPresent(m -> {
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        });*/
        // 위의 코드를 아래와 같이 바꿀 수 있다. 이미 findByName이 Optional을 리턴하고 있으므로

        validateDuplicateMember(member);    // 중복회원 검증
        memberRepository.save(member);
        return member.getId();
    }

    private void validateDuplicateMember(Member member) {   // 메서드 분리 : shift + ctrl + alt + T
        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);
    }
}

ifPresent메서드는 Optional 클래스의 메서드로, 어떠한 값이 있으면 해당 옵션을 통과한다.

서비스는 비즈니스에 의존적으로 설계하므로 원활한 협업을 위해서 비즈니스에 가까운 용어를 사용한다.

 

회원 서비스 테스트

클래스에서 Ctrl+Shift+T 단축키를 통해 테스트 클래스를 바로생성 할  수 있다.

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import 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;
    MemoryMemberRepository memberRepository;

    @BeforeEach // 같은 memoryrepository 사용을 위한 dependency injection
    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
        Member findMember = memberService.findOne(saveId).get();
        assertThat(member.getName()).isEqualTo(findMember.getName());
    }

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

        // when
        memberService.join(member1);

        // 회원 가입에서 중요한것은 예외처리. 아래와같이 try-catch로 처리할 수 있다.
/*        try {
            memberService.join(member2);
            fail();
        } catch (IllegalStateException e) {
            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니213다.");
        }
*/
        // then
        // try-catch를 대체할 문법 제공!
        IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
    }
}

테스트 메서드는 실제 코드에 포함되지 않으므로 영미권과 협업이 아니라면 한글로도 많이 작성한다.

또, 테스트 코드를 작성시 아래와 같은 패턴에 작성하면 코드 작성에 도움이 된다.

  • given : 무언가가 주어졌을 때
  • when : 실행했을 때
  • then : 실행결과

앞서 작성한 회원 리포지토리에서 clearStore()메서드를 사용해 저장소의 데이터를 초기화했는데, 현재는 static이라 괜찮지만 새로운 memberRepository 객체를 만들어 사용하고 있으므로 다른 객체를 사용할 위험이 있다. 

이를 해결하기 위해 MemberService에 memberRepository를 외부에서 주입하도록 변경한다.

private final MemberRepository memberRepository;

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

이를 Dependency Injection, DI 라고 한다.

위 글은 김영한 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 무료강의를 듣고 정리한 내용입니다.

 

profile

숑숑이의 개발일기

@숑숑-

풀스택 개발자 준비중입니다