들어가며
지금까지 프로젝트에선 레이어드 아키텍처를 사용하여 유사한 기능들을 같은 계층으로 묶어
Controller, Service, Repository를 추상화 없이 바로바로 사용하였습니다.
JpaRepository 가 인터페이스로 만들어지긴 했지만 사실상 JPA에 직접 의존하고 있기 때문에 JPA와 강결합이 되어있습니다.
따라서 이번 프로젝트에서는 의존성 역전을 해주었습니다.
시스템 외부 연동 ( DB, WebClient 등)은 가능하면 모두 추상화하여 구현해 주었습니다.
일단 Repository interface를 새로 만들어서 분리해 주었습니다. 이것은 JPA와 관계없는 인터페이스입니다.
그리고 Persistence Layer에 해당 인터페이스의 구현체를 둡니다. 그 구현체는 Jpa Repository를 사용하게 만듭니다.
의존성 역전(DIP)란?
화살표 방향을 반대로 바꾸는 기술
1. 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
2. 추상화는 세부 사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야 한다.
HamburgerChef 입장에서 그림 a에서는 화살표 방향이 안으로 들어오는 것에서
그림 b에서 화살표 방향이 밖으로 나가는 것으로 바뀌었습니다.
의존성 역전의 장점
1. 결합도가 낮아집니다.
만약, RDB를 사용하다가 외부 상황에 의해서 MongoDB로 변경되어야 하는 상황이라고 가정합니다.
이전 구조였다면 비즈니스 레이어가 JpaRepository에 강결합되어 있기 때문에
인프라가 변경되면 비즈니스 레이어도 건드려야 합니다.
하지만 추상화가 이루어진 구조라면 아래 그림과 같이 Jpa를 Mongo 라이브러리로 변경해도
Service Layer, 즉 비즈니스 코드에 영향을 주지 않습니다. ( 개방-폐쇄 원칙 )
2. 테스트가 쉬워집니다.
우리가 어떤 서비스를 테스트하고 싶을 때 Repository에 가짜 Repository를 만들어서 주입해 줄 수 있습니다.
Fake Repository는 지정한 값만 내려주는 객체일 수도 있고, 인메모리로 구현한 객체일 수도 있습니다.
→ h2나 mockito 없이도 서비스 로직을 테스트할 수 있게 됩니다.
h2나 mockito 없이도 설계를 통해 자연스러운 테스트를 얻었습니다.
mock framework는 강력한 라이브러리라 설계를 통한 개선을 생각하지 못하게 만들며,
h2를 이용한 테스트는 일단 굉장히 느리고 소형테스트가 아니게 됩니다.
Fake Repository를 활용한 소형 테스트에 대해서는 다음 포스팅에서 다룹니다!
의존성 역전이 무조건적으로 좋을까?
추상화는 좋은 방법론이지만 개발하는데 비용을 증가시킵니다.
서비스와 컨트롤러의 목적 자체가 한번 생성으로 영원히 같은 일을 할 수 있는 객체여야 합니다.
따라서 서비스는 구현체여도 상관없기 때문에 굳이 추상화하지 않았습니다.
실제 적용 코드
https://github.com/Sum-nail/sum-nail-server
Service class
@Service
@RequiredArgsConstructor
@Builder
@Transactional
public class UserService {
private final UserRepository userRepository;
private final UserNailShopService userNailShopService;
private final NailShopService nailShopService;
private final RecentSearchService recentSearchService;
/**
* 나의 프로필 조회
*/
@Transactional(readOnly = true)
public UserFindResponse findUser(final long userId) {
User user = userRepository.getById(userId);
return UserFindResponse.from(user);
}
/**
* 저장한 네일샵 전체 조회
*/
@Transactional(readOnly = true)
public List<UserFindNailShopResponse> findAllNailShopsUser(final long userId) {
userRepository.getById(userId);
List<UserNailShop> userNailShops = userNailShopService.findByUserId(userId);
return userNailShops.stream()
.map(userNailShop -> {
NailShopFindSavedDto savedNailShop = nailShopService.findSavedNailShop(userNailShop);
return UserFindNailShopResponse.from(savedNailShop);
})
.toList();
}
/**
* 네일샵 저장하기
*/
public void saveNailShopUser(long userId, long nailShopId) {
User user = userRepository.getById(userId);
NailShop nailShop = nailShopService.getById(nailShopId);
userNailShopService.save(user, nailShop);
}
/**
* 네일샵 저장 취소하기
*/
public void deleteNailShopUser(long userId, long nailShopId) {
User user = userRepository.getById(userId);
NailShop nailShop = nailShopService.getById(nailShopId);
userNailShopService.delete(user, nailShop);
}
/**
* 지하철 역 검색 내역 조회
*/
@Transactional(readOnly = true)
public UserFindSearchStationsResponse findSearchStationsUser(long userId) {
List<RecentSearch> recentSearches = recentSearchService.findByUserId(userId);
List<RecentSearch> limitedRecentSearches = recentSearches.stream()
.sorted(Comparator.comparing(RecentSearch::getDateTime).reversed())
.limit(Math.min(recentSearches.size(), 3))
.toList();
return UserFindSearchStationsResponse.from(limitedRecentSearches);
}
/**
* 지하철 역 검색 기록 전체 삭제
*/
public void deleteSearchStationsUser(long userId) {
recentSearchService.deleteAll(userId);
}
}
JpaRepository interface
public interface UserJpaRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
}
가장 뒷단에 있는 JpaRepository입니다.
저희가 추상화하지 않았을 때 사용했던 interface JpaRepository와 같습니다.
Repository interface
public interface UserRepository {
User getById(Long id);
void save(User user);
Optional<User> findByEmail(String email);
}
JpaRepository를 사용하기 위한 인터페이스입니다.
RepositoryImpl class
@RequiredArgsConstructor
@Repository
public class UserRepositoryImpl implements UserRepository {
private final UserJpaRepository userJpaRepository;
@Override
public User getById(Long id) {
return userJpaRepository.findById(id)
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER));
}
@Override
public void save(User user) {
userJpaRepository.save(user);
}
@Override
public Optional<User> findByEmail(String email) {
return userJpaRepository.findByEmail(email);
}
}
Repository inferface와 JpaRepository inteface를 연결해 주는 구현체가 필요한데
Repository interface를 implements 해 실체화하고 있고,
내부적으로는 JpaRepository를 주입받아서 사용하고 있는 구조입니다.
헥사고날 아키텍처
포트-어댑터 패턴
의존성 역전을 보고 포트-어댑터 패턴이라고 부르기도 합니다.
인터페이스라는 포트에다가 필요한 어댑터를 꽂아주는 느낌이기 때문입니다.
어댑터는 손 청소기 뿐만 아니라 진공 청소기, 일반 청소기도 될 수 있습니다.
실행객체는 포트를 통해 연결된 어댑터를 사용할 뿐입니다.
구현체 입장에서는 인터페이스가 포트이고 나를 사용할 실행 객체가 어댑터 입니다.
청소기 입장에서는 청소기를 사용라는 사람이 누구인지 상관없고, 그저 포트를 통해 기능을 제공할 뿐입니다.
우리 프로젝트에서 만약 Service 계층도 추상화 시킨다면,
오른쪽 그림과 같이 포트-어댑터로 구분해줄 수 있습니다.
위의 그림에서 의미 없어진 계층을 제거하고 다이어그램을 재배치하면,
헥사고날 아키텍쳐가 됩니다
헥사고날 아키텍쳐의 장점
- 외부에서 도메인으로 향하는 방향이 단방향으로 유지됩니다. 따라서 도메인은 순수해집니다.
- JPA나 Spring 같은 외부 세계에 관심이 없고, 오직 도메인에 충실합니다. 따라서 애플리케이션의 핵심에 충실 할 수 있습니다.
클린 아키텍쳐
헥사고날은 클린 아키텍처의 실천법으로 나온 내용이기 때문에,
본질적으로 두 가지 아키텍처는 같습니다.
클린 아키텍쳐에서는 Input port 대신 Use Case라는 용어를 사용하고,
Output port 대신 Gateway라는 용어를 사용합니다.
테스트 하기 어려운 부분을 Humble이라고 부릅니다. 대표적으로 GUI, DB는 Humble 입니다.
DB, 라이브러리, 프레임워크 등을 바꾼다고 계산로직이 변경되면 안되기 때문에 본질과 험블을 구분해야 합니다.
서비스는 추상화하지 않아도 되는 이유
응용 프로그램 서비스는 구체다. 사용하는 응용 프로그램의 매우 특정한 사용 사례를 나타낸다.
사용 사례의 이야기가 바뀌면 응용 프로그램 서비스 자체도 바뀌므로 인터페이스를 지니지 않는다
- 마티아스 노박, 오브젝트 디자인 스타일 가이드 팀의 생산성을 높이는 고품질 객체지향 코드 작성법
서비스는 한번 생성으로 영원히 같은 일을 할 수 있는 객체여야 합니다.
이렇게 되면 더 이상 핵사고날 아키텍쳐는 아니지만,
외부 세계에서 내부 세계로 향하는 방향을 단방향으로 유지해야해서
도메인이라는 내부 세계를 독립시킨다는 클린 아키텍쳐의 요구사항은 만족합니다.