초기 세팅

gradle

buildscript {
    ext {
        queryDslVersion = "5.0.0"
    }
}

plugins {
    id 'java'
    id 'war'
    id 'org.springframework.boot' version '3.1.6'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    // 기본 라이브러리 ( spring initializer 로 추가하기)
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    // implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-websocket'
    implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
    implementation 'org.springframework.boot:spring-boot-starter-validation:3.1.1'
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.3.1'

    // querydsl 라이브러리
    // 참고: https://velog.io/@juhyeon1114/Spring-QueryDsl-gradle-%EC%84%A4%EC%A0%95-Spring-boot-3.0-%EC%9D%B4%EC%83%81
    implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta" // 기존에는 그냥 jpa, 3 버전 이상은 :jakarta 추가
    implementation "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta"
    // apt(annotation processing tool), Q도메인 생성. 컴파일 시에만 사용됨
    implementation "com.querydsl:querydsl-core"
    implementation "com.querydsl:querydsl-sql"

    // modelMapper: DTO 와 엔티티 간 변환 처리해주는 라이브러리
    implementation "org.modelmapper:modelmapper:3.2.0"
    implementation 'org.springframework.boot:spring-boot-starter-security'
    testImplementation 'junit:junit:4.13.1'

    compileOnly("org.projectlombok:lombok", "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta") // 롬복 외에도 querydsl 어노테이션 추가

    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'

    // 기존 롬복 이외도, jakarta 와 querydsl 어노테이션 추가 (spring boot 3.0 이상은 jakarta 대신 jakarta 사용)
    annotationProcessor(
            "org.projectlombok:lombok",

            'javax.persistence:javax.persistence-api:2.2', // 추가
            'javax.annotation:javax.annotation-api:1.3.2', // 추가

            "jakarta.persistence:jakarta.persistence-api",
            "jakarta.annotation:jakarta.annotation-api",
            "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta"); // querydsl-apt:jpa -> querydsl-apt:jakarta

    providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

}

tasks.named('bootBuildImage') {
    builder = 'paketobuildpacks/builder-jammy-base:latest'
}

tasks.named('test') {
    useJUnitPlatform()
}

// 프로젝트의 소스 코드 및 리소스 디렉토리 구성
// 개발 시 작성하는 java 파일의 위치 (src/main/java)와 Q도메인이 저장되는 위치(build/generated)를 명시
// 기존 파일과 Q도메인이 gradle 빌드 시 자동 컴파일 되게 함
sourceSets {
    main.java.srcDirs = ["$projectDir/src/main/java", "$projectDir/build/generated"]
}

properties

db 연결및 기타 속성 설정

server.port=8085
server.error.path=/error/

spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://localhost:3306/team16
spring.datasource.username=root
spring.datasource.password=1234

logging.level.package_name=info
logging.level.org.hibernate.type.descriptor.sql=info
logging.level.org.springframework.security=info

spring.servlet.multipart.enabled=false
spring.servlet.multipart.max-file-size=100MB
spring.servlet.multipart.max-request-size=600MB
spring.servlet.multipart.location=c:\\shop

spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true

MultipartConfig

spring boot 3.x.x 버전 부터 multipart 기능을 사용해야할 때 추가해야하는 클래스, 코드

package com.pro06.config;

import jakarta.servlet.MultipartConfigElement;
import org.modelmapper.ModelMapper;
import org.modelmapper.convention.MatchingStrategies;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.MultipartConfigFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.unit.DataSize;
import org.springframework.web.multipart.MultipartResolver;
import org.springframework.web.multipart.support.StandardServletMultipartResolver;

// spring boot 3 이상부터 multipart를 사용할때 이걸 만들어줘야 한다.
@Configuration
public class MultipartConfig {

    @Value("${spring.servlet.multipart.location}")
    String uploadPath;
    
    @Bean
    public MultipartResolver multipartResolver() {
        return new StandardServletMultipartResolver();
    }

    @Bean
    public ModelMapper getMapper() {
        ModelMapper modelMapper = new ModelMapper();
        modelMapper.getConfiguration()
                .setFieldMatchingEnabled(true)
                .setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE)
                .setMatchingStrategy(MatchingStrategies.LOOSE);

        return modelMapper;
    }
    
    @Bean
    public MultipartConfigElement multipartConfigElement() {
        MultipartConfigFactory factory = new MultipartConfigFactory();
        factory.setLocation(uploadPath);
        factory.setMaxRequestSize(DataSize.ofMegabytes(100L));
        factory.setMaxFileSize(DataSize.ofMegabytes(100L));

        return factory.createMultipartConfig();
    }
}

MvcConfig

thymeleaf를 사용하는 경우 외부 디렉토리를 지정해줘야 하기에 지정해주는 코드

package com.pro06.config;

import org.modelmapper.ModelMapper;
import org.modelmapper.convention.MatchingStrategies;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    String uploadPath = "file:///c:/shop/";

    private final long MAX_AGE_SECS = 3600;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/css/**").addResourceLocations("classpath:/static/css/");
        registry.addResourceHandler("/js/**").addResourceLocations("classpath:/static/js/");
        registry.addResourceHandler("/images/**").addResourceLocations("classpath:/static/images/");
        registry.addResourceHandler("/assets/**").addResourceLocations("classpath:/static/assets/");
        registry.addResourceHandler("/skydash-admin/**").addResourceLocations("classpath:/static/skydash-admin/");
        registry.addResourceHandler("/upload/**").addResourceLocations(uploadPath);
    }

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:8085")
                .allowedMethods("GET","POST","PUT","DELETE","PATCH","OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(MAX_AGE_SECS);
    }

    // model mapper
    @Bean
    public ModelMapper modelMapper() {
        ModelMapper modelMapper = new ModelMapper();
        modelMapper.getConfiguration()
                .setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE)
                .setFieldMatchingEnabled(true)
                .setMatchingStrategy(MatchingStrategies.STRICT);
        return modelMapper;
    }
}

객체

Entity

jpa에서 테이블을 생성하는 기능을 담당

Course

여러개의 강의를 하나로 묶는 역할을 하는 강좌 테이블

package com.pro06.entity.course;

import com.pro06.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.Comment;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;

import java.time.LocalDateTime;

// 강좌 테이블
// 여기에 나중에 선생님 관련 컬럼 하나 추가
// validation을 이용해 size, notnull 을 써도 되고 아니면 
// column을 이용해 length랑 null able 지정해줘도 됨

@Entity
@Getter
@Table(name="course")
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor

// column에 defualt 값을 설정할 때에 밑의 두개를 같이 써줘야 한다.
@DynamicInsert
@DynamicUpdate
public class Course extends BaseEntity {

    @Id
    @Column(name = "no")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer no;         // 강좌 번호

    @Comment("작성자(관리자)")    // jpa 코멘트 작성 예시
    @Column(length = 20, nullable = false)
    private String id;          // 작성자(관리자)

    @Column(length = 10, nullable = false)
    private String level;      // 학년

    @Column(length = 200, nullable = false)
    private String title;       // 제목

    @Column(length = 2000, nullable = false)
    private String content;     // 내용

    @ColumnDefault("0")
    private Integer cnt;        // 조회수

    @ColumnDefault("0")
    private Integer peo;        // 수강인원

    @Column(nullable = false)
    private Integer peo_max;    // 최대 수강인원

    @Column(nullable = true)
    private LocalDateTime copendate;    // 개강날짜

    private Integer copen;    // 오픈여부

    @ColumnDefault("'n'")
    private String deleteYn;        // 삭제 여부

    public void peoUp(){
        this.peo = this.peo + 1;
    }
		
		// jpa 에서 수정을 하기위해 선언한 메소드
    public void change(String id, String level,
                       String title, String content, Integer copen) {
        this.id = id;
        this.level = level;
        this.title = title;
        this.content = content;
        this.copen = copen;
    }

		//  삭제를 컬럼의 값 형태로 하기위해 선언한 메소드
    public void delete(String deleteYn) {
        this.deleteYn = deleteYn;
    }

}

Lecture

강의 정보 관련 데이터 저장 테이블

package com.pro06.entity.course;

import com.pro06.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;

// 강의 테이블
// 강좌에 속해있으며 여러개의 동영상을 하나로 묶는 역할

@Entity
@Getter
@Table(name="lecture")
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor

// column에 defualt 값을 설정할 때에 밑의 두개를 같이 써줘야 한다.
@DynamicInsert
@DynamicUpdate
public class Lecture extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer no;         // 강의 번호

    @Column(length = 20, nullable = false)
    private String id;          // 작성자(관리자)

    @Column(length = 200, nullable = false)
    private String title;       // 강의 제목

    @Column(length = 2000, nullable = false)
    private String content;     // 강의 설명

    @Column(length = 200, nullable = false)
    private String keyword;     // 키워드

    @ManyToOne(cascade = CascadeType.REMOVE, fetch = FetchType.LAZY)
    @JoinColumn(name = "cno", referencedColumnName = "no")
    private Course course;        // 강좌 번호 외래키 지정
}

Video

강의에 연결된 영상 정보 저장 테이블

package com.pro06.entity.course;

import com.pro06.entity.BaseEntity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;

// 강의 영상 테이블
// 강의 영상 정보를 저장

@Entity
@Getter
@Table(name="video")
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor

// column에 defualt 값을 설정할 때에 밑의 두개를 같이 써줘야 한다.
@DynamicInsert
@DynamicUpdate
public class Video extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer no;             // 영상번호

    @Column(nullable = false)
    private String savefolder;      // 저장경로

    @Column(nullable = false)
    private String originfile;      // 실제 파일 이름

    @Column(nullable = false)
    private String savefile;        // 저장된 파일 이름

    @Column(nullable = false)
    private Long filesize;          // 파일 크기

    @ManyToOne(cascade = CascadeType.REMOVE, fetch = FetchType.LAZY)
    @JoinColumn(name = "cno", referencedColumnName = "no")
    private Course course;        // 강좌 번호 외래키 지정

    @ManyToOne(cascade = CascadeType.REMOVE, fetch = FetchType.LAZY)
    @JoinColumn(name = "lno", referencedColumnName = "no")
    private Lecture lecture;        // 강의 번호 외래키 지정
}

LecTest

강의를 다 보고난 후 수강 완료 확인을 위해 검사하는 시험 데이터 테이블

package com.pro06.entity.course;

import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;

@Entity
@Getter
@Table(name="lectest")
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor

// column에 defualt 값을 설정할 때에 밑의 두개를 같이 써줘야 한다.
@DynamicInsert
@DynamicUpdate
public class LecTest {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer no;         // 시험 번호
    
    // 문제
    @Column(nullable = false)
    private String exam1;

    @Column(nullable = false)
    private String exam2;

    @Column(nullable = false)
    private String exam3;

    @Column(nullable = false)
    private String exam4;

    @Column(nullable = false)
    private String exam5;
    
    // 해당 문제의 답안
    @Column(nullable = false)
    private String answer1;

    @Column(nullable = false)
    private String answer2;

    @Column(nullable = false)
    private String answer3;

    @Column(nullable = false)
    private String answer4;

    @Column(nullable = false)
    private String answer5;
    
    @ManyToOne(cascade = CascadeType.REMOVE, fetch = FetchType.LAZY)
    @JoinColumn(name = "cno", referencedColumnName = "no")
    private Course course;        // 강좌 번호 외래키 지정

    @ManyToOne(cascade = CascadeType.REMOVE, fetch = FetchType.LAZY)
    @JoinColumn(name = "lno", referencedColumnName = "no")
    private Lecture lecture;        // 강의 번호 외래키 지정
}

DTO

mvc 패턴을 준수하기 위해 entity 대신 데이터를 주고받기 위해 사용

CourseDto

package com.pro06.dto.course;

import com.pro06.dto.BaseDto;
import jakarta.persistence.Column;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.*;

import java.time.LocalDateTime;

// 강좌 테이블
// 여기에 나중에 선생님 관련 컬럼 하나 추가
// validation을 이용해 size, notnull 을 써도 되고 아니면 
// column을 이용해 length랑 null able 지정해줘도 됨

@Getter @Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class CourseDto extends BaseDto {

    private Integer no;         // 강좌 번호

    @Size(max = 20)
    @NotBlank
    private String id;          // 작성자(관리자)

    @Size(max = 10)
    @NotBlank
    private String level;      // 학년

    @Size(max = 200)
    @NotBlank
    private String title;       // 제목

    @Size(max = 2000)
    @NotNull
    private String content;     // 내용

    @NotNull
    private Integer cnt;        // 조회수

    @NotNull
    private Integer peo;        // 수강인원

    @NotBlank
    private Integer peo_max;    // 최대 수강인원

    private LocalDateTime copendate;    // 개강날짜

    private Integer copen;    // 오픈여부   open=1, close=0

    @NotNull
    private String deleteYn;        // 삭제 여부
}

LectureDto

package com.pro06.dto.course;

import com.pro06.dto.BaseDto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.*;

// 강의 테이블
// 강좌에 속해있으며 여러개의 동영상을 하나로 묶는 역할

@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class LectureDto extends BaseDto {

    @NotBlank
    private Integer no;         // 강의 번호

    @Size(max = 20)
    @NotBlank
    private String id;          // 작성자(관리자)

    @Size(max = 200)
    @NotBlank
    private String title;       // 강의 제목

    @Size(max = 2000)
    @NotNull
    private String content;     // 강의 설명

    @Size(max = 200)
    @NotNull
    private String keyword;     // 키워드

    @NotNull
    private CourseDto course;        // 강좌 번호 외래키 지정
}

VideoDto

package com.pro06.dto.course;

import com.pro06.dto.BaseDto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.*;

// 강의 영상 테이블
// 강의 영상 정보를 저장

@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class VideoDto extends BaseDto {

    @NotBlank
    private Integer no;             // 영상번호

    @Size(max = 255)
    @NotBlank
    private String savefolder;      // 저장경로

    @Size(max = 255)
    @NotBlank
    private String originfile;      // 실제 파일 이름

    @Size(max = 255)
    @NotBlank
    private String savefile;        // 저장된 파일 이름

    @Size(max = 255)
    @NotBlank
    private Long filesize;          // 파일 크기

    @NotNull
    private CourseDto course;        // 강좌 번호 외래키 지정

    @NotNull
    private LectureDto lecture;        // 강의 번호 외래키 지정
}

LecTestDto

package com.pro06.dto.course;

import com.pro06.dto.BaseDto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.*;

@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class LecTestDto extends BaseDto {

    @NotBlank
    private Integer no;         // 시험 번호
    
    // 문제 + 4지선다
    @Size(max = 255)
    @NotBlank
    private String exam1;

    @Size(max = 255)
    @NotBlank
    private String exam2;

    @Size(max = 255)
    @NotBlank
    private String exam3;

    @Size(max = 255)
    @NotBlank
    private String exam4;

    @Size(max = 255)
    @NotBlank
    private String exam5;
    
    // 해당 문제의 답안
    @Size(max = 255)
    @NotBlank
    private String answer1;

    @Size(max = 255)
    @NotBlank
    private String answer2;

    @Size(max = 255)
    @NotBlank
    private String answer3;

    @Size(max = 255)
    @NotBlank
    private String answer4;

    @Size(max = 255)
    @NotBlank
    private String answer5;

    @NotNull
    private CourseDto course;        // 강좌 번호 외래키 지정

    @NotNull
    private LectureDto lecture;        // 강의 번호 외래키 지정
}

LectureVo

여러개의 테이블을 join을 사용하지 않고 자바(백엔드) 부분에서 연결해서 할때 사용

package com.pro06.dto.course;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;

import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class LectureVO {
    private LectureDto lecture;
    private List<VideoDto> fileList;
    private LecTestDto lecTest;
    private Integer cno;
}

Repository

entity로 만들어진 db에서 데이터를 주고받는 기능을 담당

CourseRepository

package com.pro06.repository.course;

import com.pro06.entity.course.Course;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface CourseRepository extends JpaRepository<Course, Integer> {
    
    // 수강생이 다 차지 않고 삭제 되지 않고 개설된 경우의 강좌만 불러오기
    @Query("select c from Course c where c.peo < c.peo_max and c.deleteYn = 'n' and c.copen = 1")
    List<Course> courseList();
}

LectureRepository

package com.pro06.repository.course;

import com.pro06.entity.course.Lecture;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface LectureRepository extends JpaRepository<Lecture, Integer> {
    // cno로 강의 리스트 정보 추출
    @Query("select l from Lecture l where l.course.no = :cno")
    List<Lecture> lectureCnoList(Integer cno);

    // 해당 강좌, 강의 영상 정보 추출
    @Query("select l from Lecture l where l.no = :lno and l.course.no = :cno")
    Lecture videoList(@Param("cno") Integer cno, @Param("lno") Integer lno);
}

VideoRepository

package com.pro06.repository.course;

import com.pro06.dto.course.VideoDto;
import com.pro06.entity.course.Video;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface VideoRepository extends JpaRepository<Video, Integer> {
    
    // 해당 강좌의 해당 강의의 실제 저장된 비디오 이름을 List 형태로 추출
    @Query("select v from Video v where v.lecture.no = :lno and v.course.no = :cno")
    List<Video> videoList(@Param("cno") Integer cno, @Param("lno") Integer lno);
}

LecTestRepository

package com.pro06.repository.course;

import com.pro06.entity.course.LecTest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

@Repository
public interface LecTestRepository extends JpaRepository<LecTest, Integer> {
    // 시험 정보 가져오기
    @Query("select lt from LecTest lt where lt.lecture.no = :lno and lt.course.no = :cno")
    LecTest getLecTest(@Param("cno") Integer cno, @Param("lno") Integer lno);

}

Service

view에서 요청된 데이터를 controller에서 받아서 repository에 전달하고 다시 controller로 데이터를 전달하는 기능을 담당

CourseService

package com.pro06.service.course;

import com.pro06.dto.course.CourseDto;
import com.pro06.entity.course.Course;
import com.pro06.repository.course.CourseRepository;
import jakarta.transaction.Transactional;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@Service
@Transactional
public class CourseServiceImpl {
    @Autowired
    private CourseRepository courseRepository;

    @Autowired
    private ModelMapper modelMapper;
    
    // 강좌 등록
    public void courseInsert(CourseDto courseDto) {
        Course course = modelMapper.map(courseDto, Course.class);
        courseRepository.save(course);
    }

    // 강좌 수정
    public void courseUpdate(CourseDto courseDto) {
        Optional<Course> course = courseRepository.findById(courseDto.getNo());
        Course res = course.orElseThrow();
        res.change(courseDto.getId(), courseDto.getLevel(),
                courseDto.getTitle(), courseDto.getContent(),
                courseDto.getCopen());
        courseRepository.save(res);
    }

    // 강좌 삭제, 복구
    public void couDelRec(CourseDto dto) {
        Optional<Course> course = courseRepository.findById(dto.getNo());
        Course res = course.orElseThrow();
        res.delete(dto.getDeleteYn());
        courseRepository.save(res);
    }
    
    // 어드민 강좌 목록 불러오기
    public List<CourseDto> admCourseList() {
        List<Course> lst = courseRepository.findAll();
        List<CourseDto> courseList = lst.stream().map(course ->
                modelMapper.map(course, CourseDto.class))
                .collect(Collectors.toList());
        return courseList;
    }

    // 강좌 목록 불러오기
    public List<CourseDto> courseList() {
        List<Course> lst = courseRepository.courseList();
        List<CourseDto> courseList = lst.stream().map(course ->
                modelMapper.map(course, CourseDto.class))
                .collect(Collectors.toList());
        return courseList;
    }
    
    // 강좌 상세 보기
    public CourseDto getCourse(Integer no) {
        Optional<Course> course = courseRepository.findById(no);
        CourseDto courseDto = modelMapper.map(course, CourseDto.class);
        return courseDto;
    }

    // 강좌 수강신청 수강생 +1
    public void setCoursePeo(Integer no) {
        Optional<Course> course = courseRepository.findById(no);
        Course res = course.orElseThrow();
        res.peoUp();
        courseRepository.save(res);
    }
}

LectureService

package com.pro06.service.course;

import com.pro06.dto.course.*;
import com.pro06.entity.course.*;
import com.pro06.repository.course.*;
import jakarta.transaction.Transactional;
import lombok.extern.log4j.Log4j2;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@Service
@Transactional
@Log4j2
public class LectureServiceImpl {
    @Autowired
    private CourseRepository courseRepository;

    @Autowired
    private LectureRepository lectureRepository;

    @Autowired
    private VideoRepository videoRepository;

    @Autowired
    private LecTestRepository lecTestRepository;

    @Autowired
    private LecAnsRepository lecAnsRepository;

    @Autowired
    private LecQueRepository lecQueRepository;

    @Autowired
    private ModelMapper modelMapper;

    // 강의 정보 + 파일 등록
    public void LectureVoInsert(LectureVO vo) throws Exception {
        LectureDto lecture = vo.getLecture();
        List<VideoDto> fileList = vo.getFileList();
        
        // 강의 정보 등록
        Optional<Course> cou = courseRepository.findById(vo.getCno());
        CourseDto coudto = modelMapper.map(cou, CourseDto.class);
        lecture.setCourse(coudto);
        Lecture lec = modelMapper.map(lecture, Lecture.class);
        
        // 저장 및 dto로 변환
        Lecture lec2 = lectureRepository.save(lec);
        // 이거 안해주면 오류뜸
        LectureDto lecDto = modelMapper.map(lec2, LectureDto.class);

        log.info("lecture 저장");
        
        // 강의 영상 등록
        for(VideoDto video: fileList) {
            video.setLecture(lecDto);
            video.setCourse(coudto);
            Video vdo = modelMapper.map(video, Video.class);
            videoRepository.save(vdo);
            log.info("video 저장");
        }
        
        // 시험 정보 등록
        LecTestDto lecTest = vo.getLecTest();
        lecTest.setLecture(lecDto);
        lecTest.setCourse(coudto);
        LecTest lt = modelMapper.map(lecTest, LecTest.class);
        lecTestRepository.save(lt);
    }

    // 해당강의의 강좌 목록 불러오기
    public List<LectureDto> lectureCnoList(Integer cno) {
        List<Lecture> lst = lectureRepository.lectureCnoList(cno);
        List<LectureDto> lectureDtoList = lst.stream().map(lecture ->
                modelMapper.map(lecture, LectureDto.class))
                .collect(Collectors.toList());
        return lectureDtoList;
    }
    
    // 시험 정보 가져오기
    public LecTestDto getLecTest(Integer cno, Integer lno) {
        LecTest opt = lecTestRepository.getLecTest(cno, lno);
        if(opt != null) {
            return modelMapper.map(opt, LecTestDto.class);
        } else {
            return null;
        }
    }

    // region lecAns
    // 시험 제출 답안 입력, 수정
    public void lecAnsInsert(LecAnsDto lecAnsDto) {
        LecAns lecAns = modelMapper.map(lecAnsDto, LecAns.class);
        lecAnsRepository.save(lecAns);
    }
    
    // 제출된 답안 수정
    public void lecAnsUpdate(LecAnsDto lecAnsDto) {
        Optional<LecAns> lec = lecAnsRepository.getLecAns(lecAnsDto.getCourse().getNo(),
                lecAnsDto.getLecture().getNo(), lecAnsDto.getId());
        LecAns lecAns = lec.orElseThrow();
        lecAns.answerChange(lecAnsDto.getAnswer1(), lecAnsDto.getAnswer2(), lecAnsDto.getAnswer3(),
                lecAnsDto.getAnswer4(), lecAnsDto.getAnswer5(), lecAnsDto.getAnsCnt());
        lecAnsRepository.save(lecAns);
    }
    
    // 답안 정보 추출
    public LecAnsDto getLecAns(Integer cno, Integer lno, String id) {
        Optional<LecAns> lecAns = lecAnsRepository.getLecAns(cno, lno, id);
        log.warn("lecAns.isPresent() : " + lecAns.isPresent());
        if(lecAns.isPresent()) { // Optional 에서 null 비교할 때 사용
            LecAnsDto lecAnsDto = modelMapper.map(lecAns, LecAnsDto.class);
            return lecAnsDto;
        } else {
            return null;
        }
    }
    // endregion

    // region lecQue
    // admin 용
    // admin 에서 사용할 list
    // 모든 질문 목록
    public List<LecQueDto> lecQueDtoFindByAll() {
        List<LecQue> lst = lecQueRepository.findAll();
        List<LecQueDto> dtoList = lst.stream().map(lecQue ->
                        modelMapper.map(lecQue, LecQueDto.class))
                .collect(Collectors.toList());
        return dtoList;
    }
    
    // user 용
    // video 에서 사용할 list
    // 해당 강좌, 강의, 영상에서 유저가 질문한 목록
    public List<LecQueDto> lecQueList(LecQueDto lecQueDto) {
        List<LecQue> lst = lecQueRepository.lecQueList(lecQueDto.getId(), lecQueDto.getPage(),
                lecQueDto.getCourse().getNo(), lecQueDto.getLecture().getNo(), "n");
        List<LecQueDto> dtoList = lst.stream().map(lecQue ->
                        modelMapper.map(lecQue, LecQueDto.class))
                .collect(Collectors.toList());
        return dtoList;
    }

    // 질문 입력
    public LecQueDto lecQueInsert(LecQueDto lecQueDto) {
        LecQue lecQue = modelMapper.map(lecQueDto, LecQue.class);
        LecQue lec = lecQueRepository.save(lecQue);
        LecQueDto dto = modelMapper.map(lec, LecQueDto.class);
        return dto;
    }

    // 질문 보기
    public LecQueDto getLecQue(Integer no) {
        Optional<LecQue> lecQue = lecQueRepository.findById(no);
        LecQueDto dto = modelMapper.map(lecQue, LecQueDto.class);
        return dto;
    }

    // 질문 삭제
    public void lecQueDelete(LecQueDto dto) {
        Optional<LecQue> lecQue = lecQueRepository.findById(dto.getNo());
        LecQue lecQue1 = lecQue.orElseThrow();
        lecQue1.delete(dto.getDeleteYn());
        lecQueRepository.save(lecQue1);
    }

    // 질문에 대한 답변 입력, 수정
    public void lecQueAnsInsUpd(LecQueDto lecQueDto) {
        Optional<LecQue> lecQue = lecQueRepository.findById(lecQueDto.getNo());
        LecQue lecQue1 = lecQue.orElseThrow();
        lecQue1.answer(lecQueDto.getAns());
        lecQueRepository.save(lecQue1);
    }

    // 질문 삭제 취소
    public void lecQueAnsRecover(Integer no) {
        Optional<LecQue> lecQue = lecQueRepository.findById(no);
        LecQue lecQue1 = lecQue.orElseThrow();
        lecQue1.delete("n");
        lecQueRepository.save(lecQue1);
    }
    // endregion
}

VideoService

package com.pro06.service.course;

import com.pro06.dto.course.LecQueDto;
import com.pro06.dto.course.VideoDto;
import com.pro06.entity.course.Video;
import com.pro06.repository.course.VideoRepository;
import jakarta.transaction.Transactional;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
@Transactional
public class VideoServiceImpl {
    @Autowired
    private VideoRepository videoRepository;

    @Autowired
    private ModelMapper modelMapper;
    
    // 강좌 영상
    public List<VideoDto> videoList(Integer cno, Integer lno) {
        List<Video> lst = videoRepository.videoList(cno, lno);
        List<VideoDto> dtoList = lst.stream().map(video ->
                modelMapper.map(video, VideoDto.class))
                .collect(Collectors.toList());
        return dtoList;
    }
}

Controller

@Log4j2
@Controller
@Component
@RequestMapping("/admin/*")
public class AdminController {

    // 실제 업로드 디렉토리
    // thymeleaf 에서는 외부에 지정하여 사용해야 한다.
    // jsp와는 다르게 webapp이 없기 때문이다.
    // resources는 정적이라 업데이트 되어도 파일을 못 찾기에 서버를 재 시작 해야함
    @Value("${spring.servlet.multipart.location}")
    String uploadFolder;

		@Autowired
    private LectureServiceImpl lectureService;

		// lecture
		// 강의 생성폼 이동
		@GetMapping("lectureInsert")
		public String lectureInsert(Principal principal, @RequestParam("cno") Integer cno, Model model) {
		    String id = principal.getName();
		
		    model.addAttribute("id", id);
		    model.addAttribute("cno", cno);
		    return "admin/lecture/lectureInsert";
		}
		
		// spring boot 3 이상부터 이런식으로 사용해야 함
		// 강의 생성
		// 강의 정보, 강좌 번호, 파일 추출
		@PostMapping("lectureInsert")
		public String lectureInsertPro(MultipartHttpServletRequest req) throws Exception {
		
		    // 입력된 파일목록
		    List<MultipartFile> files = new ArrayList<>();
		    // 강의 정보
		    LectureDto lecture = new LectureDto();
		    // 여러 파일 반복 저장
		    List<VideoDto> fileList = new ArrayList<>();
		
		    // 파일 저장
		    for (int i = 0; i < req.getFiles("files").size(); i++) {
		        files.add(req.getFiles("files").get(i));
		    }
		
		    // 강의정보 저장
		    CourseDto course = new CourseDto();
		    Integer cno = Integer.parseInt(req.getParameter("cno"));
		    course.setNo(cno);
		    lecture.setCourse(course);      // cno 저장
		    lecture.setId(req.getParameter("id"));
		    lecture.setTitle(req.getParameter("title"));
		    lecture.setContent(req.getParameter("content"));
		    lecture.setKeyword(req.getParameter("keyword"));
		
		    // 시험정보 저장
		    LecTestDto lecTest = new LecTestDto();
		    lecTest.setExam1(req.getParameter("exam1"));
		    lecTest.setExam2(req.getParameter("exam2"));
		    lecTest.setExam3(req.getParameter("exam3"));
		    lecTest.setExam4(req.getParameter("exam4"));
		    lecTest.setExam5(req.getParameter("exam5"));
		    lecTest.setAnswer1(req.getParameter("answer1"));
		    lecTest.setAnswer2(req.getParameter("answer2"));
		    lecTest.setAnswer3(req.getParameter("answer3"));
		    lecTest.setAnswer4(req.getParameter("answer4"));
		    lecTest.setAnswer5(req.getParameter("answer5"));
		
		    // 파일 추출 테스트
		    for (int i = 0; i < req.getFiles("files").size(); i++) {
		        log.info("req.getParameter(\"title\") : " + req.getParameter("title"));
		        log.info("req.getFile(\"files\") : " + req.getFile("files"));
		        log.info("req.getFile(\"files\").getOriginalFilename() : " +
		                req.getFiles("files").get(i).getOriginalFilename());      // 실제 파일이름 출력
		        log.info("req.getFiles(\"files\").get(" + i + ").getBytes() : " +
		                req.getFiles("files").get(i).getBytes());                 // 파일의 용량 출력
		        log.info("req.getFiles(\"files\").get(" + i + ").getName() : " +
		                req.getFiles("files").get(i).getName());                  // name 속성값 출력
		        log.info("req.getFiles(\"files\").size() : " +
		                req.getFiles("files").size());                            // 입력된 파일의 개수 출력
		    }
		
		    // 만약 저장 폴더가 없다면 생성
		    File folder = new File(uploadFolder);
		    if (!folder.exists()) folder.mkdirs();
		
		    // log 출력
		    log.info("-----------------------------------");
		    log.info(" 현재 프로젝트 홈 : " + req.getContextPath());
		    log.info(" 요청 URL : " + req.getServletPath());
		    log.info(" 파일 저장 경로 : " + uploadFolder);
		
		    // 첨부된 파일(MultipartFile) 처리
		    if (files != null && files.size() > 0) {
		        for (MultipartFile file : files) {
		            // 파일 처리 로직 시작
		            String randomUUID = UUID.randomUUID().toString();  // 파일 이름 중복 방지를 위한 랜덤 UUID 생성
		            String OriginalFilename = file.getOriginalFilename();  // 실제 파일 이름
		            String Extension = OriginalFilename.substring(OriginalFilename.lastIndexOf("."));  // 파일 확장자 추출
		            String saveFileName = randomUUID + Extension;  // 저장할 파일 이름 생성
		
		            // 저장위치, 실제파일이름, 저장될 파일이름, 파일크기 정보를 저장
		            VideoDto data = new VideoDto();
		            data.setSavefolder(uploadFolder);
		            data.setOriginfile(file.getOriginalFilename());
		            data.setSavefile(saveFileName);
		            data.setFilesize(file.getSize());
		            fileList.add(data);
		
		            // 파일 저장
		            File saveFile = new File(uploadFolder, saveFileName);
		            try {
		                file.transferTo(saveFile); // 실제 upload 위치에 파일 저장
		            } catch (IllegalStateException | IOException e) {
		                e.printStackTrace();
		                // 예외 처리
		            }
		        }
		    }
		    // VO를 통해 db에 저장
		    LectureVO lectureVO = new LectureVO();
		    lectureVO.setLecture(lecture);
		    lectureVO.setFileList(fileList);
		    lectureVO.setLecTest(lecTest);
		    lectureVO.setCno(cno);
		    lectureService.LectureVoInsert(lectureVO); // 강의와 비디오를 같이 저장
		
		    return "redirect:/admin/courseDetail?no=" + cno;
		}
}

View

사용자가 실제로 보는 곳, 나는 thymeleaf 사용

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>강의등록</title>
    <style>
        .file-list {
            height: 200px;
            width: 400px;
            overflow: auto;
            border: 1px solid #989898;
            padding: 10px;
        }
        .file-list .filebox p {
            font-size: 14px;
            margin-top: 10px;
            display: inline-block;
        }
        .file-list .filebox .delete i{
            color: #ff5353;
            margin-left: 5px;
            cursor: pointer;
        }
    </style>
    <th:block th:replace="/include/adminhead :: head"></th:block>
</head>
<body>
<div class="container-scroller">
    <div class="container-fluid page-body-wrapper">
        <th:block th:replace="/include/adminheader :: header"></th:block>
        <div class="main-panel">
            <div class="content-wrapper">
                <h1>관리자페이지</h1>
                <h2>강의 등록</h2>
                <div class="card p-3">
                    <form method="post" class="text-center mb-3 was-validated" enctype="multipart/form-data">
                        <div class="form-group">
                            <label for="id" class="form-label">아이디</label>
                            <input type="hidden" name="cno" id="cno" th:value="${cno}" readonly>
                            <input type="text" name="id" id="id" class="form-control" th:value="${id}" readonly>
                        </div>

                        <div  class="form-group">
                            <label for="title" class="form-label">제목</label>
                            <input type="text" name="title" id="title" class="form-control"
                                   pattern="^[ㄱ-ㅎ가-힣0-9a-zA-Z\s]+$" maxlength="100" required>
                            <div class="valid-feedback">
                                형식에 알맞습니다.
                            </div>
                            <div class="invalid-feedback">
                                한글, 숫자, 영어대소문자만 가능합니다.
                            </div>
                        </div>

                        <div class="form-group">
                            <label for="keyword" class="form-label">키워드</label>
                            <input type="text" name="keyword" id="keyword" class="form-control"
                                   pattern="^[ㄱ-ㅎ가-힣0-9a-zA-Z\s]+$" maxlength="100" required>
                            <div class="valid-feedback">
                                형식에 알맞습니다.
                            </div>
                            <div class="invalid-feedback">
                                한글, 숫자, 영어대소문자만 가능합니다.
                            </div>
                        </div>

                        <div class="form-group">
                            <label for="content" class="form-label">내용</label>
                            <input type="text" name="content" id="content" class="form-control"
                                   pattern="^[ㄱ-ㅎ가-힣0-9a-zA-Z\s]+$" maxlength="1000" required>
                            <div class="valid-feedback">
                                형식에 알맞습니다.
                            </div>
                            <div class="invalid-feedback">
                                한글, 숫자, 영어대소문자만 가능합니다.
                            </div>
                        </div>
                        <hr style="border-top: 1px solid #333">

                        <div class="form-group">
                            <label for="file" class="form-label">강의영상</label>
                            <input type="file" id="file" class="form-control" name="file" onchange="addFile(this);" multiple accept="video/mp4"/>
                            <div class="file-list"></div>
                        </div>
                        <hr style="border-top: 1px solid #333">

                        <div  class="form-group">
                            <!-- 문제, 답 5개 씩 만듬 -->
                            <th:block th:each="i : ${#numbers.sequence(1, 5)}">
                                <div>
                                    <div class="form-group">
                                        <label th:for="'exam' + ${i}" class="form-label" th:text="'문제 ' + ${i}"></label>
                                        <input type="text" class="exam form-control" th:name="'exam' + ${i}"
                                               th:id="'exam' + ${i}" pattern="^[ㄱ-ㅎ가-힣a-zA-Z]+$" maxlength="100" required>
                                        <div class="valid-feedback">
                                            형식에 알맞습니다.
                                        </div>
                                        <div class="invalid-feedback">
                                            한글, 숫자, 영어대소문자만 가능합니다.
                                        </div>
                                    </div>
                                    <br>
                                    <div class="form-group">
                                        <label th:for="'answer' + ${i}" class="form-label" th:text="'답 ' + ${i}"></label>
                                        <input type="text" class="exam form-control" th:name="'answer' + ${i}"
                                               th:id="'answer' + ${i}" pattern="^[ㄱ-ㅎ가-힣a-zA-Z]+$" maxlength="100" required>
                                        <div class="valid-feedback">
                                            형식에 알맞습니다.
                                        </div>
                                        <div class="invalid-feedback">
                                            한글, 숫자, 영어대소문자만 가능합니다.
                                        </div>
                                    </div>
                                </div>
                                <hr style="border-top: 1px solid #333"><br>
                            </th:block>
                        </div>

                        <div>
                            <input type="button" class="btn btn-primary" th:onclick="return submitForm()" value="등록">
                            <input type="reset" class="btn btn-danger" value="초기화">
                        </div>
                    </form>
                </div>
            </div>

            <footer class="footer">
                <div class="d-sm-flex justify-content-center justify-content-sm-between">
                    <span class="text-muted text-center text-sm-left d-block d-sm-inline-block">Copyright © 2021.  Premium <a href="https://www.bootstrapdash.com/" target="_blank">Bootstrap admin template</a> from BootstrapDash. All rights reserved.</span>
                    <span class="float-none float-sm-right d-block mt-1 mt-sm-0 text-center">Hand-crafted & made with <i class="ti-heart text-danger ml-1"></i></span>
                </div>
            </footer>
        </div>
    </div>
</div>

<script th:inline="javascript">
    let fileNo = 0;
    let filesArr = new Array();
    /*<![CDATA[*/
    let cno = [[${cno}]];
        /*]]>*/

        /* 첨부파일 추가 */
    function addFile(obj){
        let maxFileCnt = 5;   // 첨부파일 최대 개수
        let attFileCnt = document.querySelectorAll('.filebox').length;    // 기존 추가된 첨부파일 개수
        let remainFileCnt = maxFileCnt - attFileCnt;    // 추가로 첨부가능한 개수
        let curFileCnt = obj.files.length;  // 현재 선택된 첨부파일 개수

        // 첨부파일 개수 확인
        if (curFileCnt > remainFileCnt) {
            alert("첨부파일은 최대 " + maxFileCnt + "개 까지 첨부 가능합니다.");
        } else {
            for (const file of obj.files) {
                // 첨부파일 검증
                if (validation(file)) {
                    // 파일 배열에 담기
                    var reader = new FileReader();
                    reader.onload = function () {
                        filesArr.push(file);
                    };
                    reader.readAsDataURL(file);

                    console.log(file);

                    // 목록 추가
                    let htmlData = '';
                    htmlData += '<div id="file' + fileNo + '" class="filebox">';
                    htmlData += '   <p class="name">' + file.name + '</p>';
                    htmlData += '   <a class="delete"token operator">+ fileNo + ');"><i>삭제</i></a>';
                    htmlData += '</div>';
                    $('.file-list').append(htmlData);
                    fileNo++;
                } else {
                    continue;
                }
            }
        }
        // 초기화
        document.querySelector("input[type=file]").value = "";
    }

    /* 첨부파일 검증 */
    function validation(obj){
        const fileTypes = ['video/mp4'];
        if (obj.name.length > 100) {
            alert("파일명이 100자 이상인 파일은 제외되었습니다.");
            return false;
        } else if (obj.size > (100 * 1024 * 1024)) {
            alert("최대 파일 용량인 100MB를 초과한 파일은 제외되었습니다.");
            return false;
        } else if (obj.name.lastIndexOf('.') == -1) {
            alert("확장자가 없는 파일은 제외되었습니다.");
            return false;
        } else if (!fileTypes.includes(obj.type)) {
            alert("첨부가 불가능한 파일은 제외되었습니다.");
            return false;
        } else {
            return true;
        }
    }

    /* 첨부파일 삭제 */
    function deleteFile(num) {
        if(!confirm('해당 파일을 삭제하시겠습니까?')) {
            return false;
        }
        document.querySelector("#file" + num).remove();
        filesArr[num].is_delete = true;
    }

    /* 폼 전송 */
    function submitForm() {
        let title = $("#title").val();
        let keyword = $("#keyword").val();
        let content = $("#content").val();
        let exam = $(".exam").length;

        if(title === '') {
            alert('제목을 입력하세요');
            $('#title').focus();
            return false;
        }

        if(keyword === '') {
            alert('키워드를 입력하세요');
            return false;
        }

        if(content === '') {
            alert('설명을 입력하세요')
            return false;
        }

        // 동영상 체크
        let attFileCnt = document.querySelectorAll('.filebox').length;    // 기존 추가된 첨부파일 개수
        if(attFileCnt === 0) { // 추가된 파일이 없으면 submit 실패
            alert('동영상 파일이 최소 1개이상 있어야 합니다.')
            return false;
        }

        // 시험 정보 체크
        for(var i=0; i<exam; i++) {
            let value = $(".exam").eq(i).val()
            if(value === '') {
                alert('시험에 입력되지 않은 곳이 존재합니다.')
                return false;
            }
        }

        // 폼데이터 담기
        let form = document.querySelector("form");
        let formData = new FormData(form);
        for (var i = 0; i < filesArr.length; i++) {
            // 삭제되지 않은 파일만 폼데이터에 담기
            if (!filesArr[i].is_delete) {
                formData.append("files", filesArr[i]);
            }
        }

        // ajax로 form 데이터 전송
        $.ajax({
            method: 'POST',
            url: '/admin/lectureInsert',
            data: formData,                 // 필수
            cache: false,
            enctype: 'multipart/form-data',	// 필수
            processData: false,	            // 필수
            contentType: false,	            // 필수
            success: function () {
                console.log('강의 업로드 성공');
                location.href = '/admin/courseDetail?no=' + cno;
            },
            error: function () {
                alert('강의 업로드 실패');
                return false;
            }
        })
    }
</script>

</body>
</html>
profile
자바 풀 스택 주니어 개발자

0개의 댓글