그리미
공연 예매 프로젝트 개발 일지(1) - 객체 역할과 책임 그리고 협력 본문
요약 (Summary)
객체의 역할을 고려해서 책임과 협력을 나눈다.
(현행) TicketService 의 purchaseTicket() 은 account 저장, ticket 저장 그리고 memberTicket 저장을 하고 있다.
-> (리팩토링) Ticket 과 Account, MemberTicket 역할에 따라 책임과 협력을 나누었습니다.
(현행) Ticket 이 seatCount를 계산하고 있다.
-> (리팩토링) Ticket 과 Seat 를 구분하고 Seat 에서 해당 로직을 처리했습니다.
배경 (Background)
TicketService
@Transactional
public void purchaseTicket(final String nickName, final Long ticketId, final Integer requestSeatCount) {
Member member = memberRepository.findByNickName(nickName)
.orElseThrow(() -> new RuntimeException("존재하지 않는 이름입니다 닉네임 : " + nickName));
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다 상품Id : " + ticketId));
Account account = accountRepository.findById(member.getAccountId())
.orElseThrow(() -> new RuntimeException("존재하지 않는 계좌입니다"));
account.calculate(ticket.getFixedPrice() * requestSeatCount);
accountRepository.save(account);
ticket.calculateSeat(requestSeatCount);
ticketRepository.save(ticket);
MemberTicket memberTicket = MemberTicket.of(member.getId(), ticketId, ticket.getFixedPrice(), requestSeatCount);
memberTicketRepository.save(memberTicket);
}
TicketService purchaseTicket() 은 account 저장, ticket 저장 그리고 memberTicket 저장을 하고 있다.
차후 프로젝트 서비스가 늘어 난다고 가정하자.
각종 할인 정책이 추가되게 되면 account calculate를 수정하기 위해서 위의 코드까지 와서 수정 하는 등
프로젝트가 굉장히 복잡해 진다.
그렇기 때문에 객체 책임에 맞게 그 역할과 협력을 구성 해 주어야한다.
Ticket
public void calculateSeat(final Integer requestCount){
if (this.seatCount < requestCount){
throw new RuntimeException("좌석이 부족합니다.");
}
this.seatCount -= requestCount;
}
현재 Ticket에서 좌석 관련 로직을 처리하고 있다. 이렇게 되면 Ticket이 너무 많은 책임을 가지게 되서 서비스가 늘어남에 따라 (특정 등급 좌석 처리 로직 등등) Ticket 코드가 비대해진다.
그렇기 때문에 별도의 Seat 객체를 만들고 해당 역할을 위임한다.
목표 (Goals)
1. 객체의 책임에 맡게 역할와 협력을 나누기
목표가 아닌 것 (Non-Goals)
1. 트랜잭션, 동시성 이슈 처리
계획 (Plan)
1. Ticket 이 seatCount를 계산하고 있다. 를 해결 해보자.
먼저 Ticket과 Seat 를 분리 한다.
@Embedded 와 @Embeddable 을 사용하였다.
참고 자료
https://www.baeldung.com/jpa-embedded-embeddable
아래와 같이 구현했다.
Ticket
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Ticket {
// ... 코드 생략
@Embedded
private Seat seat;
}
Seat
@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Seat {
private Integer seatCount;
@Enumerated(value = EnumType.STRING)
private Grade grade;
private Seat(Integer seatCount, Grade grade){
this.seatCount = seatCount;
this.grade = grade;
}
public Grade getGrade(){
return this.grade;
}
public static Seat of(final Integer seatCount, final Grade grade){
return new Seat(seatCount, grade);
}
public void reserveSeat(final Integer requestCount){
if (this.seatCount < requestCount){
throw new RuntimeException("좌석이 부족합니다.");
}
Integer seatCount = this.seatCount - requestCount;
this.seatCount = seatCount;
}
}
Seat 에 seatCount 만 아니라 Grade까지 넣은 근거
티켓이 팔릴 때, 등급 별로 남겨둬야할 seatCount 가 있다면 이에 대한 유효성 검사를 어디서 할까?
이를 책임 지는 건 Seat 이다.
따라서 Seat 에는 seatCount 와 Grade가 있어야 한다.
2. TicketService 의 purchaseTicket() 은 account 저장, ticket 저장 그리고 memberTicket 저장을 하고 있다. 해결 해보자
앞의 리팩토링으로 TicketSevice의 코드 다음과 같이 변경되었다.
@Transactional
public void purchaseTicket(final String nickName, final Long ticketId, final Integer requestSeatCount) {
Member member = memberRepository.findByNickName(nickName)
.orElseThrow(() -> new RuntimeException("존재하지 않는 이름입니다 닉네임 : " + nickName));
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다 상품Id : " + ticketId));
Account account = accountRepository.findById(member.getAccountId())
.orElseThrow(() -> new RuntimeException("존재하지 않는 계좌입니다"));
account.calculate(ticket.getFixedPrice() * requestSeatCount);
accountRepository.save(account);
ticket.getSeat().reserveSeat(requestSeatCount);
ticketRepository.save(ticket);
MemberTicket memberTicket = MemberTicket.of(member.getId(), ticketId, ticket.getFixedPrice(), requestSeatCount);
memberTicketRepository.save(memberTicket);
}
수정에 앞서 책임에 대해 이야기 해보자.
Ticket 은 스스로를 판매한다.
Seat 는 Ticket이 판매되면 좌석이 줄어 든다.
Account 는 계산이 되면 잔액이 줄어 들어야 한다.
MemberTicket은 특정 Member 에게 Ticket 팔렸을 때 기록을 한다.
먼저 다음 부분을 수정해보자.
account.calculate(ticket.getFixedPrice() * requestSeatCount);
accountRepository.save(account);
현재는 비지니스 메서드를 통해서 처리하고 TicketSevice에서 저장하고있다.
하지만, 이것은 account의 책임이다
accountService 에서 계산이 되면 잔액이줄어드는 로직을 만들어 처리 한다.
참고: https://effectiveprogramming.tistory.com/entry/Tell-dont-ask
Account
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long amount;
private Long memberId;
private Account(final Long amount, final Long memberId){
this.amount = amount;
this.memberId = memberId;
}
public static Account of(final Long amount, final Long memberId){
return new Account(amount, memberId);
}
public void deductAmount(Integer deductionAmount) {
if (this.amount < deductionAmount) {
throw new RuntimeException("잔액이 부족합니다.");
}
Long remainderAmount = this.amount - deductionAmount;
this.amount = remainderAmount;
}
}
AccountService
@Service
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
public void deductAmount(Account account, Ticket ticket, Integer requestSeatCount){
Integer totalPaymentAmount = ticket.getFixedPrice() * requestSeatCount;
account.deductAmount(totalPaymentAmount);
accountRepository.save(account);
}
}
이제 다음 부분을 수정 해보자
ticket.getSeat().reserveSeat(requestSeatCount);
ticketRepository.save(ticket);
Seat 의 몇개의 좌석이 남았는 지 처리하는 로직이 있으니 이에 맞춰 바꿔주었다.
Seat seat = ticket.getSeat();
seat.calculateSeat(requestSeatCount);
마지막으로 다음 로직을 개선해보자
MemberTicket memberTicket = MemberTicket.of(member.getId(), ticketId, ticket.getFixedPrice(), requestSeatCount);
memberTicketRepository.save(memberTicket);
memberTicket에 저장 로직을 위임해보자
MemberTicket
@Service
@RequiredArgsConstructor
public class MemberTicketService {
private final MemberTicketRepository memberTicketRepository;
public void registerTicketForMember(Member member, Ticket ticket, Integer requestSeatCount){
MemberTicket memberTicket = MemberTicket.of(member.getId(), ticket.getId(),
ticket.getFixedPrice(), requestSeatCount);
memberTicketRepository.save(memberTicket);
}
}
최종적으로 수정된 TicketService의 코드는 다음과 같다
@Service
@RequiredArgsConstructor
public class TicketService{
private final AccountService accountService;
private final MemberTicketService memberTicketService;
private final TicketRepository ticketRepository;
private final MemberRepository memberRepository;
private final AccountRepository accountRepository;
public GradeCount countTicketByGradeForPerformance(Performance performance) {
return GradeCount.from(ticketRepository.findAllById(performance.getTicketIds()));
}
@Transactional
public void purchaseTicket(final String nickName, final Long ticketId, final Integer requestSeatCount) {
Member member = memberRepository.findByNickName(nickName)
.orElseThrow(() -> new RuntimeException("존재하지 않는 이름입니다 닉네임 : " + nickName));
Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다 상품Id : " + ticketId));
Account account = accountRepository.findById(member.getAccountId())
.orElseThrow(() -> new RuntimeException("존재하지 않는 계좌입니다"));
Seat seat = ticket.getSeat();
accountService.deductAmount(account, ticket, requestSeatCount);
seat.reserveSeat(requestSeatCount);
ticketRepository.save(ticket);
memberTicketService.registerTicketForMember(member, ticket, requestSeatCount);
}
}
이외 고려 사항들 (Other Considerations)
메서드 명을 용도에 따라 알기 쉽게 바꿔주었다.
1. AccountService 의 deductAmount (이전 calculate)
2. Seat 의 reserveSeat (이전 calculateSeat)
느낀 점
역할이 잘 나누어져있으면 생기는 장점 -> 변경의 최소화!
Account 로직이 바뀌어도 TicketService 는 바꿀 필요 없다!
TDA 원칙에 따라서 아래 코드 리팩토링 하고 싶었다.
AccountService
public void deductAmount(Account account, Ticket ticket, Integer requestSeatCount){
Integer totalPaymentAmount = ticket.getFixedPrice() * requestSeatCount;
if (account.getAmount() < totalPaymentAmount){
throw new RuntimeException("잔액이 부족합니다.");
}
Long remainingBalance = account.getAmount() - totalPaymentAmount;
accountRepository.save(Account.of(remainingBalance, account.getMemberId()));
}
수정 코드
AccountService
public void deductAmount(Account account, Ticket ticket, Integer requestSeatCount){
Integer totalPaymentAmount = ticket.getFixedPrice() * requestSeatCount;
account.deductAmount(totalPaymentAmount);
accountRepository.save(account);
}
Account
public void deductAmount(Integer deductionAmount) {
if (this.amount < deductionAmount) {
throw new RuntimeException("잔액이 부족합니다.");
}
Long remainderAmount = this.amount - deductionAmount;
this.amount = remainderAmount;
}
Account 가 온전히 본인의 책임을 가지니 Account 로직만 바꿔주면 된다.
TicketServie 에서 위의 로직을 호출하지만 TicketService 는 호출만 하지 별도의 수정을 하지 않는 다.
만약 리팩토링 전 처럼 TicketService에서 Account 내부 로직을 가지고 있었다면
코드 수정 범위가 더 늘어났을 거다.
'프로젝트' 카테고리의 다른 글
파일 공유 구현해보자 (0) | 2024.05.19 |
---|