Java Category/Spring

[Spring 입문] 회원 관리 예제 - 백엔드 개발

ReBugs 2024. 1. 21.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


비즈니스 요구사항 정리

  1. 데이터 : 회원 ID, 이름
  2. 기능 : 회원 등록(단, 중복 이름 허용X), 회원 조회
  3. 아직 DB가 선정되지 않은 상황 (가상의 시나리오)

 

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

  • 컨트롤러: 웹 MVC의 컨트롤러 역할
  • 서비스: 핵심 비즈니스 로직 구현 (예를 들어 회원 중복 가입 허용 안됨과 같은 로직들 또한 비즈니스 도메인 객체를 가지고 핵심 비즈니스 로직이 동작하도록 구현한 객체)
  • 리포지토리: 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리 (아직 DB가 선정되지 않은 상태로 가정)
  • 도메인: 비즈니스 도메인 객체, 예) 회원, 주문, 쿠폰 등등 주로 데이터베이스에 저장하고 관리됨

 

클래스 의존 관계

MemberService 클래스

  • 회원 비즈니스 로직이 있는 회원 서비스. (서비스 클래스에 핵심 비즈니스 모델이 있음)

 

MemberRepository 인터페이스

  • DB에 저장하는 것이 아닌 메모리로 단순하게 저장하는 방식을 사용
  •  아직 어떤 DB를 사용할지 정하지 않은 상태이므로 인터페이스를 사용

 

MemoryMemberRepository

  • MemberRepository 인터페이스의 구현 클래스

 

 

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

회원 객체

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();
}
  • Save : member에 들어갈 정보(id, name)을 받았을 때, 이를 저장하는 기능
  • findById : id를 받아서 해당 id를 갖고 있는 member를 찾는 기능
  • findByName  : 사용자 이름을 받아서, 해당 이름을 갖고 있는 member를 찾는 기능
  • findAll : 지금까지 저장된 모든 회원 리스트를 반환하는 기능
java.util.Optional
객체를 포장해주는 Wrapper Class이다. 주로 null을 다룰 때 유용하게 사용된다.
이 클래스는 값이 존재할 수도 있고 아닐 수도 있는 컨테이너 객체를 나타낸다.
주로 메서드 반환 값으로 사용되어, 해당 메서드가 어떤 값이나 null을 반환할 수 있는 경우, null 대신에 Optional을 사용하여 더 안전한 코드를 작성할 수 있다.
Optional은 주로 메서드 체이닝과 함께 사용되어 null 처리를 더 간결하고 안전하게 할 수 있도록 도와준다.

 

회원 리포지토리 인터페이스 구현 클래스

package hello.hellospring.repository;
import hello.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
    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))
                .findAny();
    }
    public void clearStore() {
        store.clear();
    }
}

 

Hashmap.values()
HashMap 클래스의 values() 메서드는 해당 HashMap에 저장된 모든 값들을 Collection 형태로 반환한다.
이 메서드는 HashMap에 저장된 값들의 컬렉션을 얻고자 할 때 사용된다.

 

ofNullable(), filter(), findAny()

Optional.ofNullable()
주어진 값으로 Optional 객체를 생성한다. 값이 null인 경우에도 사용할 수 있다.
Optional.ofNullable 메서드의 반환 값은 Optional 클래스의 인스턴스이다.
-값이 null이 아닌 경우: Optional 인스턴스는 해당 값을 갖는다.
-값이 null인 경우: Optional 인스턴스는 빈 상태(값이 없는 상태)이다.

Optional.filter()
이 메서드는 Optional 객체 내부의 값을 조건적으로 필터링하는 데 사용된다.
이 메서드는 true 또는 false를 반환하는 test 메서드를 인자로 받는다.
Optional 내부의 값이 주어진 조건을 만족하면 동일한 값을 포함하는 Optional을 반환하고, 그렇지 않으면 빈 Optional을 반환한다.
// filter를 사용하여 값이 "World"를 포함하는지 확인
Optional<String> filteredOptional = optionalValue.filter(s -> s.contains("World"));​


Optional.findAny()
Stream 에서 첫 번째 요소를 찾아서 Optional 타입으로 리턴한다.
조건에 일치하는 요소가 없다면 empty 가 리턴된다.
따라서 Stream 의 첫 번째 요소를 구체적으로 원할 때 이 방법을 사용
(병렬 작업을 처리하는 경우 리턴값을 안정적으로 반환)

 

 

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

개발한 기능을 실행해서 테스트 할 때 자바의 main 메서드를 통해서 실행하거나, 웹 애플리케이션의 컨트롤러를 통해
서 해당 기능을 실행한다.

이러한 방법은 준비하고 실행하는데 오래 걸리고, 반복 실행하기 어렵고 여러 테스트를 한번 에 실행하기 어렵다는 단점이 있다.

자바는 JUnit이라는 프레임워크로 테스트를 실행해서 이러한 문제를 해결한다.

보통 테스트 코드를 작성할 때, 테스트 대상이 되는 것들의 이름은 그대로 따라가고 class의 경우 뒤에 Test를 붙여주는 것이 관례이다.

 

package hello.hellospring.repository;
import hello.hellospring.domain.Member;
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() {
        //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
@AfterEach는 JUnit 5에서 제공하는 어노테이션 중 하나로, 각각의 테스트 메서드가 실행된 후에 어떠한 정리 작업을 수행할 수 있도록 하는 어노테이션이다.
이 어노테이션을 사용하면 테스트 메서드가 실행된 후에 자원을 해제하거나 정리 작업을 수행하는 등의 후처리 작업을 할 수 있다.
여기서는 메모리 DB에 저장된 데이터를 삭제한다.

@BeforeEach
@BeforeEach는 JUnit 5에서 제공하는 어노테이션 중 하나로, 각각의 테스트 메서드가 실행되기 전에 필요한 초기화 작업을 수행할 수 있도록 하는 어노테이션이다.
이 어노테이션을 사용하면 중복 코드를 방지하고 테스트 메서드 간의 독립성을 유지할 수 있다.

테스트는 각각 독립적으로 실행되어야 한다. 테스트 순서에 의존관계가 있는 것은 좋은 테스트가 아니다

 

Optional.get()
이 메서드는 Optional 클래스에서 값을 가져오는 메서드이다.
Optional은 값이 존재할 수도 있고 아닐 수도 있는 컨테이너로, get() 메서드는 이 컨테이너에서 값을 추출한다.
get() 메서드는 해당 Optional 객체가 값(Non-null 값)을 포함하고 있을 경우 그 값을 반환다.
// 값이 있는 Optional
Optional<String> optionalWithValue = Optional.of("Hello, World!");

// 값이 있는 경우 get() 메서드로 값을 얻음
String value = optionalWithValue.get();
System.out.println(value); // 출력: Hello, World!

// 값이 없는 Optional
Optional<String> optionalWithoutValue = Optional.empty();

// 값이 없는 경우 get() 메서드를 사용하면 NoSuchElementException 발생
// 주의: 이 부분은 예제이며, 실제 코드에서는 isPresent()나 ifPresent() 등을 사용하여 안전하게 처리하는 것이 좋습니다.
String valueFromEmptyOptional = optionalWithoutValue.get(); // 예외 발생​

그러나 get() 메서드를 사용할 때 주의해야 한다. 만약 Optional이 비어있는 경우(값이 존재하지 않는 경우), NoSuchElementException이 발생할 수 있다.
따라서 get()을 호출하기 전에 isPresent() 메서드로 값의 존재 여부를 확인하는 것이 좋다.

assert란 JUnit에서 테스트에 넣을 수 있는 정적 메서드 호출이다. 어떤 조건이 참인지 검증하며 테스트 케이스 수행 결과를 판별하는 역할을 한다.

assertEquals()
예상 값과 실제 값을 비교하여 두 값이 동일한지를 확인하는 데 사용됩니다. 이 메서드는 테스트 케이스의 성공 여부를 판단하는 데 주로 활용
String expected = "Hello, World!";
String actual = "Hello, World!";
assertEquals(expected, actual);


assertThat().isEqualTo()
메서드 체이닝을 통해 객체나 값의 동등성을 검사하는 방법이다.
이것은 테스트 코드에서 예상 값과 실제 값을 비교하고, 두 값이 동일한지 확인하는 데 사용된다.
assertThat(actual).isEqualTo(expected);

 

 

회원 서비스 개발

회원 서비스는 회원 리포지트리와 도메인을 활용해서 비지니스 로직을 작성하는 것이다.

package hello.hellospring.repository;
import hello.hellospring.domain.Member;

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);
    }
}
ifPresent()
Optional 클래스에서 제공하는 메서드 중 하나로, 값이 존재하는 경우에만 주어진 동작을 실행한다.
이 메서드는
Optional에 값이 존재하는지 여부를 확인하고, 값이 존재하는 경우에만 주어진 동작(Consumer)을 실행다.
// 값이 있는 Optional
Optional<String> nonEmptyOptional = Optional.of("Hello, World!");

// 값이 있는 경우 동작 실행
nonEmptyOptional.ifPresent(value -> {
    System.out.println("Value is present: " + value);
    // 여기서 필요한 다양한 동작을 수행할 수 있습니다.
});

// 값이 없는 Optional
Optional<String> emptyOptional = Optional.empty();

// 값이 없는 경우 동작 실행되지 않음
emptyOptional.ifPresent(value -> {
    System.out.println("This won't be printed, as the value is absent.");
});

 

회원 서비스 테스트

given-when-then 패턴은 test code 작성 시 자주 사용하는 패턴이다.
테스트 코드의 구조를 생각해보면, 어떤 상황이 주어져서(given), 이것을 실행했을 때(when), 어떤 결과가 나와야 한다(then)이라는 구조를 가지고 있음을 알 수 있다.


테스트 코드 작성 시, 준비 - 실행 - 검증 세 부분으로 나누어서 작성하면, 테스트 코드가 길어졌을 때 각 부분(given, when, then)을 보면 테스트 코드를 이해하는데에 도움이 된다.

Given

  • 테스트를 위해 준비를 하는 과정이다.
  • 테스트에서 사용되는 변수, 입력 값들을 정의하거나, Mock 객체를 정의하는 부분이 이에 해당된다.

When

  • 실제로 action을 하는 테스트를 실행하는 과정이다.
  • 하나의 메서드만 수행하는 것이 바람직하다. When은 대체로 가장 중요하지만 가장 짧다.

Then

  • When에서 실행한 결과를 검증하는 과정이다. 예상 값(expected)와 실제 값(actual)을 비교한다.
  • 주로 assertThat 구문을 사용하여 검증한다

 

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.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("이미 존재하는 회원입니다.");
    }
}

 

assertEquals()
메서드는 JUnit에서 제공하는 단언(assertion) 메서드 중 하나로, 예상 값과 실제 값을 비교하여 두 값이 동일한지를 확인하는 데 사용됩니다. 만약 두 값이 일치하지 않으면 해당 테스트는 실패하게 됩니다.
assertEquals(expected, actual);​

 

assertThrows()
JUnit 5에서 제공하는 메서드 중 하나로, 예외가 발생하는지 여부를 테스트하는 데 사용된다.
이 메서드는 특정 예외가 발생하는지를 확인하고, 발생한 예외를 검증할 수 있다.

assertThrows() 메서드는 예외를 기대하고, 예외가 발생하면 테스트를 성공으로 간주합니다.
예외가 발생하지 않거나 다른 예외가 발생하는 경우 테스트는 실패합니다.

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class AssertThrowsExample {
    @Test
    void testDivisionByZero() {
        // 예외가 발생해야 하는 코드
        assertThrows(ArithmeticException.class, () -> {
            int result = 10 / 0; // 0으로 나누기는 ArithmeticException을 발생시킴
        });
    }

    @Test
    void testIndexOutOfBounds() {
        // 예외가 발생해야 하는 코드
        assertThrows(IndexOutOfBoundsException.class, () -> {
            int[] array = new int[5];
            int element = array[10]; // 배열의 범위를 벗어난 인덱스에 접근
        });
    }
}

댓글