1. 들어가며
패키는 온라인 상으로 선물박스를 만들고 '주고받는' 서비스이다. 패키에서는 선물박스를 접하는 입장에서 유저를 보내는 유저(Sender), 받는 유저(Receiver), 둘 다 아닌 유저(Stranger) 3가지로 구분할 수 있다.
그렇기 때문에 대부분의 기능에 대해 3가지 입장을 모두 고려하여 로직을 설계해야 한다. 그러다보니 하나의 메서드에서 if문이 늘어나고, 이는 한 메서드의 책임이 너무 커지는 문제를 초래하며 가독성도 같이 떨어진다.
이번 포스트에서는 '선물박스 삭제하기' 로직을 대표로 예시를 들어 설명하겠다. 아래는 리팩토링을 진행하기 전 코드이다.
if문이 너무 많이 중첩되어 있어 주석 없이 코드를 이해하는데 오랜 시간이 소요된다..
public String deleteGiftBox(Long giftBoxId) {
Long memberId = SecurityUtil.getMemberId();
Member member = memberReader.findById(memberId);
GiftBox giftBox = giftBoxReader.findById(giftBoxId);
GiftBoxRole role = getGiftBoxRole(member, giftBox);
if (role.equals(MemberRole.STRANGER)) {
throw new GiftBoxAccessDeniedException();
}
if (role.equals(GiftBoxRole.SENDER)) {
if (giftBox.getDeliverStatus().equals(DeliverStatus.DELIVERED)) {
giftBox.delete();
} else if (giftBox.getDeliverStatus().equals(DeliverStatus.WAITING)) {
letterWriter.delete(giftBox.getLetter());
giftBox.getPhotos().forEach(photo -> {
fileService.deleteFile(photo.getImgUrl());
photoWriter.delete(photo);
});
if (giftBox.getGift().getGiftType().equals(GiftType.PHOTO)) {
fileService.deleteFile(giftBox.getGift().getGiftUrl());
}
giftBox.getGiftBoxStickers().forEach(giftBoxStickerWriter::delete);
giftBoxWriter.delete(giftBox);
}
} else if (role.equals(GiftBoxRole.RECEIVER)) {
Receiver receiver = receiverReader.findByMemberAndGiftBox(member, giftBox);
receiver.delete();
}
return "선물박스가 삭제되었습니다";
}
선물박스 삭제하기 로직의 if문의 가장 큰 특징은
유저 타입(Sender, Receiver)에 따라 선물박스 삭제 로직이 다르다
라는 점이다.
* Stranger의 경우 선물박스 삭제 권한이 없으므로 예외를 던진다.
어떤 식으로 리팩토링을 할까 찾아보다가 전략 패턴이라는 디자인 패턴을 알게 되었고, 지금 상황에 알맞는 방법인 것 같아 전략 패턴을 사용하여 코드를 리팩토링해보았다.
2. 전략 패턴이 뭐죠?
전략 패턴의 정의는 아래와 같다.
객체들이 할 수 있는 행위 각각에 대한 전략 클래스를 생성하고, 유사한 행위들을 캡슐화하는 인터페이스를 정의하여, 동적으로 행위의 수정이 필요한 경우 전략을 바꾸는 것만으로 행위의 수정이 가능하도록 만든 패턴
정의만 봐서는 감이 안 올 수 있어 전략 패턴 적용 구조를 선물박스 삭제 사례와 연관지어 도식화하였다.
- GiftBoxService의 deleteGiftBox()는 전략 패턴의 클라이언트로, 전략 객체를 전달하여 전략을 등록하거나 변경하여 전략 알고리즘을 실행한 결과를 얻는다.
- GiftBoxActionProvider는 컨텍스트로, 알고리즘을 실행해야 할 때마다 해당 알고리즘과 연결된 전략 객체의 메소드를 호출한다.
- GiftBoxStrategy는 전략 구현체에 대한 공용 인터페이스이며, SenderStrategy와 ReceiverStrategy는 이를 구현한 클래스들이다.
GiftBoxActionProvider는 유저 타입에 따라 동적으로 전략을 제공하는데, 유저 타입이 변경되더라도 해당 클래스에만 전략을 추가하고 매핑해도 되기 때문에 시스템의 유연성을 향상시켜주고, OCP(개방-폐쇄 원칙)을 준수하는 구조를 만들어준다.
또한, 전략 패턴을 통해 구현 클래스로 책임을 분리하여 단일 책임 원칙을 준수할 수 있게 되며 이 외에도 합성(composition), 다형성, 캡슐화 등 OOP 기술이 총 집합된 패턴이다.
3. 프로젝트 코드 개선 과정
먼저 공통 인터페이스를 만들어보자.
@FunctionalInterface
public interface GiftBoxStrategy {
void delete(Member member, GiftBox giftBox);
}
@FunctionalInterface는 Object Class의 메서드를 제외하고 구현해야 할 추상 메서드가 하나만 정의된 인터페이스임을 검증 및 명시해주는 어노테이션이다.
이제 각 전략을 분리해보자.
@RequiredArgsConstructor
@Component
public class SenderStrategy implements GiftBoxStrategy {
private final FileService fileService;
private final GiftBoxWriter giftBoxWriter;
private final LetterWriter letterWriter;
private final PhotoWriter photoWriter;
private final GiftBoxStickerWriter giftBoxStickerWriter;
@Override
public void delete(Member member, GiftBox giftBox) {
if (giftBox.getDeliverStatus().equals(DeliverStatus.DELIVERED)) {
giftBox.delete();
} else if (giftBox.getDeliverStatus().equals(DeliverStatus.WAITING)) {
letterWriter.delete(giftBox.getLetter());
giftBox.getPhotos().forEach(photo -> {
fileService.deleteFile(photo.getImgUrl());
photoWriter.delete(photo);
});
if (giftBox.getGift().getGiftType().equals(GiftType.PHOTO)) {
fileService.deleteFile(giftBox.getGift().getGiftUrl());
}
giftBox.getGiftBoxStickers().forEach(giftBoxStickerWriter::delete);
giftBoxWriter.delete(giftBox);
}
}
}
GiftBox의 DevliverStatus에 따라 분기하는 코드도 전략 패턴을 적용할 수 있겠지만 ...
1) DeliverStatus가 현재 Enum 값 외에 추가될 가능성이 거의 없음
2) 전략 패턴을 연속으로 2번 사용할 경우 추상화 레벨이 높아져 혼란스러울 수 있음
이라는 이유로 우선은 SenderStrategy의 delete 메서드는 추가적인 리팩토링을 진행하지 않았다.
@RequiredArgsConstructor
@Component
public class ReceiverStrategy implements GiftBoxStrategy {
private final ReceiverReader receiverReader;
@Override
public void delete(Member member, GiftBox giftBox) {
Receiver receiver = receiverReader.findByMemberAndGiftBox(member, giftBox);
receiver.delete();
}
}
마지막으로 ActionProvider는 아래와 같다.
ActionProvider는 전략 클래스들 중 적절한 클래스를 선택하는 역할을 수행한다.
@Component
public class GiftBoxActionProvider {
private final Map<MemberRole, GiftBoxStrategy> giftBoxActions;
public GiftBoxActionProvider(
final SenderStrategy senderStrategy,
final ReceiverStrategy receiverStrategy
) {
this.giftBoxActions = new EnumMap<>(MemberRole.class);
this.giftBoxActions.put(MemberRole.SENDER, senderStrategy);
this.giftBoxActions.put(MemberRole.RECEIVER, receiverStrategy);
}
public GiftBoxStrategy getStrategy(MemberRole memberRole) {
return giftBoxActions.get(memberRole);
}
}
이제 다 만든 전략 패턴을 클라이언트에게 적용시키면 ...
public String deleteGiftBox(Long giftBoxId) {
Long memberId = SecurityUtil.getMemberId();
Member member = memberReader.findById(memberId);
GiftBox giftBox = giftBoxReader.findById(giftBoxId);
MemberRole memberRole = getMemberRole(member, giftBox);
if (memberRole.equals(MemberRole.STRANGER)) {
throw new GiftBoxAccessDeniedException();
}
final GiftBoxStrategy giftBoxStrategy = giftBoxActionProvider.getStrategy(memberRole);
giftBoxStrategy.delete(member, giftBox);
return "선물박스가 삭제되었습니다";
}
기존에 18줄짜리 로직을 2줄로 줄일 수 있게 되었다 !!
4. 마치며
이전에는 리팩토링을 진행하게 되면 대부분 private 함수로 분리하는 정도로 그쳤기 때문에 하나의 클래스에 코드 줄이 길어지고 위 아래를 스크롤해가며 보아야 해서 불편했다. OOP를 적극적으로 활용하여 클래스 수준으로 코드를 분리하니 훨씬 관리가 쉽고, 이해하기 쉬운 코드가 되었다.
이번 리팩토링을 계기로 더 많은 디자인 패턴을 공부하고 다음 리팩토링에도 사용해보고 싶어졌다.