SOLID Example : 도서 대출 시스템

함형주·2023년 1월 15일
0

질문, 피드백 등 모든 댓글 환영합니다.

소개

객체 지향 개발 원칙인 SOLID의 이해를 높이기 위해 진행한 프로젝트입니다.
간단한 도서 대출 시스템을 SOILD 원칙을 준수하도록 설계하여 개발했습니다.

해당 프로젝트 주제의 퀄리티보다 SOLID 원칙을 따라 설계하는 것이 목표였기에 최대한 간단한 예제로 구성했습니다.

프로젝트 소스 코드는 GitHub 에서 확인하실 수 있습니다.

개발환경

  • IDE : IntelliJ
  • OS : Window 10
  • Java 11
  • Gradle 7.6

의존성 추가

  • Lombok : 개발 시 반복 작업을 줄여주는 편의성 라이브러리
  • Junit5 : 자바 기반 테스트 프레임워크
  • Assertj : 자바 기반 테스트 라이브러리

기획

예제

  • 도서 대출에 관한 정책은 2가지, SOLID 원칙 중 OCP(개방-폐쇄 원칙)에 따라 클라이언트 코드의 수정 없이 설정 클래스 파일의 변경만으로 서비스를 바꿀 수 있어야 한다.
    • LimitLoanService : 등급에 따라 대출 한도의 차이가 있으며 대출 요금은 동일하다.
    • DiscountLoanService : 등급에 따라 대출 요금의 차이가 있으며 대출 한도는 동일하다
  • 책의 재고와 회원의 대출 한도가 남아 있을 경우에만 대출이 가능하다.

요구사항

[Member]

  • 회원은 Long id, String name, Grade grade, Map loans 필드를 갖는다.
  • 회원은 BASIC 혹은 VIP 등급을 갖는다
  • 회원은 재고가 남아 있는 책을 대출할 수 있다.
  • 회원의 대출 한도 및 요금은 LoanService(Interface)에 의해 결정된다. (LoanService 의 구현체는 두 개)

[Book]

  • 책은 Long id, String name, String author, int price, int stockQuantity의 필드를 가진다.

[Loan]

  • 대출은 Long id, Long bookId, int loanPrice 필드를 갖는다.

[Repository]

  • Repository는 도메인 객체의 저장과 조회 기능을 담당한다.

[LoanService]

  • 해당 서비스는 DiscountLoanService, LimitLoanService 구현체를 가진다.
  • 대출 실행 시 대출 가능 여부를 판단하고 대출 요금 책정 및 책 재고 수량 변경, Member에 해당 대출에 대한 참조가 추가된다.
  • 반납 시 책의 재고 수량을 변경하고 Member에 해당 대출에 대한 참조를 제거한다.

[ApplicationInit]

  • 해당 클래스에서 Repository, LoanService에 대하여 의존성 주입의 역할을 담당한다.

프로젝트 개발

의존성 추가

build.gradle

plugins {
    id 'java'
}

group 'library'
version '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    compileOnly 'org.projectlombok:lombok:1.18.24'
    annotationProcessor 'org.projectlombok:lombok:1.18.24'

    testCompileOnly 'org.projectlombok:lombok:1.18.24'
    testAnnotationProcessor 'org.projectlombok:lombok:1.18.24'
    testImplementation 'org.junit.jupiter:junit-jupiter:5.9.2'
    testImplementation 'org.assertj:assertj-core:3.24.1'
}

test {
    useJUnitPlatform()
}

공통

Sequence

public class Sequence {

    private static Long sequence = 0L;

    public static Long getSequence() {
        return ++sequence;
    }
}

객체 생성 시 사용 될 ID 값의 생성을 담당합니다.

OutOfStockException

public class OutOfStockException extends RuntimeException{}

책 재고 부족 시 해당 예외가 발생합니다.

OutOfLoanLimitException

public class OutOfLoanLimitException extends RuntimeException{}

대출 한도 초과 시 해당 예외가 발생합니다.

ApplicationInit

public class ApplicationInit {

    public MemberRepository memberRepository() {
        return new MemberRepositoryImpl();
    }

    public BookRepository bookRepository() {
        return new BookRepositoryImpl();
    }

    public LoanService loanService() {
//        return new DiscountLoanService(memberRepository(), bookRepository());
        return new LimitLoanService(memberRepository(), bookRepository());
    }
}

Repository와 LoanService에 대해 의존성을 주입하는 역할을 가집니다.

Member

member

@Getter @EqualsAndHashCode
public class Member {

    private Long id;
    private String name;
    private Grade grade;
    private Map<Long, Loan> loans;

    public static Member createMember(Long id, String name, Grade grade, Map loans) {
        Member member = new Member();
        member.id = id;
        member.name = name;
        member.grade = grade;
        member.loans = loans;

        return member;
    }
}

Grade

public enum Grade {
    BASIC, VIP
}

MemberRepository (Interface)

public class MemberRepositoryImpl implements MemberRepository{

    private static Map<Long, Member> store = new HashMap<>();

    @Override
    public Long save(Member member) {
        store.put(member.getId(), member);
        return member.getId();
    }

    @Override
    public Member findById(Long id) {
        return store.get(id);
    }
}

Member 객체의 저장과 조회를 담당합니다.

Member 테스트

MemberTest

public class MemberTest {

    ApplicationInit init = new ApplicationInit();

    private final MemberRepository memberRepository = init.memberRepository();

    @Test
    public void createMember() {
        /****** given - 회원 생성 *************/
        Member basic_member = Member.createMember(Sequence.getSequence(), "Basic member", Grade.BASIC, new HashMap<>());
        Member vip_member = Member.createMember(Sequence.getSequence(), "VIP member", Grade.VIP, new HashMap<>());
        Long long1 = memberRepository.save(basic_member);
        Long long2 = memberRepository.save(vip_member);
        /****** given - 회원 생성 *************/

        /****** when - 회원 조회 *************/
        // BASIC Member
        Member find1 = memberRepository.findById(long1);
        // VIP Member
        Member find2 = memberRepository.findById(long2);
        /****** when - 회원 조회 *************/

        /****** then - 회원 비교 *************/
        // 리포지토리에 저장이 잘 됐는지, 등급은 맞는 지 비교
        assertThat(basic_member).isEqualTo(find1);
        assertThat(find1.getGrade()).isEqualTo(Grade.BASIC);

        assertThat(vip_member).isEqualTo(find2);
        assertThat(find2.getGrade()).isEqualTo(Grade.VIP);
        /****** then - 회원 비교 *************/
    }
}

Member 객체를 생성하고 리포지토리에 저장, 처음 생성한 객체와 조회한 객체를 비교

Book

Book

@Getter @EqualsAndHashCode
public class Book {

    private Long id;
    private String name;
    private String author;
    private int price;
    private int stockQuantity;

    public static Book createBook(Long id, String name, String author, int price, int stockQuantity) {
        Book book = new Book();
        book.id = id;
        book.name = name;
        book.author = author;
        book.price = price;
        book.stockQuantity = stockQuantity;
        return book;
    }

    public void minusStockQuantity() {
        this.stockQuantity--;
    }

    public void plusStockQuantity() {
        this.stockQuantity++;
    }
}

Book 클래스는 객체 생성 메서드와 대출이 실행될 시 재고 수량을 조절하는 메서드를 가집니다.

BookRepository (Interface)

public class BookRepositoryImpl implements BookRepository{

    public static Map<Long, Book> store = new HashMap<>();

    @Override
    public Long save(Book book) {
        store.put(book.getId(), book);
        return book.getId();
    }

    @Override
    public Book findById(Long id) {
        return store.get(id);
    }
}

Book 객체의 저장과 조회를 담당합니다.

Book 테스트

BookTest

public class BookTest {

    ApplicationInit init = new ApplicationInit();
    private final BookRepository bookRepository = init.bookRepository();

    @Test
    public void createBook() {
        /****** given - 책 생성 *************/
        Book book1 = Book.createBook(Sequence.getSequence(), "book1", "author1", 12000, 1);
        Long long1 = bookRepository.save(book1);
        /****** given - 책 생성 *************/

        /****** when - 책 조회 *************/
        Book findBook = bookRepository.findById(long1);
        /****** when - 책 조회 *************/

        /****** then - 책 비교 *************/
        assertThat(findBook).isEqualTo(book1);
        /****** then - 책 비교 *************/
    }
}

Loan

Loan

@Getter @EqualsAndHashCode
public class Loan {

    private Long id;
    private Long bookId;
    private int loanPrice;

    public static Loan createLoan(Long id, Long bookId, int loanPrice, Member member, Book book) {
        Loan loan = new Loan();
        loan.id = id;
        loan.bookId = bookId;
        loan.loanPrice = loanPrice;

        member.getLoans().put(loan.getId(), loan);
        book.minusStockQuantity();

        return loan;
    }

    public static void returnLoan(Long id, Member member, Book book) {
        member.getLoans().remove(id);
        book.plusStockQuantity();
    }
}

Loan은 객체 스스로 생명 주기를 관리하는 메서드를 가집니다.

일반적으로는 Loan 객체도 리포지토리에 저장하여 관리 해야하겠지만 예제를 단순화 하기 위해 단순히 Member의 Map 필드에 객체를 저장했습니다.

LoanService

public interface LoanService {

    Loan loan(Long memberId, Long bookId);

    void returnBook(Long loanId, Long memberId, Long bookId);

}

LoanService 에서는 대출의 실행과 반납하는 기능만을 관리합니다.

첫 정책 적용

LimitLoanService

public class LimitLoanService implements LoanService{

    private final MemberRepository memberRepository;
    private final BookRepository bookRepository;

    public LimitLoanService(MemberRepository memberRepository, BookRepository bookRepository) {
        this.memberRepository = memberRepository;
        this.bookRepository = bookRepository;
    }

    @Override
    public Loan loan(Long memberId, Long bookId) {
        Member member = memberRepository.findById(memberId);
        Book book = bookRepository.findById(bookId);

        if (book.getStockQuantity() == 0) throw new OutOfStockException();
        if (member.getGrade().equals(Grade.BASIC) && member.getLoans().size() >= 1) 
            throw new OutOfLoanLimitException();
        else if (member.getGrade().equals(Grade.VIP) && member.getLoans().size() >= 3) 
            throw new OutOfLoanLimitException();

        return Loan.createLoan(Sequence.getSequence(), bookId, book.getPrice() / 10, member, book);
    }

    @Override
    public void returnBook(Long loanId, Long memberId, Long bookId) {
        Member member = memberRepository.findById(memberId);
        Book book = bookRepository.findById(bookId);
        Loan.returnLoan(loanId, member, book);
    }
}

Basic 회원은 최대 1권 대출 가능하며, Vip 회원은 최대 3권 대출 가능합니다.
공통으로 대출 요금은 책 가격의 10%입니다.

loan() : 회원 등급별 대출 가능 조건에 불만족할 시 예외를 발생시키고 Loan 객체의 생성 로직을 실행합니다.

returnBook() : Loan 객체의 대출 반납 로직을 실행합니다.

대출 가능 조건에 따른 예외 발생 로직은 해당 서비스에 종속적인 로직이라 판단하여 기능을 분리하지 않았습니다.

첫 정책 테스트

LimitLoanTest

public class LimitLoanTest {

    static ApplicationInit init = new ApplicationInit();

    private static final MemberRepository memberRepository = init.memberRepository();
    private static final BookRepository bookRepository = init.bookRepository();
    private static LoanService loanService = init.loanService(); // LimitLoanService 주입

    @Test
    @DisplayName("등급에 따른 대출 한도")
    public void limitAccordingToGrade() {
        /****** given - 회원, 책 생성 *************/
        Long basicMemberId = memberRepository.save(
                Member.createMember(Sequence.getSequence(), "basicMember", Grade.BASIC, new HashMap<>()));
        Long vipMemberId = memberRepository.save(
                Member.createMember(Sequence.getSequence(), "vipMember", Grade.VIP, new HashMap<>()));

        Long bookId = bookRepository.save(
                Book.createBook(Sequence.getSequence(), "book", "author", 12000, 10));
        /****** given - 회원, 책 생성 *************/

        /****** when v1 - BASIC 회원 대출 실행 *************/
        loanService.loan(basicMemberId, bookId);
        assertThatExceptionOfType(OutOfLoanLimitException.class)
                .isThrownBy(()-> loanService.loan(basicMemberId, bookId));
        /****** when v1 - BASIC 회원 대출 실행 *************/

        /****** when v2 - VIP 회원 대출 실행 *************/
        loanService.loan(vipMemberId, bookId);
        loanService.loan(vipMemberId, bookId);
        loanService.loan(vipMemberId, bookId);
        assertThatExceptionOfType(OutOfLoanLimitException.class)
                .isThrownBy(()-> loanService.loan(vipMemberId, bookId));
        /****** when v2 - VIP 회원 대출 실행 *************/

    }

    @Test @DisplayName("도서 재고 예외 및 공통 정책")
    public void exception() {
        /****** given - 회원, 책 생성 *************/
        Long basicMemberId = memberRepository.save(
                Member.createMember(Sequence.getSequence(), "basicMember", Grade.BASIC, new HashMap<>()));
        Long vipMemberId = memberRepository.save(
                Member.createMember(Sequence.getSequence(), "vipMember", Grade.VIP, new HashMap<>()));

        Long book1Id = bookRepository.save(
                Book.createBook(Sequence.getSequence(), "book1", "author1", 12000, 10));
        Long book2Id = bookRepository.save(
                Book.createBook(Sequence.getSequence(), "book2", "author2", 12000, 0));
        /****** given - 회원, 책 생성 *************/

        /****** when v1 - 대출 실행 *************/
        Loan loan1 = loanService.loan(basicMemberId, book1Id);
        Loan loan2 = loanService.loan(vipMemberId, book1Id);
        /****** when v1 - 대출 실행 *************/

        /****** then v1 - 대출 결과 *************/
        // BASIC 멤버
        Loan member1Loan = memberRepository.findById(basicMemberId).getLoans().get(loan1.getId());
        // VIP 멤버
        Loan member2Loan = memberRepository.findById(vipMemberId).getLoans().get(loan2.getId());

        // 대출 결과가 회원에게 적용 되었는지 확인
        assertThat(member1Loan).isEqualTo(loan1);
        assertThat(member2Loan).isEqualTo(loan2);

        // book 의 재고 10에서 - 2
        assertThat(bookRepository.findById(book1Id).getStockQuantity()).isEqualTo(8);

        // BASIC 멤버 - 12000 * 0.1 = 1200
        assertThat(member1Loan.getLoanPrice()).isEqualTo(1200);

        // VIP 멤버 - 12000 * 0.1 = 1200
        assertThat(member2Loan.getLoanPrice()).isEqualTo(1200);
        /****** then v1 - 대출 결과 *************/

        /****** when v2 - 대출 반납 실행 *************/
        loanService.returnBook(member1Loan.getId(), basicMemberId, book1Id);
        loanService.returnBook(member2Loan.getId(), vipMemberId, book1Id);
        /****** when v2 - 대출 반납 실행 *************/

        /****** then v2 - 대출 반납 결과 *************/
        // book 재고가 다시 + 2
        assertThat(bookRepository.findById(book1Id).getStockQuantity()).isEqualTo(10);

        Loan member1LoanAfterReturn = memberRepository.findById(basicMemberId).getLoans().get(loan1.getId());
        Loan member2LoanAfterReturn = memberRepository.findById(vipMemberId).getLoans().get(loan2.getId());

        // 대출 내역 삭제
        assertThat(member1LoanAfterReturn).isNull();
        assertThat(member2LoanAfterReturn).isNull();
        /****** then v2 - 대출 반납 결과 *************/

        /****** when v3 - 재고 부족 도서 대출 실행 *************/
        assertThatExceptionOfType(OutOfStockException.class)
                .isThrownBy(()-> loanService.loan(vipMemberId, book2Id));
        /****** when v3 - 재고 부족 도서 대출 실행 *************/
    }
}

limitAccordingToGrade() : 회원 등급에 따른 대출 가능 횟수 조건을 테스트합니다.
대출 한도 초과 시 예외가 잘 생성되는 지 테스트합니다.

exception() : 일반적인 대출 실행과 반납 로직을 검증합니다. 대출 시 Member와 Loan 간의 참조 연결 여부와 Book 객체의 재고 수량 변경, 가격 정책이 잘 반영이 되었는지 테스트 합니다. Book 객체의 재고 수량이 0일 시에 OutOfStockException이 발생하는 지 테스트 합니다.

또한 대출 반납 시에도 Member와 Loan 간의 참조 삭제 여부와 Book 객체의 재고 수량 변경 여부를 테스트합니다.

두번째 정책 적용

DiscountLoanService

public class DiscountLoanService implements LoanService{

    private final MemberRepository memberRepository;
    private final BookRepository bookRepository;

    public DiscountLoanService(MemberRepository memberRepository, BookRepository bookRepository) {
        this.memberRepository = memberRepository;
        this.bookRepository = bookRepository;
    }

    @Override
    public Loan loan(Long memberId, Long bookId) {
        Member member = memberRepository.findById(memberId);
        Book book = bookRepository.findById(bookId);

        if (member.getLoans().size() >= 2) throw new OutOfLoanLimitException();
        if (book.getStockQuantity() == 0) throw new OutOfStockException();

        int loanPrice = Grade.BASIC.equals(member.getGrade()) ?
                book.getPrice() / 5 : book.getPrice() / 10;

        return Loan.createLoan(Sequence.getSequence(), bookId, loanPrice, member, book);
    }

    @Override
    public void returnBook(Long loanId, Long memberId, Long bookId) {
        Member member = memberRepository.findById(memberId);
        Book book = bookRepository.findById(bookId);
        Loan.returnLoan(loanId, member, book);
    }
}

Basic 회원의 대출 요금은 책 가격의 20% 이며 , Vip 회원은 10% 입니다.
공통으로 최대 2권 대출이 가능합니다.

loan() : 회원 등급별 대출 가능 조건에 불만족할 시 예외를 발생시키고 Loan 객체의 생성 로직을 실행합니다.

returnBook() : Loan 객체의 대출 반납 로직을 실행합니다.

대출 가능 조건에 따른 예외 발생 로직은 해당 서비스에 종속적인 로직이라 판단하여 기능을 분리하지 않았습니다.

두번째 정책 테스트

DiscountLoanTest

public class DiscountLoanTest {

    static ApplicationInit init = new ApplicationInit();

    private static final MemberRepository memberRepository = init.memberRepository();
    private static final BookRepository bookRepository = init.bookRepository();
    private static LoanService loanService = init.loanService(); // DiscountLoanService 주입

    @Test @DisplayName("등급에 따른 요금")
    public void createAndReturnLoan() {
        /****** given - 회원, 책 생성 *************/
        Long basicMemberId = memberRepository.save(
                Member.createMember(Sequence.getSequence(), "basicMember", Grade.BASIC, new HashMap<>()));
        Long vipMemberId = memberRepository.save(
                Member.createMember(Sequence.getSequence(), "vipMember", Grade.VIP, new HashMap<>()));

        Long bookId = bookRepository.save(
                Book.createBook(Sequence.getSequence(), "book", "author", 12000, 10));
        /****** given - 회원, 책 생성 *************/

        /****** when v1 - 대출 실행 *************/
        Loan loan1 = loanService.loan(basicMemberId, bookId);
        Loan loan2 = loanService.loan(vipMemberId, bookId);
        /****** when v1 - 대출 실행 *************/

        /****** then v1 - 대출 실행 결과 *************/
        // BASIC 멤버
        Loan member1Loan = memberRepository.findById(basicMemberId).getLoans().get(loan1.getId());
        // VIP 멤버
        Loan member2Loan = memberRepository.findById(vipMemberId).getLoans().get(loan2.getId());

        // 대출 결과가 회원에게 적용 되었는지 확인
        assertThat(member1Loan).isEqualTo(loan1);
        assertThat(member2Loan).isEqualTo(loan2);

        // book 의 재고 10에서 - 2
        assertThat(bookRepository.findById(bookId).getStockQuantity()).isEqualTo(8);

        // BASIC 멤버 - 12000 * 0.2 = 2400
        assertThat(member1Loan.getLoanPrice()).isEqualTo(2400);

        // VIP 멤버 - 12000 * 0.1 = 1200
        assertThat(member2Loan.getLoanPrice()).isEqualTo(1200);
        /****** then v1 - 대출 실행 결과 *************/

        /****** when v2 - 대출 반납 실행 *************/
        loanService.returnBook(member1Loan.getId(), basicMemberId, bookId);
        loanService.returnBook(member2Loan.getId(), vipMemberId, bookId);
        /****** when v2 - 대출 반납 실행 *************/

        /****** then v2 - 대출 반납 결과 *************/
        // book 재고가 다시 + 2
        assertThat(bookRepository.findById(bookId).getStockQuantity()).isEqualTo(10);

        Loan member1LoanAfterReturn = memberRepository.findById(basicMemberId).getLoans().get(loan1.getId());
        Loan member2LoanAfterReturn = memberRepository.findById(vipMemberId).getLoans().get(loan2.getId());

        // 대출 내역 삭제
        assertThat(member1LoanAfterReturn).isNull();
        assertThat(member2LoanAfterReturn).isNull();
        /****** then v2 - 대출 반납 결과 *************/
    }

    @Test @DisplayName("대출 한도 및 도서 재고 예외")
    public void exception() {
        /****** given - 회원, 책 생성 *************/
        Long basicMemberId = memberRepository.save(
                Member.createMember(Sequence.getSequence(), "basicMember", Grade.BASIC, new HashMap<>()));
        Long vipMemberId = memberRepository.save(
                Member.createMember(Sequence.getSequence(), "vipMember", Grade.VIP, new HashMap<>()));

        Long book1Id = bookRepository.save(
                Book.createBook(Sequence.getSequence(), "book1", "author1", 12000, 10));
        Long book2Id = bookRepository.save(
                Book.createBook(Sequence.getSequence(), "book2", "author2", 12000, 0));
        /****** given - 회원, 책 생성 *************/

        /****** when v1 - 대출 실행 *************/
        Loan loan1 = loanService.loan(basicMemberId, book1Id);
        Loan loan2 = loanService.loan(basicMemberId, book1Id);
        /****** when v1 - 대출 실행 *************/

        /****** then v1 - 세번째 대출 결과 *************/
        // 대출 한도 초과 예외 발생
        assertThatExceptionOfType(OutOfLoanLimitException.class)
                .isThrownBy(()-> loanService.loan(basicMemberId, book1Id));
        /****** then v1 - 세번째 대출 결과 *************/

        /****** when v2 - 재고 부족 도서 대출 실행 *************/
        assertThatExceptionOfType(OutOfStockException.class)
                .isThrownBy(()-> loanService.loan(vipMemberId, book2Id));
        /****** when v2 - 재고 부족 도서 대출 실행 *************/
    }
}

createAndReturnLoan() : 대출 실행 시 회원 등급에 맞게 요금이 책정 되었는 지 테스트합니다. 대출 시 Member와 Loan 간의 참조 연결 여부와 Book 객체의 재고 수량 변경 여부를 검증합니다.

exception() : 대출 한도 초과 및 도서 재고 부족 예외가 정상 작동하는지 테스트합니다.

리뷰

생성한 테스트 파일을 작동시키면 ApplicationInit의 loanService() 반환 값에 따라 LimitLoanTest와 DiscountLoanTest 중 하나의 테스트만 성공하는 것을 확인할 수 있습니다.
이는 클라이언트의 코드 변경 없이 설정 클래스 코드의 수정만으로 서비스 변경이 잘 이루어진 것을 반증합니다.

다형성을 활용해 LoanService 의 핵심 기능을 추상화 시켜 세부 정책에 따라 그 구현체를 생성했고 DIP를 만족함으로써 OCP 또한 만족할 수 있었습니다.

profile
평범한 대학생의 공부 일기?

0개의 댓글