클린 코드와 예외 처리의 중요성: 사용자 권한 검증 및 중복 코드의 리팩토링

신수정·2024년 5월 13일
0

오늘은 클린 코드와 예외 처리의 중요성을 주제로 이야기하려고 합니다. 특히 중복 코드의 리팩토링사용자 권한 검증을 통해 코드의 가독성과 유지보수성을 높이는 방법에 대해 소개하겠습니다.

프로젝트 배경

이번 프로젝트는 RESTful API를 사용해 게시판 서비스를 구현하는 것이 목적이었습니다. 게시글 작성, 수정, 삭제와 같은 기능을 구현하면서, 아래와 같은 문제점을 발견하게 되었습니다.

  1. 중복 코드의 반복 사용:

    • 로그인한 사용자를 찾는 로직이 여러 곳에서 반복되고 있었음.
    • 이를 해결하기 위해 로그인된 사용자를 가져오는 서비스 로직을 별도로 분리하여 재사용성을 높임.
  2. 예외 처리의 일관성 부족:

    • 권한 검증 및 요청 유효성 검사 과정에서 예외 처리의 중요성을 깨달음.
    • ResponseStatusException을 통해 명확한 에러 메시지와 상태 코드를 반환하도록 개선함.

MemberLoginService 클래스

중복된 로그인 유저 조회 로직을 MemberLoginService 클래스에 통합하여 각 컨트롤러에서 재사용할 수 있게 했습니다.

package com.moonBam.service.member;

import com.moonBam.dto.MemberDTO;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;

import java.security.Principal;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class MemberLoginService {

    private final MemberService memberService;

    public MemberDTO findByPrincipal(Principal principal) {
        if (principal == null) {
            return null;
        }

        return Optional.ofNullable(memberService.findByUserId(principal.getName()))
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, principal.getName() + " not found"));
    }
}
  • findByPrincipal 메서드:
    • 로그인된 사용자의 Principal을 통해 MemberDTO를 찾음.
    • 찾지 못한 경우 ResponseStatusException을 발생시켜 UNAUTHORIZED 상태 코드를 반환.

BoardAPIController 클래스

권한 검증과 유효성 검사를 개선한 BoardAPIController 클래스입니다.

package com.moonBam.controller.board;

import com.moonBam.dto.MemberDTO;
import com.moonBam.dto.board.PostDTO;
import com.moonBam.dto.board.PostPageDTO;
import com.moonBam.dto.board.PostUpdateRequestDTO;
import com.moonBam.service.PostService;
import com.moonBam.service.ScrapService;
import com.moonBam.service.member.MemberLoginService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.security.Principal;
import java.util.Map;
import java.util.stream.Collectors;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/post")
public class BoardAPIController {

    private final PostService postService;
    private final MemberLoginService memberLoginService;
    private final ScrapService scrapService;

    @PostMapping
    public ResponseEntity<?> create(@RequestBody @Validated PostDTO postDTO,
                                    BindingResult bindingResult, Principal principal) {

        if (bindingResult.hasErrors()) {
            Map<String, String> errors = getErrors(bindingResult);
            String s = errors.values().stream().toList().get(0);
            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                    .body(Map.of("message", s, "errors", errors));
        }

        // XSS 방지를 위해 입력값에서 스크립트 태그를 제거하는 로직 추가
        String sanitizedPostText = sanitizeHtml(postDTO.getPostText());
        String sanitizedPostTitle = sanitizeHtml(postDTO.getPostTitle());
        postDTO.setPostText(sanitizedPostText);
        postDTO.setPostTitle(sanitizedPostTitle);

        MemberDTO loginUser = memberLoginService.findByPrincipal(principal);

        postDTO.setUserId(loginUser.getUserId());
        postDTO.setNickname(loginUser.getNickname());
        Long postID = postService.save(postDTO);
        return ResponseEntity.ok(Map.of("postID", postID)); // 동적으로 ajax success function에서 redirect 시킬 용도
    }

    @PatchMapping("/{postId}")
    public ResponseEntity<?> update(@RequestBody @Validated PostUpdateRequestDTO postUpdateRequestDTO, @PathVariable("postId") Long postId,
                                    BindingResult bindingResult, Principal principal) {

        PostDTO postDTO = postService.findById(postId);

        if (bindingResult.hasErrors()) {
            Map<String, String> errors = getErrors(bindingResult);
            String s = errors.values().stream().toList().get(0);
            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                    .body(Map.of("message", s, "errors", errors));
        }

        // XSS 방지를 위해 입력값에서 스크립트 태그를 제거하는 로직 추가
        String sanitizedPostText = sanitizeHtml(postUpdateRequestDTO.getPostText());
        postUpdateRequestDTO.setPostText(sanitizedPostText);
        postUpdateRequestDTO.setPostTitle(sanitizeHtml(postUpdateRequestDTO.getPostTitle()));

        MemberDTO loginUser = memberLoginService.findByPrincipal(principal);

        if (!loginUser.getUserId().equals(postDTO.getUserId())) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "글을 수정할 권한이 없습니다."));
        }

        postService.update(postUpdateRequestDTO);

        return ResponseEntity.ok(Map.of("postID", postId));
    }

    @DeleteMapping("{postId}")
    public ResponseEntity<?> delete(@PathVariable("postId") Long postId, Principal principal) {
        PostDTO postDTO = postService.findById(postId);
        MemberDTO loginUser = memberLoginService.findByPrincipal(principal);

        if (postDTO == null) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", "글이 존재하지 않습니다."));
        }

        if (!loginUser.getUserId().equals(postDTO.getUserId())) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("message", "글을 삭제할 권한이 없습니다."));
        }

        scrapService.findAllByPostId(postId).forEach(scrapDTO -> scrapService.delete(scrapDTO.getScrapId()));
        postService.delete(postId);

        return ResponseEntity.ok(Map.of("message", "삭제가 완료되었습니다."));
    }

    @GetMapping("/{postId}")
    public ResponseEntity<?> findOne(@PathVariable("postId") Long postId, Principal principal) {
        PostPageDTO pDTO = postService.selectPagePost(postId);

        if (pDTO == null) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", "존재하지 않는 게시글입니다."));
        }

        MemberDTO loginUser = memberLoginService.findByPrincipal(principal);
        boolean isAuthorized = loginUser != null && loginUser.getUserId().equals(pDTO.getUserId());

        return ResponseEntity.ok(Map.of("pDTO", pDTO, "isAuthorized", isAuthorized));
    }

    private Map<String, String> getErrors(BindingResult bindingResult) {
        return bindingResult.getFieldErrors().stream()
                .collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
    }

    private String sanitizeHtml(String input) {
        return input.replaceAll("(?i)<script.*?>.*?</script>", "") // 스크립트 태그 제거
                .replaceAll("(?i)<.*?javascript:.*?>.*?</.*?>", "") // "javascript:" URI 사용 제거
                .replaceAll("(?i)<.*?\\bon.*?>.*?</.*?>", ""); // 이벤트 핸들러 제거 (예: onclick)
    }
}

클린 코드와 예외 처리 개선의 장점

  1. 중복 코드 제거:

    • 중복되는 코드를 분리하여 MemberLoginService로 통합함으로써 가독성 및 재사용성을 높였습니다.
  2. 일관된 예외 처리:

    • ResponseStatusException을 활용해 사용자 권한 검증과 요청 유효성 검사 시 명확한 상태 코드를 반환함으로써 예외 처리의 일관성을 확보했습니다.
  3. 보안 강화:

    • sanitizeHtml 메서드를 통해 사용자 입력에 대한 XSS 방어 로직을 추가하여 보안을 강화했습니다.

이번 프로젝트를 통해 클린 코드의 중요성과 예외 처리의 중요성을 다시 한번 깨닫게 되었습니다. 코드를 리팩토링하고 중복 로직을 제거함으로써 가독성과 유지보수성을 향상시키고, 일관된 예외 처리로 권한 검증 및 유효성 검사 로직을 개선했습니다.

profile
안녕하세요:)

0개의 댓글