상세 컨텐츠

본문 제목

[스프링 3팀] 스프링 입문 섹션 4

24-25/Spring 3

by Igumi 2024. 10. 4. 10:00

본문

728x90

 


비즈니스 요구사항 정리

데이터
  • 회원 ID, 이름
기능
  • 회원 등록, 조회
기타 사항
  • 데이터 저장소는 아직 선정되지 않았으며, 가상의 시나리오로 진행
  • 초기 개발 단계에서는 가벼운 메모리 기반 데이터 저장소 사용 예정

 

 

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

  • 컨트롤러 (Controller): 웹 MVC에서 컨트롤러 역할
  • 서비스 (Service): 핵심 비즈니스 로직을 구현
  • 리포지토리 (Repository): 데이터베이스에 접근, 도메인 객체를 DB에 저장 및 관리
  • 도메인 (Domain): 비즈니스 도메인 객체

 

 

클래스 의존 관계

  • 데이터 저장소가 아직 선정되지 않았기 때문에, 인터페이스로 구현 클래스를 변경할 수 있도록 설계
  • 초기에는 메모리 기반 저장소를 사용하나, 추후 데이터베이스로 변경할 수 있음

 

 

 

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

📍  회원 객체 생성 (Member 클래스)

// domain/Member.java

package Corner.Spring_Study.domain;

public class Member {

    private Long id;
    private String name;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

 

 

📍  회원 리포지토리 인터페이스 생성 (MemberRepository 클래스)

// repository/MemberRepository.java

package Corner.Spring_Study.repository;

import Corner.Spring_Study.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 <Member> findById(Long id): Java 8에서 Null을 처리할 때 Optional로 감싸서 반환

 

 

📍  회원 리포지토리 구현체  (MemoryMemberRepository 클래스)

// repository/MemoryMemberRepository.java

package Corner.Spring_Study.repository;

import Corner.Spring_Study.domain.Member;

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

 

 

HashMap: 실무에서는 동시성 문제로 ConcurrentHashMap을 사용

Sequence: 키 값을 생성하며, 실무에서는 AtomicLong 등 동시성 문제를 고려한 자료형 사용

Optional: Null을 반환할 가능성이 있으면 Optional.ofNullable()로 감쌈

 

 

 

 

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

  • JUnit을 사용해 테스트를 효율적으로 실행.
// test/java/하위폴더/repository/MemoryMemberRepositoryTest.java

package Corner.Spring_Study.repository;

import Corner.Spring_Study.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(){
        Member member = new Member();
        member.setName("spring");

        repository.save(member);

        Member result = repository.findById(member.getId()).get();
        assertThat(member).isEqualTo(result); //result를 null로 바꾸면 오류
    }

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

        Member member2 = new Member();
        member2.setName("spring2");
        repository.save(member2);

        Member result = repository.findByName("spring1").get(); //spring2시 오류

        assertThat(result).isEqualTo(member1);
    }

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

        Member member2 = new Member();
        member2.setName("spring2");
        repository.save(member2);

        List<Member> result = repository.findAll();

        assertThat(result.size()).isEqualTo(2);
    }
}

 

Assertions.assertEquals, assertThat: 두 객체가 동일한지 확인하는 메서드

→ 테스트는 순서랑 상관없이 메서드별로 따로 동작하게 설계 : 테스트 하나 끝나면 데이터  클리어 해줘야 함

→ 테스트가 실행되고 끝날 때마다 한 번씩 저장소를 지움

 

 

 

테스트 메서드의 독립성

 

  • 테스트는 순서와 상관없이 독립적으로 동작하도록 설계
  • 테스트 하나가 끝나면 저장된 데이터를 클리어해줘야 함
  • 테스트가 실행되고 끝날 때마다 저장소를 초기화

 

// repository/MemoryMemberRepository.java

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


// test/java/하위폴더/repository/MemoryMemberRepositoryTest.java

    @AfterEach
    public void afterEach(){
        repository.clearStore();
    }

 

 

테스트 주도 개발 (TDD)
  • 테스트를 먼저 작성한 후 그에 맞춰 구현 클래스를 작성
  • 기존 개발 순서와 반대로, 테스트 클래스를 먼저 작성하고 구현을 진행
  • 검증할 수 있는 틀을 먼저 만들고 기능을 개발

 

 

 

회원 서비스 개발

// service/MemberService.java

package Corner.Spring_Study.service;

import Corner.Spring_Study.domain.Member;
import Corner.Spring_Study.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){
        validateDublicateMember(member); //중복 회원 검증
        memberRepository.save(member);
        return member.getId();
    }

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

 

Optional: 내부에 멤버 객체가 있을 경우 Optional로 감싸서 반환, if null을 사용하지 않고, ifPresent를 이용해 객체가 존재하는 경우에만 동작

orElseGet: 값이 있으면 해당 값을 꺼내고, 값이 없으면 주어진 메서드를 실행하거나 기본값을 반환

서비스 클래스의 용어: 비즈니스 로직에 가까운 용어 사용 (예: join, findMembers)

리포지토리 클래스의 용어: 개발 용어에 가까운 용어 사용 (예: save, findById)

 

 

 

회원 서비스 테스트

// src/test/java/하위폴더/service/MemberServiceTest.java

package Corner.Spring_Study.service;

import Corner.Spring_Study.domain.Member;
import Corner.Spring_Study.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
    void 회원가입() {
        //given
        Member member = new Member();
        member.setName("spring");

        //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("spring");

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

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

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

//        try{
//            memberService.join(member2);
//            fail();
//        }catch (IllegalStateException e){
//            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.1234");
//        }

        //then
    }
    
    @Test
    void findMembers() {
    }

    @Test
    void findOne() {
    }
}

 

테스트 한글 사용: 테스트 코드는 실제 실행 코드에 포함되지 않으므로 한글로 작성해도 문제없음. 가독성을 위해 한글로 작성 가능

given, when, then 패턴: 주어진 상황 (given)에서, 특정 동작 (when)을 수행한 후, 예상 결과 (then)를 검증

 

 

 

중복 회원 예외 처리

1. try-catch 방식: 예외를 직접 처리하고 메시지를 검증

        try{
            memberService.join(member2);
            fail();
        }catch (IllegalStateException e){
            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.1234");
        }

→ 중복된 회원을 가입 시도할 때 IllegalStateException을 잡아내고, 해당 예외 메시지가 맞는지 검증

 

2. assertThrows 방식: 예외가 발생하는지 간결하게 확인

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

  memberService.join(member2) 실행 시 IllegalStateException이 발생해야 하며, 메시지가 "이미 존재하는 회원입니다."와 일치하는지 검증

 

 

 

의존성 주입(Dependency Injection, DI)

  • 객체를 직접 생성하지 않고, 외부에서 필요한 객체를 주입받아 사용하는 방법
  • 객체 간의 결합도를 낮추고, 코드의 유연성테스트 가능성을 높일 수 있음
  • 즉 클래스가 필요한 객체를 스스로 생성하는 대신, 외부에서 주입해 주는 방식으로 관리하는 설계 패턴

 

 

MemberService의 MemberRepository 문제 해결

 

MemberService의 MemoryMemberRepository와 테스트에서 사용하는 MemoryMemberRepository가 서로 다르게 생성되어, 동일한 리포지토리를 사용하는 것이 아님. 이를 해결하기 위해 의존성 주입(DI, Dependency Injection) 패턴 사용

 

  • 기존 방식: MemberService와 테스트 코드에서 각각 다른 MemoryMemberRepository 인스턴스를 사용함
// service/MemberService.java

private final MemberRepository memberRepository = new MemoryMemberRepository();

 

 

  • 변경 방식: MemberService를 생성할 때 MemoryMemberRepository 인스턴스를 외부에서 주입하여 같은 인스턴스를 사용하도록 수정
// service/MemberService.java

    private final MemberRepository memberRepository;

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

→ memberRepository를 직접 new로 생성하지 않고, 외부에서 주입받도록 수정

// test/java/하위폴더/service/MemberServiceTest.java

MemberService memberService;
MemoryMemberRepository memberRepository;

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

beforeEach 메서드에서 각 테스트가 실행되기 전에 MemoryMemberRepository 인스턴스를 생성, 이를 MemberService에 주입

 

 

 

 

 

 


Blank Quiz

 

  1. 의존성 주입(Dependency Injection, DI)은 객체를 직접 생성하지 않고, ____________에서 필요한 객체를 주입받아 사용하는 방법이다.
  2. 테스트 코드에서는 테스트가 독립적으로 실행되도록 설계하며, 이를 위해 각 테스트가 끝날 때마다 ____________를 초기화해야 한다.
  3. JUnit 테스트에서 메서드 이름과 설명은 실제 코드에 포함되지 않으므로 ____________으로 작성해도 가독성에 문제가 없다.
  4. 비즈니스 로직을 담당하는 계층은 ____________라고 하며, 이 계층은 핵심적인 로직을 처리하는 역할을 한다.
  5. HashMap은 동시성 문제가 발생할 수 있기 때문에, 실무에서는 ____________을(를) 사용하는 것이 일반적이다.
  6. 테스트 코드를 작성할 때, 예외가 발생하는지 확인하는 메서드는 ____________이다.
  7. 리포지토리(Repository) 클래스는 ____________에 접근하여 도메인 객체를 저장하고 관리하는 역할을 한다.

 

 

Programming Quiz

 

1. 다음은 중복된 회원을 가입하려 할 때 발생하는 예외를 처리하는 코드이다. 이 코드에서 IllegalStateException을 잡아내는 다른 방법은 무엇인가?

IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");

 

 

2. 아래 코드에서, 새로운 회원을 저장한 후 그 회원을 조회하는 테스트 코드를 작성하시오.

Member member = new Member();
member.setName("spring");
memberService.join(member);

 

 

 


<퀴즈 답안>

더보기

Blank Quiz

  • 외부, 저장소, 한글, 서비스(Service), ConcurrentHashMap, assertThrows, 데이터베이스

 

Programming Quiz

1.

try {
    memberService.join(member2);
    fail();  // 예외가 발생하지 않으면 실패
} catch (IllegalStateException e) {
    assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
}​

 

2.

@Test
public void 회원_저장_및_조회() {
    // given
    Member member = new Member();
    member.setName("spring");

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

    // then
    Member findMember = memberService.findOne(saveId).get();
    assertThat(findMember.getName()).isEqualTo("spring");
}​

 

 

 

 

[출처] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술

(섹션 4  회원 관리 예제 - 백엔드 개발)

 

Corner Spring 3

ⓒ Hetbahn

728x90

관련글 더보기