개요
Layered Architecture에 대해 간략하게 내용을 정리한 후 테스트하기 좋은 상태로 변환해 가는 과정에 대해서 보여주고자 한다.
Layered Architecture 란?
가장 일반적인 아키텍처 패턴으로, n-tier 아키텍처 패턴이라고도 알려져 있다.
계층화 아키텍처 패턴은 각각의 수평적인 계층으로 구성된 컴포넌트들을 가지고 있으며, 각 계층은 애플리케이션 내에서 특정한 역할을 수행한다.
계층화 아키텍처 패턴은 몇 개의 표준 계층으로 구성되어 있는데, 주로 프레젠테이션, 비즈니스, 영속성, 데이터베이스 등의 계층으로 구성된다.
프레젠테이션 계층, 비즈니스 계층, 영속성 계층, 데이터베이스 계층이라는 네 가지 계층이 일반적으로 존재한다.
- 프레젠테이션 계층은 사용자 인터페이스와 브라우저 간의 통신 및 화면 표시 로직을 처리하는데 대표적인 구성요소는 View와 Controller가 있다.
- 비즈니스 계층은 요청과 관련된 특정 비즈니스 로직을 실행하는 역할을 수행한다. Persistence Layer에서 불러온 데이터에 대해 로직을 실행 후 Presentation Layer 로 전달한다. 대표적으로 Service가 있다.
- 영속성 계층은 데이터 액세스 및 데이터의 영구 저장을 처리한다. 대표적으로 DB와 상호작용 및 트랜잭션 관리하기 위한 JPA / JDBC 가 이 영역에 해당된다.
- 데이터베이스 계층은 실제 데이터베이스와의 상호작용을 처리한다. MySQL, MariaDB, PostgreSQL, MongoDB 등 데이터베이스가 위치한 계층을 의미한다.
Layered Architecture의 문제점
위 이미지와 같이 클라이언트 요청은 순차적으로 Layers에 접근하고 마지막 Database Layer에서 필요한 데이터를 조회한 후 다시 역행해서 클라이언트에게 응답한다.
즉 요청을 처리하기 위해서는 DB에 접근해 데이터를 조회한 후 이를 바탕으로 비즈니스 로직이 수행된다는 점이다.
이로인해 문제점이 여러 개 생긴다.
1. 테스트 코드 작성시에 항상 DB가 필요하며 Spring Test를 기반으로 한 무거운 테스트를 수행해야 한다.
Spring을 기반으로 Test 하는 것은 스프링 컨테이너를 만들고 빈주입 등등 많은 과정이 필요할 뿐만 아니라 H2를 연결한다고 하면 더 많은 시간이 소요된다.
그리고 이는 테스트 코드가 많다면 매번 테스트를 수행에 몇 초를 대기해야 하는 문제가 발생한다. 매번 테스트를 수행할 때마다 긴 시간이 소요된다고 하면 개발자에게도 테스트수행하기에 부담이 된다.
2. DB의 상태는 언제든지 변경될 수 있다. 그렇기 때문에 테스트 성공이 보장되지 않는다.
DB 내부 row에 의존적이기 때문에 특정 결괏값이 항상 보장되지 않을 수 있으며 동일한 결과를 얻기 위해 동일한 DB 상태를 구성해놓아야 한다. 즉 DB에 매우 강결합된 모습이다.
@SpringBootTest
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class GetUserCouponsTest {
@Autowired
private UserCouponRepository userCouponRepository;
private ArrayList<String> userEmails;
@BeforeEach
void getUserEmails() {
this.userEmails = new UserEmailDummy().getUserEmail();
}
@Test
@Order(1)
@DisplayName("특정 이메일(유저)가 소유한 쿠폰 갯수 구하기")
void getUserCoupons() {
final int USER_COUPON = 10;
String userEmail = userEmails.get(0);
// userCouponRepository에서 데이터가 없다면 테스트에 성공할 수 있을까?
List<UserCoupon> userCoupons = userCouponRepository.findUserCouponByEmail(userEmail);
int userCouponSize = userCoupons.size();
assertEquals(USER_COUPON, userCouponSize);
}
}
소규모 프로젝트이거나 단순 CRUD 프로젝트가 아닌 복잡한 프로젝트의 경우 더더 테스트가 힘들어진다.
해결책 : 의존관계 역전 활용하기
객체 지향 프로그래밍에서 유지보수 가능하고 확장 가능하도록 하는 원칙인 SOLID 원칙에 대해 알고 있어야 한다.
- 단일 책임 원칙 (Single Responsibility Principle, SRP): 클래스나 모듈은 하나의 책임만 가져야 한다. 즉, 클래스는 단 하나의 변경 이유만을 가져야 하며, 기능이나 역할이 여러 개인 클래스는 분리되어야 한다.
- 개방-폐쇄 원칙 (Open-Closed Principle, OCP): 소프트웨어의 엔티티는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다. 즉, 기존의 코드를 수정하지 않고도 새로운 기능을 추가할 수 있어야 한다.
- 리스코프 치환 원칙 (Liskov Substitution Principle, LSP): 상위 타입의 객체는 하위 타입의 객체로 대체 가능해야 한다. 즉, 하위 클래스는 상위 클래스의 기능을 변경하지 않고 사용할 수 있어야 한다.
- 인터페이스 분리 원칙 (Interface Segregation Principle, ISP): 클라이언트는 자신이 사용하지 않는 인터페이스에 의존하도록 강요받지 않아야 한다. 즉, 인터페이스는 클라이언트에 필요한 기능에만 집중하도록 분리되어야 한다.
- 의존관계 역전 원칙 (Dependency Inversion Principle, DIP): 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 추상화된 인터페이스에 의존해야 한다. 즉, 의존성은 추상화에 의존해야 하며 구체화에 의존해서는 안된다.
이 중 의존관계 역전을 활용해서 빠르고 작은 단위테스트가 가능하도록 할 것이다.
개선 전 객체 간 강결합 일때의 문제점은 여러가지가 있다.
1. 유연성의 저하: 강한 결합은 한 객체의 변경이 다른 객체에 영향을 미칠 수 있다. 하나의 객체를 수정하면 해당 객체와 직접적으로 상호 작용하는 다른 객체도 수정해야 할 수 있다.
2. 재사용의 어려움: 재사용하려는 객체를 사용하기 위해서는 해당 객체와 강하게 결합된 모든 객체도 함께 가져와야 할 수 있다.
3. 테스트의 어려움: 강한 결합은 객체를 독립적으로 테스트하기 어렵게 만든다. 객체를 단독으로 테스트하기 위해서는 해당 객체와 강하게 결합된 다른 객체들을 더미 객체 또는 목 객체로 대체해야 할 수 있다.
4. 확장의 어려움: 강한 결합은 시스템에 새로운 기능을 추가하거나 변경할 때 어려움을 줄 수 있다. 기존의 객체들에 대한 변경이 필요하고, 새로운 객체를 추가하기 위해 기존 객체들과 함께 수정해야 할 수 있다.
개선 후 객체간 결합되어 있던 부분을 인터페이스를 통해 추상화하면서 느슨한 결합이 되도록 했다.
UserService 객체는 UserRepositoryImpl에 무엇이 구현되어 있는지 전혀 알 필요가 없고 의존관계 주입된 객체를 사용하기만 하면 된다.
즉 실제 Production 환경에서는 UserRepositoryImpl를 사용하고 테스트 코드 작성 시엔 FakeUserRepository를 객체로 사용할 수 있으며 Business Layer 이후의 Persistence Layer, Database Layer의 강결합을 약하게 할 수 있다.
최종적으로 각 레이어마다 의존관계 역전을 통해 느슨한 결합을 통해 레이어별로 테스트하기 쉬운 환경을 만들 수 있다.
테스트 코드 작성
느슨한 결합으로 특정 레이어를 테스트할 때 진짜 객체가 아닌 테스트 대역을 사용해서 순수 로직만 테스트할 수 있다.
테스트 대역
- Dummy : 아무 동작하지 않는 객체
- fake : Local에서 사용허거나 테스트하기 위해 만들어진 가짜 객체로 자체 로직 보유
- stub : 미리 준비된 값을 세팅 후 특정 메서드 호출 시 특정 결괏값 도출
- mock : Stub 역할에 메서드 호출 확인 객체 (검증 로직 보유)
- spy : 메서드 호출 전부 기록 후 확인용 객체
stub, mock, spy 같은 경우 각 특징이 다르지만 stub의 경우 특정 응답값을 전달해 주는 외부 컴포넌트에, Mock은 Stub의 기능에 특정 결괏값 검증 로직에, spy는 메서드가 실행됐는지 확인해야 하는 로직에 사용되면 좋을듯하다.
Fake 객체는 다른 테스트 대역과 달리 상호작용할 수 있는 로직이 있어 최대한 본래의 콘크리트 객체를 모방할 수 있어서 다른 테스트 대역보다는 Fake의 테스트 대역이 persistence Layer에 적합하다는 생각이 든다.
// Fake 객체 활용 예시
@Test
void 메일_발송시_파라미터가_잘_주입되어_발송되는지_확인한다() {
// given
FakeMailSender fakeMailSender = new FakeMailSender();
CertificationService certificationService = new CertificationService(fakeMailSender);
// when
certificationService.send("test@gmail.com", "인증코드 발송 : " + 1, "abcde");
// then
assertThat(fakeMailSender.getTo()).isEqualTo("test@gmail.com");
assertThat(fakeMailSender.getSubject()).isEqualTo("인증코드 발송 : 1");
assertThat(fakeMailSender.getText()).isEqualTo("abcde");
}
@BeforeEach
void setUp() {
FakeUserRepository fakeUserRepository = new FakeUserRepository();
FakePostRepository fakePostRepository = new FakePostRepository();
TestClockHolder testClockHolder = new TestClockHolder(1670000000000L);
postService = new PostServiceImpl(fakeUserRepository, fakePostRepository, testClockHolder);
User user1 = User.builder()
.id(1L)
.email("user1@gmail.com")
.nickname("user1")
.verificationCode("abcx")
.status(UserStatus.UNCERTIFIED)
.lastLoginAt(0L)
.build();
fakeUserRepository.save(user1);
fakePostRepository.save(Post.builder()
.id(1L)
.title("제목")
.text("내용")
.createdAt(1665000000000L)
.updatedAt(null)
.user(user2)
.likeCount(0L)
.build());
}
관련 코드는 https://github.com/kuninaa19/testable-architecture 에 있다.
테스트 코드 작성에 대해서는 상세하게 적지 않았는데, 가장 중요한 건 의존관계 역전 필요성을 이해하는 것이기 때문이다.
이는 외부 컴포넌트와 실제 DB를 사용하지 않게 되면서 통합 테스트 대신 유닛 테스트를 수행할 수 있도록 하며 순수하게 각 Layer 별로 테스트를 수행할 수 있기 때문이다.
개인적으로 테스트 영역은 Business Layer만 테스트해도 되지 않을까라는 생각을 하게 됐다.
Presentation Layer는 Business Layer를 DTO로 converting 해주는 역할만 수행하기 때문에 개인적으로는 안 해도 되지 않을까라는 생각이 들었다.
물론 개발자가 DTO와 Domain 객체를 잘 mapping 못했을 경우도 있겠지만 말이다.
Persistence Layer는 JPA를 사용하고 도메인을 중심으로 개발된 코드라면 JPA는 단순히 Input/Output 용으로 Entity를 불러오는 역할일 텐데 Presentation Layer와 마찬가지로 안 해도 되지 않을까라는 생각이 들었다.
단지 저의 생각일 뿐이고 다른 의견이 있으시다면 댓글로 작성해 주신다면 정말 감사하겠습니다.
링크로 작성한 github 레포지토리에는 Domain Entity와 DB Entity가 분리되어 있는데 다음 기회에 관련 글을 작성해볼까 한다.
뭐랄까 Domain Entity와 DB Entity가 결합됐을 때와 분리됐을 때 트레이드오프가 존재하는데 현재 Domain Entity와 DB Entity가 결합된 형태의 개발에 익숙하기 때문에 좀 더 학습한 후 공유하도록 하겠다.
미리 궁금하시다면 여기를 눌러주세요
'Project' 카테고리의 다른 글
다양한(?) 동시성 제어 방법 맛보기 (0) | 2023.05.30 |
---|