Post

멀티보드 프로젝트 중간점검: 코드 개선과 리팩토링

코드 개선

1. UserID가 아닌 사용자 Sequence Number ID로 게시글 작성자 판단

[개선 전]

게시글과 댓글 수정 권한을 판단하는 로직은 로그인한 사용자의 JWT에서 String userId를 추출해 해당 정보를 작성한 userId와 비교하는 방식입니다. userId가 유저 테이블의 기본키이기 때문에 기능상에는 문제가 없습니다.

하지만 추후 회원탈퇴 기능이 추가 될 경우, 신규 회원가입 사용자가 탈퇴한 사용자의 아이디를 사용한다면 탈퇴한 사용자의 게시글/댓글에 대한 권한을 갖게 되는 문제가 발생합니다.

따라서, 게시글과 댓글 작성자 여부를 String userId가 아닌 회원가입 시 부여된 Integer SeqId를 사용하여 확인하도록 변경이 필요합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
 * 자유게시판 정보를 수정합니다.
 *
 * @param userId   사용자 ID
 * @param boardDTO 수정할 게시글 정보를 담은 DTO 객체
 * @throws AppException 사용자 정보가 유효하지 않을 경우 예외 발생
 */
public void updateFreeBoardInfo(String userId, BoardDTO boardDTO) throws Exception {
    if (StringUtils.isEmpty(userId) || !userId.equals(boardDTO.getUserId())) {
        throw new AppException(ErrorCode.USER_NOT_FOUND, "유효한 사용자가 아닙니다.");
    }
    ...

[개선 후]

먼저 데이터베이스 변경이 필요합니다.

사용자, 관리자 테이블에 sequence id 컬럼을 추가하였습니다. image

각 테이블에서 작성자 정보를 user_id VARCHAR(255)에 저장하고 있었습니다. 이를 테이블에 따라 user_seq_id INT(11) 또는 admin_seq_id INT(11)을 사용하도록 변경하였습니다. image image

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
 * security > JwtTokenProvider.java
 */
public String createToken(int seqId) {
    String subject = String.valueOf(seqId);
    Claims claims = Jwts.claims().setSubject(subject);

    Date now = new Date();
    Date validity = new Date(now.getTime()
            + validityInMilliseconds);

    return Jwts.builder()
            .setClaims(claims)
            .setIssuedAt(now)
            .setExpiration(validity)
            .signWith(SignatureAlgorithm.HS256, secretKey)
            .compact();
}

JWT을 생성하는 createToken에서는 이제 userId가 아닌 seqId를 추가하여 생성하도록 변경합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
 * security > BearerAuthInterceptor.java
 */
@Component
@AllArgsConstructor
public class BearerAuthInterceptor implements HandlerInterceptor {
/**
 * 요청 헤더에서 인증 정보를 추출
 */
private AuthorizationExtractor authExtractor;
/**
 * jwtToken 관련 유틸리티
 */
private JwtTokenProvider jwtTokenProvider;

/**
 * Pre-handle 메서드는 요청이 컨트롤러에 도달하기 전에 실행되는 메서드입니다.
 * 이 메서드에서는 요청 헤더에서 JWT 토큰을 추출하고 유효성을 검사한 후,
 * 추출한 토큰의 사용자 ID를 요청 속성에 저장합니다.
 *
 * @param request  현재 요청 객체 (HttpServletRequest)
 * @param response 현재 응답 객체 (HttpServletResponse)
 * @param handler  현재 처리기 객체 (Object)
 * @return 요청 처리 여부 (true: 계속 진행, false: 중단)
 * @throws IllegalArgumentException 토큰이 유효하지 않을 경우 발생하는 예외
 */
@Override
public boolean preHandle(HttpServletRequest request,
                          HttpServletResponse response, Object handler) {

    //헤더에서 JWT 토큰 추출
    String token = authExtractor.extract(request, "Bearer");

    //빈 토큰 일 경우 다음으로 이동
    if (StringUtils.isEmpty(token) || "null".equals(token)) {
        return true;
    }

    //JWT 토큰이 유효하지 않는 경우 예외처리
    if (!jwtTokenProvider.validateToken(token)) {
        throw new IllegalArgumentException("요청이 정상적으로 실행되지 않았습니다. 유효하지 않는 토큰입니다.");
    }

    /**
     * TODO : 프로젝트 범위 상 인터셉터를 통해 request에 seqId를 추가하여 권한을 확인하지만 이는 권고되는 방법은 아닙니다.
     * TODO : AOP, Resolver, @RequestHeader 어노테이션 사용 등 다른방식으로 jwt 토큰을 확인하는 것이 좋습니다.
     */
    String seqId = jwtTokenProvider.getSubject(token);
    request.setAttribute("seqId", seqId);
    return true;
}
}

또한, BearerAuthInterceptor에서 JWT로부터 userId가 아닌 seqId를 추출하여 컨트롤러에 전달해야합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
 * 자유 게시글 정보를 수정하는 메서드입니다.
 *
 * @param seqId 사용자 식별 ID
 * @param boardDTO 수정할 게시글 정보
 * @throws Exception 예외 발생 시
 */

public void updateFreeBoardInfo(int seqId, BoardFreeDTO boardDTO) throws Exception {
    //현재 userSeqId와 게시글 정보에 저장된 userSeqId와 비교
    int getUserSeqId = boardRepository.getFreeBoardDetail(boardDTO.getBoardId()).getUserSeqId();
    if (seqId != getUserSeqId) {
        throw new AppException(ErrorCode.INVALID_PERMISSION, "수정 권한이 없습니다.");
    }

    //게시글 수정
    boardRepository.updateFreeBoardInfo(boardDTO);
    ...

이제 JWT에서 추출한 JWT의 seqId와 게시글/댓글을 작성한 seqId를 비교해야합니다.

위 코드를 예시로 보겠습니다. updateFreeBoardInfo는 자유게시판을 업데이트하는 메소드입니다. 컨트롤러부터 JWT에서 추출한 seqId와 수정하려는 board 정보를 전달받습니다.

이후 board 정보에서 boardId를 가져오고, boardId에 해당하는 데이터를 조회하여 이 게시글을 작성한 사용자의 seqId를 가져옵니다.

위와 같이 seqID로 비교할 경우 과거 탈퇴한 사용자와 신규 사용자가 같은 아이디를 사용하고 있더라도, seqId는 다르기 때문에 해당 게시글/댓글에 대한 권한이 없습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- 자유게시판 상세 내용을 조회 -->
<select id="getFreeBoardDetail" parameterType="java.lang.Integer" resultType="BoardFreeDTO">
  SELECT target_board.*,
          target_board.child_code_value as categoryValue,
          cc.child_code_name            AS categoryName,
          u.user_id                     as userId
  FROM free_board AS target_board
            JOIN category_child_code AS cc ON target_board.child_code_value = cc.child_code_value
            JOIN users AS u ON target_board.user_seq_id = u.seq_id
  WHERE target_board.board_id = #{boardId}
</select>

<!-- 게시글에 달린 모든 댓글 조회 -->
<select id="getCommentsByBoardId" resultType="CommentDTO">
    SELECT c.*, u.user_id AS userId
    FROM comments AS c
              JOIN users u on c.user_seq_id = u.seq_id
    WHERE c.board_id = #{boardId}
</select>

SQL도 변경이 필요합니다.

기존에는 각 테이블의 데이터베이스의 userId 컬럼이 게시글/댓글 작성자임을 나타냈었지만, 이제는 사용자의 seqId값만 가지고 있기 때문에 사용자 및 관리자 테이블과 JOIN하여 seqId가 가리키는 String ID를 가져와야합니다.

위 변경에 따른 클라이언트 수정은 설명을 생략하겠습니다.


2. Enum을 활용한 Category Code와 Table Mapping

[개선 전]

관리자가 새로운 공지사항 테이블에 대한 카테고리를 관리하기 위해 category_parent_code테이블에 parent_code_value = "notice", parent_code_name ="공지사항 카테고리"를 추가했다고 가정하겠습니다.

현재 방식은 parent_code_name에서 “공지사항” 문자열이 포함되어있는 parent_code_value를 찾아서 해당 값을 가지고 있는 category_child_code 테이블 값들을 공지사항 하위 카테고리라고 판단합니다.

1
2
3
4
5
6
7
<!--  공지사항의 카테고리 목록을 조회  -->
<select id="getNoticeBoardCategories" resultType="CategoryDTO">
    SELECT  cc.child_code_name as categoryName ,cc.child_code_value as categoryValue
    FROM category_child_code cc
        JOIN category_parent_code cp ON cc.parent_code_value = cp.parent_code_value
    WHERE cp.parent_code_name LIKE '%공지사항%'
</select>

위 같이 하드코딩을 작성하게 된 이유는 결국 관리자가 입력한 parent_code_value"notice"notice_table 간의 연결관계가 없기 때문입니다.

데이터베이스에 맵핑 테이블을 만들어서 관리해도 되지만 더 간단하게 자바 enum class를 통해 연결관계를 표현할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
 * service > BoardCategory.java
 */

/**
 * 게시글의 카테고리를 정의하는 열거형(Enum) 클래스
 */
@Getter
public enum BoardCategory {
    /**
     * 공지사항 카테고리
     */
    FREE_BOARD("자유게시판", "free"),
    /**
     * 공지사항 카테고리
     */
    NOTICE_BOARD("공지사항", "notice"),

    /**
     * 갤러리게시판 카테고리
     */
    GALLERY_BOARD("갤러리게시판", "gallery"),

    /**
     * 문의게시판 카테고리
     */
    INQUIRY_BOARD("문의게시판", "inquiry");

    /**
     * 카테고리의 부모 코드 테이블 이름
     */
    private final String categoryParentCodeName;

    /**
     * 카테고리의 부모 코드 테이블 값
     */
    private final String categoryParentCodeValue;

    /**
     * BoardCategory 생성자
     *
     * @param categoryParentCodeName 카테고리의 부모 코드 테이블 이름
     * @param categoryParentCodeValue 카테고리의 부모 코드 테이블 값
     */
    BoardCategory(String categoryParentCodeName, String categoryParentCodeValue) {
        this.categoryParentCodeName = categoryParentCodeName;
        this.categoryParentCodeValue = categoryParentCodeValue;
    }

}

위와 같이 enum을 활용하면 어느 게시판이, 어느 테이블과 관계가 있는지 한눈에 파악하며 관리할 수 있습니다.

예로 공지사항인 BoardCategory.NOTICE를 보면 categoryParentCodeName에는 “공지사항”, categoryParentCodeValue는 “notice“가 맵핑되어있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
 * service > BoardService.java
 */
   
/**
 * 공지사항의 카테고리 목록을 가져옵니다.
 *
 * @return 공지사항의 카테고리 목록
 */
public List<CategoryDTO> getNoticeBoardCategories() {

    BoardCategory categoryParentCode = BoardCategory.NOTICE_BOARD;
    return boardRepository.getNoticeBoardCategories(categoryParentCode.getCategoryParentCodeValue());
}

이제 서비스에서 공지사항 카테고리를 가져오는 메소드를 실행시킬 때 BoardCategory.NOTICE_BOARDgetCategoryParentCodeValue인 “notice” 라는 값을 넘겨 sql에서 notice table에 관한 카테고리를 찾아야 하는구나를 알 수 있게 됩니다.

1
2
3
4
5
6
7
<!--  공지사항의 카테고리 목록을 조회  -->
<select id="getNoticeBoardCategories" parameterType="java.lang.String" resultType="CategoryDTO">
    SELECT cc.child_code_name as categoryName, cc.child_code_value as categoryValue
    FROM category_child_code cc
              JOIN category_parent_code cp ON cc.parent_code_value = cp.parent_code_value
    WHERE cp.parent_code_value = #{categoryParentCodeValue}
</select>

sql에서는 categoryParentCodeValue"notice"에 해당하는 값을 알고 있기 때문에 이를 활용하여 하위 카테고리 목록을 가져올 수 있습니다.

이렇게 sql문에 문자열 하드코딩 부분을 enum을 통해 관리하도록 개선함으로써 아래와 같은 장점을 갖을 수 있습니다.

1. 자동완성, 오타확인, 허용 가능한 값 범위 제한 등 IDE 지원과 코드 안정성을 확보할 수 있습니다.

2. 변경 지점을 sql문에서 enum으로 최소화 시킬 수 있습니다.


다음으로

다음으로는 변경된 코드를 기반으로 갤러리게시판을 구현해보겠습니다. 자유 게시판과 중복은 최대한 생략하고 이미지 관리, 썸네일, content slider 라이브러리 사용 등 새로 배운 내용을 위주로 포스팅을 진행 할 예정입니다.

This post is licensed under CC BY 4.0 by the author.