안녕하세요! 오늘 글을 쓰게된 계기는 하나의 유튜브 영상을 보고나서입니다!
아래는 그 영상입니다!! (두둥)
해당 영상은 개인적으로 존경하는 개발자인 김재민님의 유튜브 영상인데요. 해당 영상을 보고 와닿은 점이 많이 있어 글로써 기록하고자 합니다. 개인적인 생각이 담긴 글에 가까우니 다들 가벼운 맘으로 읽어주세요 😉 (영상을 보고 오시면 더욱 좋습니다!)
ServiceImpl이 무엇인가요?
먼저 스프링 기반의 프로젝트를 진행할 때 대부분 국룰로써 이러한 구조를 사용하게 됩니다.
사용자의 입력을 담당하는 Controller
, 비즈니스 로직을 담당하는 Service
, 데이터베이스와의 상호작용을 담당하는 Repository
의 구조입니다.
여기서 Service계층을 인터페이스의 형태로 정의하여 비즈니스 로직의 추상화를 제공하는 형태를 말하기 쉽게 ServiceImpl
이라고 이야기합니다.
예를 들어볼까요?
프로젝트 내에서 회원을 관리하는 기능을 만든다고 가정해봅시다.
public interface UserService {
User getUserById(Long id);
void createUser(User user);
void updateUser(User user);
void deleteUser(Long id);
}
이런식으로 UserService
라는 이름의 인터페이스를 생성합니다.
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserRepository userRepository
@Override
public User getUserById(Long id) {
return userRepository.findById(id).orElse(null);
}
@Override
public void createUser(User user) {
userRepository.save(user);
}
@Override
public void updateUser(User user) {
userRepository.save(user);
}
@Override
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
}
이후 UserServiceImpl
이라는 클래스가 UserService인터페이스를 실제로 구현하는 구현체가 됩니다. 여기서 Impl은 Implementation
의 약자로, 구현체임을 명확히 하기 위해 하는 네이밍입니다.
이런 식의 코드 디자인은 스프링 프레임워크 개발에서 흔히 사용되는 패턴입니다.
ServiceImpl 왜 쓸까?
저런 패턴으로 개발을 하게되면 실제 구현과 더불어서 매 서비스마다 인터페이스를 선언해야하는 번거로움이 발생할 것으로 예상되는데요, 왜 저런 패턴을 채택해서 많은 개발자들이 사용하고 있을까요?
1. 비즈니스 로직의 가독성과 코드의 유지보수성 향상
복잡한 비즈니스 로직을 인터페이스 없이 클래스만 있는 경우 해당 비즈니스 로직의 가독성이 떨어지게 됩니다.
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public User getUserById(Long id) {
return userRepository.findById(id).orElse(null);
}
public void createUser(User user) {
if (userRepository.existsByUsername(user.getUsername())) {
throw new IllegalArgumentException("Username already exists");
}
userRepository.save(user);
}
public void updateUser(User user) {
if (!userRepository.existsByid(user.getId())) {
throw new IllegalArgumentException("User does not exist");
}
userRepository.save(user);
}
public void deleteUser(Long id) {
if (!userRepository.existsById(id)) {
throw new IllegalArgumentException("User does not exist");
}
userRepository.deleteById(id);
}
}
이렇게 Service 클래스를 선언 해놓으면 실제 구현 코드가 길기 때문에, 해당 클래스의 내용을 한 눈에 파악하기 어렵게 됩니다. 예시를 위해 간단한 클래스를 만들었기 때문에 이정도는 읽을 수 있지 하는 생각이 들 수 있지만, 실제 현업에서 사용하는 클래스는 훨씬 비대하기도 하기 때문에 결국 비즈니스 로직의 가독성이 떨어지게 되고, 유지보수성 또한 현저히 낮아지게 됩니다.
public interface UserService {
User getUserByid(Long id);
void createUser(User user);
void updateUser(User user);
void deleteUser(Long id);
}
하지만 이렇게 인터페이스와 구현체로 분리되어있는 구조라면, 인터페이스만 보고 빠르게 해당 비즈니스 로직을 파악할 수 있게 되는 장점이 있습니다.
2. 코드의 확장성과 디커플링
프로젝트를 항상 진행하면 요구사항이 변경되고 확장되면서 코드가 변경되야 합니다. ServiceImpl 패턴을 사용하면 새로운 요구사항에 따라 새로운 추가 구현체를 작성할 수 있습니다.
public class UserServiceV2Impl implements UserService {
@Autowired
private UserRepository userRepository;
@Override
public User getUserById(Long id) {
// 새로운 비즈니스 로직
return userRepository.findById(id).orElseThrow(() -> new UserNotFoundException("User not found"));
}
@Override
public void createUser(User user) {
// 새로운 비즈니스 로직
if (userRepository.existsByUsername(user.getUsername())) {
throw new IllegalArgumentException("Username already exists");
}
userRepository.save(user);
}
}
이런식으로 새로운 요구사항에 맞는 새로운 구현체를 구현하여 구현체만 바꾸어주면 클라이언트 코드는 변경될 필요가 없습니다.
이를 통해 낮은 결합성을 가진 구조를 가질 수 있습니다.
ServiceImpl 왜 쓰지 말까?
ServiceImpl 패턴이 주는 이점들을 살펴보았는데요, 이렇게 좋은 이점들이 많은데 왜 쓰지 말아야할까요?
1. 프로젝트의 직관성 떨어짐
인터페이스는 왜 존재할까요? 여러 이점을 주는 것은 분명하지만 인터페이스의 가장 중요한 가치는 바로 다형성을 보장함이라고 생각합니다.
하나의 인터페이스가 여러 구현체로 나누어 동작해야함을 알려주는 힌트가 되는 것이죠. 개발자들도 이 인터페이스를 보고 아 이 인터페이스와 그 구현체는 요구사항에 따라 여러 구현체로 나뉘어 같은 동작을 해야하는구나 알 수 있게 되는 것이죠.
하지만, ServiceImpl 패턴을 사용한다면 하나의 구현체만 필요한 경우에도 전부 인터페이스를 통해 구현하게 됩니다. 그렇게되면 어느 부분이 다형성이 필요한 부분인지 파악이 어렵게되고 결국에는 프로젝트의 복잡성만 증가하는 결과를 낳게 됩니다.
아래 예시를 볼까요?
public interface UserService {
User getUserById(Long id);
void createUser(User user);
void updateUser(User user);
void deleteUser(Long id);
void performUserSpecificOperation(User user);
}
이런 UserService 인터페이스가 있습니다. 그리고 여러 상황에 따라 다른 구현체를 사용하도록 구현합니다.
@Service
public class GeneralUserService implements UserService {
@Autowired
private UserRepository userRepository;
@Override
public User getUserById(Long id) {
return userRepository.findById(id).orElse(null);
}
@Override
public void createUser(User user) {
if (userRepository.existsByUsername(user.getUsername())) {
throw new IllegalArgumentException("Username already exists");
}
userRepository.save(user);
}
@Override
public void updateUser(User user) {
if (!userRepository.existsById(id)) {
throw new IllegalArgumentException("User does not exists");
}
userRepository.deleteById(id);
}
@Override
public void performUserSpecificOperation(User user) {
System.out.println("General user specific operation");
}
}
@Service
public class AdminUserService implements UserService {
@Autowired
private UserRepository userRepository;
@Override
public User getUserById(Long id) {
return userRepository.findById(id).orElse(null);
}
@Override
public void createUser(User user) {
if (userRepository.existsByUsername(user.getUsername())) {
throw new IllegalArgumentException("Username already exists");
}
userRepository.save(user);
}
@Override
public void updateUser(User user) {
if (!userRepository.existsById(id)) {
throw new IllegalArgumentException("User does not exists");
}
userRepository.deleteById(id);
}
@Override
public void performUserSpecificOperation(User user) {
System.out.println("Admin user specific operation");
}
}
@Service
public class GuestUserService implements UserService {
@Autowired
private UserRepository userRepository;
@Override
public User getUserById(Long id) {
return userRepository.findById(id).orElse(null);
}
@Override
public void createUser(User user) {
throw new UnsupportedOperationException("Guests cannot create users");
}
@Override
public void updateUser(User user) {
throw new UnsupportedOperationException("Guests cannot update users");
}
@Override
public void deleteUser(Long id) {
throw new UnsupportedOperationException("Guests cannot delete users");
}
@Override
public void performUserSpeicificOperation(User user) {
System.out.println("Guest user specific operation");
}
}
이렇게 여러 유저 유형에 따라 다른 구현체를 사용할 수 있도록 설계한 경우, 인터페이스의 올바른 사용이라고 할 수 있습니다. 위 코드는 다음과 같이 활용할 수 있게 된답니다.
public class UserServiceFactory {
@Autowired
private GeneralUserService generalUserService;
@Autowired
private AdminUserService adminUserService;
@Autowired
private GuestUserService guestUserService;
public UserService getUserService(User user) {
switch (user.getRole()) {
case "ADMIN":
return adminUserService;
case "GUEST":
retrun guestUserService;
default:
retrun generalUserService;
}
}
}
이러한 방식으로 적절한 서비스 구현체를 선택하여 사용하도록 구현을 하게되면 여러 요구사항에 유연하게 대처할 수 있는 코드를 만들 수 있게됩니다. 이러한 구조에만 인터페이스를 사용한다면, 인터페이스를 보고 개발자들은 해당 클래스의 설계를 직관적으로 알 수 있게 됩니다.
2. 관습적인 사용으로 인한 단일 책임 원칙 위반
ServiceImpl 패턴을 사용하면 관습적인 사용으로 인해 SRP를 위반하기 쉽습니다. 보통 해당 패턴으로 구현을 하게되면 이런 식으로 구현이 됩니다. 계속해서 예시로 사용한 도메인인 User 라는 도메인이 있다고 가정해봅시다. User라는 도메인 패키지에 UserService를 선언하게 되고 해당 인터페이스에 모든 비즈니스 로직을 선언하고 그 구현체로 UserServiceImpl을 사용하게 됩니다. 이런 경우 해당 클래스에 모든 비즈니스 로직이 집중되고 결국에는 SRP를 위반하기 쉬운 구조가 됩니다.
@Service
public class LoginService {
@Autowired
private UserRepository userRepository;
public User login(String username, String password) {
User user = userRepository.findByUsername(username);
if (user != null && user.getPassword().equals(password)) {
return user;
} else {
throw new IllegalArgumentException("Invalid username or password");
}
}
}
@Service
public class SignUpService {
@Autowired
private UserRepository userRepository;
public void signUp(User user) {
if (userRepository.existsByUsername(user.getUsername())) {
throw new IllegalArgumentException("Username already exists");
}
userRepository.save(user);
}
}
@Service
public class UserProfileService {
@Autowired
private UserRepository userRepository;
public User getUserById(Long id) {
return userRepository.findById(id).orElse(null);
}
public void updateUser(User user) {
if (!userRepository.existsById(user.getId())) {
throw new IllegalArgumentException("User does not exist");
}
userRepository.save(user);
}
public void deleteUser(Long id) {
if (!userRepository.eixstsById(id)) {
throw new IllegalArgumentException("User does not exist");
}
userRepository.deleteById(id);
}
}
이런식으로 각 클래스를 작게 작게 설계하고 각 클래스가 하나의 책임만 가지게 한다면, 오히려 클래스의 이름만 보고도 어떤 기능들이 있는지 파악하기 쉬운 구조가 됩니다. 유지보수성도 올라가게 되고, 인터페이스를 구현했을 때 보다 오히려 작은 변경들을 통해 확장에 용이한 구조가 될 수 있습니다.
결론
재민님은 위 동영상에서 인터페이스를 가장 아껴서 사용해야할 자원이라고 표현합니다.
초기 설계를 통해 관습적인 인터페이스 사용보다, 초기 구현을 통해 프로젝트를 개발하다가 공통되는 부분을 인터페이스로 분리하는 작업을 통해 생성된 인터페이스가 더 값지다는 이야기를 합니다. 저는 이 이야기에 굉장히 공감이 되었답니다.
저도 ServiceImpl 패턴을 통해서 좋은게 좋은거다라고 생각하며 프로젝트를 진행했던 적이 있었는데요, 불필요하게 프로젝트의 복잡성을 높이는 설계를 했던건 아닐지 반성하게 됩니다. 앞으로는 무분별한 사용보다는 실제로 공통되는 부분을 묶어 다형성을 띄게 한다던가, 혹은 코드를 리팩토링 하는 과정이 아니라면 인터페이스를 최대한 사용하지 않고 아껴서 사용하도록 노력할 것 같습니다.
여담으로, 기회가 닿아 재민님과 기술적인 의견을 나누고 피드백을 받을 수 있었습니다. 그 당시 저의 이력과 경험을 설명드리면서 동시성 처리를 어떻게 했냐는 질문을 받게 되었는데요. 저는 Redis를 사용해서 Lock을 획득해 처리한다는 답변을 드렸고 이 답변을 드리면서 내심 만족하고 있었습니다. 실제로도 이런 서드파티 프록시를 통해 해결하는 것이 DB나 어플리케이션을 통해 동시성을 처리하는 것보다 성능이 유리하기도 하구요. 하지만 재민님은 오히려 그런 새로운 기술의 도입을 통해 문제를 해결하는 것은 기존 프로젝트의 탄탄한 구조를 무너뜨린다는 피드백을 주셨답니다. 약 1달전인데 그 당시에는 레디스 같은 기술에 너무 심취해있지 않았나 생각이 들었답니다.
이러한 패턴도 기술도 매한가지인 것 같습니다. 좋아보인다고 무분별한 사용보다는 꼭 필요할 때 사용하는 것이 그러한 기술이나 패턴에 의존을 줄이고 더 단단해지는 방법인 것 같습니다. 물론 ServiceImpl 패턴이 주는 장점 또한 존재하는 것도 부정할 수 없는 사실입니다. 이러한 트레이드 오프를 잘 고려해서 좋은 소프트웨어 설계를 하기 위해 오늘도 정진해야겠습니다!
'Backend > Spring 🌱' 카테고리의 다른 글
스프링 빈과 컨테이너 (0) | 2024.03.12 |
---|---|
HandlerMapping과 HandlerAdapter는 왜 나뉘었나요? (0) | 2024.02.07 |
스프링부트 프로젝트 시작하는 법 (0) | 2024.02.02 |
안녕하세요, 저는 주니어 개발자 박석희 입니다. 언제든 하단 연락처로 연락주세요 😆