[우아한테크세미나] Entity 클래스의 설계와 퍼시스턴스 프레임워크의 활용
Entity 클래스의 설계와 퍼시스턴스 프레임워크의 활용
실무 설계가 다들 어떻게 이뤄지나 이것저것 세미나를 보다가 좋은 세미나 영상이 있어서 블로그에 기록합니다. 세미나의 대략적인 내용은 다음과 같으니 해당 내용이 궁금하다면 세미나 영상을 보는 것을 추천합니다.
- Smart UI 패턴부터 현재 MVC 패턴까지의 엔티티 설계 변천사
- DTO와 VO의 차이
- REPOSITORY와 DAO의 차이
- Aggregate와 객체 지향 설계
- Immutable과 Rich Domain Object
- 퍼시스턴스 프레임워크(JPA, JDBC...)의 허와 실
원본 링크: [우아한테크세미나] 200507 우아한CRUD by 정상혁&이명현님
초고속 웹 개발 예제
SpringBoot + JSP
- 메인 메서드를 선언한다
- jsp와 연결되는 viewController 메서드를 만든다.
- jsp안에 sql쿼리로 DB에 조회한 다음 자바 언어로 NamedParameterJdbcTemplated와 Map을 사용해서 바로 jsp el을 통해 페이지에 데이터를 뿌려주면 된다.
코드보기 ↓
main
public class Application extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/repos").setViewName("repos");
}
jsp 코드
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ page import="org.springframework.web.context.support.WebApplicationContextUtils"%>
<%@ page import="org.springframework.jdbc.core.ColumnMapRowMapper"%>
<%@ page import="org.springframework.web.context.WebApplicationContext"%>
<%@ page import="javax.sql.DataSource"%>
<%@ page import="java.util.List"%>
<%@ page import="java.util.ArrayList"%>
<%@ page import="java.util.Map"%>
<%@ page import="java.util.HashMap"%>
<%@ page import="org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate"%>
<c:set var="sql">
SELECT r.name, r.description, a.name AS creator_name , a.email
FROM repo r
INNER JOIN account a ON a.id = r.created_by
WHERE a.email = :email
</c:set>
<%
String email = request.getParameter("email");
String sql = (String) pageContext.getAttribute("sql");
WebApplicationContext ctx = WebApplicationContextUtils.getWebApplicationContext(getServletContext());
DataSource ds = (DataSource) ctx.getBean("dataSource");
NamedParameterJdbcTemplate db = new NamedParameterJdbcTemplate(ds);
Map<String, Object> params = Map.of("email", email)
List<Map<String,Object>> repos = db.<Map<String,Object>>query(sql, params, new ColumnMapRowMapper());
request.setAttribute("repos", repos);
%>
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>저장소 조회</title>
</head>
<body>
<h1>저장소를 만든 사람의 이메일로 검색하기</h1>
<form action="/files" method="GET">
<h2>이메일 입력</h2>
<p>
<input type="text" name="email" size="40" value="${email}"> <input type="submit" value="조회">
</p>
</form>
<h2>조회 결과</h2>
<table border="1">
<tr>
<th>저장소 이름</th>
<th>저장소 설명</th>
<th>생성자</th>
<th>이메일</th>
</tr>
<c:forEach var="item" items="${repos}">
<tr>
<td>${item.name}</td>
<td>${item.description}</td>
<td>${item.creator_name}</td>
<td>${item.email}</td>
</tr>
</c:forEach>
</table>
</body>
</html>
초고속 웹 개발 문제점
이는 Model1 혹은 Smart UI 패턴이라고 불린다.
- Domain-Driven Design(에릭에반스, 2003)의 1장에서 소개. 에릭에반스는 이를 안티 패턴으로 얘기했다.
한계
- Map을 사용해서 컴파일(빌드) 시점에 속성명, 타입을 식별하기 어렵다.
- SQL의 추상화가 어려워서 SQL, 로직의 중복이 많아진다. 그래서 전체적인 앱 구조가 딱딱해진다.
- 추상화가 되어야 테스트 코드 작성이 쉬워지는데 해당 패턴은 테스트 코드 작성이 많이 어렵다.
뷰 레이어의 분리
java.util.Map가 여러 레이어를 거쳐갈 때의 한계
- Map에는 뭐가 들어 있는지, 어디에서 어떤 속성이 참조되고 있는지 확인하기 어렵다.
- 실수, 고치기 어려움 → 스케치만 초고속이지 이것을 운영하게 되면 더 이상 초고속이 아니다. 유지 보수가 어렵다.
속성이 선언된 클래스가 필요하다!
만능 클래스 1
그래서 속성이 선언된 일명 만능 클래스가 나왔다. 이는 DB 컬럼, 응답, 요청에 필요한 모든 속성을 하나의 클래스에 정의한다.
public class Issue {
private int id;
private String title;
private List<Account> subscribers;
private String searchKeyword; // 검색어
private boolean subscribed; // 내가 구독하고 있는지의 여부
}
클래스 기능 자체가 DB에 저장될 부분, 요청을 받고 뷰에 보여줘야 할 부분 여러 역할이 겹쳐 여러 기능들이 하나의 클래스에 모이게 되었다. 그래서 해당 클래스의 역할이 모호해져 재사용성이 급격히 떨어지는 문제를 겪게 되었다.
- 현대 기술로 생각해보면 JPA, Jackson JSON, Swagger 등 많은 애너테이션이 한 클래스에 들어가게 된다.
@Entity
@Table(indexes = {
@Index(columnList = "createdBy"),
@Index(columnList = "title")
})
@ApiModel(value = "Issue", description = "이슈")
public class Issue {
@Column("id")
private Integer id;
@ApiModelProperty("이슈 제목")
@Column("title")
private String title;
@JsonIgnore // 이슈 목록 조회때는 필요 없음.
private List<Account> unsubscribers;
만능 클래스 2
또 하나의 만능 클래스 패턴이 있다. 이전 만능 클래스는 레이어 별로 다른 역할을 하나로 뭉친 것이고 이는 하나의 클래스가 많은 엔티티(객체)들과 연관관계를 가지고 있어 이 관계에 대해 한 클래스에 담은 클래스이다.
- 이는 JPA를 사용하는 개발자분들에게서 많이 보여지는 클래스 형태이다
public class Issue {
private Repo repo;
private List<Comment> comments;
private List<Label> labels;
private Milestone milestone;
private List<Account> partipants;
}
하다보면 이게 안전성에 대해서 위험이 있다. 만약 Account(User) 객체가 account.id를 참조하는 모든 테이블과 대응되는 객체(Issue)를 의존하는 경우는?
- JPA와 같은 ORM를 얼마나 성숙하게 쓰고있느냐를 알고싶다면 Account(User) 클래스를 보면 된다.
- Account에 온갖 의존성이 다 걸려있다면 상당히 많은 부작용이 있거나 시스템이 작아서 N+1 문제가 발생해도 개의치않고 있을 가능성이 높다.
public class Account {
private List<Issue> myIssues;
private List<Repo> myRepos;
private List<Comment> myComment;
private List<Label> myLabels;
}
부작용
다음과 같이 의존성이 다 걸려있으면 성능저하를 불러일으킨다.
- 항상 연관된 객체를 다 조회한다면 불필요한 쿼리가 많이 날아간다.
- 이러한 많은 의존성에서는 당연히 조회할 때 N+1 쿼리 주의해야 한다.
이러한 문제를 잘 해결하는 것이 Lazy loading인데 이것이 또 모든 문제를 해결해주지는 않는다. 이것이 모든 쿼리를 대변해주지는 않기 때문에 무조건 수동으로 값을 채워야 하는 경우가 생기는 데 이때 또 난관이 있다.
- Issue.getComments()에 값이 채워질지 아닐지는 DAO 내부까지 따라가봐아 알수 있다.
- 비슷한 메서드가 여러개 생길수도 있다.
- findIssueById(), findIssueByIdWithComments()
만능 클래스가 뷰까지 바로 전달됨
엎치고 덮친격으로 레이어 별로 모든 책임을 다지고 있고 연관관계에 대해서도 모든 테이블을 다 담고있는 상태에서 뷰로 전달된다면 더 큰 문제를 야기할 수 있다.
JSP/Freemarker, SpEL에서 깊은 객체 탐색이 이루어질 가능성이 높다.
- 객체 참조 관계를 바꾸는 비용이 크다.
- 예로 issue.milestone.creator.email → issue.milestone.creator.mail로 바꾼다고 하면 JSP와 같은 뷰에서는 컴파일 타임 검증이 안되어서 에러가 발생하기 쉽다.
<div>${issue.milestone.creator.email}</div>
이러한 만능 클래스들을 보면 SMART UI 패턴이 그리워질 수도 있다...
패턴과 이름
Smart UI 패턴에서 만능 클래스를 거쳐 이렇게 해법을 찾고 찾다가 나온 클래스를 현장에서 다음과 같이 많이 부른다.
- Java Beans
- VO
- DTO
- Entity
이들은 모두 동일한 것일까? 한 번 비교해보자.
1. Java Beans
오라클에는 현재 Java Beans 스펙이 다 나와있다. 근데 또 Java Beans라고 부르기에는 애매한게 DB에서 조회한 값을 담는 객체가 꼭 setter가 있어야 하는 것은 아니다. immutalbe하게 builder를 사용해서 만들 수도 있다.
- 그런데 현재 이 스펙을 다 의식하고 개발하는 사람은 거의 없다.
- getter/setter는 많은 프레임워크에서 활용되고 있기는 하다.
- 그 역할을 하는 객체가 Setter가 꼭 있어야하는 것은 아니다. builder와 같은 패턴으로 대체할 수 있다.
VO vs DTO
VO와 DTO는 용어 상의 혼란이 상당히 있다. 이에 대해 자료를 찾아보며 정리해봤다.
2. VO
VO의 보편적 정의는 마틴파울러의 정의와 위키페디아의 정의에 따르면 값이 같으면 동일하다고 간주되는, 식별성이 없는 작은 객체로 나타낼 수 있다. (예 : Money, Color )
- JEP 169 : Value Object : getter/setter가 아닌 값의 동일성으로 판단하는 그런 객체들을 JVM 내부에 어떻게 효율적으로 메모리에 저장할까를 담고있다.
- DDD에서도 이 정의를 따르고 있다.
- Hibernate 메뉴얼의 Value Type도 Value Object를 포함한다고 생각된다.
VO는 DTO와 혼용해서 쓰여 왔다.
- Core J2EE Pattern 1판(2001년)에서 TO(Transfer Object)를 VO라고 적었던 것이 VO와 DTO를 혼용되어 쓰기 시작했다고 볼 수 있다.
- 다음과 같은 서적에서는 DTO와 동일한 의미라고 밝혔다.
- Expert One-to-One J2EE Development(로드존슨, 2004) : (265페이지) 'Value objects are sometimes referred to as Data Transfer Object(DTOs)’
- Professional Java EE Design Patterns(2014) : (12장) 'The DTO is also referred to as the Value Object'
- The Java EE Architect’s Handbook, Second Edition(2014) : (5장) 'My definition of "value object" is very close to a Data Transfer Object (DTO)'
Data Holder의 의미로 폭넓게 생각하는 경향도 있다.
- Money, Color와 같이 값이 동일하다고 간주되는 객체라고 볼 수는 없지만 주로 데이터 중심적인 개발을 할 때 DB에 있는 것이나 어떤 값을 실어서 나르는 객체가 VO라고 치면은 뒤에 나오는 Entity처럼 식별성을 가지거나 rich domain으로 안써서 그냥 VO라고 하지 않았나 싶다.
3. DTO
원격호출을 효율화하기 위해 나온 패턴이다.
- 마틴 파울러의 페이지를 보면 이 용어에 대한 역사까지 설명해주고 있다.
- Core J2EE Pattern 2판(2003년)에서는 TO로 이름 붙였다.
요즘에는 네트워크 전송 시의 Data holder 역할로 쓰이는 느낌이다.
- MSDN의 DTO(데이터 전송 개체) 만들기에서는 '네트워크를 통해 데이터를 전송 하는 방법을 정의 하는 개체'
- CQRS journey 5장 : 수정할 속성을 담는 객체도 DTO로 정의
그런데 기존에 원격 호출을 줄이려는 관점에서 벗어나 현재는 맥락이 달라졌는데 같은 패턴 이름을 쓸 수 있을까?
- '레이어 간의 경계를 넘어서 데이터를 전달’하는 역할은 과거와 동일하다고 생각할 수 있다.
- 원격 호출 레이어에 국한 되지 않게 쓰는 경우도 넓게 보면 이 용어로 이해할만 하다.
- 다만 다양한 객체의 역할을 다 DTO로 이름 붙이는 건 혼란도 있다.
- 예) HTTP 요청으로 오는 파라미터를 담을 객체, 통계 쿼리의 결과를 담을 객체
- 예) QueryDSL에서 DB조회 결과를 담을 객체
정리하면 DTO는 현재 초기 문제를 해결하려는 요지와는 많이 달라지긴 했지만 역할 자체는 크게 변한 것이 없어서 괜찮다. 하지만 이것도 DTO, 저것도 DTO라고 부르는 것은 한번 더 고민해 볼 필요가 있다.
Entity와 Value Object의 구분
Entity
- 사전적 의미 : 실체. Something that exists apart from other things, having its own independent existence
- JPA의 @Entity 로 익숙한 개념 : DB 테이블과 대응되는 객체
DDD의 용어
DDD에서는 ENTITY와 VALUE OBJECT에 대해 다음과 같이 정의한다. (DDD 책에서 처럼 대문자로 표기)
- ENTITY : 연속성과 식별성의 맥락에서 정의되는 객체
- VALUE OBJECT : 식별성 없이 속성만으로 동일성을 판단하는 객체. 마틴 파울러가 언급한 것과 동일 (money, color)
ENTITY를 감추기
ENTITY가 뷰, API 응답에 바로 노출될 때의 비용
entity가 뷰나 JSP, 응답에 바로 노출될 때 다음과 같은 비용들이 발생하는 것을 알 수 있다.
캡슐화를 지키기 어려워진다.
- 내부에 꼭 필요하지 않는 속성(createAt, updatedAt 등)도 외부로 노출되어 향후 수정하기 어려워진다.
JSP, Freemarker에서의 ENTITY 참조
- 컴파일 시점의 검사 범위가 좁다 → ENTITY 클래스를 수정했을때 뷰에서 에러가 나는 경우가 뒤늦게 발견된다.
- JPA를 쓴다면 OpenEntityManagerInViewFilter 를 고려해야한다.
- JPA가 관리하는 세션 사이클에 포함되어 있어야만 lazy loading으로 추가 쿼리를 날릴 수 있다.
- 그런데 트랜잭션 경계를 벗어났을 때 즉, 뷰(JSP)에 엔티티가 전달된 후에 lazy loading으로 가져오는 객체의 getter가 호출 된다면 세션 사이클이 끝났기 때문에 쿼리를 호출할 수 없다. 이게 가능하게 하려면 OpenEntityManagerInViewFilter 와 같은 필터로 뷰까지 세션 사이클을 열어줘야 한다.
- 뭐 사용하면 되지만 최적화 관점에서 신경도 많이 써야하고 초보 개발자는 쿼리가 실행되는 시점을 예상하지 못한다.
JSON 응답
- @JsonIgnore , @JsonView 같은 선언이 많아지면 JSON의 형태를 클래스만 보고 예측하는 난이도가 올라간다.
외부 노출용 DTO를 따로 만들기
- ENTITY → DTO 변환 로직은 컴파일 타임에 체크된다.
- DTO는 비교적 구조를 단순하게 가져갈 수 있다.
- 더 단순한 JSON 응답, JSP에서 쓰기 좋은 구조를 만들기에 유리하다.
- DTO의 변화는 외부 인터페이스로 의식해서 관리하는 범위가 된다.
- 예: Swagger 스펙 활용
- 여러 ENTITY를 조합할수 있는 여지가 생긴다.
DTO의 이름 고민
역할별로 구분된 DTO 정의 예
- 이슈 조회 JSON 응답 : IssueResponse, IssueDto, IssueDetailDto
- 이슈 생성 JSON 요청 : IssueCreationRequest, IssueCreationCommand
- 이슈 조회 조건 : IssueQuery, IssueCriteria
- 이슈 DB 통계 조회 결과 : IssueStatsRow
- 이슈 + 코멘트 복합 조회 결과 : IssueCommentRow
AGGREGATE로 ENTITY 간의 선긋기
AGGREGATE는 하나의 단위로 취급되는 연관된 객체군, 객체망이다.
- DDD에서 언급한 ENTITY와 VALUE OBJECT의 묶음으로, 엄격한 데이터 일관성, 제약사항이 유지되어야 할 단위이다.
- 이 AGGREGATE안에 속한 객체들은 같은 Transaction, Lock의 필수 범위에 포함된다.
- 불변식(Invariants, 데이터가 변경될 때마다 유지돼야 하는 규칙)이 적용되는 단위이다. (ex. 주문을 할 때 총 주문금액은 얼마 이상 넘을 수 없다.)
- RDB보다는 명확하게 한꺼번에 저장하는 Document DB와 어울린다고 볼 수 있다.
AGGREGATE 1개당 REPOSITORY 1개
- AGGREGATE ROOT를 통해서 밖에서 AGGEGATE 안의 객체로 접근한다.
Spring Data의 CrudRepository 인터페이스도 AGGREGATE 관점으로 보는 것이 좋다.
- AGGREGATE_ROOT로 저장 대상 타입을 표현해본 CrudRepository
- 이 개념없이 Repository를 설계하면 초반에 설명한 만능클래스처럼 많은 엔티티 연관성이 걸쳐져있는 그런 엔티티가 나오게 된다.
public interface CrudRepository<AGGREGATE_ROOT, ID> extends Repository<AGGREGATE_ROOT, ID> {
Optional<AGGREGATE_ROOT> findById(ID id);
// ...
}
AGGREGATE간의 경계가 있는 시스템
이미 상대적으로 일관성을 지켜져야 할 부분이 나눠져있기 때문에 별도의 저장소나 API 서버를 분리할 때 상대적으로 유리하다.
- AGGREGATE 밖은 좀 더 느슨하게 eventual consistancy(시간이 지나서 일관성을 처리)를 목표로 할 수도 있다.
- 여러 AGGREGATE의 변경은 Event, SAGA, TCC 등의 패턴을 활용할 수도 있음
AGGREGATE별로 Cache를 적용하기에도 좋다. 만약 분리할 계획이 없더라도 AGGREGATE 경계로 설정해놓으면 코드를 고칠 때 ‘이 엔티티의 영향성은 어디까지다.’ 라는 식으로 쉽게 구조를 파악할 수 있어 유지보수에 유리하다.
AGGREGATE 식별시 의식할 점
말로는 쉽지만 식별할 때 어떻게 해야 될까?
CUD를 먼저 생각하고 R은 단순 R(findById)에 집중해야 한다.
- 모든 R을 다 포용하려고 한다면 깊은 객체 그래프가 나온다.
(JPA를 쓴다면) ‘Cascade를 써도 되는 범위인가?’를 생각해야 한다. Cascade를 써도된다면 같은 공동체라라고 생각해도 된다. Cascade를 사용할 때는 엔티티 경계에 신경쓰면서 항상 유의하도록 하자.
AGGREGATE 간의 참조
AGGREGATE간의 참조를 할 때는 다른 AGGREGATE의 Root를 직접 참조하지 않고 ID로만 참조하면 좋다.
Stackoverflow에서 ‘Aggregate Root references other aggregate roots’라는 질문에 달린의 한 답변
- It makes life much easier if you just keep a reference of the aggregate's ID rather than the actual aggregate itself.
- AGGREGATE 그 자체를 참조하기 보다는 그 ID를 참조한다면 인생이 좀 더 쉬워질 것이다.
예를 들어 Issue가 Repo를 참조한다고 하면 다음과 같이 직접 참조를 하면 경계가 모호해지기 때문에 Id를 참조하는 식으로 설계를 하는 것이다.
// 직접 참조 X (경계가 모호해짐)
public class Issue {
private Repo repo;
}
// Id 참조 O
public class Issue {
private long repoId;
}
다만 Id만 그냥 참조하면 참조타입을 식별하기 어려우므로 다음과 같이 참조될 타입을 알수 있도록 힌트를 주는 클래스를 만들어도 좋다.
- Spring Data JDBC의 AggregateReference도 이와 같은 역할을 해준다.
public class Issue {
private Association<Repo> repoId;
}
public class Association<T> {
private final long id;
public Association(long id) {
this.id = id;
}
...
}
여러 AGGREGATE에 걸친 조회
결국에는 조인을 해야하지 않을까? 생각할 수 있지만 굳이 조인을 하지 않고도 해결할 수 있는 케이스가 있다.
Service 레이어에서 조합
다음과 같이 milestone과 issue를 조회한다고 하면 한방 쿼리보다는 따로 조회한 다음 DTO에 붙여서 보내주면 된다.
- 한방쿼리보다 오히려 이 방식이 DB 성능에 더 좋을 때가 있다. 왜냐하면 각각의 쿼리가 단순해지기 때문이다.
- 그리고 Application/DB 레벨의 캐시에 더 유리하다
MilestoneEntity milestone = milestoneRepository.findByid(milestoneId);
int issueCount = issueRepository.countByMilestoneId(milestoneId)
var miletoneReponse = MilestoneResponse.builder()
.name(milestone.getName())
.endedAt(milestone.endedAt())
.issueCount(issueCount)
.build();
JOIN이 필수적인 경우
그런데 JOIN이 필수적인 경우도 있다. 바로 WHERE절에 다른 AGGREGATE의 속성이 필요한 경우이다.
- repository의 데이터를 조회하는데 repository 생성한 사람(created_by)의 account.email로 조회를 할 경우 다음과 같이 쿼리를 작성한다.
- List<Repo> findByCreatorEmail(String email)
SELECT r.name, r.description, r.created_by, r.created_at
FROM repo r
INNER JOIN account a ON a.id = r.created_by
WHERE a.email = :email
만약 SELECT 결과까지 다른 AGGREGATE의 속성까지 포함할 경우
- 맞춤형 전용 DTO를 만들 수 있다.
- 클래스가 늘어나지만 장점도 있다.
- Aggregate를 단순하게 유지할 수 있다.
- JPA의 경우: Persistent Context를 의식하지 않아도 된다.
- 이런 쿼리는 Repository보다는 Dao에 어울린다.
SELECT r.name, r.description, a.name AS creator_name , a.email
FROM repo r
INNER JOIN account a ON a.id = r.created_by
WHERE a.email = :email
REPOSITORY vs DAO
- DAO는 퍼시스턴스 레이어를 쿼리가 왔다갔다 하는 레이어를 캡슐화하는 관점이다.
- DDD의 REPOSITORY는 도메인 레이어에 객체 지향적인 컬렉션 관리 인터페이스를 제공한다.
조영호님 블로그 글 ‘DAO와 REPOSITORY 논쟁’ 중에서
개인적으로 TRANSACTION SCRIPT 패턴에 따라 도메인 레이어가 구성되고 퍼시스턴스 레이어에 대한 FAÇADE의 역할을 하는 객체가 추가될 때는 거리낌 없이 DAO라고 부른다.
도메인 레이어가 DOMAIN MDOEL 패턴으로 구성되고 도메인 레이어 내에 객체 컬렉션에 대한 인터페이스가 필요한 경우에는 REPOSITORY라고 부른다.
결과적으로 두 객체의 인터페이스의 차이가 보잘 것 없다고 하더라도 DAO가 등장하게된 시대적 배경과 현재까지 변화되어온 과정 동안 개발 커뮤니티에 끼친 영향력을 깨끗이 지워 버리지 않는 한 DAO와 REPOSITORY를 혼용해서 사용하는 것은 더 큰 논쟁의 불씨를 남기는 것이라고 생각한다.
깔끔하게 REPOSITORY는 AGGREGATE과 1대1 개념이고 특정 하나의 컬럼을 쿼리치는 것과 같이 AGGREGATE에 대한 개념에 대해 신경안쓰고 접근할 수 있을 때 DAO라고 부를 수 있다.
Lazy loading 다시 생각하기
- AGGREGATE를 정리하고 복합조회용 객체를 따로 정의하면 Lazy loading이 필수일지 한번 더 생각해 볼 필요가 있다.
- 반대로 Lazy loading이 있어서 깊은 객체 그래프의 ENTITY를 설계하는 유혹에 빠질 수도 있다.
- Lazy loading이 필요하다는 것은 모델링을 다시 생각해봐야한다는 신호일 수도 있다.
- aggregate 경계는 일관성과 함께 변경되어야 한다는 것을 의미하는데, lazy-loading가 존재한다면 실제로 그 그룹은 aggregate 집합으로 묶여있지 않아도 된다는 것을 의미한다.
- 동의한다. 나도 lazy-loading이 명령측에 있다는 것은 잘못 모델링했다는 것을 의미한다는 것을 발견했다. 만약 명령 측에서 값이 필요하지 않다면 거기엔 lazy-loading이 없어야 한다.
Developer 2: To be clear, the aggregate boundary is here to group things that should change together for reasons of consistency. A lazy load would indicate that things that have been grouped together don't really need this grouping.
Developer 1: I agree. I have found that lazy-loading in the command side means I have it modeled wrong. If I don't need the value in the command side, then it shouldn't be there.
이런 전제들이 이뤄지기 위해서는 복잡한 조회를 어떻게 빼낼까하는 것을 풀어내는 게큰 숙제중 하나이다.
Immutable과 Rich Domain Object
캐쉬 부작용 사례
- 다음 로직은 isMyIssue()에서 캐쉬된 issue 객체를 글로벌하게 변경하는 문제가 발생한다.
- 문제점은 Issue를 Immutable하게 설정하지 않은 점과 isMyIssue에서 상태를 바꾼 점이다.
public Issue findIssue(long issueId, long accountId)
Issue issue = repository.findById(issueId); // 캐쉬된 객체를 변환
if(isMyIssue(issue, accountId)) {
// 추가 적인 처리
}
return issue;
}
boolean isMyIssue(Issue issue, long accountId) {
if (accountId == issue.getCreatedBy()) {
issue.setMyIssue(true);
// 캐쉬된 객체의 상태를 바꿔버림
// 문제1: 메서드 이름과 어울리지 않아 예측이 어려움
// 문제2: issue를 보는 특정 사용자에게 한정된 뷰의 속성인데 Issue객체에 함께 있음
return true;
}
return false;
}
Immutable 객체의 장점
- Cache 하기에 안전하다.
- 다른 레이어에 메서드 파라미터로 보내도 값이 안 바뀌었다는 확신을 할 수 있다.
- DTO류가 여러 레이어를 오간다면 Immutable하면 더 좋다.
그러면 모든 객체는 다 Immutable해야 하나? 그건 또 고민을 해봐야 한다. Immutable하지 않은 예로 Rich Domain Object가 있다.
Rich Domain Object
Domain object가 가진 속성과 연관된 행위를 한다.
- 해당 객체에 있는 것이 책임이 자연스럽다. (INFORMATION EXEPERT 패턴)
- 데이터 중심 → 책임 중심의 설계로 진화할 수 있다.
상태를 바꾸는 메서드가 포함될 수도 있다.
- 상태를 바꿀 때의 정합성 검사를 포함
- 예) Domain Event 추가. Spring Data의 AbstractAggregateRoot에 있는 메서드 참고
Immutable이 아니게 될 수 있다. 만약 영속화될 Domain Object라면 상태를 바꾸는건 시스템의 전체 상태(DB 데이터까지)를 바꾸는 경우에 한해야 한다.
- 메서드명도 그 행위를 잘 드러내어야한다. ( setTitle() → changeTitle() )
끝나지 않는 고민
- 비슷한 속성을 가진 클래스가 너무 많이 생기는 건 아닐까?
- 예) 이슈의 제목과 관련된 속성을 담은 클래스
- IssueCreationCommand, IssueCriteria, IssueDetailResponse, IssueCreatedEvent
- IssueCreationCommand, IssueModificationCommand를 따로 만들어야하나? (id등 차이가 나는건 1개로 동일한 건 10개일때)
- 이름 짓기가 어려움
- 예) 이슈의 제목과 관련된 속성을 담은 클래스
- 객체 간의 매핑 로직에서의 실수
퍼시스턴스 프레임워크
프레임워크 돌아보기
Aggregate가 안쳐져 있는 '선을 넘는 Entity' 로는 어떤 프레임워크를 써도 개발이 괴롭다.
- 반대로 경계가 잘 처진 Entity를 쓴다면 프레임워크의 마법(lazy-loading, persistence level auto-cash 등)이 필수적이지 않다.
프레임워크의 특정 기능을 위해서 추구하는 객체 설계를 포기하는 상황이 적을수록 좋다.
- 예로, Spring JDBC의 BeanPropertyRowMapper 를 쓰려면 setter가 필수이다. Cache 되었을때 부작용을 막기 위해 Immutable하게 만들고 싶어도 할 수 없다.
- 이러한 순간이 많을 수록 설계에 프레임워크가 해를 끼치고 있다는 것을 인지해야 한다.
Spring JDBC
- 단순한 쿼리 실행기. JDBC wrapper
- 확장 가능
- 비교적 Low level 인터페이스 제공 : RowMapper
- Spring-JDBC-ROMA도 확장 사례
- 쿼리 관리 방법도 프레임워크에서 강제하지 않는다
JPA
- OR-MAPPING, 퍼시스턴스 컨텍스트 : 몇가지 좋은 원칙을 지키도록 해준다,
- Entity의 상태가 변한다는 것→ DB에도 Update된다는 의미로 볼 수 있다.
- 성숙한 사용자는 이미 2가지 프로그래밍 모델을 섞어서 쓰고 있을 가능성이 높다.
- CUD, 단순R : Entity, Repository, 쿼리 자동 생성
- 복잡한 R : 응답전용 DTO, + QueryDSL 등으로 개념적인 쿼리는 직접 작성한다.
- Trade-Off가 있다면 개발 중 스키마 자동 생성 VS Local 서버의 재시작 시간이 길다.
MyBatis
- Spring JDBC와 JPA의 중간 정도의 추상화라고 보면 된다. iBatis → MyBatis로 넘어오면서 단순한 쿼리 실행기가 아니게 되었다.
- ORM적인 특성을 갖고 있다.
- First level cache, Lazy loading
- 1대 다 매핑, N+1 쿼리 가능성 등
- JDBC API보다 추상화된 동작
- batchUpdate 를 유도하기 위해서는 executorType을 BATCH로 설정해서 sqlSession을 따로 분리해야 한다.
- executorType이 batch일때는 UPDATE, UPDATE, SELECT 순서로 쿼리가 호출되면 UPDATE 쿼리를 몰아서 날리기도 함한다.
- XML로 쿼리관리를 할 수 있다는 점은 지금 시점에서 장점이 아니다.
최대 약점
- 쿼리 결과 → Entity 매핑 때 Entity 속성명을 Type safe하게 지정할 수 있는 방법이 없다.
- RowMapper같은 확장 인터페이스가 메뉴얼에는 없다.
- XML이나 Annotation 방식이나 컴파일 타임 체크가 약하고 다른 result mapping 방법보다 확장 가능성이나 코드 간결성면에서 단점이 크다고 느낀다.
XML 방식의 쿼리 결과 매핑
<resultMap id="projectResultMap" type="example.Product">
<result property="id" column="id" />
<result property="name" column="name"/>
<result property="price" column="price"/>
<result property="description" column="desc"/>
<association property="seller" javaType="example.Seller">
<constructor>
<idArg column="seller_id" javaType="int"/>
<arg column="seller_name" javaType="String"/>
</constructor>
</association>
</resultMap>
애너테이션 방식의 쿼리 결과 매핑
@Select(ProductSql.SELECT_PRODUCT)
@Results(value = {
@Result(property="id", column="id"),
@Result(property="name", column="name"),
@Result(property="price", column="price")
@Result(property="seller.id", column="seller_id")
@Result(property="seller.name", column="seller_name")
})
대안 Framework
- JOOQ : Type safety를 활용할 수 있음.
- Requery : Native 쿼리를 Annotation 기반의 결과 매핑
- Spring Data JDBC → 해당 발표 자료 참고
- Micronaut Data JDBC : Spring Data JDBC와 유사하지만 몇가지 기능이 더 있음.
- 복합 키 (@EmbeddedId) 지원
- insert / update 구분 지원
- @ColumnTransformer 지원
프레임워크 활용 전략
- 적용 패턴에 따라 프레임워크를 다르게 사용
- 예) CUD + 단순 R → JPA
- 복합적인 R → Spring JDBC
- JPA를 써도 쿼리작성/최적화 의식을 하면서 구현하고 있을 것이다.
- Native SQL 위주라면 Spring JDBC로도 쓸만하다 : 단순한 쿼리 실행기, 확장 가능성
- 하나의 프레임워크로 통일한다면 Spring Data JDBC도 고려해볼만하다.
- AGGREGATE/ENTITY 개념으로 다루기 어려운 부분은 NamedParameterJdbcTemplate 을 직접 사용하면 된다.
아키텍처 전략
- AGGREGATE별 분리가 어려운 부분을 별도의 시스템으로 분리하는 것도 고려해볼만하다.
실무 사례 : 복합조회 API 서버 분리
다음은 결제일자, 카드번호, 취소 건 여부 등 조회조건이 복잡한 화면이다. 실무에서 겪었던 문제와 가장 비슷한 느낌의 화면을 구글에서 가져왔다. 어디에 where 절이 걸리고 조건이 따로 걸리고 정렬 순서를 바꾸면 orderBy에 값이 추가되는 데 또 index가 변경되고 이러한 것들이 얽혀 온갖 복잡한 기법을 다 써야하는 상황을 마주했었다.
어떤 퍼시스턴스 프레임워크를 사용해야 할까?
- 기존 시스템을 점진적으로 분리하는 프로젝트의 방향성을 갖고 복잡한 쿼리를 담당하는 API서버부터 새로 개발했다.
- AGGREAGATE 경계를 넘어서는 복잡한 조회이기 때문에 REPOSITORY가 아닌 DAO 개념으로 접근했다.
- Native SQL 중심 개발
- 쿼리 힌트를 조회/정렬 조건에 따라 다르게 넣어야했음.
- 최적화를 위해 복잡한 형태의 쿼리가 많이 들어감
- Spring JDBC + Spring Data JDBC의 일부 기능 사용
- JPA Dialect가 없는 자체 분산 DB(NBase-T) 사용
- JPA를 쓸 수 있다고해도 적용의 이득이 없는 상황
정리
선을 넘지 않는 ENTITY를 만들자
- 외부 레이어에 ENTITY 감추기
- AGGREGATE 단위로 ENTITY간의 경계 의식하기 (ID만 참조)
- 복합적인 READ 결과를 담을 클래스 분리
프레임워크는 설계를 거드는 역할이 되어야 한다.
- 편의성을 주는 기능이 설계를 해치는지 경계해야 한다.
- 프레임워크에서 주는 제약이 설계에 도움을 주기도한다.
때로는 다른 가치를 위해 더 긴 코드를 만들 수도 있다
- 고치기 쉬운 , 협업하기 쉬운, 확장하기 쉬운 코드
핵심 키워드
- AGGREGATE
- '객체지향 설계는 (J2EE나 심지어 자바 같은) 특정 구현 기술보다 더 중요하다.' (로드 존슨)
- 이 말을 한층 더 응용해서 생각해보면 ENTITY 설계는 (JPA나 Spring JDBC 같은) 특정 구현 기술보다 더 중요하다.
JSP Mode1부터 DDD까지의 엔티티 설계 변천 과정에 대해 살펴볼 수 있었고 DTO, VO의 차이와 DAO와 REPOSIOTRY의 차이 그리고 개념에 대해 다시 한번 생각해볼 수 있었다. 그리고 Aggregate boundary 잡기... 아직 DDD 설계를 완벽히 이해하진 못했지만 어느정도 감은 잡히는 것 같다. 실제로 한번 써먹어봐야 더 이해가 갈 것 같다. 그런데 작은 개인 프로젝트에서 DDD를 쓰는 것은 배꼽이 더 큰 느낌이긴 하다. 그리고 객체 지향 설계, 아니 그냥 설계는 솔직히 장 폴이 말한 BDC(B와 D사이의 C)처럼 앱의 시작과 죽음 사이에는 끊임없는 선택을 해야 하는구나라는 생각이 들었다. 누구나 완벽함을 꿈꾸지만 결국 완벽함은 없다. 각 앱 특징과 성격에 따라 트레이드 오프를 고려하여 최적의 방식을 맞춰나가는 과정들의 연속. 최선의 선택이 무엇인지 알아내고 결정을 내릴 수 있을 수 만큼의 실력을 가지는 것이 좋은 개발자가 되는 길이 아닐까 하는 생각이 들었다.
참고 & 추천 자료
- 애그리게잇 하나에 리파지토리 하나
- Spring Data JDBC, References, and Aggregates
- Domain-Driven Design with Relational Databases Using Spring Data JDBC (SpringOne Platform 2019의 발표)
- 우아한 객체지향 (우아한 테크 세미나 2019.06.20)
- 발표 영상
- 99쪽 : 어떤 객체를 묶고 어떤 객체들을 분리할 것인가?
- 103쪽 : 경계 밖의 객체는 ID를 이용해 접근
- 169쪽 : 도메인 단위 모듈 = 시스템 분리의 기반
- 전체 내용을 글로 풀어서 정리한 블로그 포스트
- Open Session in View Pattern (조영호 님의 블로그)
- DAO와 REPOSITORY 논쟁 (조영호 님의 블로그)
- Effective Aggregate Design