투두몬 서비스에는 크게 두 가지 방식의 아이템이 있다.
- 구매 후 즉시 유저의 상태를 변화시키는 아이템 (
IMMEDIATE_EFFECT
)- ex) 펫 뽑기, 펫 하우스 확장(+1), 당근(펫 먹이), 유료 플랜 구독권
- 인벤토리에 저장되었다가 사용 가능한 소모형 아이템 (
CONSUMABLE
) -> 유저가 원하는 때에 사용해야 함- ex) 칭호 변경권, 펫 이름 변경권
각 아이템마다 호출 시기가 다르므로 아이템의 효과 자체를 Item 엔터티가 갖고 있으면서 이를 원할 때 호출할 수 있도록 해야한다.item.getItemEffect().applyEffect()
과 같은 코드를 원할 때 호출할 수 있어야 할 것이다.
이를 실현시키기 위해서는 일단 모든 아이템이 ItemEffect
라는 타입의 필드를 갖고 있어야 관리하기 용이할 것 같다.
물론, 각 ItemEffect
는 데이터베이스에 저장되어서는 안되며(코드 덩어리이니..), 그 자세한 효과에 대한 구현은 서버에서 하나하나 코드로 구현되어야 할 것이다.
그렇다면 요구사항은 두 가지이다.
ItemEffect
인터페이스를 구현하고, 모든Item
이 이를 구현한 구현체를 필드로 갖도록 한다.- 그리고 그 필드는 데이터베이스에 저장되지 않고, 런타임 시점에 동적으로 설정 및 사용되어야 한다.
고민
ItemEffect
를 어떻게 관리하고 매핑해줄 것인가?
- 각
ItemEffect
의 구현체를 Bean으로 관리하여 Bean Name을 통해 매핑한다.
@Component("petSummonEffect")
public class PetSummonEffect implements ItemEffect {
@Override
public void applyEffect(User user) {
// 펫 소환 로직
user.summonPet();
}
}
@Component("petHouseExpansionEffect")
public class PetHouseExpansionEffect implements ItemEffect {
@Override
public void applyEffect(User user) {
// 펫 하우스 확장 로직
user.expandPetHouse();
}
}
- 장점
- 의존성 관리 용이: Spring의 의존성 주입의 이점(싱글톤 관리, 프로토타입 관리 등)을 사용할 수 있다
- 간단한 구현: 새로운 구현체를 빈으로 등록하고, 사용할 때는 ApplicationContext.getBean(String name) 메서드를 통해 조회 후, applyEffect를 호출해주면 된다
- 테스트 용이
- 확장성 증가: 새롭게 구현 후 빈으로 등록만 해주면 된다.
- 단점
- Spring에 강하게 의존하게 된다
- Bean 이름의 중복 문제: Bean 이름이 중복되면 충돌이 발생할 수 있다. Bean 이름은 고유해야 하며, 이를 방지하기 위해 각 ItemEffect 구현체의 이름을 신중하게 관리해야 한다.
- ItemEffect가 많아질 수록 Bean 정의가 많아지고 설정이 증가하기에 관리하기 어렵다.
- 성능 문제: Bean의 동적 조회는
ApplicationContext.getBean(String name)
메서드를 통해 Bean을 조회하는 것은 비교적 비용이 높은 작업이다.
- 각
ItemEffect
의 구현체를 매핑해줄ItemEffectFactory
클래스를 만들어effectName
을 기반으로 매핑한다.
public class ItemEffectFactory {
public static ItemEffect createItemEffect(String effectName) {
switch (effectName) {
case "petSummonEffect":
return new PetSummonEffect();
case "petHouseExpansionEffect":
return new PetHouseExpansionEffect();
case "titleChangeEffect":
return new TitleChangeEffect();
case "petNameChangeEffect":
return new PetNameChangeEffect();
default:
throw new IllegalArgumentException("Unknown effect name: " + effectName);
}
}
}
- 장점
- Spring 의존성 제거: ItemEffect 클래스들이 더 이상 Spring의 관리 대상이 아니므로, Spring 컨텍스트가 아닌 곳에서도 사용할 수 있다
- 명확한 책임 분리: 모든 인스턴스 생성을 팩토리에서 제어하므로, 로직을 중앙에서 관리할 수 있다
- 다양한 로직 추가 가능: 다양한 생성 로직을 캡슐화할 수 있으며, 생성 조건이나 복잡한 초기화가 필요한 경우 유연하게 대응할 수 있다
- 단점:
- 의존성 관리의 어려움: Spring이 제공하는 의존성 주입의 이점(예: 싱글톤 관리, 프로토타입 관리 등)을 사용할 수 없다
- 확장성 감소: 새로운 ItemEffect를 추가할 때마다 팩토리 클래스를 수정해야 하므로 코드 수정이 필요하다
- 성능: 매번 새로운 객체를 생성하므로 메모리 사용량이 증가할 수 있다
나는 위 방식 중 구현 및 확장이 간단하고 스프링의 강력한 기능을 활용할 수 있다는 점에서 Bean Name
방식을 채택하기로 했다. 스프링 기반 프로젝트에서 여러가지 강력한 기능을 활용할 수 있다면 활용하는 것이 좋다고 생각했다.
만약 스프링에 의존하지 않는 독립적인 모듈이었다면 팩토리 방식을 선택했을 것이다.
아이템을 생성할 때, ItemEffect를 어떻게 설정하지?
아이템의 효과는 모두 서버에 저장되어 있을 것이다. 하지만, 새롭게 생성되는 아이템은 서버에 구현되지 않은 기능을 요구한다.
예를 들면, A라는 아이템이 추가되는데 이는, 기존에 없던 새로운 아이템이다. 이 아이템에 대한 기능은 서버에 구현되어 있지 않기 때문에,
아이템을 추가하는 과정에서 ItemEffect
가 매핑되지 않아 런타임 에러가 발생한다.
즉, 우리 방식에서는 아이템을 동적으로 추가하는 방식은 불가능하다. 아이템을 추가하기 위해서는 결국엔 해당 아이템에 대한 구현 코드를 서버에 추가 후 재배포해야 하며, 이후에 데이터베이스에 새로운 아이템을 추가해야 한다.
=> 재배포가 필요하므로 REST API를 통한 아이템 생성 자체가 의미가 없다..
그렇다면 서버에 ItemEffect
구현체를 미리 만들어두고 아이템을 추가하는 것이 아니라, 완전히 동적으로 아이템을 생성할 때 새로운 ItemEffect
도 동적으로 추가하는 방법이 있을까?
- 스크립트 기반 효과 정의
- 아이템의 효과를 Java 코드 대신 스크립트 파일(예: JavaScript, Groovy, Python)로 정의하는 방법
ItemEffect
를 스크립트로 정의하고, 이를 파일이나 데이터베이스에 저장한 후, 런타임에 로드하여 실행하는 방법- 예시 코드
public class ScriptEffect implements ItemEffect {
private String scriptPath;
public ScriptEffect(String scriptPath) {
this.scriptPath = scriptPath;
}
@Override
public void applyEffect(User user) {
try {
ScriptEngineManager manager = new ScriptEngineManager();
ScriptEngine engine = manager.getEngineByName("nashorn"); // JavaScript 엔진
engine.put("user", user); // 스크립트에서 사용할 객체 주입
// 스크립트 파일 실행
engine.eval(new FileReader(scriptPath));
} catch (Exception e) {
e.printStackTrace();
}
}
}
- 장점
- 동적 확장성: 서버를 내리지 않고도 새로운 효과를 스크립트로 추가하고, 런타임에 동적으로 로드할 수 있다
- 유연성: 효과 로직을 변경하거나 테스트할 때 쉽게 수정 가능하다
- 단점:
- 성능 이슈: 스크립트 실행 시 성능 저하가 있을 수 있다
- 안전성: 스크립트 언어로 작성된 코드의 보안 문제가 발생할 수 있다.. 신뢰할 수 없는 코드의 실행은 피해야 한다
애초에 eval
이라는 함수가 보안상 너무 위험해서 사용하지 않는다고 한다. 이 방식은 어려울 것 같다
- 런타임에 JAR 파일 동적 로딩(플러그인 방식)
플러그인 또는 모듈 시스템을 사용하여 새로운 ItemEffect
를 독립적인 모듈로 추가하는 방법
- 구현 방법
ItemEffect
를 구현한 구현체를 코드로 작성 후, 이를 JAR 파일로 빌드해서 서버의 특정 클래스패스에 추가- 외부 JAR는
METH-INF/spring.factories
또는 별도 설정 파일로 구현 클래스 지정 필요
- 외부 JAR는
- 서버에서
URLClassLoader
를 사용해 외부 Jar를 로드해서 ItemEffect 구현체를 탐색하고, 이를 자료구조롤 관리- 이 탐색 작업은 while(true)를 통해 반복하거나, 특정 REST API로 제공하여 필요할 때마다 수행하도록 할 수 있을듯
- 필요할 때마다 이 자료구조에서 ItemEffect를 조회한 후 사용(
apply
호출)
- 장점
- 고도화된 확장성: 서버 중단 없이 플러그인을 통해 기능을 확장할 수 있다
- 모듈화 및 확장성: 아이템 효과를 별도의 JAR 파일로 분리하여 모듈화할수 있으며, 기존 코드를 수정하지 않아도 된다 -> 개방-폐쇄 원칙
- 유연성: 외부 개발자가 플러그인 형태로 기능을 추가할 수 있다
- 단점:
- 관리 포인트 증가: 별도의 아이템 효과들을 담는 Jar 파일을 관리해야 한다. 아이템 수가 증가할 수록 관리가 어렵다
- 보안 문제: 외부 JAR 파일을 로드할 때 악성 코드가 포함될 위험이 있다
- 테스트 및 디버깅 어려움: 동적으로 로드된 코드는 디버깅이 어려우며, 클래스로더 문제로 예외가 발생하면 원인 찾기가 어렵다
- 설정 및 관리의 복잡성: 동적 JAR을 관리하기 위해 별도의 디렉토리 구조와 설정 파일이 필요하게 된다
이러한 방식은 아이템이 많을 경우, 협업하는 상황에서 명세만 잘 정의되어 있다면 매우 효과적인 작업 방식일 것 같다. 아이템 효과를 다른 개발자가 개발하고 특정 클래스패스에 추가하여 플러그인으로 제공하면, 서버 개발을 담당하는 개발자는 이를 추가하고 동적 로드 API를 호출하기만 하면 아이템 효과가 추가되는 것이다. 하지만, 나는 혼자 개발을 하고 있으며, 아이템 개수도 매우 적고 이들을 모두 제어할 자신이 없다..
일단 지금은 위에서 본 정적인 방식으로 가보자.
(정적인 방식을 사용한다면 아이템 추가 API는 필요가 없어지니 과감히 삭제해도 된다)
구현
Item (Entity)
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Item extends BaseEntity {
@Column(nullable = false, length = 30, unique = true)
private String name;
@Column(nullable = false)
private String description;
@Enumerated(EnumType.STRING)
private ItemType itemType;
@Enumerated(EnumType.STRING)
private MoneyType moneyType;
@Column(nullable = false)
private Long price; // 가격; 실제 돈 또는 starPoint에 따라 다르게 해석
@Column(nullable = false)
private Boolean isAvailable = true; // 구매 가능 여부
@Column(nullable = false)
private String effectName;
@Builder
public Item(String name, String description, ItemType itemType, MoneyType moneyType, Long price, String effectName) {
this.name = name;
this.description = description;
this.itemType = itemType;
this.moneyType = moneyType;
this.price = price;
this.isAvailable = true;
this.effectName = effectName;
}
public void update(UpdateItemRequest req) {
if (hasText(req.getName())) {
this.name = req.getName();
}
if (hasText(req.getDescription())) {
this.description = req.getDescription();
}
if (req.getPrice() != null) {
this.price = req.getPrice();
}
if (req.getItemType() != null) {
this.itemType = req.getItemType();
}
if (req.getMoneyType() != null) {
this.moneyType = req.getMoneyType();
}
if (req.getIsAvailable() != null) {
this.isAvailable = req.getIsAvailable();
}
}
}
ItemEffect
public interface ItemEffect {
@Transactional
void applyEffect(Member member, ItemEffectRequest itemEffectReq);
}
ChangePetNameEffect (ItemEffect 구현체 중 하나)
@Service("changePetNameEffect")
@RequiredArgsConstructor
public class ChangePetNameEffect implements ItemEffect {
private final static String ITEM_NAME = "펫 이름 변경권";
private final PetService petService;
private final InventoryItemService inventoryItemService;
@Override
public void applyEffect(Member member, ItemEffectRequest request) {
if (!(request instanceof ChangePetNameRequest changePetNameRequest)) {
throw new BadRequestException(ErrorCode.BAD_REQUEST, "잘못된 요청 타입입니다.");
}
InventoryItem findInventoryItem = inventoryItemService.getInventoryItem(member.getId(), ITEM_NAME);
inventoryItemService.consumeItem(findInventoryItem);
petService.updatePetName(member.getId(), changePetNameRequest);
}
}
ItemService 일부분
public class ItemService() {
...
public void purchase(Member member, Order order) {
Item purchasedItem = order.getItem();
switch (purchasedItem.getItemType()) {
case CONSUMABLE -> inventoryItemRepository
.findByMember_IdAndItem_Id(member.getId(), purchasedItem.getId())
.ifPresentOrElse(
existingItem -> {
// 인벤토리에 해당 아이템이 있으면 수량 수정
existingItem.addQuantity(order.getQuantity());
},
() -> {
// 없다면 생성
InventoryItem newInventoryItem = InventoryItem.of(member, order);
member.addItemToInventory(newInventoryItem);
inventoryItemRepository.save(newInventoryItem);
}
);
case IMMEDIATE_EFFECT -> {
// 즉시 효과 적용
applyItemEffect(member, purchasedItem);
}
}
}
private void applyItemEffect(Member member, Item item) {
String effectName = item.getEffectName();
ItemEffect itemEffect = (ItemEffect) applicationContext.getBean(effectName);
itemEffect.applyEffect(member, null);
}
public void useInventoryItem(Member member, String itemName, ItemEffectRequest req) {
InventoryItem findInventoryItem = inventoryItemService.getInventoryItem(member.getId(), itemName);
applyItemEffect(member, findInventoryItem.getItem(), req);
inventoryItemService.consumeItem(findInventoryItem);
}
...
}
ItemController 일부분
...
@PostMapping("/use")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void useInventoryItem(
@AuthenticationPrincipal TodomonOAuth2User todomonOAuth2User,
@RequestParam String itemName,
@RequestBody ItemEffectRequest req) {
itemService.useInventoryItem(todomonOAuth2User.getMember(), itemName, req);
}
...
}
'Project' 카테고리의 다른 글
[TODOMON] Ep.6 Spring Batch 도입기 (+ 멀티스레드를 통한 성능 개선까지) (0) | 2025.02.02 |
---|---|
[TODOMON] EP.5 견고한(?)결제 시스템 구축기.. with 포트원 (2) | 2025.01.31 |
[TODOMON] EP.3 펫 정보 저장 방식에 대한 고민 (0) | 2025.01.28 |
[TODOMON] EP.2 투두 달성 보상 기능 (1) | 2025.01.28 |
[TODOMON] EP.1 투두 비즈니스 로직에 대한 고민 (0) | 2025.01.27 |