이번 포스팅은 프로젝트에서 QueryDSL을 사용하면서 가끔 잊어버리는 것이 있어서 전체적으로 정리해보려 한다.
➡️QueryDSL
원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL을 작성하게 된다. 만약 이런과정에서 로직이 복잡한 로직의 경우 개행이 포함된 쿼리 문자열이 떨어지고 가독성이 떨어진다. JPQL 문자열에 오류가 있을 경우 정적 쿼리라면 어플리케이션 로딩 시점에 이를 발견할 수 있으나 런타임 시점에 에러가 발생한다. 이러한 문제를 QueryDSL이 해결을 해준다.
QueryDSL은 정적 타입을 이용해서 SQL 등의 쿼리를 생성해주는 프레임워크이다.
➡️QueryDSL의 장점
문자가 아닌 코드로 쿼리를 작성함으로써, 컴파일 시점에 문제 오류를 빠르게 찾을 수 있다.
long fetchCount = jpaQueryFactory
.selectFrom(post)
.fetchCount();
3.paging
querydsl에서는 페이징에 필요한 정보를 가져올 수 있는 메서드가 존재한다.
QueryResults<Post> result = jpaQueryFactory
.selectFrom(post)
.orderBy(post.id.desc())
.offset(0)
.limit(3)
.fetchResult();
long total = result.getTotal();
long limit = result.getLimit();
long offset = result.getOffset();
List<Post> results = result.getResults();
간단하게 메서드를 설명하자면
total은 전체 컨텐츠 갯수이고,
offset은 조회를 시작할 위치이고,
limit은 조회 개수를 제한할 위치이다.
.getResults() 메서드는 조건에 맞게 조회된 컨텐츠 리스트이다.
4. aggregation
querydsl에서 편하게 sql 집계를 낼수 있는 기능이 있다. 실제 sql을 사용하는 것처럼 sql의 groupBy.having절을 사용할 수 있다.
User user = User.builder()
.id(1L)
.email("rrr11@naver.com")
.name("KK")
.password("l12313123")
.age(14)
.build();
이런식으로 한눈에 직관적으로 보이게 값을 삽입할 수 있다.
🛠두번째로 불변성을 확보하는 예시를 보자
자바 빈 패턴으로 @Setter로 값을 삽입할 수 있는데 이 방식은 가독성도 생성자 패턴보다 좋아지고 객체를 생성하기에도 편해졌지만, 함수 호출 한 번으로 객체를 생성할 수 없고 객체 일관성이 일시적으로 깨질 수 있다.
@Setter
public class User {
private Long id;
private String email;
private String name;
private String password;
private int age;
}
User user = new User();
user.setId(1L);
user.setEmail("rrr11@naver.com");
user.setName("KK");
user.setPassword("l12313123");
user.setAge(23);
위처럼 @Setter를 열어두는 것은 불필요하게 확장 가능성을 열어두는 것이기 때문에 Open-Close 법칙에도 위배가 되고, 불필요한 코드 리딩을 유발한다.
🛠세번째로 필요한 데이터를 설정하는 예시를 보자.
우선 데이터를 전송할 때 그 작업에 필요한 데이터만 넘길 수 있다. 필요한 데이터만 넘기게 되면 유저 정보의 패스워드 등을 노출하지 않을 수 있기 때문에 보안적으로 이점을 가진다.
일단 패스워드와 아이디는 넘기면 안된다고 가정을 해보자.
기본적인 생성자로 넘기면 우선 모든 데이터가 넘어간다. 이 문제를 생성자로 해결해보자.
public class User {
private Long id;
private String email;
private String name;
private String password;
private int age;
public User(String email, String name, int age) {
this.email = email;
this.name = name;
this.age = age;
}
public User(Long id, String password) {
this.id = id;
this.password = password;
}
}
이러한 방식으로 분리해서 하면 아이디와 패스워드를 빼고 넘길 수 있다. 이런 식으로 분리하면 코드가 지저분 해지고 가독성이 떨어진다.
공부하다가 우연히 저번에 다른분이 쓰신 블로그에 있는 애자일 소프트웨어 개발에 대해서 궁금해서 한번 정리 해볼려고한다.
💡애자일 소프트웨어 개발?
애자일 방법론은 소프트웨어 개발 방법에 있어서 아무런 계획이 없는 개발 방법과 계획이 지나치게 많은 개발 방법들 사이에서 타협점을 찾고자 하는 방법론이다.
💡애자일 소프트웨어 개발 선언 4가지
공정과 도구 보다 개인과 상호 작용을
포괄적인 문서보다 작동하는 소프트웨어를
계약 협상보다 고객과 협력을
계획을 따르기보다 변화에 대응하기를 가치 있게 여긴다.
💡애자일 선언 이면의 12가지 원칙
우리의 최우선 순위는 가치 있는 소프트웨어를 일찍 그리고 지속적으로 전달해서 고객을 만족시키는 것이다.
비록 개발의 후반부일지라도 요구사항 변경을 환영하라.애자일 프로세스들은 변화를 활용해 고객의 경쟁력에 도움이 되게한다.
작동하는 소프트웨어를 자주 전달하라.2주에서 2개월 간격으로 하되 더 짧은 기간을 선호하라
비즈니스 쪽의 사람들과 개발자들은 프로젝트 전체에 걸쳐 날마다 함께 일해야한다.
동기가 부여된 개인들 중심으로 프로젝트를 구성하라. 그들이 필요로 하는 환경과 지원을 주고 그들이 일을 끝내리라 신뢰하라.
개발팀으로 또 개발팀 내부에서 정보를 전하는 가장 효율적이고 효과적인 방법은 면대면 대화이다.
작동하는 소프트웨어가 진척의 주된 척도이다.애자일 프로세스들은 지속 가능한 개발을 장려한다.
스폰서, 개발자, 사용자는 일정한 속도를 계속 유지할 수 있어야한다.
기술적 탁월성과 좋은 설계에 대한 지속적 관심이 기민함을 높인다.
단순성이 - 안 하는 일의 양을 최대화하는 기술이 - 필수적이다.최고의 아키텍처, 요구사항, 설계는 자기 조직적인 팀에서 창발한다.
팀은 정기적으로 어떻게 더 효과적이 될지 숙고하고, 이에 따라 팀의 행동을 조율하고 조정한다.
위 12가지 원칙 중 위 세가지는 소프트웨어를 일찍 그리고 지속적으로 전달하며, 요구사항 변경을 적극적으로 받아들이고, 짧은 주기마다 작동하는 소프트웨어를 제공하는 것이다. 이렇게 하기 위해서반복(iteration)과백로그(backlog)와 같은 방법을 사용하여 관리한다.
반복 주기(iteration): 모든 프로젝트 활동을 반복적으로 수행하여 작동하는 소프트웨어를 지속적으로 제공한다. 반복주기는 정해진 시간(Timedboxed)이 있기에 이 시기에 수행할 수 있은 일을 매 반복 주기에 우선순위를 두어 할당하고 조율하게 된다. 이를 통해 가장 중요한 요청사항에 집중해서 작업할 수 있는 환경을 만들 수 있다.
백로그(backlog): 변하는 요구사항을 관리하는 아주 좋은 방법으로 반복주기에 포함되어 있지 않지만 개발예정된 피처목록이다.
이 내용을 읽으면서 느낀건 잘만들어도 고객들이 필요성을 못느낀다면 무조건 좋다고 말은 못할거 같다.개발자들이 조금 번거롭고 하더라도 고객들이 원하는 서비스를 제공하는 것이 더 맞는거 같다.진짜 개발은 끝이 없다.
대표 사진 출처:https://m.post.naver.com/viewer/postView.nhnvolumeNo=
프로젝트를 진행하던 중 DTO를 개념만 알고 어디서 사용해야 적절한지에 대해서 고민해보지 않고 사용해서 정리를 해보려고 한다. 그래서 어디서 사용해야 적절한 것인지 알아보려 한다.
➡️DTO
DTO(Data Transfer Object)란 계층 간 데이터 교환을 위해 사용하는 객체이다.
MVC패턴으로 예를 들어보자
MVC 패턴은 애플리케이션을 개발할 때 그 구성요소를 Model과 View 및 Controller 등 세 가지 역할로 구분하는 디자인 패턴이다. 비즈니스 처리 로직(Model)과 UI영역(View)은 서로의 존재를 모르고 Controller가 중간에서 Model과 View를 연결하는 역할을 한다.
Controller는 View로부터 들어온 사용자 요청을 해석하여 Model을 업데이트하거나 Model로부터 데이터를 받아 View로 전달하는 작업을 수행한다.
MVC 패턴의 장점은 Model과 View를 분리함으로써 서로의 의존성을 낮추고 독립적인 개발을 가능하게 한다.
Controller는 View와 도메인 Model의 데이터를 주고받을 때 별도의DTO 를 주로 사용한다.
왜냐하면 도메인 객체를 View에 직접 전달할 수 있지만, 민감한 도메인 비즈니스 기능이 노출될 수 있으며 Model과 View 사이에 의존성이 생기고 비즈니스 로직 등 민감한 정보가 외부에 노출되기 때문에 보안상의 문제가 생길 수 있기 때문이다.
문제가 생길수 있는 예제를 한번 보자
public class User{
public Long id;
public String name;
public String email;
public String password;
}
@GetMapping
public ResponseEntity<User> findeUser(@PathVariable long id) {
User user = userService.findById(id);
return ResponseEntity.ok(user);
}
이런 식으로 컨트롤러가 클라이언트의 요청에 대한 응답으로 도메인 모델인 User를 넘겨주면 다음과 같은 문제점이 발생한다.
도메인 모델의 모든 속성이 외부에 노출된다. 저 요청에서 사용하지 않는 데이터를 불필요하게 가지고 있는 것이다.
모든 속성이 외부에 노출되기 때문에 보안상 좋지 않다.
View에서 Model의 메서드를 호출하거나 상태를 변경시킬 위험이 존재한다.
Model과 View가 상호 의존성이 높아져서 View에 요구사항이 있을 경우 Model에 영향을 끼칠 수 있다.
이제 DTO를 사용해보자
public class UserDto {
public long id;
public String name;
public String email;
@Builder
public UserDto(long id,String name,String email){
this.id = id;
this.name = name;
this.email = email;
}
}
@GetMapping
public ResponseEntity<UserDto> findUser(@PathVariable long id) {
UserDto userDto = userService.findById(id);
return ResponseEntity.ok(userDto);
}
이런 식으로 DTO를 사용하면 도메인 모델을 캡슐화하고, View에서 사용하는 데이터만 선택적으로 응답할 수 있다. DTO는 클라이언트 요청에 포함된 데이터를 담아 서버 측에 전달하고, 서버 측의 응답 데이터를 담아 클라이언트에 전달하는 계층 간 전달자 역할을 한다.
이제 DTO의 사용범위에 대해서 알아보자
위 그림은 Layered Architecture이다. 유사한 관심사를 레이어로 나눠서 추상화하여 수직적으로 배열하는 아키택처이다. 하나의 계층은 주어진 역할을 수행하고, 인접한 다른 계층과 상호작용한다. 이런 식으로 시스템을 계층으로 나누면 시스템 전체를 수정하지 않고도 특정 계층을 수정 및 개선할 수 있어 재사용성과 유지보수에 유리하다.
한 로직을 예로 들어보자
View로부터 받아온 DTO를 Controller에서 도메인(Entity)으로 변환하고 서비스 계층에게 이를 전달하여 작업을 수행하고 서비스 계층은 Controller에게 도메인을 반환하고, Controller는 도메인을 DTO로 변환해 View로 응답을 보낸다.
여기서 보면 Controller가 도메인을 DTO로 변환을 한다. 그러나 잘 생각해보면 굳이 Controller에서 변환을 해야 하나 라는 생각이 든다.
서비스 계층이 요청으로 DTO를 받고 응답으로 DTO를 보내줘도 아무 문제가 없다. 그러면 어느 계층에서 사용하는 것이 자연스러운지 알아보자
🪐Repository
Repository 계층은 Entity의 영속성을 관장하는 역할이라고 명시되어있다. 이로 인해, 표현 계층에서 사용할 도메인 계층을 DTO로 변환하는 작업을 책임지게 하는 것을 지양하자는 다수의 의견이 존재한다.
대부분은 Controller와 Service 계층에 위치시켰다.
🪐Service
마틴 파울러의 말을 인용해보자면 Service계층이란 애플리케이션의 경계를 정의하고 비즈니스 로직 등 도메인을 캡슐화하는 역할이라고 정의한다. 즉, 도메인 모델을 표현 계층에서 사용하는 경우 결합도가 증가하여 도메인의 변경이 Controller의 변경을 촉발하는 유지보수의 문제로 이어질 수 있다.
이러한 관점에서 바라볼 때, 레이어 간 데이터 전달 목적으로 DTO를 엄격하게 고수한다면 변환 로직이 Service 레이어에서 정의되어야 한다는 의견이 존재했습니다. 요청에 대한 응답 역시 Service 레이어의 일부분이기 때문이다.
Service가 DTO를 사용하는 경우 Controller가 View로부터 받은 DTO를 Entity로 변환한 뒤, Service 레이어가 Entity를 전달받아 일련의 비즈니스 로직을 수행한다고 가정해보자.
복잡한 애플리케이션의 경우 Controller가 View에서 전달받은 DTO만으로 Entity를 구성하기란 어렵다. Repository를 통해 여러 부수적인 정보들을 조회하여 Domain 객체를 구성할 수 있는 경우도 존재하기 때문이다.
Controller에서 DTO를 완벽하게 Domain 객체로 구성한 뒤 Service에 넘겨주려면, 복잡한 경우 Controller가 여러 Service(혹은 Repository)에 의존하게 된다.
블로그와 깃 헙을 보면서 정답을 찾으려 했지만 대부분의 사람들이 명확한 답이 없고 상황에 따라 맞게 써야 한다고 한다.
여기서 많은 개발자들이 서비스가 무조건 DTO를 반환해야 하는지 아니면 도메인 모델을 반환해도 되는지에 대한 질문이다.
여기서 나온 답을 정리해보자면
서비스 계층은 애플리케이션의 경계를 정의하고 도메인을 캡슐화한다.
DTO는 말 그대로 데이터 전달용 객체이고, communication에 사용된다면 의미가 있다. 대신 Presentation 레이어에서 도메인 모델을 사용하게 되면, 결합도가 증가해 코드 변경이 불가피할 수 있다
도메인 모델과 똑같은 DTO를 만드는 것 같고 의미 없게 느껴진다 : DTO의 단점 중 하나이다. 대신 지금은 코드 중복으로만 생각되지만, 나중에 프로젝트가 확장되면 더 큰 의미를 발휘할 수 있다.
대규모 프로젝트에는 DTO를 사용하면 더 큰 의미를 발휘할 수 있다고 한다.
어느 블로그에서 본 걸로 인용하지만 DTO사용범위라는 고민보다는 도메인 모델 보호라는 관점에서 생각해보자고 한다. 나도 이 의견에 동의한다.그리고 Repository단위는 다들 공통적으로 지양한다.
📁정리
DTO를 공부하면서 많은 고민을 해봤다. 내가 아직 많이 부족해서 답을 결정짓기 어려운 내용인 거 같다. DTO는 상황에 맞게 사용하고 무한한 피드백으로 최적의 케이스를 찾아야 한다. 정리하자면 DTO를 남발하는 것은 좋지 않다. 상황에 따라 DTO가 필요 없을 수도 있다.
이렇게 의견이 다 다르고 정답이 없는 건 처음 느껴본다. 공부를 무한히 하고 많은 사람들과 토론을 해보는 것도 성장에 많은 도움이 될 것 같다.
우선 RDS설정 정보를 적어 놓은 yml파일에 slave-list,name,url 을 추가해준다.
다음은 DataSource를 직접 설정해야하기 때문에 Spring을 실행할 때 DataSourceAutoConfiguration을 제외시켜준다.
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
이제 DB 설정파일을 가져올 DatabaseProperty클래스를 만든다.(yml에서 설정을 가져오는 클래스이다.)
@Getter
@Setter
@Component
@ConfigurationProperties("spring.datasource")
public class DatabaseProperty {
private String url;
private List<Slave>slaveList;
private String username;
private String password;
private String driverClassName;
@Getter
@Setter
public static class Slave {
private String name;
private String url;
}
}
여러대의 Slave DB를 순서대로 로드밸런싱 하기위해 CircularList클래스를 만든다.
public class CircularList<T> {
private List<T> list ;
private Integer counter = 0;
public CircularList(List<T> list) {
this.list = list;
}
public T getOne(){
if (counter + 1 >= list.size()){
counter = -1;
}
return list.get(++counter);
}
}
여러개의 DataSource를 묶고 필요에 따라 분기처리를 위해 AbstractRoutingDataSource클래스를 사용해야 한다.
여러대의 Slave DB를 순서대로 사용하기 위해 CircularList에 Slave DB 키를 추가 해준다.
determineCurrentLookup 메서드에서 현재 @Transactional(readOnly = true)일 경우 Slave DB로, 아닐 경우 Master DB의 DataSource의 키를 리턴하도록 설정해준다.
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
private CircularList<String> dataSourceList;
@Override
public void setTargetDataSources(Map<Object, Object> targetDataSources) {
super.setTargetDataSources(targetDataSources);
dataSourceList = new CircularList<>(
targetDataSources.keySet()
.stream()
.filter(key -> key.toString().contains("slave"))
.map(key -> key.toString())
.collect(toList()));
}
@Override
protected Object determineCurrentLookupKey() {
boolean isReadOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
if (isReadOnly){
return dataSourceList.getOne();
}else{
return "master";
}
}
}
이제 최종적으로 DataSource, TransactionManager, EntityManager 설정을 해야한다.
우선 DataConfig 클래스를 생성한다.
@Configuration
public class DatabaseConfig {
@Autowired
private DatabaseProperty databaseProperty;
//아래 routingDataSource에서 사용할 설정 메서드
public DataSource routingDataProperty(String url){
HikariDataSource hikariDataSource = new HikariDataSource();
hikariDataSource.setJdbcUrl(databaseProperty.getUrl());
hikariDataSource.setDriverClassName(databaseProperty.getDriverClassName());
hikariDataSource.setPassword(databaseProperty.getPassword());
hikariDataSource.setUsername(databaseProperty.getUsername());
return hikariDataSource;
}
@Bean
public DataSource routingDataSource(){
ReplicationRoutingDataSource replicationRoutingDataSource = new ReplicationRoutingDataSource();
//#1
DataSource master = routingDataProperty(databaseProperty.getUrl());
Map<Object,Object> dataSourceMap = new LinkedHashMap<>();
dataSourceMap.put("master",master);
//#2
databaseProperty.getSlaveList().forEach(slave -> {
dataSourceMap.put(slave.getName() , routingDataProperty(slave.getUrl()));
});
//#2
replicationRoutingDataSource.setTargetDataSources(dataSourceMap);
//#3
replicationRoutingDataSource.setDefaultTargetDataSource(master);
return replicationRoutingDataSource;
}
@Bean
public DataSource dataSource() {
return new LazyConnectionDataSourceProxy(routingDataSource());
}
#routingDataSource
우선 이전에 만들었던 ReplicationRoutingDataSource 클래스에 Master DB와 Slave DB를 추가해준다.
replicationRoutingDataSource 의 replicationRoutingDataSourceNameList 세팅한다.(Slave Key 이름 리스트 세팅)
디폴트는 Master로 설정
#dataSource
LazyConnectionDataSourceProxy는 실제 쿼리가 실행될 때 Connection을 가져온다.
LazyConnectionDataSourceProxy는 실질적인 쿼리 실행 여부와 상관없이 트랜잭션이 걸리면 무조건 Connection객체를 확보하는 Spring의 단점을 보완하며 트랜잭션 시작시에 Connection Proxy객체를 리턴하고 실제로 쿼리가 발생할 때 데이터 소스에서 getConnection()을 호출하는 역할을 한다.
TransactionSynchronizationManager가 현재 트랜잭션을 상태를 읽어올 수 있지만 트랜잭션 동기화 시점과 Connection이 연결되는 시점이 다르기 때문에 LazyConnectionDataSourceProxy를 사용하여 Connection객체를 가져온다.
이제 기본설정은 어느정도 마무리 되었고 JPA에서 사용할 EntityManager과 TransactionManager 설정을 해준다.
@Configuration
public class DatabaseConfig {
.
.
.
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(){
LocalContainerEntityManagerFactoryBean entityManagerFactoryBean = new LocalContainerEntityManagerFactoryBean();
entityManagerFactoryBean.setDataSource(dataSource());
entityManagerFactoryBean.setPackagesToScan("엔티티가 위치한 패키지 경로" ex)com.example);
JpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
entityManagerFactoryBean.setJpaVendorAdapter(vendorAdapter);
return entityManagerFactoryBean;
}
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory){
JpaTransactionManager tm = new JpaTransactionManager();
tm.setEntityManagerFactory(entityManagerFactory);
return tm;
}
}
이제 JPA 설정까지 마무리 했다.
테스트를 한번 해보자
Test
@Entity
@Table(name = "member")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Builder
public Product(String name) {
this.na,e = name;
}
}
public interface MemberRepository extends JpaRepository<Member, Long> {}
@RestController
@RequestMapping("/api/members")
public class MemberController {
@Autowired
private MemberService memberService;
@GetMapping
public ResponseEntity<?> getMembers() {
List<Member> memberList = memberService.getMembers();
return new ResponseEntity<>(memberList, HttpStatus.OK);
}
@GetMapping("/masterDB")
public ResponseEntity<?> getMembersFromMasterDB() {
List<Member> memberList = memberService.getMembersMaster();
return new ResponseEntity<>(memberList, HttpStatus.OK);
}
}
@Service
public class MemberService {
@Autowired
private MemberRepository memberRepository;
@Transactional(readOnly = true)
public List<Member> getMembers() {
return memberRepository.findAll();
}
@Transactional
public List<Member> getMembersMaster() {
return memberRepository.findAll();
}
}
Master/Slave DB에 쿼리가 날아가는 것을 확인하기 위해 yml 파일에 설정을 추가해준다.
지속적 통합이란, 애플리케이션의 새로운 코드 변경 사항이 정기적으로 빌드 및 테스트 되어 공유 레포지토리에 통합하는 것을 의미한다.
CI는 지속적으로 서비스해야 하는 애플리케이션은 기능 추가 시마다 커밋을 날려 저장소에 버전을 업데이트 해야한다. 여러명의 사람이 한팀으로 작업을 할 경우 많은 커밋들이 쌓이게 된다. 그럴 때마다 , 기능 별로 빌드/테스트/병합을 한다면 너무 번거로운 작업이 된다.이러한 상황에서 자동화된 빌드 & 테스트는 소스코드의 충돌 등을 방어하는 이익을 제공할 수 있다.
CI는 MSA환경에서는 대부분 소규모 기능 단위로 빠르게 개발 & 적용을 반복하는 방법론이 적용되기 때문에 기능 추가가 빈번하게 발생하게 된다. 이러한 상황에서 CI적용은 기능 충돌 방지등의 이익을 제공할 수 있다.
그래서 CI목표는 버그를 신속하게 찾아 해결하고, 소프트웨어 품질을 개선하고, 새로운 업데이트의 검증 및 릴리즈의 시간을 단축시키는 것에 있다.