
차량 판매등록 기능은 카히스토리 api를 사용해 받아온 데이터를 등록해야 하지만, api를 아직 도입하지 못해서(너무 비싸..) 임시로 더미 데이터를 입력하는 방식으로 구현했다.
해당 기능은 인터페이스를 통해 구현된 facade 서비스 클래스에 존재하는데, 기존엔 배포용 차량 판매등록 기능과 더미 데이터 판매등록 기능이 하나의 인터페이스에 공존하고 있었고, 그것을 두개의 구현체로 구현했었다.
배포 때는 배포 판매등록 기능, 개발 때는 더미 판매등록 기능이 이용 되어야 하며, 두 기능은 하나의 환경에서 동시에 사용되지 않아야 한다. 따라서 하나의 인터페이스에서 파생된 두 구현체는, 사용되지 않는 메서드까지 상속받아 무의미하게 구현하고 있는 상태였다( ISP 위반 ). 또한 두 구현체는 같은 부모 인터페이스를 두었고, 확장하지 않았음에도 서로를 대체할 수 없었다( LSP 위반 ).
기존 코드
Facade 인터페이스
// service/facade/CarFacadeService
/**
* 차량 관련 Facade 서비스 입니다.
*/
public interface CarFacadeService {
/**
* 판매중인 차량 목록을 조회합니다. -> 공통 메서드
*/
List<CarListResponseDto> getCarList();
/**
* 차량을 판매등록합니다. -> 배포용 메서드
*/
RegisterCarResponseDto registerCar(
MultipartFile mainImage,
List<MultipartFile> additionalImages,
String carNumber,
BigDecimal price
);
/**
* 차량을 판매등록합니다. -> 더미 데이터용 메서드
*/
RegisterCarResponseDto registerCar(RegisterCarDummyRequestDto dto, String userId);
}
기존 인터페이스는 위와 같이 배포용, 더미데이터용 메서드를 전부 선언하고 있다.
Facade 구현체 ( 배포용 )
// service/facade/CarFacadeServiceImpl
// 배포용 구현체
/**
* 차량 관련 Facade 서비스 구현체 입니다.
*/
@Service
@Profile("!dev")
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CarFacadeServiceImpl implements CarFacadeService {
private final CarListingService carListingService;
/**
* 차량 판매등록 메서드 -> 배포용
*
* @param mainImage - 메인 이미지
* @param additionalImages - 나머지 이미지 리스트
* @param carNumber - 차량번호
* @param price - 차량 가격
*/
@Override
@Transactional
public RegisterCarResponseDto registerCar(
MultipartFile mainImage,
List<MultipartFile> additionalImages,
String carNumber,
BigDecimal price
) {
// 생략...
// 생략...
return RegisterCarResponseDto.builder().build();
}
/**
* 차량 판매등록 메서드 -> 더미 데이터용
* - 이 구현체에서는 사용되지 않음
* - 예외를 던지는 방식으로 임시 구현만 해둔 상태
*/
@Override
@Transactional
public RegisterCarResponseDto registerCar(RegisterCarDummyRequestDto dto, String userId) {
throw new DummyDataNotSupportedException(ErrorMessages.DUMMY_DATA_NOT_SUPPORTED);
}
}
이것은 배포용 구현체인데, 인터페이스를 구현하는 구현체는 모든 메서드를 구현할 의무가 있으므로 사용하지도 않는 더미 데이터용 메서드를 예외를 던지는 식으로 임시 구현만 해둔 상태이다.
마찬가지로 더미 데이터용 구현체도 배포용 메서드를 임시 구현만 해 둔 상태이다.
인터페이스가 명확하게 분리되지 않아 사용하지도 않는 메서드를 무의미하게 구현하고, 결과적으로 같은 인터페이스의 구현체인데도 서로를 대체할 수 없는 ISP, LSP 위반 문제가 발생했다.
컨트롤러
// controller/car/CarController
/**
* 차량 관련 컨트롤러 입니다.
*/
@RestController
@RequestMapping("/cars")
@ResponseStatus(HttpStatus.OK)
@Tag(name = "Car")
@RequiredArgsConstructor
public class CarController {
/**
* 두 구현체의 공통 인터페이스에 의존
*/
@Qualifier("CarFacadeServiceDummyImpl")
private final CarFacadeService carFacadeService;
// 엔드포인트 로직 생략..
// 엔드포인트 로직 생략..
}
컨트롤러를 보면, 두 구현체의 공통 인터페이스에 의존하고 있다(DIP).
그러나 컨트롤러의 입장에서는 굉장히 곤란하다. 컨트롤러는 해당 인터페이스에 특정 기능을 기대하고 있고, 해당 인터페이스의 구현체라면 어느 것을 끼워넣어도 대체가 되어야 하는데( 구현체에서 확장한 게 아닌 이상 ), 배포용 구현체와 더미 데이터용 구현체는 서로를 대체할 수가 없다.
이것은 인터페이스의 잘못된 설계에서 비롯된 문제이다. 서로 상충되는 기능(배포, 더미 데이터)을 하나의 인터페이스에 둔 것이 LSP와 ISP 위반을 초래했고 클라이언트(컨트롤러) 입장에서의 곤란함을 초래했다.
명확한 책임 분리를 위해, 그리고 혼란을 막기 위해 반드시 개선할 필요가 있다.
개선 코드 ( Facade 인터페이스 3개로 세분화 )
Base 인터페이스
// service/facade/CarFacadeBaseService
/**
* 차량 관련 Facade Base 서비스 입니다.
*/
public interface CarFacadeBaseService {
/**
* 판매중인 차량 목록을 조회합니다.
*/
List<CarListResponseDto> getCarList();
}
우선 배포, 더미 데이터 구현체가 공통적으로 사용하는 메서드를 Base 인터페이스에 정의했다.
배포용 인터페이스
// service/facade/CarFacadeService
/**
* 차량 관련 Facade 서비스 입니다.
*/
public interface CarFacadeService extends CarFacadeBaseService {
/**
* 차량을 판매등록합니다.
*/
RegisterCarResponseDto registerCar(
MultipartFile mainImage,
List<MultipartFile> additionalImages,
String carNumber,
BigDecimal price
);
}
배포용 인터페이스이다.
여기선 Base 인터페이스를 상속받고 배포용 메서드를 확장 선언했다.
더미 데이터용 인터페이스
// service/facade/CarFacadeDummyService
/**
* 차량 관련 Facade 서비스 입니다.
* - 더미 데이터 입력용 입니다.
*/
public interface CarFacadeDummyService extends CarFacadeBaseService {
/**
* 차량을 판매등록합니다. - 더미 데이터 입력을 위한 메서드 입니다.
*/
RegisterCarResponseDto registerCar(RegisterCarDummyRequestDto dto, String userId);
}
더미 데이터용 인터페이스이다.
여기서도 Base 인터페이스를 상속받고 더미 데이터용 메서드를 확장 선언했다.
배포용 구현체
// service/facade/CarFacadeServiceImpl
/**
* 차량 관련 Facade 서비스 구현체 입니다.
*/
@Service
@Profile("!dev")
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CarFacadeServiceImpl implements CarFacadeService {
private final CarListingService carListingService;
/**
* 판매중인 차량 목록을 조회합니다. -> 공통 메서드 ( Base 인터페이스 )
*/
@Override
public List<CarListResponseDto> getCarList() {
List<CarListResponseDto> carList = carListingService.getCarList();
return carList;
}
/**
* 차량을 판매등록합니다. -> 배포용 메서드
*
* @param mainImage - 메인 이미지
* @param additionalImages - 나머지 이미지 리스트
* @param carNumber - 차량번호
* @param price - 차량 가격
*/
@Override
@Transactional
public RegisterCarResponseDto registerCar(
MultipartFile mainImage,
List<MultipartFile> additionalImages,
String carNumber,
BigDecimal price
) {
/**
* TODO - 이미지 저장 로직 만들기
* 1. 메인 이미지, 나머지 이미지를 S3에 등록
* 2. DB에 이미지 경로 저장
*/
/**
* TODO - 카히스토리 API
* 1. 차량번호로 카히스토리 API 호출
* 2. 각 서비스 함수로 값 전달
*/
return RegisterCarResponseDto.builder().build();
}
// 필요 없는 더미 데이터용 메서드는 구현하지 않았다!!
}
이제 배포용 구현체는, 공통 메서드와 배포용 차량 등록 메서드만을 구현하게 되었다. ( 물론 아직 기능 미구현이긴 하다 )
더미 데이터용 구현체
// service/facade/CarFacadeDummyServiceImpl
/**
* 차량 관련 Facade 서비스 구현체 입니다.
* - 더미 데이터 입력용으로, 개발환경일 때만 활성화됩니다.
*/
@Service
@Profile("dev")
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CarFacadeDummyServiceImpl implements CarFacadeDummyService {
// 의존성 생략...
/**
* 판매중인 차량 목록을 조회합니다. -> 공통 메서드 (Base 인터페이스)
*/
@Override
public List<CarListResponseDto> getCarList() {
List<CarListResponseDto> carList = carListingService.getCarList();
return carList;
}
/**
* 차량을 판매등록합니다.
* - 더미 데이터를 이용합니다. -> 더미 데이터용 메서드
*/
@Override
@Transactional
public RegisterCarResponseDto registerCar(RegisterCarDummyRequestDto dto, String userId) {
/** Model */
ModelDto modelDto = carDummyDataGenerator.generateModelDto();
Model model = modelService.createModel(modelDto);
// 로직 생략...
/**
* CarListingImage
*/
CarListingImageDto mainImageDto = carDummyDataGenerator.generateCarListingImageDtoFromMainImage(listing.getId(), dto.getMainImage());
CarListingImage mainImage = carListingImageService.createMainImage(mainImageDto);
List<CarListingImageDto> additionalImageDtos = carDummyDataGenerator.generateCarListingDtosFromAdditionalImages(listing.getId(), dto.getAdditionalImages());
List<CarListingImage> additionalImages = carListingImageService.createImages(additionalImageDtos);
/**
* 결과를 dto로 만든 후 리턴
*/
RegisterCarResponseDto registerCar = carMapper.toRegisterCarResponseDto(listing, car, model);
return registerCar;
}
// 필요 없는 배포용 메서드는 구현하지 않았다!!
}
마찬가지로 더미 데이터용 구현체도 꼭 필요한 메서드만 구현하게 되었다.
컨트롤러
// controller/car/CarController
/**
* 차량 관련 컨트롤러 입니다.
*/
@RestController
@RequestMapping("/cars")
@ResponseStatus(HttpStatus.OK)
@Tag(name = "Car")
@RequiredArgsConstructor
public class CarController {
/**
* CarFacadeDummyService 인터페이스에 의존
*/
private final CarFacadeDummyService carFacadeDummyService;
// 엔드포인트 로직 생략...
// 엔드포인트 로직 생략...
}
개선 전에는 인터페이스의 잘못된 설계로 컨트롤러 입장에서 굉장한 곤란함을 겪었었다.
그러나 책임에 따라 인터페이스를 명확히 분리하였고, 정확히 필요한 인터페이스에 의존함으로써 곤란함을 해결했다.
이제 인터페이스가 배포용, 더미 데이터용으로 나뉘었기 때문에 두 구현체는 서로를 대체할 필요가 없으며,
컨트롤러가 배포용 서비스를 사용하고 싶다면 인터페이스를 교체하면 된다. ( LSP 위반 해결 )
또한 이제 두 구현체는 필요하지 않은 메서드는 제외하고, 명확하게 자신이 필요로 하는 메서드만 구현하고 있다. ( ISP 위반 해결 )
마무리
사실 기능의 완성과 클라이언트(사용자 또는 프론트) 입장에서의 사용성만을 최우선으로 한다면, 당장 이런 건 신경쓰지 않아도 될 수도 있다. 하나의 인터페이스에 다 담고 하나의 구현체에서 다 구현해 버리고 해도 결국 클라이언트 입장에선 별 다를게 없을 것이다. 리턴받는 JSON 데이터는 같을 테니까. 또한 개인 프로젝트이기 때문에, 소규모이기 때문에 이런 짓을 하는 건 현재 단계에서 비효율일 수도 있다. 아니 아마 비효율이 맞을 것이다. 지금 고작 메서드 한개 때문에 인터페이스를 1개에서 3개로 늘린 거니까. 명백한 오버엔지니어링이다.
그러나 프로젝트 초반부터 객체지향적인 설계에 집중해 놓는다면 훗날 서비스 규모가 커졌을 때 쉽게 대응할 수 있을 것이고, 리팩토링 하느라 시간을 버리는 일이 훨씬 줄어들 것이다. 또한 잘 설계된 코드는 팀원들간의 협업에도 도움이 될 것이다.
할 줄 알지만 안하는 것과 할 줄 몰라서 안하는 것은 다르다. 내가 훗날 실무에서 일하게 되었을 때 객체지향적 설계는 뒷전으로 하고 기능 구현에만 집중할 수도 있다. 하지만 그런 와중에도 항상 좋은 설계를 할 수 있는 능력을 가지고 있어야 하며, 필요할 때 언제든 적용할 수 있어야 한다. 나는 그게 실력이라고 생각하며, 지금은 그 기반을 다지는 중이다.
=============
썬카 노션
https://lava-move-d1e.notion.site/SunCar-1a754e6b788180f598cdea3bfaff3139?pvs=4
깃허브
'Development > 썬카(SunCar) - 개인 프로젝트' 카테고리의 다른 글
[썬카/정기 회고] 스프린트 3 종료 - 객체지향적 설계와 쿼리 최적화 (3) | 2025.04.09 |
---|---|
[썬카/백엔드] 판매 차량 상세조회 기능 구현 - Lazy Loading, fetchJoin(), BatchSize (0) | 2025.04.08 |
[썬카/백엔드] 차량 판매등록 기능 구현 - 더미 데이터 생성과 객체지향적 설계 (0) | 2025.04.03 |
[썬카/백엔드] DTO <-> 엔티티 변환을 위한 매퍼 클래스 도입 및 적용 (0) | 2025.04.02 |
[썬카/정기 회고] 스프린트 2 종료 - N+1 문제 (2) | 2025.03.25 |