저번에 등록(create) 기능까지 만들었고, 이번엔 조회(Read), 수정(Update), 삭제(Delete) 기능을 만들어보려 한다. 우선 조회부터 시작쓰.

 

게시글의 상세내용을 보기 위해서 클라쪽에서 서버 쪽으로 게시글id를 넘기도록 할 건데, 이 때 url변수로 넘기든가(ex : /board/1) 아님 쿼리스트링 형태로 넘기든가(ex : /board?id=1) 해야 한다. (body에 적는 방법 등도 있지만..패쓰) 아무래도 대중적인 방법은 url변수로 하는 방법이라 어떻게 하는지 알아봤다.

 

방법은 간단했다. @PathVariable이라는 어노테이션을 사용하면 됐다! 방법은

 

  • GetMapping(Post든 뭐든 상관 X)의 url입력하는 부분에 {받을 변수명}
  • 메서드의 파라미터로 @PathVariable("받을 변수명")

 

이다. 다음과 같이 활용해줬다.

 

@GetMapping("/{id}")
    public BoardResponseDTO getBoard(@PathVariable("id") Long id) {
        Board board = boardService.findBoard(id).get();
        // ... 생략
    }

 

그리고 다음과 같이 BoardResponseDTO를 만들어줬다.

 

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class BoardResponseDTO {
    private Long boardId;
    private String writerNickname;
    private String title;
    private String content;
    private Long hits;
    private Long like;
    private LocalDateTime createdDate;
    private LocalDateTime updatedDate;
    private int categoryId;

    public static BoardResponseDTO from(Member writer, Board board) {
        return new BoardResponseDTO(
                board.getBoardId(),
                writer.getNickname(),
                board.getTitle(),
                board.getContent(),
                board.getHits(),
                board.getLike(),
                board.getCreatedDate(),
                board.getUpdatedDate(),
                board.getCategoryId()
        );
    }
}

 

요즘 이 토이플젝을 하면서 참고하는 분이 있는데, 그 분은 이런 식으로 from메서드를 만들어 활용하는 식으로 하는 것 같아 참고해봤다. AccessLevel지정해주는 건 Lombok으로 만들어주는 생성자의 접근지정자를 설정하는거다. private로 한 것은 외부에서 요 놈의 인스턴스를 만드는 것을 막아주므로, BoardResponseDTO를 만들려면 from메서드를 사용하는 수밖에 없다!

 

암튼! 그래서 결과는..

 

 

성공~! 저번에 인터셉터 통해서 응답값들을 통일시켜준 방식 그대로 뱉어지는 모습도 곁들여 볼 수 있었다.

 

그 다음으론 삭제기능을 만들어보기로 했다. 삭제 기능에서의 고민은 바로 "삭제 권한". 글의 작성자만이 본인의 글을 삭제할 수 있도록 해야 할 것이다. 저번 학기에 프론트를 했을 때는 따로 프론트 쪽에서 현재 로그인한 유저의 정보를 가지고 있었기 때문에, 내가 지금 보고 있는 글의 작성자와 내가 가진 유저의 정보가 같으면 그 글에 삭제버튼을 보이게 하는 식으로 이를 만들었다. 그러나 지금 드는 생각은 어차피 contextHolder?거기에 현재 로그인한 유저의 정보를 가진다 하니 걔를 통해서 비교해줄 수 있을 것 같다!

 

그래서 다음과 같이 일단 delete에 해당하는 메서드를 만들어줬다.

 

@DeleteMapping("/{id}")
public String deleteBoard(@AuthenticationPrincipal Member member, @PathVariable("id") Long id) {
    Board board = boardService.findBoard(id).get();
    Member writer = board.getWriter();
    if (writer.getEmail().equals(member.getEmail())) {
        return "same";
    }
    return "diff";
}

 

현재 유저와 게시글의 작성자 이메일이 같다면 same을 뱉고, 다르면 diff를 뱉을 거다. 우선 작성자로 로그인하고 request를 보내봤다! 결과는..

 

 

같다고 잘 나왔다. 이번엔 다른 사용자로 로그인하고 (즉 포스트맨 상에서는 헤더에 담는 토큰을 다른 걸로 설정) 보내봤다. 결과는..

 

 

다르다고 잘 나온다! 삭제코드 자체는 BoardRepository에 deleteById메서드를 만들어 다음과 같이 구현해줬다.

 

@Override
public void deleteById(Long id) {
    Board board = em.find(Board.class, id);
    em.remove(board);
}

 

암튼 그렇게 해서 만든 삭제기능은..

 

 

27번 게시물이 잘 삭제된 것을 볼 수 있었다.

 

마지막으로 수정 기능을 만들 차례. 제목이나 본문의 내용이 잘 바뀌는지도 중요하겠지만, 아무래도 관건은 

 

  1. 수정일이 알아서 업데이트되는지, 생성일자는 변함없는지
  2. 게시물의 id가 그대로 유지되는지

 

정도가 될 것 같다. 

컨트롤러에서 다음과 같이 코드를 작성했다.

 

@PutMapping("/{id}")
public BoardResponseDTO updateBoard(
        @AuthenticationPrincipal Member member,
        @PathVariable Long id,
        @RequestBody BoardDTO boardDTO) {
    Board oldBoard = boardService.findBoard(id).get();
    Member writer = oldBoard.getWriter();

    if (writer.getEmail().equals(member.getEmail())) {
        Board updatedBoard = boardService.updateBoard(id, boardDTO);
        return BoardResponseDTO.from(writer, updatedBoard);
    }
    
    return null;
}

 

역시나 요청을 보낸 사람의 정보와 게시글 작성자가 같을 때에만 수정이 이뤄지게 했다. 그리고 게시글 작성 때 사용했던 BoardDTO를 수정할 때도 그대로 재탕해줬다. 어차피 게시글 작성할 때 작성하는 부분이 수정할 때 작성하는 부분이랑 같으니까..

BoardRepository에서 다음과 같이 코드를 짜 수정이 이루어지게끔 했다.

 

@Override
public Board updateBoard(Long id, BoardDTO boardDTO) {
    Board board = em.find(Board.class, id);
    board.setTitle(boardDTO.getTitle());
    board.setContent(boardDTO.getContent());
    board.setCategoryId(boardDTO.getCategoryId());
    em.persist(board);
    return board;
}

 

세터 메서드들을 사용해 Board엔티티의 내용물들을 바꿔치기해주고 저장해주는 식. entitymanager의 save라는 메서드가 인자로 받는 entity의 id값이 있냐없냐에 따라 insert를 할지 update를 할지 결정해준다고 한다. 이 경우에선 이미 board는 id가 있는 상태이니 update가 될 것이다.

 

이제 한 번 시험해볼 차례! 우선 수정 전 db상황은 다음과 같았다. 여기서 28번 게시물을 바꿀 거다.

 

 

포스트맨을 사용해 수정 테스트하기. 결과는..!

 

 

우선 응답값 자체는 문제가 없어보인다! 과연 db에서는..?

 

 

수정한 부분들이 잘 바뀌었음을 확인했다. title, content, category_id 컬럼값이 바뀌었으며 board_id나 writer는 변함없는 걸 볼 수 있다. 또한 created_date는 변화가 없고 updated_date에만 변화가 생겼음을 잘 확인할 수 있었다.

https://www.acmicpc.net/problem/14890

 

14890번: 경사로

첫째 줄에 N (2 ≤ N ≤ 100)과 L (1 ≤ L ≤ N)이 주어진다. 둘째 줄부터 N개의 줄에 지도가 주어진다. 각 칸의 높이는 10보다 작거나 같은 자연수이다.

www.acmicpc.net

 

행 하나하나, 열 하나하나에 대해 지나갈 수 있는지 판단해주면 되는 문제. 단순히 구현만 해주면 된다.

내가 처음에 작성한 코드는 다음과 같다.

 

import sys
input = sys.stdin.readline


def is_all_same_height(heights):
    for i in range(len(heights) - 1):
        if heights[i] != heights[i + 1]:
            return False
    return True


def is_all_same_column(board, c):
    for r in range(len(board) - 1):
        if board[r][c] != board[r + 1][c]:
            return False
    return True


def solution(n, k, board):
    answer = 0

    for row in board:
        checked = [False for _ in range(n)]
        if is_all_same_height(row):
            answer += 1
        else:
            for c in range(n - 1):
                if row[c] == row[c + 1]:
                    continue
                if row[c] + 1 == row[c + 1]:
                    for nc in range(c, c - k, -1):
                        if nc < 0 or checked[nc] or row[nc] != row[c]:
                            break
                    else:
                        nc = c - k + 1
                        for i in range(nc, c + 1):
                            checked[i] = True
                        continue
                    break
                elif row[c] == row[c + 1] + 1:
                    for nc in range(c + 1, c + 1 + k):
                        if nc >= n or checked[nc] or row[nc] != row[c + 1]:
                            break
                    else:
                        nc = c + 1 + k
                        for i in range(c + 1, nc):
                            checked[i] = True
                        continue
                    break
                else:
                    break
            else:
                answer += 1

    for c in range(n):
        checked = [False for _ in range(n)]
        if is_all_same_column(board, c):
            answer += 1
        else:
            for r in range(n - 1):
                if board[r][c] == board[r + 1][c]:
                    continue
                if board[r][c] + 1 == board[r + 1][c]:
                    for nr in range(r, r - k, -1):
                        if nr < 0 or checked[nr] or board[nr][c] != board[r][c]:
                            break
                    else:
                        nr = r - k + 1
                        for i in range(nr, r + 1):
                            checked[i] = True
                        continue
                    break
                elif board[r][c] == board[r + 1][c] + 1:
                    for nr in range(r + 1, r + 1 + k):
                        if nr >= n or checked[nr] or board[nr][c] != board[r + 1][c]:
                            break
                    else:
                        nr = r + 1 + k
                        for i in range(r + 1, nr):
                            checked[i] = True
                        continue
                    break
                else:
                    break
            else:
                answer += 1
    return answer


N, L = map(int, input().split())
_board = []
for _ in range(N):
    _board.append(list(map(int, input().split())))

print(solution(N, L, _board))

 

사실 나조차도 돌이켜보면 어떤 일을 하는지 참 알기 어려운 코드..우테코 프리코스 이후로 이런 더러운 코드 작성을 내면에서 혐오하곤 있긴 한데, 코테의 특성상 일단 구현하는게 먼저라고 생각해 더러운 걸 알면서 일단 저렇게 풀어봤다.

 

대충 저 코드의 핵심은 행과 열에 대해 지나갈 수 있는지를 판단하는 코드가 다르다는 것이다. 행에 접근할 때와 열에 접근할 때는 인덱싱 방법이 달라지기 때문에 구분해서 만들었다.

 

그러나 다른 사람들의 풀이를 보니, 좀 더 간단한 방법이 있었다. 바로 행과 열을 인덱싱 방법 이런 거 상관없이 똑같은 하나의 1차원 배열로 보는 것이다. 즉 열도 행처럼 보는 것! 파이썬의 for문을 이용하면

 

board = [
    [1, 2, 3, 4],
    [5, 6, 7, 8],
    [9, 10, 11, 12],
    [13, 14, 15, 16]
]

column = [board[i][0] for i in range(4)]

 

이런 식으로 하나의 열을 1차원 리스트, 즉 하나의 행처럼 가져올 수 있다. 이렇게 하면 행과 열에 대한 판단을 할 때 각기 다른 코드를 만들 필요 없이 하나의 코드로 행과 열에 대한 판단을 내릴 수 있다.

 

import sys
input = sys.stdin.readline


def check_path(path, k):
    length = len(path)
    check = [0 for _ in range(length)]

    for i in range(length - 1):
        if abs(path[i] - path[i + 1]) > 1:
            return False
        if path[i] + 1 == path[i + 1]:
            for j in range(i, i - k, -1):
                if j < 0 or check[j] or path[j] != path[i]:
                    return False
                check[j] = 1
            continue
        if path[i] == path[i + 1] + 1:
            for j in range(i + 1, i + 1 + k):
                if j >= length or check[j] or path[j] != path[i + 1]:
                    return False
                check[j] = 1
    return True


def solution(n, k, board):
    answer = 0
	# 행에 대한 판단
    for row in board:
        if check_path(row, k):
            answer += 1
	# 열에 대한 판단
    for c in range(n):
        if check_path([board[i][c] for i in range(n)], k):
            answer += 1

    return answer


N, L = map(int, input().split())
_board = []
for _ in range(N):
    _board.append(list(map(int, input().split())))

print(solution(N, L, _board))

 

구현..이라는 종목을 풀 때는, 이런 식으로 단순화해서 생각하는게 중요한 것 같다. 안 그러면 시간이 너무 오래 걸리는 듯..

저번 시간에 했던 것.

 

1. 카테고리 테이블, 서브카테고리 테이블, 게시글 테이블을 생성했다.

 

ㅎㅎ 소마 준비하면서 프로그래머스 sql 고득점 kit을 여러번 돌리면서 sql이 공부가 돼서.. join으로 글 정보랑 카테고리 정보들 가져오는 거 자랑(?)하기

 

2. 게시글 생성일, 수정일 자동으로 db에 저장되게 함.


우선 한 가지 확인하고 싶은게 생겼다. 이전에 필터를 만들어둔 상태고, 로그인/회원가입을 제외한 곳들은 전부다 막아둔 상태다. 

 

@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http.csrf().disable() // CSRF 공격에 대한 방어를 해제
                .cors().and() // cors 허용
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 서버를 stateless하게 유지 즉 세션 X
                .and()
                .formLogin().disable() // 시큐리티가 기본 제공하는 로그인 화면 없앰. JWT는 로그인과정을 수동으로 클래스로 만들어야 하니까
                .httpBasic().disable() // 토큰 방식을 이용할 것이므로 보안에 취약한 HttpBasic은 꺼두기
                .authorizeRequests() // HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다
                .requestMatchers("/account/signup", "/account/login", "/").permitAll() // 요 세 놈에 대한 요청은 인증없이 접근 허용
                .anyRequest().authenticated() // 나머지에 대해선 인증을 받아야 한다.
                .and()
                // 여러 필터들 중 UsernamePassword필터 앞에 내가 만든 필터를 둔다. 이렇게 하면 커스텀 필터로 인가인증을 다룰 수 있음
                .addFilterBefore(jwtAuthenticationFilter,
                        UsernamePasswordAuthenticationFilter.class)
                .build();
    }

 

게시글에 관한 것은 BoardController에서 일괄적으로 처리하게 할 계획이다. 이 때 /board로 들어오는 놈들은 인증을 받아야 한다!

 

@RestController
@RequestMapping(value = "/board")
@RequiredArgsConstructor
public class BoardController {
    private final BoardService boardService;

    @GetMapping("/test")
    public String boardTest() {
        return "test";
    }
}

 

한마디로 /board/test로 생으로 그냥 들어가면 test라는 응답을 받을 수 없을 거고, 토큰을 함께 보내면 test라는 응답을 받을 수 있다는 소리가 되는데.. 정말 잘 동작하는가?를 시험해보고 싶었다.

 

우선 생으로 /board/test로 보내봤다.

 

 

결과는 실패..

이번엔 토큰을 헤더에 담고 보내봤다. 결과는..?

 

 

성공한 모습!! 😄😄

 

 

참고로 위는 유효기간이 지난 토큰인데, 이렇게 유효하지 않은 토큰을 담아 보내면 응답이 오지 않는, 즉 필터가 일을 잘 하고 있는 모습을 볼 수 있었다.

 

 

이제 글 작성 api를 만들 차례. 컨트롤러에 다음과 같은 createBoard메서드를 추가해줬다.

 

@PostMapping("/write")
    public String createBoard(@RequestBody BoardDTO boardDTO) {
        Board newBoard = new Board();
        newBoard.setWriter(boardDTO.getEmail());
        newBoard.setTitle(boardDTO.getTitle());
        newBoard.setContent(boardDTO.getContent());
        newBoard.setCategoryId(boardDTO.getCategoryId());
        newBoard.setLike(0L);
        newBoard.setHits(0L);
        
        boardService.registerNewBoard(newBoard);
        return "success";
    }

 

BoardDTO는 다음과 같다.

 

@Getter
@Setter
public class BoardDTO {
    private String email;
    private String title;
    private String content;
    @JsonProperty("category_id")
    private int categoryId;
}

 

RequestBody는 예전에 작성했듯 클라이언트가 보낸 json데이터를 자바객체로 매핑해주는 역할을 하는데, 이 때 json데이터의 키 값과 매핑될 클래스의 멤버변수들 이름이 같아야 정상적으로 착착 매핑된다. 그러나 json에서 category_id라는 snake형식으로 보낸 값이 클래스에서 camel형식으로 작성된 categoryId에 매핑시키고 싶다면, 저렇게 해당 필드에 @JsonProperty라는 어노테이션을 멕이면 된다.

 

 

게시글이 잘 등록되는 걸 볼 수 있다!

 

현재 이 게시판 등록 기능은, 클라이언트 측에서 json으로 작성자의 이메일을 함께 넘겨주는 방식이다. 그러나 구글에 게시글 등록과 관련된 포스트들을 보면, 이메일이 아니라 멤버자체를 게시글 엔티티의 필드로 설정하는 방식도 있었다. 차이점이 뭔지는 잘 모르겠으나..일단 한 번 따라해봤다.

 

우선 Board클래스를 다음과 같이 수정해줬다.

 

@Getter
@Setter
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_id")
    private Long boardId;

    @JoinColumn(name = "writer", nullable = false)
    @ManyToOne(fetch = FetchType.LAZY)
    private Member writer;

    @Column(length = 80)
    private String title;
    
    // ... 생략

 

기존엔 write필드를 String으로 해서 이메일 자체를 세팅해줬었지만, 보시다시피 멤버로 받는 모습.

 

@ManyToOne(fetch = FetchType.LAZY) : 게시글 입장에선 멤버와 다대일(N : 1) 관계임. 이 다중성을 나타내는 어노테이션. FetchType은 즉시로딩과 지연로딩에 관한 거라는데..음...뭔 소린지 몰라서 패쓰

 

@JoinColumn(name = "writer") : 외래키를 매핑할 때 사용하는 어노테이션.  name은 외래키의 이름, 즉 조인의 대상으로 사용할 컬럼의 이름을 지정해줌. Board테이블의 writer컬럼에 Member의 pk인 email이 들어갈 것임을 명시하는 것!! Member클래스에서 email필드에 @Id 어노테이션에 멕여져 있어서 가능.

 

그리고 BoardDTO에서 다음과 같이 email필드를 빼줬다.

 

@Getter
@Setter
public class BoardDTO {
    private String title;
    private String content;
    @JsonProperty("category_id")
    private int categoryId;
}

 

그리고 BoardController에서 다음과 같이 인증정보를 가져와서 멤버를 꽂아넣도록(?) 바꿔줬다.

 

@PostMapping("/write")
    public String createBoard(@AuthenticationPrincipal Member member, @RequestBody BoardDTO boardDTO) {
        Board newBoard = new Board();
        newBoard.setWriter(member);
        newBoard.setTitle(boardDTO.getTitle());
        newBoard.setContent(boardDTO.getContent());
        newBoard.setCategoryId(boardDTO.getCategoryId());
        newBoard.setLike(0L);
        newBoard.setHits(0L);

        boardService.registerNewBoard(newBoard);
        return "success";
    }

 

@AuthenticationPrincipal : UserDetails를 구현한 인증객체를 주입할 때 사용하는 어노테이션.  요청을 보낸 사용자가 인증된 경우SecurityContextHolder라는 곳에 저장된 인증객체(현재사용자의 정보!)를 꺼내와 넣어주는 역할! 나같은 경우는 Member자체가 UserDetails를 구현하도록 만들었기 때문에 Member로 받도록 했다.

 

결과는..

 

 

 

서..성공!

 

첫 질문으로 들어와서, 그럼 게시글 등록을 할 때 Board엔티티의 writer필드를 String으로 하는게 맞는걸까 아님 Member로 하는게 맞는걸까? 이는 연관관계 매핑이라는 것과 관련이 있다고 한다. 이것에 대해서 알아봐야겠다.

@Value를 사용해 application.properties에 적은 값을 가져오는 과정, 구체적으론 jwt secret key를 가져오는 과정에서 null값을 가져오는 문제가 생겨버렸다.

 

이상하게도 AccountController에서 jwt관련 기능들을 쓸 때는 null이 아니라 제대로 된 값을 가져오는데, 필터 쪽에서 jwt 토큰 인증을 할 때에만 null로 가져오는 게 문제였다..

 

원인은 찾아보니 알 수 있었다. 바로 AccountController에서는 jwt key를 담는 멤버변수가 있는 객체가 빈으로 등록된 애였지만, 같은 jwt key를 담는 멤버변수를 가지는 필터는 빈으로 등록된 애가 아니었던 것

 

public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http.csrf().disable() // CSRF 공격에 대한 방어를 해제
                .cors().and() // cors 허용
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 서버를 stateless하게 유지 즉 세션 X
                .and()
                .formLogin().disable() // 시큐리티가 기본 제공하는 로그인 화면 없앰. JWT는 로그인과정을 수동으로 클래스로 만들어야 하니까
                .httpBasic().disable() // 토큰 방식을 이용할 것이므로 보안에 취약한 HttpBasic은 꺼두기
                .authorizeRequests() // HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다
                .requestMatchers("/account/signup", "/account/login", "/").permitAll() // 요 세 놈에 대한 요청은 인증없이 접근 허용
                .anyRequest().authenticated() // 나머지에 대해선 인증을 받아야 한다.
                .and()
                // 여러 필터들 중 UsernamePassword필터 앞에 내가 만든 필터를 둔다. 이렇게 하면 커스텀 필터로 인가인증을 다룰 수 있음
                .addFilterBefore(new JwtAuthenticationFilter(tokenProvider),
                        UsernamePasswordAuthenticationFilter.class)
                .build();
    }

 

보이다시피 new 연산자를 통해 새로운 필터객체를 만들어서 끼워주는 모습을 볼 수 있다. 이 필터객체가 @Value 어노테이션을 통해 jwt key를 가져와야 하던 놈이다..근데 빈이 아니라 못 가져오던 것! 뭐, 해결법은 간단하다. 필터를 빈으로 만들어 끼우면 된다! 

 

private final JwtAuthenticationFilter jwtAuthenticationFilter;

public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http.csrf().disable() // CSRF 공격에 대한 방어를 해제
                .cors().and() // cors 허용
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 서버를 stateless하게 유지 즉 세션 X
                .and()
                .formLogin().disable() // 시큐리티가 기본 제공하는 로그인 화면 없앰. JWT는 로그인과정을 수동으로 클래스로 만들어야 하니까
                .httpBasic().disable() // 토큰 방식을 이용할 것이므로 보안에 취약한 HttpBasic은 꺼두기
                .authorizeRequests() // HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다
                .requestMatchers("/account/signup", "/account/login", "/").permitAll() // 요 세 놈에 대한 요청은 인증없이 접근 허용
                .anyRequest().authenticated() // 나머지에 대해선 인증을 받아야 한다.
                .and()
                // 여러 필터들 중 UsernamePassword필터 앞에 내가 만든 필터를 둔다. 이렇게 하면 커스텀 필터로 인가인증을 다룰 수 있음
                .addFilterBefore(jwtAuthenticationFilter,
                        UsernamePasswordAuthenticationFilter.class)
                .build();
    }

 

 

결론!

@Value어노테이션을 통해 값이 멕여지는 멤버변수를 가지는 객체는 빈으로 등록돼있어야 값을 잘 가져온다.


근데 왜 Bean으로 등록해야 가져와지는 거지?

결론적으로 Bean 생성과정에서 @Value가 inject되기 때문..

구체적인 과정들은 현재 내 수준에선 뭔 말인지 잘 모르기 때문에 생략(ㅠ)

 


 

참고한 링크

https://wildeveloperetrain.tistory.com/143

 

@Value 어노테이션 null이 나오는 문제 해결 방법

@Value annotation을 사용하여 properties에 있는 메타 정보 값을 가져오는 과정에서, 값이 null으로 들어오는 문제를 해결하며 기록한 내용입니다. @Value Annotation 쉽게 @Value 어노테이션은 데이터베이스

wildeveloperetrain.tistory.com

https://duooo-story.tistory.com/32

 

@Value는 어디서 set이 되는걸까?

프로젝트에서 reousrce로 등록한 데이터를 @value를 통해서 데이터를 받아 사용하고 있습니다. 이 기능 덕분에 각 존별로 다른데이터를 코드의 분기처리없이 사용하고 있습니다. @Configuration public cla

duooo-story.tistory.com

 

우선 현재 내 mysql에는 member 테이블밖에 없다. 게시글 등록을 위해서는 Category 테이블과 SubCategory테이블, 그리고 Board테이블을 만들어야 한다!

 

우선 Category테이블부터 시작했다.

 

 

원래는 카테고리id라는 컬럼은 없고 카테고리명을 pk로 해서 만들라고 했는데, 멘토님이 따로 id컬럼을 만드는 걸 추천해주셔서 반영했다. 나는 카테고리명 자체가 고유한 이름이 될 것이니까 pk로 둬도 된다고 생각했지만, 카테고리명을 나중에 바꾸게 되는 상황이 생길 때 카테고리명이 pk라면 대처가 어렵다고 말씀해주셨다. pk는 고유한 이름이 되는 것 뿐만 아니라  변경될 여지가 없는 컬럼에 박아주는게 좋다고 하셨다. 

 

또한 변경이 거의 없고 데이터를 소규모로 가지는 테이블에 한해서는 Enum으로 하면 좋다고 해서 카테고리명은 Enum으로 바꿔줬다. 

 

public enum Category {
    QNA, // QnA
    KNOWLEDGE, // 지식
    COMMUNITY, // 커뮤니티
    NOTICE // 공지사항
}

 

 그리고 위와 같이 Enum클래스를 만들어줬다. 만들면서 "근데 내가 왜 이 클래스를 작성하고 있지?" 라는 생각이 들었다. 사실 그에 대한 근거는 없다. 단순히 member를 등록하는 예제에서 멤버의 role에 Enum으로 작성한 것을 넣어줬던 기억이 있어, 나중에 게시글을 등록할 때도 Category값에 저 Enum을 넣어주지 않을까라는 추측에서 만든 거다. 실제로 쓰일지는 나중에 봐야 알 듯 하다.. 

 

 

암튼 이렇게 dokky에 category테이블이 잘 생겼다. 그리고 바로 쿼리문을 통해 category테이블들에 데이터들을 추가해줬다.

 

 

음..잘 된 것 같다. 이젠 SubCategory 테이블을 만들 차례.

 

 

다음과 같이 Enum 클래스를 만들어줬다.

 

public enum SubCategory {
    SKILL, // 기술 in QNA
    CAREER, // 커리어 in QNA
    COLUMN, // 칼럼 in 지식
    REVIEW, // 리뷰 in 지식
    DAILY, // 사는얘기 in 커뮤니티
    STUDY, // 스터디 in 커뮤니티
    NOTICE // 공지사항 in 공지사항
}

 

다음과 같이 쿼리를 멕여서 테이블을 만들어줬다.

 

CREATE TABLE `dokky`.`subcategory` (
  `sub_category_id` INT NOT NULL AUTO_INCREMENT,
  `parent_category_id` INT NOT NULL,
  `sub_category_name` ENUM("SKILL", "CAREER", "COLUMN", "REVIEW", "DAILY", "STUDY", "NOTICE") NOT NULL,
  FOREIGN KEY (`parent_category_id`) REFERENCES `category` (`category_id`),
  PRIMARY KEY (`sub_category_id`));

 

암튼 SubCategory테이블도 잘 생겼다. 간단한 실험을 위해 데이터 하나를 추가해보기로 했다! sql쿼리를 잘 모르는 만큼 외래키 값을 insert into를 통해 어떻게 넣는지 궁금했기 때문.

 

 

별 거 없었다. 참조하는 테이블에서 사용하는 값을 그냥 넣어주면 됐다..암튼 SubCategory까지 만드는데 성공!

마지막으로 Board 테이블을 만들 차례다.

 

 

created_date와 updated_date컬럼의 경우, 추가/삭제가 빈번하거나 추적하기 어려운 테이블에 넣어주면 좋다고 한다. 운영하는 관점에서 해당 데이터가 언제 수정되고 생성됐는지에 대한 정보는 중요하기 때문이라고 한다.

아 mysql에서 이미지는 BLOB타입으로 저장한다고 한다. 이유는 간단한데, 이미지는 바이너리 파일이고 BLOB이 그런 녀석을 위한 타입이어서이다. (BLOB = Binary Large Object의 약자)

 

 

이렇게 Board 테이블을 만들어준 후, 간단한 실험을 해보기로 했다! 이번엔 datetime값을 insert into를 통해 어떻게 넣는지 궁금했기 때문이다.

 

역시나 간단했다. datetime같은 경우 지금처럼 create_date에 주는거면 now()를 쓰면 된다고 한다!

 

insert into board (writer, title, content, hits, like, created_date, category_id) 
     values("success@google.com", "도서관에서 공부중", 
    "수지도서관 맛돌이네여ㅎㅎ 다들 한번씩 오세요", 0, 0, now(), 5);

 

근데 에러가 난다..문법적인 에러란다. 찾아보니까 like필드가 mysql에서 예약어로 사용되는 애들이라고 한다! 이 경우 이런 필드들을 ``으로 묶어주니까 간단하게 해결됐다.

 

 

잘 되는 것 같다!! 중간에 멤버테이블에 테스트용 데이터 급하게 추가해주고 오류나는거 찾아보느라 시간이 꽤 걸렸다,,ㅠ

hits와 like, created_date의 경우 기본값을 설정해주는 게 좋을 것 같은데(각자 0, 0, 만들어지는 시간으로), 이걸 DB에서 지금 설정해주는게 맞는지 스프링 쪽에서 해줘야 되는지는 모르겠다. 일단 보류하기로 했다.

 

 

이제 DB 만지는 거에서 넘어와서, 게시글의 Entity 클래스를 만들어줄 차례..근데 여기서 많이 헷갈렸다. 찾아보니까 이것저것 이상한게 많기 때문

 

  • 방금 mysql에서 테이블만들 땐 Board테이블의 writer 컬럼에 member의 pk, 즉 이메일을 했는데 그걸 그대로 쓰면 안되는건가? Entity클래스 설계할 땐 Member객체를 필드로 줘야 하남?
  • 이미지는 어떻게 주지?
  • 시간은 어떻게 처리하지? 그냥 따로 시간에 대한 컬럼 만들지말고 DB에서 알아서 하게 해야 하나?

 

등등..

뭐 그래도 모르는 걸 쭉 뽑아 놓았으니, 하나하나 찾으면 된다!

 

@Getter
@Setter
@Entity
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_id")
    private Long boardId;

    private String writer;

    @Column(length = 80)
    private String title;

    @Column(columnDefinition = "TEXT") // 필드의 타입을 텍스트로 지정
    private String content;

    private Long hits;
    private Long like;

    @Column(name = "created_date")
    private LocalDateTime createdDate;

    @Column(name = "updated_date", nullable = true)
    private LocalDateTime updatedDate;
}

 

이렇게 하고 다음과 같은 테스트 코드를 실행해줬다.

 

@SpringBootTest
@Transactional
class BoardServiceIntegrationTest {
    @Autowired BoardService boardService;

    @DisplayName("게시글 등록 테스트")
    @Test
    void registerNewBoard() {
        Board board = new Board();
        board.setWriter("success@google.com");
        board.setTitle("제에목");
        board.setContent("보오오오오오온문");
        board.setHits(0L);
        board.setLike(0L);
        board.setCreatedDate(LocalDateTime.now());

        Long boardId = boardService.registerNewBoard(board).getBoardId();

        Board foundBoard = boardService.findBoard(boardId).get();
        System.out.println(foundBoard.getWriter());
        System.out.println(foundBoard.getTitle());
        System.out.println(foundBoard.getContent());
        System.out.println(foundBoard.getCreatedDate());
        assertThat(foundBoard.getBoardId()).isEqualTo(boardId);
    }
}

 

근데 에러가 난다..! 알아보니, Board클래스의 like필드가 문제였다. 아까와 마찬가지로 mysql에서 쓰는 예약어였기 때문에, 별도의 조치가 필요했다.

 

다음과 같은 방법으로 해결해줬다. 

 

    @Column(name = "`like`")
    private Long like;

 

ㅋㅋㅋㅋㅋ 컬럼이름을 ``로 감싸주기! 진짜 야매가 따로 없다 이게 아니면 application.properties에 다음을 추가해줘도 된다고 함.

 

spring.jpa.properties.hibernate.globally_quoted_identifiers=true

 

이 값을 요렇게 true로 설정해주면 sql문이 실행될 때 테이블과 컬럼명이 자동으로 ``로 감싸진다고 한다.

암튼 그렇게 한 결과..

 


조금 develop을 해보자. 매번 이렇게 게시글 생성할 때마다 LocalDateTime.now()를 박아줘야 할까? (참고로 요놈은 스태틱 메서드로 현재 시간 객체를 반환해줌) 귀찮다. 이를 위해 @CreatedDate라는 어노테이션이 존재하는데, 이 놈은 엔티티가 생성되어 저장될 때 자동으로 생성시간을 박아준다.

 

    @CreatedDate // Entity가 생성되어 저장될 때 시간이 자동으로 저장되게 하는 어노테이션
    private LocalDateTime createdDate;

 

즉 테스트 코드에서 더 이상 setter를 통해 LocalDateTime.now()를 박아줄 필요가 없을 것이다! 그러나,,

 

 

ㅌ..통수 지대로 당해버렸다. @CreatedDate박으면 시간이 알아서 들어가진다며! 근데 null이 들어가버려 오류가 뜨는 모습;

찾아보니까, Auditing이란 것이 제대로 적용되지 않아 발생한다고 한다. Audit이라고 해서 JPA에서 제공하는 게 있는데 이게 바로 생성일/ 수정일 등의 자동화를 돕는다고 한다. CreatedDate같은 건 이 녀석의 일종인 거고.

 

암튼, 다음과 같이 해결가능하다. 먼저 JpaAuditing을 쓰기 위해 main메서드가 있는 클래스에 다음과 같은 어노테이션을 먹여준다.

 

@EnableJpaAuditing // 바로 이거! JpaAuditing을 활성화하는 기능
@SpringBootApplication
public class DokkyApplication {

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

}

 

그 다음 Entity클래스에 다음과 같은 어노테이션을 멕인다.

 

@Getter
@Setter
@Entity
@EntityListeners(AuditingEntityListener.class) // 이거! 이 클래스에 Auditing기능을 멕인다는 의미
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_id")
    private Long boardId;

    private String writer;
    
    // .. 생략

 

결과는..

 

 

테스트에 성공하는 모습!!

+ Recent posts