diff --git a/build.gradle b/build.gradle
index 491c004..10d68fc 100644
--- a/build.gradle
+++ b/build.gradle
@@ -24,16 +24,51 @@ repositories {
}
dependencies {
+ implementation 'org.springframework.boot:spring-boot-starter'
+
+ // JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
- implementation 'org.springframework.boot:spring-boot-starter-jdbc'
+
+ // Thymeleaf
+ implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
+
+ // Security
+ implementation 'org.springframework.boot:spring-boot-starter-security'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+
+ // JWT
+ implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
+ runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
+ runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
+
+ // Spring Web
implementation 'org.springframework.boot:spring-boot-starter-web'
+
+ // Lombok
compileOnly 'org.projectlombok:lombok'
- runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
+
+ // Database
+ implementation 'com.mysql:mysql-connector-j:8.2.0' // 보안 취약점 패치(>=8.2.0) 적용
+ implementation 'org.springframework.boot:spring-boot-starter-jdbc'
+ runtimeOnly 'com.h2database:h2'
+
+ // Test tool
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+ testCompileOnly 'org.projectlombok:lombok'
+ testAnnotationProcessor 'org.projectlombok:lombok'
+
+ // Querydsl
+ implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
+ annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
+ annotationProcessor "jakarta.annotation:jakarta.annotation-api"
+ annotationProcessor "jakarta.persistence:jakarta.persistence-api"
+
+ // Swagger
+ implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'
}
tasks.named('test') {
useJUnitPlatform()
-}
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/DemoApplication.java b/src/main/java/com/demo/DemoApplication.java
index c6f2a20..362a5ae 100644
--- a/src/main/java/com/demo/DemoApplication.java
+++ b/src/main/java/com/demo/DemoApplication.java
@@ -1,9 +1,14 @@
package com.demo;
+import com.demo.config.properties.JWTProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication
+@EnableConfigurationProperties(
+ JWTProperties.class
+)
public class DemoApplication {
public static void main(String[] args) {
diff --git a/src/main/java/com/demo/config/CorsConfig.java b/src/main/java/com/demo/config/CorsConfig.java
new file mode 100644
index 0000000..aa9ecc7
--- /dev/null
+++ b/src/main/java/com/demo/config/CorsConfig.java
@@ -0,0 +1,27 @@
+package com.demo.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import org.springframework.web.filter.CorsFilter;
+
+/**
+ * 애플리케이션에서 CORS 설정을 하기 위한 Configuration
+ *
+ * @author duskafka
+ * */
+@Configuration
+public class CorsConfig {
+ @Bean
+ public CorsFilter corsFilter() {
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ CorsConfiguration config = new CorsConfiguration();
+ config.setAllowCredentials(true);
+ config.addAllowedOriginPattern("*");
+ config.addAllowedHeader("*");
+ config.addAllowedMethod("*");
+ source.registerCorsConfiguration("/api/v1/**", config);
+ return new CorsFilter(source);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/config/JpaConfig.java b/src/main/java/com/demo/config/JpaConfig.java
new file mode 100644
index 0000000..fe41588
--- /dev/null
+++ b/src/main/java/com/demo/config/JpaConfig.java
@@ -0,0 +1,19 @@
+package com.demo.config;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+
+/**
+ * JPA 설정을 설정하기 위한 Configuration
+ *
+ *
JPAQueryFactory: Querydsl을 사용하기 위해 JPAQueryFactory를 빈으로 등록
+ *
+ * @author duskafka
+ * */
+@Slf4j
+@EnableJpaAuditing
+@Configuration
+public class JpaConfig {
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/config/JwtSecurityConfig.java b/src/main/java/com/demo/config/JwtSecurityConfig.java
new file mode 100644
index 0000000..5d767dd
--- /dev/null
+++ b/src/main/java/com/demo/config/JwtSecurityConfig.java
@@ -0,0 +1,28 @@
+package com.demo.config;
+
+import com.demo.config.properties.JWTProperties;
+import com.demo.jwt.JwtFilter;
+import com.demo.jwt.TokenProvider;
+import com.demo.jwt.TokenValidator;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.annotation.SecurityConfigurerAdapter;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.web.DefaultSecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+@Configuration
+@RequiredArgsConstructor
+public class JwtSecurityConfig extends SecurityConfigurerAdapter {
+ private final TokenProvider tokenProvider;
+ private final TokenValidator tokenValidator;
+ private final JWTProperties jwtProperties;
+
+ @Override
+ public void configure(HttpSecurity http) {
+ http.addFilterBefore(
+ new JwtFilter(tokenProvider, tokenValidator, jwtProperties),
+ UsernamePasswordAuthenticationFilter.class
+ );
+ }
+}
diff --git a/src/main/java/com/demo/config/SecurityConfig.java b/src/main/java/com/demo/config/SecurityConfig.java
new file mode 100644
index 0000000..e5bf511
--- /dev/null
+++ b/src/main/java/com/demo/config/SecurityConfig.java
@@ -0,0 +1,104 @@
+package com.demo.config;
+
+import com.demo.config.properties.JWTProperties;
+import com.demo.jwt.JwtAccessDeniedHandler;
+import com.demo.jwt.JwtAuthenticationEntryPoint;
+import com.demo.jwt.TokenProvider;
+import com.demo.jwt.TokenValidator;
+import com.demo.repository.StudentRepository;
+import com.demo.service.CustomLoginService;
+import lombok.AccessLevel;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.filter.CorsFilter;
+import org.springframework.web.cors.CorsUtils;
+
+@Slf4j
+@EnableWebSecurity
+@EnableMethodSecurity
+@Configuration
+@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
+public class SecurityConfig {
+ private final CorsFilter corsFilter;
+ private final TokenProvider tokenProvider;
+ private final TokenValidator tokenValidator;
+ private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
+ private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
+ private final JWTProperties jwtProperties;
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ @Bean
+ public CustomLoginService customLoginService(StudentRepository studentRepository) {
+ return new CustomLoginService(studentRepository);
+ }
+
+ @Bean
+ public AuthenticationManager authenticationManager(HttpSecurity http, PasswordEncoder passwordEncoder, CustomLoginService customLoginService) throws Exception {
+ return http.getSharedObject(AuthenticationManagerBuilder.class)
+ .userDetailsService(customLoginService)
+ .passwordEncoder(passwordEncoder)
+ .and()
+ .build();
+ }
+
+ @Bean
+ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
+ http.csrf(AbstractHttpConfigurer::disable)
+ .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
+ .exceptionHandling(exception -> {
+ exception.accessDeniedHandler(jwtAccessDeniedHandler)
+ .authenticationEntryPoint(jwtAuthenticationEntryPoint);
+ })
+ .authorizeHttpRequests(request -> {
+ request
+ // UI 라우트: 비로그인 허용
+ .requestMatchers("/", "/sign-in", "/sign-up").permitAll()
+ .requestMatchers("/posts", "/posts/*", "/posts/edit/*").permitAll()
+ .requestMatchers("/js/**", "/css/**", "/images/**", "/favicon.ico", "/error").permitAll()
+
+ // API 조회는 비로그인 허용
+ .requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll()
+
+ // API 쓰기 작업은 인증 필요
+ .requestMatchers(HttpMethod.POST, "/api/v1/posts/**").authenticated()
+ .requestMatchers(HttpMethod.PUT, "/api/v1/posts/**").authenticated()
+ .requestMatchers(HttpMethod.DELETE, "/api/v1/posts/**").authenticated()
+
+ // 나머지 기존 정책
+ .requestMatchers("/api/v1/auth/login", "/api/v1/auth/register", "/api/v1/auth/check-id").permitAll()
+ .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
+ .requestMatchers("/api/v1/students/**").hasAnyRole("ADMIN", "USER")
+ .requestMatchers(org.springframework.web.cors.CorsUtils::isPreFlightRequest).permitAll()
+ .anyRequest().authenticated();
+ })
+ .sessionManagement(session -> {
+ session.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
+ })
+ .headers(headers -> {
+ headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin);
+ })
+ .with(new JwtSecurityConfig(tokenProvider, tokenValidator, jwtProperties), customizer -> {
+ });
+ return http.build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/config/properties/JWTProperties.java b/src/main/java/com/demo/config/properties/JWTProperties.java
new file mode 100644
index 0000000..1646ef4
--- /dev/null
+++ b/src/main/java/com/demo/config/properties/JWTProperties.java
@@ -0,0 +1,28 @@
+package com.demo.config.properties;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * JWT 토큰 프로퍼티를 가지는 클래스
+ *
+ * @author duskafka
+ * */
+@ConfigurationProperties(prefix = "jwt")
+public record JWTProperties(
+ //JWT 토큰이 HTTP 헤더에 담길 때 사용될 헤더 이름 (예: Authorization)
+ String accessTokenHeader,
+
+ //JWT 서명 및 검증에 사용될 비밀 키 (Base64 인코딩된 문자열)
+ //HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 합니다.
+ String secret,
+
+ //Access Token의 만료 기간 (초 단위)
+ long accessTokenValidityInHour,
+
+ //인증용 키
+ String authorityKey,
+
+ //액세스 토큰 인증 헤더
+ String bearerHeader
+) {
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/controller/ApiController.java b/src/main/java/com/demo/controller/ApiController.java
deleted file mode 100644
index 238ac39..0000000
--- a/src/main/java/com/demo/controller/ApiController.java
+++ /dev/null
@@ -1,45 +0,0 @@
-package com.demo.controller;
-
-import com.demo.service.StudentService;
-import com.demo.dto.request.StudentCreateRequest;
-import com.demo.dto.request.StudentUpdateRequest;
-
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.*;
-
-@Slf4j
-@RestController
-@RequestMapping("/api/v1/students")
-@RequiredArgsConstructor
-public class ApiController {
- private final StudentService studentService;
-
- @PostMapping
- public ResponseEntity> create(@RequestBody StudentCreateRequest request) {
- var response = studentService.create(request);
- return ResponseEntity.ok(response);
- }
-
- @GetMapping("/{id}")
- public ResponseEntity> get(@PathVariable(name = "id") Long id) {
- var response = studentService.findById(id);
- return ResponseEntity.ok(response);
- }
-
- @PostMapping("/{id}")
- public ResponseEntity> update(
- @RequestBody StudentUpdateRequest request,
- @PathVariable(name = "id") Long id
- ) {
- var response = studentService.update(id, request);
- return ResponseEntity.ok(response);
- }
-
- @DeleteMapping("/{id}")
- public ResponseEntity> delete(@PathVariable(name = "id") Long id) {
- studentService.delete(id);
- return ResponseEntity.noContent().build();
- }
-}
\ No newline at end of file
diff --git a/src/main/java/com/demo/controller/api/AuthApiController.java b/src/main/java/com/demo/controller/api/AuthApiController.java
new file mode 100644
index 0000000..f1efff2
--- /dev/null
+++ b/src/main/java/com/demo/controller/api/AuthApiController.java
@@ -0,0 +1,74 @@
+package com.demo.controller.api;
+
+import com.demo.config.properties.JWTProperties;
+import com.demo.dto.request.LoginRequest;
+import com.demo.dto.request.StudentCreateRequest;
+import com.demo.jwt.TokenProvider;
+//import com.demo.repository.StudentRepository;
+import com.demo.service.StudentService;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.parameters.P;
+import org.springframework.web.bind.annotation.*;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/auth")
+public class AuthApiController {
+
+ private final TokenProvider tokenProvider;
+ private final JWTProperties jwtProperties;
+ private final StudentService studentService;
+ private final AuthenticationManager authenticationManager;
+ //private final StudentRepository studentRepository;
+
+ @PostMapping("/login")
+ public ResponseEntity> login(
+ @RequestBody LoginRequest loginRequest,
+ HttpServletResponse response
+ ) {
+ Authentication authentication = authenticationManager.authenticate(
+ new UsernamePasswordAuthenticationToken(loginRequest.getLoginId(), loginRequest.getPassword())
+ );
+
+ log.info(authentication.toString());
+
+ String accessToken = tokenProvider.createAccessToken(authentication);
+ response.addHeader(jwtProperties.accessTokenHeader(), jwtProperties.bearerHeader() + accessToken);
+
+ return ResponseEntity.ok().build();
+ }
+
+ //@PostMapping("/register")
+ @PostMapping(value = "/register")
+ public ResponseEntity> register(@RequestBody StudentCreateRequest studentCreateRequest) {
+ var response = studentService.create(studentCreateRequest);
+ return ResponseEntity.ok(response);
+ }
+
+ /* 아이디 중복 확인 API 만들어봤는데 한번확인해주시면 감사하겠습니다.
+ @GetMapping("/check-id")
+ public ResponseEntity> checkLoginId(@RequestParam String userId) {
+ boolean exists = studentRepository.existsByLoginId(userId);
+
+ Map result = new HashMap<>();
+ result.put("available", !exists); // true = 사용 가능
+
+ return ResponseEntity.ok(result);
+ }*/
+
+ @GetMapping("/check-id")
+ public ResponseEntity> checkId(@RequestParam String loginId){
+ if(studentService.ixExistLoginId(loginId)) {
+ return ResponseEntity.status(HttpStatus.CONFLICT).build();
+ }
+ return ResponseEntity.ok().build();
+ }
+}
diff --git a/src/main/java/com/demo/controller/api/PostApiController.java b/src/main/java/com/demo/controller/api/PostApiController.java
new file mode 100644
index 0000000..4554864
--- /dev/null
+++ b/src/main/java/com/demo/controller/api/PostApiController.java
@@ -0,0 +1,78 @@
+package com.demo.controller.api;
+
+import com.demo.dto.request.PostCreateRequest;
+import com.demo.dto.request.PostUpdateRequest;
+import com.demo.dto.response.PostResponse;
+import com.demo.dto.response.PostWithUserNameResponse;
+import com.demo.service.PostService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Page;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/api/v1/posts")
+public class PostApiController {
+
+ private final PostService postService;
+
+ @PostMapping
+ @PreAuthorize("hasRole('USER')")
+ public PostResponse createPost(@RequestBody PostCreateRequest request, @AuthenticationPrincipal UserDetails userDetails) {
+ String studentId = userDetails.getUsername();
+ log.info("Creating post: Student ID: {}, Post Title: {}", studentId, request.getTitle());
+ PostResponse response = postService.createPost(request, studentId);
+ log.info("Post created successfully: Post ID: {}", response.getId());
+ return response;
+ }
+
+ // 상세 조회 반환 타입을 PostWithUserNameResponse로 변경
+ @GetMapping("/{postId}")
+ public PostWithUserNameResponse getPost(@PathVariable Long postId) {
+ log.info("Fetching post with ID: {}", postId);
+ PostWithUserNameResponse response = postService.getPost(postId);
+ log.info("Post fetched successfully: Post ID: {}", postId);
+ return response;
+ }
+
+ @PutMapping("/{postId}")
+ @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
+ public PostResponse updatePost(@PathVariable Long postId, @RequestBody PostUpdateRequest request, @AuthenticationPrincipal UserDetails userDetails) {
+ String studentId = userDetails.getUsername();
+ log.info("Updating post: Post ID: {}, Student ID: {}, New Title: {} ", postId, studentId, request.getTitle());
+ PostResponse response = postService.updatePost(postId, request, studentId);
+ log.info("Post updated successfully: Post ID: {}", postId);
+ return response;
+ }
+
+ @DeleteMapping("/{postId}")
+ @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
+ public void deletePost(@PathVariable Long postId, @AuthenticationPrincipal UserDetails userDetails) {
+ String studentId = userDetails.getUsername();
+ log.info("Deleting post: Post ID: {}, Student ID: {}", postId, studentId);
+ postService.deletePost(postId, studentId);
+ log.info("Post deleted successfully: Post ID: {}, ", postId);
+ }
+
+ @GetMapping
+ public Page getPostList(@RequestParam(required = false) String titleSearch,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size) {
+ Pageable pageable = PageRequest.of(page, size);
+ log.info("Fetching post list: Search Query: {}, Page: {}, Size: {}", titleSearch, page, size);
+
+ Page postPage = (titleSearch == null)
+ ? postService.getAllPosts(pageable)
+ : postService.searchPosts(titleSearch, pageable);
+
+ log.info("Fetched {} posts.", postPage.getTotalElements());
+ return postPage;
+ }
+}
diff --git a/src/main/java/com/demo/controller/api/StudentApiController.java b/src/main/java/com/demo/controller/api/StudentApiController.java
new file mode 100644
index 0000000..bc825af
--- /dev/null
+++ b/src/main/java/com/demo/controller/api/StudentApiController.java
@@ -0,0 +1,43 @@
+package com.demo.controller.api;
+
+import com.demo.service.StudentService;
+import com.demo.dto.request.StudentCreateRequest;
+import com.demo.dto.request.StudentUpdateRequest;
+
+import jakarta.annotation.security.RolesAllowed;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.web.bind.annotation.*;
+
+@Slf4j
+@RestController
+@RequestMapping("/api/v1/students")
+@RequiredArgsConstructor
+public class StudentApiController {
+ private final StudentService studentService;
+
+ @GetMapping
+ public ResponseEntity> get(@AuthenticationPrincipal UserDetails authentication) {
+ var response = studentService.findById(authentication.getUsername());
+ return ResponseEntity.ok(response);
+ }
+
+ @PostMapping
+ public ResponseEntity> update(
+ @RequestBody StudentUpdateRequest request,
+ @AuthenticationPrincipal UserDetails authentication
+ ) {
+ var response = studentService.update(authentication.getUsername(), request);
+ return ResponseEntity.ok(response);
+ }
+
+ @DeleteMapping
+ public ResponseEntity> delete(@AuthenticationPrincipal UserDetails authentication) {
+ studentService.delete(authentication.getUsername());
+ return ResponseEntity.noContent().build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/controller/ui/AuthUIController.java b/src/main/java/com/demo/controller/ui/AuthUIController.java
new file mode 100644
index 0000000..1e83cc4
--- /dev/null
+++ b/src/main/java/com/demo/controller/ui/AuthUIController.java
@@ -0,0 +1,19 @@
+package com.demo.controller.ui;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.RequestMapping;
+
+@Controller
+public class AuthUIController {
+
+ @RequestMapping("/sign-in")
+ public String signIn() {
+
+ return "/login/login-1.0";
+ }
+
+ @RequestMapping("/sign-up")
+ public String signUp() {
+ return "/signup/signup-1.0";
+ }
+}
diff --git a/src/main/java/com/demo/controller/HomeController.java b/src/main/java/com/demo/controller/ui/HomeController.java
similarity index 90%
rename from src/main/java/com/demo/controller/HomeController.java
rename to src/main/java/com/demo/controller/ui/HomeController.java
index a0be3cb..44da75f 100644
--- a/src/main/java/com/demo/controller/HomeController.java
+++ b/src/main/java/com/demo/controller/ui/HomeController.java
@@ -1,4 +1,4 @@
-package com.demo.controller;
+package com.demo.controller.ui;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
diff --git a/src/main/java/com/demo/controller/ui/PostUIController.java b/src/main/java/com/demo/controller/ui/PostUIController.java
new file mode 100644
index 0000000..8b20e92
--- /dev/null
+++ b/src/main/java/com/demo/controller/ui/PostUIController.java
@@ -0,0 +1,33 @@
+package com.demo.controller.ui;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+
+@Controller
+public class PostUIController {
+
+ // 게시글 목록 페이지
+
+ @RequestMapping("/posts")
+ public String postList() {
+ return "/post/post-list";
+ }
+
+
+ @RequestMapping("/posts/new")
+ public String postCreate() {
+ return "/post/post-create";
+ }
+
+ @RequestMapping("/posts/edit/{id}")
+ public String postEdit(@PathVariable Long id) {
+ // id를 수정 페이지에 전달하고 싶으면 Model에 담아서 넘길 수도 있음
+ return "/post/post-update";
+ }
+
+ @RequestMapping("/posts/{id}")
+ public String postDetails(@PathVariable Long id) {
+ return "/post/post-details";
+ }
+}
diff --git a/src/main/java/com/demo/domain/Authority.java b/src/main/java/com/demo/domain/Authority.java
new file mode 100644
index 0000000..489f153
--- /dev/null
+++ b/src/main/java/com/demo/domain/Authority.java
@@ -0,0 +1,50 @@
+package com.demo.domain;
+
+import jakarta.persistence.*;
+import lombok.*;
+
+/**
+ * 사용자가 가지는 권한 엔티티
+ *
+ * 현재는 두 가지 권한만 존재한다.
+ * ROLE_USER
+ * ROLE_ADMIN
+ *
+ * @author duskafka
+ * */
+@Entity
+@Table(name = "authority")
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Authority {
+
+ @Id
+ @Column(name = "authority_name", length = 50)
+ @Enumerated(EnumType.STRING) // Enum 값을 DB에 저장할 때 문자열로 저장하게 했습니다
+ private Role role;
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Authority authority = (Authority) o;
+ return role == authority.role;
+ }
+
+ public Authority(Role role) {
+ this.role = role;
+ }
+
+ public static Authority createRole(Role role) {
+ return new Authority(role);
+ }
+
+ public String getAuthorityName() {
+ return role.name(); // Role enum 값을 문자열로 반환
+ }
+
+ @Override
+ public int hashCode() {
+ return role.hashCode();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/domain/Post.java b/src/main/java/com/demo/domain/Post.java
new file mode 100644
index 0000000..8ad7a85
--- /dev/null
+++ b/src/main/java/com/demo/domain/Post.java
@@ -0,0 +1,63 @@
+package com.demo.domain;
+
+import com.demo.dto.request.PostUpdateRequest;
+import com.demo.dto.response.PostResponse;
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.AllArgsConstructor;
+
+
+import java.time.ZoneOffset;
+import java.time.LocalDateTime;
+
+@Entity
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class Post {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+ private String title;
+ private String content;
+ private String description;
+
+ @ManyToOne
+ @JoinColumn(name = "student_id")
+ private Student student;
+
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+
+
+ @PrePersist
+ public void prePersist() {
+ this.createdAt = LocalDateTime.now(ZoneOffset.UTC);
+ this.updatedAt = LocalDateTime.now(ZoneOffset.UTC);
+ }
+
+ // 게시글 수정 시 수정 시간 업데이트
+ @PreUpdate
+ public void preUpdate() {
+ this.updatedAt = LocalDateTime.now(ZoneOffset.UTC);
+ }
+
+ public void update(PostUpdateRequest request) {
+ this.title = request.getTitle();
+ this.content = request.getContent();
+ this.description = request.getDescription();
+ }
+
+ public Post(String title, String content, String description, Student student) {
+ this.title = title;
+ this.content = content;
+ this.description = description;
+ this.student = student;
+ }
+
+ public PostResponse toResponse() {
+ return PostResponse.fromPost(this);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/domain/Role.java b/src/main/java/com/demo/domain/Role.java
new file mode 100644
index 0000000..be7eb2f
--- /dev/null
+++ b/src/main/java/com/demo/domain/Role.java
@@ -0,0 +1,25 @@
+package com.demo.domain;
+
+public enum Role {
+ ROLE_USER("ROLE_USER"),
+ ROLE_ADMIN("ROLE_ADMIN");
+
+ private final String roleName;
+
+ Role(String roleName) {
+ this.roleName = roleName;
+ }
+
+ public String getRoleName() {
+ return roleName;
+ }
+
+ public static Role fromString(String roleName) {
+ for (Role role : Role.values()) {
+ if (role.getRoleName().equals(roleName)) {
+ return role;
+ }
+ }
+ throw new IllegalArgumentException("Unknown role: " + roleName);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/domain/Student.java b/src/main/java/com/demo/domain/Student.java
index 15b48f0..5593f23 100644
--- a/src/main/java/com/demo/domain/Student.java
+++ b/src/main/java/com/demo/domain/Student.java
@@ -1,52 +1,104 @@
package com.demo.domain;
+import com.demo.domain.converter.PasswordEncodeConverter;
import com.demo.dto.request.StudentCreateRequest;
import com.demo.dto.response.StudentResponse;
import com.demo.dto.request.StudentUpdateRequest;
import jakarta.persistence.*;
-import lombok.AccessLevel;
-import lombok.Builder;
-import lombok.NoArgsConstructor;
-import lombok.ToString;
+import lombok.*;
+
+import java.util.HashSet;
+import java.util.Set;
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
+@Getter
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "student_id")
private Long id;
- @Column(name = "student_name", nullable = true, length = 50)
+ @Column(name = "student_name", length = 50)
private String name;
- @Column(name = "student_age", nullable = true, length = 3)
- private Long age;
+ @Column(name = "student_number", length = 10)
+ private Long studentNumber;
+
+ @Column(name = "student_phone", length = 11)
+ private String phoneNumber;
+
+ @Column(name = "user_id", length = 25)
+ private String loginId;
+
+ @Column(name = "user_password", nullable = false)
+ @Convert(converter = PasswordEncodeConverter.class)
+ private String password;
+
+ @Column(name = "user_email", length = 50)
+ private String email;
+
+ @ManyToMany
+ @JoinTable(
+ name = "student_authority",
+ joinColumns = {@JoinColumn(name = "student_id", referencedColumnName = "student_id")},
+ inverseJoinColumns = {@JoinColumn(name = "authority_name", referencedColumnName = "authority_name")}
+ )
+ private Set authorities = new HashSet<>();
+
+ public void addAuthority(Authority authority) {
+ this.authorities.add(authority);
+ }
+
+ /**
+ * 권한 보유 여부 반환
+ * 기존 구현은 noneMatch를 사용해 "권한이 없을 때 true"가 되는 역동작이었음.
+ * → anyMatch로 수정하여 "권한이 있을 때 true"가 되도록 정상화.
+ */
+ public boolean hasRole(Role role) {
+ return this.getAuthorities().stream()
+ .anyMatch(authority -> authority.getRole().equals(role)); // <-- 수정: noneMatch -> anyMatch
+ }
@Builder(access = AccessLevel.PRIVATE)
- public Student(String name, Long age) {
+ public Student(String name, Long studentNumber, String phoneNumber, String loginId, String password, String email) {
this.name = name;
- this.age = age;
+ this.studentNumber = studentNumber;
+ this.phoneNumber = phoneNumber;
+ this.loginId = loginId;
+ this.password = password;
+ this.email = email;
}
- public static Student toEntity(StudentCreateRequest request){
+ public static Student toEntity(StudentCreateRequest request) {
return Student.builder()
.name(request.getName())
- .age(request.getAge())
+ .studentNumber(request.getStudentNumber())
+ .phoneNumber(request.getPhoneNumber())
+ .loginId(request.getUserId())
+ .password(request.getPassword())
+ .email(request.getEmail())
.build();
}
- public void update(StudentUpdateRequest request){
+ public void update(StudentUpdateRequest request) {
this.name = request.getName();
- this.age = request.getAge();
+ this.studentNumber = request.getStudentNumber();
+ this.phoneNumber = request.getPhoneNumber();
+ this.loginId = request.getUserId();
+ this.password = request.getPassword();
+ this.email = request.getEmail();
}
- public StudentResponse toResponse(){
+ public StudentResponse toResponse() {
return StudentResponse.builder()
.id(this.id)
.name(this.name)
- .age(this.age)
+ .studentNumber(this.studentNumber)
+ .phoneNumber(this.phoneNumber)
+ .userId(this.loginId)
+ .password(this.password)
.build();
}
}
\ No newline at end of file
diff --git a/src/main/java/com/demo/domain/converter/PasswordEncodeConverter.java b/src/main/java/com/demo/domain/converter/PasswordEncodeConverter.java
new file mode 100644
index 0000000..3e6573d
--- /dev/null
+++ b/src/main/java/com/demo/domain/converter/PasswordEncodeConverter.java
@@ -0,0 +1,34 @@
+package com.demo.domain.converter;
+
+import com.demo.domain.vo.Password;
+import jakarta.persistence.AttributeConverter;
+import jakarta.persistence.Converter;
+import lombok.AccessLevel;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Component;
+
+/**
+ * Programmer 엔티티에서 password를 암호화하는 컨버터
+ *
+ * @author duskafka
+ * */
+@Converter
+@Component
+@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
+public class PasswordEncodeConverter implements AttributeConverter {
+ private final PasswordEncoder passwordEncoder;
+
+ @Override
+ public String convertToDatabaseColumn(String attribute) {
+ var password = new Password(attribute);
+ password.setEncodePassword(passwordEncoder.encode(attribute));
+
+ return password.getPassword();
+ }
+
+ @Override
+ public String convertToEntityAttribute(String dbData) {
+ return dbData;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/domain/vo/Password.java b/src/main/java/com/demo/domain/vo/Password.java
new file mode 100644
index 0000000..64529b5
--- /dev/null
+++ b/src/main/java/com/demo/domain/vo/Password.java
@@ -0,0 +1,29 @@
+package com.demo.domain.vo;
+
+import lombok.Getter;
+
+import java.util.regex.Pattern;
+
+/**
+ * Programmer 엔티티에서 password가 유효한 형식인지 검사하고 저장하기 위한 VO
+ *
+ * 최소 8글자, 글자 1개, 숫자 1개, 특수문자 1개
+ *
+ * @author duskafka
+ * */
+@Getter
+public class Password {
+ private String password;
+
+ public Password(String password) {
+ validatePassword(password);
+ }
+
+ private void validatePassword(String password) {
+ this.password = password;
+ }
+
+ public void setEncodePassword(String encodePassword) {
+ this.password = encodePassword;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/dto/request/LoginRequest.java b/src/main/java/com/demo/dto/request/LoginRequest.java
new file mode 100644
index 0000000..3e676b2
--- /dev/null
+++ b/src/main/java/com/demo/dto/request/LoginRequest.java
@@ -0,0 +1,9 @@
+package com.demo.dto.request;
+
+import lombok.Data;
+
+@Data
+public class LoginRequest {
+ String loginId;
+ String password;
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/dto/request/PostCreateRequest.java b/src/main/java/com/demo/dto/request/PostCreateRequest.java
new file mode 100644
index 0000000..6adb563
--- /dev/null
+++ b/src/main/java/com/demo/dto/request/PostCreateRequest.java
@@ -0,0 +1,14 @@
+package com.demo.dto.request;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class PostCreateRequest {
+
+ private String title;
+ private String content;
+ private String description;
+ private String studentId;
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/dto/request/PostUpdateRequest.java b/src/main/java/com/demo/dto/request/PostUpdateRequest.java
new file mode 100644
index 0000000..fdf4de8
--- /dev/null
+++ b/src/main/java/com/demo/dto/request/PostUpdateRequest.java
@@ -0,0 +1,13 @@
+package com.demo.dto.request;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class PostUpdateRequest {
+
+ private String title;
+ private String content;
+ private String description;
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/dto/request/StudentCreateRequest.java b/src/main/java/com/demo/dto/request/StudentCreateRequest.java
index 33e9247..b58a6c1 100644
--- a/src/main/java/com/demo/dto/request/StudentCreateRequest.java
+++ b/src/main/java/com/demo/dto/request/StudentCreateRequest.java
@@ -5,5 +5,9 @@
@Getter
public class StudentCreateRequest {
private String name;
- private Long age;
+ private Long studentNumber;
+ private String phoneNumber;
+ private String userId;
+ private String password;
+ private String email;
}
diff --git a/src/main/java/com/demo/dto/request/StudentUpdateRequest.java b/src/main/java/com/demo/dto/request/StudentUpdateRequest.java
index 238332d..621fd09 100644
--- a/src/main/java/com/demo/dto/request/StudentUpdateRequest.java
+++ b/src/main/java/com/demo/dto/request/StudentUpdateRequest.java
@@ -5,5 +5,9 @@
@Getter
public class StudentUpdateRequest {
private String name;
- private Long age;
+ private Long studentNumber;
+ private String phoneNumber;
+ private String userId;
+ private String password;
+ private String email;
}
diff --git a/src/main/java/com/demo/dto/response/PostResponse.java b/src/main/java/com/demo/dto/response/PostResponse.java
new file mode 100644
index 0000000..b1c4b3c
--- /dev/null
+++ b/src/main/java/com/demo/dto/response/PostResponse.java
@@ -0,0 +1,28 @@
+package com.demo.dto.response;
+
+import lombok.Getter;
+import lombok.AllArgsConstructor;
+
+@Getter
+@AllArgsConstructor
+public class PostResponse {
+
+ private Long id; // 게시글 ID
+ private String title; // 게시글 제목
+ private String content; // 게시글 내용
+ private String description; // 게시글 설명
+ private String createdAt; // 게시글 작성 시간
+ private String updatedAt; // 게시글 수정 시간
+
+
+ public static PostResponse fromPost(com.demo.domain.Post post) {
+ return new PostResponse(
+ post.getId(),
+ post.getTitle(),
+ post.getContent(),
+ post.getDescription(),
+ post.getCreatedAt() != null ? post.getCreatedAt().toString() : null,
+ post.getUpdatedAt() != null ? post.getUpdatedAt().toString() : null
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/dto/response/PostWithUserNameResponse.java b/src/main/java/com/demo/dto/response/PostWithUserNameResponse.java
new file mode 100644
index 0000000..16ab365
--- /dev/null
+++ b/src/main/java/com/demo/dto/response/PostWithUserNameResponse.java
@@ -0,0 +1,29 @@
+package com.demo.dto.response;
+
+import com.demo.domain.Post;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class PostWithUserNameResponse {
+ private Long id; // 게시글 ID
+ private String title; // 게시글 제목
+ private String content; // 게시글 내용
+ private String description; // 게시글 설명
+ private String createdAt; // 게시글 작성 시간
+ private String updatedAt; // 게시글 수정 시간
+ private String username;
+
+ public static PostWithUserNameResponse toResponse(Post post) {
+ return PostWithUserNameResponse.builder()
+ .id(post.getId())
+ .title(post.getTitle())
+ .content(post.getContent())
+ .description(post.getDescription())
+ .createdAt(post.getCreatedAt().toString())
+ .updatedAt(post.getUpdatedAt().toString())
+ .username(post.getStudent().getName())
+ .build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/dto/response/StudentResponse.java b/src/main/java/com/demo/dto/response/StudentResponse.java
index 70d29b7..c0d6b0e 100644
--- a/src/main/java/com/demo/dto/response/StudentResponse.java
+++ b/src/main/java/com/demo/dto/response/StudentResponse.java
@@ -1,5 +1,6 @@
package com.demo.dto.response;
+import com.demo.domain.Student;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@@ -10,5 +11,19 @@
public class StudentResponse {
private Long id;
private String name;
- private Long age;
+ private Long studentNumber;
+ private String phoneNumber;
+ private String userId;
+ private String password;
+
+ public static StudentResponse fromStudent(Student student) {
+ return StudentResponse.builder()
+ .id(student.getId())
+ .name(student.getName())
+ .studentNumber(student.getStudentNumber())
+ .phoneNumber(student.getPhoneNumber())
+ .userId(student.getLoginId())
+ .password(null)
+ .build();
+ }
}
\ No newline at end of file
diff --git a/src/main/java/com/demo/exception/BusinessException.java b/src/main/java/com/demo/exception/BusinessException.java
new file mode 100644
index 0000000..bf02534
--- /dev/null
+++ b/src/main/java/com/demo/exception/BusinessException.java
@@ -0,0 +1,47 @@
+package com.demo.exception;
+
+import com.demo.exception.dto.ErrorMessage;
+import lombok.Getter;
+
+/**
+ * 애플리케이션에서 발생하는 예외들을 래핑해서 {@code GlobalExceptionHandler}에서 차리하기 위한 예외.
+ *
+ *
+ * - 애플리케이션에서 발생할 수 있는 모든 예외는 이 클래스를 상속한다.
+ *
+ * */
+@Getter
+public class BusinessException extends RuntimeException {
+ private final ErrorMessage errorMessage;
+
+ /**
+ * ErrorMessage를 사용하여 예외를 생성합니다.
+ *
+ * @param message 오류 메시지
+ * */
+ public BusinessException(ErrorMessage message) {
+ super(message.getMessage());
+ this.errorMessage = message;
+ }
+
+ /**
+ * ErrorMessage와 추가 이유를 사용하여 예외를 생성합니다.
+ *
+ * @param message 오휴 메시지
+ * @param reason 추가 이유
+ * */
+ public BusinessException(ErrorMessage message, String reason) {
+ super(reason);
+ this.errorMessage = message;
+ }
+
+ /**
+ * 이유만으로 예외를 생성한다.
+ *
+ * @param reason 예외 발생 이유
+ * */
+ public BusinessException(String reason) {
+ super(reason);
+ this.errorMessage = ErrorMessage.INTERNAL_SERVER_ERROR;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/exception/GlobalExceptionHandler.java b/src/main/java/com/demo/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000..5b699f8
--- /dev/null
+++ b/src/main/java/com/demo/exception/GlobalExceptionHandler.java
@@ -0,0 +1,36 @@
+package com.demo.exception;
+
+import com.demo.exception.dto.ErrorMessage;
+import com.demo.exception.dto.ErrorResponseDto;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.stream.Collectors;
+
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(BusinessException.class)
+ protected ResponseEntity handleBusinessException(BusinessException e) {
+ var errorMessage = e.getErrorMessage();
+
+ log.error("[ERROR] BusinessException -> {}", errorMessage.getMessage());
+
+ return ErrorResponseDto.of(errorMessage);
+ }
+
+ @ExceptionHandler(MethodArgumentNotValidException.class)
+ protected ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
+ String detailedErrorMessage = e.getBindingResult().getFieldErrors().stream()
+ .map(error -> error.getField() + ": " + error.getDefaultMessage())
+ .collect(Collectors.joining(" "));
+
+ log.error("[ERROR] MethodArgumentNotValidException -> {}", detailedErrorMessage);
+
+ return ErrorResponseDto.of(ErrorMessage.INVALID_REQUEST_PARAMETER);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/exception/dto/ErrorDto.java b/src/main/java/com/demo/exception/dto/ErrorDto.java
new file mode 100644
index 0000000..cb1db4b
--- /dev/null
+++ b/src/main/java/com/demo/exception/dto/ErrorDto.java
@@ -0,0 +1,39 @@
+package com.demo.exception.dto;
+
+import org.springframework.validation.FieldError;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 에러가 발생했을 때 에러를 응답하기 위한 DTO
+ *
+ * @author duskafka
+ * */
+public class ErrorDto {
+ private final int status;
+ private final String message;
+ private List fieldErrors = new ArrayList<>();
+
+ public ErrorDto(int status, String message) {
+ this.status = status;
+ this.message = message;
+ }
+
+ public int getStatus() {
+ return status;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void addFieldError(String objectName, String path, String message) {
+ FieldError error = new FieldError(objectName, path, message);
+ fieldErrors.add(error);
+ }
+
+ public List getFieldErrors() {
+ return fieldErrors;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/exception/dto/ErrorMessage.java b/src/main/java/com/demo/exception/dto/ErrorMessage.java
new file mode 100644
index 0000000..b6ec847
--- /dev/null
+++ b/src/main/java/com/demo/exception/dto/ErrorMessage.java
@@ -0,0 +1,76 @@
+package com.demo.exception.dto;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
+
+/**
+ * 에러가 발생했을 때 HTTP 상태 코드와 메시지를 반환하는 열거형
+ *
+ * 열거형의 접미어로 Exception을 붙이지 않는다.
+ * 네이밍 일관성을 가지게 한다. NOT_FOUND_XXX, INVALID_XXX, FAILED_XXX, DUPLICATE_XXX 등으로 통일
+ *
+ * @author duskafka
+ */
+@Getter
+@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
+public enum ErrorMessage {
+ //Server
+ INVALID_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST, "잘못된 요청 입니다."),
+ INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "예기치 못한 에러가 발생했습니다."),
+
+ //PROGRAMMER
+ DUPLICATE_PROGRAMMER(HttpStatus.CONFLICT, "중복된 사용자가 존재합니다."),
+ PROGRAMMER_NOT_CREATED(HttpStatus.BAD_REQUEST, "사용자 생성에 실패했습니다."),
+ INVALID_ID(HttpStatus.BAD_REQUEST, "존재하지 않는 ID입니다."),
+ NOT_FOUND_STUDENT(HttpStatus.NOT_FOUND, "요청한 사용자를 찾을 수 없습니다."),
+ INVALID_PASSWORD_REGEX(HttpStatus.CONFLICT, "유효한 비밀번호 형식이 아닙니다."),
+ INVALID_EMAIL_REGEX(HttpStatus.CONFLICT, "유효한 이메일 형식이 아닙니다."),
+ INVALID_NAME_REGEX(HttpStatus.CONFLICT, "유효한 이름 형식이 아닙니다."),
+ LOGIN_FAIL(HttpStatus.BAD_REQUEST, "ID/PW 가 일치하지 않습니다."),
+ WRONG_ID(HttpStatus.BAD_REQUEST, "ID 가 일치하지 않습니다."),
+ WRONG_PASSWORD(HttpStatus.BAD_REQUEST, "Password 가 일치하지 않습니다"),
+ UPDATE_FAILED(HttpStatus.NOT_FOUND, "업데이트에 실패했습니다."),
+
+
+ //ANSWER
+ NOT_FOUND_ANSWER(HttpStatus.NOT_FOUND, "풀이를 찾을 수 없습니다."),
+ ANSWER_AND_PROGRAMMER_DO_NOT_MATCH(HttpStatus.BAD_REQUEST, "해당 풀이는 사용자가 작성한 풀이가 아닙니다."),
+
+ //JWT
+ INVALID_JWT(HttpStatus.UNAUTHORIZED, "유효하지 않은 JWT 토큰입니다."),
+ NO_TOKEN_IN_HEADER(HttpStatus.UNAUTHORIZED, "헤더에 토큰이 없습니다."),
+ EXPIRED_JWT(HttpStatus.UNAUTHORIZED, "토큰 유효기간이 만료되었습니다."),
+ SECURITY_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 JWT 서명입니다."),
+ REFRESH_TOKEN_IS_NULL(HttpStatus.BAD_REQUEST, "갱신할 리프레쉬 토큰이 없습니다."),
+ UN_MATCH_JTI(HttpStatus.BAD_REQUEST, "JTI가 일치하지 않습니다."),
+ NOT_FOUND_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "요청한 리프레시 토큰을 찾을 수 없습니다."),
+ REFRESH_TOKEN_OVER_MAX(HttpStatus.CONFLICT, "리프레시 토큰을 더이상 발급할 수 없습니다."),
+ REFRESH_TOKEN_REVOKED(HttpStatus.CONFLICT, "무효화된 리프레쉬 토큰입니다."),
+
+ //RANKING
+ NO_RANKING(HttpStatus.NO_CONTENT, "금일 랭킹이 존재하지 않습니다."),
+ JOB_ALREADY_EXECUTION(HttpStatus.INTERNAL_SERVER_ERROR, "이미 스케줄러가 실행 중입니다."),
+ JOB_BUILDER_BUILD_INVALID_PARAMETERS(HttpStatus.INTERNAL_SERVER_ERROR, "잘못된 Job 파라미터가 생성되었습니다."),
+ RANKING_ILLEGAL_TYPE(HttpStatus.INTERNAL_SERVER_ERROR, "Rank 데이터 타입이 유효하지 않습니다."),
+ RANKING_COUNT(HttpStatus.INTERNAL_SERVER_ERROR, "랭킹 카운트 처리 중 예외가 발생했습니다."),
+ FAILED_SAVE_RANKING_IN_REDIS(HttpStatus.INTERNAL_SERVER_ERROR, "Redis에 저장하지 못했습니다."),
+
+ //Redis
+ REDIS_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Redis 서버 오류가 발생했습니다."),
+ FAILED_DELETE_REFRESH_TOKEN(HttpStatus.INTERNAL_SERVER_ERROR, "Redis에서 리프레시 토큰을 삭제하는 중 오류가 발생했습니다."),
+ FAILED_FIND_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "Redis에서 리프레시 토큰을 찾지 못했습니다."),
+
+ //EMAIL
+ NOT_FOUND_EMAIL_VERIFICATION(HttpStatus.NOT_FOUND, "데이터베이스에서 이메일 인증 토큰을 조회하지 못했습니다"),
+ EMAIL_MESSAGING(HttpStatus.INTERNAL_SERVER_ERROR, "이메일 생성 중 오류가 발생했습니다."),
+ EMAIL_NOT_VERIFICATION(HttpStatus.BAD_REQUEST, "인증된 이메일이 아닙니다."),
+ DUPLICATE_EMAIL_VERIFICATION(HttpStatus.BAD_REQUEST, "이미 인증이 완료되었거나 인증이 요첟된 이메일입니디."),
+ ILLEGAL_EMAIL_REGEX(HttpStatus.BAD_REQUEST, "유효한 이메일 형식이 아니기에 이메일 인증 요청에 실패했습니다."),
+ ;
+
+
+ private final HttpStatus status;
+ private final String message;
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/exception/dto/ErrorResponseDto.java b/src/main/java/com/demo/exception/dto/ErrorResponseDto.java
new file mode 100644
index 0000000..4f10015
--- /dev/null
+++ b/src/main/java/com/demo/exception/dto/ErrorResponseDto.java
@@ -0,0 +1,31 @@
+package com.demo.exception.dto;
+
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+
+import java.time.LocalDateTime;
+
+/**
+ * 에러가 발생했을 때 GlobalExceptionHandler에서 응답에 사용하는 DTO
+ *
+ * @author duskafka
+ * */
+@Getter
+@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
+public class ErrorResponseDto {
+ private final String code;
+ private final String message;
+ private final LocalDateTime serverDateTime;
+
+ public static ResponseEntity of(ErrorMessage message) {
+ return ResponseEntity
+ .status(message.getStatus())
+ .body(new ErrorResponseDto(
+ message.getStatus().toString(),
+ message.getMessage(),
+ LocalDateTime.now())
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/exception/post/PostNotFoundException.java b/src/main/java/com/demo/exception/post/PostNotFoundException.java
new file mode 100644
index 0000000..b94da2b
--- /dev/null
+++ b/src/main/java/com/demo/exception/post/PostNotFoundException.java
@@ -0,0 +1,7 @@
+package com.demo.exception.post;
+
+public class PostNotFoundException extends RuntimeException {
+ public PostNotFoundException(String PostNotFound) {
+ super("게시글을 찾을 수 없습니다");
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/exception/post/UnauthorizedAccessException.java b/src/main/java/com/demo/exception/post/UnauthorizedAccessException.java
new file mode 100644
index 0000000..878ec07
--- /dev/null
+++ b/src/main/java/com/demo/exception/post/UnauthorizedAccessException.java
@@ -0,0 +1,7 @@
+package com.demo.exception.post;
+
+public class UnauthorizedAccessException extends RuntimeException {
+ public UnauthorizedAccessException(String nonePermission) {
+ super("이 게시글에 대한 접근 권한이 없습니다.");
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/exception/student/NotFoundStudentException.java b/src/main/java/com/demo/exception/student/NotFoundStudentException.java
new file mode 100644
index 0000000..cff2055
--- /dev/null
+++ b/src/main/java/com/demo/exception/student/NotFoundStudentException.java
@@ -0,0 +1,18 @@
+package com.demo.exception.student;
+
+import com.demo.exception.BusinessException;
+import com.demo.exception.dto.ErrorMessage;
+
+public class NotFoundStudentException extends BusinessException {
+ public NotFoundStudentException(ErrorMessage message) {
+ super(message);
+ }
+
+ public NotFoundStudentException(ErrorMessage message, String reason) {
+ super(message, reason);
+ }
+
+ public NotFoundStudentException(String reason) {
+ super(reason);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/jwt/JwtAccessDeniedHandler.java b/src/main/java/com/demo/jwt/JwtAccessDeniedHandler.java
new file mode 100644
index 0000000..74617b7
--- /dev/null
+++ b/src/main/java/com/demo/jwt/JwtAccessDeniedHandler.java
@@ -0,0 +1,39 @@
+package com.demo.jwt;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.web.access.AccessDeniedHandler;
+import org.springframework.stereotype.Component;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+
+/**
+ * JWT 토큰이 없거나 권한이 없어서 요청이 거부된 경우 사용되는 핸들러
+ *
+ * @author duskafka
+ * */
+@Slf4j
+@Component
+public class JwtAccessDeniedHandler implements AccessDeniedHandler {
+ /**
+ * 인증된 사용자가 필요한 권한 없이 보호된 리소스에 접근하려 할 때 호출됩니다.
+ * 이 메서드는 HTTP 응답을 '403 Forbidden' 상태로 설정하고, 접근 거부 발생을 로깅합니다.
+ *
+ * @param request 접근이 거부된 요청을 나타내는 {@link HttpServletRequest} 객체.
+ * @param response 클라이언트에게 응답을 보낼 {@link HttpServletResponse} 객체.
+ * @param accessDeniedException 접근 거부를 초래한 {@link AccessDeniedException} 객체.
+ * @throws IOException 응답을 보내는 도중 I/O 오류가 발생할 경우.
+ */
+ @Override
+ public void handle(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ AccessDeniedException accessDeniedException
+ ) throws IOException {
+ log.warn("[JwtAccessDeniedHandler] Access Denied for request URI: {}. Error message: {}", request.getRequestURI(), accessDeniedException.getMessage());
+ response.sendError(HttpServletResponse.SC_FORBIDDEN, "Forbidden: You do not have sufficient permissions to access this resource.");
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/com/demo/jwt/JwtAuthenticationEntryPoint.java
new file mode 100644
index 0000000..80a2731
--- /dev/null
+++ b/src/main/java/com/demo/jwt/JwtAuthenticationEntryPoint.java
@@ -0,0 +1,40 @@
+package com.demo.jwt;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+
+/**
+ * JWT 토큰을 가지고 요청을 했지만 유효한 권한 증명이 없어 요청이 거부되었을 때 호출되는 클래스
+ *
+ * @author duskafka
+ * */
+@Slf4j
+@Component
+public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
+ /**
+ * 클라이언트가 유효한 자격 증명 없이 보호된 리소스에 접근하려 할 때 호출됩니다.
+ * 이 메서드는 HTTP 응답을 '401 Unauthorized' 상태로 설정하고, 인증 실패를 로깅합니다.
+ *
+ * @param request 인증이 실패한 요청을 나타내는 {@link HttpServletRequest} 객체.
+ * @param response 클라이언트에게 응답을 보낼 {@link HttpServletResponse} 객체.
+ * @param authException 인증 실패를 초래한 {@link AuthenticationException} 객체.
+ * @throws IOException 응답을 보내는 도중 I/O 오류가 발생할 경우.
+ */
+ @Override
+ public void commence(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ AuthenticationException authException
+ ) throws IOException {
+ // 인증 실패 발생 시, 요청 URI와 함께 경고 로그를 남깁니다.
+ log.warn("[JwtAuthenticationEntryPoint] Authentication failed for request URI: {}. Error message: {}", request.getRequestURI(), authException.getMessage());
+ response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized: Authentication required.");
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/jwt/JwtFilter.java b/src/main/java/com/demo/jwt/JwtFilter.java
new file mode 100644
index 0000000..d25a019
--- /dev/null
+++ b/src/main/java/com/demo/jwt/JwtFilter.java
@@ -0,0 +1,67 @@
+package com.demo.jwt;
+
+import com.demo.config.properties.JWTProperties;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+/**
+ * 이 애플리케이션에서 모든 요청에 대해서 기본적으로 수행될 JWT 필터
+ * 헤더에서 토큰을 가져와 유효한 자격을 가지고 있다면 SecurityContext에 저장한다
+ * 애플리케이션은 무상태로 구성되었기 때문에 이 저장 정보는 한 스레드에서만 가지고 있는다
+ *
+ * @author duskafka
+ * */
+@Slf4j
+public class JwtFilter extends OncePerRequestFilter { // 모든 요청에 대해 동일한 로직을 수행하는 일반 필터
+
+ private final TokenProvider tokenProvider;
+ private final TokenValidator tokenValidator;
+ private final String ACCESS_TOKEN_HEADER;
+ private final String BEARER_HEADER;
+
+ public JwtFilter(TokenProvider tokenProvider, TokenValidator tokenValidator, JWTProperties jwtProperties) {
+ this.tokenProvider = tokenProvider;
+ this.tokenValidator = tokenValidator;
+ this.ACCESS_TOKEN_HEADER = jwtProperties.accessTokenHeader();
+ this.BEARER_HEADER = jwtProperties.bearerHeader();
+ }
+
+ /**
+ * 요청이 들어오면 헤더의 토큰을 검사하고 검사 후 SecurityContextHolder에 정보를 저장해준다.
+ *
+ * @param request 토큰을 담고 있는 헤더를 가져오기 위해 사용
+ * */
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain
+ ) throws ServletException, IOException {
+
+ String token = resolveToken(request);
+ if (token != null && tokenValidator.validateToken(token)) {
+ Authentication authentication = tokenProvider.getAuthentication(token);
+ log.info(authentication.toString());
+ SecurityContextHolder.getContext().setAuthentication(authentication);
+ }
+
+ filterChain.doFilter(request, response);
+ }
+
+ private String resolveToken(HttpServletRequest request) {
+ String bearer = request.getHeader(ACCESS_TOKEN_HEADER);
+ if (bearer != null && bearer.startsWith(BEARER_HEADER)) {
+ return bearer.substring(7);
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/jwt/TokenProvider.java b/src/main/java/com/demo/jwt/TokenProvider.java
new file mode 100644
index 0000000..364bdf7
--- /dev/null
+++ b/src/main/java/com/demo/jwt/TokenProvider.java
@@ -0,0 +1,114 @@
+package com.demo.jwt;
+
+import com.demo.config.properties.JWTProperties;
+import io.jsonwebtoken.*;
+import io.jsonwebtoken.io.Decoders;
+import io.jsonwebtoken.security.Keys;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.stereotype.Component;
+
+import java.security.Key;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 토큰 발급을 위한 서비스
+ *
+ * @author duskafka
+ * */
+@Slf4j
+@Component
+public class TokenProvider {
+ private final String AUTHORITIES_KEY; // JWT 클레임에서 권한 정보를 추출할 때 사용하는 키
+ private final Key KEY; // JWT 서명에 사용되는 Secret Key
+
+ private final long ACCESS_TOKEN_VALIDATE_HOUR; // 토큰의 유효 시간 (시간 단위)
+
+ public TokenProvider(
+ JWTProperties jwtProperties
+ ) {
+ this.AUTHORITIES_KEY = jwtProperties.authorityKey();
+ this.ACCESS_TOKEN_VALIDATE_HOUR = Duration.ofHours(jwtProperties.accessTokenValidityInHour()).toMillis();
+ byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.secret()); // Secret 값을 Base64 디코딩하여 HMAC SHA 키로 변환합니다.
+ this.KEY = Keys.hmacShaKeyFor(keyBytes);
+ }
+
+
+ public String createAccessToken(Authentication authentication) {
+ Claims claims = Jwts.claims().setSubject(authentication.getName());
+
+ List roles = authentication.getAuthorities().stream()
+ .map(GrantedAuthority::getAuthority)
+ .collect(Collectors.toList());
+
+ claims.put(AUTHORITIES_KEY, roles);
+
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + ACCESS_TOKEN_VALIDATE_HOUR);
+
+ return Jwts.builder()
+ .setClaims(claims)
+ .setIssuedAt(now)
+ .setExpiration(expiry)
+ .signWith(SignatureAlgorithm.HS256, KEY)
+ .compact();
+ }
+
+ public Authentication getAuthentication(String token) {
+ try {
+ Claims claims = Jwts
+ .parserBuilder()
+ .setSigningKey(KEY)
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+
+ // 클레임에서 권한 정보 추출 및 GrantedAuthority 객체로 변환
+ Collection extends GrantedAuthority> authorities = extractAuthorities(claims);
+
+ User principal = new User(claims.getSubject(), "", authorities);
+
+ return new UsernamePasswordAuthenticationToken(principal, token, authorities);
+
+ } catch (JwtException | IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid JWT token", e);
+ }
+ }
+
+ private Collection extends GrantedAuthority> extractAuthorities(Claims claims) {
+ Object authoritiesObj = claims.get(AUTHORITIES_KEY);
+
+ if (authoritiesObj == null) {
+ return Collections.emptyList();
+ }
+
+ if (authoritiesObj instanceof List) {
+ return ((List>) authoritiesObj).stream()
+ .map(Object::toString)
+ .map(SimpleGrantedAuthority::new)
+ .collect(Collectors.toList());
+ }
+
+ String authoritiesStr = authoritiesObj.toString();
+
+ // 대괄호 제거 (예: "[ROLE_USER]" -> "ROLE_USER")
+ authoritiesStr = authoritiesStr.replaceAll("^\\[|\\]$", "");
+
+ if (authoritiesStr.trim().isEmpty()) {
+ return Collections.emptyList();
+ }
+
+ return Arrays.stream(authoritiesStr.split(","))
+ .map(String::trim)
+ .filter(role -> !role.isEmpty())
+ .map(SimpleGrantedAuthority::new)
+ .collect(Collectors.toList());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/jwt/TokenValidator.java b/src/main/java/com/demo/jwt/TokenValidator.java
new file mode 100644
index 0000000..92d4ae6
--- /dev/null
+++ b/src/main/java/com/demo/jwt/TokenValidator.java
@@ -0,0 +1,50 @@
+package com.demo.jwt;
+
+import com.demo.config.properties.JWTProperties;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.MalformedJwtException;
+import io.jsonwebtoken.UnsupportedJwtException;
+import io.jsonwebtoken.io.Decoders;
+import io.jsonwebtoken.security.Keys;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.security.Key;
+
+/**
+ * 토큰의 유효성을 검증하기 위한 클래스
+ *
+ * @author duskafka
+ * */
+@Slf4j
+@Service
+public class TokenValidator {
+ private final Key KEY;
+
+ public TokenValidator(JWTProperties jwtProperties) {
+ byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.secret()); // Secret 값을 Base64 디코딩하여 HMAC SHA 키로 변환합니다.
+ this.KEY = Keys.hmacShaKeyFor(keyBytes);
+ }
+
+ public boolean validateToken(String token) {
+ try {
+ log.debug("[TokenProvider] validateToken({})", token);
+ Jwts.parserBuilder().setSigningKey(KEY).build().parseClaimsJws(token); // 토큰 파싱 및 검증
+ log.info("[TokenProvider] Token validation successful.");
+ return true;
+ } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
+ log.warn("[TokenProvider] Invalid JWT signature or malformed JWT token. Token: {}, Error: {}", token, e.getMessage());
+ return false;
+ } catch (ExpiredJwtException e) {
+ log.warn("[TokenProvider] Expired JWT token. Token: {}, Error: {}", token, e.getMessage());
+ return false;
+ } catch (UnsupportedJwtException e) {
+ log.warn("[TokenProvider] Unsupported JWT token. Token: {}, Error: {}", token, e.getMessage());
+ return false;
+ } catch (IllegalArgumentException e) {
+ log.warn("[TokenProvider] JWT token is illegal or invalid argument. Token: {}, Error: {}", token, e.getMessage());
+ return false;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/repository/AuthorityRepository.java b/src/main/java/com/demo/repository/AuthorityRepository.java
new file mode 100644
index 0000000..f4c09f3
--- /dev/null
+++ b/src/main/java/com/demo/repository/AuthorityRepository.java
@@ -0,0 +1,13 @@
+package com.demo.repository;
+
+import com.demo.domain.Authority;
+import com.demo.domain.Role;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface AuthorityRepository extends JpaRepository {
+ Optional findByRole(Role role);
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/repository/PostRepository.java b/src/main/java/com/demo/repository/PostRepository.java
new file mode 100644
index 0000000..a4c3e3c
--- /dev/null
+++ b/src/main/java/com/demo/repository/PostRepository.java
@@ -0,0 +1,11 @@
+package com.demo.repository;
+
+import com.demo.domain.Post;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PostRepository extends JpaRepository {
+ // 제목에 특정 단어가 포함된 게시글을 찾는 메소드입니다
+ Page findByTitleContaining(String search, Pageable pageable);
+}
\ No newline at end of file
diff --git a/src/main/java/com/demo/repository/StudentRepository.java b/src/main/java/com/demo/repository/StudentRepository.java
index b78a078..507f045 100644
--- a/src/main/java/com/demo/repository/StudentRepository.java
+++ b/src/main/java/com/demo/repository/StudentRepository.java
@@ -4,6 +4,12 @@
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
+import java.util.Optional;
+
@Repository
public interface StudentRepository extends JpaRepository {
+ Optional findOneWithAuthoritiesByLoginId(String loginId);
+ Optional findByLoginId(String loginId);
+ void deleteByLoginId(String loginId);
+ boolean existsByLoginId(String loginId);
}
\ No newline at end of file
diff --git a/src/main/java/com/demo/service/CustomLoginService.java b/src/main/java/com/demo/service/CustomLoginService.java
new file mode 100644
index 0000000..6a9b36f
--- /dev/null
+++ b/src/main/java/com/demo/service/CustomLoginService.java
@@ -0,0 +1,76 @@
+ package com.demo.service;
+
+ import com.demo.domain.Authority;
+ import com.demo.domain.Student;
+ import com.demo.repository.StudentRepository;
+ import lombok.RequiredArgsConstructor;
+ import lombok.extern.slf4j.Slf4j;
+ import org.springframework.security.core.GrantedAuthority;
+ import org.springframework.security.core.authority.SimpleGrantedAuthority;
+ import org.springframework.security.core.userdetails.User;
+ import org.springframework.security.core.userdetails.UserDetails;
+ import org.springframework.security.core.userdetails.UserDetailsService;
+ import org.springframework.security.core.userdetails.UsernameNotFoundException;
+ import org.springframework.stereotype.Service;
+ import org.springframework.transaction.annotation.Transactional;
+
+ import java.util.List;
+ import java.util.stream.Collectors;
+
+ /**
+ * 스프링 시큐리티에서 로그인을 처리하기 위한 클래스.
+ *
+ * @author duskafka
+ * */
+ @Slf4j
+ @Service
+ @RequiredArgsConstructor
+ public class CustomLoginService implements UserDetailsService {
+ private final StudentRepository repo;
+
+ /**
+ * 로그인 요청이 들어올 시 로그인을 요청을 처리해주는 메소드.
+ *
+ * 로그인 요청 외에 리프레쉬 토큰을 재발급할 때 사용자 정보를 가져오는 용도로도 사용된다.
+ *
+ * @param username 로그인을 요청한 사용자의 {@code username}
+ * @return 로그인을 성공하여 사용자 정보가 담긴 UerDetails
+ * @see UserDetails 내부에 권한과 사용자 이름({@code username})을 담아서 비즈니스 로직을 처리할 수 있게 해준다.
+ */
+ @Override
+ @Transactional
+ public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
+ log.info("[UserDetailLoginService] loadUserByUsername({})", username);
+ Student student = repo.findOneWithAuthoritiesByLoginId(username)
+ .orElseThrow(() -> new UsernameNotFoundException(username));
+ log.info(student.toString());
+
+ return createUser(student);
+ }
+
+ /**
+ * Programmer 엔티티를 User로 매핑해주는 메소드
+ *
+ * @param student 사용자 정보를 가진 엔티티
+ * @return UserDetails를 상속한 클래스로 인증 정보를 가진다.
+ */
+ private User createUser(Student student) {
+ return new User(
+ student.getLoginId(),
+ student.getPassword(),
+ createAuthorities(student)
+ );
+ }
+
+ /**
+ * Programmer 엔티티 내부에 있는 authorities를 GrantedAuthority로 매핑해 List로 반환하는 메소드.
+ *
+ * @param student authorities를 가지고 있는 엔티티
+ * @return 매핑하여 반환되는 {@code List}
+ */
+ private List createAuthorities(Student student) {
+ return student.getAuthorities().stream()
+ .map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
+ .collect(Collectors.toList());
+ }
+ }
\ No newline at end of file
diff --git a/src/main/java/com/demo/service/PostService.java b/src/main/java/com/demo/service/PostService.java
new file mode 100644
index 0000000..257f36c
--- /dev/null
+++ b/src/main/java/com/demo/service/PostService.java
@@ -0,0 +1,89 @@
+package com.demo.service;
+
+import com.demo.domain.Role;
+import com.demo.domain.Student;
+import com.demo.domain.Post;
+import com.demo.dto.request.PostCreateRequest;
+import com.demo.dto.request.PostUpdateRequest;
+import com.demo.dto.response.PostResponse;
+import com.demo.dto.response.PostWithUserNameResponse;
+import com.demo.exception.student.NotFoundStudentException;
+import com.demo.exception.post.PostNotFoundException;
+import com.demo.exception.post.UnauthorizedAccessException;
+import com.demo.repository.PostRepository;
+import com.demo.repository.StudentRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class PostService {
+
+ private final PostRepository postRepository;
+ private final StudentRepository studentRepository;
+
+ // 상세 조회는 작성자 이름이 포함된 DTO로 반환
+ @Transactional(readOnly = true)
+ public PostWithUserNameResponse getPost(Long postId) {
+ Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new PostNotFoundException("게시글을 찾을 수 없습니다"));
+
+ return PostWithUserNameResponse.toResponse(post);
+ }
+
+ @Transactional(readOnly = true)
+ public Page getAllPosts(Pageable pageable) {
+ return postRepository.findAll(pageable).map(PostWithUserNameResponse::toResponse);
+ }
+
+ @Transactional(readOnly = true)
+ public Page searchPosts(String search, Pageable pageable) {
+ return postRepository.findByTitleContaining(search, pageable)
+ .map(PostWithUserNameResponse::toResponse);
+ }
+
+ @Transactional
+ public PostResponse createPost(PostCreateRequest request, String studentId) {
+ Student student = studentRepository.findByLoginId(studentId)
+ .orElseThrow(() -> new NotFoundStudentException("학생을 찾을 수 없습니다"));
+
+ Post post = new Post(request.getTitle(), request.getContent(), request.getDescription(), student);
+ postRepository.save(post);
+
+ return post.toResponse();
+ }
+
+ @Transactional
+ public PostResponse updatePost(Long postId, PostUpdateRequest request, String studentId) {
+ Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new PostNotFoundException("게시글을 찾을 수 없습니다"));
+
+ Student student = studentRepository.findByLoginId(studentId)
+ .orElseThrow(() -> new NotFoundStudentException("학생을 찾을 수 없습니다"));
+
+ if (!post.getStudent().getLoginId().equals(studentId) && !student.hasRole(Role.ROLE_ADMIN)) {
+ throw new UnauthorizedAccessException("권한이 없습니다");
+ }
+
+ post.update(request);
+ return post.toResponse();
+ }
+
+ @Transactional
+ public void deletePost(Long postId, String studentId) {
+ Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new PostNotFoundException("게시글을 찾을 수 없습니다"));
+
+ Student student = studentRepository.findByLoginId(studentId)
+ .orElseThrow(() -> new NotFoundStudentException("학생을 찾을 수 없습니다"));
+
+ if (!post.getStudent().getLoginId().equals(studentId) && !student.hasRole(Role.ROLE_ADMIN)) {
+ throw new UnauthorizedAccessException("권한이 없습니다");
+ }
+
+ postRepository.delete(post);
+ }
+}
diff --git a/src/main/java/com/demo/service/StudentService.java b/src/main/java/com/demo/service/StudentService.java
index 3eea3c9..47a979f 100644
--- a/src/main/java/com/demo/service/StudentService.java
+++ b/src/main/java/com/demo/service/StudentService.java
@@ -1,44 +1,81 @@
package com.demo.service;
+import com.demo.domain.Authority;
+import com.demo.domain.Role;
+import com.demo.exception.dto.ErrorMessage;
+import com.demo.exception.student.NotFoundStudentException;
+import com.demo.repository.AuthorityRepository;
import com.demo.repository.StudentRepository;
import com.demo.domain.Student;
import com.demo.dto.request.StudentCreateRequest;
import com.demo.dto.response.StudentResponse;
import com.demo.dto.request.StudentUpdateRequest;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+@Slf4j
@Service
@RequiredArgsConstructor
public class StudentService {
private final StudentRepository studentRepository;
+ private final AuthorityRepository authorityRepository;
@Transactional
public StudentResponse create(StudentCreateRequest studentCreateRequest) {
- Student entity = Student.toEntity(studentCreateRequest); //id 값이 없는 상태
+ Student entity = Student.toEntity(studentCreateRequest);
+ log.info("Student information: {}", entity);
- studentRepository.save(entity); //저장하면서 id값을 데이터베이스에서 생성해준다.
+ assignRole(entity, Role.ROLE_USER);
+
+ studentRepository.save(entity);
return entity.toResponse();
}
+ public void assignRole(Student entity, Role role) {
+ Authority authority = authorityRepository.findByRole(role)
+ .orElseThrow(() -> new RuntimeException(role.name() + " 권한이 존재하지 않습니다."));
+ entity.addAuthority(authority);
+ }
+
@Transactional(readOnly = true)
- public StudentResponse findById(Long id) {
- return studentRepository.findById(id).orElse(null).toResponse();
+ public StudentResponse findById(String id) {
+ return studentRepository.findByLoginId(id)
+ .orElseThrow(() -> new NotFoundStudentException(
+ ErrorMessage.NOT_FOUND_STUDENT,
+ "요청한 사용자를 찾을 수 없습니다.")
+ )
+ .toResponse();
}
@Transactional
- public StudentResponse update(Long id, StudentUpdateRequest studentUpdateRequest) {
- Student student = studentRepository.findById(id).get();
+ public StudentResponse update(String id, StudentUpdateRequest studentUpdateRequest) {
+ Student student = studentRepository.findByLoginId(id)
+ .orElseThrow(() -> new NotFoundStudentException(
+ ErrorMessage.NOT_FOUND_STUDENT,
+ "요청한 사용자를 찾을 수 없습니다.")
+ );
+
student.update(studentUpdateRequest); //객체가 수정될 것
return student.toResponse();
}
@Transactional
- public void delete(Long id) {
- if(studentRepository.existsById(id)) {
- studentRepository.deleteById(id);
+ public void delete(String id) {
+ if (studentRepository.existsByLoginId(id)) {
+ studentRepository.deleteByLoginId(id);
+ } else {
+ throw new NotFoundStudentException(
+ ErrorMessage.NOT_FOUND_STUDENT,
+ "요청한 사용자를 찾을 수 없습니다."
+ );
}
}
+
+ @Transactional(readOnly = true)
+ public boolean ixExistLoginId(String loginId){
+ return studentRepository.existsByLoginId(loginId);
+ }
}
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 3982117..845a328 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -1,26 +1,42 @@
spring:
-
- h2:
- console:
- enabled: true
-
+ # 데이터베이스 연결 설정
datasource:
- url: jdbc:mysql://127.0.0.1:3306/{사용할 데이터베이스}
- driver-class-name: com.mysql.cj.jdbc.Driver
- username: {접속자명}
- password: {비밀번호}
- #url: jdbc:h2:tcp://localhost/~/querydsl
- #driver-class-name: org.h2.Driver
- #username: sa
- #password:
-
+ driver-class-name: com.mysql.cj.jdbc.Driver # 사용하는 데이터베이스 드라이버
+ url: jdbc:mysql://localhost:3306/SE_webserver_database # 데이터베이스 URL
+ username: root # 데이터베이스 유저 이름
+ password: mmsdud00!! # 데이터베이스 유저의 비밀번호
+ # 스프링 JPA 설정
jpa:
- database-platform: org.hibernate.dialect.MySQLDialect
- #database-platform: org.hibernate.dialect.H2Dialect
hibernate:
- ddl-auto: create-drop # 설정 변경 가능
+ ddl-auto: create-drop # DB 테이블 생성 전략 설정
properties:
hibernate:
- format_sql: true
- show_sql: true
- defer-datasource-initialization: true
\ No newline at end of file
+ dialect: org.hibernate.dialect.MySQL8Dialect # MySQL 8.x용 Dialect
+ format_sql: true # 쿼리 로그를 정렬해서 출력한다
+ show_sql: true # 실행되는 SQL 쿼리를 콘솔에 출력한다. 프로덕션 환경에서는 false로 변경해야 함.
+ defer-datasource-initialization: true # SQL 스크립트 초기화를 JPA 앤티티 매니저 초기화 이후로 지연시킨다. 이는 data.sql이 있어서 필요하다.
+ sql:
+ init:
+ mode: always # SQL 초기화 스크립트를 언제 실행할지 결정한다.
+
+
+springdoc:
+ swagger-ui:
+ path: /swagger-ui.html # 스웨거 접근 경로
+ api-docs:
+ path: /api-docs # openAPI 접근 경로. default 값은 /v3/api-docs 이다.
+ show-actuator: true # Spring Actuator의 endpoint까지 보여줄 것인지?
+ default-consumes-media-type: application/json # request media type 의 기본 값
+ default-produces-media-type: application/json # response media type 의 기본 값
+ paths-to-match: # 해당 패턴에 매칭되는 controller만 swagger-ui에 노출한다.
+ - /api/v1/**
+
+# JWT 토큰 설정
+jwt:
+ #HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
+ #echo 'silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret'|base64
+ secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
+ access-token-header: Authorization # 액세스 토큰 헤더
+ access-token-validity-in-hour: 3 # 액세스 토큰 유효기간 (시간)
+ authority-key: auth # 액세스 토큰에서 권한을 가져올 키
+ bearer-header: "Bearer "
\ No newline at end of file
diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql
new file mode 100644
index 0000000..e580547
--- /dev/null
+++ b/src/main/resources/data.sql
@@ -0,0 +1,2 @@
+insert into authority (authority_name) values ('ROLE_USER');
+insert into authority (authority_name) values ('ROLE_ADMIN');
\ No newline at end of file
diff --git a/src/main/resources/static/css/common.css b/src/main/resources/static/css/common.css
new file mode 100644
index 0000000..946e296
--- /dev/null
+++ b/src/main/resources/static/css/common.css
@@ -0,0 +1,10 @@
+body {
+ font-family: 'Arial', sans-serif;
+ background-color: #f0f2f5;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+ margin: 0;
+ padding: 20px;
+}
\ No newline at end of file
diff --git a/src/main/resources/static/css/login-1.0.css b/src/main/resources/static/css/login-1.0.css
new file mode 100644
index 0000000..720cf93
--- /dev/null
+++ b/src/main/resources/static/css/login-1.0.css
@@ -0,0 +1,106 @@
+/* 로그인 페이지 디자인 (모바일 우선 + 데스크톱 대응) */
+
+/* 공통 스타일 */
+body {
+ font-family: 'Arial', sans-serif;
+ background-color: #f0f2f5;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+ margin: 0;
+ padding: 20px;
+}
+
+.login-container {
+ background-color: white;
+ padding: 30px 20px;
+ border-radius: 10px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ width: 100%;
+ max-width: 300px;
+ box-sizing: border-box;
+}
+
+.login-container h2 {
+ margin-bottom: 20px;
+ text-align: center;
+ color: #333;
+}
+
+label {
+ display: block;
+ margin-top: 10px;
+ margin-bottom: 5px;
+ color: #555;
+}
+
+input {
+ width: 100%;
+ padding: 8px 10px;
+ margin-bottom: 10px;
+ border: 1px solid #ccc;
+ border-radius: 5px;
+ box-sizing: border-box;
+}
+
+/* input focus 스타일 */
+input:focus {
+ outline: none;
+ border-color: #007bff;
+ box-shadow: 0 0 4px rgba(0, 123, 255, 0.5);
+}
+
+/* 공통 버튼 스타일 */
+button {
+ width: 100%;
+ border-radius: 5px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ font-size: 16px;
+}
+
+/* 로그인 버튼 */
+#login-btn {
+ padding: 10px;
+ background-color: #007bff;
+ color: white;
+ border: none;
+}
+
+#login-btn:hover {
+ background-color: #0056b3;
+}
+
+/* 회원가입 버튼 스타일 */
+.signup-button {
+ margin-top: 10px;
+ padding: 8px;
+ background-color: transparent;
+ color: #333;
+ border: 1px solid #aaa;
+ font-size: 14px;
+}
+
+.signup-button:hover {
+ background-color: #f1f1f1;
+}
+
+/* 오류 메시지 */
+#message {
+ margin-top: 15px;
+ color: red;
+ text-align: center;
+}
+
+/* 데스크톱 대응 */
+@media (min-width: 768px) {
+ .login-container {
+ max-width: 500px;
+ padding: 40px 30px;
+ }
+
+ body {
+ font-size: 16px;
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/static/css/login-purple-1.0.css b/src/main/resources/static/css/login-purple-1.0.css
new file mode 100644
index 0000000..720cf93
--- /dev/null
+++ b/src/main/resources/static/css/login-purple-1.0.css
@@ -0,0 +1,106 @@
+/* 로그인 페이지 디자인 (모바일 우선 + 데스크톱 대응) */
+
+/* 공통 스타일 */
+body {
+ font-family: 'Arial', sans-serif;
+ background-color: #f0f2f5;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+ margin: 0;
+ padding: 20px;
+}
+
+.login-container {
+ background-color: white;
+ padding: 30px 20px;
+ border-radius: 10px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ width: 100%;
+ max-width: 300px;
+ box-sizing: border-box;
+}
+
+.login-container h2 {
+ margin-bottom: 20px;
+ text-align: center;
+ color: #333;
+}
+
+label {
+ display: block;
+ margin-top: 10px;
+ margin-bottom: 5px;
+ color: #555;
+}
+
+input {
+ width: 100%;
+ padding: 8px 10px;
+ margin-bottom: 10px;
+ border: 1px solid #ccc;
+ border-radius: 5px;
+ box-sizing: border-box;
+}
+
+/* input focus 스타일 */
+input:focus {
+ outline: none;
+ border-color: #007bff;
+ box-shadow: 0 0 4px rgba(0, 123, 255, 0.5);
+}
+
+/* 공통 버튼 스타일 */
+button {
+ width: 100%;
+ border-radius: 5px;
+ cursor: pointer;
+ transition: background-color 0.2s;
+ font-size: 16px;
+}
+
+/* 로그인 버튼 */
+#login-btn {
+ padding: 10px;
+ background-color: #007bff;
+ color: white;
+ border: none;
+}
+
+#login-btn:hover {
+ background-color: #0056b3;
+}
+
+/* 회원가입 버튼 스타일 */
+.signup-button {
+ margin-top: 10px;
+ padding: 8px;
+ background-color: transparent;
+ color: #333;
+ border: 1px solid #aaa;
+ font-size: 14px;
+}
+
+.signup-button:hover {
+ background-color: #f1f1f1;
+}
+
+/* 오류 메시지 */
+#message {
+ margin-top: 15px;
+ color: red;
+ text-align: center;
+}
+
+/* 데스크톱 대응 */
+@media (min-width: 768px) {
+ .login-container {
+ max-width: 500px;
+ padding: 40px 30px;
+ }
+
+ body {
+ font-size: 16px;
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/static/css/mainpage-1.0.css b/src/main/resources/static/css/mainpage-1.0.css
new file mode 100644
index 0000000..3e87836
--- /dev/null
+++ b/src/main/resources/static/css/mainpage-1.0.css
@@ -0,0 +1,142 @@
+/* 공통 */
+html, body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+}
+
+.page-wrapper {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ background-color: #f3e9ff;
+ color: #333;
+ font-family: Arial, sans-serif;
+}
+
+/* 상단 헤더 */
+.top-section {
+ background-color: #6a0dad;
+ padding: 20px 10px;
+ text-align: center;
+ position: relative;
+}
+
+.logo-center {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 10px;
+}
+
+.logo-img {
+ height: 40px;
+ width: auto;
+}
+
+.logo-text {
+ color: white;
+ font-size: 24px;
+ font-weight: bold;
+}
+
+/* 네비게이션 메뉴 (데스크탑) */
+.main-nav {
+ display: flex;
+ justify-content: center;
+ gap: 20px;
+ flex-wrap: wrap;
+}
+
+.main-nav a {
+ color: white;
+ text-decoration: none;
+ font-weight: 500;
+ font-size: 16px;
+ transition: opacity 0.2s ease;
+}
+
+.main-nav a:hover {
+ opacity: 0.7;
+}
+
+/* 햄버거 버튼 */
+.menu-toggle {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ font-size: 28px;
+ color: white;
+ cursor: pointer;
+ display: none;
+}
+
+/* 메인 콘텐츠 */
+.main-content {
+ padding: 40px 20px;
+ flex-grow: 1;
+}
+
+/* 푸터 */
+.main-footer {
+ background-color: #4b0082;
+ color: white;
+ text-align: center;
+ padding: 15px;
+ margin-top: auto;
+}
+
+/* === 반응형: 모바일 햄버거 메뉴 === */
+@media (max-width: 768px) {
+ .menu-toggle {
+ display: block;
+ font-size: 28px;
+ color: white;
+ position: absolute;
+ right: 20px;
+ top: 26px;
+ cursor: pointer;
+ z-index: 1101;
+ }
+
+ .main-nav {
+ position: fixed;
+ top: 0;
+ right: -100%;
+ width: 250px;
+ height: 100%;
+ background-color: #6a0dad;
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 80px 20px;
+ transition: right 0.3s ease;
+ z-index: 1100;
+ }
+
+ .main-nav a {
+ color: white;
+ margin-bottom: 20px;
+ font-size: 18px;
+ }
+
+ .main-nav.active {
+ right: 0;
+ }
+
+ .overlay {
+ position: fixed;
+ top: 0; left: 0;
+ width: 100%; height: 100%;
+ background-color: rgba(0, 0, 0, 0.4);
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.3s ease;
+ z-index: 1000;
+ }
+
+ .overlay.active {
+ opacity: 1;
+ visibility: visible;
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/static/css/post-create.css b/src/main/resources/static/css/post-create.css
new file mode 100644
index 0000000..91b3684
--- /dev/null
+++ b/src/main/resources/static/css/post-create.css
@@ -0,0 +1,176 @@
+/* ==========================================================================
+ post-create.css — 글 작성 화면 전용 스타일
+ 목표: 컴팩트하지만, 큰 화면(데스크탑/와이드)에서는 폼과 텍스트영역이 넉넉하게 커짐
+ ========================================================================== */
+
+:root {
+ /* 팔레트 */
+ --brand: #6a0dad;
+ --brand-600: #5a0ab9;
+ --brand-100: rgba(106, 13, 173, 0.08);
+ --text: #222;
+ --muted: #6b6b6b;
+ --line: #e6e6eb;
+ --white: #fff;
+
+ /* 모양/그림자 */
+ --radius-lg: 14px;
+ --radius-md: 10px;
+ --shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.06);
+ --shadow-md: 0 6px 24px rgba(62, 31, 117, 0.08);
+}
+
+/* 페이지 레이아웃: 가운데 정렬 + 큰 화면에서도 여백 유지 */
+.main-content {
+ max-width: 1920px; /* ★ 변경: 상한 올려서 초와이드 지원 */
+ margin: 28px auto 48px;
+ padding: 0 24px; /* 좌우 안전 여백 */
+}
+
+/* 타이틀 */
+.page-title {
+ margin: 4px 0 16px;
+ font-size: clamp(20px, 1.6vw, 26px);
+ font-weight: 700;
+ color: var(--brand);
+ letter-spacing: -0.2px;
+ position: relative;
+}
+.page-title::after {
+ content: "";
+ display: block;
+ width: 48px;
+ height: 3px;
+ background: var(--brand);
+ opacity: .22;
+ border-radius: 999px;
+ margin-top: 8px;
+}
+
+/* 카드형 폼
+ - width: 100% 안에서, 뷰포트에 따라 유연하게 확장
+ - clamp(min, preferred, max)로 넓게 성장 (기존 대비 2배 가까이 확대)
+*/
+.post-form {
+ /* ★ 변경: 훨씬 넓게 — 최소 960px, 기본 92vw, 최대 1600px */
+ width: min(100%, clamp(960px, 92vw, 1600px));
+ background: var(--white);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ padding: 20px 20px 16px;
+ margin: 0 auto;
+ box-shadow: var(--shadow-sm);
+ transition: box-shadow .2s ease, border-color .2s ease;
+}
+.post-form:focus-within {
+ border-color: var(--brand);
+ box-shadow: var(--shadow-md);
+}
+
+/* 라벨 */
+.post-form label {
+ display: block;
+ margin: 10px 0 12px;
+ font-weight: 600;
+ color: var(--text);
+}
+
+/* 입력/텍스트영역
+ - 폰트와 패딩도 살짝 유동적으로(clamp) 성장
+*/
+.post-form input,
+.post-form textarea {
+ width: 100%;
+ box-sizing: border-box;
+ margin-top: 6px;
+ padding: clamp(10px, 0.8vw, 14px) clamp(12px, 1vw, 16px);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ font-size: clamp(15px, 1vw + 12px, 18px);
+ color: var(--text);
+ background: #fafafa;
+ outline: none;
+ transition: border-color .15s ease, box-shadow .15s ease, background .15s ease;
+}
+.post-form input:focus,
+.post-form textarea:focus {
+ border-color: var(--brand);
+ background: #fff;
+ box-shadow: 0 0 0 4px var(--brand-100);
+}
+
+/* 텍스트영역 높이
+ - 작은 화면: 200px
+ - 큰 화면: 화면 높이에 비례해 더 넓게
+*/
+.post-form textarea {
+ min-height: clamp(200px, 34vh, 520px);
+ resize: vertical; /* 세로만 조절 */
+ line-height: 1.55;
+}
+
+/* 액션 영역 */
+.actions {
+ margin-top: 16px;
+ padding-top: 12px;
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+ border-top: 1px dashed var(--line);
+}
+
+/* 버튼 공통 */
+.btn {
+ appearance: none;
+ -webkit-appearance: none;
+ padding: 10px 16px;
+ border: 1px solid var(--line);
+ background: var(--white);
+ border-radius: 10px;
+ cursor: pointer;
+ text-decoration: none;
+ color: var(--text);
+ font-weight: 600;
+ line-height: 1;
+ transition: transform .06s ease, filter .12s ease, background .12s ease, border-color .12s ease;
+}
+.btn:hover { background: #f7f7fb; }
+.btn:active { transform: translateY(1px); }
+
+.btn.primary {
+ background: var(--brand);
+ color: #fff;
+ border-color: var(--brand);
+}
+.btn.primary:hover { filter: brightness(1.06); }
+.btn.primary:active { filter: brightness(0.98); }
+
+.btn:disabled,
+.btn[disabled] {
+ opacity: .55;
+ cursor: not-allowed;
+}
+
+/* 모바일 최적화 */
+@media (max-width: 520px) {
+ .main-content { margin-top: 20px; padding: 0 16px; }
+ .post-form { padding: 16px; width: 100%; } /* 모바일은 화면폭 꽉 채움 */
+ .actions {
+ flex-direction: column-reverse;
+ align-items: stretch;
+ gap: 10px;
+ }
+ .actions .btn { width: 100%; }
+}
+
+/* 초와이드 화면(예: 1600px 이상)에서 폼을 더 키우고 여백도 늘림 */
+@media (min-width: 1600px) {
+ .main-content { max-width: 2048px; } /* ★ 변경 */
+ .post-form { width: min(100%, 1680px); } /* ★ 변경: 최대폭 더 확대 */
+ .post-form textarea { min-height: clamp(260px, 40vh, 640px); }
+}
+
+/* 사용자 환경설정: 모션 축소 */
+@media (prefers-reduced-motion: reduce) {
+ * { transition: none !important; }
+}
diff --git a/src/main/resources/static/css/post-details.css b/src/main/resources/static/css/post-details.css
new file mode 100644
index 0000000..a644dba
--- /dev/null
+++ b/src/main/resources/static/css/post-details.css
@@ -0,0 +1,55 @@
+.hidden { display: none !important; }
+:root {
+ --purple: #6a0dad;
+ --purple-dark: #4b0082;
+ --line: #e5e5e5;
+ --text: #333;
+}
+
+.detail-head {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-bottom: 14px;
+}
+.detail-head .spacer { flex: 1; }
+
+.btn {
+ padding: 8px 12px;
+ border: 1px solid var(--line);
+ background: #fff;
+ border-radius: 8px;
+ cursor: pointer;
+ text-decoration: none;
+ color: var(--text);
+}
+.btn:hover { background: #f7f7f7; }
+.btn.ghost { background: transparent; color: var(--purple); border-color: var(--purple); }
+.btn.ghost:hover { background: #efe2ff; }
+.btn.danger { background: #fff5f5; border-color: #ffcdcd; color: #a10000; }
+.btn.danger:hover { background: #ffe9e9; }
+
+.post-card {
+ background: #fff;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 18px;
+ box-shadow: 0 2px 10px rgba(0,0,0,.04);
+}
+.post-title {
+ margin: 0 0 8px;
+ font-size: 22px;
+ color: var(--purple-dark);
+}
+.meta {
+ color: #666;
+ font-size: 13px;
+ margin-bottom: 16px;
+}
+.meta .dot { margin: 0 6px; }
+.post-content {
+ white-space: pre-wrap;
+ line-height: 1.6;
+ font-size: 15px;
+ color: #222;
+}
diff --git a/src/main/resources/static/css/post-list.css b/src/main/resources/static/css/post-list.css
new file mode 100644
index 0000000..47e8fc1
--- /dev/null
+++ b/src/main/resources/static/css/post-list.css
@@ -0,0 +1,128 @@
+.hidden { display:none !important; }
+/* 메인 컬러 계열 맞춤 */
+:root {
+ --purple: #6a0dad;
+ --purple-dark: #4b0082;
+ --bg: #f3e9ff;
+ --line: #e5e5e5;
+ --text: #333;
+}
+
+/* 메인 레이아웃(mainpage-1.0.css)과 조화되도록 최소 보강 */
+.page-title {
+ margin: 0 0 18px;
+ font-size: 22px;
+ color: var(--purple-dark);
+}
+
+.post-toolbar {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+ margin-bottom: 10px;
+}
+.post-search {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+.post-toolbar .spacer { flex: 1; }
+
+.post-search input[type="text"],
+.post-search input {
+ padding: 8px 10px;
+ min-width: 240px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ outline: none;
+}
+
+/* 버튼 스타일을 메인 톤에 맞춤 */
+.btn {
+ padding: 8px 12px;
+ border: 1px solid var(--line);
+ background: #fff;
+ color: var(--text);
+ border-radius: 8px;
+ cursor: pointer;
+}
+.btn:hover { background: #f7f7f7; }
+.btn.primary {
+ background: var(--purple);
+ color: #fff;
+ border-color: var(--purple);
+}
+.btn.primary:hover { background: #7b16c6; }
+.btn.ghost {
+ background: transparent;
+ border-color: var(--purple);
+ color: var(--purple);
+}
+.btn.ghost:hover { background: #efe2ff; }
+
+/* 테이블 */
+.table-wrap { width: 100%; overflow-x: auto; }
+.post-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 10px;
+ background: #fff;
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 2px 10px rgba(0,0,0,.04);
+}
+.post-table th, .post-table td {
+ border: 1px solid var(--line);
+ padding: 12px 10px;
+ font-size: 14px;
+ vertical-align: middle;
+}
+.post-table thead th {
+ background: #faf5ff; /* 보라톤 아주 옅게 */
+ text-align: left;
+ color: var(--purple-dark);
+ font-weight: 700;
+}
+.post-table a.row-link { color: inherit; text-decoration: none; }
+.post-table a.row-link:hover { text-decoration: underline; }
+
+.col-id { width: 80px; }
+.col-writer { width: 160px; }
+.col-date { width: 200px; }
+.col-actions { width: 160px; }
+
+.action {
+ padding: 6px 10px;
+ margin-right: 6px;
+ border: 1px solid var(--line);
+ background: #fff;
+ border-radius: 8px;
+ cursor: pointer;
+}
+.action:hover { background: #f7f7f7; }
+
+.empty {
+ padding: 24px;
+ text-align: center;
+ color: #777;
+ border: 1px dashed var(--line);
+ border-radius: 8px;
+ margin-top: 14px;
+ background: #fff;
+}
+
+/* 페이지네이션 */
+.paginator {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ justify-content: center;
+ margin: 16px 0;
+}
+.muted { color: #666; font-size: 12px; }
+
+/* 모바일: 작성자 컬럼 숨김 */
+@media (max-width: 768px) {
+ .col-writer { display: none; }
+}
diff --git a/src/main/resources/static/css/post-update.css b/src/main/resources/static/css/post-update.css
new file mode 100644
index 0000000..91b3684
--- /dev/null
+++ b/src/main/resources/static/css/post-update.css
@@ -0,0 +1,176 @@
+/* ==========================================================================
+ post-create.css — 글 작성 화면 전용 스타일
+ 목표: 컴팩트하지만, 큰 화면(데스크탑/와이드)에서는 폼과 텍스트영역이 넉넉하게 커짐
+ ========================================================================== */
+
+:root {
+ /* 팔레트 */
+ --brand: #6a0dad;
+ --brand-600: #5a0ab9;
+ --brand-100: rgba(106, 13, 173, 0.08);
+ --text: #222;
+ --muted: #6b6b6b;
+ --line: #e6e6eb;
+ --white: #fff;
+
+ /* 모양/그림자 */
+ --radius-lg: 14px;
+ --radius-md: 10px;
+ --shadow-sm: 0 1px 4px rgba(0, 0, 0, 0.06);
+ --shadow-md: 0 6px 24px rgba(62, 31, 117, 0.08);
+}
+
+/* 페이지 레이아웃: 가운데 정렬 + 큰 화면에서도 여백 유지 */
+.main-content {
+ max-width: 1920px; /* ★ 변경: 상한 올려서 초와이드 지원 */
+ margin: 28px auto 48px;
+ padding: 0 24px; /* 좌우 안전 여백 */
+}
+
+/* 타이틀 */
+.page-title {
+ margin: 4px 0 16px;
+ font-size: clamp(20px, 1.6vw, 26px);
+ font-weight: 700;
+ color: var(--brand);
+ letter-spacing: -0.2px;
+ position: relative;
+}
+.page-title::after {
+ content: "";
+ display: block;
+ width: 48px;
+ height: 3px;
+ background: var(--brand);
+ opacity: .22;
+ border-radius: 999px;
+ margin-top: 8px;
+}
+
+/* 카드형 폼
+ - width: 100% 안에서, 뷰포트에 따라 유연하게 확장
+ - clamp(min, preferred, max)로 넓게 성장 (기존 대비 2배 가까이 확대)
+*/
+.post-form {
+ /* ★ 변경: 훨씬 넓게 — 최소 960px, 기본 92vw, 최대 1600px */
+ width: min(100%, clamp(960px, 92vw, 1600px));
+ background: var(--white);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-lg);
+ padding: 20px 20px 16px;
+ margin: 0 auto;
+ box-shadow: var(--shadow-sm);
+ transition: box-shadow .2s ease, border-color .2s ease;
+}
+.post-form:focus-within {
+ border-color: var(--brand);
+ box-shadow: var(--shadow-md);
+}
+
+/* 라벨 */
+.post-form label {
+ display: block;
+ margin: 10px 0 12px;
+ font-weight: 600;
+ color: var(--text);
+}
+
+/* 입력/텍스트영역
+ - 폰트와 패딩도 살짝 유동적으로(clamp) 성장
+*/
+.post-form input,
+.post-form textarea {
+ width: 100%;
+ box-sizing: border-box;
+ margin-top: 6px;
+ padding: clamp(10px, 0.8vw, 14px) clamp(12px, 1vw, 16px);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-md);
+ font-size: clamp(15px, 1vw + 12px, 18px);
+ color: var(--text);
+ background: #fafafa;
+ outline: none;
+ transition: border-color .15s ease, box-shadow .15s ease, background .15s ease;
+}
+.post-form input:focus,
+.post-form textarea:focus {
+ border-color: var(--brand);
+ background: #fff;
+ box-shadow: 0 0 0 4px var(--brand-100);
+}
+
+/* 텍스트영역 높이
+ - 작은 화면: 200px
+ - 큰 화면: 화면 높이에 비례해 더 넓게
+*/
+.post-form textarea {
+ min-height: clamp(200px, 34vh, 520px);
+ resize: vertical; /* 세로만 조절 */
+ line-height: 1.55;
+}
+
+/* 액션 영역 */
+.actions {
+ margin-top: 16px;
+ padding-top: 12px;
+ display: flex;
+ gap: 10px;
+ justify-content: flex-end;
+ border-top: 1px dashed var(--line);
+}
+
+/* 버튼 공통 */
+.btn {
+ appearance: none;
+ -webkit-appearance: none;
+ padding: 10px 16px;
+ border: 1px solid var(--line);
+ background: var(--white);
+ border-radius: 10px;
+ cursor: pointer;
+ text-decoration: none;
+ color: var(--text);
+ font-weight: 600;
+ line-height: 1;
+ transition: transform .06s ease, filter .12s ease, background .12s ease, border-color .12s ease;
+}
+.btn:hover { background: #f7f7fb; }
+.btn:active { transform: translateY(1px); }
+
+.btn.primary {
+ background: var(--brand);
+ color: #fff;
+ border-color: var(--brand);
+}
+.btn.primary:hover { filter: brightness(1.06); }
+.btn.primary:active { filter: brightness(0.98); }
+
+.btn:disabled,
+.btn[disabled] {
+ opacity: .55;
+ cursor: not-allowed;
+}
+
+/* 모바일 최적화 */
+@media (max-width: 520px) {
+ .main-content { margin-top: 20px; padding: 0 16px; }
+ .post-form { padding: 16px; width: 100%; } /* 모바일은 화면폭 꽉 채움 */
+ .actions {
+ flex-direction: column-reverse;
+ align-items: stretch;
+ gap: 10px;
+ }
+ .actions .btn { width: 100%; }
+}
+
+/* 초와이드 화면(예: 1600px 이상)에서 폼을 더 키우고 여백도 늘림 */
+@media (min-width: 1600px) {
+ .main-content { max-width: 2048px; } /* ★ 변경 */
+ .post-form { width: min(100%, 1680px); } /* ★ 변경: 최대폭 더 확대 */
+ .post-form textarea { min-height: clamp(260px, 40vh, 640px); }
+}
+
+/* 사용자 환경설정: 모션 축소 */
+@media (prefers-reduced-motion: reduce) {
+ * { transition: none !important; }
+}
diff --git a/src/main/resources/static/css/signup-1.0.css b/src/main/resources/static/css/signup-1.0.css
new file mode 100644
index 0000000..a561088
--- /dev/null
+++ b/src/main/resources/static/css/signup-1.0.css
@@ -0,0 +1,55 @@
+/* 기본 레이아웃 */
+.login-container{
+ background:#fff; padding:30px; border-radius:8px;
+ box-shadow:0 4px 8px rgba(0,0,0,.1); width:320px;
+}
+h2{ text-align:center; color:#333; margin-bottom:20px; }
+
+label{ display:block; margin-bottom:6px; color:#555; font-weight:bold; }
+
+input[type="text"],input[type="password"],input[type="email"],input[type="tel"]{
+ width:100%; padding:10px; margin-bottom:15px;
+ border:1px solid #ccc; border-radius:4px; box-sizing:border-box;
+}
+
+/* 제출 버튼 */
+button[type="submit"]{
+ width:100%; padding:10px; background:#1034d3; color:#fff;
+ border:0; border-radius:4px; font-weight:bold; cursor:pointer; transition:.2s;
+}
+button[type="submit"]:hover{ background:#0d2ab0; }
+
+/* 결과/에러 문구 */
+.signup-msg{ margin-top:10px; color:red; text-align:center; font-size:.9em; }
+
+/* 한 줄 정렬 공통 */
+:root{ --control-h:40px; }
+
+.field-row{
+ display:flex; align-items:stretch; gap:8px; margin-bottom:15px;
+}
+.field-row input{
+ flex:1 1 auto; min-width:0; height:var(--control-h);
+}
+
+/* 중복 확인 버튼 */
+.btn-idcheck{
+ height:var(--control-h); padding:0 12px; white-space:nowrap;
+ border:1px solid #1034d3; background:#f5f8ff; color:#1034d3;
+ border-radius:4px; font-weight:600; cursor:pointer;
+}
+.btn-idcheck:hover,.btn-idcheck:focus{ background:#e9f0ff; outline:none; }
+
+/* 비밀번호 아이콘 버튼 */
+.icon-btn{
+ width:var(--control-h); height:var(--control-h);
+ border:1px solid #ccc; background:#fff; border-radius:4px;
+ display:grid; place-items:center; cursor:pointer;
+}
+.icon-btn:hover,.icon-btn:focus{ border-color:#1034d3; outline:none; }
+
+/* 아이콘 크기 */
+.mi{ font-size:20px; line-height:1; }
+
+/* 중복 확인 결과 위치/색상 */
+.assistive-msg{ display:block; color:#0077cc; margin-top:-8px; margin-bottom:10px; }
diff --git a/src/main/resources/static/css/write-page-1.0.css b/src/main/resources/static/css/write-page-1.0.css
new file mode 100644
index 0000000..5566195
--- /dev/null
+++ b/src/main/resources/static/css/write-page-1.0.css
@@ -0,0 +1,197 @@
+/* === 기본 === */
+body, html {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+}
+
+.page-wrapper {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ background-color: #f3e9ff;
+ font-family: Arial, sans-serif;
+}
+
+/* === 헤더 영역 === */
+.top-section {
+ background-color: #6a0dad;
+ padding: 20px 10px;
+ text-align: center;
+ position: relative;
+}
+
+.logo-center {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 10px;
+}
+
+.logo-img {
+ height: 40px;
+}
+
+.logo-text {
+ color: white;
+ font-size: 24px;
+ font-weight: bold;
+}
+
+.main-nav {
+ display: flex;
+ justify-content: center;
+ gap: 20px;
+}
+
+.main-nav a {
+ color: white;
+ text-decoration: none;
+ font-weight: 500;
+ font-size: 16px;
+ transition: opacity 0.2s ease;
+}
+
+.main-nav a:hover {
+ opacity: 0.7;
+}
+
+/* === 햄버거 버튼 === */
+.menu-toggle {
+ display: none;
+}
+
+/* === 본문 === */
+.main-content {
+ flex-grow: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 40px 20px;
+}
+
+.write-form-container {
+ background-color: white;
+ padding: 30px;
+ border-radius: 12px;
+ box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
+ width: 100%;
+ max-width: 600px;
+}
+
+#write-form {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+#write-form input[type="text"] {
+ padding: 10px;
+ border: 1px solid #aaa;
+ border-radius: 6px;
+ font-size: 16px;
+}
+
+.editor-toolbar {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.editor-toolbar button,
+.editor-toolbar select,
+.editor-toolbar input[type="color"] {
+ padding: 6px 8px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ background: #fff;
+ cursor: pointer;
+ font-size: 14px;
+}
+
+.editor-area {
+ min-height: 200px;
+ border: 1px solid #aaa;
+ border-radius: 6px;
+ padding: 10px;
+ font-size: 16px;
+ background-color: #fff;
+ overflow-y: auto;
+}
+
+#write-form button[type="submit"] {
+ align-self: flex-end;
+ background-color: #6a0dad;
+ color: white;
+ border: none;
+ padding: 10px 16px;
+ border-radius: 6px;
+ font-size: 16px;
+ cursor: pointer;
+}
+
+#write-form button[type="submit"]:hover {
+ background-color: #5a029d;
+}
+
+/* === 푸터 === */
+.main-footer {
+ background-color: #4b0082;
+ color: white;
+ text-align: center;
+ padding: 15px;
+}
+
+/* === 반응형: 모바일 햄버거 메뉴 === */
+@media (max-width: 768px) {
+ .menu-toggle {
+ display: block;
+ font-size: 28px;
+ color: white;
+ position: absolute;
+ right: 20px;
+ top: 26px;
+ cursor: pointer;
+ z-index: 1101;
+ }
+
+ .main-nav {
+ position: fixed;
+ top: 0;
+ right: -100%;
+ width: 250px;
+ height: 100%;
+ background-color: #6a0dad;
+ flex-direction: column;
+ align-items: flex-start;
+ padding: 80px 20px;
+ transition: right 0.3s ease;
+ z-index: 1100;
+ }
+
+ .main-nav a {
+ color: white;
+ margin-bottom: 20px;
+ font-size: 18px;
+ }
+
+ .main-nav.active {
+ right: 0;
+ }
+
+ .overlay {
+ position: fixed;
+ top: 0; left: 0;
+ width: 100%; height: 100%;
+ background-color: rgba(0, 0, 0, 0.4);
+ opacity: 0;
+ visibility: hidden;
+ transition: opacity 0.3s ease;
+ z-index: 1000;
+ }
+
+ .overlay.active {
+ opacity: 1;
+ visibility: visible;
+ }
+}
diff --git a/src/main/resources/static/images/main-logo.svg b/src/main/resources/static/images/main-logo.svg
new file mode 100644
index 0000000..73ccc95
--- /dev/null
+++ b/src/main/resources/static/images/main-logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/main/resources/static/images/main_logo.svg b/src/main/resources/static/images/main_logo.svg
new file mode 100644
index 0000000..73ccc95
--- /dev/null
+++ b/src/main/resources/static/images/main_logo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/main/resources/static/js/apiClient.js b/src/main/resources/static/js/apiClient.js
new file mode 100644
index 0000000..9911544
--- /dev/null
+++ b/src/main/resources/static/js/apiClient.js
@@ -0,0 +1,95 @@
+// === 공통 API 클라이언트 ===
+// - JWT 자동 첨부
+// - 401 응답 시 토큰 폐기 + 로그인으로 이동(redirect 유지)
+// - 토큰 유효성(만료) 검사 유틸 제공
+
+const API_BASE = '/api/v1';
+const TOKEN_HEADER_NAME = 'Authorization';
+const TOKEN_PREFIX = 'Bearer ';
+const TOKEN_STORAGE_KEY = 'Authorization';
+
+// ----- 과거 키 자동 마이그레이션(accessToken → Authorization) -----
+// 혹시모를 accessToken 남아있는거 방지
+(() => {
+ const old = localStorage.getItem('accessToken');
+ const cur = localStorage.getItem(TOKEN_STORAGE_KEY);
+ if (old && !cur) {
+ const val = old.startsWith(TOKEN_PREFIX) ? old : (TOKEN_PREFIX + old);
+ localStorage.setItem(TOKEN_STORAGE_KEY, val);
+ localStorage.removeItem('accessToken');
+ }
+})();
+
+// ----- 토큰 저장소 -----
+function getToken() { return localStorage.getItem(TOKEN_STORAGE_KEY); }
+function setToken(t) { if (t) localStorage.setItem(TOKEN_STORAGE_KEY, t); }
+function clearToken() { localStorage.removeItem(TOKEN_STORAGE_KEY); }
+
+// 내부 유틸: "Bearer " 제거
+function stripBearer(t) {
+ if (!t) return t;
+ return t.startsWith(TOKEN_PREFIX) ? t.slice(TOKEN_PREFIX.length) : t;
+}
+
+// ----- JWT 유틸 -----
+function parseJwt(tokenLike) {
+ const token = stripBearer(tokenLike);
+ if (!token) return null;
+ const p = token.split('.');
+ if (p.length < 2) return null;
+ try {
+ const b64 = p[1].replace(/-/g, '+').replace(/_/g, '/');
+ const json = decodeURIComponent(escape(atob(b64)));
+ return JSON.parse(json);
+ } catch {
+ try { return JSON.parse(atob(p[1])); } catch { return null; }
+ }
+}
+
+function isTokenValid(tokenLike) {
+ const payload = parseJwt(tokenLike);
+ if (!payload) return false;
+ if (typeof payload.exp !== 'number') return false;
+ return Date.now() < payload.exp * 1000;
+}
+
+function isLoggedIn() { return isTokenValid(getToken()); }
+
+function getLoginIdFromToken(tokenLike) {
+ const p = parseJwt(tokenLike);
+ if (!p) return null;
+ return p.sub || p.loginId || p.userId || p.username || null;
+}
+function getCurrentLoginId() { return getLoginIdFromToken(getToken()); }
+
+// ----- fetch 래퍼 -----
+async function apiFetch(path, options = {}) {
+ const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
+ const headers = new Headers(options.headers || {});
+ if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
+
+ const token = getToken(); // "Bearer xxx" 또는 "xxx"
+ if (token) {
+ headers.set(
+ TOKEN_HEADER_NAME,
+ token.startsWith(TOKEN_PREFIX) ? token : (TOKEN_PREFIX + token)
+ );
+ }
+
+ const resp = await fetch(url, { ...options, headers });
+
+ if (resp.status === 401) {
+ clearToken();
+ const back = encodeURIComponent(location.pathname + location.search);
+ location.href = `/sign-in?redirect=${back}`;
+ }
+ return resp;
+}
+
+window.API = {
+ API_BASE, TOKEN_HEADER_NAME, TOKEN_PREFIX,
+ getToken, setToken, clearToken,
+ parseJwt, isTokenValid, isLoggedIn,
+ getLoginIdFromToken, getCurrentLoginId,
+ apiFetch
+};
diff --git a/src/main/resources/static/js/login-1.0.js b/src/main/resources/static/js/login-1.0.js
new file mode 100644
index 0000000..a8428cd
--- /dev/null
+++ b/src/main/resources/static/js/login-1.0.js
@@ -0,0 +1,56 @@
+// login-1.0.js
+
+document.getElementById('login-form').addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const id = document.getElementById('username').value.trim();
+ const pw = document.getElementById('password').value.trim();
+ const message = document.getElementById('message');
+ const loginBtn = document.getElementById('login-btn');
+
+ message.textContent = '';
+ message.style.color = 'red';
+
+ if (!id || !pw) {
+ message.textContent = '아이디와 비밀번호를 모두 입력해주세요.';
+ return;
+ }
+
+ loginBtn.disabled = true;
+ loginBtn.textContent = '로그인 중...';
+
+ try {
+ const res = await fetch('/api/v1/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ loginId: id, password: pw })
+ });
+
+ if (res.ok) {
+ const headerToken = res.headers.get('Authorization');
+ if (headerToken) {
+ if (window.API?.setToken) window.API.setToken(headerToken);
+ else localStorage.setItem('Authorization', headerToken);
+
+ alert('로그인 성공!');
+ window.location.replace('/posts');
+ } else {
+ message.textContent = '로그인 실패: 토큰이 존재하지 않습니다.';
+ }
+ } else {
+ const errorText = await res.text();
+ message.textContent = errorText || '로그인 실패: 아이디 또는 비밀번호를 확인해주세요.';
+ }
+ } catch (err) {
+ console.error('서버 연결 오류:', err);
+ message.textContent = '서버 오류가 발생했습니다. 나중에 다시 시도해주세요.';
+ } finally {
+ loginBtn.disabled = false;
+ loginBtn.textContent = '로그인';
+ }
+});
+
+// 회원가입 버튼 클릭 시 회원가입 페이지로 이동
+document.querySelector('.signup-button')?.addEventListener('click', () => {
+ window.location.href = '/sign-up';
+});
diff --git a/src/main/resources/static/js/mainpage-1.0.js b/src/main/resources/static/js/mainpage-1.0.js
new file mode 100644
index 0000000..1995bbd
--- /dev/null
+++ b/src/main/resources/static/js/mainpage-1.0.js
@@ -0,0 +1,77 @@
+// /js/mainpage-1.0.js
+// - 네비 렌더링/토글 로직 동일
+// - 토큰은 Authorization 키만 사용
+
+document.addEventListener('DOMContentLoaded', () => {
+ const toggleBtn = document.getElementById('menuToggle');
+ const nav = document.getElementById('mainNav');
+ const overlay = document.getElementById('overlay');
+
+ function getTokenSafe() {
+ try { return window.API?.getToken?.() || null; }
+ catch { return null; }
+ }
+
+ function isLoggedIn() {
+ const token = getTokenSafe();
+ return !!token;
+ }
+
+ function buildRedirectParam() {
+ const redirect = location.pathname + location.search;
+ try { return encodeURIComponent(redirect); }
+ catch { return encodeURIComponent('/posts'); }
+ }
+
+ function renderNav() {
+ if (!nav) return;
+
+ const loggedIn = isLoggedIn();
+ const redirect = buildRedirectParam();
+
+ const items = [{ href: '/posts', label: '목록' }];
+
+ if (!loggedIn) {
+ items.push(
+ { href: `/sign-in?redirect=${redirect}`, label: '로그인', id: 'nav-login' },
+ { href: '/sign-up', label: '회원가입', id: 'nav-signup' },
+ );
+ } else {
+ items.push({ href: '#', label: '로그아웃', id: 'nav-logout' });
+ }
+
+ nav.innerHTML = items
+ .map(i => `${i.label}`)
+ .join('');
+
+ const $logout = document.getElementById('nav-logout');
+ if ($logout) {
+ $logout.addEventListener('click', (e) => {
+ e.preventDefault();
+ try { window.API?.clearToken?.(); } catch {}
+ localStorage.removeItem('Authorization'); // ← key 통일
+ location.href = '/sign-in';
+ });
+ }
+
+ document.querySelectorAll('.main-nav a').forEach(link => {
+ link.addEventListener('click', () => {
+ nav.classList.remove('active');
+ overlay?.classList.remove('active');
+ });
+ });
+ }
+
+ renderNav();
+
+ if (toggleBtn && nav && overlay) {
+ toggleBtn.addEventListener('click', () => {
+ nav.classList.toggle('active');
+ overlay.classList.toggle('active');
+ });
+ overlay.addEventListener('click', () => {
+ nav.classList.remove('active');
+ overlay.classList.remove('active');
+ });
+ }
+});
diff --git a/src/main/resources/static/js/post-create.js b/src/main/resources/static/js/post-create.js
new file mode 100644
index 0000000..b84ca8e
--- /dev/null
+++ b/src/main/resources/static/js/post-create.js
@@ -0,0 +1,73 @@
+// 글 작성 페이지 스크립트
+// - 진입 시 로그인/만료 검사 → 로그인으로 이동(redirect=/posts/new)
+// - 제출 직전에도 토큰 재검사(세션 만료 대비)
+// - 401/403/기타 에러 메시지 친화화
+
+(() => {
+ const { apiFetch: callApi, isLoggedIn } = window.API || {};
+
+ // 진입 가드
+ if (!isLoggedIn?.()) {
+ alert('로그인이 필요한 기능입니다.\n로그인 페이지로 이동합니다.');
+ location.replace(`/sign-in?redirect=${encodeURIComponent('/posts/new')}`);
+ return;
+ }
+
+ const form = document.getElementById('form');
+ const btn = document.getElementById('btn-submit');
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ // 제출 직전 재확인(세션 만료 대비)
+ if (!isLoggedIn?.()) {
+ alert('세션이 만료되었습니다.\n다시 로그인해 주세요.');
+ location.replace(`/sign-in?redirect=${encodeURIComponent('/posts/new')}`);
+ return;
+ }
+
+ const title = form.title.value.trim();
+ const content = form.content.value.trim();
+ const description = (form.description?.value ?? '').trim();
+
+ if (!title || !content) {
+ alert('제목과 내용을 모두 입력해주세요.');
+ return;
+ }
+
+ btn.disabled = true;
+ try {
+ const resp = await callApi(`/posts`, {
+ method: 'POST',
+ body: JSON.stringify({
+ title,
+ content,
+ description: description || null
+ })
+ });
+
+ if (resp.status === 401) { // apiClient가 이미 리다이렉트하지만, 이중 안전망
+ alert('로그인이 필요한 기능입니다.');
+ return;
+ }
+ if (resp.status === 403) {
+ alert('작성 권한이 없습니다.\n다른 계정으로 로그인해 주세요.');
+ return;
+ }
+ if (!resp.ok) {
+ const t = await resp.text().catch(() => '');
+ throw new Error(t || '등록 실패');
+ }
+
+ let id = null;
+ try { id = (await resp.json())?.id ?? null; } catch {}
+
+ alert('등록되었습니다.');
+ location.replace(id ? `/posts/${id}` : `/posts`);
+ } catch (err) {
+ alert(err.message || '등록 실패');
+ } finally {
+ btn.disabled = false;
+ }
+ });
+})();
diff --git a/src/main/resources/static/js/post-details.js b/src/main/resources/static/js/post-details.js
new file mode 100644
index 0000000..6651266
--- /dev/null
+++ b/src/main/resources/static/js/post-details.js
@@ -0,0 +1,182 @@
+// /js/post-details.js
+// 상세 페이지 전용 스크립트
+// - 삭제 시 상태코드별 친화적 안내
+// - "내 글만" 수정/삭제 허용(가능하면 버튼 자체도 숨김)
+// - 소유자를 확실히 알 수 없으면 UI는 보이되 서버 응답으로 차단
+
+(() => {
+ const { apiFetch: callApi, getToken } = window.API || {};
+
+ // ===== 요소 =====
+ const $title = document.getElementById('title');
+ const $writer = document.getElementById('writer');
+ const $createdAt = document.getElementById('createdAt');
+ const $content = document.getElementById('content');
+ const $btnEdit = document.getElementById('btn-edit');
+ const $btnDel = document.getElementById('btn-del');
+
+ // ===== 유틸 =====
+ function escapeHtml(v) {
+ return String(v ?? '').replace(/[&<>"']/g, c => ({
+ '&':'&', '<':'<', '>':'>', '"':'"', "'":'''
+ })[c]);
+ }
+ function parseApiDate(v) {
+ if (v == null) return null;
+ if (typeof v === 'number') return new Date(v);
+ if (typeof v !== 'string') return null;
+ if (/[zZ]|[+\-]\d{2}:\d{2}$/.test(v)) return new Date(v);
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v)) return new Date(v + 'Z');
+ return new Date(v);
+ }
+ function formatDateTime(v) {
+ const d = parseApiDate(v);
+ if (!d || Number.isNaN(d.getTime())) return v ?? '';
+ return d.toLocaleString();
+ }
+ function getId() {
+ const m = location.pathname.match(/\/posts\/(\d+)/);
+ return m ? m[1] : null;
+ }
+
+ // JWT → 현재 사용자 loginId(= sub)
+ function decodeJwtPayload(token) {
+ if (!token) return null;
+ const parts = token.split('.');
+ if (parts.length < 2) return null;
+ try {
+ const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
+ const json = decodeURIComponent(escape(atob(b64)));
+ return JSON.parse(json);
+ } catch {
+ try { return JSON.parse(atob(parts[1])); } catch { return null; }
+ }
+ }
+ function getCurrentLoginId() {
+ const token = typeof getToken === 'function' ? getToken() : null;
+ const payload = decodeJwtPayload(token);
+ if (!payload) return null;
+ return payload.sub || payload.loginId || payload.userId || payload.username || null;
+ }
+
+ // 상세 응답에서 글 소유자 loginId 후보 추출 (여러 스키마 호환)
+ function getOwnerLoginIdFromPost(data) {
+ if (data?.student?.loginId) return data.student.loginId; // 권장
+ if (data?.student?.userId) return data.student.userId; // 대체
+ return (
+ data.ownerLoginId ||
+ data.studentLoginId ||
+ data.authorLoginId ||
+ data.userLoginId ||
+ data.userId || // 평면 키
+ null
+ );
+ }
+
+ // 소유자 판별 결과
+ let ownerLoginId = null;
+ let currentLoginId = null;
+ let canEditOrDelete = true; // 기본: 모르면 UI 보이고 서버가 막게
+
+ function applyOwnerGuard(postData) {
+ currentLoginId = getCurrentLoginId();
+ ownerLoginId = getOwnerLoginIdFromPost(postData);
+
+ // 두 값이 모두 있을 때만 엄격 비교해서 숨김
+ if (currentLoginId && ownerLoginId) {
+ canEditOrDelete = (currentLoginId === ownerLoginId);
+ if (!canEditOrDelete) {
+ $btnEdit?.classList.add('hidden'); // CSS: .hidden { display:none !important; }
+ $btnDel?.classList.add('hidden');
+ }
+ } else {
+ // 소유자 정보를 확정 못하면 UI는 보이게 두고 서버 응답으로 차단
+ canEditOrDelete = true;
+ }
+ }
+
+ // ===== 데이터 로드 & 렌더 =====
+ async function load() {
+ const id = getId();
+ if (!id) return;
+
+ const resp = await callApi(`/posts/${encodeURIComponent(id)}`);
+ if (!resp.ok) {
+ const t = await resp.text().catch(()=> '');
+ alert(t || '상세 조회 실패');
+ return;
+ }
+ const data = await resp.json();
+
+ const title = data.title ?? '(제목)';
+ const created = formatDateTime(data.createdAt ?? data.updatedAt ?? '');
+ const body = (data.content ?? data.description ?? '').toString();
+
+ const writerRaw = data.authorName ?? data.username ?? data.studentName ?? data.student?.name ?? '';
+ const writer = (writerRaw ?? '').toString().trim() || '-';
+
+ $title && ($title.textContent = title);
+ $writer && ($writer.textContent = writer);
+ $createdAt && ($createdAt.textContent = created || '-');
+ $content && ($content.innerHTML = `${escapeHtml(body)}`);
+
+ applyOwnerGuard(data);
+ }
+
+ // ===== 버튼 이벤트 =====
+ if ($btnEdit) $btnEdit.addEventListener('click', (e) => {
+ const id = getId(); if (!id) return;
+
+ // 1) 비로그인 → 로그인으로 (새 패턴으로 redirect 유지)
+ if (!getToken?.()) {
+ e.preventDefault();
+ alert('로그인이 필요한 기능입니다.\n로그인 페이지로 이동합니다.');
+ location.href = `/sign-in?redirect=${encodeURIComponent(`/posts/edit/${id}`)}`;
+ return;
+ }
+
+ // 2) 소유자를 확실히 알 수 있고, 내가 아니면 이동 차단
+ if (currentLoginId && ownerLoginId && currentLoginId !== ownerLoginId) {
+ e.preventDefault();
+ alert('본인이 작성한 글만 수정할 수 있습니다.');
+ return;
+ }
+
+ // 3) 수정 페이지로 이동 (새 패턴)
+ location.href = `/posts/edit/${id}`;
+ });
+
+ if ($btnDel) $btnDel.addEventListener('click', async (e) => {
+ const id = getId(); if (!id) return;
+
+ // 비로그인 → 상세로 돌아오도록 유지(기존 동작)
+ if (!getToken?.()) {
+ e.preventDefault();
+ alert('로그인이 필요한 기능입니다.\n로그인 페이지로 이동합니다.');
+ location.href = `/sign-in?redirect=${encodeURIComponent(`/posts/${id}`)}`;
+ return;
+ }
+
+ // 소유자 확정 + 내가 아니면 즉시 차단
+ if (currentLoginId && ownerLoginId && currentLoginId !== ownerLoginId) {
+ e.preventDefault();
+ alert('본인이 작성한 글만 삭제할 수 있습니다.');
+ return;
+ }
+
+ if (!confirm('정말 삭제하시겠습니까?')) return;
+
+ const resp = await callApi(`/posts/${encodeURIComponent(id)}`, { method: 'DELETE' });
+
+ // 상태코드별 친화적 메시지
+ if (resp.status === 401) { alert('로그인이 필요합니다.'); location.href = '/sign-in'; return; }
+ if (resp.status === 403) { alert('본인이 작성한 글만 삭제할 수 있습니다.'); return; }
+ if (resp.status === 404) { alert('이미 삭제되었거나 존재하지 않는 게시글입니다.'); return; }
+ if (!resp.ok) { alert('삭제에 실패했습니다.'); return; }
+
+ alert('삭제되었습니다.');
+ location.href = '/posts';
+ });
+
+ load();
+})();
diff --git a/src/main/resources/static/js/post-list.js b/src/main/resources/static/js/post-list.js
new file mode 100644
index 0000000..ccc00a5
--- /dev/null
+++ b/src/main/resources/static/js/post-list.js
@@ -0,0 +1,178 @@
+// 게시글 목록 페이지 스크립트 (최신 글 페이지 자동 점프 + 로그인 가드 있는 '새 글 작성')
+const { apiFetch: callApi } = window.API;
+
+const PAGE_SIZE = 20;
+let state = { page: 0, q: '' };
+
+// 최초 진입 시 최신 글(마지막 페이지)로 자동 이동
+let firstLoad = true;
+
+// ===== JWT 유틸 (새 글 작성 버튼 가드에만 사용) =====
+function getAccessToken() {
+ return window.API?.getToken?.() ?? null; // ← Authorization 키만 사용
+}
+function decodeJwtPayload(tokenLike) {
+ const token = tokenLike?.startsWith('Bearer ') ? tokenLike.slice(7) : tokenLike;
+ if (!token) return null;
+ const parts = token.split('.');
+ if (parts.length < 2) return null;
+ try {
+ const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
+ const json = decodeURIComponent(escape(atob(b64)));
+ return JSON.parse(json);
+ } catch {
+ try { return JSON.parse(atob(parts[1])); } catch { return null; }
+ }
+}
+
+// ===== DOM =====
+const tbody = document.querySelector('#list tbody');
+const emptyEl = document.getElementById('empty');
+const pageInfo = document.getElementById('page-info');
+const prevBtn = document.getElementById('prev');
+const nextBtn = document.getElementById('next');
+const searchForm= document.getElementById('search-form');
+const qInput = document.getElementById('q');
+const resetBtn = document.getElementById('btn-reset');
+const newBtn = document.getElementById('btn-new');
+
+// 새 글 작성 (비로그인/만료 → 로그인으로 안내)
+if (newBtn) {
+ newBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ const token = getAccessToken();
+ const payload = decodeJwtPayload(token);
+ const notLoggedIn = !payload || (typeof payload.exp === 'number' && Date.now() >= payload.exp * 1000);
+
+ if (notLoggedIn) {
+ alert('로그인이 필요한 기능입니다.\n로그인 페이지로 이동합니다.');
+ location.href = `/sign-in?redirect=${encodeURIComponent('/posts/new')}`;
+ return;
+ }
+ location.href = '/posts/new';
+ });
+}
+
+// 검색
+searchForm.addEventListener('submit', (e) => {
+ e.preventDefault();
+ state.q = qInput.value.trim();
+ state.page = 0;
+ firstLoad = true;
+ load();
+});
+
+// 초기화
+resetBtn.addEventListener('click', () => {
+ qInput.value = '';
+ state.q = '';
+ state.page = 0;
+ firstLoad = true;
+ load();
+});
+
+// 페이징
+prevBtn.addEventListener('click', () => {
+ if (state.page > 0) { state.page--; firstLoad = false; load(); }
+});
+nextBtn.addEventListener('click', () => {
+ state.page++; firstLoad = false; load();
+});
+
+// 렌더 (관리 열/버튼 없음)
+function renderRows(items) {
+ if (!items || items.length === 0) {
+ tbody.innerHTML = '';
+ emptyEl.style.display = 'block';
+ return;
+ }
+ emptyEl.style.display = 'none';
+
+ tbody.innerHTML = items.map(row => {
+ const id = row.id;
+ const title = row.title ?? '(제목 없음)';
+ const writer = (row.authorName ?? row.username ?? row.studentName ?? '').toString().trim() || '-';
+ const createdRaw = row.createdAt ?? row.updatedAt ?? row.createdDate ?? '';
+ const createdAt = formatDateTime(createdRaw);
+
+ return `
+
+ | ${escapeHtml(id)} |
+
+
+ ${escapeHtml(title)}
+
+ |
+ ${escapeHtml(writer)} |
+ ${escapeHtml(createdAt)} |
+
+ `;
+ }).join('');
+}
+
+function renderPager(data) {
+ const cur = (data.number ?? state.page) + 1;
+ const total = data.totalPages ?? 1;
+ pageInfo.textContent = `페이지 ${cur} / ${total}`;
+ prevBtn.disabled = data.first === true || (data.number ?? 0) <= 0;
+ nextBtn.disabled = data.last === true || cur >= total;
+}
+
+// 데이터 로드
+async function load() {
+ const params = new URLSearchParams({ page: state.page, size: PAGE_SIZE });
+ params.append('sort', 'id,desc');
+ if (state.q) params.append('titleSearch', state.q);
+
+ const resp = await callApi(`/posts?${params.toString()}`);
+ if (!resp.ok) {
+ const t = await resp.text().catch(() => '');
+ console.error('[POST LIST] API 실패', resp.status, t);
+ alert(t || '목록 조회 실패');
+ return;
+ }
+
+ const data = await resp.json();
+
+ if (firstLoad) {
+ firstLoad = false;
+ const last = Math.max(0, (data.totalPages ?? 1) - 1);
+ if (state.page !== last) {
+ state.page = last;
+ return load();
+ }
+ }
+
+ const rows = data.content || [];
+
+ if (rows.length === 0 && (data.totalPages ?? 1) > 0 && state.page > 0) {
+ state.page = Math.min(state.page, (data.totalPages ?? 1) - 1);
+ return load();
+ }
+
+ renderRows(rows);
+ renderPager(data);
+}
+
+// ===== 유틸 =====
+function escapeHtml(v) {
+ return String(v ?? '').replace(/[&<>"']/g, c => ({
+ '&':'&', '<':'<', '>':'>', '"':'"', "'":'''
+ })[c]);
+}
+function parseApiDate(v) {
+ if (v == null) return null;
+ if (typeof v === 'number') return new Date(v);
+ if (typeof v !== 'string') return null;
+ if (/[zZ]|[+\-]\d{2}:\d{2}$/.test(v)) return new Date(v);
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(v)) return new Date(v + 'Z');
+ return new Date(v);
+}
+function formatDateTime(v) {
+ const d = parseApiDate(v);
+ if (!d || Number.isNaN(d.getTime())) return v ?? '';
+ return d.toLocaleString();
+}
+
+// 시작
+load();
diff --git a/src/main/resources/static/js/post-update.js b/src/main/resources/static/js/post-update.js
new file mode 100644
index 0000000..65fa6ea
--- /dev/null
+++ b/src/main/resources/static/js/post-update.js
@@ -0,0 +1,142 @@
+// /js/post-update.js
+// 수정 페이지 전용 스크립트
+// - 비로그인 접근 → 로그인으로 안내(redirect 유지)
+// - 로드 시 소유자 확인 가능하면 내 글이 아닐 때 즉시 되돌림(상세로)
+// - 저장(PUT) 시 상태코드별 친화적 안내
+
+(() => {
+ const { apiFetch: callApi, getToken } = window.API || {};
+
+ // ===== JWT 유틸 =====
+ function decodeJwtPayload(token) {
+ if (!token) return null;
+ const parts = token.split('.');
+ if (parts.length < 2) return null;
+ try {
+ const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
+ const json = decodeURIComponent(escape(atob(b64)));
+ return JSON.parse(json);
+ } catch {
+ try { return JSON.parse(atob(parts[1])); } catch { return null; }
+ }
+ }
+ function getCurrentLoginId() {
+ const token = getToken?.();
+ const payload = decodeJwtPayload(token);
+ if (!payload) return null;
+ return payload.sub || payload.loginId || payload.userId || payload.username || null;
+ }
+
+ // ✅ 새 패턴(/posts/edit/{id}) 인식 (필요시 옛 패턴도 함께 인식하려면 주석 해제)
+ function getId() {
+ // 새 패턴
+ let m = location.pathname.match(/^\/posts\/edit\/(\d+)\/?$/);
+ if (m) return m[1];
+ // // 옛 패턴(호환용)
+ // m = location.pathname.match(/^\/posts\/(\d+)\/edit\/?$/);
+ // if (m) return m[1];
+ return null;
+ }
+
+ function getOwnerLoginIdFromPost(data) {
+ if (data?.student?.loginId) return data.student.loginId;
+ if (data?.student?.userId) return data.student.userId;
+ return (
+ data.ownerLoginId ||
+ data.studentLoginId ||
+ data.authorLoginId ||
+ data.userLoginId ||
+ data.userId ||
+ null
+ );
+ }
+
+ // ===== 진입 가드: 비로그인 → 로그인으로 =====
+ const id = getId();
+ if (!getToken?.()) {
+ alert('로그인이 필요한 기능입니다.\n로그인 페이지로 이동합니다.');
+ // ✅ 새 경로로 redirect 유지
+ location.replace(`/sign-in?redirect=${encodeURIComponent(`/posts/edit/${id}`)}`);
+ return;
+ }
+
+ const form = document.getElementById('form');
+ const btn = document.getElementById('btn-submit');
+ const cancel = document.getElementById('btn-cancel');
+
+ if (cancel) {
+ cancel.addEventListener('click', (e) => {
+ e.preventDefault();
+ location.href = `/posts/${id}`;
+ });
+ }
+
+ // ===== 초기 데이터 로드(+ 소유자 체크) =====
+ (async function load() {
+ const resp = await callApi(`/posts/${id}`);
+ if (!resp.ok) {
+ const t = await resp.text().catch(() => '');
+ alert(t || '게시글 조회 실패');
+ location.replace(`/posts/${id}`);
+ return;
+ }
+ const data = await resp.json();
+
+ // 소유자 판단 가능하면 내 글이 아니면 즉시 차단
+ const current = getCurrentLoginId();
+ const owner = getOwnerLoginIdFromPost(data);
+ if (current && owner && current !== owner) {
+ alert('본인이 작성한 글만 수정할 수 있습니다.');
+ location.replace(`/posts/${id}`);
+ return;
+ }
+
+ // 폼 채우기
+ form.title.value = data.title ?? '';
+ form.content.value = data.content ?? data.description ?? '';
+ })();
+
+ // ===== 저장 =====
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ if (!getToken?.()) {
+ alert('세션이 만료되었습니다.\n다시 로그인해 주세요.');
+ // ✅ 새 경로로 redirect 유지
+ location.replace(`/sign-in?redirect=${encodeURIComponent(`/posts/edit/${id}`)}`);
+ return;
+ }
+
+ const title = form.title.value.trim();
+ const content = form.content.value.trim();
+ if (!title || !content) {
+ alert('제목과 내용을 모두 입력해주세요.');
+ return;
+ }
+
+ btn.disabled = true;
+ try {
+ const resp = await callApi(`/posts/${id}`, {
+ method: 'PUT',
+ body: JSON.stringify({ title, content })
+ });
+
+ // 상태코드별 안내
+ if (resp.status === 401) { alert('로그인이 필요합니다.'); location.href = '/sign-in'; return; }
+ if (resp.status === 403) { alert('본인이 작성한 글만 수정할 수 있습니다.'); return; }
+ if (resp.status === 404) { alert('존재하지 않는 게시글입니다.'); location.replace('/posts'); return; }
+ if (resp.status >= 500){ alert('다른 사용자의 글은 수정할 수 없습니다.'); return; }
+ if (!resp.ok) {
+ const t = await resp.text().catch(() => '');
+ throw new Error(t || '수정 실패');
+ }
+
+ alert('수정되었습니다.');
+ location.replace(`/posts/${id}`);
+ } catch (err) {
+ alert(err.message || '수정 실패');
+ } finally {
+ btn.disabled = false;
+ }
+ });
+})();
diff --git a/src/main/resources/static/js/signup-1.0.js b/src/main/resources/static/js/signup-1.0.js
new file mode 100644
index 0000000..4d9a429
--- /dev/null
+++ b/src/main/resources/static/js/signup-1.0.js
@@ -0,0 +1,151 @@
+// 상태 플래그
+let isIdChecked = false;
+let lastCheckedId = "";
+
+// 요소 참조
+const usernameEl = document.getElementById('username');
+const checkIdMsgEl = document.getElementById('checkIdMessage');
+const signupMsgEl = document.getElementById('signupMessage');
+const checkBtn = document.getElementById('checkIdBtn');
+
+// 메시지 유틸
+function setMsg(el, text, color=''){ if(!el) return; el.textContent = text; el.style.color = color || ''; }
+
+// 아이디 입력 변경 시 중복확인 초기화
+usernameEl?.addEventListener('input', () => {
+ isIdChecked = false; lastCheckedId = ''; setMsg(checkIdMsgEl, ''); setMsg(signupMsgEl, '');
+});
+
+// 중복요청 제어
+let currentAbort = null;
+let lastRequestToken = 0;
+
+// 아이디 중복 확인 (★ loginId 파라미터 사용)
+checkBtn?.addEventListener('click', async () => {
+ const loginId = (usernameEl?.value || '').trim();
+ if (!loginId) { setMsg(checkIdMsgEl, '아이디를 입력해주세요.', 'red'); isIdChecked = false; return; }
+
+ if (currentAbort) currentAbort.abort();
+ currentAbort = new AbortController();
+ const signal = currentAbort.signal;
+
+ const prevBtnText = checkBtn.textContent;
+ checkBtn.disabled = true; checkBtn.textContent = '확인 중...';
+ setMsg(checkIdMsgEl, '중복 여부를 확인하는 중입니다...', '#555');
+
+ const reqToken = ++lastRequestToken;
+
+ try {
+ const res = await fetch(`/api/v1/auth/check-id?loginId=${encodeURIComponent(loginId)}`, {
+ method:'GET', headers:{ 'Accept':'application/json' }, cache:'no-store', signal
+ });
+
+ if (reqToken !== lastRequestToken) return;
+
+ if (res.status === 200) { // 사용 가능
+ setMsg(checkIdMsgEl, '사용 가능한 아이디입니다.', 'green');
+ isIdChecked = true; lastCheckedId = loginId; return;
+ }
+ if (res.status === 409) { // 이미 존재
+ setMsg(checkIdMsgEl, '이미 사용 중인 아이디입니다.', 'red');
+ isIdChecked = false; return;
+ }
+
+ // 다른 구현 대비(선택)
+ if (res.ok) {
+ const ct = res.headers.get('content-type') || '';
+ if (ct.includes('application/json')) {
+ try {
+ const data = await res.json();
+ if (typeof data?.available === 'boolean') {
+ if (data.available) { setMsg(checkIdMsgEl, '사용 가능한 아이디입니다.', 'green'); isIdChecked = true; lastCheckedId = loginId; }
+ else { setMsg(checkIdMsgEl, '이미 사용 중인 아이디입니다.', 'red'); isIdChecked = false; }
+ return;
+ }
+ } catch {}
+ }
+ }
+ setMsg(checkIdMsgEl, '서버 오류로 중복 확인에 실패했습니다.', 'red');
+ isIdChecked = false;
+
+ } catch (err) {
+ if (err.name !== 'AbortError') { console.error(err); setMsg(checkIdMsgEl, '서버에 연결할 수 없습니다.', 'red'); isIdChecked = false; }
+ } finally {
+ if (reqToken === lastRequestToken) { checkBtn.disabled = false; checkBtn.textContent = prevBtnText; }
+ }
+});
+
+// 폼 제출
+document.getElementById('signupForm')?.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const name = (document.getElementById('name')?.value || '').trim();
+ const studentId = (document.getElementById('studentId')?.value || '').trim();
+ const phone = (document.getElementById('phone')?.value || '').trim();
+ const loginId = (usernameEl?.value || '').trim();
+ const password = (document.getElementById('password')?.value || '');
+ const confirmPassword = (document.getElementById('confirmPassword')?.value || '');
+ const email = (document.getElementById('email')?.value || '').trim();
+
+ if (!name || !studentId || !phone || !loginId || !password || !confirmPassword || !email) { setMsg(signupMsgEl, '모든 항목을 입력해주세요.', 'red'); return; }
+ if (password !== confirmPassword) { setMsg(signupMsgEl, '비밀번호가 일치하지 않습니다.', 'red'); return; }
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { setMsg(signupMsgEl, '올바른 이메일 형식을 입력하세요.', 'red'); return; }
+ if (password.length < 8) { setMsg(signupMsgEl, '비밀번호는 최소 8자 이상이어야 합니다.', 'red'); return; }
+ if (!/^010\d{7,8}$/.test(phone)) { setMsg(signupMsgEl, '전화번호는 010으로 시작하고 하이픈 없이 숫자만 입력하세요.', 'red'); return; }
+ if (!isIdChecked || lastCheckedId !== loginId) { setMsg(signupMsgEl, '아이디 중복 확인을 먼저 해주세요.', 'red'); return; }
+
+ try {
+ const res = await fetch('/api/v1/auth/register', {
+ method:'POST',
+ headers:{ 'Content-Type':'application/json' },
+ body: JSON.stringify({ name, studentNumber:studentId, phoneNumber:phone, userId:loginId, password, email })
+ });
+
+ if (res.ok) { alert('회원가입 성공! 로그인 페이지로 이동합니다.'); window.location.href = '/sign-in'; return; }
+
+ let errorMessage = '회원가입 실패. 입력값을 확인해주세요.';
+ try {
+ const ct = res.headers.get('content-type') || '';
+ if (ct.includes('application/json')) {
+ const data = await res.json();
+ if (data?.message) errorMessage = data.message;
+ } else {
+ const text = await res.text(); if (text) errorMessage = text;
+ }
+ } catch {}
+
+ setMsg(signupMsgEl, errorMessage, 'red');
+ } catch (err) {
+ console.error(err);
+ setMsg(signupMsgEl, '서버와의 연결에 실패했습니다.', 'red');
+ }
+});
+
+// 전화번호: 숫자만, 11자리 제한
+document.getElementById('phone')?.addEventListener('input', function () {
+ let v = this.value.replace(/[^0-9]/g, '');
+ if (v.length > 11) v = v.slice(0, 11);
+ this.value = v;
+});
+
+// 비밀번호 보기/숨김 토글
+function attachPwToggle(inputId, btnId){
+ const input = document.getElementById(inputId);
+ const btn = document.getElementById(btnId);
+ if(!input || !btn) return;
+
+ const icon = btn.querySelector('.mi'); // visibility / visibility_off
+ btn.setAttribute('aria-pressed','false');
+ btn.setAttribute('aria-label','비밀번호 보기');
+ if(icon) icon.textContent = 'visibility_off';
+
+ btn.addEventListener('click', ()=>{
+ const show = input.type === 'password';
+ input.type = show ? 'text' : 'password';
+ btn.setAttribute('aria-pressed', String(show));
+ btn.setAttribute('aria-label', show ? '비밀번호 숨기기' : '비밀번호 보기');
+ if(icon) icon.textContent = show ? 'visibility' : 'visibility_off';
+ });
+}
+attachPwToggle('password','togglePwBtn');
+attachPwToggle('confirmPassword','toggleConfirmPwBtn');
diff --git a/src/main/resources/static/js/write-page-1.0.js b/src/main/resources/static/js/write-page-1.0.js
new file mode 100644
index 0000000..69a1035
--- /dev/null
+++ b/src/main/resources/static/js/write-page-1.0.js
@@ -0,0 +1,43 @@
+// 에디터 포맷 명령 실행
+function formatText(command, value = null) {
+ document.execCommand(command, false, value);
+}
+
+// 글쓰기 저장 기능
+document.getElementById('write-form').addEventListener('submit', function (e) {
+ e.preventDefault();
+
+ const title = document.getElementById('title').value.trim();
+ const content = document.getElementById('editor').innerHTML.trim();
+
+ if (!title || !content) {
+ alert('제목과 본문을 모두 입력해주세요.');
+ return;
+ }
+
+ // 숨은 필드에 에디터 내용 저장
+ document.getElementById('content-hidden').value = content;
+
+ const posts = JSON.parse(localStorage.getItem('posts') || '[]');
+ posts.unshift({ title, content, date: new Date().toLocaleString() });
+ localStorage.setItem('posts', JSON.stringify(posts));
+
+ alert('✅ 글이 저장되었습니다!');
+ this.reset();
+ document.getElementById('editor').innerHTML = '';
+});
+
+// 햄버거 메뉴 토글
+const toggleBtn = document.getElementById('menuToggle');
+const nav = document.getElementById('mainNav');
+const overlay = document.getElementById('overlay');
+
+toggleBtn.addEventListener('click', () => {
+ nav.classList.toggle('active');
+ overlay.classList.toggle('active');
+});
+
+overlay.addEventListener('click', () => {
+ nav.classList.remove('active');
+ overlay.classList.remove('active');
+});
diff --git a/src/main/resources/templates/Mainpage/mainpage-1.0.html b/src/main/resources/templates/Mainpage/mainpage-1.0.html
new file mode 100644
index 0000000..db21838
--- /dev/null
+++ b/src/main/resources/templates/Mainpage/mainpage-1.0.html
@@ -0,0 +1,50 @@
+
+
+
+
+ SE LAB 메인페이지
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 여기에 메인 콘텐츠를 작성하세요.
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/login/login-1.0.html b/src/main/resources/templates/login/login-1.0.html
new file mode 100644
index 0000000..db30afd
--- /dev/null
+++ b/src/main/resources/templates/login/login-1.0.html
@@ -0,0 +1,31 @@
+
+
+
+
+ 로그인
+
+
+
+
+
+
로그인
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/resources/templates/post/post-create.html b/src/main/resources/templates/post/post-create.html
new file mode 100644
index 0000000..6f474c6
--- /dev/null
+++ b/src/main/resources/templates/post/post-create.html
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+ SE LAB | 글 작성
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 글 작성
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/post/post-details.html b/src/main/resources/templates/post/post-details.html
new file mode 100644
index 0000000..b99ba47
--- /dev/null
+++ b/src/main/resources/templates/post/post-details.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+ SE LAB | 게시글 상세
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (제목)
+
+
+
+ 작성자: -
+ ·
+ 작성일: -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/post/post-list.html b/src/main/resources/templates/post/post-list.html
new file mode 100644
index 0000000..ce7af73
--- /dev/null
+++ b/src/main/resources/templates/post/post-list.html
@@ -0,0 +1,102 @@
+
+
+
+
+ SE LAB | 게시글 목록
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
SE LAB
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 게시글 목록
+
+
+
+
+
+
+
+
+
+
새 글 작성
+
+
+
+
+
+
+
+
+ | ID |
+ 제목 |
+
+ 작성자 |
+ 작성일 |
+
+
+
+
+
+
+
+
+
검색 결과가 없습니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/post/post-update.html b/src/main/resources/templates/post/post-update.html
new file mode 100644
index 0000000..2c46837
--- /dev/null
+++ b/src/main/resources/templates/post/post-update.html
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+ SE LAB | 글 수정
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 글 수정
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/post/post_details.html b/src/main/resources/templates/post/post_details.html
deleted file mode 100644
index 566549b..0000000
--- a/src/main/resources/templates/post/post_details.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
- Title
-
-
-
-
-
\ No newline at end of file
diff --git a/src/main/resources/templates/post/post_list.html b/src/main/resources/templates/post/post_list.html
deleted file mode 100644
index 566549b..0000000
--- a/src/main/resources/templates/post/post_list.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
- Title
-
-
-
-
-
\ No newline at end of file
diff --git a/src/main/resources/templates/post/post_update.html b/src/main/resources/templates/post/post_update.html
deleted file mode 100644
index 566549b..0000000
--- a/src/main/resources/templates/post/post_update.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
-
- Title
-
-
-
-
-
\ No newline at end of file
diff --git a/src/main/resources/templates/profile/login.html b/src/main/resources/templates/profile/login.html
new file mode 100644
index 0000000..e69de29
diff --git a/src/main/resources/templates/profile/signup.html b/src/main/resources/templates/profile/signup.html
new file mode 100644
index 0000000..554a31e
--- /dev/null
+++ b/src/main/resources/templates/profile/signup.html
@@ -0,0 +1,19 @@
+
+
+
+
+ 회원가입
+
+
+
+
+
회원가입
+
+
+
+
diff --git a/src/main/resources/templates/signup/signup-1.0.html b/src/main/resources/templates/signup/signup-1.0.html
new file mode 100644
index 0000000..f052023
--- /dev/null
+++ b/src/main/resources/templates/signup/signup-1.0.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+ 회원가입
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/resources/templates/write-page/write-page-1.0.html b/src/main/resources/templates/write-page/write-page-1.0.html
new file mode 100644
index 0000000..b6e5a1e
--- /dev/null
+++ b/src/main/resources/templates/write-page/write-page-1.0.html
@@ -0,0 +1,73 @@
+
+
+
+
+ 글쓰기 - SE LAB
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+