배달의 정석: 광고 기능 개발 및 트러블슈팅 과 마무리

이상민·2024년 9월 25일
0

이번 팀 프로젝트에서 저번 사용자 인증 기능에 이어서 가게 광고 기능을 개발했다. 이 기능을 통해 관리자는 특정 가게를 광고로 지정할 수 있고, 광고로 지정된 가게는 검색 결과 상단에 노출된다. 팀원들과 함께 광고 기능을 설계하고, 관리자만 광고를 생성하고 수정할 수 있도록 권한 검증 기능도 구현했다. 기능을 구현하는 과정에서 여러 문제가 발생했지만, 하나씩 해결하면서 프로젝트의 완성도를 높일 수 있었다.

0. 통합 예외 처리

팀 회의 과정에서 팀원분들 중 한분이 이렇게 에러 상태를 관리하는 방법도 있다고 한번 우리 팀프로젝트에서 활용해보는게 어떻냐고 의견을 내셔서 ApiException 을 직접 만들어서 관리하는 방식으로 예외 처리를 진행하였다.

ApiException

@Getter
@RequiredArgsConstructor
public class ApiException extends RuntimeException {

    private final BaseCode errorCode;
}

ErrorStatus


@Getter
@AllArgsConstructor
public enum ErrorStatus implements BaseCode {

    // auth
    _NOT_FOUND_USER(HttpStatus.NOT_FOUND, "404", "존재하지 않은 유저입니다"),
    _BAD_REQUEST_EMAIL(HttpStatus.BAD_REQUEST, "400", "이미 존재하는 이메일입니다"),
    _BAD_REQUEST_PASSWORD(HttpStatus.BAD_REQUEST, "400", "비밀번호가 일치하지 않습니다."),

    // shop
    _BAD_REQUEST_CREATE_SHOP(HttpStatus.BAD_REQUEST, "400", "사장님 계정만 가게 생성이 가능합니다."),
    _BAD_REQUEST_UPDATE_SHOP(HttpStatus.BAD_REQUEST, "400", "본인 가게만 수정이 가능합니다."),
    _NOT_FOUND_SHOP(HttpStatus.NOT_FOUND, "404", "존재하지 않은 가게입니다."),

    // menu
    _NOT_FOUND_MENU(HttpStatus.NOT_FOUND, "404", "존재하지 않는 메뉴입니다."),
    _FILE_UPLOAD_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "500", "내부 서버 오류로 이미지를 업로드 할 수 없습니다."),

    // cart
    _NOT_FOUND_CART(HttpStatus.NOT_FOUND, "404", "존재하지 않는 장바구니입니다."),

    // order
    _BAD_REQUEST_ORDER_AMOUNT(HttpStatus.BAD_REQUEST, "400", "최소 주문금액을 넘지 않습니다."),
    _BAD_REQUEST_ORDER_TIME(HttpStatus.BAD_REQUEST, "400", "가게 주문 시간이 아닙니다."),
    _INVALID_PAYMENT_METHOD(HttpStatus.BAD_REQUEST, "400", "유효하지 않은 결제 방법입니다."),
    _INVALID_ORDER_STATUS(HttpStatus.BAD_REQUEST, "400", "유효하지 않은 주문 상태입니다."),
    _BAD_REQUEST_UPDATE_STATUS(HttpStatus.BAD_REQUEST, "400", "사장님 계정만 주문 상태 변경이 가능합니다."),
    _NOT_FOUND_ORDER_LIST(HttpStatus.BAD_REQUEST, "404", "주문 목록이 존재하지 않습니다."),
    _NOT_FOUND_ORDER_MENU_LIST(HttpStatus.BAD_REQUEST, "404", "주문-메뉴 목록이 존재하지 않습니다."),
    _NOT_FOUND_POINT_HISTORY(HttpStatus.BAD_REQUEST, "404", "포인트 기록이 존재하지 않습니다."),


    // review
    _NOT_FOUND_REVIEW(HttpStatus.NOT_FOUND, "404", "존재하지 않는 리뷰입니다."),
    _NOT_FOUND_ORDER(HttpStatus.NOT_FOUND, "404", "존재하지 않는 주문입니다."),
    _BAD_REQUEST_UPDATE_REVIEW(HttpStatus.BAD_REQUEST, "400", "본인의 리뷰만 수정할 수 있습니다."),

    // token
    _NOT_FOUND_TOKEN(HttpStatus.NOT_FOUND, "404", "CICD 테스트!!!!! JWT 토큰이 필요합니다."),
    _BAD_REQUEST_TOKEN(HttpStatus.BAD_REQUEST, "400", "잘못된 JWT 토큰입니다"),
    _INVALID_TOKEN(HttpStatus.BAD_REQUEST, "400", "유효하지 않은 토큰입니다"),
    _EXPIRED_TOKEN(HttpStatus.BAD_REQUEST, "400", "만료 토큰입니다"),
    _UNSUPPORTED_TOKEN(HttpStatus.BAD_REQUEST, "400", "지원하지 않는 토큰입니다"),
    _EXCEPTION_ERROR_TOKEN(HttpStatus.BAD_REQUEST, "400", "토큰 검증 중 오류가 발생했습니다."),
    _INVALID_USER_ROLE(HttpStatus.BAD_REQUEST, "400", "유효하지 않은 User Role"),

    // ad
    _FORBIDDEN(HttpStatus.FORBIDDEN, "403", "접근이 금지되었습니다. 접근 권한이 없습니다."),
    _NOT_FOUND_AD(HttpStatus.NOT_FOUND, "404", "광고를 찾을 수 없습니다.");

    private HttpStatus httpStatus;
    private String statusCode;
    private String message;

    @Override
    public ReasonDto getReasonHttpStatus() {
        return ReasonDto.builder()
                .statusCode(statusCode)
                .message(message)
                .httpStatus(httpStatus)
                .success(false)
                .build();
    }
}

이렇게 ErrorStatus를 만들어서 커스텀 예외 처리를 관리하고 ApiResponse를 반환해준다

ApiResponse

@Getter
@AllArgsConstructor
@JsonPropertyOrder({"success", "statusCode", "message", "data"})
public class ApiResponse<T> {

    @JsonProperty("success")
    private final Boolean success;

    private final String statusCode;

    private final String message;

    @JsonInclude(JsonInclude.Include.NON_NULL)
    private final T data;

    public static <T> ApiResponse<T> onSuccess(T data) {
        return new ApiResponse<>(true, SuccessStatus._OK.getStatusCode(), SuccessStatus._OK.getMessage(), data);
    }

    public static ApiResponse<String> onFailure(BaseCode errorCode) {
        return new ApiResponse<>(false, errorCode.getReasonHttpStatus().getStatusCode(), errorCode.getReasonHttpStatus().getMessage(), "null");
    }
}

1. 광고 기능 요구사항

우리 팀에서 논의한 결과, 광고 기능의 핵심 요구사항은 다음과 같다:

  • 광고 생성: 관리자는 가게를 광고로 지정할 수 있어야 한다. 광고에는 시작일, 종료일, 상태(활성/비활성)를 설정할 수 있다.

  • 광고 수정: 광고의 상태(활성/비활성)만 수정할 수 있다. 가게 정보는 수정하지 않는다.

  • 광고 가게 상단 노출: 광고로 지정된 가게는 검색 결과에서 상단에 노출된다.

  • 관리자 권한 검증: 광고 기능은 관리자만 사용할 수 있어야 한다.
    이 요구사항에 맞춰 개발을 진행했고, 각 기능을 구현할 때 겪은 문제와 해결 과정을 공유한다.

2. 광고 생성 기능 구현

팀 프로젝트에서 내가 맡은 부분중 하나인 광고 생성 기능을 구현해보았다. 광고는 관리자 권한을 가진 사용자가 특정 가게를 광고로 지정할 수 있으며, 광고 상태(활성/비활성), 시작일, 종료일을 설정할 수 있도록 설계했다.

광고 생성 로직

@Transactional
public AdResponse createAd(AuthUser authUser, AdSaveRequest adSaveRequest) {
    validateAdminRole(authUser);  // 관리자 권한 검증

    Shop shop = shopRepository.findById(adSaveRequest.getShopId()).orElseThrow(
        () -> new ApiException(ErrorStatus._NOT_FOUND_SHOP)
    );  // 가게 조회

    Ads ads = new Ads(shop.getId(), adSaveRequest.getStartDate(), adSaveRequest.getEndDate(), adSaveRequest.isStatus());
    Ads savedAd = adRepository.save(ads);  // 광고 생성 및 저장

    return new AdResponse(
        savedAd.getId(),
        shop.getId(),
        shop.getName(),
        "광고 생성",
        savedAd.isStatus() ? "활성" : "비활성",
        savedAd.getStartDate(),
        savedAd.getEndDate()
    );  // 응답 반환
}

광고 생성 로직 설명

1. 관리자 권한 검증: 팀에서 관리자 권한이 없으면 광고 기능을 사용하지 못하도록 설계했다. validateAdminRole() 메서드를 통해 관리자인지 확인하고, 관리자가 아니면 예외를 발생시킨다.

2. 가게 조회: 광고를 생성할 가게를 shopId로 조회하고, 가게가 존재하지 않으면 예외를 발생시킨다.

3. 광고 생성: 광고 객체를 생성하고, 광고의 시작일, 종료일, 광고 상태(활성/비활성)를 설정한다.

4. 응답 반환: 생성된 광고 정보를 AdResponse DTO로 반환한다. 광고 ID, 가게 ID, 가게 이름, 광고 상태 등이 포함된다.

3. 광고 수정 기능 구현

광고 수정 기능은 팀에서 요청한 사항대로, 광고의 상태(활성/비활성)만 변경할 수 있도록 제한했다. 가게 정보는 수정하지 않고, 광고가 활성화된 상태인지 비활성화된 상태인지만 관리할 수 있다.

광고 수정 로직

@Transactional
public AdChangeResponse updateAd(AuthUser authUser, Long adId, AdChangeRequest adChangeRequest) {
    validateAdminRole(authUser);  // 관리자 권한 검증

    Ads ads = adRepository.findById(adId).orElseThrow(
        () -> new ApiException(ErrorStatus._NOT_FOUND_AD)
    );  // 광고 조회

    ads.update(adChangeRequest.isStatus());  // 광고 상태 업데이트

    return new AdChangeResponse(
        ads.getId(),
        ads.getShopId(),
        "광고 상태 변경",
        ads.isStatus() ? "활성" : "비활성",
        ads.getStartDate(),
        ads.getEndDate()
    );  // 응답 반환
}

광고 수정 로직 설명

1. 관리자 권한 검증: 광고를 수정하려면 ADMIN 권한이 있어야 한다. 관리자가 아니면 ApiException을 발생시켜 예외 처리한다.

2. 광고 조회: 수정할 광고를 adId로 조회하고, 광고가 없으면 예외를 발생시킨다.

3. 광고 상태 업데이트: 광고 상태만 변경하고, 가게의 다른 정보는 수정하지 않는다.

4. 응답 반환: 변경된 광고 상태를 응답으로 반환한다.

4. 광고 가게 상단 노출 기능 구현

팀에서 요청한 기능 중 중요한 부분은 광고 가게를 검색 결과 상단에 노출시키는 것이었다. 이 기능은 광고 상태가 활성화된 가게를 먼저 조회한 후, 그 다음에 일반 가게를 조회하여 리스트를 결합하는 방식으로 구현했다.

광고 상단 노출 로직

@Transactional(readOnly = true)
public List<ShopResponse> getShopList() {
    // 광고 가게 조회 (광고 상태가 활성인 가게)
    List<Shop> adShops = shopRepository.findAdShops();

    // 일반 가게 조회 (광고 상태가 비활성 또는 광고가 없는 가게)
    List<Shop> regularShops = shopRepository.findRegularShops();

    // 광고 가게 리스트 + 일반 가게 리스트 결합
    List<ShopResponse> result = new ArrayList<>();
    result.addAll(adShops.stream().map(ShopResponse::of).toList());
    result.addAll(regularShops.stream().map(ShopResponse::of).toList());

    return result;
}

설명
1. 광고 가게 조회: shopRepository.findAdShops()로 광고 상태가 활성화된 가게만 조회한다.

2. 일반 가게 조회: 광고가 없거나 비활성 상태인 가게를 조회한다.

3. 결과 결합: 광고 가게가 상단에 노출되도록 리스트를 결합한다.

4. 응답 반환: 광고 가게와 일반 가게가 순서대로 정렬된 결과를 반환한다.

5. 트러블슈팅 기록

(1) user_role 데이터 삽입 시 에러
문제:
ADMIN 권한으로 회원가입 시 "Data truncated for column 'user_role'" 에러가 발생했다.

원인:
user_role 컬럼의 데이터 길이가 충분하지 않아 ADMIN 값이 잘려서 발생한 문제였다.

해결 방법:
user_role 컬럼의 길이를 늘렸다. 기본적으로 VARCHAR(4)로 되어 있던 컬럼을 VARCHAR(10)으로 확장했다.

수정된 SQL 쿼리:

ALTER TABLE users MODIFY user_role VARCHAR(10);

(2) 광고 생성 시 가게 정보를 가져올 때 발생한 오류
문제:
광고 생성 시 shopId로 가게를 조회할 때, getShop() 메서드가 없다는 에러가 발생했다.

원인:
Ad 엔티티에서 shopId만 저장하고 있어, 가게 객체에 직접 접근할 수 없었다.

해결 방법:
서비스 계층에서 shopId로 가게 정보를 조회하도록 수정했다.

수정된 서비스 로직:

Shop shop = shopRepository.findById(adSaveRequest.getShopId()).orElseThrow(
    () -> new ApiException(ErrorStatus._NOT_FOUND_SHOP)
);

(3) 관리자 권한 검증 시 예외가 발생하지 않는 문제
문제:
광고 생성 및 수정 기능을 테스트하는 과정에서, 관리자 권한이 없는 사용자가 광고를 생성하거나 수정할 때도 정상적으로 기능이 동작하는 것을 발견했다. 즉, 관리자 권한 검증 로직이 제대로 작동하지 않아, 관리자만 접근해야 할 기능을 일반 사용자도 사용할 수 있었다.

원인:
권한 검증 로직이 제대로 설정되어 있지 않았거나, 검증이 필요한 부분에서 검증 메서드 호출이 누락된 것이 문제였다. 이를 통해 권한에 대한 철저한 검증이 없으면, 시스템 보안에 취약점이 생긴다는 점을 깨달았다.

해결 방법:
권한 검증을 철저히 하기 위해, validateAdminRole() 메서드를 작성하여 모든 관리자 기능에 이 메서드를 추가했다. 이 메서드는 AuthUser 객체에서 사용자의 권한을 확인하고, 관리자가 아닐 경우 ApiException을 발생시켜 예외 처리하도록 했다.

수정된 관리자 권한 검증 로직:

private void validateAdminRole(AuthUser authUser) {
    if (!authUser.getUserRole().equals(UserRole.ADMIN)) {
        throw new ApiException(ErrorStatus._FORBIDDEN);  // 관리자 권한이 아닐 경우 예외 발생
    }
}

해결 과정 설명:

관리자 권한 검증: 사용자가 광고 생성 또는 수정 요청을 보낼 때, 해당 사용자가 ADMIN 권한을 가지고 있는지 확인하는 검증을 추가했다. 관리자가 아니면 ApiException을 발생시켜 403 Forbidden 상태 코드를 반환하고, 권한이 없음을 사용자에게 명확히 알린다.
권한 검증 적용: 광고 생성 및 수정 로직에서 권한 검증 메서드를 반드시 호출하도록 모든 관련 메서드에 추가했다.
이러한 수정으로 인해 관리자 권한이 없는 사용자는 광고 기능을 사용할 수 없도록 보안성을 높일 수 있었다.

KPT 회고

Keep

  • 함께 사용할 코드 구조를 알려줄 때 사용법을 미리 한번 설명하고 사용했던 부분
  • 실제 기능을 구현하기 전에 구조를 구체적이고 체계적으로 짜기 위해서 많은 노력을 했던 부분
  • 즉각적인 소통, 다같이 실시간 문제 공유 및 해결
  • 목표를 높게 설정하기

Problem

  • 코드 리뷰 시간이 부족해 코드 품질 관리가 미흡했다.
  • 키 관리가 체계적이지 않아 보안 리스크 존재 가능성이 있었다.
  • 각자 서로 구현한 코드에 대한 이해도가 높지 않은듯함. 서로 코드를 보고 리뷰하고 설명해주는 시간이 필요하다!
  • 테스트 커버리지를 조금 더 채워보면 더 좋았을 것 같다

Try

  • 초기 ERD 작성 시 설계를 보다 자세히 진행한다.
  • 주간 코드 리뷰 세션을 도입해 코드 품질을 개선한다.
  • 전체 통합 후 리팩토링으로 퀄리티 높이기
  • 중간 중간 테스트 코드를 작성해서 실제 코드를 테스트 해보자

github
https://github.com/devmoonjs/fm-delivery
배포 사이트 링크
http://fm-delivery.site

마치며

이렇게 팀 프로젝트에서 사용자/인증과 광고 기능을 개발하고, 그 과정에서 발생한 문제들을 해결하며 배운 점을 정리했다. 이번 프로젝트는 단순히 기능 구현에서 끝나는 것이 아니라, 팀원들과의 소통과 협업을 통해 프로젝트를 계속 발전시키면서 함께 성장할 수 있었던 좋은 경험이 되었다.

프로젝트를 통해 얻은 교훈

권한 관리의 중요성: 사용자 권한을 제대로 검증하는 것은 보안 측면에서 매우 중요하다. 이번 프로젝트에서 관리자 권한 검증 로직을 작성하며, 잘못된 권한 접근을 방지하는 코드가 얼마나 중요한지 깨달았다.

데이터 처리의 신중함: 데이터 저장과 수정 로직을 구현하면서, 데이터 무결성을 유지하는 것이 매우 중요하다는 것을 다시 한 번 확인했다. 특히 광고 상태 관리와 관련된 로직을 통해, 데이터 처리의 신중함을 배웠다.

협업의 효율성: 팀원들과의 소통이 원활할 때, 문제 해결 속도가 빨라지고 기능 구현이 더 효율적으로 이루어질 수 있다는 점을 느꼈다. 팀 프로젝트에서는 혼자 해결하려고 하기보다는 서로의 의견을 주고받는 과정에서 프로젝트가 더 발전할 수 있다라는 것을 깨달았다.

앞으로의 계획
이번 프로젝트에서 쌓은 경험을 바탕으로, 앞으로 더 복잡한 기능도 자신 있게 구현할 수 있을 것 같다. 특히 권한 검증이나 데이터 처리와 같은 보안적 요소를 더 강화하고, 유지보수하기 쉬운 코드를 작성하는 데 주력할 예정이다. 또한, 이번에는 배포 부분을 팀원분들이 담당해 주셨는데,
Redis,AWS,Github action 등을 추가로 공부해 볼 예정이다.

profile
안녕하세요

0개의 댓글