2차 프로젝트(BlueBucket)

유요한·2023년 9월 5일
1

프로젝트

목록 보기
4/5
post-thumbnail

BlueBuket 중고마켓 프로젝트

프로젝트 설명

각 지역에 오프라인 센터가 있다는 가정하에 판매자가 오프라인 센터에 판매자가 물건을 가서 등록을 하면 오프라인 센터가 물건을 검수하여 사이트에 물건에 대한 정보와 이미지를 올려놓으면 구매자는 사이트를 둘러보다 원하는 물건이 있으면 예약을 걸어놓고 현장에서 구매한다. 그러면 관리자는 그 물건을 판매완료를 해줍니다. 당근마켓의 직거래가 일대일관계라면 이 사이트 컨셉은 판매자와 구매자 사이에 껴서 거래를 순조롭게 도와주는 컨셉입니다. 즉 쇼핑몰보다는 중고마켓과 같은 느낌입니다.

중고마켓 컨셉을 고른 이유

처음에 쇼핑몰을 구현할 것인지 중고마켓을 구현할 것인지 고민을 많이 했습니다. 고민 끝에 중고마켓을 선택한 이유는 기존에 쇼핑몰들은 많은 반면에 중고마켓 같은 사이트는 별로 없고 요즘 젊은 사람들은 물건을 고장날 때 까지 사용하는 것이 아니라 사용하다 중고로 판매하고 새로운 제품을 구매하거나 중고로 구매를 많이해서 사용하는데 기존에 있는 중고관련 사이트들은 판매자와 구매자간의 거래를 책임지지 않는 모습들이 있고 일대일 거래에서 문제가 생기는 경우가 많습니다. 그래서 평소에 생각하던 있었으면 좋겠다고 생각하던 중고마켓을 선택하게 되었습니다.


링크

GitHub 주소

코드보러 가기


백엔드 소개

유요한오현진
@YuYoHan@hyeonjin-OH
Back-endBack-end

프론트는 별도로 리드미를 꾸밀거기 때문에 프론트에 대해서는 작성하지 않았습니다.

소통 방법

오프라인으로 모였고 정기적인 시간을 정해서 진행하고 질문이 있거나 긴급히 모여야할 때는 별도로 모여서 회의를 진행하고 수정되어야 하는 부분이 있으면 서로 회의를 거쳐서 피드백을 받고 하는 등 한명의 의견을 무시하지 않고 진행했습니다.

소통하는 도구

  • 노션
    노션링크

    노션으로 회의를 진행한 것을 작성했고 프론트에게 의견을 제시할 때 기록하는 용으로 사용했으며 프론트와 백엔드 파트를 나누는 용으로 사용했습니다. 그리고 배포전 프론트가 API문서화를 볼 수 있게 스샷으로 남기고 후에 배포해서 API문서를 직접 볼 수 있게 했습니다.

  • 디스코드

ERD


역할 분담

회원(유요한)

  • 회원가입
  • 로그인

    JWT 반환

  • OAuth2 로그인

    JWT 반환

  • 로그아웃
  • 마이페이지
  • 회원 수정
  • 회원 탈퇴
  • 주문내역 조회

    회원페이지에서 구매/판매 내역을 조회할 수 있게 한다.

  • 나의 문의 보기

상품문의(유요한)

  • 상품 문의 작성 기능
  • 상품 문의 수정
  • 상품 문의 게시글 삭제
  • 상품 문의 게시글 전체 보기(해당 상품만)
  • 상품 안에 있는 문의글은 게시글 형태

상품(오현진 & 유요한)

  • 상품 등록 (유요한)
  • 상품 수정 (유요한)
  • 상품 삭제 (오현진)
  • 전체 상품 가져오기 (유요한)

    조건에 따라서 상품 검색, 가격 검색, 지역 검색, 그리고 상태에 따라서 을 페이지처리해서 가져옵니다.

장바구니 기능(오현진)

  • 장바구니 기능
    • 유저가 상품을 장바구니에 담는다.
    • 유저가 주문 수량을 조절한다. 하지만 1개인 상품은 조절이 불가능하다.
    • 주문하기를 누른다.
    • Item의 상태가 예약으로 바뀐다.
  • 장바구니 물건 삭제기능
  • 일부삭제 및 전체삭제 구현
  • 장바구니 수량 수정
  • 주문하기
    • 위에서 예약으로 바뀐 상품을 주문(예약)해준다.
    • 주문한 수량에 따라 Item의 stockNumber을 줄여준다.
    • 예약한 상품과 유저를 등록해준다.
    • 1개인 상품을 주문했다고 하면 상품의 상태를 SOLD_OUT바꿔준다.
    • 수량이 남아 있으면 계속 SELL
  • 주문 취소
  • 상품 전체 보기
  • 상품 검색 기능
  • 상품 상세 페이지

이미지 넣기 기능(유요한)

  • S3 이미지 넣기

관리자(유요한 & 오현진)

  • 상품 문의 답변 기능 (유요한)
  • 관리자가 상품 삭제 (유요한)
  • 관리자가 게시글 삭제 (유요한)
  • 관리자가 모든 상품 문의 전체 보기(펼쳐보기) (유요한)
  • 관리자가 특정 유저의 문의글을 가져온다. (유요한)
  • 관리자가 상품을 최종 구매 확정하는 기능 (오현진)
  • 관리자 회원가입

    관리자 회원가입의 경우 이메일에 인증번호를 보내주는데 인증번호를 인증해야 합니다.

  • 관리자가 상품 관리를 위한 전체 상품 가져오기 (유요한)

    조건에 따라서 상품 검색, 가격 검색, 지역 검색, 그리고 상태에 따라서 을 페이지처리해서 가져옵니다.

Swagger(유요한)

  • swagger 기능

예외처리(유요한)

  • 전체 예외처리, 유저를 못 찾을 경우 발생하는 예외 처리
  • 게시글을 못 찾을 경우 발생하는 예외 처리
  • 상품을 못 찾을 경우 발생하는 예외 처리
  • 검증 오류 예외 처리
  • 인증 예외 처리
  • 파일 업로드 예외 처리
  • 파일 다운로드 예외 처리
  • 외부 서비스 예외 처리
  • 서비스 로직 예외 처리
  • JWT 권한 예외 처리
  • JWT 인증 예외 처리

Spring jacoco(유요한)

  • jacoco기능

배포(유요한 & 오현진)

  • EC2
  • RDS
  • Git action
  • S3

ERD(유요한 & 오현진)

  • ERD 작성

발생한 문제

1. S3 오류

발생한 문제를 최우선으로 정리하려고 한다.

S3를 사용에서 이미지를 올리려고 하는데 다음과 같은 오류가 발생했다.

찾아보니 로컬환경에서는 AWS 환경이 아니라 발생한 오류라고 하는데 이 오류는 배포할 때는 발생하지 않지만 로컬에서는 계속 딜레이가 발생하므로 고치고자 합니다.

해결방법

@SpringBootApplication(
        exclude = {
                org.springframework.cloud.aws.autoconfigure.context.ContextInstanceDataAutoConfiguration.class,
                org.springframework.cloud.aws.autoconfigure.context.ContextStackAutoConfiguration.class,
                org.springframework.cloud.aws.autoconfigure.context.ContextRegionProviderAutoConfiguration.class
        }
)
public class ShoppingApplication {

    public static void main(String[] args) {
        SpringApplication.run(ShoppingApplication.class, args);
    }

}

EC2에 배포할 때는 이거를 지워야 합니다.

2. S3config에서 yml을 못가져오는 오류

이건 습관의 무서움을 느꼈습니다. @Value로 yml의 값을 가져와야 하는데 왜 못가져오는지 확인 중 yml은 access_key 이렇게 하고 S3config에서는 accessKey로 작성해서 못가져왔다.

3. 레포지토리 오류

상품과 상품 이미지를 따로 나누어서 사용하는데 레포지토리에서 오류가 발생했습니다.

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'itemImgRepository' defined in com.example.shopping.repository.item.ItemImgRepository defined in @EnableJpaRepositories declared on JpaRepositoriesRegistrar.EnableJpaRepositoriesConfiguration: Invocation of init method failed; nested exception is org.springframework.data.repository.query.QueryCreationException: Could not create query for public abstract java.util.List com.example.shopping.repository.item.ItemImgRepository.findByItemItemIdOrderByItemIdAsc(java.lang.Long); Reason: Failed to create query for method public abstract java.util.List com.example.shopping.repository.item.ItemImgRepository.findByItemItemIdOrderByItemIdAsc(java.lang.Long)! No property 'id' found for type 'ItemEntity'; Traversed path: ItemImgEntity.item; nested exception is java.lang.IllegalArgumentException: Failed to create query for method public abstract java.util.List com.example.shopping.repository.item.ItemImgRepository.findByItemItemIdOrderByItemIdAsc(java.lang.Long)! No property 'id' found for type 'ItemEntity'; Traversed path: ItemImgEntity.item

해당 오류는 id를 참조할 수 없다고 나오는 오류인데 이거를 고치기 위해서

List<ItemImgEntity> findByItemItemIdOrderByItemImgIdAsc(Long itemId);

이렇게 수정했다. ItemImgEntity에 있는 Item엔티티를 참조해서 ItemId를 찾고 ItemImgId를 오름차순으로 List로 담아달라는 뜻이다. 근데 이름이 너무 길어서 가독성이 없다...

List<ItemImgEntity> findByItemItemId(Long itemId)는 Spring Data JPA의 메서드 네이밍 규칙을 따르는 메서드 선언입니다. 이 메서드는 ItemImgEntity 엔티티에서 Item 엔티티의 itemId 필드를 기반으로 데이터를 조회하는데 사용됩니다.

여기서 findByItemItemId라는 메서드 이름은 다음과 같이 해석됩니다:

  • findBy: 데이터를 조회할 때 사용하는 접두사.
  • Item: ItemImgEntity 안에 있는 Item 엔티티를 나타냅니다.
  • ItemId: Item 엔티티 안에 있는 itemId 필드를 나타냅니다.

따라서 이 메서드는 ItemImgEntity 엔티티에서 Item 엔티티의 itemId 필드를 기반으로 데이터를 조회하는 역할을 합니다. 이 메서드를 호출하면 해당 itemId와 일치하는 모든 ItemImgEntity 레코드를 검색하여 리스트로 반환합니다.

4. JWT 생성시 암호화 오류

JWT를 만들어 주려고 하는데 암호화하는데 지속적으로 오류가 발생했다.

ECDSA signing keys must be PrivateKey instances.

해결방법

    private Key key;

    public JwtProvider(
            @Value("${jwt.secret_key}") String secret_key
    ) {
        byte[] secretByteKey = DatatypeConverter.parseBase64Binary(secret_key);
        this.key = Keys.hmacShaKeyFor(secretByteKey);
    }

이거로 수정하니 고쳐지고 잘된다.

5. 소셜 로그인시 JWT 반환

소셜 로그인을 성공하면 JWT를 반환해주는 로직을 작성하려고 했는데 계속 컨트롤러에서 값을 가지고 오는 방식이 실패를 해서 JWT를 반환하지 못했다.

해결방법

package com.example.shopping.config.oauth2;

import com.example.shopping.entity.jwt.TokenEntity;
import com.example.shopping.entity.member.MemberEntity;
import com.example.shopping.repository.jwt.TokenRepository;
import com.example.shopping.repository.member.MemberRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Log4j2
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
   private final MemberRepository memberRepository;
   private final TokenRepository tokenRepository;
   // Jackson ObjectMapper를 주입합니다.
   private final ObjectMapper objectMapper;

   @Override
   public void onAuthenticationSuccess(HttpServletRequest request,
                                       HttpServletResponse response,
                                       Authentication authentication) throws IOException, ServletException {
       try {
           log.info("OAuth2 Login 성공!");
           String email = authentication.getName();
           log.info("email : " + email);
           TokenEntity findToken = tokenRepository.findByMemberEmail(email);
           log.info("token : " + findToken);
           MemberEntity findUser = memberRepository.findByEmail(email);
           // 헤더에 담아준다.
           response.addHeader("email", findToken.getMemberEmail());

           // 바디에 담아준다.
           Map<String, Object> responseBody = new HashMap<>();
           responseBody.put("providerId", findUser.getProviderId());
           responseBody.put("accessToken", findToken.getAccessToken());
           responseBody.put("refreshToken", findToken.getRefreshToken());
           responseBody.put("email", findToken.getMemberEmail());

           // JSON 응답 전송
           response.setContentType("application/json");
           response.setCharacterEncoding("UTF-8");
           response.getWriter().write(objectMapper.writeValueAsString(responseBody));
       } catch (Exception e) {
           // 예외가 발생하면 클라이언트에게 오류 응답을 반환
           response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
           response.getWriter().write("OAuth 2.0 로그인 성공 후 오류 발생: " + e.getMessage());
           response.getWriter().flush();
       }
   }
}
        http
                // oauth2Login() 메서드는 OAuth 2.0 프로토콜을 사용하여 소셜 로그인을 처리하는 기능을 제공합니다.
                .oauth2Login()
                // OAuth2 로그인 성공 이후 사용자 정보를 가져올 때 설정 담당
                .userInfoEndpoint()
                // OAuth2 로그인 성공 시, 후작업을 진행할 서비스
                .userService(principalOAuth2UserService)
                .and()
                .successHandler(oAuth2SuccessHandler);

이렇게 하니 소셜 로그인을 성공하면 바디에 토큰을 담아서 줄 수 있게 되었다.

6. 회원 수정 오류

발생한 이유는 passwordEncoder.encode가 수정시에 회원이 수정안할 때가 있는데 null로 오면 오류를 발생했다. 그래서 다음과 같이 수정해서 고침

  // 회원정보 수정
   @Override
   public ResponseEntity<?> updateUser(Long memberId, ModifyMemberDTO modifyMemberDTO, String memberEmail) {
       try {
           // 회원조회
           MemberEntity findUser = memberRepository.findByEmail(memberEmail);
           log.info("user : " + findUser);

           findUser = MemberEntity.builder()
                   .memberId(findUser.getMemberId())
                   .email(findUser.getEmail())
                   .memberPw(
                           modifyMemberDTO.getMemberPw() == null
                                   ? findUser.getMemberPw()
                                   : passwordEncoder.encode(modifyMemberDTO.getMemberPw()))
                   .nickName(modifyMemberDTO.getNickName() == null
                           ? findUser.getNickName() : modifyMemberDTO.getNickName())
                   .memberRole(findUser.getMemberRole())
                   .memberPoint(findUser.getMemberPoint())
                   .memberName(findUser.getMemberName())
                   .address(AddressEntity.builder()
                           .memberAddr(modifyMemberDTO.getMemberAddress() != null && modifyMemberDTO.getMemberAddress().getMemberAddr() != null
                                   ? modifyMemberDTO.getMemberAddress().getMemberAddr()
                                   : findUser.getAddress().getMemberAddr())
                           .memberAddrDetail(modifyMemberDTO.getMemberAddress() != null && modifyMemberDTO.getMemberAddress().getMemberAddrDetail() != null
                                   ? modifyMemberDTO.getMemberAddress().getMemberAddrDetail()
                                   : findUser.getAddress().getMemberAddrDetail())
                           .memberZipCode(modifyMemberDTO.getMemberAddress() != null && modifyMemberDTO.getMemberAddress().getMemberZipCode() != null
                                   ? modifyMemberDTO.getMemberAddress().getMemberZipCode()
                                   : findUser.getAddress().getMemberZipCode())
                           .build()).build();
           log.info("유저 수정 : " + findUser);

           MemberEntity updateUser = memberRepository.save(findUser);
           ResponseMemberDTO toResponseMemberDTO = ResponseMemberDTO.toMemberDTO(updateUser);
           return ResponseEntity.ok().body(toResponseMemberDTO);

       } catch (EntityNotFoundException e) {
           return ResponseEntity.status(HttpStatus.NOT_FOUND).body("회원 정보가 없습니다.");
       }
   }

주소에서 이중으로 null체크하여 하나라도 null이면 기존의 주소를 가져오고 아니면 수정된 주소를 입력하며 닉네임도 같은 방식이다.

7. 상품 등록시 오류

발생한 오류는 400번 에러 발생했다.

해당 코드는 이렇게 되어 있는데

   // 상품 등록
    @PostMapping("")
    @PreAuthorize("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
    @Tag(name = "item")
    @Operation(summary = "상품 등록", description = "상품을 등록하는 API입니다.")
    public ResponseEntity<?> createItem(@RequestPart("key") CreateItemDTO item,
                                        @RequestPart(value = "files", required = false)List<MultipartFile>itemFiles,
                                        BindingResult result
                                        ,@AuthenticationPrincipal UserDetails userDetails
    ){
        try {
            if(result.hasErrors()) {
                log.error("bindingResult error : " + result.hasErrors());
                return ResponseEntity.badRequest().body(result.getClass().getSimpleName());
            }

                ItemDTO itemInfo = ItemDTO.builder()
                        .itemName(item.getItemName())
                        .price(item.getPrice())
                        .itemDetail(item.getItemDetail())
                        .stockNumber(item.getStockNumber())
                        .sellPlace(item.getSellPlace())
                        .itemSellStatus(ItemSellStatus.SELL)
                        .itemReserver(null)
                        .itemRamount(0)
                        .build();

            String email = userDetails.getUsername();
            ItemDTO savedItem = itemServiceImpl.saveItem(itemInfo, itemFiles, email);
            //testData
            //ItemDTO savedItem = itemServiceImpl.saveItem(itemInfo, itemFiles, "mem123@test.com");
            return ResponseEntity.ok().body(savedItem);
        } catch (Exception e) {
            return ResponseEntity.badRequest().body(e.getMessage());
        }
    }

이거를 보낼 때 files도 key는 넣어야 하는 것을 몰랐다. 그래서 key를 빼고 해서 에러가 발생했던 것이다.

8. Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed; nested exception is java.lang.StackOverflowError] with root cause

두개의 객체에서 @ToString을 사용해서 무한 순환 참조가 발생한 오류였다. 이 오류가 발생하는 이유는 메소드 호출 스택이 너무 깊게 들어가서 스택 메모리를 초과했을 때 발생하는 오류입니다. 이 오류는 주로 재귀 호출이 무한으로 이어지거나 메소드가 스택에 계속해서 쌓이는 경우에 발생합니다.

@ToString 애너테이션은 Lombok 프로젝트에서 제공하는 애너테이션 중 하나로, 해당 클래스의 toString() 메소드를 자동으로 생성해주는 기능을 제공합니다. 그러나 이 애너테이션을 사용할 때 주의해야 할 사항이 있습니다.

만약 클래스 간에 서로 참조하는 관계가 있다면, toString() 메소드에서 이를 출력하려고 할 때 무한 루프에 빠질 수 있습니다. 예를 들어, 클래스 A가 클래스 B를 참조하고, 클래스 B가 다시 클래스 A를 참조하는 경우가 있을 때, 두 클래스에 @ToString 애너테이션이 적용되면 서로를 계속해서 출력하려고 하다가 스택 오버플로우가 발생할 수 있습니다.

따라서 엔티티에 @ToString을 사용하는 것을 지양하거나 사용해야 한다면 exclude를 추가하여 사용하자.

@Entity
@ToString(exclude={"제외할 필드", ...})
public Entity {...}

9. 회원 수정 & 삭제 문제

수정하면서 조건문을 빼먹어서 아무나 수정, 삭제가 가능한 상황이라서 조건문을 수정하고 고쳤다.

 // 회원 삭제
    @Override
    public String removeUser(Long memberId, String email) {
        // 회원 조회
        MemberEntity findUser = memberRepository.findByEmail(email);
        log.info("email check : " + email);
        log.info("email check2 : " + findUser.getEmail());

        // 회원이 비어있지 않고 넘어온 id가 DB에 등록된 id가 일치할 때
        if (findUser.getMemberId().equals(memberId)) {
            memberRepository.deleteByMemberId(memberId);
            return "회원 탈퇴 완료";
        } else {
            return "해당 유저가 아니라 삭제할 수 없습니다.";
        }
    }

    // 로그인
    @Override
    public ResponseEntity<?> login(String memberEmail, String memberPw) {
        try {
            // 회원 조회
            MemberEntity findUser = memberRepository.findByEmail(memberEmail);
            log.info("user : " + findUser);

            if (findUser != null) {
                // DB에 넣어져 있는 비밀번호는 암호화가 되어 있어서 비교하는 기능을 사용해야 합니다.
                // 사용자가 입력한 패스워드를 암호화하여 사용자 정보와 비교
                if (passwordEncoder.matches(memberPw, findUser.getMemberPw())) {
                    Authentication authentication = new UsernamePasswordAuthenticationToken(memberEmail, memberPw);
                    log.info("authentication : " + authentication);
                    List<GrantedAuthority> authoritiesForUser = getAuthoritiesForUser(findUser);

                    // JWT 생성
                    TokenDTO token = jwtProvider.createToken(authentication, authoritiesForUser, findUser.getMemberId());
                    // 토큰 조회
                    TokenEntity findToken = tokenRepository.findByMemberEmail(token.getMemberEmail());

                    // 토큰이 없다면 새로 발급
                    if (findToken == null) {
                        log.info("발급한 토큰이 없습니다. 새로운 토큰을 발급합니다.");

                        // 토큰 생성과 조회한 memberId를 넘겨줌
                        TokenEntity tokenEntity = TokenEntity.tokenEntity(token);
                        // 토큰id는 자동생성
                        tokenRepository.save(tokenEntity);
                    } else {
                        log.info("이미 발급한 토큰이 있습니다. 토큰을 업데이트합니다.");
                        token = TokenDTO.builder()
                                .grantType(token.getGrantType())
                                .accessToken(token.getAccessToken())
                                .accessTokenTime(token.getAccessTokenTime())
                                .refreshToken(token.getRefreshToken())
                                .refreshTokenTime(token.getRefreshTokenTime())
                                .memberEmail(token.getMemberEmail())
                                .memberId(token.getMemberId())
                                .build();
                        // 이미 존재하는 토큰이니 토큰id가 있다.
                        // 그 id로 토큰을 업데이트 시켜준다.
                        TokenEntity tokenEntity = TokenEntity.updateToken(findToken.getId(), token);
                        tokenRepository.save(tokenEntity);
                    }
                    return ResponseEntity.ok().body(token);
                } else {
                    return ResponseEntity.badRequest().build();
                }
            } else {
                return ResponseEntity.status(HttpStatus.NOT_FOUND).body("유저가 없습니다.");
            }
        } catch (Exception e) {
            return ResponseEntity.badRequest().build();
        }
    }

10. 댓글 등록시 N+1문제

댓글 등록시 N+1문제가 발생했다. 문제의 원인은 잘못이해하고 사용해서 그렇다.

              // 게시글 엔티티안에 있는 댓글 리스트에 추가
                findBoard.getCommentEntityList().add(comment);
                log.info("댓글 : " + comment);
                // DB에 저장
                // 게시글과 연관관계를 맺었기 때문에 게시글만 저장해도 댓글이 저장된다.
                BoardEntity saveBoard = boardRepository.save(findBoard);
                log.info("board : " + saveBoard);
@OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true)
   @OrderBy("commentId asc ")
   private List<CommentEntity> commentEntityList = new ArrayList<>();
   
      @ManyToOne(fetch = FetchType.LAZY)
   @JoinColumn(name = "board_id")
   private BoardEntity board;

각각의 문의글 엔티티와 댓글 엔티티를 연관관계를 맺었고 문의글 엔티티에
cascade = CascadeType.ALL을 넣었기 때문에 등록할 때 문의글만 저장해도 리스트에 올라가는데 리스트에도 올려주고 저장도 했기때문에 N+1문제 발생

해결

  // 댓글 등록
   @Override
   public ResponseEntity<?> save(Long boardId,
                                 ModifyCommentDTO commentDTO,
                                 String memberEmail) {
       try {
           // 회원 조회
           MemberEntity findUser = memberRepository.findByEmail(memberEmail);
           log.info("유저 : " + findUser);
           // 게시글 조회
           BoardEntity findBoard = boardRepository.findById(boardId)
                   .orElseThrow(EntityNotFoundException::new);
           log.info("게시글 : {}", findBoard);

           if(findUser != null) {
               // 댓글 생성
               CommentEntity comment = CommentEntity.createComment(commentDTO, findUser, findBoard);

               CommentEntity saveComment = commentRepository.save(comment);
               CommentDTO returnComment = CommentDTO.toCommentDTO(saveComment);
               log.info("댓글 : " + returnComment);

               return ResponseEntity.ok().body(returnComment);
           } else {
               return ResponseEntity.status(HttpStatus.NOT_FOUND).body("회원이 없습니다.");
           }
       } catch (Exception e) {
           return ResponseEntity.badRequest().build();
       }
   }

11. 게시글 수정 에러

발생한 에러

"message": "A collection with cascade=\"all-delete-orphan\" was no longer referenced by the owning entity instance: com.example.shopping.entity.board.BoardEntity.commentEntityList",
        "suppressed": [],
        "localizedMessage": "A collection with cascade=\"all-delete-orphan\" was no longer referenced by the owning entity instance: com.example.shopping.entity.board.BoardEntity.commentEntityList"
    },
    "suppressed": [],
    "localizedMessage": "A collection with cascade=\"all-delete-orphan\" was no longer referenced by the owning entity instance: com.example.shopping.entity.board.BoardEntity.commentEntityList; nested exception is org.hibernate.HibernateException: A collection with cascade=\"all-delete-orphan\" was no longer referenced by the owning entity instance: com.example.shopping.entity.board.BoardEntity.commentEntityList"

문제의 원인은 BoardEntity와 CommentEntity 사이의 양방향 관계 설정에서 발생하고 있습니다. 현재의 설정에서는 @OneToMany 어노테이션에 cascade = CascadeType.ALL와 orphanRemoval = true가 적용되어 있습니다. 이로 인해 게시글을 저장할 때 댓글들도 함께 저장되며, 게시글 엔티티와 댓글 엔티티 사이의 연관관계가 맺어집니다.

문제는 게시글을 업데이트할 때 해당 게시글의 댓글 리스트가 변경되었음에도 불구하고 Hibernate가 이를 감지하지 못하고 있다는 것입니다. 게시글과 댓글 간의 연관관계에서 댓글 엔티티들이 올바르게 관리되지 않았을 때 발생할 수 있는 문제입니다.

문제 해결
정말 간단한 실수였습니다.

  findBoard = BoardEntity.builder()
                        .boardId(findBoard.getBoardId())
                        .title(boardDTO.getTitle() != null ? boardDTO.getTitle() : findBoard.getTitle())
                        .content(boardDTO.getContent() != null ? boardDTO.getContent() : findBoard.getContent())
                        .item(findBoard.getItem())
                        .member(findBoard.getMember())
                        .boardSecret(BoardSecret.UN_LOCK)
                        .commentEntityList(findBoard.getCommentEntityList())
                        .build();

여기서 빌드처리를 하는데 댓글 리스트부분을 빼먹은 거였습니다.
하루종일 각종 디버깅과 로그로 테스트해봤는데 엄청 사소한 실수였습니다.

12. fetch join시 페이지 처리 문제

fetch join과 페이지 처리를 같이 사용하려고 하니 문제가 있었습니다.

페이징은 count로 게시글이 몇개인지 찾고 그에 맞는 데이터 즉, 10개만 찾아오면 10개만 찾아주는 역할을 하는데 fetch join은 count가 아니라 필요한 것을 모두 데이터로 가져오는 것이기 때문에 에러가 발생합니다. CountQuery를 정상적으로 자동으로 만들어주지 못한다.
아마 그래서 발생하는 문제인 것같다.

해결방법

 @Query(value = "select b from board b" +
            " join fetch b.member " +
            " join fetch b.item " +
            " order by b.boardId DESC ",
            countQuery = "select count(b) from board b")
    Page<BoardEntity> findAll(Pageable pageable);

  @Query(value = "select  b from board  b " +
            " join fetch b.member " +
            " join fetch b.item " +
            " where b.member.email = :email and b.title like %:searchKeyword%" +
            " order by b.boardId DESC ",
            countQuery = "select count(b) from board b " +
                    "where b.member.email = :email and b.title like %:searchKeyword%")
    Page<BoardEntity> findByMemberEmailAndTitleContaining(@Param("email") String email,
                                                          Pageable pageable,
                                                          @Param("searchKeyword") String searchKeyword);

13. swagger와 actuator 같이사용하면 에러 발생

caused by: java.lang.nullpointerexception: null이 발생하며 프로젝트가 실행되지 않았다. Swagger는 모든 endpoint에 대해 documentation 해주는 역할이고 Actuator는 몇 몇 endpoint를 직접 생성해서 노출시켜주는 역할이니 이 두 의존성이 충돌되는 것으로 판단했습니다.

해결방법
SwaggerConfig에 추가

   @Bean
   public WebMvcEndpointHandlerMapping webEndpointServletHandlerMapping(
           // 웹 엔드포인트
           WebEndpointsSupplier webEndpointsSupplier,
           // 서블릿 엔드포인트
           ServletEndpointsSupplier servletEndpointsSupplier,
           // 컨트롤러 엔드포인트
           ControllerEndpointsSupplier controllerEndpointsSupplier,
           // 엔드포인트의 미디어 타입
           EndpointMediaTypes endpointMediaTypes,
           // CORS 설정
           CorsEndpointProperties corsProperties,
           // 웹 엔드포인트 설정
           WebEndpointProperties webEndpointProperties,
           // 환경과 관련된 빈이나 속성
           Environment environment) {
       List<ExposableEndpoint<?>> allEndpoints = new ArrayList();
       Collection<ExposableWebEndpoint> webEndpoints = webEndpointsSupplier.getEndpoints();
       allEndpoints.addAll(webEndpoints);
       allEndpoints.addAll(servletEndpointsSupplier.getEndpoints());
       allEndpoints.addAll(controllerEndpointsSupplier.getEndpoints());
       String basePath = webEndpointProperties.getBasePath();
       EndpointMapping endpointMapping = new EndpointMapping(basePath);
       boolean shouldRegisterLinksMapping = this.shouldRegisterLinksMapping(webEndpointProperties, environment, basePath);
       return new WebMvcEndpointHandlerMapping(endpointMapping, webEndpoints, endpointMediaTypes, corsProperties.toCorsConfiguration(), new EndpointLinksResolver(allEndpoints, basePath), shouldRegisterLinksMapping, null);
   }

14. could not execute statement; SQL [n/a]; constraint [null]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement

발생 이유
유저를 회원 탈퇴하려고 할 때 다른 엔티티에서 연관관계를 맺고 있어서 SQL상으로는 외래 키(FK)로 이어져 있어서 무결성이 깨져서 이런 오류가 발생

해결 방법1
유저와 연관된 엔티티를 양방향으로 묶어주고 cascade로 해줘서 JPA에서 회원이 삭제되면 연관된 엔티티를 삭제하게 한다.

해결 방법2
유저와 연관된 엔티티를 단방향으로 처리하고 각각의 엔티티를 삭제해준다.

저같은 경우는 방법2를 사용했습니다. 그 이유는 양방향은 필요한 경우를 제외하고는 단방향으로 처리하고자 했기 때문입니다. 현재 프로젝트에서는 유저가 양방향 연관관계를 맺을 필요가 없기 때문에 단방향으로 처리했습니다.

    @Override
    public String removeUser(Long memberId, String email) {
        // 회원 조회
        MemberEntity findUser = memberRepository.findByEmail(email);
        log.info("email check : " + email);
        log.info("email check2 : " + findUser.getEmail());

        // 회원이 비어있지 않고 넘어온 id가 DB에 등록된 id가 일치할 때
        if (findUser.getMemberId().equals(memberId)) {
            boardRepository.deleteAllByMemberMemberId(memberId);
            commentRepository.deleteAllByMemberMemberId(memberId);
            cartRepository.deleteAllByMemberMemberId(memberId);
            memberRepository.deleteByMemberId(memberId);
            return "회원 탈퇴 완료";
        } else {
            return "해당 유저가 아니라 삭제할 수 없습니다.";
        }
    }

서로 레포지토리도 다르고, 주문, 회원 각각의 엔티티가 별도로 중요한 엔티티이기 때문에 연관된 엔티티를 먼저 삭제해주고 유저 엔티티를 삭제 해주었습니다.


API 기능


프로젝트 세부 진행

프로젝트 파악하기
여기서는 프로젝트를 코드와 설명으로 어떻게 진행하고 왜 그렇게 했는지 설명하면서 진행할 생각입니다. 이곳에는 프로젝트를 진행하면서 만났던 문제들도 기록하고 있습니다.

유저 기능

메인페이지(비로그인) & 검색

검색은 처음에는 상품 이름으로 검색하고 그 후에 상세 검색을 통해서 가격, 지역을 설정해서 검색할 수 있습니다.

회원가입

로그인

메인 페이지(로그인 - 유저)

장바구니

장바구니 등록

장바구니

수정

삭제

예약중인 상품 처리

마이 페이지

여기서는 본인이 여태 구매한 이력을 볼 수 있고 본인이 무슨 문의글을 작성했는지 볼 수 있고 답변이 달렸는지 확인할 수 있습니다. 문의글을 클릭하면 해당 문의글을 수정할 수 있고 상품 정보를 클릭하면 해당 문의글을 작성한 상품으로 이동합니다.

회원정보 수정

회원탈퇴

문의글

문의글 등록 & 수정 & 삭제

관리자 기능

메인 페이지(로그인 - 관리자)

상품

상품 등록

상품 상세 페이지

상품 수정

상품 삭제

상품 주문

상품 관리

문의 관리

마이페이지

사용한 기술 및 라이브러리

사용기술배포협업사용 IDE

프로젝트

후기

중고마켓 프로젝트를 진행하면서 느낀 점은 1차 프로젝트보다 더 많은 학습이 프로젝트를 진행하는데 큰 도움이 되었습니다. 에러가 발생했을 때 구글 검색 등을 통해 문제를 해결하는 과정에서 어떻게 해결해야 하는지 빠르게 감이 왔습니다. 에러 코드를 보는 것에 익숙해졌고, 문제를 해결할 때 "왜 안될까?"라는 고민을 하고 여러 방식으로 시도해보며 문제를 해결하는 과정에서 코드는 상황에 따라 달라질 수 있다는 것을 깨달았습니다.

1차 프로젝트는 단순한 MyBatis를 사용한 블로그 형식의 프로젝트였다면, 2차 프로젝트는 평소에 생각했던 웹사이트를 구현했습니다. 리더로서 프로젝트를 기획할 때 팀원들에게 컨셉을 명확히 설명하고 "왜" 이 프로젝트를 진행하려 하는지 구체적으로 전달했습니다.

JPA를 사용한 경험에서 느낀 점은 편리함이었습니다. JPA를 사용하면 데이터베이스 설계에 의존하지 않고 객체지향적 설계를 할 수 있으며, 어떤 DB를 사용하더라도 동일한 코드를 사용할 수 있어 의존성을 낮출 수 있었습니다. 엔티티로 테이블을 파악하는 것이 개발과 유지보수에 더 편리했습니다. 이번 프로젝트에서는 보안 강화를 위해 시큐리티, 소셜 로그인(Google, Naver), JWT를 사용했습니다. 이전 프로젝트에서 느낀 부족함을 채우기 위한 선택이었고, 기술의 발전에 따라 변화하는 개발 환경에 대응하기 위한 의도도 있었습니다.

프로젝트를 통해 배운 것 중 가장 큰 교훈은 소통의 중요성입니다. 미리 배포하고 Swagger로 문서화하여 팀원과 프론트와의 소통을 강화하고, 노션을 활용하여 회의를 정리하고 문서화하여 팀원 간의 파악을 증진시켰습니다. 의문이 생길 때는 적극적으로 디스코드 회의를 통해 의논하고 해결책을 찾아 나가는 과정에서 더욱 성장했습니다. 그리고 배운 것은 지속적인 공부의 필요성입니다. 1차 프로젝트를 하고 2차 프로젝트를 진행하면서 발전된 기술을 사용함에 따라 코드가 간결해지고 가독성이 높아지고 편리성이 높아질 수 있다는 것을 다시 한번 깨닫게 되었습니다.

힘들었던 점

힘들었던 점은 처음에 프로젝트를 시작할 때 팀원 부분이였습니다. 팀원을 구해서 프로젝트를 시작했는데 프로젝트에 참여하겠다던 팀원이 도망가서 다시 팀원을 구하니라 딜레이가 많이 걸렸던점입니다.

profile
발전하기 위한 공부

0개의 댓글