Skip to content

SesacAcademy/SesacAnimal

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

📝 프로젝트 소개

image


매년 유기견들이 늘어남에 따라 많은 유기견들에게 보호와 돌봄이 필요하지만 자원이 부족한 상황입니다.

새싹 애니멀은 이러한 상황을 개선하고 유기견에게 더 나은 미래를 제공하기 위해 유기견 입양/임보 서비스를 제공합니다.

개발 인원 : 4명 (박성수, 류명한, 이경진, 손승범)
개발 기간 : 2023.11.01 ~ 2023.11.17

Members


박성수(BE)

팀장

류명한(BE)

팀원

이경진(BE)

팀원

손승범(BE)

팀원

💁‍♂️ Wiki


🛠 사용 기술

[Front-end]

[Back-end]


[Tool & Environment]


🔨 시스템 아키텍처

image


🗒️ ERD 설계

image


💡 주요 업무

👩‍👧 회원 (박성수)

🚨 실종 (류명한)

🐈 입양/임보 (이경진)

✏️ 입양 후기 (손승범)


🌟 트러블 슈팅

박성수
문제 상황 JWT 리프레시 토큰 만료 시, 토큰이 담긴 쿠키가 삭제되지 않음
원인 HttpServletRequest 객체에 담긴 쿠키는 단순히 Key-Value 값만을 가지고 있기 Cookie 객체에 setMaxAge() 외에 추가적인 설정 필요
해결 만료 날짜 (setMaxage), 경로 (setPath), 값 (setValue)을 지정하여 쿠키를 삭제
[Before]
private void removeTokenInCookie(HttpServletRequest request, HttpServletResponse response) {
	// request 객체에서 JWT Token이 담긴 Cookie를 List 형태로 가져 온다.
	List cookielist = Arrays.stream(request.getCookies())
			.filter(cookie -> {
				return cookie.getName().equals(JWT_ACCESS_TOKEN) || cookie.getName().equals(JWT_REFRESH_TOKEN);})
			.toList();

	// cookie의 타임 아웃을 0으로 만들고 다시 response 객체에 저장한다.
	cookielist.forEach(cookie -> {
		cookie.setMaxAge(0);
		response.addCookie(cookie);
	});
}

[After]
private void removeTokenInCookie(HttpServletRequest request, HttpServletResponse response) {
	// request 객체에서 JWT Token이 담긴 Cookie를 List 형태로 가져 온다.
	List cookielist = Arrays.stream(request.getCookies())
			.filter(cookie -> {
				return cookie.getName().equals(JWT_ACCESS_TOKEN) || cookie.getName().equals(JWT_REFRESH_TOKEN);})
			.toList();

	// cookie의 타임 아웃을 0으로 만들고 다시 response 객체에 저장한다.
	cookielist.forEach(cookie -> {
		cookie.setMaxAge(0);
		cookie.setPath("/");
            	cookie.setValue(null);
		response.addCookie(cookie);
	});
}


문제 상황 Controller에서 Redirect 시, 브라우저에서 Redirect된 주소로 이동하지 못함
원인 HTTP 요청은 로드밸런서를 통해 Tomcat으로 전달되고 외부 통신은 HTTPS, 내부 통신은 HTTP를 이용하기 때문에 Controller에서 Redirect 시, Location 헤더에는 "http://~~" 값이 들어가기 때문
해결 내부 통신도 Self-Signed Key를 생성하여 HTTPS 통신을 해도 되지만 HTTP(80)으로 요청 시, HTTPS(443)으로 Redirect 하도록 HAProxy 설정을 추가
[Before]
#---------------------------------------------------------------------
# main frontend which proxys to the backends
#---------------------------------------------------------------------
frontend main
    bind *:443 ssl crt /etc/haproxy/server.pem
    log 127.0.0.1:514 local1
    default_backend             app

#---------------------------------------------------------------------
# round robin balancing between the various backends
#---------------------------------------------------------------------
backend app
    balance     roundrobin
    server  was01 192.168.0.105:8001 check
    server  was02 192.168.0.105:8002 check
    server  was03 192.168.0.105:8003 check

[After]
#---------------------------------------------------------------------
# main frontend which proxys to the backends
#---------------------------------------------------------------------
frontend main
    bind *:80
    bind *:443 ssl crt /etc/haproxy/server.pem
    http-request redirect scheme https unless { ssl_fc }
    log 127.0.0.1:514 local1
    default_backend             app

#---------------------------------------------------------------------
# round robin balancing between the various backends
#---------------------------------------------------------------------
backend app
    balance     roundrobin
    server  was01 192.168.0.105:8001 check
    server  was02 192.168.0.105:8002 check
    server  was03 192.168.0.105:8003 check


류명한
문제 #1 Redis의 캐싱된 좋아요 Count를 갱신할 때 동시성 이슈 발생
원인 get과 set 연산 사이에 다른 스레드의 요청에 의해 값이 변경될 수 있음
해결 redis에서 제공하는 원자성을 보장하는 함수를 사용하여 해결 (incr, dec)
[Before]
@Override
  public void update(long postId, int status) {
    String likeCountKey = cachePrefix + postId;
    Optional maybeCurrentCount = redisServiceProvider.get(likeCountKey);

    int currentCount = maybeCurrentCount.isPresent()
            ? Integer.parseInt(maybeCurrentCount.get())
            : missingLikeRepository.likedCountByPostId(postId);

    int nextCount = status == ADD
            ? addCount(currentCount)
            : subCount(currentCount);

    redisServiceProvider.save(likeCountKey, nextCount);
  }


  private int addCount(int currentCount) {
    return currentCount + 1;
  }

  private int subCount(int currentCount) {
    return currentCount > 0 ? currentCount - 1 : 0;
  }

[After]
  @Override
  public Optional getCountByPostId(long postId) {
    String likeCountKey = cachePrefix + postId;
    Optional maybeCurrentCount = redisServiceProvider.get(likeCountKey);

    Integer currentCount = maybeCurrentCount.isPresent()
            ? Integer.parseInt(maybeCurrentCount.get())
            : null;

    return Optional.ofNullable(currentCount);
  }

  @Override
  public void updateLike(long postId, int status) {
    String likeCountKey = cachePrefix + postId;

    if (status == ADD) {
      redisServiceProvider.increase(likeCountKey); // 함수 내부에서 incr 실행
    } else {
      redisServiceProvider.decrease(likeCountKey); // 함수 내부에서 decr 실행
    }
  }


문제 #2 게시판 목록에서 게시글 별로 좋아요 숫자를 표현하는 로직이 비효율적인 상황
원인 초기 테이블 설계 좋아요 숫자 표현이 고려되지 않음
옵션 좋아요 테이블을 반정규화 vs 미리 집계한 count를 별도의 장소에 캐싱
선택 Redis를 사용하여 게시글 별 좋아요 숫자를 캐싱함
근거 1. 프로젝트에서 이미 Redis를 사용 중이기 때문에, 즉시 사용가능한 상황
2. 테이블 구조를 변경하는 것은 서비스 전반에 영향을 미치기 때문에 개발 후반부에 작업하기에 부적절하다고 판단

이경진
문제 상황 작성 예정
원인 작성 예정
해결 작성 예정
손승범
문제#1 1. 좋아요순으로 게시글 조회시 데이터 정렬을 어플리케이션에서 처리
-> 이에 따른 어플리케이션 과부하 가능성 존재
원인 1. 초기 ERD 설계 시 게시글 좋아요 숫자 반영하지 않음
2. DB에서 조회시 쿼리를 통해 데이터 정렬하지 않음
옵션 좋아요 테이블을 반정규화 vs 각 게시글 별로 좋아요 숫자만큼 그룹화하여 그 크기별로 정렬
선택 각 게시글 별로 좋아요 숫자만큼 그룹화하여 그 크기별로 정렬
근거 개발 일정이 얼마 남지 않은 상황에서 테이블 구조를 변경하는 것은 협업, 코드 수정에 있어서 예측불가의 과업이 생길 수 있다고 판단
[Before]
   public Page findAllByType(String type, Pageable pageable) {
        QReviewPost reviewPost = QReviewPost.reviewPost;
        QMember member = QMember.member;
        QReviewImage reviewImage = QReviewImage.reviewImage;

        BooleanBuilder builder = new BooleanBuilder();
        builder.and(reviewPost.isActive.eq(1));

        JPAQuery query = jpaQueryFactory
                .selectFrom(reviewPost)
                .leftJoin(reviewPost.member, member).fetchJoin()
                .where(builder);

        if ("view".equals(type)) {
            query.orderBy(reviewPost.viewCount.desc());
        } else if ("like".equals(type)) {
            query.groupBy(reviewPost.id)
                    .orderBy(reviewPost.reviewPostLikes.size().desc());
        }

        List content = query
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        long total = jpaQueryFactory
                .selectFrom(reviewPost)
                .leftJoin(reviewPost.member, member)
                .leftJoin(reviewPost.reviewImages, reviewImage)
                .where(builder)
                .fetchCount();

        return new PageImpl<>(content, pageable, total);
    }

[After]
public Page findAllByType(String type, Pageable pageable) {

        BooleanBuilder builder = new BooleanBuilder();
        builder.and(reviewPost.isActive.eq(1));

        JPAQuery query = jpaQueryFactory
                .selectFrom(reviewPost)
                .leftJoin(reviewPost.member, member).fetchJoin()
                .leftJoin(reviewPost.reviewPostLikes, reviewPostLike)
                .where(builder);

        if ("view".equals(type)) {
            query.orderBy(reviewPost.viewCount.desc());
        } else if ("like".equals(type)) {
            query.groupBy(reviewPost.id)
                    .orderBy(reviewPostLike.count().desc());
        }

        List content = query
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        long total = jpaQueryFactory
                .selectFrom(reviewPost)
                .where(builder)
                .fetchCount();

        return new PageImpl<>(content, pageable, total);
    }

문제#2 데이터 중복 로드, N+1로 인한 불필요한 쿼리 발생
원인 N+1 문제 발생을 위해 컬렉션을 패치조인하여 데이터 중복이 발생
해결 1. ToMany를 로드할 때는 Batch 활용
2. 가져와야 할 컬렉션의 양이 제한적이라면 Batch 활용이 아닌 직접 쿼리 작성
3. ToOne을 로드할 때는 Fetch Join 활용
[Before]
     @Query(value = "SELECT p FROM ReviewPost p JOIN FETCH p.member m LEFT JOIN FETCH p.		reviewImages i WHERE p.isActive = 1",
            countQuery = "SELECT count(p.id) FROM ReviewPost p WHERE p.isActive = 1")
    Page findAllPrevious(Pageable pageable);

[After]
 @Query(value = "SELECT p FROM ReviewPost p JOIN FETCH p.member m WHERE p.isActive = 1",
            countQuery = "SELECT count(p.id) FROM ReviewPost p WHERE p.isActive = 1")
    Page findAll(Pageable pageable);


	//댓글의 데이터가 방대할 시에 Batch를 활용해 가져온다면 불필요한 데이터를 로드할 것이라고 판단
	//후에 내부 결정에 따른 limit을 걸 수 있게 쿼리 직접 작성
	public List findAllByPostId(Long reviewPostId){
        QReviewComment reviewComment = QReviewComment.reviewComment;
        QMember member = QMember.member;
        return jpaQueryFactory.selectFrom(reviewComment)
                .leftJoin(reviewComment.member)
                .fetchJoin()
                .leftJoin(reviewComment.parentComment)
                .fetchJoin()
                .where(reviewComment.reviewPost.id.eq(reviewPostId))
                .orderBy(reviewComment.parentComment.id.asc().nullsFirst(), reviewComment.createdAt.desc())
                .fetch();
    }


👩‍💻 리팩토링

박성수
Before 도메인별 Controller에서 사용자 정보 (MemberDto)를 얻기 위해, JWT 토큰에 담긴 클레임을 직접 파싱
After MemberDto에 맞는 ArgumentResolver를 추가하여 Controller에서 직접 파싱하지 않도록 변경 (중복 코드 제거)
@Slf4j
@Component
@RequiredArgsConstructor
public class MemberDtoArgumentResolver implements HandlerMethodArgumentResolver {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasMemberAnnotation = parameter.hasParameterAnnotation(Member.class);
        boolean hasMemberType = MemberDto.class.isAssignableFrom(parameter.getParameterType());

        return hasMemberAnnotation && hasMemberType;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();

        String token = jwtTokenProvider.resolveToken(request, JWT_ACCESS_TOKEN);

        // 기존 쿠키에 JWT Access 토큰이 없는 경우, Request 영역에 저장해둔 newAccessToken을 사용
        if (token == null && request.getAttribute(JWT_ACCESS_TOKEN) != null)
            token = (String) request.getAttribute(JWT_ACCESS_TOKEN);

        // 기존 쿠키에 JWT Access 토큰이 있는 경우, JWT를 파싱하여 MemberDto 객체로 리턴
        if (token != null)
            return jwtTokenProvider.parseToken(token);

        // 없으면 null 값 리턴
        return null;
    }
}


류명한
Before BooleanBuilder와 반복되는 if문을 사용하여 필터를 위한 동적 쿼리 생성
After BooleanExpression을 사용하여 조건문을 제거하고 쿼리를 보다 직관적으로 변경

 [Before]
 @Override
  public Page findByFilter(MissingFilterDto filter, Pageable pageable) {
    QMissingPost qMissing = QMissingPost.missingPost;
    QMissingPostImage qImage = QMissingPostImage.missingPostImage;

    BooleanBuilder builder = new BooleanBuilder();

    builder.and(qMissing.isActive.eq(isActive));

    if (filter.getAnimalType() != null) {
      builder.and(qMissing.animalType.equalsIgnoreCase(filter.getAnimalType()));
    }

    if (filter.getFromDate() != null) {
      builder.and(qMissing.missingTime.goe(filter.getFromDate()));
    }

    if (filter.getEndDate() != null) {
      builder.and(qMissing.missingTime.loe(filter.getEndDate()));
    }

    if (filter.getSearch() != null && !filter.getSearch().isBlank() && !filter.getSearch().isEmpty()) {
      builder.and(qMissing.title.containsIgnoreCase(filter.getSearch()));
    }

    List results = queryFactory
            .selectFrom(qMissing).distinct()
            .where(builder)
            .innerJoin(qMissing.images, qImage)
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .orderBy()
            .fetch();

    long total = queryFactory
            .select(qMissing.missingId.count())
            .where(qMissing.isActive.eq(isActive))
            .from(qMissing)
            .fetchOne();

    return new PageImpl<>(results, pageable, total);
  }


 [After]
 @Override
  public Page findByFilter(MissingFilterDto filter, Pageable pageable) {

    List results = queryFactory
            .selectFrom(qMissing)
            .innerJoin(qMissing.images, qImage).fetchJoin()
            .where(getFilterExpressions(filter))
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .orderBy(qMissing.updatedAt.desc())
            .fetch();

    long total = queryFactory
            .select(qMissing.missingId.count())
            .where(getFilterExpressions(filter))
            .from(qMissing)
            .fetchOne();

    return new PageImpl<>(results, pageable, total);
  }

  private BooleanExpression[] getFilterExpressions(MissingFilterDto filter) {

    return new BooleanExpression[] {
            eqAnimalType(filter.getAnimalType()),
            eqSpecifics(filter.getSpecifics()),
            containKeyword(filter.getSearch()),
            eqColor(filter.getColor()),
            goeFromDate(filter.getFromDate()),
            loeEndDate(filter.getEndDate()),
            eqIsActive(isActive)
    };
  }

  private BooleanExpression eqAnimalType(String animalType) {
    if (StringUtils.isNullOrEmpty(animalType)) {
      return null;
    }
    return qMissing.animalType.equalsIgnoreCase(animalType);
  }

  private BooleanExpression containKeyword(String keyword) {
    if (StringUtils.isNullOrEmpty(keyword)) {
      return null;
    }
    return qMissing.title.containsIgnoreCase(keyword);
  }
	...


이경진
Before 작성 예정
After 작성 예정
손승범
Before 제목, 작성자, 내용에 따른 검색 시에 요구되는 api와 1대1 매핑관계 형성
추가되는 기능에 따른 메소드 증가로 인한 불필요한 코드 증가
After 동적 쿼리를 작성하여 변경, 수정사항이 생길 시에 where절만 수정하게끔 변경

        public ReadListGeneric readBySearch(String type, String keyword, Integer page, int size) {
            switch (type){
                case "view":
                    return readByView(page ,size, keyword);
                case "author":
                    return readByName(page ,size, keyword);
                case "title":
                    return readByTitle(page,size,keyword);
                case "content":
                    return readByContent(page, size, keyword);
            }
            return null;
        }

		// 내용 검색
        @Transactional(readOnly = true)
        private ReadListGeneric readByContent(Integer page, int size, String content) {
            Pageable pageable = createPageByCreatedAt(page,size);
            Page postList = reviewRepository.findAllWithMemberAndImageByContent(content, pageable);
            return entityToDtoByReadAll(postList);
        }

		// 제목 검색
        @Transactional(readOnly = true)
        private ReadListGeneric readByTitle(Integer page, int size, String title) {
            Pageable pageable = createPageByCreatedAt(page,size);
            Page postList = reviewRepository.findAllWithMemberAndImageByTitle(title,pageable);
            return entityToDtoByReadAll(postList);
        }

		//유저 닉네임으로 검색
        @Transactional(readOnly = true)
        private ReadListGeneric readBynickName(Integer page, int size, String nickname) {
            Pageable pageable = createPageByCreatedAt(page,size);
            Page postList = reviewRepository.findAllWithMemberAndImageByNickname(nickname,pageable);
            return entityToDtoByReadAll(postList);
        }


		// 닉네임 검색 쿼리
		@Query(value = "SELECT p FROM ReviewPost p JOIN FETCH p.member m LEFT JOIN FETCH p.reviewImages i WHERE m.nickname = :nickname AND p.isActive = 1",
				countQuery = "SELECT count(p.id) FROM ReviewPost p JOIN p.member m WHERE m.nickname = :nickname AND p.isActive = 1")
		Page findAllWithMemberAndImageByNickname(@Param("nickname") String nickname, Pageable pageable);


		// 제목 검색 쿼리
		@Query(value = "SELECT p FROM ReviewPost p JOIN FETCH p.member m LEFT JOIN FETCH p.reviewImages i WHERE p.title LIKE %:title% AND p.isActive = 1",
				countQuery = "SELECT count(p.id) FROM ReviewPost p WHERE p.title LIKE %:title% AND p.isActive = 1")
		Page findAllWithMemberAndImageByTitle(@Param("title") String title, Pageable pageable);


		//내용 검색 쿼리
		@Query(value = "SELECT p FROM ReviewPost p JOIN FETCH p.member m LEFT JOIN FETCH p.reviewImages i WHERE p.content LIKE %:content% AND p.isActive = 1",
				countQuery = "SELECT count(p.id) FROM ReviewPost p WHERE p.content LIKE %:content% AND p.isActive = 1")
		Page findAllWithMemberAndImageByContent(@Param("content") String content, Pageable pageable);


	// 검색 관련 서비스 로직
    @Transactional(readOnly = true)
    public readList readByKeyword(String type, Integer page, int size, String keyword) {
        Pageable pageable = createPageByCreatedAt(page,size);
        Page postList = reviewPostCustomRepository.findAllWithMemberAndImageByTypeAndKeyword(type, keyword,pageable);
        return entityToDtoByReadAll(postList);
    }


    // 제목, 작성자, 내용 검색 따른 동적 쿼리 작성
    // 투원 관계 - > 패치조인, 투 매니(컬렉션) 관계 -> 배치 활용
    public Page findAllWithMemberAndImageByTypeAndKeyword(String type, String keyword, Pageable pageable) {
        QReviewPost reviewPost = QReviewPost.reviewPost;
        QMember member = QMember.member;

        BooleanBuilder builder = new BooleanBuilder();
        builder.and(reviewPost.isActive.eq(1));

        if(type != null && keyword != null) {
            switch(type) {
                case "author":
                    builder.and(member.nickname.eq(keyword));
                    break;
                case "title":
                    builder.and(reviewPost.title.eq(keyword));
                    break;
                case "content":
                    builder.and(reviewPost.content.contains(keyword));
                    break;
            }
        }
        List content = jpaQueryFactory
                .selectFrom(reviewPost)
                .leftJoin(reviewPost.member, member).fetchJoin()
                .where(builder)
                .orderBy(reviewPost.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();

        long total = jpaQueryFactory
                .selectFrom(reviewPost)
                .where(builder)
                .fetchCount();

        return new PageImpl<>(content, pageable, total);
    }


📋 프로젝트를 진행하며 느낀 점

박성수
  • 프로젝트를 진행하며 현재 나의 위치와 수준을 파악할 수 있었습니다.

  • 리팩토링 과정을 거치게 되면서, 테스트 코드의 중요성을 깨닫게 되었습니다.

류명한
  • 효율적인 협업을 위해서는 커뮤니케이션이 중요하다고 다시 한번 느꼈습니다.

  • 데일리 스크럼이 효과가 있다는 것을 느꼈습니다.

이경진
  • 확장성을 고려한 설계가 중요하다고 생각했습니다. 설계를 하더라도 수정사항이 생기게 되는데 미리 구상하고 설계를 하면 업무가 수월하다는 걸 느꼈습니다.

  • 효율적인 업무를 위해서 커뮤니케이션이 중요하다고 느꼈습니다. 공통의 작업과 일정과 업무를 정할 때도 커뮤니케이션이 원활해야 순조롭게 진행되는 점을 느꼈습니다.

  • 데일리 스크럼을 통하여 작업 진행 사항을 바로 파악하여 조정해보았는데 좋은 경험이었습니다.

손승범
  • 팀의 목표를 기간별로 나누어 수행하는 것이 2가지 측면에서 이점이 있음을 알게되었습니다.
  1. 나의 수준과 역량을 파악할 수 있다.

  2. 프로젝트 진행에 대한 피드백을 빠르게 가질 수 있다.

  • 단순 코드를 치는 것보다 고민을 하는 시간을 가지는 것이 2가지 측면에서 이점이 있다는 것을 배웠습니다.
  1. 미시적 관점보다 거시적 관점에서 해당 로직을 바라볼 수 있다.

  2. 하고 있는 과업에 대해 단계별 접근이 가능하다.

About

새싹 애니멀 커뮤니티입니다.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published