옌의 로그

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

스터디/스프링

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

dev-yen 2023. 7. 6. 18:24

https://inf.run/8u6a

 

[무료] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - 인프런 | 강의

스프링 입문자가 예제를 만들어가면서 스프링 웹 애플리케이션 개발 전반을 빠르게 학습할 수 있습니다., 스프링 학습 첫 길잡이! 개발 공부의 길을 잃지 않도록 도와드립니다. 📣 확인해주세

www.inflearn.com

(본 게시글은 인프런 스프링 입문 강의에 의해 작성되었습니다.)

 

비즈니스 요구사항 정리

  • 데이터 : 회원 ID, 이름
  • 기능 : 회원 등록, 조회
  • 데이터 저장소(DB) 선정되지 않음
    • 목적에 따라 선택할 DB가 달라진다

 

일반적인 웹 애플리케이션 계층 구조

  • 컨트롤러 : 웹 MVC의 컨트롤러 역할
  • 서비스 : 핵심 비즈니스 로직
    • 비즈니스 도메인 객체를 가지고 핵심 비즈니스 로직을 동작하도록 구현한 계층
    • ex ) 회원은 중복가입을 할 수 없다
  • 리포지토리 : DB에 접근, 도메인 객체를 DB에 저장하고 관리
  • 도메인 : 비즈니스 도메인 객체
    • ex ) 회원, 주문, 쿠폰 등 주로 데이터베이스에 저장하고 관리된다

 

클래스 의존관계

  • MemberService (회원 비즈니스 로직)
  • MemberRepository (회원 리포지토리)
    • 아직 데이터 저장소가 선정되지 않아서, 인터페이스로 구현 클래스를 변경할 수 있도록 설계한다
  • MemoryMemberRepository (회원 레포지토리 구현체)
    • 개발을 위해 구현체로 가벼운 메모리 기반의 데이터 저장소를 사용한다

 

회원 도메인 & 리포지토리 생성

회원 객체

src/main/java/hellospring/domain/Member.java

package yenie.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;
    } 
}

 

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

src/main/java/hellospring/repository/MemberRepository.java

package yenie.hellospring.repository;

import yenie.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 인지 아닌지) 를 나타내는 wrapper class 이다
  • 주로 null값으로 인해 발생하는 NullPointerException(NPE)를 방지하기 위해 사용된다
  • 상태값
    • 값이 존재하는 경우 Optional 객체 속에 실제 값을 포함한다
    • 값이 존재하지 않는 경우, Optional.empty() 를 통해 빈 Optional 객체를 생성한다
  • 주요 메서드
    • isPresent() : Optional 객체에 값이 존재하는 경우 true 반환, 존재하지 않는 경우 false 반환
    • orElse(), orElseGet() : 값이 존재하지 않는 경우, 기본 값을 제공하거나 람다 표현식을 통해 값을 생성
Optional<String> optionalValue = Optional.of("Hello");
String value = optionalValue.orElse("Default Value");
System.out.println(value); // Hello

Optional<String> emptyOptional = Optional.empty();
String value2 = emptyOptional.orElse("Default Value");
System.out.println(value2); // Default Value

 

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

src/main/java/hellospring/repository/MemoryMemberRepository.java

package yenie.hellospring.repository;

import yenie.hellospring.domain.Member;

import java.util.*;

/**
 * 동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려
 */
 
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 // ID값으로 조회
    public Optional<Member> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }
    
    @Override // 전체 데이터 조회
    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }
    
    @Override // 이름으로 조회
    public Optional<Member> findByName(String name) {
        return store.values().stream()
                .filter(member -> member.getName().equals(name)) //name과 일치하는 회원만 스트림에 남음
                .findAny(); // 필터링된 회원 중 랜덤하게 1명을 선택
    }
    
    public void clearStore() {
        store.clear();
    } 
}

 

  • sequence : 유저 고유 id 값으로 사용할 변수
  • Optional.ofNullable(value) : 주어진 값 value가 null이면 Optional.empty()를 반환, null이 아닌 경우 해당 값으로 채워진 Optional을 반환
  • store.values().stream() : store에서 모든 회원 데이터를 가져온 후 스트림 생성
    • stream은 Java8에서 도입된 개념으로, 데이터 요소의 시퀀스를 나타낸다. 스트림을 생성하면 해당 데이터 소스에 대한 연속적인 데이터 흐름을 나타내는 스트림 객체가 생성된다.

 

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

자바 JUnit 프레임워크를 사용해 테스트를 진행한다

 

회원 리포지토리 메모리 구현체 테스트

src/test/java/hellospring/repository/MemoryMemberRepositoryTest.java

package yenie.hellospring.repository;

import yenie.hellospring.domain.Member;
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() {
        //given
        Member member = new Member();
        member.setName("spring");

        //when
        repository.save(member);

        //then
        Member result = repository.findById(member.getId()).get();
        assertThat(result).isEqualTo(member);
    }

    @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);
    }
}
  • @AfterEach : 각각의 메서드가 끝난 뒤 샐행되는 콜백함수
    • 여러번 테스트를 진행할 경우, DB에 직전 테스트의 결과가 남을 수 있으므로 이전 테스트로 인해 다음 테스트가 실패할 가능성이 있다. 이 때, AfterEach 를 사용해 각 테스트가 종료될 때마다 메모리 DB에 저장된 데이터를 삭제하도록 한다
    • 테스트는 각각 독립적으로 실행되어야 한다. 테스트 순서에 의존관계가 있는 것은 좋은 테스트가 아님!!

AfterEach를 사용하지 않았을 때 발생하는 에러

  • assertThat
    • assertj라이브러리가 제공하는 단언문
    • 체인 형식으로 다양한 단언문을 작성할 수 있다
      (ex, assertThat(actual).[matcher statement])
      • actual : 실제 값
      • matcher statment : 실제값과 비교해 검증하기 위한 매처 문
        • isEqualTo(), isGreaterThan(), contains() 등의 매처를 사용하여 값의 동등성, 크기 비교, 요소 포함 여부 등을 테스트 할 수 있다

 

회원 서비스 개발

src/test/java/hellospring/service/MemberService.java

package yenie.hellospring.service;

import yenie.hellospring.domain.Member;
import yenie.hellospring.repository.MemberRepository;

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) {
        validateDuplicateMember(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);
    }
    
}
  • ifPresent : 값이 존재하는 경우에 대한 추가적인 작업을 수행하는 메서드

 

회원 서비스 테스트

  • cmd+shift+t 를 누르면 자동으로 테스트 코드를 생성할 수 있다
  • 테스트를 원하는 메서드들을 체크해주면 된다

회원 서비스 테스트

src/test/java/hellospring/service/MemberServiceTest.java

package yenie.hellospring.service;

import org.junit.jupiter.api.AfterEach;
import yenie.hellospring.domain.Member;
import yenie.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;
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
    public void 회원가입() throws Exception {

        //Given
        Member member = new Member();
        member.setName("hello");

        //When
        Long saveId = memberService.join(member);

        //Then
        Member findMember = memberRepository.findById(saveId).get();
        assertEquals(member.getName(), findMember.getName());
    }

    @Test
    public void 중복_회원_예외() throws Exception {

        //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("이미 존재하는 회원입니다.");
    }
}
  • @BeforeEach : 각 테스트 실행 전에 호출된다
    • 테스트가 서로 영향이 없도록 항상 새로운 객체를 생성하고 의존관계도 새로 맺어준다
  • assertThrows (예외, 람다) : 람다가 실행 됐을 때, 예외가 발생해야 한다
    • 만약, 예외가 발생하지 않거나, 기대했던 예외가 아닌 경우 해당 테스트는 실패하게 된다.

 

* 회원 서비스 코드를 DI(Dependency Injection) 가능하게 변경하기

DI(의존성 주입)란?

- 객체 간의 의존 관계를 느슨하게 만들고, 코드의 유연성과 재사용성을 높이기 위해 사용되는 소프트웨어 디자인 패턴

- 객체 간의 의존성을 외부에서 주입하는 방식을 사용

 

(기존)

public class MemberService {

      private final MemberRepository memberRepository = new MemoryMemberRepository();

}
  • 위 코드의 경우 회원 서비스 클래스는 회원리포지토리에 강하게 결합되어 있으며, 회원리포지토리의 변경에 민감해질 수 있다
  • 만약 회원 서비스 클래스가 MemoryMemberRepository 대신 다른 구현체를 사용하려면 코드를 직접 수정해야 한다 (유연성과 재사용성을 저하시키는 요인이 됨)

 

(DI 가능하게 변경)

public class MemberService {

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

... 

}

회원 서비스가 생성될 때 회원리포지토리를 같이 받음으로써 객체간의 의존성을 외부에서 주입하도록 한다!

 

 

Comments