반려인(집사)들의 공동 기록과 소통을 위한 서비스를 기획하여 프로젝트를 진행하였습니다.
개발기간 : 2023. 1. 20(토) ~ 2023. 2. 29(목)
협업 방법
노션을 통해서 문서 공유를 하였고
실시간 소통을 위해 디스코드 채널을 활용하였습니다.
프론트엔드&벡엔드&디자이너
협업 미팅 및 회고는 목요일에 진행했습니다.
미팅에서는 진행사항, 논의사항 그리고 각 팀의 회고 시간을 가졌습니다.
팀 회고는 "KPT 회고" 방식을 도입하여 진행했습니다.
Keep
현재 우리팀이 잘 하고 있어서 계속 유지하면 좋을 것 같은 부분
Problem
현재 우리팀이 가지고 있는 문제
Try
Problem에 적은 것을 해결하기 위해 우리 팀이 다음에 시도해볼만한 것 (혹은 Keep 중에서도 더 개선하거나 시도해보고 싶은 것)
이러한 활동들 뿐만 아니라 우선 팀원들끼리 서로 배려심이 넘쳤고 사이가 좋아서
원활히 진행이 될 수 있었습니다.
기획부터 시작하는 프로젝트였습니다.
그래서 벡엔드에서는 프로젝트의 스펙을 정하고 세팅을 하는 시간을 가졌습니다.
기술 스택
시스템 아키텍처
멀티 모듈 설계
멀티 모듈 프로젝트로 진행했습니다.
특징 | 모놀리틱 아키텍처 | 멀티모듈 설계 |
구조 | 단일 코드 베이스에 모든 기능이 포함됨 | 여러 개의 모듈로 기능을 분리하여 개발됨 |
유연성 | 확장 및 변경이 어려움 | 각 모듈을 독립적으로 확장하고 변경할 수 있음 |
배포 | 전체 애플리케이션을 한 번에 배포 | 각 모듈을 개별적으로 배포할 수 있음 |
관리 및 유지보수 | 큰 규모의 애플리케이션에서 관리가 어려움 | 모듈별로 관리 및 유지보수가 용이함 |
개발 생산성 | 초기에는 구현이 쉽고 단순함 | 초기 구성은 복잡할 수 있지만, 장기적으로는 생산성 향상 |
의존성 관리 | 모든 기능이 단일 코드 베이스에 의존함 | 각 모듈이 필요한 의존성을 관리하고 최소화할 수 있음 |
팀 작업 | 대규모 팀 작업에는 적합하지 않음 | 팀 간 협업이 용이하며, 각 팀이 독립적으로 모듈을 관리 |
기술 변경 및 업그레이드 | 변경이 어려움 | 각 모듈을 개별적으로 기술 변경 및 업그레이드할 수 있음 |
확장성 | 전체 애플리케이션의 확장만 가능 | 각 모듈의 확장이 용이함 |
프로젝트는 하나고, 그 안에 여러 개의 모듈을 설치 가능한 방법을 찾게 됨
XXX-api
XXX-common
XXX-domain
아쉬운 점이 있다면
초기 멀티모듈의 패키지 구성을 잘 몰랐기에
config 같은 설정 파일을 common 모듈에 놓아두었습니다.
공통 모듈 계층은 Type, Util 등을 정의한다고 합니다.
도메인 외 시스템에서 필요한 모듈들은 '내부 모듈 계층' 속하는 게 좋고,
시스템 전체적인 기능을 서포트하기 위한 기능 모듈입니다.
core-web
web 설정을 사용하는 프로젝트에서 사용할 수 있는 모듈이다. 주로 Web Filter 를 이용한 보안, 로깅 등으로 활용되며, 웹에 대한 필수적인 공통 설정을 하기도 한다.
xxx-client
외부의 xxx 시스템과 통신을 책임지는 모듈이며 각 외부 시스템별로 따로 모듈을 만들었습니다. 이 모듈은 비지니스와 관계없이 요청과 응답을 할 수 있는 사용성을 제공하고, 요청에 대한 설렁과 스팩을 책임집니다. 어플리케이션 모듈에서는 사용하는 외부 시스템 모듈만 사용하게 됩니다.
xxx-event-publisher
특정 이벤트에 대한 처리를 담당합니다. 여기서 말하는 이벤트는 Spring ApplicationEvent 를 말하며, 이벤트가 발생했을 때 SQS 로 이벤트를 전송하거나, 로그를 남기는 등 특정 행위를 처리합니다. 이 모듈 또한 하고 있는 주요 행위의 범위에 따라 따로 모듈이 생성될 수 있습니다.
외부 시스템 통신이나 보안, config는 해당 모듈에 사용하는 게 더 좋았을 것입니다.
Spring Security
AuthenticationPrincipal 활용
Spring Security는 같은 스레드의 앱 내에서 어디서든
"SecurityContextHolder" 에 인증된 사용자 정보를 저장하여
인증 정보를 확인할 수 있도록 구현되어있습니다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request.getServletPath().contains("/api/v1/auth")) { // 필터체인 통과
log.debug("################필터체인 통과##############");
filterChain.doFilter(request,response);
return;
}
String token = jwtTokenProvider.getJwtFromRequestHeader(request);
// token validation
if(StringUtils.hasText(token) && jwtTokenProvider.validateAccessToken(token, request)){
Map<String, Object> userInfo = jwtTokenProvider.getUserFromJwt(token);
UserDetails userDetails = customUserDetailsService.loadUserByUsername((String)userInfo.get("email"));
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,null,userDetails.getAuthorities()
);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
filterChain.doFilter(request, response);
}
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
SecurityContext의 Authentication 에 저장
@AuthenticationPrincipal 사용
authentication 에서 꺼내 사용할 수 있지만 해당 애노테이션을 통해 인증된 사용자 정보를 얻을 수 있습니다.
// 기존
@GetMapping("/api/v1/member/me")
public ResponseEntity<String> findOauthProfile(Authentication authentication) {
PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();
String email = principal.getUsername();
return ResponseEntity.ok(email);
}
// 이후
@GetMapping("/api/v1/member/me")
public ResponseEntity<String> findOauthProfile(@AuthenticationPrincipal PrincipalDetails principal) {
String email = principal.getUsername();
return ResponseEntity.ok(email);
}
암호화
자바로 암호화할 수 있는 Jasypt 라이브러리를 사용했습니다.
암호키(password)를 안다면 팀원과 설정파일을 공유하면서
편하게 암호화하여 사용할 수 있는 장점이 있었습니다.
그렇지만 실행을 시킬 때 별도의 VM Option 을 사용해야하는 불편함이 있습니다.
-Djasypt.encryptor.password=
또는
System.getProperty("jasypt.encryptor.password")
스프링부트를 도커로 감싸서 실행을 할 때 잘 안되는 것 같았습니다.
+ 패스워드를 파일에 저장한 경우
jar 실행시 에러 발생
설정파일은 api 모듈에 있음 common 에 있는 시큐리티가 api 모듈에 있는 설정파일의 값을 불러오려고 하는데 못 받아옴
암호화 config 에서 txt 파일을 읽는 부분이있는데 못읽어낸다..
try {
ClassPathResource resource = new ClassPathResource("jasypt-password.txt");
return Files.readAllLines(Paths.get(resource.getURI())).stream()
.collect(Collectors.joining(""));
} catch (IOException e) {
throw new RuntimeException("Not found Jasypt password file.");
}
해결, 환경변수 사용
스웨거
프론트 개발자와 협업을 하면서 중요하다고 느꼈던 부분이
API 자동화 문서 스웨거 였습니다.
https://www.baeldung.com/swagger-operation-vs-apiresponse
https://jeonyoungho.github.io/posts/Open-API-3.0-Swagger-v3-%EC%83%81%EC%84%B8%EC%84%A4%EC%A0%95/
그래서 이전의 프로젝트보다 공들여서 작성하였습니다.
알게된 점
요청 성공시 반환 객체가 있다면 @Schema 를 통해 보여줄 수 있습니다.
@Operation(summary = "내 정보 조회")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", content = { @Content(mediaType = "application/json", schema = @Schema(implementation = ProfileResponse.class)) }),
@ApiResponse(responseCode = "404", description = "User not found", content = {@Content(schema = @Schema(implementation = ExceptionResponse.class))})
})
@GetMapping("/v1/users/me")
public ResponseEntity<ProfileResponse> displayMe(@AuthenticationPrincipal PrincipalDetails principalDetails) {
return ResponseEntity.ok(userService.displayMe(principalDetails.getUser()));
}
Schema 를 통해 반환 객체를
빠르게 확인할 수 있습니다.
multipart form 에러
요청시 multipart/form-data 일 경우 consumes 를 통해 변경할 수 있습니다.
@Operation(summary = "프로필 수정")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", content = {@Content(schema = @Schema())}),
@ApiResponse(responseCode = "404", description = "User not found", content = {@Content(schema = @Schema(implementation = ExceptionResponse.class))}),
})
@PutMapping(value = "/v1/users/profile", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Void> updateProfile(
@RequestPart(required = false, value = "nickname") String nickname,
@RequestPart(required = false, value = "password") String password,
@AuthenticationPrincipal PrincipalDetails principalDetails
) {
userService.updateProfile(principalDetails.getUser(), nickname, password);
return ResponseEntity.ok().build();
}
배열 반환
반환값이 리스트일 경우
스웨거에 ArraySchema를 통해 배열이 반환된다는 것을
설정할 수 있습니다.
@Operation(summary = "내가 초대한 내역", description = "펫메이트 초대 내역에서 내가 초대한 내역을 확인합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", content = {@Content(array = @ArraySchema(schema = @Schema(implementation = MyInvitationResponse.class)))}),
})
@GetMapping("/v1/my/invitations/{petId}/my-invitations")
public ResponseEntity<List<MyInvitationResponse>> displayMyInvitations(@PathVariable Long petId, @AuthenticationPrincipal PrincipalDetails principalDetails) {
return ResponseEntity.ok(invitationService.displayMyInvitations(petId, principalDetails.getUser()));
}
DTO 클래스
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Schema(description = "Pet 정보 요청 DTO")
public class PetRequest {
@Schema(description = "이름", requiredMode = Schema.RequiredMode.REQUIRED)
private String name;
@Schema(description = "타입", requiredMode = Schema.RequiredMode.REQUIRED)
private String type;
@Schema(description = "품종", requiredMode = Schema.RequiredMode.REQUIRED)
private String breed;
@EnumValue(enumClass = Gender.class, message = "유효하지 않은 성별입니다.")
@Schema(description = "성별", allowableValues = {"FEMALE", "MALE"}, requiredMode = Schema.RequiredMode.REQUIRED)
private String gender;
@Schema(description = "중성화 여부")
private Boolean isNeutered;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
@Schema(description = "생년월일(yyyy-MM-dd)")
private LocalDate birth;
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
@Schema(description = "처음 만난 날(yyyy-MM-dd)")
private LocalDate firstMeetDate;
@Min(0)
@Digits(integer = 5, fraction = 2, message = "2자리 소수점으로 구성되어야 합니다.")
@Schema(description = "무게")
private double weight;
@Schema(description = "등록번호")
private String registeredNumber;
@Hidden
public Gender getToGender() {
return getGender().equals("MALE") ? Gender.MALE : Gender.FEMALE;
}
public LocalDateTime convertToBirthLocalDateTime() {
return getBirth() != null ? getBirth().atStartOfDay() : null;
}
public LocalDateTime convertToFirstMeetDateLocalDateTime() {
return getFirstMeetDate() != null ? getFirstMeetDate().atStartOfDay() : null;
}
}
gender 의 경우 특정한 값만 요청되도록 설정되어있습니다.
그럴 경우 프론트 개발자분들이 인지할 수 있도록
Enum도 자세히 설정할 수 있습니다.
validation
어노테이션 | 내용 | 동작 |
@NotNull | 해당 필드가 null이 아닌지를 검증합니다. | 값이 null이 아니라면 검증을 통과합니다. |
@NotEmpty | 해당 필드가 null이 아니고, 빈 문자열("")이 아닌지를 검증합니다. | 값이 null이 아니고 빈 문자열이 아니라면 검증을 통과합니다. |
@NotBlank | 해당 필드가 null이 아니고, 빈 문자열("")이 아니며, 공백 문자열(" ")이 아닌지를 검증합니다. | 값이 null이 아니고 빈 문자열도 아니며, 공백 문자열도 아니라면 검증을 통과합니다. |
enum 에 대한 validation
@EnumValue(enumClass = Gender.class, message = "유효하지 않은 성별입니다.")
private String gender;
controller 에서 @Valid 애노테이션 붙여서 검증
(@Valid @RequestPart Request request)
코드 리뷰 하면서 좋지 않은 코드 고치기
코드리뷰를 통해서 많이 래거시적인 저의 코드가 많이 고쳐졌습니다.
덕분에 코드가 좀 더 가독성이 높아지고 많이 발전이 되어서 회사를 다녔을 때 보다
짧은 시간이지만 더 많이 배웠던 시간이였습니다.
1. set 메소드 사용하지 않기
무분별한 @Setter 으로 인해 의도하지 않은 곳에서 값이 수정되거나, 리팩토링시 문제가 되거나 불편한 점들이 발생
set 메소드 대신 생성자를 통해서 필요한 데이터를 모두 받아 처리하기
* 파라미터가 많을 경우 builder 패턴 사용
User user = User.builder()
.id("randomstring")
.nickname("닉네임")
.build();
생성 메소드로 인스턴스 생성
public static User createUserByEmail(String email, Role role) {
return User.builder()
.id(GenerationUtil.generateIdFromEmail(email))
.email(email)
.role(role)
.isDeleted(false)
.build();
}
2. 존재 여부 커스텀 쿼리
existsBy를 사용하면 select count(*) 가 아닌 select ~ limit 1 쿼리가 실행됩니다.
순회 검색 중 중복되는 게 단 하나라도 있는 경우 쿼리를 종료하기 때문에 모든 개수를 세는 count 보다 좋은 성능을 냅니다.
그 외 수도 없이 많음,,
Query DSL 도입
Spring DATA JPA 를 사용하다보면
join 이 많이 필요한 상황에 쓰기가 힘들어지고
@OneToMany 일 경우 1 + N 상황이 발생합니다.
JPQL 또한 사용하기 힘들었기 때문에 Query DSL를 사용하기로 했습니다.
JPQL 예시)
@Query("select u from User u left join Guardian g on u.id = g.user.id where g.pet.id = ?1 and u.id = ?2 and u.isDeleted = false")
Optional<User> findByGuardianUserByPetIdAndUserId(Long petId, String userId);
Query dsl 에러
만약 IntelliJ를 사용하신다면, 'Build' > 'Rebuild Project'를 선택하여 전체 프로젝트를 재빌드해 보세요.
그래도 문제가 해결되지 않는다면, IDE의 캐시 문제일 수 있습니다.
IntelliJ의 경우 'File' > 'Invalidate Caches / Restart'를 선택하여 캐시를 초기화하고 IDE를 재시작해 보세요.
쿼리 성능 향상
65~90ms → 30ms
최대 66.67% 상승
Before
기존 코드
펫별로 반복적으로 pet Image table 조회 1 + N - 약 65~90ms
public MyPetsResponse findMyPets(User user) {
List<MyPetResponse> myPetResponseList = new ArrayList<>();
List<Pet> myPets = petRepository.findAllByUserId(user.getId());
for (Pet pet : myPets) {
PetImage petImage = petImageRepository.findByPet(pet)
.orElse(new PetImage());
myPetResponseList.add(MyPetResponse.from(pet, petImage));
}
return new MyPetsResponse(myPetResponseList.size(), myPetResponseList);
}
After
쿼리 1개 - 30ms 로 단축
public MyPetsResponse findMyPetByInGuardian(User user) {
List<MyPetResponse> myPetResponseList = new ArrayList<>();
List<MyPetDto> myPetDtos = guardianQuerydslRepository.findMyPetByInGuardian(user.getId());
myPetDtos.forEach(
myPetDto -> myPetResponseList.add(MyPetResponse.from(myPetDto))
);
return new MyPetsResponse(myPetResponseList.size(), myPetResponseList);
}
List<Long> petIds = queryFactory
.select(pet.id)
.from(guardian)
.where(guardian.user.id.eq(userId))
.fetch();
return queryFactory
.select(Projections.fields(MyPetResponseDto.class,
pet.id.as("petId"),
pet.user.id.as("ownerId"),
pet.invitedCode,
pet.name,
pet.type,
pet.breed,
pet.gender,
pet.isNeutered,
pet.birth,
pet.firstMeetDate,
pet.weight,
pet.registeredNumber,
pet.repStatus,
petImage.url.as("petImageUrl")
))
.from(pet)
.leftJoin(petImage).on(pet.id.eq(petImage.pet.id))
.where(pet.id.in(petIds))
.orderBy(pet.createdAt.asc())
.fetch();
이후 고도화로 모니터링과
NGINX 로드밸런싱 추가 예정입니다
감사합니다.
'Dev > [프로젝트] 2024 마이펫로그' 카테고리의 다른 글
페이지네이션 미적용 원인 이슈 (0) | 2024.05.16 |
---|
댓글