🔥 Controller → Service → Repository 계층 구조의 문제점
2.0.0 버전 업데이트 이슈 대응 이후, 서버 업무가 한가해진 타이밍에 기존 설계를 점검해보았습니다. Briefing 팀에서는 전형적인 Controller → Service → Repository 구조를 바탕으로 개발이 되어있었고, 기존 설계에서 여러 문제점을 찾을 수 있었습니다.
먼저, Command 요청에 대한 처리과정 중 DTO ↔ Entity간 변환을 예시로 문제점을 살펴보겠습니다.
[기존] DTO ↔ Entity 변환 과정 (Command)
Layer | Problem-1 | Problem-2 |
Controller | Service로 부터 Entity를 반환받습니다. → Controller가 테이블의 세부 구현을 알게 됩니다. |
반환받은 Entity를 Converter를 이용해 응답 DTO로 변환해야합니다. → Controller가 Entity to 응답 DTO 변환을 위해 Converter를 알고 있어야합니다. |
Service | Service가 구체적인 요청 DTO를 기준으로 저장 로직을 구성합니다. → Service의 재사용성이 떨어집니다. |
Controller부터 받은 요청 DTO를 Converter를 이용해 Entity로 변환해야합니다. → Service가 요청 DTO to Entity 변환을 위해 Converter를 알고 있어야합니다. |
다음으로, 클래스 다이어그램을 살펴보겠습니다.
[기존] 클래스 다이어그램
1. Controller
- 여러 엔티티로 구성해야하는 응답 DTO가 있을 경우, 여러 서비스를 알고 있어야하고 의존해야합니다.
// BriefingApi (Controller)
@Tag(name = "03-Briefing \\uD83D\\uDCF0", description = "브리핑 관련 API")
@RestController
@RequiredArgsConstructor
public class BriefingApi {
private final BriefingQueryService briefingQueryService;
private final BriefingCommandService briefingCommandService;
private final ScrapQueryService scrapQueryService;
// ...
@GetMapping("/briefings/{id}")
@Parameter(name = "member", hidden = true)
@Operation(summary = "03-02Briefing \\uD83D\\uDCF0 브리핑 단건 조회 V1", description = "")
public CommonResponse<BriefingResponseDTO.BriefingDetailDTO> findBriefing(
@PathVariable final Long id, @AuthMember Member member) {
Boolean isScrap =
Optional.ofNullable(member)
.map(m -> scrapQueryService.existsByMemberIdAndBriefingId(m.getId(), id))
.orElseGet(() -> Boolean.FALSE);
// TODO : 추후 요구사항에 대응
Boolean isBriefingOpen = false;
Boolean isWarning = false;
return CommonResponse.onSuccess(
BriefingConverter.toBriefingDetailDTO(
briefingQueryService.findBriefing(id, APIVersion.V1),
isScrap,
isBriefingOpen,
isWarning));
}
// ...
}
이렇게, ScrapQueryService와 BriefingQueryService 둘을 의존해야만 응답 DTO를 구성할 수 있는 경우로 인해 Controller에서 여러 서비스를 의존했었습니다.
2. Service
- 복잡한 비즈니스 로직을 구성하기 위해 여러 Repository를 알고 있어야하고 의존해야합니다.
- 여러 엔티티에 대한 처리로직을 포함하여 하나의 메소드에서 여러가지 일을 하고 있습니다.
@Service
@Transactional
@RequiredArgsConstructor
public class BriefingCommandService {
private final BriefingRepository briefingRepository;
private final ArticleRepository articleRepository;
private final BriefingArticleRepository briefingArticleRepository;
public void createBriefing(final BriefingRequestDTO.BriefingCreate request) {
final Briefing briefing = BriefingConverter.toBriefing(request);
final List<Article> articles =
request.getArticles().stream().map(BriefingConverter::toArticle).toList();
briefingRepository.save(briefing);
articleRepository.saveAll(articles);
final List<BriefingArticle> briefingArticles =
articles.stream().map(article -> new BriefingArticle(briefing, article)).toList();
briefingArticleRepository.saveAll(briefingArticles);
}
public Briefing updateBriefing(Long id, final BriefingRequestDTO.BriefingUpdateDTO request) {
Briefing briefing =
briefingRepository
.findById(id)
.orElseThrow(() -> new BriefingException(ErrorCode.NOT_FOUND_BRIEFING));
briefing.updateBriefing(request.getTitle(), request.getSubTitle(), request.getContent());
return briefing;
}
}
Briefing 생성 → Article 목록 생성 → BriefingArticle 목록 생성 총 3가지 일을 createBriefing 메소드에서 수행하고 있었습니다.
이에 따라 3종류의 엔티티 저장을 수행하기 위해 BriefingCommandService는 필수적으로 3개의 Repository를 의존할 수 밖에 없었고, 요청 DTO를 매개변수로 받아 저장 로직을 구성하므로 재사용성이 떨어지는 구조였습니다.
[기존] DTO ↔ Entity 변환 과정 (Command) + [기존] 클래스 다이어그램 두 사례의 문제점을 종합하여 정리해보겠습니다.
Layer | Problem-1 | Problem-2 | Problem-3 |
Controller | Service로 부터 Entity를 반환받습니다. → Controller가 테이블의 세부 구현을 알게 됩니다. |
반환받은 Entity를 Converter를 이용해 응답 DTO로 변환해야합니다. → Controller가 Entity to 응답 DTO 변환을 위해 Converter를 알고 있어야합니다. |
여러 엔티티로 구성해야하는 응답 DTO가 있을 경우, 여러 서비스를 의존합니다. → Controller가 여러 Service를 알고 있어야합니다. |
Service | Service가 구체적인 요청 DTO를 기준으로 저장 로직을 구성합니다. → Service의 재사용성이 떨어집니다. |
Controller부터 받은 요청 DTO를 Converter를 이용해 Entity로 변환해야합니다. → Service가 요청 DTO to Entity 변환을 위해 Converter를 알고 있어야합니다. |
비즈니스 로직을 구성하기 위해 여러 Repository를 의존합니다. → Service가 여러 Repository를 알고 있어야합니다. |
도출된 문제점들을 해결하는 방향으로 구조를 개선하기로 결정했습니다.
♻️ Controller → Facade → Service → Repository 계층 구조
해결책으로, Controller와 Service 계층 사이에 Facade 계층을 추가했습니다.
[개선] DTO ↔ Entity 변환 과정 (Create)
기존 Controller 계층의 문제점을 가지고 와서 개선된 내용을 확인해보겠습니다.
Layer | Problem-1 | Problem-2 | Problem-3 |
Controller | Service로 부터 Entity를 반환받습니다. → Controller가 테이블의 세부 구현을 알게 됩니다. |
반환받은 Entity를 Converter를 이용해 응답 DTO로 변환해야합니다. → Controller가 Entity to 응답 DTO 변환을 위해 Converter를 알고 있어야합니다. |
여러 엔티티로 구성해야하는 응답 DTO가 있을 경우, 여러 서비스를 의존합니다. → Controller가 여러 Service를 알고 있어야합니다. |
Solution | Facade로부터 응답 DTO를 반환받기 때문에 Controller는 테이블의 세부 구현을 모릅니다. | Entity to 응답 DTO는 Facade가 수행합니다. | Facade에서 응답 DTO를 구성해서 반환해주기 때문에 Controller는 하나의 Facade만 의존하면 됩니다. |
다음으로, 클래스 다이어그램을 살펴보겠습니다.
[개선] 클래스 다이어그램
복잡한 비즈니스 로직을 구성하기 위해 여러 Repository를 의존해야했던 Service 계층의 문제점을 해결하기위해 각 서비스는 해당하는 엔티티의 CRUD만을 수행하도록 메소드들을 분리했고, Facade에서 서비스들을 조합해서 비즈니스 로직을 구성하는 형태로 리팩토링했습니다.
기존 Service 코드
@Service
@Transactional
@RequiredArgsConstructor
public class BriefingCommandService {
private final BriefingRepository briefingRepository;
private final ArticleRepository articleRepository;
private final BriefingArticleRepository briefingArticleRepository;
public void createBriefing(final BriefingRequestDTO.BriefingCreate request) {
final Briefing briefing = BriefingConverter.toBriefing(request);
final List<Article> articles =
request.getArticles().stream().map(BriefingConverter::toArticle).toList();
briefingRepository.save(briefing);
articleRepository.saveAll(articles);
final List<BriefingArticle> briefingArticles =
articles.stream().map(article -> new BriefingArticle(briefing, article)).toList();
briefingArticleRepository.saveAll(briefingArticles);
}
public Briefing updateBriefing(Long id, final BriefingRequestDTO.BriefingUpdateDTO request) {
Briefing briefing =
briefingRepository
.findById(id)
.orElseThrow(() -> new BriefingException(ErrorCode.NOT_FOUND_BRIEFING));
briefing.updateBriefing(request.getTitle(), request.getSubTitle(), request.getContent());
return briefing;
}
}
Briefing 저장 / Article 목록 저장 / BriefingArticle 목록 저장을 각 서비스에서 수행하도록 아래와 같이 분리했습니다.
분리된 서비스 코드
@Service
@RequiredArgsConstructor
public class BriefingCommandService {
private final BriefingRepository briefingRepository;
public Briefing create(final Briefing briefing) {
return briefingRepository.save(briefing);
}
public Briefing update(
Briefing briefing, final String title, final String subTitle, final String content) {
briefing.updateBriefing(title, subTitle, content);
return briefing;
}
}
@Service
@RequiredArgsConstructor
public class ArticleCommandService {
private final ArticleRepository articleRepository;
public List<Article> createAll(final List<Article> articles) {
return articleRepository.saveAll(articles);
}
}
@Service
@RequiredArgsConstructor
public class BriefingArticleCommandService {
private final BriefingArticleRepository briefingArticleRepository;
public List<BriefingArticle> createAll(final List<BriefingArticle> briefingArticles) {
return briefingArticleRepository.saveAll(briefingArticles);
}
}
각 Service에서는 연관된 Repository만을 의존하고 엔티티를 파라미터로 받아 재사용가능하게 리팩토링했습니다. 요청 DTO를 파라미터로 받고 있었던 update 메소드도 파라미터를 직접 받도록 분리했습니다.
이렇게 분리된 Service들을 조합하여 Facade에서는 비즈니스 로직을 구성하게 됩니다.
@Component
@RequiredArgsConstructor
public class BriefingFacade {
private final ScrapQueryService scrapQueryService;
private final BriefingQueryService briefingQueryService;
private final BriefingCommandService briefingCommandService;
private final ArticleCommandService articleCommandService;
private final BriefingArticleCommandService briefingArticleCommandService;
private static final APIVersion version = APIVersion.V1;
// ...
@Transactional
public void createBriefing(final BriefingRequestDTO.BriefingCreate request) {
final List<Article> articles =
request.getArticles().stream().map(BriefingConverter::toArticle).toList();
Briefing createdBriefing =
briefingCommandService.create(BriefingConverter.toBriefing(request));
List<Article> createdArticles = articleCommandService.createAll(articles);
final List<BriefingArticle> briefingArticles =
createdArticles.stream()
.map(article -> new BriefingArticle(createdBriefing, article))
.toList();
briefingArticleCommandService.createAll(briefingArticles);
}
@Transactional
public BriefingResponseDTO.BriefingUpdateDTO updateBriefing(
Long id, final BriefingRequestDTO.BriefingUpdateDTO request) {
Briefing briefing = briefingQueryService.findBriefing(id, APIVersion.V1);
Briefing updatedBriefing =
briefingCommandService.update(
briefing, request.getTitle(), request.getSubTitle(), request.getContent());
return BriefingConverter.toBriefingUpdateDTO(updatedBriefing);
}
// ...
}
Controller에서도 여러 Service를 의존하지 않고, Facade만 의존합니다.
@Tag(name = "03-Briefing \\uD83D\\uDCF0", description = "브리핑 관련 API")
@RestController
@RequiredArgsConstructor
public class BriefingApi {
private final BriefingFacade briefingFacade;
@GetMapping("/briefings")
@Parameter(name = "timeOfDay", hidden = true)
@Operation(summary = "03-01Briefing \\uD83D\\uDCF0 브리핑 목록 조회 V1", description = "")
public CommonResponse<BriefingResponseDTO.BriefingPreviewListDTO> findBriefings(
@ParameterObject @ModelAttribute BriefingRequestParam.BriefingPreviewListParam params) {
return CommonResponse.onSuccess(briefingFacade.findBriefings(params));
}
@GetMapping("/briefings/{id}")
@Parameter(name = "member", hidden = true)
@Operation(summary = "03-02Briefing \\uD83D\\uDCF0 브리핑 단건 조회 V1", description = "")
public CommonResponse<BriefingResponseDTO.BriefingDetailDTO> findBriefing(
@PathVariable final Long id, @AuthMember Member member) {
return CommonResponse.onSuccess(briefingFacade.findBriefing(id, member));
}
@CacheEvict(value = "findBriefingsV2", key = "#request.getBriefingType()")
@PostMapping("/briefings")
@ResponseStatus(HttpStatus.CREATED)
@Operation(summary = "03-03Briefing \\uD83D\\uDCF0 브리핑 등록", description = "")
public void createBriefing(@RequestBody final BriefingRequestDTO.BriefingCreate request) {
briefingFacade.createBriefing(request);
}
@CacheEvictByBriefingId(value = "findBriefingsV2", briefingId = "#id")
@Operation(summary = "03-04Briefing \\uD83D\\uDCF0 브리핑 내용 수정", description = "")
@Parameter(name = "id", description = "브리핑 아이디", example = "1")
@PatchMapping("/briefings/{id}")
public CommonResponse<BriefingResponseDTO.BriefingUpdateDTO> patchBriefingContent(
@PathVariable(name = "id") Long id,
@RequestBody @Valid BriefingRequestDTO.BriefingUpdateDTO request) {
return CommonResponse.onSuccess(briefingFacade.updateBriefing(id, request));
}
}
이제는 Service 계층의 기존 문제점들을 가져와서 개선된 내용을 확인해보겠습니다.
Layer | Problem-1 | Problem-2 | Problem-3 |
Service | Service가 구체적인 요청 DTO를 기준으로 저장 로직을 구성합니다. → Service의 재사용성이 떨어집니다. |
Controller부터 받은 요청 DTO를 Converter를 이용해 Entity로 변환해야합니다. → Service가 요청 DTO to Entity 변환을 위해 Converter를 알고 있어야합니다. |
비즈니스 로직을 구성하기 위해 여러 Repository를 의존합니다. → Service가 여러 Repository를 알고 있어야합니다. |
Solution | 메소드를 분리하고, 엔티티를 파라미터로 받아 독립적으로 사용가능한 Service들을 만들었습니다. | Facade에서 DTO to Entity를 수행합니다. | 연관된 Repository만을 의존합니다. |
전체코드는 아래 Github 리포지토리에서 확인하실 수 있습니다!
https://github.com/Team-Shaka/Briefing-Backend
📚 계층별 역할 정리
Controller와 Service 사이에 Facade 계층을 추가한 계층 구조에서 각 계층의 역할을 정리해보겠습니다.
- Controller)
- 클라이언트로부터 요청을 받고 응답하는 것에만 집중합니다.
- 클라이언트 요청 데이터의 유효성 검사나 응답 캐싱 등 클라이언트와의 상호작용, 가장 앞단의 책임만 가집니다.
- Facade)
- 재사용가능한 여러 Service를 조합하여 비즈니스 로직을 구성합니다.
- 여러 Service를 조합해서 사용하므로 Facade에서 트랜잭션을 관리합니다.
- Converter로 DTO ↔ Entity간 변환을 수행해줍니다.
- Service)
- 연관된 엔티티의 CRUD만을 담당합니다.
- XQueryService) X엔티티의 Read를 담당하며, 존재 여부를 검사합니다.
- XCommandSerivce) X엔티티의 Create, Update, Delete를 담당합니다.
- Repository)
- Data Access 계층입니다.
🚀 Facade 계층의 단점과 Service 재사용성 비교
Facade 계층을 도입했을 때의 단점은 명확합니다. Facade 계층이 비대해질 수 있다는 것입니다.
아무래도 재사용가능한 여러 Service를 레고처럼 조립하여 사용하다보니, 필요한 모든 Service들을 의존해야하게 되어 비대해질 수 있습니다.
물론, Facade 없이 아래처럼 구성할 수도 있습니다.
1. 뉴스 스크랩 기능
2. 뉴스 좋아요 기능
Facade가 없는 위의 계층 구조로 1, 2 기능을 구현한다면 Service 계층에서 중복코드가 발생할 수 있습니다.
// 1. 뉴스 스크랩 기능
@Service
@RequiredArgsConstructor
public class ScrapCommandService {
private final MemberRepository memberRepository;
private final BriefingRepository briefingRepository;
private final ScrapRepository scrapRepository;
@Transactional
public ScrapResponse.CreateDTO create(ScrapRequest.CreateDTO request) {
Member member = memberRepository
.findById(request.getMemberId())
.orElseThrow(() -> new MemberException(ErrorCode.MEMBER_NOT_FOUND));
Briefing briefing = briefingRepository
.findById(request.getBriefingId())
.orElseThrow(() -> new BriefingException(ErrorCode.NOT_FOUND_BRIEFING));
Scrap scrap = scrapRepository.save(ScrapConverter.toScrap(member, briefing));
return ScrapConverter.toCreateDTO(scrap);
}
}
// 2. 뉴스 좋아요 기능
@Service
@RequiredArgsConstructor
public class FavoriteCommandService {
private final MemberRepository memberRepository;
private final BriefingRepository briefingRepository;
private final FavoriteRepository favoriteRepository;
@Transactional
public FavoriteResponse.CreateDTO create(FavoriteRequest.CreateDTO request) {
Member member = memberRepository
.findById(request.getMemberId())
.orElseThrow(() -> new MemberException(ErrorCode.MEMBER_NOT_FOUND));
Briefing briefing = briefingRepository
.findById(request.getBriefingId())
.orElseThrow(() -> new BriefingException(ErrorCode.NOT_FOUND_BRIEFING));
Favorite favorite = favoriteRepository.save(FavoriteConverter.toFavorite(member, briefing));
return FavoriteConverter.toCreateDTO(favorite);
}
}
Member엔티티와 Briefing 엔티티를 가져올 때, null일 경우 예외처리 해주는 코드 orElseThrow(존재 여부 검사)가 중복되어 있습니다.
만약 비슷한 기능 요구사항이 3, 4, 5개로 점점 늘어난다면 존재 여부 검사 중복 코드들이 함께 늘어나게 됩니다.
반면, Facade 계층이 있는 경우라면 존재 여부 검사는 Service 계층의 역할이므로 Facade 계층은 신경쓰지 않고 아래와 같이 구현할 수 있습니다.
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
public Member findById(Long id) {
return memberRepository
.findById(request.getMemberId())
.orElseThrow(() -> new MemberException(ErrorCode.MEMBER_NOT_FOUND));
}
}
@Service
@RequiredArgsConstructor
public class BriefingService {
private final BriefingRepository briefingRepository;
public Briefing findById(Long id) {
return briefingRepository
.findById(id)
.orElseThrow(() -> new BriefingException(ErrorCode.NOT_FOUND_BRIEFING));
}
}
// 1. 뉴스 스크랩 기능
@Component
@RequiredArgsConstructor
public class ScrapFacade {
private final MemberService memberService;
private final BriefingService briefingService;
private final ScrapService scrapService;
@Transactional
public ScrapResponse.CreateDTO create(ScrapRequest.CreateDTO request) {
Member member = memberService.findById(request.getMemberId());
Briefing briefing = briefingService.findById(request.getBriefingId());
Scrap scrap = scrapService.create(ScrapConverter.toScrap(member, briefing));
return ScrapConverter.toCreateDTO(scrap);
}
}
// 2. 뉴스 좋아요 기능
@Component
@RequiredArgsConstructor
public class FavoriteFacade {
private final MemberService memberService;
private final BriefingService briefingService;
private final FavoriteService favoriteService;
@Transactional
public FavoriteResponse.CreateDTO create(FavoriteRequest.CreateDTO request) {
Member member = memberService.findById(request.getMemberId());
Briefing briefing = briefingService.findById(request.getBriefingId());
Favorite favorite = favoriteService.create(FavoriteConverter.toFavorite(member, briefing));
return FavoriteConverter.toCreateDTO(favorite);
}
}
이렇게 존재 여부 검사 중복 코드 없이 구현할 수 있습니다.
🚗 마무리
팀별로 구조와 문화도 다르고, 컨벤션도 다르기 때문에 팀원들과 적절히 이야기해보고 팀내에서 구조를 합의하고 코드를 작성하는 것이 가장 중요하다고 생각합니다. 혼자만의 생각으로 구조를 바꿨다간 팀에 혼란을 줄 수 있고 복잡도만 높이는 방향일 수 있습니다.
'💻 Service > Briefing' 카테고리의 다른 글
[Briefing] nGrinder로 성능 테스트 해보기 (2) | 2024.02.07 |
---|---|
[Briefing] API 응답 캐싱을 통한 조회 속도 개선 (0) | 2024.01.06 |
[Briefing] Spotless로 코드 포맷 유지하기 (0) | 2023.12.27 |