1. @Embedded , @Embeddable : JPA_ Entity의 가독성 높이기
1.1 Member Entity의 기존 구성요소
public class Member extends Auditable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(length = 20)
private String nickname;
private String password;
private String realName;
@Column(nullable = false, unique = true)
private String phone;
@Enumerated(value = EnumType.STRING)
private MemberStatus status = MemberStatus.MEMBER_ACTIVE;
private String zipCode;
private String address;
private String detailAddress;
// //추후 추가할 Oauth 기능을 위함
// private String oauthId;
//
// private String provider;
//
// private String providerId;
}
정기 구독 쇼핑몰을 구현하기 위해서 Member 관련하여 받아두어야 하는 정보들이 많았고, 따라서 Member Entity가 너무 많은 정보를 담게 되는 현상이 발생했다. 따라서 주문배송을 위한 Address관련 정보(zipcode, address, detailAddress)를 묶어서 따로 관리할 방법이 없을까 고민하던 중 JPA의 @Embedded , @Embeddable에 대해서 알게 되었고 해당 정보들을 Address라는 Class로 따로 뺀 뒤 해당 기능을 통해 Entity를 관리하기러 했다. 수정된 코드는 아래와 같다.
1.2 Member Entity와 @Embeddable Address Class
public class Member extends Auditable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(length = 20)
private String nickname;
private String password;
private String realName;
@Column(nullable = false, unique = true)
private String phone;
@Enumerated(value = EnumType.STRING)
private MemberStatus status = MemberStatus.MEMBER_ACTIVE;
@Embedded
private Address address;
// //추후 추가할 Oauth 기능을 위함
// private String oauthId;
//
// private String provider;
//
// private String providerId;
}
@Embeddable
@Getter
public class Address {
private String zipCode;
private String address;
private String detailAddress;
public Address () {
};
public Address(String zipCode, String address, String detailAddress){
super();
this.zipCode = zipCode;
this.address = address;
this.detailAddress = detailAddress;
}
}
훨씬 가독성도 좋으며, Member table에 컬럼도 잘 생성되는것을 확인했다.
만약 같은 자료형으로 구성된 배송지를 구분해서 여러 컬름을 가지고싶은 경우, 아래처럼 사용 가능하다고 한다.
@Embedded
@AttributeOverride(name = "zipCode", column = @Column(name = "home_zipCode"))
@AttributeOverride(name = "address1", column = @Column(name = "home_address1"))
@AttributeOverride(name = "address2", column = @Column(name = "home_address2"))
private Address homeAddress;
@Embedded
@AttributeOverride(name = "zipCode", column = @Column(name = "company_zipCode"))
@AttributeOverride(name = "address1", column = @Column(name = "company_address1"))
@AttributeOverride(name = "address2", column = @Column(name = "company_address2"))
private Address companyAddress;
2. DTO(Data Transfer Object), 어디까지 만들어야 하는데..!
2.1. DTO, SoC란?
Data Transfer Object의 약자로, 계층 간 데이터 전송을 위해 도메인 모델 대신 사용되는 객체. 계층이란 Presentation(View, Controller), Business(Service), Persistence(DAO, Repository) 등을 의미함. DTO는 순수하게 데이터를 저장하고, 데이터에 대한 getter, setter 만을 가져야함.
- DTO 사용의 장점
1) 도메인(Entity) 모델을 캡슐화 하여 보호할 수 있음
2) 모델과 뷰의 결합을 느슨하게 만든다. 일례로 뷰에서 요구하는 값이 바뀐다고 하더라도 domain 을 수정할 필요는 없어진다.
- 이런식으로 DTO를 사용함으로써 우리는 객체지향의 Seperation of Concern(SoC)또한 이룰 수 있다.
2.2. DTO, 그럼 어디서 변환해야하고 어디까지 써야 하는데..?
우선 이번 프로젝트는 RESTful API를 구현하고자 함으로 View 없이 Controller, Service, Repository로 구성된 서비스를 만들고자 했다. 그럼 어디까지 DTO를 사용하고, 어디부터는 Entity를 다루어야 할 까? 다양한 레퍼런스들을 찾아보았지만 이 질문에 정해진 답은 없다고 한다. 추가로 추후에 더할 예정인 Querydsl을 사용하는 경우 DTO로 반환값을 받게 될수도 있다고 한다. 그래서 DTO를 어디부터 어디까지 써야할지 고민하기 위해 각 요소들의 역할과 해당 계층에서 DTO-Entity변환을 하는 장단점에 대해서 정리해봤다.
2.2.1. Controller
- 클라이언트 요청을 처리하고 응답을 반환하는 역할을 담당
- HTTP 요청을 수신하고, 요청 데이터를 검증하고, 비즈니스 로직을 호출하여 처리
- 요청에 대한 응답을 생성하고, HTTP 상태 코드를 설정하며, 적절한 데이터를 반환
- 주로 사용자와 직접 상호작용하며, 요청의 유효성 검사와 비즈니스 로직의 호출을 처리하는 역할
- controller에서는 어차피 client에서 받아온 자료를 담을 dto가 필요하다..! 그렇다면 entity는 필요할까? 즉, dto to entity가 controller에서 일어나야만 할까?
- controller가 entity를 알게되는 경우 controller와 entity사이의 결합도가 상승하고 이는 entity의 변경이 controller의 변경을 촉발하게될 수 있게 될것이라고 판단했다.
- 이외의 단점 : View에 반환할 필요가 없는 데이터까지 Domain 객체에 포함되어 Controller(표현 계층)까지 넘어온다. 즉, domain(Entity)가 controller까지 노출된다. controller의 역할이 증가한다. Controller가 여러 Domain 객체들의 정보를 조합해서 DTO를 생성해야 하는 경우, 결국 Service(응용 계층) 로직이 Controller에 포함된다.
2.2.2. Service
- 비즈니스 로직을 구현하는 역할을 담당
- 데이터베이스와의 상호작용을 처리하고, 트랜잭션 관리 및 데이터 조작을 수행
- 보통 하나 이상의 Repository 인터페이스를 사용하여 데이터 액세스 계층과 상호작용
- 비즈니스 규칙에 따라 데이터를 가공하고, 다양한 기능을 제공
- Repository에서 반환된 데이터를 가공하거나 여러 Repository의 조합으로 복잡한 로직을 수행하는 역할
- 장점 : 비즈니스 로직과 관련된 데이터의 가공 및 변환 작업을 한 곳에서 처리할 수 있음
2.2.3. Repository
- 데이터 액세스 계층을 담당
- 데이터베이스나 다른 영속성 저장소에 액세스하여 데이터를 읽고 쓰는 기능을 제공
- 주로 엔티티에 대한 CRUD (Create, Read, Update, Delete) 작업을 수행
- 데이터베이스와의 상호작용을 추상화하여 비즈니스 로직 계층에서 사용하기 쉬운 인터페이스를 제공
- 데이터 액세스의 세부 구현을 숨기고, 다양한 데이터 저장소에 대해 동일한 인터페이스를 유지할 수 있음
- 그럼.. Repository는 DB에 대한 역할만 수행 할 수 있도록 두고싶다. (물론 Querydsl을 사용하게 되면 dto를 반환하게 될 확률이 높지만, 그건 추후에 생각하도록 하자)
그렇다면, service에서 dto to entity 변환작업을 해야겠다는 생각이 드는데, 추가로 드는 의문이 또 있었다.
controller가 클라이언트로부터 값을 전달받을 때 사용한 dto를, service가 그대로 받아서 변환하는게 맞을까..?
결론적부터 말하면 사실 명확한 분리를 위해서는 controller가 클라이언트로부터 값을 받을 때 사용한 dto와 service가 controller로부터 값을 전달받을 때 사용할 dto는 서로 다른 dto여야한다는게 각각 계층을 분리할 수 있음과 동시에 가장 객체지향적이었다. 하지만 그렇게 되면 하나의 domain을 위해 controller의 request와 response, service 관점의 request와 response 4개의 dto가 생성되어야했다. 과연 객체지향만을 위해서 4개의 dto를 만드는게 현명할까..? 현재 진행중인 과정에서는 딱히 controller의 dto와 service의 dto를 구분지을 필요를 느끼지 못했고, 이에 하나의 dto로 개발을 진행하다 다른 기능(예를들어 batch)을 추가함에 있어 dto의 분리가 필요하다는 판단이 서면 추가로 dto를 구현하기러 했다.
3. DTO-Entity 변환, 어떻게 할 까? Mapstruct..?
위처럼 dto를 사용해야할 필요성을 느끼고, service에서 dot-entity간의 변환을 진행하는것으로 방향을 잡고 dto와 entity를 변환하는 방법에 대해서 찾던 도중 Mapstruct라는 라이브러리를 추천받았다. Mapstruct의 mapper Class를 구현하고 간단한 설정만 해주면 entity와 dto를 변환하는 mapperImpl Class를 annotationProcessor를 통해 build시에 자동으로 구현해주는 편리한 라이브러리라고 판단했다. 사용을 위한 셋팅 또한 간단해 보였기에 바로 적용해봤다. 물론, 내 프로젝트는 멀티모듈 프로젝트..! 아래처럼 현재 구성되어있는 api 모듈과 domain 모듈 중 service가 속한 domain 모듈에 관련 의존성을 추가해주고 MapperClass를 작성한 뒤 build를 해보았다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa:3.0.4'
implementation 'org.mapstruct:mapstruct:1.5.3.Final'
runtimeOnly 'com.mysql:mysql-connector-j'
}
package yana.playground.global.mapper;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import yana.playground.member.dto.MemberRequest;
import yana.playground.member.entity.Member;
@Mapper(componentModel = "spring")
public interface MemberMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "status", ignore = true)
Member memberRequestToMember(MemberRequest.Create memberDto);
}
그런데.. 안..돌아간다...?
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 1 of constructor in yana.playground.member.service.MemberService required a bean of type 'yana.playground.global.mapper.MemberMapper' that could not be found.
Action:
Consider defining a bean of type 'yana.playground.global.mapper.MemberMapper' in your configuration.
이유는 간단했다. mapstruct의 경우 implememtation 뿐 아니라, mapstruct-processor까지 받아와야하는데 mapstruct만 받아왔기 때문에 발생한 문제였다. 아래와 같이 dependency를 추가해줬다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa:3.0.4'
implementation 'org.mapstruct:mapstruct:1.5.3.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
runtimeOnly 'com.mysql:mysql-connector-j'
}
그런데 또다른 문제가 발생했다. 1번에서 작성한 @Embaded @Embadable 때문에 위와같은 기본적인 Mapper Class만으로는 Address가 null값이 뜨는 문제가 발생한것이다. 해결방법은 아래와 같았다.
1) Address dto와 Entity를 매핑해줄 AddressMapper를 생성한다.
package yana.playground.global.mapper;
import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;
import yana.playground.member.dto.AddressRequest;
import yana.playground.member.entity.Address;
@Mapper(componentModel = "spring")
public interface AddressMapper {
AddressMapper INSTANCE = Mappers.getMapper(AddressMapper.class);
default Address toEntity(AddressRequest addressRequest) {
if (addressRequest == null) {
return null;
}
return new Address(addressRequest.getZipCode(),addressRequest.getAddress(),addressRequest.getDetailAddress());
}
AddressRequest toDto(Address address);
}
2) MemberMapper의 @Mapper config에 uses = AddressMapper.class를 추가하고, @Mapping config에 @Mapping(target = "address", source = "memberDto.address")를 추가함으로써 Address가 먼저 Mapping된 뒤 생성된 객체를 다시 MemberMapping시에 사용할 수 있도록 설정했다.
package yana.playground.global.mapper;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import yana.playground.member.dto.MemberRequest;
import yana.playground.member.entity.Member;
@Mapper(componentModel = "spring",uses = AddressMapper.class)
public interface MemberMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "status", ignore = true)
@Mapping(target = "address", source = "memberDto.address")
Member memberRequestToMember(MemberRequest.Create memberDto);
}
성공적으로 동작하는 모습..! 인줄알았으나, 눈썰미가 좋은 사람이라면 보일것이다."status": null... 왜!!
이유는 MemberMapperImpl에서 확인 할 수 있었다.
package yana.playground.global.mapper;
import javax.annotation.processing.Generated;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import yana.playground.member.dto.MemberRequest;
import yana.playground.member.entity.Member;
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2023-07-03T17:08:41+0900",
comments = "version: 1.5.3.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-7.6.1.jar, environment: Java 17.0.6 (Amazon.com Inc.)"
)
@Component
public class MemberMapperImpl implements MemberMapper {
@Autowired
private AddressMapper addressMapper;
@Override
public Member memberRequestToMember(MemberRequest.Create memberDto) {
if ( memberDto == null ) {
return null;
}
Member.MemberBuilder member = Member.builder();
member.address( addressMapper.toEntity( memberDto.getAddress() ) );
member.email( memberDto.getEmail() );
member.nickname( memberDto.getNickname() );
member.password( memberDto.getPassword() );
member.realName( memberDto.getRealName() );
member.phone( memberDto.getPhone() );
return member.build();
}
}
Mapstruct는 변환하는 Entity가 Builder패턴을 사용하는 경우 Builder패턴을 우선으로 하여 Entity를 생성하게 되어있는데, Member Entity에 @Builder 어노테이션을 추가해 놓았기 때문에 Builder 패턴으로 생성되어 status값이 setting 되지 않아 발생한 문제이다. 아래와 같이 기본값을 설정하고싶은 컬럼에 @Builder.Default를 추가함으로써 해결 할 수 있다.
package yana.playground.member.entity;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Column;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import yana.playground.global.Auditable;
@Getter
//TODO : setter의 경우 전체 적용하는게 맞는지 고민해보기
@Setter
@Builder
@Entity
@Table(name = "MEMBER")
@NoArgsConstructor
@AllArgsConstructor
public class Member extends Auditable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(length = 20)
private String nickname;
private String password;
private String realName;
@Column(nullable = false, unique = true)
private String phone;
@Enumerated(value = EnumType.STRING)
@Builder.Default
private MemberStatus status = MemberStatus.MEMBER_ACTIVE;
@Embedded
private Address address;
// private String oauthId;
//
// private String provider;
//
// private String providerId;
}
Entity.. Setter를 열어놓는게 맞는 선택일까?
여기서 Builder 패턴을 사용하는게 맞는 것인지, 기본값으로 Mapstruct를 사용하기 위해 Member Entity에 setter를 열어놓는것이 맞을지에 대한 의문이 들기 시작했다. 그리고 이러한 의문은 Member update api를 구현하며 더욱 커져만 갔다.
@Transactional
public Member updateMember(MemberRequest.Update memberDto) {
Member newMember = mapper.memberUpdateDtoE(memberDto);
Member existingMember = getMemberByEmail(newMember.getEmail());
existingMember.setEmail(newMember.getEmail());
existingMember.setPassword(newMember.getPassword());
existingMember.setNickname(newMember.getNickname());
existingMember.setAddress(newMember.getAddress());
existingMember.setPhone(newMember.getPhone());
existingMember.setRealName(newMember.getRealName());
return existingMember;
}
과연 Entity에 setter를 열어놓는게.. 맞는 선택일까?
[다음 포스팅에서 계속] Entity update, 어떤게 최선이지?
'Project > playground(java-spring,멀티모듈)' 카테고리의 다른 글
[playground] Entity update, 어떤게 최선이지? (0) | 2023.07.03 |
---|---|
[playground] spring boot 멀티모듈 프로젝트 시작하기 (0) | 2023.06.25 |
야나의 코딩 일기장 :) #코딩블로그 #기술블로그 #코딩 #조금씩,꾸준히
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!