들어가기
이 글은 개인적인 생각을 포함하여 작성한 글이다.
나는 프로젝트를 할 때 항상 service 계층에 class 하나로만 구현을 하였었다. 하지만 다른 사람들이 한 프로젝트에서는 service 명칭으로 된 interface와 serviceImpl 명칭으로 된 class 구현체로 구현되어 있었다. 이는 디자인 패턴 중 전략 패턴을 적용한 것이라고 할 수 있다.
이게 과연 의미가 있나 싶어서 지금까지 적용하지 않았었는데 마침 리펙토링을 하면서 한번 알아보고 적용해 보기로 하였다.
이 글에서는 impl을 쓰는 이유와 간단 적용, 인터페이스와 여러 구현체일 때 공통 메서드 처리 등을 다룰 예정이다.
Service 계층에서 인터페이스와 구현체의 구분은 왜 생긴 것인가?
처음 인터넷에 'spring service impl' 이라고 검색하면 하나의 구현체만 있을 때는 사용할 필요 없다는 의견과 해야 한다는 의견 모두 나왔다.
이는 Service 계층을 인터페이스와 ServiceImpl 클래스 구현체로 나누는 이유부터 알아야 한다.
다형성 및 OCP(Open Closed Principle)
첫번째는 객체지향의 특징 중 하나인 다형성 때문이다.
controller에서 service를 호출할 때 클래스가 아닌 인터페이스를 호출하여 객체 간의 결합도를 낮추어 변화에 유연한 개발을 할 수 있다. 하나의 인터페이스를 구현하는 여러 구현체가 있고 기능에 따라 달라져서 다형성을 줄 수 있다. 그리고 controller에서는 service의 인터페이스만 바라보니 의존관계도 줄일 수 있다.
또한 OCP(Open Closed Principle)이라고 개방폐쇄 원칙 또한 지켜진다. 이는 기존의 코드의 수정 없이 확장할 수 있어야 한다는 원칙이다. 인터페이스와 구현체가 나눠져 있으면 구현체는 외부로부터 독립된다. 이로 인해 구현체의 수정이나 확장이 자유로워지고, 이를 사용하는 클라이언트의 코드에는 영향을 주지 않는다.
즉, controller에서 service 인터페이스 의존성을 주입받고 있는 상황에서 새로운 구현체가 확장되어 이 구현체로 변경하고 싶다면 단순히 @service 어노테이션을 이전 것을 제외하고 새로운 구현체에 달기만 해도 변경이 되는 것이다. controller에서는 service의 구체적인 내용이 없이 어떠한 변화도 없이 말이다.
AOP Proxy
두번째는 AOP Proxy에 관한 것이다. AOP에 대한 것은 https://khdscor.tistory.com/44를 참고하길 바란다.
과거에는 Spring에서 AOP를 구현할 때 JDK Dynamic Proxy를 사용했다. 이 프록시 객체를 생성하려면 인터페이스가 필수적이다. service 로직을 구현할 때 항상 추가하는 어노테이션이 있다. 바로 @Transational이다. 서비스 메서드가 트랜젝션으로 묶는 것이고 이러한 어노테이션은 AOP를 사용하여 어노테이션 방식으로 구현한 것이다. 즉, AOP를 사용한 어노테이션은 인터페이스에서만 적용할 수가 있어서 동작하려면 인터페이스와 구현체 관계로 작성해야 했던 것이다. 그러나 Spring 3.2 / Spring Boot 1.4 버전부터는 디폴트 클래스 기반(GGLIB)으로 만들도록 되어서 클래스 기반으로 프록시 객체를 생성할 수 있게 되었다
그래서 지금에 들어서는 두번째 이유는 상관없고 첫 번째 이유만 남은 것이다.
결론적으로 2번의 이유는 사실상 사라져버린지 오래고, 1번의 이유 또는 과거부터 이어진 관습적인 사용이 주된 이유라고 볼 수 있다.
그렇다면 인터페이스와 구현체로 나눠서 구현해야 할까?
이러한 이유로 굳이 인터페이스와 구현체로 나눠야 하는지에 대한 글들을 몇 가지 볼 수가 있었다. 여러 가지 구현체로 써야 한다면 나눠야 하는 게 맞다. 하지만 하나의 인터페이스와 하나의 구현체는 그저 옛날의 관습처럼 계속 사용하는 것이지, 굳이 나누는 것은 쓸데없이 계층을 더 늘리는 것이 되고 이를 Impl이라는 명칭으로 하는 것은 인터페이스와 구현체의 사상에 맞지 않는다는 의견도 있었다.
처음에는 이에 동조하여 구현하지 말자는 쪽으로 기울었지만 다른 글들을 읽으면서 생각이 바뀌었다.
구현체가 하나만 나온다 해도 하나의 인터페이스와 하나의 구현체로 표현을 해야한다고 생각한다.
확장성
첫번째는 인터페이스와 구현체 방식이 확장을 고려한다는 것이다. 공통의 인터페이스를 생성하고 구현체를 생성하여 구체적인 기능을 정의한다. 여기서 다른 방식이 추가된다면 어떨까? 단순히 인터페이스를 상속받는 구현체를 생성하면 되는 것이다.
만약 절대로 확장을 고려하고 있지 않다면 이를 고려하지 않아도 된다. 하지만, 절대로라는 말을 할 수 있을까? 어떤 상황이 일어날지도 모르는 것이므로 언제나 확장을 고려해야 한다. 그러기에 위의 방식이 적합하다고 생각한다.
DIP(Dependency Inversion Principle) 원칙
두 번째는 DIP(Dependency Inversion Principle)를 지키기 위함이다. 객체지향 5원칙(SOLID) 중 D에 해당하는 원칙으로 고수준 모듈이 저수준 모듈을 의존하면 안 된다는 원칙이다.
변화하기 쉬운 것보다는 변화하지 않는 것에 의존을 해야한다고도 할 수 있다.
아래의 코드는 디자인 패턴중 전략 패턴의 예시를 설명하는 코드이며 DIP가 적용된 코드라고 할 수 있다. https://khdscor.tistory.com/75의 수록된 코드이다.
카카오페이와 네이버페이 방식이 있고 테스트 시 둘 다 테스트를 하고 싶다. 만약 각각의 객체가 따로 구현이 되었다면 테스트 코드에서는 카카오 페이 따로, 네이버 페이 따로 구현을 해야 한다. 하지만 아래의 코드에서는 그렇게 되지 않는다. 카카오, 네이버페이 구현체들이 페이 인터페이스를 상속받도록 설계했기 때문이다. 그렇기에 테스트 시 각각의 구현체들을 설정하는 것이 아니라 공통의 인터페이스를 선언하여 사용한다. 그리고 구현체를 선택하여 적용할 수 있는 것이다. 이는 확장이 돼도 테스트 코드에서는 변함이 없을 것이기에 확장에 유용하다는 장점이 있다.
결국 DIP의 의미인 저수준에서 고수준을 의존하라는 말은 구현체(저수준)이 인터페이스(고수준)를 의존하도록 설계하는 것이라고 할 수 있다.
interface PaymentStrategy {
public void pay(int amount);
}
class KakaoPayStrategy implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println(amount + " paid using KakaoPay");
}
}
class NaverPayStrategy implements PaymentStrategy {
@Override
public void pay(int amount) {
System.out.println(amount + " paid using NaverPay");
}
}
class Item {
private String name;
private int price;
public Item(String name, int price) {
this.name = name;
this.price = price;
}
public String getName() {
return name;
}
public int getPrice() {
return price;
}
}
class ShoppingCart() {
List<Item> items;
public ShoppingCart() {
this.items = new ArrayList<Item>();
}
public void addItem(Item item) {
this.items.add(item);
}
public void removeItem(Item item) {
this.items.remove(item);
}
public int caculateTotal() {
int sum = 0;
for (Item item : items) {
sum += item.getPrice();
}
return sum;
}
public void pay(PaymentStrategy paymentMethod) {
int amount = calculateTotal();
paymentMethod.pay(amount);
}
}
//test
public class Test {
public static void main(String[] args) {
ShoppingCart cart = new ShoppingCart();
Item A = new Item("apple", 1000);
Item B = new Item("banana", 2000);
cart.addItem(A);
cart.addItem(B);
cart.pay(new KakaoPayStrategy());
cart.pay(new NaverPayStrategy());
}
}
Controller과 Service를 보자면 Conroller에서는 Service를 의존할 것이다. 이럴 때 변화하기 쉬운 service 구현체를 직접 의존하는 것이 아니라 잘 변화하지 않는 interface를 의존함으로써 DIP를 만족시킬 수 있다.
또한 Controller은 service interface를 의존하는 것이기에 service의 구현체에 대한 내용은 일체 알 수가 없고 일 필요가 없다. 그저 service의 메서드를 실행하기만 하면 된다. controller과 service의 경계를 완벽히 구현한 것이다. 경계를 오갈 때는 직접적으로 오가는 것이 아닌 간접적으로 오가야 하고 인터페이스를 사이에 두는 방법이라 할 수 있다.
이는 service가 repository를 의존하는 것과 같다고 볼 수있다. JPA방식을 사용할 시 JPARepository를 상속받는 커스텀 Repository 인터페이스를 service가 상속하게 된다. mybatis를 사용할 시에도 인터페이스를 상속받는다.
service가 repository 인터페이스를 상속받는 것처럼 controller에서는 service 인터페이스를 상속받는 것이다.
이러한 이유들 때문에 service를 인터페이스와 구현체로 나누는 것이 좋다고 생각한다.
Impl 네이밍 컨벤션은 괜찮은가?
하지만 impl이라는 명칭은 잘못되었다고 생각한다. ArrayList나 LinkedList 도 List 인터페이스를 구현하는 구현체이지만 impl이라는 명칭은 안 쓰고 있지 않는가?
service와 serviceImpl은 의미가 중복되는 코드를 생성한 것과 같고 인터페이스와 구현체는 중복의 의미가 아닌 인터페이스를 토대로 여러 구현체가 구현되는 것이다. Pay라는 인터페이스를 토대로 카카오페이, 삼성페이, 네이버페이 등으로 나뉘는 것처럼 말이다.
그렇기에 네이밍 컨벤션 부분은 확실히 수정해야할 것 같다.
service 계층을 인터페이스와 여러 구현체로 표현
이제 인터페이스와 구현체로 표현을 해보자. 하나의 인터페이스와 하나의 구현체로 표현하는 것은 어렵지 않을 것이다. 하지만 하나의 인터페이스와 여러 개의 구현체로는 어떻게 코드를 작성해야 할까? 구현체들 간의 중복 메서드는 어떻게 해결해야 할까?
중복 메서드 해결
바로 추상클래스를 통해 해결한다.
인터페이스 - 추상클래스 - 구현체 로 연결되도록 구상해 주는 것이다. 아래 예시 코드를 보자. 내가 실제로 프로젝트 상에서 구현한 코드이다. 게시글의 좋아요를 수정하는 코드이고 url에 public, private, grouped 구분을 통해 전체 인원이 접근할 수 있는 권한, 자신만 접근할 수 있는 권한, 그룹 내 인원들만 접근할 있는 권한으로 나뉜다.
- ChangeArticleLIkeService
public interface ChangeArticleLikeService {
void changeArticleLike(ArticleLikeDto articleLikeDto);
}
우선 외부에서 접근할 인터페이슬르 구현하고 메서드를 선언한다.
- ChangeArticleLIke
@RequiredArgsConstructor
public abstract class ChangeArticleLike implements ChangeArticleLikeService {
protected final ArticleLikeRepository articleLikeRepository;
protected final FindArticleRepository findArticleRepository;
//게시글에 이미 좋아요가 눌렸으면 좋아요 취소, 아니면 좋아요
protected void changeLike(ArticleLikeDto articleLikeDto) {
if (articleLikeDto.isHasILiked()) {
deleteLike(articleLikeDto);
return;
}
articleLikeRepository.saveArticleLike(ArticleLike.createArticleLike(articleLikeDto));
}
private void deleteLike(ArticleLikeDto articleLikeDto) {
int deleted = articleLikeRepository.deleteArticleLike(articleLikeDto);
if (deleted == 0) {
throw new NotExistsException("이미 좋아요를 취소하였거나 누르지 않았습니다.");
}
}
protected Article findAndValidateArticle(Long articleId) {
return findArticleRepository.findById(articleId)
.orElseThrow(() -> new NotExistsException(" 해당 게시글이 존재하지 않습니다."));
}
}
인터페이스를 구현하는 추상메서드를 선언한다. 구현체가 사용할 중복메서드들은 protected로 설정하여 구현체에서 사용가능하도록 한다.
여기서 의아한 점은 인터페이스의 'changeArticleLike' 메서드를 구현하지 않았다는 점이다. 추상 클래스의 경우 상속 받은 인터페이스의 일부만 구현해도 컴파일에러가 발생하지 않는다. 그 이유는 인터페이스에서 선언한 API의 타입은 항상 abstract public 타입이기 때문이다.
아래의 예시코드를 봐보자.
interface Example{
void api();
}
abstract class Example{
abstract public void api();
}
위의 두 선언은 동등하다. interface는 실체화할 수 없는 추상 클래스(abstract class)와 같고, api()는 실제로는 abstract public 타입의 함수이다. 다만, 인터페이스는 다중 상속이 가능하지만 추상 클래스는 단 한 개의 클래스만 상속 가능하다는 점에서 실질적으로는 같지 않다. 개념적으로 보면 인터페이스는 추상 클래스의 "특수 케이스"라고 이해할 수 있다.(참고: https://effectiveprogramming.tistory.com/entry/interface-abstract-class-concrete-class-%ED%8C%A8%ED%84%B4)
이렇게 추상클래스를 구현했으니 이를 상속받는 구현체들을 만들어보자.
- ChangePublicArticleLIke
@Service
@Qualifier("public")
public class ChangePublicArticleLike extends ChangeArticleLike {
public ChangePublicArticleLike(ArticleLikeRepository articleLikeRepository,
FindArticleRepository findArticleRepository) {
super(articleLikeRepository, findArticleRepository);
}
@Override
@Transactional
public void changeArticleLike(ArticleLikeDto articleLikeDto) {
Article article = findAndValidateArticle(articleLikeDto.getArticleId());
//전체 인원 접근 권한을 가진지 true/false 로 반환
if (!article.isPublic_map()) {
throw new NumberFormatException("게시글이 전체지도에 포함되지 않습니다.");
}
changeLike(articleLikeDto);
}
}
- ChangePrivateArticleLike
@Service
@Qualifier("private")
public class ChangePrivateArticleLike extends ChangeArticleLike {
public ChangePrivateArticleLike(ArticleLikeRepository articleLikeRepository,
FindArticleRepository findArticleRepository) {
super(articleLikeRepository, findArticleRepository);
}
@Override
@Transactional
public void changeArticleLike(ArticleLikeDto articleLikeDto) {
Article article = findAndValidateArticle(articleLikeDto.getArticleId());
//개인만 접근 가능한지 true/false로 반환
if (!article.isPrivate_map()) {
throw new NumberFormatException("게시글이 개인지도에 포함되지 않습니다.");
}
ValidateIsMine.validateArticleIsMine(article.getMember_id(), articleLikeDto.getMemberId());
changeLike(articleLikeDto);
}
}
- ChangeGroupedArticleLike
@Service
@Qualifier("grouped")
public class ChangeGroupedArticleLike extends ChangeArticleLike {
private final ArticleGroupRepository articleGroupRepository;
public ChangeGroupedArticleLike(ArticleLikeRepository articleLikeRepository,
FindArticleRepository findArticleRepository,
ArticleGroupRepository articleGroupRepository) {
super(articleLikeRepository, findArticleRepository);
this.articleGroupRepository = articleGroupRepository;
}
@Override
@Transactional
public void changeArticleLike(ArticleLikeDto articleLikeDto) {
findAndValidateArticle(articleLikeDto.getArticleId());
//게시글이 내가 속한 그룹에 포함되어있는지 검증
ValidateIsMine.validateInMyGroup(articleLikeDto.getArticleId(),
articleLikeDto.getMemberId(), articleGroupRepository);
changeLike(articleLikeDto);
}
}
이렇게 3개의 구현체로 구현하였다. 인터페이스로부터 'changeArticleLIke'를 구현하고 내부로직은 3개의 구현체가 모두 다르다. 그리고 모든 구현체가 추상클래스의 추상클래스를 공통적으로 사용하고 있다.
이렇게 인터페이스를 구현하는 구현체가 여러 개일 경우 생기는 중복메서드 문제를 추상클래스, 추상메서드를 통해 해결하였다.
그렇다면 이렇게 다른 구현체들은 외부에서 어떻게 선언할까? 방법은 다양하게 있지만 나는 @Qualifier를 통해 해결하였다.
Controller에서 인터페이스 선언(구현체들을 구분)
하나의 인터페이스로 여러가지 구현체를 구현하였을 때, 외부에서 인터페이스를 선언할 때 이를 어떻게 구분할까? 바로 @Qualifier를 통해 해결할 수 있다. 위에서 3가지의 구현체에 @Qualifier를 달고 public, private, grouped를 지정하였다. 이는 구현체들을 구분하기 위한 것이고 외부에서 이를 언급할 때 지정한 것을 통해 구분한다. 아래의 코드를 봐보자.
@RestController
public class ArticleLikeController {
private final ChangeArticleLikeService changePublicArticleLike;
private final ChangeArticleLikeService changePrivateArticleLike;
private final ChangeArticleLikeService changeGroupedArticleLike;
public ArticleLikeController(
@Qualifier("public") ChangeArticleLikeService changePublicArticleLike,
@Qualifier("private") ChangeArticleLikeService changePrivateArticleLike,
@Qualifier("grouped") ChangeArticleLikeService changeGroupedArticleLike) {
this.changePublicArticleLike = changePublicArticleLike;
this.changePrivateArticleLike = changePrivateArticleLike;
this.changeGroupedArticleLike = changeGroupedArticleLike;
}
}
생성자 주입 방식으로 의존성을 주입하는데 선언된 final 필드 3가지의 타입은 모두 동일하게 위에서 선언한 인터페이스이다. 하지만 생성자 내에서 매개변수 앞에 @Qualifier과 함께 service 구현체에 어노테이션을 통해 지정한 것으로 맞춰주면 동일한 인터페이스 타입이더라도 원하는 이름으로 구현체들을 지정할 수 있다.
내가 진행중인 프로젝트에서는 아래와 같이 사용하였다.
@PostMapping("/articles/{mapType}/{articleId}/like")
public ResponseEntity<Void> changeMyLike(@PathVariable("articleId") Long articleId,
@PathVariable("mapType") String mapType,
@RequestParam(value = "hasiliked") Boolean hasILiked,
@AuthenticationPrincipal CustomUserDetails userDetails) {
ArticleLikeDto articleLikeDto = new ArticleLikeDto(articleId, userDetails.getId(),
hasILiked);
changeArticleLike(MapType.from(mapType), articleLikeDto);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
private void changeArticleLike(MapType mapType, ArticleLikeDto articleLikeDto) {
if (mapType == MapType.PUBLIC) {
changePublicArticleLike.changeArticleLike(articleLikeDto);
}
if (mapType == MapType.PRIVATE) {
changePrivateArticleLike.changeArticleLike(articleLikeDto);
}
if (mapType == MapType.GROUPED) {
changeGroupedArticleLike.changeArticleLike(articleLikeDto);
}
}
참고로 위의 changeArticleLIke를 클릭하면 구현체가 아닌 인터페이스에 선언된 메서드로 넘어가진다. controller에서 service 구현체의 내용들을 알 수 없다.
참고자료
https://colabear754.tistory.com/109
https://techblog.woowahan.com/2647/
https://huisam.tistory.com/entry/DIP
https://lktprogrammer.tistory.com/42
https://effectiveprogramming.tistory.com/entry/interface-abstract-class-concrete-class-패턴
https://velog.io/@owljoa/Spring-Boot-의존성-주입-시-서로-다른-구현체-식별-방법
https://hyeonguj.github.io/2020/02/07/spring-interface-choice-implements-dynamically/
https://velog.io/@sung_hyuki/Autowired의-원리와-스프링-팀에서-Autowired의-사용을-지양하라고-하는-이유-추가-수정-필요
https://catsbi.oopy.io/72475 b41-f527-4e64-867c-7cbdc5a04d69
https://junior-datalist.tistory.com/243
https://stackoverflow.com/questions/2814805/java-interfaces-implementation-naming-convention
'Spring' 카테고리의 다른 글
Springboot 3.x.x 를 사용해보자 (0) | 2024.01.24 |
---|---|
springboot(jpa, mybatis) - page 객체 및 커버링 인덱스를 사용해보자! (0) | 2023.08.24 |
Java Springboot AOP를 통한 로그 출력(메서드 이름을 활용) (0) | 2023.07.08 |
Springboot - 서비스 단위 테스트 (0) | 2023.04.02 |
Spring boot - 서버의 존재하지 않는 URL 접근 시 예외 처리 (0) | 2022.04.09 |