INDEX
Service 인터페이스와 ServiceImpl을 만드는 이유 [ 참고 ]
- OOP, Loose coupling → 개발 코드를 수정하지 않고, 사용하는 객체를 변경할 수 있도록 하는 다형성 → 다른 기능을 추가해야할 경우 다른 구현 객체(ServiceImpl2)를 만들어 사용 - 유지보수 측면에서 좋다.
- AOP AOP와 트랜잭션은 Service 인터페이스에서 처리 → 스프링에서 AOP를 구현할 때 JDK의 기본 프록시를 사용하는데, 이 프록시는 인터페이스 기반으로 동작
Facade 패턴 적용
Facade
: 건물의 외관(프랑스어). 건물의 밖에서 안의 구조를 볼 수 없다.- Facade 패턴은 많은 서브시스템(내부 구조)을 거대한 클래스(외벽)로 감싼다.
- Facade 패턴 적용 예시
- 서로 다른 클래스 A, B, C 가 있는데 이를 합친 클래스가 필요하다. 이때 A, B, C를 필드로 갖는 클래스 'D'를 만드는 것이 대표적인 Facade 패턴이라고 할 수 있다.
- ⭐ 내가 직면한 문제에 Facade 패턴 적용
- 예를 들어, UserService에서 UserRepository를 생성자 주입 받아 사용하는데 이때 다른 Repository도 필요했다. 기본적으로 UserService에서는 UserRepository만 사용하는게 일반적이라 이때 Facade 클래스를 따로 만들었다. (멘토님의 조언) → UserRepository와 다른 Repository를 같이 사용하는 새로운 Service 클래스를 생성 → 네이밍은 행위를 붙였다. (어떤 행위를 위해 만들어진 Service)
RestDocs 적용 [ 참고 ]
- build.gradle 파일 설정
plugins { id "org.asciidoctor.convert" version "1.5.9.2" // AsciiDoc 파일을 converting, Build 폴더에 복사 } asciidoctor { dependsOn test // } bootJar { dependsOn asciidoctor // test 실행 후 asciidoctor 실행 from ("$/html5") { // html 파일 생성 into 'static/docs' } } dependencies { ... 생략 testImplementation('org.springframework.restdocs:spring-restdocs-mockmvc') // mockmvc를 restdocs에 사용할 수 있게 하는 라이브러리 }
- RestDocs 적용 예시
@AutoConfigureRestDocs @AutoConfigureMockMvc @SpringBootTest class CartControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; ... @Test @Transactional void testRestDocs() throws Exception { ... mockMvc.perform(get("/api/v1/restdocs/{Id}", id) .content(objectMapper.writeValueAsString(req)) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andDo(print()) .andDo(document("restdocs-test", // 저장 될 파일명 pathParameters( // path parameters parameterWithName("id").description("아이디") ), requestFields( // request body fieldWithPath("firstName").type(JsonFieldType.STRING).description("이름"), fieldWithPath("lastName").type(JsonFieldType.STRING).description("성"), fieldWithPath("birthDate").type(JsonFieldType.STRING).description("생년월일") ), responseFields( // response body fieldWithPath("statusCode").type(JsonFieldType.NUMBER).description("결과코드"), fieldWithPath("serverDatetime").type(JsonFieldType.STRING).description("서버시간"), fieldWithPath("data.person.id").type(JsonFieldType.NUMBER).description("아이디"), fieldWithPath("data.person.firstName").type(JsonFieldType.STRING).description("이름"), fieldWithPath("data.person.lastName").type(JsonFieldType.STRING).description("성"), fieldWithPath("data.person.age").type(JsonFieldType.NUMBER).description("나이"), fieldWithPath("data.person.birthDate").type(JsonFieldType.STRING).description("생년월일") ) )); } }
[Lombok] Test Code 작성시 @Slf4j 가 인식이 안되는 경우 (Gradle)
- build.gradle 설정을 추가해줘야한다.
dependencies { ... testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' ... }
[JPA] Enum을 List로 받고 싶은 경우
→ 새로운 클래스를 만들어서 List로 받는게 적합하다.
@Entity public class Accommodation { @Id @Column(name = "accommodation_id") @GeneratedValue(strategy = GenerationType.AUTO) private Long Id; @Column(name = "accommodation_name") private String accommodationName; @OneToMany(mappedBy = "accommodation", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) private Set<AccommodationOption> options = new HashSet<>(); // 원래는 Option들을 Enum으로 나열해서 List<>로 담고 싶었지만 불가능!! // Option 클래스를 따로 만들어서 Set에 담았다. (중복 제거) } @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor public class AccommodationOption { @Id @Column(name = "accommodation_option_id") @GeneratedValue(strategy = GenerationType.AUTO) private Long Id; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "accommodation_id", referencedColumnName = "accommodation_id", nullable = false) private Accommodation accommodation; @Builder public AccommodationOption(final Accommodation accommodation) { this.accommodation = accommodation; accommodation.addOption(this); } }
[JPA] @Enumerated
개선하기 [ 참고 ]
→
@Enumerated
는 문제가 될 만한 상황이 존재한다.-
@Enumerated(EnumType.ORDINAL)
은 Enum에서 정의된 순서의 인덱스를 DB에 저장한다. 만약 Enum이 정의된 순서가 바뀌거나, 중간에 새로운 값이 들어간다면 인덱스가 바뀌어서 큰일난다... 물론 뒤에 차례대로 추가하면 되겠지만, 아예 위험을 차단하는 방향이 좋을거라고 생각해서 ORDINAL은 지양한다.
@Enumerated(EnumType.STRING)
은 문자열을 저장하기 때문에 DB 공간 낭비가 발생한다.
이를 개선할 수 있는 것이 Attribute Converter이다.
@Entity public class Accommodation { @Id @Column(name = "accommodation_id") @GeneratedValue(strategy = GenerationType.AUTO) private Long Id; @Column(name = "accommodation_name") private String accommodationName; @Column(name = "region") @Convert(converter = RegionAttributeConverter.class) private Region region; } public enum Region { SEOUL("서울"), GYEONGGI("경기도"), GANGWON("강원도"), CHUNGCHEONG("충청도"), GYEONGSANG("경상도"), JEONLA("전라도"), JAEJU("제주도"); private final String regionName; Region(final String regionName) { this.regionName = regionName; } }
@Converter public class RegionAttributeConverter implements AttributeConverter<Region, String> { @Override public String convertToDatabaseColumn(Region region) { return region.regionName; // DB에 입력되는 데이터 } @Override public Region convertToEntityAttribute(String regionName) { return Stream.of(Region.values()) // DB에 입력 되어있는 데이터를 받아서 Enum Type으로 변환 .filter(c -> c.regionName.equals(regionName)) .findFirst() .orElseThrow(IllegalArgumentException::new); } }
- Enum에 중복 코드를 줄이고 유지보수에 유리해진다.
[JPA] JPA Auditing [ 참고 ]
대부분의 엔티티에서 중복되는 필드를 어노테이션으로 깔끔하게 적용할 수 있다.
스프링 프레임워크에서 제공하는 어노테이션이다.
@CreatedDate
: 엔티티를 생성한 시각을 저장
@LastModifiedDate
: 마지막으로 수정한 시각을 저장
@CreatedBy
: 생성한 사람을 저장
@LastModifiedBy
: 마지막으로 수정한 사람을 저장
@CreatedBy
와 @LastModifiedBy
는 Spring Security의 ContextHolder안에 들어있는 신원 정보의 name값으로 매핑해준다.
만약 Custom하게 구현하려면 AuditorAware 인터페이스를 구현해야 한다.
Spring Security의 ContextHolder에 존재하는 Authentication 정보를 통해서 매핑한다.
이 부분은 Spring Security를 배우고 나서 적용시켜봐야겠다! (현재는 Id를 매핑)주로 BaseEntity를 구현해서 엔티티 클래스에서 상속받는데,
@CreatedBy
와 @LastModifiedBy
는 특정한 엔티티에서만 필요해서 기능을 분리해 구현하였다.- 구현 예시
- SpringBoot Application에
@EnableJpaAuditing
을 추가해줘야 적용됨 @MappedSuperclass
: 엔티티가 해당 추상클래스를 상속할 경우 createdAt, modifiedAt 등을 컬럼으로 인식@EntityListeners(AuditingEntityListener.class)
: 해당 클래스에 Auditing 기능을 포함
@SpringBootApplication @EnableJpaAuditing public class Application { ... }
@Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) @NoArgsConstructor(access = AccessLevel.PROTECTED) public abstract class BaseEntity extends BaseTimeAndDeletedEntity { @CreatedBy @Column(name = "created_by", updatable = false) private Long createdBy; @LastModifiedBy @Column(name = "modified_by") private Long modifiedBy; }
@Getter @MappedSuperclass @EntityListeners(AuditingEntityListener.class) @NoArgsConstructor(access = AccessLevel.PROTECTED) public abstract class BaseTimeAndDeletedEntity { @CreatedDate @Column(name = "created_at", updatable = false) private LocalDateTime createdAt; @LastModifiedDate @Column(name = "modified_at") private LocalDateTime modifiedAt; @Column(name = "is_deleted", columnDefinition = "boolean default false") private Boolean isDeleted = false; // Soft Delete를 적용했다. public void setIsDeleted(final Boolean deleted) { this.isDeleted = deleted; } }
(+추가 비교)
- Hibernate Annotations
@CreationTimestamp
: INSERT 쿼리가 발생할 때, 현재 시간을 값으로 채워서 쿼리를 생성한다.@UpdateTimestamp
: UPDATE 쿼리가 발생할 때, 현재 시간을 값으로 채워서 쿼리를 생성한다.
스프링 프레임워크에서 제공하는
@CreatedDate
, @LastModifiedDate
와 성능 및 기능상 큰 차이는 없지만, 최근에는 하이버네이트 어노테이션 자체를 점점 사용하지 않는 추세이다.[JPA] Entity에 Builder 패턴을 적용할 때 주의할점 [ 참고1, 참고2 ]
Entity를 효율적으로 생성하기 위해 주로 Builder 패턴을 활용한다. 이때 클래스에
@Builder
를 붙이기보다는 필요한 필드만을 인자로 받는 생성자에 @Builder
를 붙이는 쪽이 좋다.
Entity 클래스 자체에 @Builder
를 붙이게되면 인자로 받지 말아야하는 필드(ex. 연관 관계가 있는 다른 Entity)까지 넣어서 객체가 생성될 수 있다.@Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor public class Member { @Id @Column(name = "member_id") private String id; @Column(name = "password", nullable = false) private String password; @OneToMany(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) private List<Review> reviews = new ArrayList<>(); @Builder // 필요한 인자만 받는 Builder public Member(final String id, final String password) { this.id = id; this.password = password; } }
@Builder
는 AllArgsConstructor가 필요하다.@Builder
는 기본 생성자가 정의되어 있지 않을때, AllArgsConstructor를 알아서 생성하고 사용한다.
@Entity
는 NoArgsConstructor가 필요하다.@Builder
를 붙여주면 AllArgsConstructor가 생성되기 때문에 NoArgsConstructor는 생성되지 않아서@NoArgsConstructor
를 붙여준다.@NoArgsConstructor
를 붙여주면 AllArgsConstructor가 생성되지 않아서@AllArgsConstructor
를 붙여준다.- 기본 생성자를 통한 객체 생성을 막기위해
@NoArgsConstructor(access = AccessLevel.PROTECTED)
로 설정한다.
[JPA] 싱글테이블 전략(상속 관계)에서 @Builder 사용
이번 프로젝트를 진행하면서 의견이 갈렸던 부분이다.
싱글테이블 전략을 사용하면 필드값들이 많아지게 되는데,
@SuperBuilder
를 사용하면 생성자를 따로 명시해주지 않아도 돼서 코드가 간결해진다는 의견이 있었다.
자식 객체에서 부모 객체의 필드값도 한번에 SuperBuilder로 지정할 수 있기 때문에, 클래스에 @Builder
를 붙여 모든 필드를 입력받는 경우에 사용하면 유용할 것 같다.
하지만 Entity를 Builder로 생성할 때 필요한 필드값만 Builder로 지정하고, 연관관계 매핑을 Builder 생성 시에 포함시키기 위해서는 기존의 Builder 패턴이 더 적합해보인다.@SuperBuilder
[ 참고 ]- 부모 객체를 상속받는 자식 객체를 만들 때, 부모 객체의 필드값도 Builder로 지정할 수 있게 하기 위해 사용
// 부모 클래스 @Entity @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @SuperBuilder public class Product { @Column(name = "business_address") private String businessAddress; @Column(name = "business_name") private String businessName; }
// 자식 클래스 @Entity @DiscriminatorValue("ACCOMMODATION") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @SuperBuilder public class Accommodation extends Product { @Column(name = "accommodation_name") private String accommodationName; @Column(name = "accommodation_notice") @Lob private String accommodationNotice; }
@Builder
- 기존 방식의 Builder 패턴 적용
// 부모 클래스 @Entity @Inheritance(strategy = InheritanceType.SINGLE_TABLE) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor public class Product { @Column(name = "business_address") private String businessAddress; @Column(name = "business_name") private String businessName; }
// 자식 클래스 @Entity @DiscriminatorValue("ACCOMMODATION") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor public class Accommodation extends Product { @Column(name = "accommodation_name") private String accommodationName; @Column(name = "accommodation_notice") @Lob private String accommodationNotice; @Builder public Accommodation(final String businessAddress, final String businessName, final String accommodationName, final String accommodationNotice) { super(businessAddress, businessName); this.accommodationName = accommodationName; this.accommodationNotice = accommodationNotice; // 연관관계 편의 메소드 추가 가능 } }
[JPA] @Transactional의 (readOnly=true) 옵션 [ 참고 ]
Entity가 영속성 컨텍스트에서 관리되면 1차 캐시, 변경감지 등 혜택이 많지만, 스냅샷을 보관하는 등 더 많은 메모리를 사용하는 단점이 존재한다.
만약 조회만 하는 경우, 읽기 전용으로 엔티티를 조회하면 메모리 사용량을 줄일 수 있다. + dirty checking 생략
또한, 의도치 않게 데이터를 변경하는 경우를 예방할 수 있다.
스프링 프레임워크에서 제공하는
@Transactional
에서는 readOnly 라는 옵션을 제공한다. → import org.springframework.transaction.annotation.Transactional;
Service 전체에 (readOnly=true) 옵션 사용을 유도하기 위해 클래스에 @Transactional(readOnly = true)
를 붙여줬고, readOnly가 아닌 메소드에만 추가로 @Transactional
을 붙여줬다.@Service @RequiredArgsConstructor @Transactional(readOnly = true) public class UserServiceImpl implements UserService { private final UserRepository userRepository; private final UserConverter userConverter; @Override @Transactional public Long createUser(final UserCreateRequestDto dto) { return userRepository .save(userConverter.toEntity(dto)) .getUserId(); } @Override // readOnly = true public UserResponseDto findById(final Long userId) { return userRepository .findById(userId) .map(userConverter::toResponseDto) .orElseThrow(() -> new NotFoundException("User is not found.")); } ... }
추가 학습 예정
- Exception Handler [ https://jeong-pro.tistory.com/195 ]
- 정규화 역정규화 [ https://jaenjoy.tistory.com/15 ]