개발자로 후회없는 삶 살기

spring PART.중간점검 3 본문

[백엔드]/[spring | 학습기록]

spring PART.중간점검 3

몽이장쥰 2023. 5. 9. 17:28

서론

지금까지 배운 JDBC를 웹 어플리케이션에 적용해봅니다.

 

본론

- 프로젝트 기획 단계

=> 요구사항 분석

기본 웹 요구사항은 같으며 데이터 계층에 DB 관련 요구사항이 생겼습니다.

 

=> 서비스 기능 설계 정리

1. 설계 : 도메인 설계(도메인 별 협력 관계 설계, 도메인 별 클래스 다이어그램 설계), 시스템 흐름 설계, UI 설계, DB 설계

2. 기능 : 요구사항 명세서에 작성(원래 요구사항 명세서에는 개발 외적인 기능도 많아서 기능 정리에서는 별도로 기능을 정리해야한다.)

 

- 프로젝트 설계 단계

1. 도메인 엔터티 설계

entity로는 회원이 있습니다. 회원끼리 계좌 이체를 하기 위해 회원 가입시 돈을 입력해야합니다.

 

2. 도메인별 협력 관계 설계

1) 회원 도메인

이 어플리케이션은 회원 도메인이 있습니다. 회원 도메인을 요구사항에 맞게 다이어그램을 설계합니다. 클라가 컨트롤러를 통해 회원 저장소에 접근하는데 이때 회원가입, 로그인, 조회를 할 수 있습니다.

 

클라가 회원 서비스로 계좌 이체를 하는데 이때 fromId, toId, money 3가지 파라미터를 넘겨 줍니다. 도메인별 협력 관계 설계는 클라이언트로부터 시작해서 비즈니스 로직을 수행합니다. 서비스를 거쳐 데이터 계층에 도달하도록 비즈니스 로직을 수행합니다.

 

3. 도메인별 클래스 다이어그램 설계

1) 회원 도메인

회원 도메인 협력 관계를 보고 회원 클래스 다이어그램을 만듭니다. 

 

멤버 서비스에서 멤버 저장소를 사용합니다.

 

4. 프론트엔드 UI 설계
1) 홈 화면

 


2) 로그인


3) 회원 목록


4) 계좌 이체

 

5. DB 설계

 


6. 서비스 제공 흐름

모든 뷰는 컨트롤러를 호출해야만 접근할 수 있습니다.

 

- 개발 시작

1. 도메인별 역할 개발

1) 회원 도메인

public interface MemberRepository {
    Member save(Member member);
    Member findById(String id);
    List<Member> findAll();
    Member findByLoginId(String loginId);
    void update(String id, int money);
    void delete(String id);
}

회원 클래스 다이어그램을 보고 회원 도메인 역할을 개발합니다. 레포에서 SQL 예외를 스프링 제공 런타임 예외로 바꿀 것이라서 SQL 예외에 의존하지 않아 throws 선언을 하지 않아도 됩니다.

 

2. 도메인별 구현 개발

1) 회원 도메인 비즈니스 로직 구현

회원 도메인 역할을 보고 회원 도메인 비즈니스 로직을 구현합니다. entity부터 구현하고 레포를 구현합니다.

 

public void accountTransfer(String fromId, String toId, int money) {
    bizlogic(fromId, toId);
}

회원 도메인은 계좌 이체라는 비즈니스 로직을 가지고 있습니다.

 

// 버전 1 비즈니스 로직상 사용자에서 예외를 잡아보자는 경우, 이때도 그냥 예외로 던질 수 있다.
public Member join(Member member) {
    try {
        memberRepository.save(member);
        return member;
    } catch (DuplicateKeyException e) {
        String retryId = generateNewId(member.getLoginId());
        member.setLoginId(retryId);
        memberRepository.save(member);
        return member;
    }
}


// 버전 2 그냥 예외로 던져버리는 경우
public Member joinThrow(Member member) {
    Member findMember = memberRepository.findByLoginId(member.getLoginId());

    // 이미 존재하지 않으면 null이 들어오나 단위 테스트로 확인해야 함
    if (findMember != null) {
        throw new IllegalStateException("이미 존재하는 회원임");
    }

    memberRepository.save(member);
    return member;
}

또한 기존에는 회원 가입을 컨트롤러에서 레포를 호출해서 했습니다. 그때는 저장하는 기능만 있으면 돼서 그랬습니다. 이번에는 저장하는 기능에 "이미 존재하는 회원"이라면 예외를 터트리는 비즈니스 로직을 추가하여 컨트롤러에서 서비스를 호출하여 회원 가입을 하도록합니다. 

 

-> 비즈니스 로직에서 서비스와 데이터 계층 간 예외처리

throw new NoSuchElementException("member not found memberId = " + loginId);

계좌 이체 중 없는 id는 레포지토리에서 예외가 발생하고 이를 호출한 서비스로 예외가 던져집니다. 그러면 이때 레포와 서비스 둘 다 예외가 터지게 해야할까요? 예외를 공통처리하지 않는다면 어디에서 처리할 지 생각해봅니다.

 

1. 레포에서 터진 상황
1) 레포에서 잡는다.
2) 서비스에서 잡는다. 
3) 서비스에서 걍 흘린다.

 

2. 서비스에서 터진 상황
1) 서비스에서 잡는다.
2) 서비스에서 흘리고 was에서 공통처리한다. 컨트에서 잡지 않는다. 

기본은 이러합니다. 예외가 레포에서 발생하면 절대로 서비스에서도 예외를 new throw로 만들지 않습니다. 그냥 기존 예외를 던집니다. 이것을 이체와 회원 가입으로 알아보겠습니다.

 

① 이체 중 예외

// when
assertThatThrownBy(() -> memberService.accountTransfer(memberA.getLoginId(), "banana", 2000))
        .isInstanceOf(NoSuchElementException.class);

계좌이체 하려고 했는데 없는 회원인 "이체 중 예외"를 알아보겠습니다. 이체 중 예외는 서비스에서 할 수 있는 것이 없습니다. 위에서 말한 것처럼 컨트롤러로 던집니다. accountTransfer 메서드는 트랜잭션이 적용되었고 프록시 메서드가 catch에서 throw를 해주기 때문에 컨트롤러로 예외가 자연스럽게 던져집니다.

 

> SQL 예외가 아닌 비즈니스 로직 상 예외이므로 스프링 추상화 예외로 변환되지 않고 레포에서 발생한 예외가 그대로 전달됩니다.

 

② 회원 가입 중 예외

// 버전 1 비즈니스 로직상 사용자에서 예외를 잡아보자는 경우, 이때도 그냥 예외로 던질 수 있다.
public Member join(Member member) {
    try {
        memberRepository.save(member);
        return member;
    } catch (DuplicateKeyException e) {
        String retryId = generateNewId(member.getLoginId());
        member.setLoginId(retryId);
        memberRepository.save(member);
        return member;
    }
}


// 버전 2 그냥 예외로 던져버리는 경우
public Member joinThrow(Member member) {
    Member findMember = memberRepository.findByLoginId(member.getLoginId());

    // 이미 존재하지 않으면 null이 들어오나 단위 테스트로 확인해야 함
    if (findMember != null) {
        throw new IllegalStateException("이미 존재하는 회원임");
    }

    memberRepository.save(member);
    return member;
}

회원 가입 중 중복 예외를 일으킵니다. 중복은 SQL 예외라 JDBC 예외가 터져서 스프링 제공 예외로 변경되됩니다. 회윈 가입 중 예외는 로직상 잡을 수도 있고 그냥 던질 수도 있습니다. 버전 1은 레포에서 예외가 터진 경우로 서비스에서 잡습니다. 버전 2는 레포에서 예외가 터지지 않고 null을 반환합니다.

 

> 서비스 상에서는 오류인데 데이터 계층 상에서는 예외가 아닌 경우로 이때 서비스가 예외를 터트리고 런타임으로 던질 수 있습니다.

 

 

3) 로그인 비즈니스 로직 구현

@Service
@RequiredArgsConstructor
public class LoginService {
    private final MemberRepository memberRepository;
    public Member login(String loginId, String password) {
        return memberRepository.findByLoginId(loginId);
    }
}

로그인 서비스 비즈니스 로직을 구현합니다.

 

3. 비즈니스 로직 단위 테스트

비즈니스 로직 작성을 맞쳤다면 단위 테스트를 해야합니다. 안하고 웹 부분을 건드리면 어디서 오류가 발생하는지 절대 못 찾을 수도 있으니 반드시 필요한 단계입니다.

 

1) 레포지토리 비즈니스 로직 테스트

@Test
void save() {
    Member member = new Member("apple", 10000);
    log.info("{}", member);
    memberRepository.save(member);

    Member findMember = memberRepository.findByLoginId(member.getLoginId());
    log.info("{}", findMember);
    Assertions.assertThat(findMember).isEqualTo(member);
}

 

2) 서비스 비즈니스 로직 테스트

@Test
void accountTransferEx() {
    // given
    Member memberA = new Member(MEMBER_A, 10000);
    Member memberB = new Member(MEMBER_B, 10000);
    memberRepository.save(memberA);
    memberRepository.save(memberB);

    // when
    assertThatThrownBy(() -> memberService.accountTransfer(memberA.getLoginId(), "banana", 2000))
            .isInstanceOf(NoSuchElementException.class);

    // then
    Member findMemberA = memberRepository.findByLoginId(memberA.getLoginId());
    Member findMemberB = memberRepository.findByLoginId(memberB.getLoginId());
    assertThat(findMemberA.getMoney()).isEqualTo(10000);
    assertThat(findMemberB.getMoney()).isEqualTo(10000);
}

이체 중 없는 id는 예외가 발생합니다. 요구사항에 DB 트랜잭션이 작동하므로 A와 B가 10000원이 유지됩니다.

 

// 서비스에서 잡기
@Test
void join() {
    Member memberA = new Member(MEMBER_A, 10000);
    Member memberB = new Member(MEMBER_A, 10000);
    Member reMemberA = memberService.join(memberA);
    log.info("A = {}", reMemberA);
    Member reMemberB = memberService.join(memberB);
    log.info("B = {}", reMemberB);
}

// 서비스에서 던지기
@Test
void joinThrow2() {
    Member memberA = new Member(MEMBER_A, 10000);
    Member memberB = new Member(MEMBER_A, 10000);
    memberService.joinThrow(memberA);

    assertThatThrownBy(() -> memberService.joinThrow(memberB))
            .isInstanceOf(IllegalStateException.class);
}

회원가입은 비즈니스 로직상으로 서비스에서 잡을 수 있으면 잡고 아니면 자연스럽게 던집니다.

 

 

 

join은 save 했더니 키 중복이 터지고 서비스에서 잡으면 새로운 id를 임의로 만들어서 저장합니다.

 

4. 컨트롤러 뷰 번갈아 시스템 흐름에 따라 개발

개발을 할 때 도메인과 web을 분리합니다. 내부 패키지는 member, login으로 비슷합니다. 컨트롤러 하나 만들고 뷰 만들고 컨토롤러 만들고 뷰 만들기를 반복합니다.

 

1) 홈페이지

홈페이지 시스템 흐름을 보고 개발합니다.

 

2) 회원가입

회원가입 시스템 흐름을 보고 개발합니다. 

 

회원가입 결과 DB에 저장됩니다.

 

3) 로그인

로그인 시스템 흐름을 보고 개발합니다.

 

 

 

로그인 시 없는 id를 입력하면

 

레포지토리에서 NoSuch 예외가 발생합니다.

 

4) 회원 관리

회원 관리 시스템 흐름을 보고 개발합니다.

 

@GetMapping("/members")
public String members(Model model) {
    List<Member> members = memberRepository.findAll();
    model.addAttribute("members", members);
    return "members/members";
}

회원 목록과 회원 상세를 할 수 있습니다.

 

@GetMapping("/members/accountTransfer")
public String accountForm(@ModelAttribute("account") AccountForm form) {
    return "members/accountForm";
}

@PostMapping("/members/accountTransfer")
public String account(@ModelAttribute("account") AccountForm form) {
    memberService.accountTransfer(form.getFromId(), form.getToId(), form.getMoney());
    return "redirect:/members";
}

계좌 이체 메서드는 작성해 놓은 서비스의 메서드를 그대로 사용하면 됩니다.

 

한상범이 q에게 2000을 보내면 계좌 이체가 적용된 모습이 보입니다.

 

 

- 이슈 사항

1. 데이터 소스 에러

테스트 때는 아래 설정 정보를 썼는데 어플리케이션에서는 위 설정 정보로 driver-class가를 추가해야합니다.

 

private final DataSource dataSource;
private final SQLErrorCodeSQLExceptionTranslator translator;
@Autowired
public JdbcMemberRepository(DataSource dataSource) {
    this.dataSource = dataSource;
    translator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
}

또한 주입받을 때 @Autowired를 써서 관례상 보기 좋게 하면 좋고 생성자가 하나면 생략해도 됩니다.

 

@RequiredArgsConstructor
public class JdbcMemberRepository implements MemberRepository{
    private final DataSource dataSource;

이렇게 생성자 주입하려고 해도 됩니다.

 

private final DataSource dataSource;
private final SQLExceptionTranslator translator = new SQLErrorCodeSQLExceptionTranslator(dataSource);

 

다만 이렇게 생성자에 데이터 소스가 필요하고 스프링이 자동으로 등록하지 않아 개발자가 new로 생성해야하는 객체가 있는 상황에서 직접 생성자를 만들지 않고  @RequiredArgsConstructor로 하려고 하면 데이터 소스가 등록되지 않았다고 오류가 발생합니다.

 

Comments