모놀리식 구조의 애플리케이션을 MSA로 전환하는 과정에서, 연관관계가 존재했던 Entity의 관계를 끊어야 했습니다.
그래서 현재 개인 프로젝트의 애플리케이션 구조에서 MSA 환경에서의 적절한 Entity 연관관계는 무엇인지 고민하게 되었습니다.
그 과정에서 생각해봤던 방안으로 2가지 방안이 떠올랐었는데, 생각했던 2가지 방안을 설명하고
개인 프로젝트에는 어떤 방안을 적용했는지 살펴보도록 하겠습니다.
0. 모놀리식 구조에서의 JPA Entity 연관관계 & 프로젝트 비즈니스 로직
기존 모놀리식 구조에서 제 개인 프로젝트의 Entity 연관관계는 다음과 같이 Member-Board가 1:N 관계였었습니다.
0-1. Board Entity
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Board extends BaseEntity {
...
@ManyToOne
@JoinColumn
private Member writer;
...
}
MSA 구조로 전환하면서, Member와 Board가 각 마이크로 서비스로 나뉘게 되고
그에 따라 Board Entity에서 참조하던 Member의 연관관계를 끊어야 했습니다.
다음은, 모놀리식 구조일 때의 개인 프로젝트 Board 도메인의 비즈니스 로직 구현부를 살펴보겠습니다.
0-2. Board의 Service 로직
@Service
@Transactional
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
private final MemberRepository memberRepository;
@Transactional(readOnly = true)
public BoardsResponse readByPage(final Pageable pageable) {
final Page<Board> boardPage = boardRepository.findAllByOrderByCreatedAtDesc(pageable);
return BoardsResponse.of(boardPage, pageable);
}
public BoardDetailResponse readDetail(final Long boardId) {
final Board findBoard = boardRepository.findById(boardId)
.orElseThrow(BoardException.NotFoundBoardException::new);
return BoardDetailResponse.of(findBoard);
}
@Transactional(readOnly = true)
public BoardsResponse searchByCondition(final String title, final String writer, final Pageable pageable) {
final BoardSearchCondition condition = new BoardSearchCondition(title, writer);
final Page<Board> searchBoards = boardRepository.searchByCondition(condition, pageable);
return BoardsResponse.of(searchBoards, pageable);
}
public Long writeBoard(final String loginId, final BoardWriteRequest request) {
final Member member = memberRepository.findByLoginId(loginId)
.orElseThrow(MemberException.NotFoundMemberException::new);
final Board board = new Board(member, request.title(), request.content());
final Board savedBoard = boardRepository.save(board);
return savedBoard.getId();
}
}
위의 메소드처럼 총 4개의 비즈니스 로직이 존재하는데, 해당 4개의 비즈니스 로직은 모두 멤버 관련 소스코드를 사용합니다.
- 페이지별 게시글 조회
- 게시글 상세 조회
- 게시글 검색
- 게시글 작성
현재 Service 계층에서는 Member와 관한 소스코드가 '게시글 작성'에서만 사용되고 있습니다.
public Long writeBoard(final String loginId, final BoardWriteRequest request) {
final Member member = memberRepository.findByLoginId(loginId)
.orElseThrow(MemberException.NotFoundMemberException::new);
...
}
나머지 조회 부분은 Service 단이 아닌 Response Dto에서 멤버 관련 소스코드가 있습니다.
0-3. Board Response Dto
public record BoardListResponse(
Long id,
String title,
String writerNickname,
LocalDateTime createdAt,
Long viewCount
) {
public static BoardListResponse from(final Board board) {
...
final String writerNickname = board.getWriterNickname();
...
}
}
해당 DTO를 생성하는 정적 팩토리 메소드의 'writerNickname'을 생성할 때,
board 엔티티의 Member 필드의 nickname을 가져오게 됩니다.
이러한 상황에서, MSA 환경으로 전환 시에 Member-Board의 Entity 관계를 어떻게 해야할지 살펴봅시다.
1. Entity 간접참조로 id만 가지기
제가 생각했던 첫번째 방법은 다음과 같이 Entity 간접참조로 id만 가지게 하는 것이었습니다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Board extends BaseEntity {
...
private Long memberId;
...
}
이 첫번째 방법의 장단점을 생각해보면 다음과 같습니다.
장점
- 도메인 관점에서 비교적 깔끔한 Board 도메인이 된다.
- Board 도메인에서 Member를 단순히 식별자 ID로 가져서 알 필요 없는 Member 정보가 삭제되었다.
단점
- 게시글 관련 4가지 로직 수행 시(Read/Write)에 모두 Member-Service 호출을 통해 Member의 정보를 얻어야 한다.
- 게시글 작성 : Board Entity 생성 시 memberId를 얻기 위해 Member-Service 호출
- 게시글 조회/검색 : Response Dto 생성 시 Board의 memberId로 member의 nickname을 얻기 위해 Member-Service 호출
- 이러한 상황에서, 성능 측면에서 비효율적이다.
처음 이 방법을 생각했던 것은, 간접참조라는 것을 원래부터 알았기 때문에 당연히 간접참조를 하면 잘 풀리겠다! 하고 적용을 해봤었습니다.
그러나 처음에는 위에서 설명한 단점(성능 이슈)이 예상되었었습니다.
- 게시글 10건 조회 시, 게시글 작성자의 이름을 Response에 담아야 함.
- 따라서, Member 서비스 요청이 게시글 조회 건 수(10건)만큼 요청되기 때문에 통신 비용이 크다.
이에 대한 해결 방안으로, IN 절을 사용해서 한번의 요청으로 Member의 nickname 리스트를 가져오는 방법을 생각했었습니다.
- 조회할 게시글의 ID가 1~10이라면, List<Long>으로 Member 서비스를 1번 호출하여 IN절로 1~10번의 닉네임을 조회
public interface MemberRepository extends JpaRepository<Member, Long> {
List<Member> findByIdIn(final List<Long> ids);
}
이렇게 해서 List<String>의 멤버 닉네임 리스트를 가져올 수는 있었지만,
해당 닉네임 리스트를 List<Board>와 조합하는 작업이 구현 상으로도 상당히 비효율적이라고 생각하게 되었습니다.
또한 게시판 도메인 비즈니스 특성 상 조회가 잦을 것이기 때문에 조회 시마다 다른 마이크로 서비스와의 통신을 통해
결과를 가져오는 것은 비효율적이라고 생각했습니다.
그래서 간접참조 대신 2번째 방안을 생각해보게 되었습니다.
2. 필요한 정보를 가지는 Entity를 생성 후 연관관계 설정
2번째로 생각했던 방법은, 필요한 정보만을 가지는 Entity를 생성하고 Board Entity와 연관관계를 설정하는 방법이었습니다.
간접참조로 Member의 ID만 가졌을 때의 단점은 조회 시에 서비스 통신을 통해 Member의 nickname을 가져와야 하는 것이었습니다.
그에 따라 성능 이슈가 예상되었었습니다.
이러한 상황에서, Board Entity가 필요한 Member의 정보를 가지는 Entity와 연관관계를 맺으면 되지 않을까? 하는 생각이 들었습니다.
이렇게 되면 조회 시에 Member 서비스 통신이 아닌, Board Entity를 통해 Response를 생성할 수 있으므로 효율적일 것 같았습니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberInfo {
@Id @GeneratedValue
private Long id;
private String nickname;
...
}
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Board extends BaseEntity {
...
@ManyToOne
@JoinColumn
private MemberInfo memberInfo;
...
}
이렇게 MemberInfo라는 Entity를 생성하고, MemberInfo-Board가 1:N 관계가 되도록 설정해줬습니다.
이 방법의 장단점을 생각해보면 다음과 같았습니다.
장점
- 게시글 관련 로직 중 게시글 작성 로직(Write)에서만 Member 서비스를 호출한다.
- 그러므로 1번 방법에 비해 성능이 훨씬 좋을 것이다.
- Response를 생성하는 구현 코드의 가독성도 좋아진다.
단점
- Board Entity가 View(Response)에 의존하는 느낌이 들 수 있다.
- MemberInfo 자체가 화면에 출력할 정보들을 담는 Entity이기 때문에 그런 느낌이 들게 되었다.
- 그래서 간접참조를 하는 1번 방법보다는 도메인 관점에서 깔끔한 도메인이 아닌 것 같았다.
- 물론, Board가 작성자 이름을 안다는 것이 도메인 관점에서도 괜찮은 측면이라고 생각도 들었는데, 생각에 따라 다를 것 같다.
- Member 정보 업데이트(삭제/수정) 시 Board의 MemberInfo에 동기화가 필요하다.
- 만약 게시글 작성 시 작성자 닉네임이 성하A 였다가, 이후에 성하B로 된다면 게시글 조회 시 작성자 닉네임이 성하B로 보여야하기 때문에 Member 정보 관련 업데이트 시에 Board 서비스의 MemberInfo도 업데이트 되어야 한다.
- 이 부분은 메시지 큐를 통해 비동기적으로 이벤트를 발행해서 해결할 수 있다.
이렇게 단점이 좀 있었지만, 1번 방법의 성능 이슈의 단점이 더 크다고 생각했습니다.
따라서 1번 방법의 예상되는 성능 이슈 및 생산성을 해결할 수 있는 2번 방법으로 리팩토링을 진행했습니다.
결과적으로, 게시글 작성/조회 시 로직을 다음과 같이 구현하게 되었습니다.
(메시지 큐를 통해 이벤트를 발행하는 부분은 추후에 구현할 예정입니다.)
Board-Service의 Service 계층 로직
@Service
@Transactional
@RequiredArgsConstructor
// TODO : 멤버 정보(닉네임) 업데이트 시 정보 동기화 처리하기
public class BoardService {
private final BoardRepository boardRepository;
private final MemberInfoRepository memberInfoRepository;
private final MemberFeignClient memberFeignClient;
...
public Long writeBoard(final String loginId, final BoardWriteRequest request) {
// Member-Service에 Feign 통신을 통해 멤버 닉네임(Response) 가져오기
final MemberFeignResponse response = memberFeignClient.findMemberIdByLoginId(loginId);
final MemberInfo memberInfo = new MemberInfo(response.nickname());
final MemberInfo savedMemberInfo = memberInfoRepository.save(memberInfo);
final Board board = new Board(savedMemberInfo, request.title(), request.content());
final Board savedBoard = boardRepository.save(board);
return savedBoard.getId();
}
}
게시글 작성 시 Feign 통신을 통해 멤버 닉네임을 가져와서 MemberInfo 엔티티를 생성하고 저장합니다.
Board-Service의 Response Dto
public record BoardListResponse(
Long id,
String title,
String writerNickname,
LocalDateTime createdAt,
Long viewCount
) {
public static BoardListResponse from(final Board board) {
...
final String writerNickname = board.getWriterNickname();
...
}
}
게시글 조회의 Response Dto에서 작성자의 닉네임은 Feign 통신을 통해 가져온 것이 아니라,
Board 엔티티에서 MemberInfo를 통해 가져옵니다.
※ MemberInfo를 VO로 구성?
사실 Member 정보를 담는 객체를 생각했을 때, Entity 이전에 VO로 구성할까도 생각해봤습니다.
굳이 따로 리소스를 들여서 테이블로 관리하지 않고 VO로 Board가 가지면 되지 않을까? 라고 처음에 생각했었습니다.
그러나, 다음과 같은 이유로 VO로 구성하지 않게 되었습니다.
- Response에 들어갈 정보가 변경(추가, 삭제)되면 Board Table을 수정해야한다.
- 만약 게시글 조회 시 작성자 닉네임 뿐만 아니라 작성자의 성별까지 포함되어야 하는 상황이라고 하면
- Board Entity가 MemberInfo를 VO로 가졌을 때는 MemberInfo의 값이 모두 컬럼으로 들어가므로 Board 테이블에 새로운 성별 Column이 추가되어야 하는 상황이 된다.
- 이러한 변경을 봤을 때 VO로 구성하는 것이 더 View에 의존하는 느낌이 들었다.
이렇게 MSA 환경에서 JPA Entity의 연관관계를 어떻게 구성할지에 대해 살펴보게 되었습니다.
아직 지식이 별로 없는 상황에서 생각해본 것이기 때문에 틀릴 수도, 더 나은 방법이 있을 수도 있겠지만
현재 제가 고민했던 상황들을 기록하고자 작성하게 되었습니다! 😃
현재 프로젝트는 2번 방법을 사용하여 Entity를 구성했으나, 도메인 및 비즈니스에 따라서 1번 방법도 괜찮을 것 같습니다.
제가 생각한 1번 방법과 2번 방법의 Trade-off는 다음과 같습니다.
마이크로 서비스 간 통신 비용 VS 데이터 동기화 비용
만약, 데이터 동기화가 필요한 상황이 잦아서
데이터 동기화 비용보다 마이크로 서비스 간 통신 비용이 적어지는 도메인 및 비즈니스라면 1번으로 간접 참조를 진행해도 될 것 같습니다!
🎯 Github Repository 링크 (전체 코드)
관련 엔티티는 아래의 Github Repository의 Member-Service, Board-Service 모듈에 존재합니다.
참고하실 분들은 아래의 Repository를 참고해주시면 감사하겠습니다.
https://github.com/sh111-coder/sh-board-msa
Reference