[TIL] 개인 프로젝트 - '일정 관리 (숙련) 프로젝트' 후기
1. 일정 관리 (숙련) 프로젝트
저번의 일정 관리 프로젝트에서 더욱 어려워진 프로젝트입니다
이전보다 훨씬 다양한 내용을 적용해보며 프로젝트를 협업하는 사람이 보아도
손쉽게 유지보수할 수 있도록 만드는게 이번 프로젝트의 목표라고도 볼 수 있겠습니다
이번에는 글이 조금 길 수도 있겠네요
2. 새롭게 배운 내용들
2-1. Issue, Pull Request 템플릿
이전의 내용을 다시 열어보고 틀에 맞춰 작성해야 한다는게 꽤나 귀찮은 작업인 것 같습니다
그런데 이전에 함께 프로젝트를 하던 팀원분께서 꽤 좋은 팁을 주더라구요
Issue Template
[ 깃허브 Repository - General - Features - Set up templates ]
이슈 템플릿을 만들 수 있습니다
기획, 추가할 기능, 개선 사항 등을 템플릿으로 만들고, 이후 살만 조금 붙여서 쉽게 Issue를 등록할 수 있습니다Pull Request Template
[ Repository Main\.github\ISSUE_TEMPLATE\pull_request_template.md ]
Pull Request 내용이 자동으로 해당 템플릿으로 채워집니다
어느 이슈와 관련된 것인지, 어떤 기능을 추가하거나 삭제, 수정을 한 것인지 체크하는 템플릿이 상당히 유용합니다
- 예시 출처 : sparkbox
2-2. MapStruct
Entity
와 DTO
를 서로 변환하는 작업은 상당히 자주 필요합니다
그런데 서로 변환해주는 작업의 방법이 다양하더라구요
@Builder
@AllArgsConstructor(생성자)
정적 팩토리 메서드
MapStruct
1~3번은 알고 있는 내용이지만 (정적 팩토리 메서드와 같은 ‘디자인 패턴’은 조만간 글로 작성할 예정)
4번의 MapStruct
는 처음 보게된 방법이기 때문에 이 참에 적용해보려고 합니다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 종속성 추가
deptendencies {
implementation 'org.mapstruct:mapstruct:1.4.2.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
}
// Mapper 클래스 추가
@Mapper(componentModel = "spring") // Bean으로 지정하려면 'componentModel' 지정
public interface MapperClass {
Entity toEntity(RequestDto requestDto); // request dto -> entity
ResponseDto toDto(Entity entity); // entity -> response dto
}
// 사용 예시 > Service
// private final MapperClass mapperClass; - @RequiredArgsConstruct로 주입
// Entity entity = mapperClass.toEntity(requestDto)
만약 entity와 dto 둘 다 같은 필드를 가지고 있는 경우, 자동으로 값을 매핑해주며 변환하게 됩니다
생성자나 빌더, 정적 팩토리 메서드와 다르게 별다른 긴 코드를 작성할 필요가 없습니다
1
2
3
4
5
6
7
8
9
10
11
12
13
// Entity의 password 필드에는 encodedPassword 값을 매핑
// 만약 source와 target에 있는 같은 목적의 다른 필드명도 @Mapping 사용으로 매핑 가능
@Mapping(source = "encodedPassword", target = "password")
Entity toEntityWithNewField(RequestDto requestDto, String encodedPassword)
// Entity -> Dto 매핑 작업에 이름 지정
@Named("toResponseDto(entity)")
ResponseDto toResponseDto(Entity entity);
// List는 지정된 매핑 작업으로 iterate 작업
@IterableMapping(qualifiedByName = "toResponseDto(entity)")
List<ResponseDto> toResponseDto(List<Entity> entities)
필드명이 다른 값을 매핑해야하거나 새로운 인자를 넣어줘야 하는 경우
또는 List
형태도 손쉽게 어노테이션으로 지정하여 사용할 수 있습니다
단, Source에는 getter가 있어야하고 Target에는 builder이나 setter, 생성자 등이 포함되어야 합니다
2-3. Record 클래스
1
2
3
4
5
public record Person(String name,
int age,
String email) {
}
JDK 14에서 Preview로 등장했다가 JDK 16에 정식으로 포함된 클래스입니다
record
를 사용할 경우 다음 내용들이 자동으로 구현됩니다
- 필드 캡슐화 (
private final
) - 생성자
- getter
- equals
- hashcode
- toString
getter에서 약간의 차이점이 생기는데, get<필드명>()
대신 <필드명>()
으로 값을 가져옵니다
스프링에서 Request/Response DTO에 적용할 때 유용하게 사용할 수 있을 것 같습니다
단, JPA와 같이 사용하는 것에는 문제가 생기므로
Entity
에record
를 사용할 수는 없습니다
자세한 이유는 한 번 찾아보는 것이 좋겠습니다
2-4. HandlerMethodArgumentResolver
session
에 저장된 유저 ID값을 Controller
에서 꺼내야하는 수고가 있을 수도 있습니다
하지만 Controller
레이어의 파라미터에서 바로 엔티티로 반환해주는 객체가 있다면 좋지 않을까요
1
2
3
4
5
6
7
8
9
// ScheduleController.java ... 일정 생성
@PostMapping
public ResponseEntity<ScheduleResponseDto> createSchedule(
@RequestBody @Valid ScheduleCreateRequestDto scheduleCreateRequestDto,
@LoginUserResolver User user) {
ScheduleResponseDto createdScheduleDto = scheduleService.createSchedule(scheduleCreateRequestDto, user);
return ResponseEntity.status(HttpStatus.CREATED).body(createdScheduleDto);
}
제가 프로젝트에 사용했던 API중 하나인데 @LoginUserResolver
어노테이션을 사용했습니다
Body
에 user 데이터를 넣지 않고 session
으로 유저의 id값만 가져가는데도 엔티티 객체를 사용할 수 있죠
ArgumentResolver
라는 것을 이용해서..
Controller로 들어온 파라미터를 엔티티 객체로 가공하는 방법을 간단하게 보겠습니다
1
2
3
4
5
@Target(ElementType.PARAMETER) // -> 메소드의 파라미터로 선언된 객체에서만 사용 가능
@Retention(RetentionPolicy.RUNTIME) // -> 메모리 생명 주기를 RUNTIME으로 지정
public @interface LoginUserResolver {
}
- 특이하게도 클래스가 아닌
@interface
로 지정합니다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Component
@RequiredArgsConstructor
public class UserHandlerArgumentResolver implements HandlerMethodArgumentResolver {
private final HttpSession httpSession;
private final UserRepository userRepository;
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().isAssignableFrom(User.class);
}
@Override
public User resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
Object userId = httpSession.getAttribute("LOGIN_USER_ID");
if (Objects.isNull(userId)) {
throw new BusinessException(ErrorCode.INTERVAL_SERVER_ERROR);
}
Optional<User> optionalUser = userRepository.findById((Long) userId);
return optionalUser.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
}
}
HandlerMethodArgumentResolver
을 상속받아 컨트롤러 메서드의 파라미터를 처리할 수 있도록 합니다- supportsParameter는
boolean
값에 따라resoveArgument
메서드를 실행할지 결정합니다 - resolveArgument메서드 에서
userRepository
에 찾은 유저 엔티티를 반환합니다
이후 컨트롤러에 어노테이션을 붙여주면 session 값을 불러온 뒤 유저 엔티티를 불러오게 됩니다
3. 조금 더 보완된 내용
3-1. @ManyToOne(fetch = FetchType.LAZE)
1
2
3
4
// Schedule.java
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
Schedule
과 User
가 N:1 관계일 때, @ManyToOne
으로 단방향 연관관계를 설정할 수 있습니다
여기서 fetch
를 정하는 방법에 따라 즉시 로딩과 지연 로딩을 선택할 수 있습니다
- 즉시 로딩(Eager loading) : 연관된 엔티티도 함께 조회하는 방식
- 지연 로딩(Lazy loading) : 연관된 엔티티가 있어도 조회하는 엔티티만 불러오는 방식
여기서 fetch = FetchType
을 사용하여 User
엔티티를 굳이 조회하지 않도록 설정했습니다
지금이야 Schedule
의 연관관계가 User
밖에 없지만 프로젝트가 커지면 성능 문제가 생길 수도 있습니다
3-2. JpaAuditing Config
1
2
3
4
5
@EnableJpaAuditing
@Configuration
public class JpaAuditingConfig {
}
이전 프로젝트에서는 어플리케이션에 붙여서 사용했는데 분리하는 것이 좋다는 피드백을 받았습니다
간단하게 @EnableJpaAuditing
과 @Configuration
을 사용해서 config 클래스를 만들면 되겠습니다
3-3. Entity를 다룰 때…
1
2
3
4
5
6
7
8
@Getter
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstuctor
public class User {
// ...
}
Entity
객체는 DTO
처럼 조심히 다뤄야 할 필요가 있습니다
그런데 @Builer
를 사용해서 값을 붙이려고 하면 @NoArgsConstructor
, @AllArgsConstuctor
가 자동으로 따라옵니다
@NoArgsConstructor 무분별한 객체 생성을 막기 위해
@NoArgsConstructor(access = AccessLevel.PROTECTED)
를 사용해줍시다
private
가 아닌protected
를 사용하는 이유는 프록시 객체때문인데 이건 자세히 알아보는 글을 써야겠네요@Builder 클래스에
@Builder
를 붙이면 위에 언급했던대로@AllArgsConstructor
까지 붙여줘야 합니다
하지만 만약 생성자에Builder
를 붙일 경우, 필요한 필드에만 적용할 수 있습니다그리고
@AllArgsConstructor
을 다시 지워주는 리팩토링까지 거치면 되겠습니다
물론 @Builder
를 필요한 경우에 사용하면 되는거지만 저는 프로젝트를 하다보니 굳이 @Builder
가 필요없더라구요
그래서 @NoArgsConstuctor(access = AccessLevel.PROTECTED)
만 붙여준 뒤 생성자를 만들어서 사용했습니다
4. 아쉬웠던 점
4-1. 컨트롤러 반환에 사용할 Response 클래스
각 컨트롤러마다 ResponseEntity.status(<HttpStatus>).body(<ResponseDto>);
를 작성하며 반환했는데
여기서 body
에 HttpStatus와 성공 코드, 성공 시간 등 다른 데이터도 함께 보낼 수 있으면 좋지 않았을까 싶습니다
ErrorResponse
클래스의 경우에는 핸들러를 통해 정해진 형식에 따라 반환할 수 있었지만
성공했을 때에는 미처 같은 형식으로 반환하게 만드는 것을 생각하지 못했던 것 같네요
다음 프로젝트까지 성공 및 실패 Response
를 하나의 클래스로 묶어서 처리해야하는지, 아니면 나누어서 처리해야하는지
알아본 다음에 적용하는 목표를 세워보겠습니다
4-2. @Column(unique = true) 예외 핸들링
엔티티에 걸린 고유 제약 조건으로 예외가 발생하는 경우, try-catch
를 통해 예외를 처리했는데요
뭔가 여기서 더 나은 코드로 수정할 수 있지 않을까…싶은 생각이 들지만 시간이 부족해서 더 찾아보진 못했습니다
ControllerAdvice
를 사용하면 전역적으로 처리하기 때문에, 제약 조건에 따라 메시지를 다르게 하는건 안되겠더라구요
더 나은 방법이 있는지 자료를 찾아보거나 물어보는 것으로 해결해봐야겠습니다
4-3. Test
1
2
3
4
5
6
7
8
9
10
11
12
@Test
@DisplayName("페이지 불러오기")
void getSchedulePageTest() throws Exception {
MockHttpSession session = new MockHttpSession();
session.setAttribute("LOGIN_USER_ID", 1L);
mvc.perform(MockMvcRequestBuilders.get("/schedules/page")
.contentType(String.valueOf(MediaType.APPLICATION_JSON))
.session(session))
.andExpect(status().isOk())
.andDo(print());
}
사실 그렇게 막 아쉬웠다-라는 느낌은 아니지만 제대로 써보지 못한 것 같더라구요
스프링의 @SpringBootTest
기능을 통해 테스트 및 디버깅을 잘 하는 능력을 키워야하지 않을까요
5. 끝으로…
이번 프로젝트는 새로 배운 내용과 배워야 할 내용을 많이 알게 된 것이 굉장히 좋은 경험이었습니다
그럼에도 아직 적용해보지 못했거나 아쉬운 점은 늘 존재하게 됩니다
새로운 기술을 적용해보고 이전 기술들과 비교해보면서 더 깔끔하고 유지보수하기도 좋은 코드를 완성하는 것은
프로그래밍에 있어서 큰 즐거움을 선사해주는 것 같기도 합니다
다음 프로젝트가 진행된다면 더 좋은 수준의 설계를 통해 완성할 수 있지 않을까요
그리고 다른 팀원분들 및 함께 스터디하는 분들을 통해 제가 미처 보지 못했던 새로운 기술과 문제점을 찾을 수 있게 되어
함께 개발을 하는 것에도 뜻깊은 시간이 된 것 같습니다