public KafkaClient(String hosts, String group, String topic) {
        HashMap<String, Object> configs = new HashMap<>();
        configs.put("bootstrap.servers", hosts);
        configs.put("group.id", group);
        configs.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer");
        configs.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");

        this.producer = new KafkaProducer<>(configs);
        log.debug("create kafka client. ");
        this.gson = new Gson();
    }
    
      public void sendMessage(String data) {
        String jsonMsg = gson.toJson(new Message(data, new Date().getTime()));
        log.debug("sendMessage [{}]", jsonMsg);
        producer.send(new ProducerRecord<>(topic, jsonMsg));
    }
.stream().collect(Collectors.toMap(A::getKey, B::getKey)
>> .stream().collect(Collectors.toMap(A::getKey, B::getKey, (k1,k2) -> k1)

java8 stream collect  toMap 사용 중 duplicate key 발생 시

Map 반환에 키 중복이 있을때 발생하는데,  오버로딩된 toMap 메소드 중 merge 기능이 있는걸 사용하면 된다. 

<실행되는 Collectors.class >

public static <T, K, U>
Collector<T, ?, Map<K,U>> toMap(Function<? super T, ? extends K> keyMapper,
                                Function<? super T, ? extends U> valueMapper,
                                BinaryOperator<U> mergeFunction) {
    return toMap(keyMapper, valueMapper, mergeFunction, HashMap::new);
}

스프링 배치잡으로 실행하려고 셋팅 하고 (라이브러리셋팅, config셋팅)

kafka consumer 를 poll 할때

ConcurrentModificationException. KafkaConsumer is not safe for multi-threaded access

에러가 발생했다. 

이유가 뭔고 하니 

카프카 컨슈머는 쓰레드세이프하지 않으니 멀티쓰레드 환경에서는 쓰지 말라는 것이다. 

어쩔까 하다 보니 결국 하나씩만 실행하게 환경을 구축해 줘야 한다. 

.yml 파일에 아래와 같이 셋팅해주자.

spring.task.execution.pool.coreSize: 1

관련 javadoc : http://kafka.apache.org/21/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html#multithreaded

 

KafkaConsumer (kafka 2.1.0 API)

Subscribe to all topics matching specified pattern to get dynamically assigned partitions. The pattern matching will be done periodically against topics existing at the time of check. This is a short-hand for subscribe(Pattern, ConsumerRebalanceListener),

kafka.apache.org

 

.yml

# hadoop
  hadoop:
    user: 
    home: /Dev/hadoop-2.6.0
    command: /Dev/hadoop-2.6.0/bin/hadoop.cmd
    confDir: /Dev/hadoop-2.6.0/etc/hadoop
    defaultFS: hdfs://
    yarn:
      resourceManager: 
      hadoopMapreduceJobTracker: 
      hadoopMapreduceJobHistory: 
      yarnQueueName: default
  hive:
    connectionTimeout: 1800000
    maximumPoolSize: 1
    infoUsername: 
    driverClassName: org.apache.hive.jdbc.HiveDriver
    jdbcUrl: jdbc:hive2://

.gradle


    // hadoop-hive

    implementation group: 'org.springframework.data', name: 'spring-data-hadoop', version: '2.2.1.RELEASE'
    implementation ('org.apache.hive:hive-jdbc:2.1.0') {
        [new Tuple('org.eclipse.jetty.orbit', 'javax.servlet')
         , new Tuple('org.eclipse.jetty.aggregate', 'jetty-all')
         , new Tuple('org.json', 'json')
         , new Tuple('org.slf4j', 'slf4j-log4j12')
         , new Tuple('org.apache.logging.log4j', 'log4j-slf4j-impl')
        ].each {
            exclude group: "${it.get(0)}", module: "${it.get(1)}"
        }
    }
    compile group: 'org.apache.parquet', name: 'parquet-hadoop', version: '1.11.1'

    compile group: 'org.apache.hadoop', 'name': 'hadoop-common', version: '2.6.5'
    compile group: 'org.apache.hadoop', 'name': 'hadoop-hdfs', version: '2.6.5'
    compile group: 'org.apache.hadoop', 'name': 'hadoop-mapreduce-client-core', version: '2.6.5'
    compile group: 'org.apache.hadoop', 'name': 'hadoop-hdfs', version: '2.6.5'
    compile group: 'org.apache.hadoop', 'name': 'hadoop-mapreduce-client-core', version: '2.6.5'

    // end-hadoop-hive

 

HadoopHiveConfig.java

package .common.config;

import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import javax.sql.DataSource;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.transaction.managed.ManagedTransactionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;

@Slf4j
@Configuration
@MapperScan(basePackages = "", sqlSessionFactoryRef = "hiveSqlSessionFactory", annotationClass = HadoopHiveConfig.HiveMapper.class)
public class HadoopHiveConfig {
    @Value("${spring.hadoop.home}")
    private String hadoopHome;
    @Value("${spring.hadoop.user}")
    private String hadoopUser;
    @Value("${spring.hadoop.command}")
    private String hadoopCommand;
    @Value("${spring.hadoop.confDir}")
    private String hadoopConfDir;
    @Value("${spring.hadoop.defaultFS}")
    private String hadoopDefaultFS;
    @Value("${spring.hadoop.yarn.resourceManager}")
    private String yarnResourceManager;
    @Value("${spring.hadoop.yarn.hadoopMapreduceJobTracker}")
    private String hadoopMapreduceJobTracker;
    @Value("${spring.hadoop.yarn.hadoopMapreduceJobHistory}")
    private String hadoopMapreduceJobHistory;
    @Value("${spring.hadoop.yarn.yarnQueueName}")
    private String yarnQueueName;

    @Value("${spring.hive.connectionTimeout}")
    private long hiveInfoConnectionTimeout;
    @Value("${spring.hive.maximumPoolSize}")
    private int hiveInfoMaximumPoolSize;
    @Value("${spring.hive.infoUsername}")
    private String hiveInfoUsername;
    @Value("${spring.hive.driverClassName}")
    private String hiveInfoDriverClassName;
    @Value("${spring.hive.jdbcUrl}")
    private String hiveInfoJdbcUrl;
    @Value("${spring.hive.poolName}")
    private String hivePoolName;

    @Bean(name = "hiveSqlSessionFactory")
    public SqlSessionFactory sqlSessionFactory(@Qualifier("hikariHiveDatasource") DataSource hikariHiveDataSource) throws Exception {
        SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
        sessionFactory.setDataSource(hikariHiveDataSource);
        sessionFactory.setMapperLocations(
            new PathMatchingResourcePatternResolver().getResources("classpath:mapper_hive/*.xml") );
        sessionFactory.setTransactionFactory(new ManagedTransactionFactory());
        return sessionFactory.getObject();
    }

    @Bean(name = "hikariHiveDatasource")
    public DataSource dataSource() {
        setSystemProperty();
        HikariConfig config = getHiveHikariConfig();
        return new HikariDataSource(config);
    }

    private void setSystemProperty() {
        System.setProperty("hadoop.home.dir", hadoopHome);
        System.setProperty("HADOOP_USER_NAME", hadoopUser);
    }

    protected HikariConfig getHiveHikariConfig() {
        HikariConfig config = new HikariConfig();

        config.setPoolName(hivePoolName);
        config.setJdbcUrl(hiveInfoJdbcUrl);
        config.setDriverClassName(hiveInfoDriverClassName);
        config.setUsername(hiveInfoUsername);
        config.setMaximumPoolSize(hiveInfoMaximumPoolSize);
        config.setConnectionTimeout(hiveInfoConnectionTimeout);

        return config;
    }

    /**
     * hdfs conf
     * @return
     */
    public org.apache.hadoop.conf.Configuration getHdfsConfig() {
        org.apache.hadoop.conf.Configuration conf = new org.apache.hadoop.conf.Configuration();
        // core-site
        conf.set("fs.defaultFS", hadoopDefaultFS);
        // hdfs-site
        conf.set("dfs.replication", "3");
        conf.set("dfs.permissions", "false");

        return conf;
    }
    /**
     * hdfs conf + yarn conf
     * @return
     */
    public org.apache.hadoop.conf.Configuration getYarnConfig() {
        org.apache.hadoop.conf.Configuration conf = new org.apache.hadoop.conf.Configuration(getHdfsConfig());

        conf.set("mapreduce.job.user.classpath.first", "true");

        conf.set("mapreduce.framework.name", "yarn");
        conf.set("yarn.resourcemanager.hostname", yarnResourceManager);
        if(hadoopMapreduceJobTracker.length() > 0) {

            conf.set("mapreduce.jobtracker.address", hadoopMapreduceJobTracker);
            conf.set("mapreduce.jobhistory.address", hadoopMapreduceJobHistory);
            conf.set("yarn.app.mapreduce.am.staging-dir", "/tmp/mapred-dic/staging");

            // 필요함.
            conf.set("mapreduce.jobhistory.webapp.address", "0.0.0.0:50030");
            conf.set("mapreduce.jobtracker.http.address", "0.0.0.0:51030");
            conf.set("mapreduce.tasktracker.http.address", "0.0.0.0:51060");
        }

        if(yarnQueueName.length() > 0) {
            conf.set("mapreduce.job.queuename", yarnQueueName);
        }


        return conf;
    }

    @Target(ElementType.TYPE)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface HiveMapper {
    }

}
import com.google.gson.annotations.SerializedName;
import lombok.Data;

@Data
public class ImageLogJson {
    @SerializedName("field_name")
    public String name;
    @SerializedName("url")
    public String url;
}

private int bitAndOperator(List<Integer> nums) {
List<String> bits = new ArrayList<>();
int maxLength = 0;

for (Integer num : nums) {
String e = Integer.toBinaryString(num);
if (maxLength < e.length()) {
maxLength = e.length();
}
bits.add(e);
}
List<String> padBits = new ArrayList<>();
for (String bit : bits) {
padBits.add(pad(bit, maxLength));
}
System.out.println(padBits);

Map<Integer, List<String>> posPerBits = new LinkedHashMap<>();
for (int i = 0; i < maxLength; i++) {
posPerBits.put(i, getAndValue(padBits, i));
}
String res = "";
for (Map.Entry<Integer, List<String>> entry : posPerBits.entrySet()) {
if (isAllPositive(entry.getValue())) {
res += "1";
} else {
res += "0";
}
}

return Integer.parseInt(res, 2);
}

private boolean isAllPositive(List<String> value) {
for (String s : value) {
if (s.equals("0")) {
return false;
}
}
return true;
}

private List<String> getAndValue(List<String> padBits, int i) {
List<String> res = new ArrayList<>();
for (String padBit : padBits) {
res.add(String.valueOf(padBit.charAt(i)));
}
return res;
}

private String pad(String input, int maxLength) {
int padLen = maxLength - input.length();
if (padLen > 0) {
for (int i = 0; i < padLen; i++) {
input = ("0" + input);
}
}
return input;
}

개발자를 위한 코드리뷰

코드리뷰를 왜 해야 하는가 ?

  • 시장과 비지니스의 요구사항

    • 항상 변한다.
      • 빠르게, 자주, 안정적으로 배포 해야 한다.
  • 릴리즈별 개발자 수(기하급수적 늘어남) , 릴리즈별 생산성 (늘어나지않음) , 릴리즈별 코드작성 비용 (늘어남)

  • 동작 > 복붙 > 공유부족으로 인한 개발 인력에 대한 의존도 높아짐

아키텍처의 중요성

클린코드, 좋은설계, 아키텍처에 대한 중요성

  • 중복이 하나도 없게 하는것도 리소스가 너무 들어감 (3개까지는..인정)

  • Big ball of mud

    • 뚜렷한 아키텍처 없이 구현된 시스템
  • 지속적으로 변화하는 요구사항 수용

코드를 잘짜는게 중요한 설계다.

  • Agile 더 좋은 sw개발 , 단순절자변경 개발 역량
  • Transformation ?

코드리뷰 목적

  • 주목적: 품질문제 검수 (버그/장애)
  • 부가 목적
    • 서로에게 관심
    • 지식공유
    • 집단 코드 오너십 및 결속 증대
  • 컴퓨터가 할수 있는건 컴퓨터에게 맡겨라
  • 스타일같은걸로 힘빼지 말자
  • 기분안상하게 조심

리뷰는 즉시 시작

  • 속도를 위해. PR은 작고, 범위가 좋은 사이즈로
  • 너무 크면 PR을 분리하라
  • 예제코드 제공에 관대해라 .
    • 다 알려줄 필요는 없지만 헤메는걸 보고있을 필요도 없다.

공격적 리뷰 자제

  • 코드작성자에 대한 지적은 제외해라.
  • 명령하지 마라. 요청해라
  • 의견을 줄때는 레퍼런스로 .
  • PR에 포함되지 않은 라인은 리뷰범위가 아님.

칭찬해라

  • 잘못된 부분에 집중하지 말고 좋은변경에 대한 진심어린 칭찬을 해라 (특히 주니어에게)

교착상태를 피해라.

  • 만나서 얘기해라 텍스트로는 한계가 있다.

  • 인정하거나 Escalate 해라. (그냥 승인해라)

  • 다른 리뷰어에게 할당

  • 오프라인 리뷰 최소화 (오프라인상에서 선배가 하는 리뷰는 지시로 받아들일수 있다.)

  • 코드 비난에 대한 두려움을 극복해라 - 그냥 받아들여라


private int getFromIdx(int idx) {
return idx * IN_QRY_LIMIT_SIZE;
}

private int getToIdx(int idx, int originSize) {
int fromIdx = getFromIdx(idx);
int toIdx = fromIdx + IN_QRY_LIMIT_SIZE;
if (toIdx > originSize) {
toIdx = originSize;
}
return toIdx;
}



    private static List<String> getFilePathListByImage(String url, String localPath, String extension) throws IOException {
        BufferedImage bi = ImageIO.read(url);
        int totalHeight = bi.getHeight();
        int splitSize = (totalHeight / SPLIT_RULE_HEIGHT) + 1;
        List<StringsplitFileNmList = new ArrayList<>();
        for (int i = 0; i < splitSize; i++) {
            String splitFileNm = "fileName_" + i;
            File outputFile = new File(localPath + splitFileNm + "." + extension);
            int ySize = SPLIT_RULE_HEIGHT * i;

            int height = SPLIT_RULE_HEIGHT;
            if ((i + 1) == splitSize) {
                height = totalHeight % SPLIT_RULE_HEIGHT;
            }
            if (height == 0) {
                continue;
            }

            BufferedImage subImage = bi.getSubimage(0, ySize, bi.getWidth(), height);

            ImageIO.write(subImage, extension, outputFile);
            splitFileNmList.add(splitFileNm);
        }
        return splitFileNmList;
    }


'java' 카테고리의 다른 글

bit AND 연산 구현  (0) 2021.08.19
from idx to idx 설정  (0) 2020.04.07
확률을 퍼센트로 줘서 랜덤하게 뽑기  (1) 2018.01.18
Intellij Custom VM Option 넣기  (0) 2017.06.16
JUnit Test property 주입 하기  (0) 2017.04.26

DDD Start- 최범균

도메인주도 설계 구현과 핵심 개념 익히기.

1. 도메인 모델 시작

도메인 모델 패턴

도메인 규칙을 객체 지향 기법으로 구현하는 패턴

  • 핵심 규칙을 구현한 코드는 도메인 모델에만 위치, 규칙이 바뀌거나 규칙을 확장해야 할때 다른코드에 영향을 덜주고 변경내역을 모델에 반영
  • 점진적으로 만든 도메인 모델은 요구사항 정련을 위해 도메인 전문가나 다른 개발자와 논의하는 과정에서 공유 하기도 한다.

엔티티와 벨류

  • 엔티티의 가장 큰 특징은 식별자를 갖는다. (ID)
  • 타입을 보고 유추가 가능하게 해서 자연스럽게 코드가 이해되도록 한다.

도메인 모델에 set 넣지 않기

밸류타입은 불변으로 구현한다.

  • set 메소드는 도메인의 핵심개념이나 의도를 코드에서 사라지게 한다.
  • 생성자를 통해 받을수 있도록 한다.

도메인 용어

  • 도메인 용어를 사용하여 구현하는 불필요한 변환과정을 하지 않아도 된다.
  • 코드로 해석하는 과정이 줄어들어 이해하는 시간을 절약한다.

도메인 영역의 주요 구성요소

  • DB엔티티와 도메인모델 엔티티의 차이.
    • 도메인 모델의 엔티티는 데이터와 도메인기능을 함께 제공한다.

2. 아키텍쳐 개요

DIP

Dependency Inversion Principle

  • 저수준 모듈이 고수준 모듈에 의존
  • 도메인과 응용 영역에 대한 영향을 주지 않거나 최소화 하면서 구현기술을 변경
  • 중간에 요구사항(저수준)이 바뀌어도 응용영역을 변경하지 않아도 된다.

애그리거트 (Aggregate)

  • 도메인 모델의 구성요소는 규모가 커질수록 복잡해진다.
  • 관련객체를 하나로 묶은 군집 이다.
  • 전체구조를 이해하는데 도움이 된다.

리포지터리 (Repository)

도메인 객체를 영속화 하는데 필요한 기능을 추상화

  • 실제 구현 클래스는 인프라 스트럭처 영역에 속한다.

인프라 스트럭처 개요 (Infrastructure)

  • 표현영역/응용영역/도메인영역을 지원
  • 인터페이스를 도메인영역과 응용영역에서 구현하는것이 시스템을 유연하게 만든다. ex. @Transaction - 한줄로 트랜잭션을 처리한다. > 시스템을 유연하게 만든다.

3. 애그리거트

관련한 모델을 하나로 모아 복잡한 모델을 관리하는 기준을 제공

도메인 규칙과 일관성

  • set 을 안넣는 이유
    • 자연스럽게 불변 타입으로 구성하게 되어 일관성이 깨질 확률이 낮아진다.
    • 의미가 드러나는 이름을 사용하게 될 확률이 높아진다.

트랜젝션 범위

  • 한 트렌젝셔에서는 한 애그리거트만 수정 해야 충돌의 가능성이 줄어든다.
  • 한 애거리거트 내부에서 다른 애거리거트 상태를 변경하는 기능을 실행하면 안된다.
  • 한 애거리거트가 다른 애거리거트의 기능에 의존하면 결합도가 높아지게 된다.

ID를 통한 애그리거트 참조

모델의 복잡도가 하향한다.

ID를 통한 참조와 조회 성능

  • N+1 조회 문제 : 조회대상이 N개면 N번 조회 (잘못된 ORM 설계)
  • join 또는 query 를 통해 해결하자.

확장

  • 초기에는 단일서버에 단일 DBMS로 구성이 가능하다.
  • 사용자가 늘고 트래픽이 증가한다.
  • 자연스럽게 부하를 분산하기 위해 도메인별로 시스템을 분리한다.

4. 리포지터리 모델구현

JPA를통한 리포지터리 구현

  • ID로 조회/애그리거트 저장
  • 밸류는 @Embededable 로 매핑 설정
    • 하이버네이트는 clear() 메소드를 호출하면 delete 쿼리로 삭제를 수행
    • 수행되길 원하지 않으면 단일클래스로 구현
  • 밸류타입 프로퍼티는 @Embedded 로 매핑
  • 조회시점에서 완전한 상태가 되게 하려면 Fetch.EAGER (반대는 Fetch.LAZY)

영속성 전파

  • 삭제메소드는 애거리거트에 속한 모든객체를 삭제 해야 한다.
  • CascadeType.PERSIST, CascadeType.REMOVE

5. 리포지터리 조회 기능

  • Specification 을 구현해야 한다.
  • JPA 정적메타모델
    • @StaticMetamodel(~~.class)
    • Criteria 를 사용할때 StaticMetamodel을 통해 구현하는것이 코드의 안정성이나 생산성측면에서 유리하다. (오타의 위험 및 자동완성)
  • Criteria 의 문제점
    • 도메인모델은 구현기술에 의존하지 않아야한다.
    • Specification 인터페이스는 toPredicate() 가 JPA 의 Root 와 CriteriaBuilder 에 의존하고 있다.

동적 인스턴스 생성

  • JPQL - select 절에 new ~~Enttiy()를 넣을 수 있따.
    • ex)SELECT new OrderView(o, m, p) FROM Order o, Member m, Product p ~~
  • 장점 : JPQL을 그대로 사용, 객체 기준으로 쿼리를 작성할 수 있다.
  • @Imutable, @Subselect, @Syncronize : 하이버네이트 전용 애노테이션 테이블이 아닌 쿼리결과를 @Entity로 매핑 할 수 있다.
  • Update가 불가하므로 @Immutable 을 선언한다.
    • 생성된 모델기준으로 생기기때문에 매핑필드변경시 불가

6. 응용서비스와 표현영역

실제 사용자가 원하는 기능을 제공하는것은 응용영역에 위치한 서비스이다.

  • 도메인 로직을 넣고싶은 욕심을 참아야 한다.
  • ex) if member.checkPwd() { member.changePwd() } // 도메인에서 체크하고 구현해야한다.

분산구현

장점 : 한눈에 들어와서 코드의 가독성 증가 
단점 : 코드의 응집력약화, 알아보는데 분석이 필요하다.

응용서비스의 구현

하나의 클래스에서 모두 구현할 때

관련없는 서비스, 코드가 뒤섞이는것을 조심해야 한다.
클래스의 크기(줄 수)가 커질수 있다.
분리하는게 좋음에도 억지로 끼워넣게 된다.
  • 표현영역 코드
    • 애거리거트 자체를 데이터로 주고받으면 코드의 응집도를 낮추게 된다.

HttpServletRequest 나 Session 을 주고받지 말자.

  • 단독 테스트가 어려워지고, 세션, 쿠키 등 표현 서비스에 있어야 하는 정보가 응용서비스로 넘어가게 되어 표현영역의 응집도가 깨지게 된다.

7. 도메인 서비스

  • 여러 애거리거트가 섞이게 되면 코드의 위치 등 책임의 문제가 드러나게 된다.
  • 도메인 개념이 애거리거트에 숨어들어 명시적으로 드러나지 않게 된다.

도메인 서비스를 별도로 구현한다.

  • 하위 패키지를 구분하여 위치 시킨다.

8. 어그리거트 트랜잭션 관리

선점 (Pessimistic) 비선점 (Optimisitic)

  • 선점
    • 사용이 끝날때가지 다른스레드가 해당 애거리거트를 수정하는것을 막는다.
    • 보통 DBMS가 제공하는 행단위 Lock 을 통해 구현한다.
    • LockModeType.PESSIMISTIC_WRITE
    • 교착상태 방지를 위해 최대 대기 시간을 설정해야 한다.
  • 비선점
    • 변경한 데이터를 싲레 DBMS 에 반영하는 시점에 변경 가능 여부를 확인하는 방식
    • 구현
      • 애거리거트 버전을 함께 보내어 확인한다.

9. 도메인 모델과 BOUNDED CONTEXT

  • 도메인을 완벽히 표현하는 단일 모델을 만드는 시도. 이는 실행할 수 없다.
  • 구분되는 경계를 갖는 컨텍스트를 바운디드 컨텍스트 라고 부른다.

10. 이벤트

  • 트렌젝션 처리가 복잡해짐에 따라 속도 및 로직이 뒤섞이는 문제가 발생한다.
  • 이러한 강한 결합 (high coupling)이 생기고, 이를 해결하기 위한 방법중 하나는 이벤트를 활용하는 추상클래스를 활용하는 것이다.
  • 비동기 이벤트 처리 ex)
    • 로컬 핸들러를 비동기로 실행
    • 메시지 큐를 사용 - 글로벌 트렌젝션 문제
    • 이벤트 저장소와 이벤트 포워더 사용
    • 이벤트 저장소와 이벤트 제공 API 사용

11. CQRS

Command Query Responsibility Segregation, 명령을 위한 모델과 상태를 제공하는 조회를 위한 모델을 분리하는 패턴

  • 도메인 로직을 구현하는데 집중
  • 구현해야 할 코드가 더 많아짐
  • 더 많은 구현 기술이 필요


+ Recent posts