Project/Newsfeed

[프로젝트] Service, ServiceImpl 구조에 대한 내 생각과 결론 (부제: 이유없는 리팩토링을 지양하자)

쉬지마 이굥진 2024. 4. 19. 03:17

프로젝트 기능 구현을 마무리 하고, 성능 테스트 및 성능 향상을 위한 리팩토링을 끝낸 후 다른 리팩토링 거리(?)를 찾고 있었다.

 

그러다 전에 진행한 팀 프로젝트에선 Service 레이어 계층을 인터페이스와 그 구현체로 분리해서 개발했었는데, (부끄럽지만 그 땐 제대로 된 이유도 모르고 강사님께서 이게 좋다! 라고 하시는 걸 무작정 따라만 했었다) 이번 개인 프로젝트에서는 서비스 계층 추상화를 따로 진행하지 않은 점이 생각났다. 

그래서 왜 사람들이 Service 인터페이스와 그 구현체를 분리해서 추상화하는지 이유를 생각해보고 내 프로젝트에 적용해보는 과정까지 진행해보려 한다. (결론부터 말하자면 구현체 분리는 하지 않기로 결정함)

 

먼저 기존 코드와 분리 후 코드를 비교해보면 이렇다. (예시)

🔹분리 전 코드

- BoardService

1
2
3
4
5
6
7
8
9
10
11
12
13
@Transactional
    public BoardResponseDto createBoardContents(BoardRequestDto requestDto, User user) {
        // RequestDto -> Entity
        Board board = new Board(requestDto, user);
 
        // DB 저장
        boardCommand.saveBoard(board);
 
        // Entity -> ResponseDto
        BoardResponseDto boardResponseDto = new BoardResponseDto(board);
 
        return boardResponseDto;
    }
cs

🔹분리 후 코드

- BoardService

1
2
3
4
public interface BoardService {
    BoardResponseDto createBoardContents(BoardRequestDto requestDto, User user);
}
 
cs

 

- BoardServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class BoardServiceImpl implements BoardService {
 
    ...
 
    @Override
    @Transactional
    public BoardResponseDto createBoardContents(BoardRequestDto requestDto, User user) {
        // RequestDto -> Entity
        Board board = new Board(requestDto, user);
 
        // DB 저장
        boardCommand.saveBoard(board);
 
        // Entity -> ResponseDto
        BoardResponseDto boardResponseDto = new BoardResponseDto(board);
 
        return boardResponseDto;
    }
}
 
cs

 

BoardService 클래스의 인터페이스를 정의한 후, 이 인터페이스를 구현하는 구현 클래스를 작성하는 식이다.

 

🔹이렇게 나누는 이유

그렇다면 이렇게 인터페이스와 구현체로 나누는 이유가 뭘까? 내 나름대로의 생각과 구글링을 합쳐본 결과는 이렇다.

 

    1. Loose Coupling
      이렇게 개발하면 객체 간의 결합도를 낮추어 변화에 유연한 개발을 할 수 있다. 즉,  구현체를 쉽게 교체할 수 있게 되어 새로운 비즈니스 로직이 필요할 때 기존 코드를 수정하지 않고 새로운 구현체를 추가할 수 있게 된다. 

    2. 다형성
      인터페이스를 통해 다형성 또한 구현할 수 있다.
      하나의 인터페이스를 구현하는 여러 구현체가 있고 기능에 따라 적절한 구현체가 들어가서 다형성을 줄 수 있게 되는 것이다. 또, 하나의 인터페이스만 바라보니 의존관계도 줄일 수 있다. 이는 동일한 인터페이스를 다양한 방식으로 구현할 수 있게 하여 코드의 재사용성을 높인다.

    3. 유지보수성 향상
      비즈니스 로직과 구현 세부 사항을 분리함으로써 코드의 가독성과 유지보수성이 향상된다. 

    4. 과거의 관습
      과거 2.0 Spring에서는 AOP Proxy를 구현할 때 JDK Dynamic Proxy를 사용했다. JDK Dynamic Proxy는 인터페이스를 구현한 객체에 대해서만 AOP Proxy를 생성할 수 있었다. 그러나 시간이 지나면서 인터페이스를 구현하지 않은 클래스에 대해서도 AOP Proxy를 생성할 수 있는 CGLIB가 포함되면서 인터페이스-구현체 관계가 아닌 클래스에 대해서도 AOP 구현이 가능해졌다.
- Spring AOP Proxy:  AOP 기능을 구현하는 핵심 개념 중 하나. Proxy는 대상 객체(Target Object)를 감싸서 요청을 대신 받아 처리하고, 필요한 경우에 부가 기능(Advice)을 실행한다. 이런 AOP Proxy를 사용해서 스프링은 애플리케이션에서 공통적으로 발생하는 문제를 해결하고, 유지보수 및 확장성을 높일 수 있게 해 준다.

 

 

🔹나누지 않아야 할 이유

이렇게 좋은 점이 많은데 나누지 말아야 할 이유가 있나? 라고 생각할 수도 있지만, 필자가 생각한 나누지 않을 이유는 이렇다.

  1. 불필요한 코드의 추상화
    추상화가 가져다주는 여러 장점도 있지만, 단순한 기능을 가지는 서비스인 경우 추상화를 진행하면 인터페이스와 구현 클래스 간의 추상화가 불필요해질 수 있다. 이렇게 되면 오히려 코드의 복잡도가 증가할 것이라고 생각했다.
    ex) 단순한 CRUD 같은 경우엔 인터페이스/구현체를 생성하지 않아도 충분하며 이 경우 서비스 인터페이스와 그 구현체를 생성하면서 중복 코드 또한 발생할 수 있다.

  2. 테스트 코드 작성의 어려움
    Service와 ServiceImpl 구조를 사용하면, 인터페이스와 구현체를 모두 생성해야 하고 이 둘 사이의 의존성이 높아져서 단위 테스트 코드 작성이 복잡해질 수 있을 거라고 생각했다.

 

🔹그럼 언제 서비스 계층을 분리하면 좋을까 

결론부터 간단히 얘기하면 구현체를 쉽게 교체해야 하는 경우에 이 구조를 사용하면 좋다. 예시를 들어보자.

 

비밀번호를 변경하는 ChangePasswordService 클래스가 있다고 하자.

평소 쓰는 비밀번호 변경 기능에 대해 생각해보면, 일반적으로 비밀번호를 변경하는 경우와 비밀번호를 잃어버렸을 때 이메일 인증으로 변경하는 경우, 핸드폰 번호 인증으로 변경하는 경우 등등 여러 경우가 있을 수 있다. 애플리케이션 규모가 커질수록 비밀번호 변경 기능에 대한 여러 가지 경우의 수가 증가할 것이다.

 

- ChangePasswordService 클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 비밀번호 변경 요청을 처리하는 클래스
public class ChangePasswordService {
 
    ...
 
    public void change(MemberId id, PasswordDto.ChangeRequest dto) {
        Member member = memberFindService.findById(id);
        String newPassword = dto.getNewPassword().getValue();
 
        switch (dto.getChangeType()) {
            case BY_PASSWORD:
                if (dto.getPassword().equals("비밀번호가 일치하는지 판단 로직...")) {
                    member.changePassword(newPassword);
                }
                break;
 
            case BY_AUTH:
                if (dto.getAuthCode().equals("인증 코드가 적합한지 로직 추가...")) {
                    member.changePassword(newPassword);
                    // 필요로직...
                }
                break;
 
            ...
 
        }
    }
}
cs

 

물론 이렇게 하면 단일 클래스에서 여러 비밀번호 변경 시나리오를 처리할 수 있기 때문에 편리함은 있을 지 모르나, 위 코드는 SRP (단일 책임 원칙)을 위반했으므로 변경 로직이 추가되거나 수정될 때 복잡성이 증가한다.

 

또한 새로운 비밀번호 변경 방법을 추가하거나 기존 방법을 수정할 때 인터페이스를 통한 유연한 대처가 어렵다. 새로운 비밀번호 변경 방식이 필요하면 기존 클래스에 계속 추가해야 하므로 코드가 점점 복잡해 질 것이다.

 

또 이 코드는 비밀번호 기반 로직과 다른 인증 기반 로직이 하나의 클래스에 묶여 있어 특정 기능을 재사용하기도 쉽지 않다.  모듈 단위로 교체할 수도 없다. 

 

인터페이스와 구현체로 분리하면?

- ChangePasswordService 인터페이스

1
2
3
4
// 비밀번호를 바꾸는 인터페이스
public interface ChangePasswordService {
    public void change(MemberId id, PasswordDto.ChangeRequest dto);
}
cs

 

- 그 구현체

1
2
3
4
5
6
7
8
9
10
11
12
// 비밀번호 기반으로 비밀번호를 변경하는 기능
public class ByPasswordChangePasswordService implements ChangePasswordService {
    private MemberFindService memberFindService;
    @Override
    public void change(MemberId id, PasswordDto.ChangeRequest dto) {
        if (dto.getPassword().equals("비밀번호가 일치하는지 판단 로직...")) {
            final Member member = memberFindService.findById(id);
            final String newPassword = dto.getNewPassword().getValue();
            member.changePassword(newPassword);
        }
    }
}
cs
1
2
3
4
5
6
7
8
9
10
11
12
13
// 비밀번호를 잃어버렸을 때 다른 인증 기반으로 비밀번호를 변경하는 기능
public class ByAuthChangePasswordService implements ChangePasswordService {
    private MemberFindService memberFindService;
    @Override
    public void change(MemberId id, PasswordDto.ChangeRequest dto) {
        if (dto.getAuthCode().equals("인증 코드가 적합한지 로직 추가...")) {
            final Member member = memberFindService.findById(id);
            final String newPassword = dto.getNewPassword().getValue();
            member.changePassword(newPassword);
            // 필요로직...
        }
    }
}
cs

 

인터페이스와 구현체를 분리함으로써, 기능에 따라 적절한 구현체가 들어가서 다형성을 주며 필요한 상황에 맞춰 갈아낄 수도 있고 재사용도 가능한 코드가 됐다.

 

🔸결론

지금까지의 생각을 정리해보면! 애플리케이션이 성장해서 기능을 추가해야 할 때, 요구사항이 변경될 때와 같은 경우를 대비해서 특정 기능의 구현체를 쉽게 교체할 수 있어야 한다. 이런 경우 인터페이스를 사용하면 새로운 구현체를 도입할 때 기존 코드의 변경을 최소화할 수 있을 것이다. 

 

하지만 필자가 진행하고 있는 프로젝트의 경우(물론 기능적으로만 생각하면 추가하고 싶은 기능/추가할 기능이 많지만), 사전에 기획한 요구사항 대로 이미 모두 구현을 마친 상황이라 요구 사항이 변경 될 여지나 더 추가될 기능이 생길 여지가 없다고 판단했다.

 

또한 이미 개발한 API 서비스 계층 코드의 경우 하나의 Service가 여러 개의 SerivceImpl을 가지고 있어야 하는 경우가 아닌 1:1 구조를 띄고 있었다. 즉, 인터페이스의 하나에 구현체 하나를 두는 것이므로 의존관계를 줄이는 효과도, 다형성을 주는 효과도 없게 된다고 생각했다. 

따라서 굳이 Service와 ServiceImpl 구조로 리팩토링을 하는 것은 불필요하다고 결론지었다.

 

🔸고려할 점

하지만 위의 예시에서 본 것 처럼, 1:1 구조라고 하더라도 인터페이스 구조가 필요한 경우가 있다. 실무에선 추후 다형성이 필요해질 경우에 해당 Service에 대해서 인터페이스를 적용하면 될 것 같다. (이렇게 리팩토링하는 것이 많은 공수를 필요로 하는 일이 아니기도 하고) 또한 무조건 인터페이스와 구현체로 나누어 구현하기보단 OCP를 준수하면서 전략을 수정해야 하는지에 대한 여부를 도메인 관점에서 팀원들과 충분한 고민을 하고 진행해야 되겠다고 생각하면서, 개발자 간 커뮤니케이션에 대한 중요성을 다시 한번 실감하게 된 순간이었다. 

 

🔸되돌아 본 점

맨 처음 API를 설계할 때 부터 이런 고민을 하면서 진행했으면 더 좋았을 것 같다. 해당 서비스의 도메인 성질을 생각하면서 (ex. 카드 결제 기능에서 신한 카드, 국민 카드, ... 등 여러 결제 가능한 카드가 지속해서 추가 될 예정이라던지) 이 서비스는 인터페이스를 둘 필요가 없겠어. 이런 식으로 ..? 🥹

그래도 이런 고민을 해 보면서 객체지향적인 코드는 뭔지 더 깊숙이 생각하게 됐다.

역시 모든 결정엔 이유가 따라야 한다.  -끝-

 


References

https://www.popit.kr/spring-oop-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EC%98%88%EC%A0%9C1-service-serviceimpl-%EA%B5%AC%EC%A1%B0%EC%97%90-%EB%8C%80%ED%95%9C-%EA%B3%A0%EC%B0%B0/

https://jeonyoungho.github.io/posts/spring%EC%97%90%EC%84%9C-Service-ServiceImpl%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%95%BC%ED%95%98%EB%82%98/