레거시 코드를 헥사고날 아키텍처로 전환(1)과 이어지는 글입니다.
1단계, 영속성 어댑터를 만들자
영속성 어댑터의 위치는 다음과 같다.
기존에 존재하는 UserRepository를 변경하기 이전에 나는 도메인 엔티티와 JPA 엔티티를 분리하기로 했다. 관련해서 "만들면서 배우는 클린 아키텍처"라는 책에서 다음과 같은 말을 인용해 봤다.
영속성 측면과 타협 없이 풍부한 도메인 모델을 생성하고 싶다면 도메인 모델과 영속성 모델을 매핑하는 것이 좋다.
JPA 엔티티에는 기본 생성자가 필요하다. 또한 영속성 레이어에서는 성능 측면에서 @ManyToOne 관계를 설정하는 것이 적합할 수 있지만, 도메인 모델에서는 반대가 되는 것이 더욱 객체지향적일 수 있다. 예를 들어, 팀(야구)이라는 객체가 있다. 4번 타자와 5번 타자의 순번을 변경하고 싶다고 가정 하자. 아직 객체지향 초보인 내가 감히 책임을 할당해 보자면 팀에게 주는 것이 적절해 보인다. 팀이라는 객체가 선수들에 대한 정보를 잘 알고 있기 때문이다. 그저 외부에서는 팀 객체에게 순서를 변경하라는 메시지를 보내기만 하면 된다.
클린 코더스 - Forms 중 임피던스 불일치와 관련된 내용 중에서 지금 상황에 해당되는 것을 몇 가지 추려봤다.
- DB row와 객체 간의 직접적인 매핑이 없기 때문에 hibernate도 진정한 ORM이 아니다. 실제로 이러한 툴은 RDB 테이블 -> Data Structure로 매핑하는 역할을 한다.
- 데이터베이스 인터페이스 레이어는 DB에 존재하는 DS를 애플리케이션이 사용하고자 하는 비즈니스 객체로 전환하는 책임을 갖는다. (DB 인터페이스 레이어는 위 그림에서 영속성 어댑터 쪽이라 개인적으로 이해함)
기존에 우리 프로젝트는 연관관계의 주인을 Many 쪽에 줬다. 즉, Team과 Member가 있을 경우 Member가 외래키를 관리한다. 이렇게 되면, Team에 순서를 변경하라는 메시지를 전달하기 어렵다. 따라서 나는 보통 이러한 경우 서비스 클래스에서 변경 대상 Member 두 개를 가져와서 서로의 순번을 바꾸도록 구현했었다. 이처럼 도메인 로직이 서비스 클래스에 들어가게 되면서 점점 자율적인 객체 협력과는 거리가 멀어지는 코드를 작성하게 된 것 같다. (물론 내가 객체지향을 잘 못해서 그런 것도 있다.)
무엇이 더 좋다고 말하기 애매하지만, 중요한 것은 엔티티를 분리해서 이러한 구체적인 것에 대한 고려 없이 객체 협력을 설계할 수 있다는 것이 마음에 들었다.
아직 나도 경험이 많이 부족해서 고민이 많지만.. 일단 import 된 목록을 보면, 분명 domain 패키지 내부에 javax.persistence에서 온 @Entity라는 외계인이 있다. 이런 게 문제가 아닐까 생각이 들기도 하다.
package com.yapp.artie.user.domain;
import javax.persistence.Entity;
domain/User
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class User {
private final Long id;
private final String uid;
private final String profileImage;
private String name;
public static User withoutId(String uid, String profileImage, String name) {
return new User(null, uid, profileImage, name);
}
public static User withId(Long id, String uid, String profileImage, String name) {
return new User(id, uid, profileImage, name);
}
public void rename(String name) {
this.name = name;
}
}
persistence/UserJpaEntity
@Entity
@Table(name = "user")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserJpaEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String uid;
private String profileImage;
private String name;
}
DB 인터페이스 레이어는 DS를 애플리케이션이 사용하고자 하는 비즈니스 객체로 전환하는 책임을 갖는다고 한다. 따라서 우리는 Mapper가 있어야 한다.
persistence/UserMapper
@Component
public class UserMapper {
User mapToDomainEntity(UserJpaEntity user) {
return User.withId(user.getId(),
user.getUid(),
user.getProfileImage(),
user.getName()
);
}
UserJpaEntity mapToJpaEntity(User user) {
return new UserJpaEntity(
user.getId(),
user.getUid(),
user.getProfileImage(),
user.getName()
);
}
}
현재 소스 코드 의존성의 방향도 구체적인 영속성 레이어에서 도메인 레이어 쪽으로 흐른다. 하지만, 나는 레거시에 코드 구조를 뒤엎는 작업을 하고 있다. 기존 User 클래스의 이름을 변경하니 다음과 같은 일이 발생했다.
이걸 어떻게 해결하지 고민을 많이 했다. UserUseCase의 findById를 외부에서 많이 호출해서 그런 것도 있고, User 클래스가 다른 곳에서 쓰임새가 많은가 보다... 임시방편으로 findById에서 JPA 엔티티를 반환하도록 해두고.. 나중에 id만 반환하도록 변경할까 고민 중이다.
일단, 영속성 어댑터를 구현하기 전에 필요한 out 포트를 추출했다. 이때는 인텔리제이 기능을 활용하지 않았다.
persistence/UserPersistenceAdapter
@PersistenceAdapter
@RequiredArgsConstructor
class UserPersistenceAdapter implements DeleteUserPort, SaveUserPort, LoadUserPort,
UpdateUserStatePort {
private final UserRepository userRepository;
private final UserMapper userMapper;
@Override
public User loadById(Long userId) {
return userMapper.mapToDomainEntity(findByIdOrElseThrow(userId));
}
@Override
public User loadByUid(String uid) {
return userMapper.mapToDomainEntity(findByUidOrElseThrow(uid));
}
@Override
public Long save(User user) {
UserJpaEntity entity = userRepository.save(userMapper.mapToJpaEntity(user));
return entity.getId();
}
@Override
public void delete(User user) {
userRepository.delete(findByIdOrElseThrow(user.getId()));
}
@Override
public void updateName(User user) {
UserJpaEntity userJpaEntity = findByIdOrElseThrow(user.getId());
userJpaEntity.setName(user.getName());
}
private UserJpaEntity findByUidOrElseThrow(String uid) {
return userRepository.findByUid(uid)
.orElseThrow(UserNotFoundException::new);
}
private UserJpaEntity findByIdOrElseThrow(Long id) {
return userRepository.findById(id)
.orElseThrow(UserNotFoundException::new);
}
}
원래는 통합 테스트할 때, H2 인메모리 데이터베이스를 사용했었다. TestContainer로 변경할까 고민하다가 일단 그대로 인메모리 데이터베이스를 사용하기로 했다. 테스트 이전에 필요한 외부 상태를 sql로 작성했다.
@DataJpaTest
@Import({UserPersistenceAdapter.class, UserMapper.class})
@AutoConfigureTestDatabase(replace = Replace.NONE)
class UserPersistenceAdapterTest {
@Autowired
private UserPersistenceAdapter adapterUnderTest;
@Autowired
private UserRepository userRepository;
@Test
@Sql("UserPersistenceAdapterTest.sql")
void loadById_id를_이용해서_사용자_조회() {
User user = adapterUnderTest.loadById(1L);
assertThat(user.getName()).isEqualTo("이하늘");
}
@Test
void loadById_사용자를_찾을_수_없으면_예외를_발생한다() {
assertThatThrownBy(() -> {
adapterUnderTest.loadById(1L);
}).isInstanceOf(UserNotFoundException.class);
}
@Test
@Sql("UserPersistenceAdapterTest.sql")
void loadById_uid를_이용해서_사용자_조회() {
User user = adapterUnderTest.loadByUid("mock-01");
assertThat(user.getName()).isEqualTo("이하늘");
}
@Test
void loadByUid_사용자를_찾을_수_없으면_예외를_발생한다() {
assertThatThrownBy(() -> {
adapterUnderTest.loadByUid("no-user-uid");
}).isInstanceOf(UserNotFoundException.class);
}
@Test
@Sql("UserPersistenceAdapterTest.sql")
void delete_사용자를_데이터베이스에서_삭제한다() {
adapterUnderTest.delete(adapterUnderTest.loadById(1L));
assertThat(userRepository.count()).isEqualTo(5);
assertThatThrownBy(() -> {
adapterUnderTest.loadById(1L);
}).isInstanceOf(UserNotFoundException.class);
}
@Test
void delete_사용자를_찾을_수_없으면_예외를_발생한다() {
assertThatThrownBy(() -> {
adapterUnderTest.delete(adapterUnderTest.loadById(1L));
}).isInstanceOf(UserNotFoundException.class);
}
@Test
void save_사용자를_데이터베이스에_저장한다() {
adapterUnderTest.save(defaultUser().withId(null).build());
assertThat(userRepository.count()).isEqualTo(1);
}
@Test
@Sql("UserPersistenceAdapterTest.sql")
void updateName_사용자의_이름을_변경한다() {
User user = adapterUnderTest.loadById(1L);
user.rename("tomcat");
adapterUnderTest.updateName(user);
String actual = userRepository.findById(1L)
.orElseThrow()
.getName();
assertThat(actual).isEqualTo("tomcat");
}
@Test
void updateName_사용자를_찾을_수_없으면_예외를_발생한다() {
assertThatThrownBy(() -> {
adapterUnderTest.updateName(defaultUser().build());
}).isInstanceOf(UserNotFoundException.class);
}
}
2단계, 영속성 어댑터를 사용하도록 변경하자
이제 UserRepository에 대한 의존을 지울 차례이다. 예를 들어 다음 코드를 보자. 기존에는 UserRepository를 가지고 있었는데, 대신 LoadUserPort, UpdateUserStatePort를 가지도록 변경했다. 이전에 내부 구현을 검증하는 테스트가 리팩터링에 취약하다는 것을 알게 됐다. 이번에는 몸소 체험했다.
service/RenameUserService
@UseCase
@Transactional
@RequiredArgsConstructor
public class RenameUserService implements RenameUserUseCase {
private final LoadUserPort loadUserPort;
private final UpdateUserStatePort updateUserStatePort;
@Override
public void rename(Long userId, String name) {
User user = loadUserPort.loadById(userId);
user.rename(name);
updateUserStatePort.updateName(user);
}
}
3단계, 읽기 전용 유스케이스를 분리하자
port/in/GetUserThumbnailQuery
public interface GetUserThumbnailQuery {
UserThumbnailResponseDto loadUserThumbnailById(Long id);
}
이전에 커맨드성 유스 케이스를 분리한 것과 유사한 방식으로 진행했다. (위임자 생성, 인터페이스 추출) 그리고 단위 테스트도 작성해 줬다. 읽기 전용 유스케이스까지 분리한 이후에 패키지 정리를 하면 다음과 같다. 아직까진 계획대로 되고 있는 것 같아서 다행이다.
service/UserServiceImpl
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserUseCase {
private final RegisterUserService registerUserService;
private final RenameUserService renameUserService;
private final UserWithdrawalService userWithdrawalService;
private final GetUserService getUserService;
@Override
public UserJpaEntity findById(Long id) {
User user = getUserService.loadUserById(id);
//이건 무시..!
return new UserJpaEntity(user.getId(), user.getUid(), user.getProfileImage(), user.getName());
}
@Override
public CreateUserResponseDto register(String uid, String username, String picture) {
return registerUserService.register(uid, username, picture);
}
@Override
public void delete(Long id) {
userWithdrawalService.delete(id);
}
@Override
public void updateUserName(Long userId, String name) {
renameUserService.rename(userId, name);
}
}
그다음에는 ISP에 맞게 외부 클라이언트에서 적절한 포트를 사용하도록 변경하고, 웹 어댑터를 만들고, 위 클래스는 지워버리면 될 것 같다.
4단계, 쿼리 개선
대부분의 로직이 수행되기 전에 토큰을 파싱해서 SecurityContext에 사용자 정보를 등록한다.
private void setTokenAtSecurityContext(FirebaseToken decodedToken) throws NoSuchElementException {
UserDetails user = userDetailsService.loadUserByUsername(decodedToken.getUid());
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
user, user.getPassword(), user.getAuthorities());
SecurityContext context = SecurityContextHolder.getContext();
context.setAuthentication(authenticationToken);
}
이때 다음과 같은 쿼리가 발생한다.
select
u.id,
u.created_at,
u.updated_at,
u.name,
u.profile_image,
u.uid
from
user as u
where
u.uid=?
테스트를 위해서 더미 데이터 20만 개를 밀어 넣고 실행계획을 살펴보니, 당연히 풀테이블 스캔이다.
실행 속도는 내 컴퓨터 기준 대략 100-130ms 정도로 측정된다. 이번에는 인덱스를 생성해 봤다.
ALTER TABLE `user` ADD INDEX index_user_constraint (uid);
type의 ref는 어떤 값 하나에 매칭되는 행들을 반환하는 인덱스 접근 방식이다. ( = 연산) 따라서 매치되는 행이 많으면 별로 좋지 않겠지만, uid 필드는 카디널리티가 높으니 나쁘지 않다.
실행 속도는 2ms - 7ms 사이로 상당히 개선됐다. 커버링 인덱스까지 적용하면 어떨까 문득 궁금해졌다.
explain
select
u.id,
u.uid
from
user as u
where
u.uid=?
실행 속도는 별차이가 없다. 쿼리가 간단해서 그런 것 같다. 혹시 몰라서 데이터 25만 건을 더 추가해서 비교해 봤는데, 비슷비슷했다. 다른 도메인의 더욱 복잡한 쿼리들이 많으니 그때 다시 테스트해 봐야겠다.
코드에서는 다음과 같이 uid에 인덱스를 생성했다. 아무리 검색해도 안 나와서 chatGPT에게 물어봤는데, ddl-auto를 validate로 설정하면 index 제약도 검사해 준다고 해서 달아놨다!
@Entity
@Table(name = "user", indexes = @Index(name = "index_user_uid", columnList = "uid"))
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserJpaEntity extends BaseEntity {
마무리
내 인생에서 처음으로 프로젝트에서 실행 계획을 분석하고, 쿼리를 개선한 경험이다. 물론 복잡한 쿼리는 아니었지만, 뿌듯했다. 다음에는 쿼리를 만들일이 있다면, 그때마다 실행 계획을 분석하고 더 좋은 방법이 있는지 고민하는 습관을 가져야겠다. 이번에는 간단했지만, 다른 도메인에서는 더욱 복잡한 것들을 할 것 같아서 기대된다.
인덱스 키 값의 크기가 커지면 디스크에서 읽어야 하는 횟수가 증가한다. 또한 버퍼 풀 크기가 제한적이라서 하나의 레코드를 위한 인덱스 크기가 커지면 캐시 가능한 레코드 수가 줄어들어 메모리 효율이 떨어진다.
위의 내용은 이전에 Real MySql 8.0을 공부하다가 봤던 내용이다. 현재 uid (varchar 255)에 인덱스를 생성해서 조금 찝찝한 부분이 있다. 괜찮은 건지 판단이 서지 않아, 추가적으로 공부를 해야겠다고 느꼈다. 또한 스프링 시큐리티도 자세히 공부해 봐야겠다.. 오늘은 여기까지 하고, 다음에는 웹 어댑터를 분리하고, ISP를 만족하도록 코드 정리를 해보자.
'개발(레거시) > 문제 해결' 카테고리의 다른 글
kotlin + spring boot 프로젝트에서 DTO 검증 체크리스트 (0) | 2023.08.04 |
---|---|
레거시 코드를 헥사고날 아키텍처로 전환(Final) - 회원 도메인에서 웹, 인증 어댑터 분리 (0) | 2023.04.04 |
레거시 코드를 헥사고날 아키텍처로 전환(1) - 회원 도메인에서 쓰기 전용 유스 케이스 분리 (2) | 2023.03.29 |
아르티 리팩터링 기록 - publish (0) | 2023.01.23 |
아르티 아키텍처 디자인에 대해서 끄적 (0) | 2023.01.23 |