본문 바로가기
Dev/[프로젝트] 2024 마이펫로그

2024 마이펫로그 프로젝트 회고

by javapp 자바앱 2024. 4. 3.
728x90

 

 

반려인(집사)들의 공동 기록과 소통을 위한 서비스를 기획하여 프로젝트를 진행하였습니다.

개발기간 : 2023. 1. 20(토) ~ 2023. 2. 29(목)

 

 

협업 방법

노션을 통해서 문서 공유를 하였고 

실시간 소통을 위해 디스코드 채널을 활용하였습니다.

 

 

프론트엔드&벡엔드&디자이너

협업 미팅 및 회고는 목요일에 진행했습니다.

미팅에서는 진행사항, 논의사항 그리고 각 팀의 회고 시간을 가졌습니다.

팀 회고는 "KPT 회고" 방식을 도입하여 진행했습니다.

 

Keep

현재 우리팀이 잘 하고 있어서 계속 유지하면 좋을 것 같은 부분

 

Problem

현재 우리팀이 가지고 있는 문제

 

Try

Problem에 적은 것을 해결하기 위해 우리 팀이 다음에 시도해볼만한 것 (혹은 Keep 중에서도 더 개선하거나 시도해보고 싶은 것)

 

 

이러한 활동들 뿐만 아니라 우선 팀원들끼리 서로 배려심이 넘쳤고 사이가 좋아서

원활히 진행이 될 수 있었습니다.

Designed by Freepik

 

 

기획부터 시작하는 프로젝트였습니다.

그래서 벡엔드에서는 프로젝트의 스펙을 정하고 세팅을 하는 시간을 가졌습니다.

 

기술 스택

 

 

 

시스템 아키텍처

 

 


 

 

멀티 모듈 설계

멀티 모듈 프로젝트로 진행했습니다.

 

특징 모놀리틱 아키텍처 멀티모듈 설계
구조 단일 코드 베이스에 모든 기능이 포함됨 여러 개의 모듈로 기능을 분리하여 개발됨
유연성 확장 및 변경이 어려움 각 모듈을 독립적으로 확장하고 변경할 수 있음
배포 전체 애플리케이션을 한 번에 배포 각 모듈을 개별적으로 배포할 수 있음
관리 및 유지보수 큰 규모의 애플리케이션에서 관리가 어려움 모듈별로 관리 및 유지보수가 용이함
개발 생산성 초기에는 구현이 쉽고 단순함 초기 구성은 복잡할 수 있지만, 장기적으로는 생산성 향상
의존성 관리 모든 기능이 단일 코드 베이스에 의존함 각 모듈이 필요한 의존성을 관리하고 최소화할 수 있음
팀 작업 대규모 팀 작업에는 적합하지 않음 팀 간 협업이 용이하며, 각 팀이 독립적으로 모듈을 관리
기술 변경 및 업그레이드 변경이 어려움 각 모듈을 개별적으로 기술 변경 및 업그레이드할 수 있음
확장성 전체 애플리케이션의 확장만 가능 각 모듈의 확장이 용이함

 

프로젝트는 하나고, 그 안에 여러 개의 모듈을 설치 가능한 방법을 찾게 됨

 

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 로드밸런싱 추가 예정입니다

감사합니다.

 

 

댓글