[LG CNS AM CAMP 1기] 백엔드 II 5 | SpringBoot

letthem·2025년 1월 24일
0

LG CNS AM CAMP 1기

목록 보기
21/31
post-thumbnail

참고: 파일 업로드 취약점

외부에서 업로드하는 파일의 크기와 개수, 종류를 제한하지 않고, 외부에서 접근 가능한 경로에 파일을 저장했을 때 발생
               ~~~~~~~~~~~~~  ~~~~~~~~~        ~~~~~~~~~~~~~~~~~~~~~~~~~
                    |                   |           서버에서 실행 가능한 파일을 외부에서 실행
                    |                   +-- 서버에서 실행 가능한 파일 또는 악성 행위를 수행하는 파일을 
                    |                       업로드 가능
                    |                       ⇒ 서버의 제어권을 탈취 (웹쉘)
                    |                       ⇒ 해당 서버가 악성 코드 유포지로 악용
                    +-- 서버의 연결 자원과 디스크(저장소) 자원을 고갈시켜 정상적인 서비스를 방해
방어 기법
1. 외부에서 업로드하는 파일의 크기, 개수, 종류를 제한 
                                 ~~~~~~~~~~~
                                 확장자 검증, Content-Type 검증, Magic Code(Number)
2. 업로드 파일을 외부에서 접근할 수 없는 경로에 저장 ⇒ 저장된 파일을 제공하려면 
              ~~~~~~~~~~~~~~~~~~~~~~~~~~~~  다운로드 기능이 필요
              Web Document Root 밖의 디렉터리를 의미 
3. 업로드 파일명, 확장자, 저장경로 등을 외부에서 알 수 없도록 변경해서 사용
4. 파일의 실행 권한을 제거하고 저장     

BoardServiceImpl

    @Override
    public void insertBoard(BoardDto boardDto, MultipartHttpServletRequest request) {
        // 로그인한 사용자를 글쓴이로 설정
        // TODO. 로그인한 사용자의 ID로 변경
        boardDto.setCreatedId("hong");
        boardMapper.insertBoard(boardDto);
        
        try {
            // 첨부 파일을 디스크에 저장하고, 첨부 파일 정보를 반환
            List<BoardFileDto> fileInfoList = fileUtils.parseFileInfo(boardDto.getBoardIdx(), request);
            
            // 첨부 파일 정보를 DB에 저장
            if (!CollectionUtils.isEmpty(fileInfoList)) {
                boardMapper.insertBoardFileList(fileInfoList);
            }
        } catch(Exception e) {
            log.error(e.getMessage());
        }           
    }

BoardMapper

@Mapper
public interface BoardMapper {
    List<BoardDto> selectBoardList();
    void insertBoard(BoardDto boardDto);
    BoardDto selectBoardDetail(int boardIdx);
    void updateBoard(BoardDto boardDto);
    void deleteBoard(BoardDto boardDto);
    void updateHitCnt(int boardIdx);
    void insertBoardFileList(List<BoardFileDto> fileInfoList);
}

sql-board.xml

    <!--
        외부 입력값을 쿼리에 반영할 경우, #{ }을 이용해야 SQL 인젝션 공격을 방어할 수 있음
        useGeneratedKeys : DBMS가 자동 생성한 키를 사용 
        keyProperty : 반환하는 키 
     -->
    <insert id="insertBoard" parameterType="board.dto.BoardDto" 
        useGeneratedKeys="true" keyProperty="boardIdx">
        insert into t_board(title, contents, created_dt, created_id)
        values (#{title}, #{contents}, now(), #{createdId})
    </insert>

    <insert id="insertBoardFileList" parameterType="board.dto.BoardFileDto">
        insert into t_file 
            (board_idx, original_file_name, stored_file_path, file_size, created_id, created_dt)
        values 
        <foreach collection="list" item="item" separator=",">
            (#{item.boardIdx}, #{item.originalFileName}, #{item.storedFilePath}, #{item.fileSize}, 'admin', now())
        </foreach>
    </insert>

첨부 파일 목록을 출력하는 기능을 추가

sql-board.xml

    <select id="selectBoardFileList" parameterType="int" resultType="board.dto.BoardFileDto">
        select idx, board_idx, original_file_name, format(round(file_size/1024), 0) as file_size
          from t_file
         where board_idx = #{boardIdx} and deleted_yn = 'N'
    </select>

BoardDto 수정

@Data
public class BoardDto {
    private int boardIdx;
    private String title;
    private String contents;
    private int hitCnt;
    private String createdDt;
    private String createdId;
    private String updatorDt;
    private String updatorId;
    
    // 첨부 파일 정보를 저장할 필드를 추가
    private List<BoardFileDto> fileInfoList;
}

BoardMapper

@Mapper
public interface BoardMapper {
    List<BoardDto> selectBoardList();
    void insertBoard(BoardDto boardDto);
    BoardDto selectBoardDetail(int boardIdx);
    void updateBoard(BoardDto boardDto);
    void deleteBoard(BoardDto boardDto);
    void updateHitCnt(int boardIdx);
    void insertBoardFileList(List<BoardFileDto> fileInfoList);
    List<BoardFileDto> selectBoardFileList(int boardIdx);
}

BoardServiceImpl

게시판 상세 조회 시 첨부 파일을 조회해서 상세 조회 결과에 추가

    @Override
    public BoardDto selectBoardDetail(int boardIdx) {
        boardMapper.updateHitCnt(boardIdx);

        BoardDto boardDto = boardMapper.selectBoardDetail(boardIdx);
        List<BoardFileDto> boardFileInfoList = boardMapper.selectBoardFileList(boardIdx);
        boardDto.setFileInfoList(boardFileInfoList);
        
        return boardDto;
    }

boardDetail.html

        </form>
        
        <!-- 첨부파일 목록 -->
        <div class="file_list">
            <a th:each="file : ${board.fileInfoList}" th:text="|${file.originalFileName} (${file.fileSize}kb)|"></a>
        </div>
        
        <input type="button" id="list" class="btn" value="목록으로" />
        <input type="button" id="update" class="btn" value="수정하기" />
        <input type="button" id="delete" class="btn" value="삭제하기" />

결과 확인

파일 다운로드 기능을 추가

boardDetail.html

        <!-- 첨부파일 목록 -->
        <div class="file_list">
            <a th:each="file : ${board.fileInfoList}" 
                th:text="|${file.originalFileName} (${file.fileSize}kb)|"
                th:href="@{/board/downloadBoardFile.do(idx=${file.idx}, boardIdx=${file.boardIdx})}"></a>
        </div>

브라우저로 전달된 내용을 확인

        <!-- 첨부파일 목록 -->
        <div class="file_list">
            <a href="/board/downloadBoardFile.do?idx=3&boardIdx=7">또강아지.png (45kb)</a>
     <a href="/board/downloadBoardFile.do?idx=4&boardIdx=7">강아지.jpg (7kb)</a>
        </div>

sql-board.xml

    <select id="selectBoardFileInfo" parameterType="map" resultType="board.dto.BoardFileDto">
        select original_file_name, stored_file_path, file_size
          from t_file
         where idx = #{idx} and board_idx = #{boardIdx} and deleted_yn = 'N'
    </select>

BoardMapper

@Mapper
public interface BoardMapper {
    List<BoardDto> selectBoardList();
    void insertBoard(BoardDto boardDto);
    BoardDto selectBoardDetail(int boardIdx);
    void updateBoard(BoardDto boardDto);
    void deleteBoard(BoardDto boardDto);
    void updateHitCnt(int boardIdx);
    void insertBoardFileList(List<BoardFileDto> fileInfoList);
    List<BoardFileDto> selectBoardFileList(int boardIdx);
    BoardFileDto selectBoardFileInfo(@Param("idx") int idx, @Param("boardIdx") int boardIdx);
}

BoardService

public interface BoardService {
    List<BoardDto> selectBoardList();
    void insertBoard(BoardDto boardDto, MultipartHttpServletRequest request);
    BoardDto selectBoardDetail(int boardIdx);
    void updateBoard(BoardDto boardDto);
    void deleteBoard(int boardIdx);
    BoardFileDto selectBoardFileInfo(int idx, int boardIdx);
}

BoardServiceImpl

    @Override
    public BoardFileDto selectBoardFileInfo(int idx, int boardIdx) {
        return boardMapper.selectBoardFileInfo(idx, boardIdx);
    }

BoardController

    // 파일 다운로드 요청을 처리하는 메서드 
    @GetMapping("/board/downloadBoardFile.do")
    public void downloadBoardFile(@RequestParam("idx") int idx, @RequestParam("boardIdx") int boardIdx, HttpServletResponse response) throws Exception {
        // idx와 boardIdx가 일치하는 파일 정보를 조회
        BoardFileDto boardFileDto = boardService.selectBoardFileInfo(idx, boardIdx);
        if (ObjectUtils.isEmpty(boardFileDto)) {
            return;
        }
        
        // 원본 파일 저장 위치에서 파일을 읽어서 호출(요청)한 곳으로 파일을 응답으로 전달
        Path path = Paths.get(boardFileDto.getStoredFilePath());
        byte[] file = Files.readAllBytes(path);
        
        response.setContentType("application/octet-stream");	⇐ 브라우저가 해당 콘텐츠를 바이너리 파일로 처리하도록 지정
        response.setContentLength(file.length);		⇐ 다운로드할 파일의 크기를 명시
        response.setHeader("Content-Disposition", 		⇐ 브라우저가 파일을 다운로드하도록 지시
"attachment; fileName=\"" + URLEncoder.encode(boardFileDto.getOriginalFileName(), "UTF-8") + "\";");
        response.setHeader("Content-Transfer-Encoding", "binary");	⇐ 응답 본문을 바이너리로 전송하도록 지정
        response.getOutputStream().write(file);
        response.getOutputStream().flush();
        response.getOutputStream().close();
    }

참고 => https://myanjini.tistory.com/entry/test

예외처리

공통 예외 페이지를 만들어서 어플리케이션에서 발생하는 예외를 처리 => @ControllerAdvice 어노테이션을 이용

사용자 정의 에러 컨트롤러를 생성

common.ErrorHandler

package board.common;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.servlet.ModelAndView;

@ControllerAdvice
@Slf4j
public class ErrorHandler {
    @ExceptionHandler(Exception.class)
    public ModelAndView defaultExceptionHandler(HttpServletRequest request, Exception exception) {
        ModelAndView mv = new ModelAndView("/error/default");
        mv.addObject("exception", exception);
        mv.addObject("request", request);
        log.error("exception >>> ", exception);
        return mv;
    }
}

공통 예외 처리 페이지를 생성

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>공통 에러 페이지</title>
    <style>
        body {
            font-family: Arial, sans-serif; line-height: 1.6; background-color: #f9f9f9; color: #333;
        }

        h1 {
            background-color: #f44336; color: white; padding: 20px; text-align: center;
        }

        .error-details {
            margin: 20px auto; padding: 20px; border: 1px solid #ccc; border-radius: 5px; background-color: white;
            max-width: 800px; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
        }

        .error-details p {
            margin: 10px 0;
        }

        .toggle-button {
            display: inline-block; margin-top: 10px; padding: 10px 20px; font-size: 14px; color: white;
            background-color: #007bff; border: none; border-radius: 5px; cursor: pointer;
        }

        .toggle-button:hover {
            background-color: #0056b3;
        }

        .hidden {
            display: none;
        }

        ul {
            list-style: none;
            padding-left: 20px;
        }

        ul li {
            font-family: monospace; font-size: 14px;
        }
    </style>
</head>
<body>
<h1>공통 에러 페이지</h1>
<div class="error-details">
    <p><strong>요청 URL:</strong> <span th:text="${request.getRequestURI()}"></span></p>
    <p><strong>오류 메시지:</strong> <span th:text="${exception.message}"></span></p>

    <h2>오류 상세 내용</h2>
    <button class="toggle-button" onclick="toggleDetails()">보기</button>
    <ul id="error-details-list" class="hidden" th:each="line : ${exception.stackTrace}" th:text="${line.toString()}"></ul>
</div>

<script>
    function toggleDetails() {
        const detailsList = document.getElementById("error-details-list");
        const button = document.querySelector(".toggle-button");
        if (detailsList.classList.contains("hidden")) {
            detailsList.classList.remove("hidden");
            button.textContent = "숨기기";
        } else {
            detailsList.classList.add("hidden");
            button.textContent = "보기";
        }
    }
</script>
</body>
</html>

기본 오류 페이지를 비활성화

방법 1. application.properties

spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfuration

방법 2. BoardApplication.java

@EnableAutoConfiguration(exclude = { ErrorMvcAutoConfiguration.class }) 어노테이션 추가

@Slf4j
@SpringBootApplication
@EnableAutoConfiguration(exclude = { ErrorMvcAutoConfiguration.class })
public class BoardApplication {

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

실습 문제

설 연휴 주까지 !!!!!! 꺄아가가각가꺄아꺄아꺄아꺙까아꺄아꺄아야야까

아래의 샘플 테이블과 데이터 또는 자바 미니 프로젝트에서 사용했던 데이터 구조를 이용해서 CRUD 기능을 구현해 보세요

프로젝트 생성해서 CRUD 기능 (등록, 목록 조회, 상세 조회, 수정, 삭제, 파일 업로드/다운로드)

작성한 프로젝트는 git 주소 또는 압축파일로 제출

도서관리

CREATE TABLE Books (
    book_id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 고유 식별자
    title VARCHAR(255) NOT NULL,               -- 도서 제목
    author VARCHAR(255) NOT NULL,              -- 저자
    publisher VARCHAR(255),                    -- 출판사
    published_date DATE,                       -- 출판일
    isbn VARCHAR(20) UNIQUE,                   -- ISBN 번호
    description TEXT,                          -- 도서 설명
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 생성 시각
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -- 수정 시각
);

CREATE TABLE BookImages (
    image_id BIGINT AUTO_INCREMENT PRIMARY KEY,     -- 고유 식별자
    book_id BIGINT NOT NULL,                        -- 도서와의 외래 키 관계
    image_url VARCHAR(500) NOT NULL,                -- 이미지 URL
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 생성 시각
    FOREIGN KEY (book_id) REFERENCES Books(book_id) ON DELETE CASCADE -- 도서 삭제 시 이미지도 삭제
);

INSERT INTO Books (title, author, publisher, published_date, isbn, description)
VALUES 
('Clean Code 클린 코드', '로버트 C. 마틴', '인사이트(insight)', '2013-12-24', '9788966260959', '애자일 소프트웨어 장인 정신'),
('스프링 부트 3 백엔드 개발자 되기 : 자바 편 (2판)', '신선영', '골든래빗', '2024-04-05', '9791191905717', 'JPA+OAuth2+JWT+AWS와 배우는 스프링 부트 3 Java 백엔드 입문자를 위한 풀 패키지'),
('GitOps Cookbook', '나탈리 빈토, 알렉스 소토 부에노', '인사이트(insight)', '2024-11-26', '9788966264537', '커스터마이즈, 헬름, 텍톤, Argo CD를 활용한 쿠버네티스 CI/CD 구축하기');

INSERT INTO BookImages (book_id, image_url)
VALUES 
(1, 'https://image.yes24.com/goods/11681152/XL'),
(2, 'https://image.yes24.com/goods/125668284/XL'),
(3, 'https://image.yes24.com/goods/138929285/XL');

영화정보관리

CREATE TABLE Movies (
    movie_id BIGINT AUTO_INCREMENT PRIMARY KEY,       -- 영화 고유 식별자
    title VARCHAR(255) NOT NULL,                      -- 영화 제목
    director VARCHAR(255) NOT NULL,                   -- 감독 이름
    release_date DATE,                                -- 개봉일
    genre VARCHAR(100),                               -- 장르
    rating DECIMAL(3, 1),                             -- 평점 (예: 8.5)
    description TEXT,                                 -- 영화 설명
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,   -- 생성 시각
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -- 수정 시각
);

CREATE TABLE MoviePosters (
    poster_id BIGINT AUTO_INCREMENT PRIMARY KEY,      -- 포스터 고유 식별자
    movie_id BIGINT NOT NULL,                         -- 영화 ID (외래 키)
    poster_url VARCHAR(500) NOT NULL,                 -- 포스터 URL
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,   -- 생성 시각
    FOREIGN KEY (movie_id) REFERENCES Movies(movie_id) ON DELETE CASCADE -- 영화 삭제 시 포스터도 삭제
);

INSERT INTO Movies (title, director, release_date, genre, rating, description)
VALUES 
('검은 수녀들', '권혁재', '2025-01-24', '미스터리,드라마', 9.0, '금지된 곳으로 갈 준비가 되었습니다.'),
('하얼빈', '우민호', '2024-12.24', '드라마,액션,스릴러,느와르,첩보,시대극', 9.3, '안중근 의사 하얼빈 의거를 다룬 우민호 감독의 여섯 번째 장편 영화. 제49회 토론토 국제 영화제 갈라 프레젠테이션 부문 공식 초청작이다.');

INSERT INTO MoviePosters (movie_id, poster_url)
VALUES 
(1, 'https://img.cgv.co.kr/Movie/Thumbnail/Poster/000089/89398/89398_320.jpg'),
(1, 'https://img.cgv.co.kr/Movie/Thumbnail/StillCut/000089/89398/89398233560_727.jpg'),
(2, 'https://img.cgv.co.kr/Movie/Thumbnail/Poster/000088/88797/88797_320.jpg');

실습

상욱슨의 티칭과 함께 해보기 !!!!!

🦴 뼈대 만들기

설정

spring.io 새 프로젝트 생성

DB 스키마 생성

빌드 도구 설정 IntelliJ IDEA로 변경

application.yml로 변경

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/[만든 db 스키마 이름]?useSSL=false&serverTimezone=Asia/Seoul&characterEncoding=UTF-8
    username: [username]
    password: [비밀번호]
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: create

entity 생성

domain/BaseEntity

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {

    @CreatedDate
    @Column(name = "created_at", columnDefinition = "TIMESTAMP(6)")
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(name = "updated_at", columnDefinition = "TIMESTAMP(6)")
    private LocalDateTime updatedAt;
}

Movie : MoviePoster = 1 : n

domain/Movie (1)

package com.practice.demo.domain;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDate;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Movie extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private String director;
    
    @Column(name = "release_date")
    private LocalDate releaseDate;
    
    private String genre;
    
    private Float rating;
    
    private String description;
}
  • @Entity : DB의 entity임을 선언
  • @Getter : Getter 만들어주는 것
  • @Builder : 생성자로 생성하지 않고 빌더 패턴으로 생성하면 넣고 싶은 것만 넣어서 생성할 수 있다.
  • @AllArgsConstructor : 모든 인자를 가진 생성자를 만들어준다.
  • @NoArgsConstructor : 아무것도 없는 디폴트 생성자를 만들어준다.

domain/MoviePoster (n)

package com.practice.demo.domain;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDate;

import static jakarta.persistence.FetchType.*;
import static jakarta.persistence.GenerationType.*;

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "movie_poster")
public class MoviePoster extends BaseEntity {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @Column(name = "poster_url")
    private String posterUrl;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "movie_id")
    private Movie movie;
}

여기서

@ManyToOne(fetch = LAZY)
@JoinColumn(name = "movie_id")
  • MoviePoster이니 자기 기준 @ManyToOne
  • LAZY(지연 로딩) - 불필요한 연산 방지용
  • movie_id를 외래키로 join 한다.
    연관관계의 주인은 Movie이다
    포스터가 movie_id를 외래키로 갖고 있음

조건 중 영화 삭제 시 포스터도 삭제된다는 조건이 있다.
CASCADE를 걸어줘야 한다.
=> 그러려면 양방향 설정을 해줘야한다. Movie에도 @OneToMany 걸어주기 !!

domain/Movie

@OneToMany(mappedBy = "movie", cascade = CascadeType.REMOVE, orphanRemoval = true)
private List<MoviePoster> moviePosterList = new ArrayList<>();
  • Movie 입장에선 @OneToMany
  • Many인 MoviePoster가 List로 되게 !

Repository 생성

  • 엔티티 별로 생성해줘야함
  • JPARepository를 상속받자 !!! interface이므로 extends.

MovieRepository

public interface MovieRepository extends JpaRepository<Movie, Long> {
}

MoviePosterRepository

public interface MoviePosterRepository extends JpaRepository<MoviePoster, Long> {
}

Service 생성

MovieService

public interface MovieService {
}

MovieServiceImpl

@Service
@RequiredArgsConstructor
public class MovieServiceImpl implements MovieService{
    private final MovieRepository movieRepository;
}

@RequiredArgsConstructor 역할은 서비스에서 repository를 사용할건데 @Autowired 해서 생성자까지 만들어서 의존관계를 주입을 했는데 @RequiredArgsConstructor 이 어노테이션을 쓰면 @Autowired 해서 생성자까지 만들 필요가 없어진다.

MoviePosterService와 Impl도 똑같이 만들어준다.

Controller 생성

서비스를 가져다 뷰에 쓰는 것 !

서비스를 델꼬오자.

MovieController

@Controller
@RequiredArgsConstructor
public class MovieController {
    private final MovieService movieService;
}

MoviePosterController

@Controller
@RequiredArgsConstructor
public class MoviePosterController {
    private final MoviePosterService moviePosterService;
}

dto 생성

전체 폴더구조


🍖 CRUD 만들기

Dto 내부에 dto 레코드 생성

service에서 return 하는 애를 dto로 필요한 애들만 쏙쏙 골라내기

  • record 로 생성 : DTO는 읽기 전용이므로 !!

dto - MovieRequest

@Builder
public record MovieRequest() {
}

dto - MoviePosterDto

@Builder
public record MoviePosterDto() {
}

CRUD를 service에서 짜보자 !!

1. 등록

dto - MovieRequest

package com.practice.demo.dto.movie;

import lombok.Builder;

import java.time.LocalDate;

@Builder
public record MovieRequest(
String title,
String director,
LocalDate releaseDate,
String genre,
Float rating,
String description) {
}



#### domain/Movie
- static 붙이면 클래스만 있으면 할당됨. 객체를 생성해서 메서드를 쓸 수 있는 게 아니라 클래스.메서드로 메서드 바로 사용할 수 있도록 static을 붙였다.

- builder 패턴으로 Movie 라는 객체를 생성한다.
```java
public static Movie create(String title, String director, LocalDate releaseDate, String genre, Float rating, String description) {
    return Movie.builder()
            .title(title)
            .director(director)
            .releaseDate(releaseDate)
            .genre(genre)
            .rating(rating)
            .description(description)
            .build();
}

MovieService

함수 선언

public interface MovieService {
    void saveMovie(MovieDto movieDto);
}

MovieServiceImpl

함수 구현

@Service
@RequiredArgsConstructor
@Transactional
public class MovieServiceImpl implements MovieService{
    private final MovieRepository movieRepository;
    private final MoviePosterRepository moviePosterRepository;

    @Override
    public void saveMovie(MovieDto movieDto) {
        Movie movie = Movie.create(
                movieDto.title(),
                movieDto.director(),
                movieDto.releaseDate(),
                movieDto.genre(),
                movieDto.rating(),
                movieDto.description()
        );
        
        movieRepository.save(movie);
    }
}

2. 목록 조회 (전체 조회)

dto - MovieResponse

보여줄 애들만 쏙쏙 !!
movieId 추가, description 삭제

package com.practice.demo.dto.movie.response;

import lombok.Builder;

import java.time.LocalDate;

@Builder
public record MovieResponse(
        Long movieId,
        String title,
        String director,
        LocalDate releaseDate,
        String genre,
        Float rating
) {
    public static MovieResponse of(Long movieId, String title, String director, LocalDate releaseDate, String genre, Float rating) {
        return MovieResponse.builder()
        		.movieId(movieId)
                .title(title)
                .director(director)
                .releaseDate(releaseDate)
                .genre(genre)
                .rating(rating)
                .build();
    }
}

MovieService

List<MovieResponse> showMovieList();

MovieServiceImpl

// Read - 목록 조회
@Override
public List<MovieResponse> showMovieList() {
    List<Movie> movies = movieRepository.findAll();

    List<MovieResponse> movieResponses = movies.stream()
            .map(m -> MovieResponse.of(
                    m.getId(),
                    m.getTitle(),
                    m.getDirector(),
                    m.getReleaseDate(),
                    m.getGenre(),
                    m.getRating()
            ))
            .toList();

    return movieResponses;

    // TODO: 영화 포스터 썸네일 포함시키기
}
  • movies 객체에 전체 다 불러옴
  • movieResponses 리스트: movies에서 stream으로 map함수를 써서 아까 만든 dto 의 of 함수를 사용해 movieResponse 객체에 movie 객체로부터 필요한 데이터를 넣어서 반환

3. 상세 조회

dto - MovieDetailResponse

package com.practice.demo.dto.movie.response;

import lombok.Builder;

import java.time.LocalDate;

@Builder
public record MovieDetailResponse(
        Long movieId,
        String title,
        String director,
        LocalDate releaseDate,
        String genre,
        Float rating,
        String description
) {
    public static MovieDetailResponse of(Long movieId, String title, String director, LocalDate releaseDate, String genre, Float rating, String description) {
        return MovieDetailResponse.builder()
        		.movieId(movieId)
                .title(title)
                .director(director)
                .releaseDate(releaseDate)
                .genre(genre)
                .rating(rating)
                .description(description)
                .build();
    }
}

MovieService

MovieDetailResponse showMovie(Long movieId);

MovieServiceImpl

// Read - 상세 조회
@Override
public MovieDetailResponse showMovie(Long movieId) {
    Movie movie = movieRepository.findById(movieId)
            .orElseThrow(() -> new RuntimeException());
    
    MovieDetailResponse movieDetailResponse = MovieDetailResponse.of(
            movie.getId(),
            movie.getTitle(),
            movie.getDirector(),
            movie.getReleaseDate(),
            movie.getGenre(),
            movie.getRating(),
            movie.getDescription()
    );
    
    return movieDetailResponse;
    
    // TODO: 영화 포스터 포함시키기
    // TODO: 예외 처리 구현하기
}

findById가 Optional 객체를 리턴하므로 Optional 안 쓰려면 예외처리 해줘야한다.

4. 수정

MovieService

void updateMovie(Long movieId, MovieRequest movieRequest); // Update - 수정

domain/Movie

public void update(String title, String director, LocalDate releaseDate, String genre, Float rating, String description) {
    if (title != null) {
        this.title = title;
    }
    if (director != null) {
        this.director = director;
    }
    if (releaseDate != null) {
        this.releaseDate = releaseDate;
    }
    if (genre != null) {
        this.genre = genre;
    }
    if (rating != null) {
        this.rating = rating;
    }
    if (description != null) {
        this.description = description;
    }        
}

MovieServiceImpl

// Update - 수정
@Override
public void updateMovie(Long movieId, MovieRequest movieRequest) {
    Movie movie = movieRepository.findById(movieId)
            .orElseThrow(() -> new RuntimeException());
    
    movie.update(
            movieRequest.title(),
            movieRequest.director(),
            movieRequest.releaseDate(),
            movieRequest.genre(),
            movieRequest.rating(),
            movieRequest.description()
    );

findById가 Optional 객체를 리턴하므로 Optional 안 쓰려면 예외처리 해줘야한다.

5. 삭제

MovieService

void deleteMovie(Long movieId); // Delete - 삭제

MovieServiceImpl

// Delete - 삭제
@Override
public void deleteMovie(Long movieId) {
    Movie movie = movieRepository.findById(movieId)
            .orElseThrow(() -> new RuntimeException());

    movieRepository.delete(movie);
}

테스트

MovieTestController

package com.practice.demo.controller;

import com.practice.demo.dto.movie.request.MovieRequest;
import com.practice.demo.dto.movie.response.MovieDetailResponse;
import com.practice.demo.dto.movie.response.MovieResponse;
import com.practice.demo.service.movie.MovieService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/movie")
public class MovieTestController {

    private final MovieService movieService;

    @PostMapping
    public ResponseEntity<?> create(@RequestBody MovieRequest movieRequest) {
        movieService.saveMovie(movieRequest);
        return ResponseEntity.ok("영화 저장 성공");
    }

    @GetMapping
    public ResponseEntity<List<MovieResponse>> readAll() {
        return ResponseEntity.ok(movieService.showMovieList());
    }

    @GetMapping("/{movieId}")
    public ResponseEntity<MovieDetailResponse> read(@PathVariable("movieId") Long movieId) {
        return ResponseEntity.ok(movieService.showMovie(movieId));
    }

    @PatchMapping("/{movieId}")
    public ResponseEntity<?> update(@PathVariable("movieId") Long movieId, @RequestBody MovieRequest movieRequest) {
        movieService.updateMovie(movieId, movieRequest);
        return ResponseEntity.ok("영화 수정 성공");
    }

    @DeleteMapping("/{movieId}")
    public ResponseEntity<?> delete(@PathVariable("movieId") Long movieId) {
        movieService.deleteMovie(movieId);
        return ResponseEntity.ok("영화 삭제 성공");
    }
}








0개의 댓글

관련 채용 정보