차근차근 Modern Spring Boot 3 기초 (6) 서비스 인터페이스의 세분화와 빈 불러오기
참고: 따라치기 위한 코드는 '인증 서비스 코드 작성' 챕터에 있습니다.
서비스 레이어의 DIP(의존성 역전 원칙)
한 레이어에서 다른 레이어를 이용할 때 자주 적용하는 규칙이 있습니다. 우리 서버 애플리케이션에서는 주로 서비스 레이어를 이용할 때 기본적으로 적용하는 설계 원칙인 '의존성 역전 원칙(DIP)'입니다. 사실 다른 레이어(persistence 등)에도 적용하지만, 설명하고 이해하는 데에는 서비스 레이어만 한 게 없죠.
의존성 역전 원칙을 쉽게 말하면, 구현되어 있는 클래스보다는 추상적인 인터페이스에 의존하는 것이 더 바람직하다고 보는 원칙입니다. 이때 의존이라는 표현을 일상적인 용어로 바꾸면, 사용한다는 표현과 그 의미가 거의 같습니다. 마치 광부가 곡괭이에 의존한다는 말이, 광부가 곡괭이를 사용하고 있다는 의미도 담듯이 말이죠. 또 곡괭이가 전혀 다르게 바뀐다면 광부의 작업 방식이나 성과에도 영향을 줄 수 있기에, 의존한다는 표현이 어떤 문맥에서 성립하는 것인지 알 수 있죠.
그림: 설계 원칙이 없을 때 생길 수 있는 각기 다른 구현 이슈
설계 원칙의 발상은, 이 의존과도 밀접할 때가 많습니다. 광부가 곡괭이에 대해서 너무 과도한 의존성을 갖는다면 어떨까요? 만약 곡괭이를 바꾸어야 할 때 광부가 일을 전혀 못하게 된다면 회사는 전보다 나은 방향을 생각하게 될 것입니다.
예를 들어, 곡괭이에 대해서 '스탠다드'를 정해 주는 것이죠. 앞으로 광부가 받는 모든 곡괭이는 규격화된 제품으로, 광부들은 여러 곡괭이에 적응할 필요 없이 규격화된 곡괭이를 계속 사용할 수 있을 것입니다.
이러한 규칙을 일반화한 것이 의존성 역전 원칙입니다. 각 레이어는 다른 레이어를 사용할 때 인터페이스 타입을 사용하도록 하고, 인터페이스는 규격화된 인풋, 아웃풋을 갖는 메서드를 보장합니다. 그리고 실제 메서드의 동작은, 그 인터페이스를 구현한 클래스에서 제공하는 것이죠. 구현 클래스 내부에서 일어나는 일은 외부에서는 하나도 궁금하지 않습니다! 그저 이 메서드를 사용할 때, 약속한 인풋을 넣으면, 약속한 아웃풋을 준다는 것이 중요하죠. 이것이 우리가 자바를 다루면서 인터페이스를 중요시하는 이유입니다!
의존성 역전 원칙을 사용하는 예 (1) 서비스 인터페이스를 타입으로 사용하기
예를 들어 다음처럼 서비스 인터페이스와 그것을 구현한 클래스가 있다고 합시다.
public interface ExampleService {
// 인터페이스의 메서드는 인풋과 아웃풋을 보장합니다.
SomeData findById(String id);
}
@Service
public class DefaultExampleService implements ExampleService {
@Override
public SomeData findById(String id) {
// ... (이번 설명에서 구현된 내용은 중요하지 않습니다.)
return result;
}
}
하나의 인터페이스와 그것을 구현한 하나의 클래스죠. 이제 앞서 말한 의존성 역전 원칙대로라면, 여기서 인터페이스와 클래스 중 어떤 것을 다른 레이어에서 사용해야 할까요?
// 1번: 필드의 타입으로 인터페이스를 사용한다.
private final ExampleService exampleService;
// 2번: 필드의 타입으로 클래스를 사용한다.
private final DefaultExampleService exampleService;
추상적인 타입인 1번 인터페이스를 사용하는 것이 맞습니다! 만약 구현 클래스가 다른 클래스로 바뀌면, 2번 방식을 사용하고 있을 때는 위 코드에 수정이 필요하지만, 1번 방식을 사용하고 있었다면 주입하는 객체(빈) 외에는 수정하지 않아도 되기 때문입니다.
게다가 빈을 주입하는 부분도 우리가 아니라 스프링이 알아서 관리해 준다는 것을 앞서 설명했죠. 코드로 간단히 살펴 봐야겠습니다.
스프링에서 의존성 주입의 여러 방식
스프링에는 의존성을 주입하는 세 방식이 있습니다. 그중 레거시 프로젝트에서 많이 쓰이는 방식이 있고, 요즘 더 권장하는 방식이 있습니다. 우리는 그중 요즘 더 권장되는 방식을 사용하려고 합니다. 주입하는 객체는 방금 만든 서비스를 예시로 하겠습니다.
주입의 세 방식은 다음과 같습니다.
필드를 통한 주입
Setter를 통한 주입 (또는 기타 메서드를 통한 주입)
생성자를 통한 주입
이걸 모두 살펴보면 헷갈릴 수 있으니, 레거시 진영에서 많이 사용하는 '필드를 통한 주입'과, 최근 가장 권장되고 있는 '생성자를 통한 주입'만 살펴 보겠습니다.
필드 주입
필드를 선언하고 이 위에 @Autowired
라는 애노테이션을 붙이는 방식입니다. 간단한 방식이고 이해하기 쉽기 때문에 레거시 진영에서 많이 사용하는 방식입니다.
@RestController
public final class ExampleApi {
@Autowired
private ExampleService exampleService;
}
이제 exampleService에 무언가를 대입하는 코드 없이 exampleService 객체를 사용할 수 있습니다.
하지만 이 방식은 다음과 같은 단점이 존재합니다. 다음 단점을 지금 모두 이해할 필요는 없습니다.
필드를 final로 선언하면 안 됩니다.
애노테이션을 처리하고 객체를 채우는 동작을 DI(의존성 주입) 프레임워크에 완전히 의존합니다.
- 따라서 프레임워크에 완전히 종속되는 방식입니다.
테스트 코드를 작성할 때 필드에 목업(mockup) 객체를 넣는 과정이 더 복잡합니다.
생성자 주입
생성자를 통한 주입입니다. 스프링에서는 빈을 생성할 때, 다른 빈이 필요하다면 생성자의 파라미터에서 그 빈을 사용할 수 있도록 자동으로 주입해 줍니다.
@RestController
public final class ExampleApi {
private final ExampleService exampleService;
// (1) 생성자의 파라미터에 필요한 빈을 선언합니다. 그러면 그 자리에 빈이 옵니다.
public ExampleApi(ExampleService exampleService) {
// (2) 받은 빈을 우리 필드에 옮겨 줍니다. (this.a = a 구조)
this.exampleService = exampleService;
}
}
필드를 final로 사용할 수 있습니다.
애플리케이션을 구동하는 초기에 생성자 주입을 통해 빈 이용에 문제가 없는지 미리 체크됩니다.
생성자를 사용할 수 있으면 되기 때문에, DI(의존성 주입) 프레임워크에 완전히 종속되지 않습니다.
생성자를 사용할 수 있으면 되기 때문에, 테스트 코드 작성 시 목업 객체를 주입하기 쉽습니다.
서비스 클래스에서 레포지터리 사용하기
서비스 클래스의 DIP 네이밍
서비스 레이어는 인터페이스와 그것을 구현한 클래스로 구성된다고 설명했습니다. 그렇다면, 그 역할이 겹치는 인터페이스와 클래스는 각각 어떻게 명명할 수 있을까요?
가장 보편적인 레거시 네이밍(-Impl 접미사)
기존에 가장 많이 사용되어 온 방식은, 기능상에서의 역할뿐 아니라 구조상에서의 위치까지 네이밍으로 표현하는 방식이었습니다.
인터페이스는 역할만 표현하는 반면, 그것을 구현한 클래스는 '구현했다'라는 의미까지 담았죠.
public interface ExampleSerivce { }
public class ExampleServiceImpl implements ExampleService {}
하지만 엄밀히 ExampleService
객체라는 것은 말이 되어도, ExampleServiceImpl
객체라는 분류는 말이 안 되는 표현이기 때문에, '사용자'가 아닌 '개발자'의 관점에만 맞는 네이밍이라고 볼 수 있습니다.
역할에 맞는 이름이 아니라, 작업용 이름인 것이죠. 그래서 네이밍에 변화를 주는 사람들은 다음과 같이 이름을 바꾸어 사용했습니다.
의미가 어색하지 않도록 개선된 네이밍(Default- 접두사)
특별히 의미를 담는 단어를 붙일 수 있다면 앞에 그 단어를 붙일 수 있습니다. 하지만 특별히 붙일 단어가 없다면, Default-
접두사를 붙여서 명명할 수 있죠.
public interface ExampleSerivce { }
public class DefaultExampleService implements ExampleService {}
구조에는 변화가 없지만, 의미가 더 잘 맞습니다. 임시 방편이어야 했던 -Impl
접미사가 어느 순간부터 보편적인 네이밍으로 자리를 잡았습니다. 그것에 어색함을 느끼는 사람들이 대안으로 제시하는 것입니다.
서비스 인터페이스의 피처 단위 세분화(usecase)
위 방식들은 네이밍은 다르지만, 결국 동일한 구조에서 작성되었습니다.
하지만 구조 또한 미래지향적인 아키텍처를 택할 수 있습니다. 우리가 이번에 사용할 방식입니다. 서비스 인터페이스를 기능에 따라 세분화하여 -UseCase
라고 명명합니다. 그리고 각 컨트롤러는 usecase에 의존하도록 합니다.
이렇게 구현한다면, 그 기능을 담당하는 서비스 클래스가 바뀌어도 기존 코드를 수정할 필요가 없습니다.
public interface SignUpUseCase {/* 이곳에 회원가입 함수 선언 */}
public interface SignInUseCase {/* 이곳에 로그인 함수 선언 */}
public interface PasswordResetUseCase {/* 이곳에 비밀번호 변경 함수 선언 */}
public class AuthService
implements SignUpUseCase,
SignInUseCase,
PasswordResetUseCase {
// Ctrl + I를 눌러 메서드 구현 시작
}
현대적인 관점에서는, 언제든 쉽게 떼어내고 쉽게 결합할 수 있는 아키텍처를 더욱 바람직한 아키텍처로 평가합니다. use case로 기능 단위 인터페이스를 만들어 두면, 언제든 쉽게 서비스나 모듈을 교체할 수 있기 때문에, (비록 초기 공수가 더 필요하더라도) 보다 바람직한 아키텍처라고 평가할 수 있습니다.
게다가 작업의 영역이 뚜렷하게 구분되기 때문에 사람의 눈으로 작업하는 데에 훨씬 편한 점도 있습니다.
인증 서비스 코드 작성
이제 우리가 사용할 코드를 작성할 테니, 잘 이해해 보며 따라 치시면 됩니다.
SignUpUseCase
auth 아래에 usecase라는 패키지를 만들어 사용하겠습니다.
- Package:
com.example.demo.auth.usecase
import com.example.demo.auth.domain.Account;
public interface SignUpUseCase {
Account signUp(Account account);
}
AuthService 작성
생성자를 통해 빈을 주입받는 코드를 포함합니다.
- Package:
com.example.demo.auth.service
import com.example.demo.auth.domain.Account;
import com.example.demo.auth.repository.AccountRepository;
import com.example.demo.auth.usecase.SignUpUseCase;
import org.springframework.stereotype.Service;
@Service
public class AuthenticationService implements SignUpUseCase {
private final AccountRepository accountRepository;
public AuthenticationService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
// Ctrl + i를 눌러 아래 메서드의 틀을 받을 수 있습니다.
@Override
public Account signUp(Account account) {
// 레포지터리에 데이터를 전달하고, 결과를 반환해 주면 됩니다.
return accountRepository.save(account);
}
}
레포지터리는 생성자를 통해 주입받아서 사용했습니다.
레포지터리에서
.save(entity)
함수는 데이터를 삽입하는 역할을 기본으로 합니다.- PK인 id가 겹친다면 update문으로 동작하는 특징이 있습니다. (SQL에도 있는 기능입니다.)
Lombok 애노테이션 사용 시
생성자를 우리가 직접 타이핑하지 않아도 됩니다. this.a = a
구조의 생성자는 롬복의 애노테이션으로 생성할 수 있습니다. @RequiredArgsConstructor
부분에 집중하시면 됩니다.
import com.example.demo.auth.domain.Account;
import com.example.demo.auth.repository.AccountRepository;
import com.example.demo.auth.usecase.SignUpUseCase;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor // final, non-null 필드에 대한 생성자
public class AuthenticationService implements SignUpUseCase {
private final AccountRepository accountRepository;
@Override
public Account signUp(Account account) {
// (나중에 다른 작업을 추가합니다.)
return accountRepository.save(account);
}
}
이제 생성자를 직접 작성하지 않아도 되어서 코드가 매우 간결합니다.
롬복에 의존하는 것을 지양하는 관점도 있습니다.
- 하지만 현대적인 아이디어와 스타일을 갖고 있는 언어들에 비해, 아직 자바는 롬복을 사용해야 편하게 작성 가능한 코드가 많은 편입니다.
final
선언을 빠뜨리는 실수를 하지 마세요.
< Prev
JPA Entity를 사용하는 JPA Repository
Next >
Subscribe to my newsletter
Read articles from Merge Simpson directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Merge Simpson
Merge Simpson
Hello, I am Korean. Welcome, visitor. You are very cool. 안녕하세요, 저는 한국어입니다. 방문자여 환영한다. 당신은 매우 시원해.