엔티티 클래스 설계와 퍼시스턴스 프레임워크

정상혁

2020-05-07

발표자 & 주제 소개

발표자 : 정상혁

경력동안 늘 떠나지 않은 고민

발표 주제

난관의 여정

최고속(?) 웹개발 스타일 예제

Spring Boot + JSP

Spring Boot에서 URL to JSP 매핑 선언
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");
     }
}
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>

최고속(?) 웹개발 스타일 분석

어떤 분들에게는 감동의 지점

뭐라고 부를까?

한계

뷰 레이어의 분리

만능 클래스 (1)

DB 컬럼, 응답,요청에 필요한 모든 속성이 하나의 클래스에 있는 경우

public class Issue {
     private int id;
     private String title;
     private List<Account> subscribers;
     private String searchKeyword; // 검색어
     private boolean subscribed; // 내가 구독하고 있는지의 여부
}
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)

연관된 모든 테이블의 데이터를 담은 클래스가 주는 어려움

public class Issue {
     private Repo repo;
     private List<Comment> comments;
     private List<Label> labels;
     private Milestone milestone;
     private List<Account> partipants;
}
public class Account {
     private List<Issue> myIssues;
     private List<Repo> myRepos;
     private List<Comment> myComment;
     private List<Label> myLabels;
}

부작용

만능 클래스가 뷰까지 바로 전달됨

<div>${issue.milestone.creator.email}</div>

해법 찾기

패턴과 이름

Java Beans

DTO, VO

DTO (Data Transfer Object)

QueryDSL 메뉴얼에 있는 DTO 관련 예제
List<UserDTO> dtos = query.list(
    Projections.fields(UserDTO.class, user.firstName, user.lastName));

Value Object

Entity와 Value Object의 구분

Entity

DDD의 용어

(DDD 책에서 처럼 대문자로 표기)

ENTITY를 감추기

ENTITY가 뷰, API 응답에 바로 노출될 때의 비용

외부 노출용 DTO를 따로 만들기

DTO의 이름 고민

AGGREGATE로 ENTITY 간의 선긋기

AGGREGATE는?

AGGREGATE_ROOT로 저장 대상 타입을 표현해본 CrudRepository
public interface CrudRepository<AGGREGATE_ROOT, ID> extends Repository<AGGREGATE_ROOT, ID> {
    Optional<AGGREGATE_ROOT> findById(ID id);
    ...
}

AGGREGATE 경계가 있는 시스템

AGGREGATE 식별시 의식할 점

AGGREGATE 간의 참조

Stackoverflow의 한 답변
It makes life much easier if you just keep a reference of the aggregate's ID rather than the actual aggregate itself.
public class Issue  {
    private Repo repo;
}

public class Issue  {
    private long repoId;
}
public class Issue  {
    private Association<Repo> repoId;
}

public class Association<T>  {
    private final long id;

    public Association(long id) {
        this.id = id;
    }
    ...
}

여러 AGGREGATE에 걸친 조회

Service 레이어에서 조합


MilestoneEntity milestone = milestoneRepository.findByid(milestoneId);
int issueCount = issueRepository.countByMilestoneId(milestoneId)

var miletoneReponse  = MilestoneResponse.builder()
    .name(milestone.getName())
    .endedAt(milestone.endedAt())
    .issueCount(issueCount)
    .build();

JOIN이 필수적인 경우

WHERE절에 다른 AGGRAGATE의 속성이 필요한 경우
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 결과까지 다른 AGGRAGATE의 속성을 포함할 경우
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

개인적으로 TRANSACTION SCRIPT 패턴에 따라 도메인 레이어가 구성되고 퍼시스턴스 레이어에 대한 FAÇADE의 역할을 하는 객체가 추가될 때는 거리낌 없이 DAO라고 부른다.
도메인 레이어가 DOMAIN MDOEL 패턴으로 구성되고 도메인 레이어 내에 객체 컬렉션에 대한 인터페이스가 필요한 경우에는 REPOSITORY라고 부른다.
결과적으로 두 객체의 인터페이스의 차이가 보잘 것 없다고 하더라도 DAO가 등장하게된 시대적 배경과 현재까지 변화되어온 과정 동안 개발 커뮤니티에 끼친 영향력을 깨끗이 지워 버리지 않는 한 DAO와 REPOSITORY를 혼용해서 사용하는 것은 더 큰 논쟁의 불씨를 남기는 것이라고 생각한다

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

캐쉬 부작용 사례


public Issue findIssue(long issueId, long accountId)
    Issue issue = repository.findById(issueId); // 캐쉬된 객체를 변환
    if(isMyIssue(checkMyIssue(issue, accountId)) {
        // 추가 적인 처리
    }
    return issue;
}

boolean checkMyIssue(Issue issue, long accountId) {
    if (accountId == issue.getCreatedBy()) {
        issue.setMyIssue(true);
        // 캐쉬된 객체의 상태를 바꿔버림
        // 문제1: 메서드 이름과 어울리지 않아 예측이 어렵다.
        // 문제2: issue를 보는 특정 사용자에게 한정된 뷰의 값인데 Issue객체에 함께 있다.
        return true;
    }
    return false;
}

Immutable 객체의 장점

Rich Domain Object

끝나지 않는 고민

퍼시스턴스 프레임워크

프레임워크 돌아보기

SQL관리 방안

JDK 13이상의 raw literal String

class AccountSqls {
    static String final selectById =
        """
        SELECT
            id, user_id, email
        FROM
            account
        WHERE
            id = :id
        """;
    }
}

interface AccountRepository extends CrudRepository<Account, Long> {
    @Query(AccountSqls.selectById)
    Account findById(Long id);
}

Groovy로 SQL 관리

class AccountSqls {
    static String selectAccounts(AccountCriteria criteria) {
        """
        SELECT
            id, name, email
        FROM
            account
        WHERE
            user_grade = :crteria.grade
        ${
            if (criteria.grade == Grade.SPECIAL) {
                """
                    AND last_login < :criteria.loginDateLimit
                """
            } else {
                """
                    AND last_login > :criteria.loginDateLimit
                """
            }
        }
        """
    }
}

groovy-auto-complete.png

Kotlin 으로 쿼리 관리

Spring JDBC

JPA

MyBatis

최대 약점

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에서도 활용할만한 요소

대안 Framework

프레임워크 활용 전략

아키텍처 전락

실무 사례 : 복합조회 API 서버 분리

(구글 검색으로 찾은 비슷한 느낌의 화면)

complex-search.png

architecure-case.png

정리

요약 & 결론

키워드

참고 & 추천 자료

/

#