plugins { id "java" id "org.springframework.boot" version "4.0.3" id "io.spring.dependency-management" version "1.1.7" } java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } dependencies { implementation "org.springframework.boot:spring-boot-starter-data-jpa" implementation "org.springframework.boot:spring-boot-starter-thymeleaf" // 서버 사이드 템플릿 implementation "org.springframework.boot:spring-boot-starter-webmvc" implementation "org.springframework.security:spring-security-crypto" // BCrypt만 (Security 전체 X) compileOnly "org.projectlombok:lombok" runtimeOnly "org.mariadb.jdbc:mariadb-java-client" annotationProcessor "org.projectlombok:lombok" testImplementation "org.springframework.boot:spring-boot-starter-test" }
spring.datasource.url=jdbc:mariadb://localhost:3306/boarddb spring.datasource.username=root spring.datasource.password=1234 spring.datasource.driver-class-name=org.mariadb.jdbc.Driver spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.jpa.properties.hibernate.format_sql=true spring.thymeleaf.cache=false
@Entity @Table(name = "users") @Getter @Setter @NoArgsConstructor public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String username; @Column(nullable = false) private String password; @Column(unique = true, nullable = false) private String nickname; private LocalDateTime createdAt; @PrePersist public void prePersist() { this.createdAt = LocalDateTime.now(); } }
@Entity @Table(name = "board") @Getter @Setter @NoArgsConstructor public class Board { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String title; @Column(nullable = false, columnDefinition = "TEXT") private String content; private int viewCount = 0; private LocalDateTime createdAt; private LocalDateTime updatedAt; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; // 작성자 @PrePersist public void prePersist() { this.createdAt = LocalDateTime.now(); } @PreUpdate public void preUpdate() { this.updatedAt = LocalDateTime.now(); } }
public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByUsername(String username); boolean existsByUsername(String username); boolean existsByNickname(String nickname); }
public interface BoardRepository extends JpaRepository<Board, Long> { Page<Board> findAllByOrderByCreatedAtDesc(Pageable pageable); // 1차: Board Entity 그대로 반환 — Thymeleaf는 Entity 직접 사용 가능 // 2차에서 Page<BoardDto>로 변경됨 (React는 JSON 필요) }
@Getter @Setter public class UserDto { private String username; private String password; private String nickname; }
@Getter @Setter public class BoardDto { private String title; private String content; // 1차는 필드 최소화 — Thymeleaf가 Entity를 직접 써도 되니까 // 2차에서 id, nickname, username, viewCount, createdAt 등 추가됨 }
@Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; private final BCryptPasswordEncoder bCryptPasswordEncoder; public void register(UserDto userDto) { if (userRepository.existsByUsername(userDto.getUsername())) throw new IllegalArgumentException("이미 사용중인 아이디입니다."); if (userRepository.existsByNickname(userDto.getNickname())) throw new IllegalArgumentException("이미 사용중인 닉네임입니다."); User user = new User(); user.setUsername(userDto.getUsername()); user.setPassword(bCryptPasswordEncoder.encode(userDto.getPassword())); user.setNickname(userDto.getNickname()); userRepository.save(user); } public User login(String username, String password) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new IllegalArgumentException("아이디 또는 비밀번호가 틀렸습니다.")); if (!bCryptPasswordEncoder.matches(password, user.getPassword())) throw new IllegalArgumentException("아이디 또는 비밀번호가 틀렸습니다."); return user; // 1차: User Entity 반환 → 세션에 저장 // 2차: JWT 토큰 String 반환으로 변경됨 } }
@Service @RequiredArgsConstructor public class BoardService { private final BoardRepository boardRepository; // 1차: Page<Board> Entity 그대로 반환 // 2차에서 Page<BoardDto>로 변경됨 public Page<Board> getBoardList(int page) { Pageable pageable = PageRequest.of(page, 10, Sort.by("createdAt").descending()); return boardRepository.findAllByOrderByCreatedAtDesc(pageable); } public Board getBoard(Long id, User loginUser) { Board board = boardRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 게시글입니다.")); board.setViewCount(board.getViewCount() + 1); boardRepository.save(board); return board; } public void write(BoardDto boardDto, User loginUser) { Board board = new Board(); board.setTitle(boardDto.getTitle()); board.setContent(boardDto.getContent()); board.setUser(loginUser); boardRepository.save(board); } @Transactional public void update(Long id, BoardDto boardDto, User loginUser) { Board board = boardRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 게시글입니다.")); if (!board.getUser().getId().equals(loginUser.getId())) throw new IllegalArgumentException("수정 권한이 없습니다."); board.setTitle(boardDto.getTitle()); // 더티체킹으로 자동 저장 board.setContent(boardDto.getContent()); } public void delete(Long id, User loginUser) { Board board = boardRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 게시글입니다.")); if (!board.getUser().getId().equals(loginUser.getId())) throw new IllegalArgumentException("삭제 권한이 없습니다."); boardRepository.delete(board); } }
@Controller @RequiredArgsConstructor // @Controller → 뷰 이름 반환 @RequestMapping("/user") public class UserController { private final UserService userService; @GetMapping("/register") public String registerForm() { return "user/register"; } @PostMapping("/register") public String register(@ModelAttribute UserDto userDto, RedirectAttributes ra) { try { userService.register(userDto); return "redirect:/user/login"; } catch (IllegalArgumentException e) { ra.addFlashAttribute("error", e.getMessage()); return "redirect:/user/register"; } } @GetMapping("/login") public String loginForm() { return "user/login"; } @PostMapping("/login") public String login(@RequestParam String username, @RequestParam String password, HttpSession session, RedirectAttributes ra) { try { User user = userService.login(username, password); session.setAttribute("loginUser", user); // 세션에 User 저장 return "redirect:/board/list"; } catch (IllegalArgumentException e) { ra.addFlashAttribute("error", e.getMessage()); return "redirect:/user/login"; } } @GetMapping("/logout") public String logout(HttpSession session) { session.invalidate(); // 세션 전체 삭제 return "redirect:/user/login"; } }
@Controller @RequiredArgsConstructor @RequestMapping("/board") public class BoardController { private final BoardService boardService; @GetMapping("/list") public String list(@RequestParam(defaultValue="0") int page, HttpSession session, Model model) { User loginUser = (User) session.getAttribute("loginUser"); // 세션에서 꺼냄 if (loginUser == null) return "redirect:/user/login"; // 반복 인증 코드! model.addAttribute("boardList", boardService.getBoardList(page)); model.addAttribute("loginUser", loginUser); return "board/list"; } @GetMapping("/write") public String writeForm(HttpSession session) { if (session.getAttribute("loginUser") == null) return "redirect:/user/login"; return "board/write"; } @PostMapping("/write") public String write(@ModelAttribute BoardDto boardDto, HttpSession session) { User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) return "redirect:/user/login"; boardService.write(boardDto, loginUser); return "redirect:/board/list"; } @GetMapping("/detail/{id}") public String detail(@PathVariable Long id, HttpSession session, Model model) { User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) return "redirect:/user/login"; model.addAttribute("board", boardService.getBoard(id, loginUser)); model.addAttribute("loginUser", loginUser); return "board/detail"; } @PostMapping("/edit/{id}") public String edit(@PathVariable Long id, @ModelAttribute BoardDto boardDto, HttpSession session) { User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) return "redirect:/user/login"; boardService.update(id, boardDto, loginUser); return "redirect:/board/detail/" + id; } @PostMapping("/delete/{id}") public String delete(@PathVariable Long id, HttpSession session) { User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) return "redirect:/user/login"; boardService.delete(id, loginUser); return "redirect:/board/list"; } }
<html xmlns:th="http://www.thymeleaf.org"> <body> <h2>로그인</h2> <p th:text="${error}" style="color:red"></p> <form th:action="@{/user/login}" method="post"> <input type="text" name="username" placeholder="아이디"> <input type="password" name="password" placeholder="비밀번호"> <button type="submit">로그인</button> </form> <a th:href="@{/user/register}">회원가입</a> </body></html>
<html xmlns:th="http://www.thymeleaf.org"> <body> <h2>게시판</h2> <a th:href="@{/board/write}">글쓰기</a> <a th:href="@{/user/logout}">로그아웃</a> <table> <tr><th>번호</th><th>제목</th><th>작성자</th><th>조회수</th></tr> <!-- th:each = for-each 반복 --> <tr th:each="board : ${boardList.content}"> <td th:text="${board.id}"></td> <td><a th:href="@{/board/detail/{id}(id=${board.id})}" th:text="${board.title}"></a></td> <td th:text="${board.user.nickname}"></td> <td th:text="${board.viewCount}"></td> </tr> </table> </body></html>
<html xmlns:th="http://www.thymeleaf.org"> <body> <h2 th:text="${board.title}"></h2> <p>작성자: <span th:text="${board.user.nickname}"></span></p> <p>조회수: <span th:text="${board.viewCount}"></span></p> <p th:text="${board.content}"></p> <!-- 본인 글일 때만 수정/삭제 버튼 표시 --> <th:block th:if="${board.user.id == loginUser.id}"> <a th:href="@{/board/edit/{id}(id=${board.id})}">수정</a> <form th:action="@{/board/delete/{id}(id=${board.id})}" method="post"> <button type="submit">삭제</button> </form> </th:block> </body></html>
dependencies { implementation "org.springframework.boot:spring-boot-starter-data-jpa" // thymeleaf 제거! implementation "org.springframework.boot:spring-boot-starter-webmvc" implementation "org.springframework.security:spring-security-crypto" // BCrypt만 // ↓ JWT 관련 3개 추가 implementation "io.jsonwebtoken:jjwt-api:0.12.3" runtimeOnly "io.jsonwebtoken:jjwt-impl:0.12.3" runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.12.3" compileOnly "org.projectlombok:lombok" runtimeOnly "org.mariadb.jdbc:mariadb-java-client" annotationProcessor "org.projectlombok:lombok" testImplementation "org.springframework.boot:spring-boot-starter-test" }
spring.datasource.url=jdbc:mariadb://localhost:3306/boarddb spring.datasource.username=root spring.datasource.password=1234 spring.datasource.driver-class-name=org.mariadb.jdbc.Driver spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true # JWT 설정 추가 (1차에 없던 것) jwt.secret=mySecretKeyForJWTTokenGenerationAndValidation2024 jwt.expiration=86400000 # 24시간 (밀리초)
@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private Long expiration; private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(secret.getBytes()); } public String createToken(String username, String nickname) { return Jwts.builder() .subject(username) .claim("nickname", nickname) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + expiration)) .signWith(getSigningKey()) .compact(); } public String getUsername(String token) { return getClaims(token).getSubject(); } public boolean validateToken(String token) { try { getClaims(token); return true; } catch (Exception e) { return false; } } private Claims getClaims(String token) { return Jwts.parser() .verifyWith(getSigningKey()).build() .parseSignedClaims(token).getPayload(); } }
@Component @RequiredArgsConstructor public class JwtFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final UserRepository userRepository; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); if (jwtUtil.validateToken(token)) { String username = jwtUtil.getUsername(token); User user = userRepository.findByUsername(username).orElse(null); if (user != null) { request.setAttribute("loginUser", user); // request에 User 저장 } } } filterChain.doFilter(request, response); } }
@Configuration @RequiredArgsConstructor public class CorsConfig implements WebMvcConfigurer { private final JwtFilter jwtFilter; @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:3000") .allowedMethods("GET","POST","PUT","DELETE") .allowedHeaders("*") .allowCredentials(true); } // JwtFilter를 수동 등록 — 3차에서는 SecurityConfig가 대신 처리 @Bean public FilterRegistrationBean<JwtFilter> jwtFilterRegistration() { FilterRegistrationBean<JwtFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(jwtFilter); registration.addUrlPatterns("/api/*"); return registration; } }
@Getter @Setter public class BoardDto { private Long id; // 추가 (상세/수정/삭제에 필요) private String title; private String content; private String nickname; // 추가 (목록에 작성자 표시) private String username; // 추가 (본인 글 여부 확인) private int viewCount; // 추가 private String createdAt; // 추가 (LocalDateTime → String 변환) private Long userId; // 추가 }
@Service @RequiredArgsConstructor public class UserService { private final UserRepository userRepository; private final BCryptPasswordEncoder bCryptPasswordEncoder; private final JwtUtil jwtUtil; // 추가! // register()는 1차와 동일 // 반환타입 변경: User → String (JWT 토큰) public String login(String username, String password) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new IllegalArgumentException("아이디 또는 비밀번호가 틀렸습니다.")); if (!bCryptPasswordEncoder.matches(password, user.getPassword())) throw new IllegalArgumentException("아이디 또는 비밀번호가 틀렸습니다."); return jwtUtil.createToken(user.getUsername(), user.getNickname()); } }
@Service @RequiredArgsConstructor public class BoardService { private final BoardRepository boardRepository; // 반환타입 변경: Page<Board> → Page<BoardDto> public Page<BoardDto> getBoardList(int page) { Pageable pageable = PageRequest.of(page, 10, Sort.by("createdAt").descending()); Page<Board> boardPage = boardRepository.findAllByOrderByCreatedAtDesc(pageable); List<BoardDto> dtoList = boardPage.getContent().stream() .map(this::toDto).collect(Collectors.toList()); return new PageImpl<>(dtoList, pageable, boardPage.getTotalElements()); } // 새로 추가: Entity → DTO 변환 private BoardDto toDto(Board board) { BoardDto dto = new BoardDto(); dto.setId(board.getId()); dto.setTitle(board.getTitle()); dto.setContent(board.getContent()); dto.setViewCount(board.getViewCount()); dto.setNickname(board.getUser().getNickname()); dto.setUsername(board.getUser().getUsername()); dto.setUserId(board.getUser().getId()); dto.setCreatedAt(board.getCreatedAt().format( DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))); return dto; } // write(), update(), delete()는 1차와 동일 }
@RestController @RequiredArgsConstructor // @Controller → @RestController @RequestMapping("/api/user") // /user → /api/user public class UserController { private final UserService userService; @PostMapping("/register") public ResponseEntity<?> register(@RequestBody UserDto userDto) { // @RequestBody! try { userService.register(userDto); return ResponseEntity.ok(Map.of("message", "회원가입 성공")); } catch (IllegalArgumentException e) { return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); } } @PostMapping("/login") public ResponseEntity<?> login(@RequestBody Map<String, String> body) { try { String token = userService.login(body.get("username"), body.get("password")); return ResponseEntity.ok(Map.of("token", token)); } catch (IllegalArgumentException e) { return ResponseEntity.badRequest().body(Map.of("message", e.getMessage())); } } }
import axios from "axios"; const api = axios.create({ baseURL: "http://localhost:8080" }); // 모든 요청에 JWT 토큰 자동 첨부 api.interceptors.request.use((config) => { const token = localStorage.getItem("token"); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); // 401 응답 시 자동 로그아웃 api.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { localStorage.removeItem("token"); window.location.href = "/login"; } return Promise.reject(error); } ); export default api;
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; // 토큰 없으면 로그인 페이지로 강제 이동 const PrivateRoute = ({ children }) => { const token = localStorage.getItem("token"); return token ? children : <Navigate to="/login" />; }; function App() { return ( <BrowserRouter> <Routes> <Route path="/login" element={<Login />} /> <Route path="/register" element={<Register />} /> <Route path="/board/list" element={<PrivateRoute><BoardList /></PrivateRoute>} /> <Route path="/board/detail/:id" element={<PrivateRoute><BoardDetail /></PrivateRoute>} /> </Routes> </BrowserRouter> ); }
function Login() { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const navigate = useNavigate(); const handleLogin = async () => { try { const res = await api.post("/api/user/login", { username, password }); localStorage.setItem("token", res.data.token); // 토큰 저장 navigate("/board/list"); } catch (e) { setError(e.response?.data?.message); } }; }
import { jwtDecode } from "jwt-decode"; function BoardDetail() { const [isOwner, setIsOwner] = useState(false); const fetchBoard = async () => { const res = await api.get(`/api/board/detail/${id}`); // JWT 디코딩으로 본인 글 여부 확인 const token = localStorage.getItem("token"); const decoded = jwtDecode(token); setIsOwner(decoded.sub === res.data.username); }; // isOwner true일 때 수정/삭제 버튼 표시 }
dependencies { implementation "org.springframework.boot:spring-boot-starter-data-jpa" implementation "org.springframework.boot:spring-boot-starter-webmvc" // ↓ Spring Security 전체 추가 (spring-security-crypto 제거) implementation "org.springframework.boot:spring-boot-starter-security" implementation "io.jsonwebtoken:jjwt-api:0.12.3" runtimeOnly "io.jsonwebtoken:jjwt-impl:0.12.3" runtimeOnly "io.jsonwebtoken:jjwt-jackson:0.12.3" compileOnly "org.projectlombok:lombok" runtimeOnly "org.mariadb.jdbc:mariadb-java-client" annotationProcessor "org.projectlombok:lombok" testImplementation "org.springframework.boot:spring-boot-starter-test" testImplementation "org.springframework.security:spring-security-test" }
spring.datasource.url=jdbc:mariadb://localhost:3306/boarddb spring.datasource.username=root spring.datasource.password=1234 spring.datasource.driver-class-name=org.mariadb.jdbc.Driver spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true jwt.secret=mySecretKeyForJWTTokenGenerationAndValidation2024 jwt.expiration=86400000 # 관리자 코드 추가 admin.code=ADMIN2024
@Entity @Table(name = "users") @Getter @Setter @NoArgsConstructor public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String username; @Column(nullable = false) private String password; @Column(unique = true, nullable = false) private String nickname; @Column(nullable = false) private String role; // "ROLE_USER" 또는 "ROLE_ADMIN" ← 추가! private LocalDateTime createdAt; @PrePersist public void prePersist() { this.createdAt = LocalDateTime.now(); } }
@Getter @RequiredArgsConstructor public class CustomUserDetails implements UserDetails { private final User user; // User Entity를 감싸는 래퍼 @Override public Collection<? extends GrantedAuthority> getAuthorities() { // ROLE_USER 또는 ROLE_ADMIN을 Spring Security 권한으로 변환 return List.of(new SimpleGrantedAuthority(user.getRole())); } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUsername(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
@Service @RequiredArgsConstructor public class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("없는 유저입니다: " + username)); return new CustomUserDetails(user); // UserDetails로 감싸서 반환 } }
@Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; private final CustomUserDetailsService customUserDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); if (jwtUtil.validateToken(token)) { String username = jwtUtil.getUsername(token); UserDetails userDetails = customUserDetailsService.loadUserByUsername(username); // SecurityContext에 인증 객체 저장 ← 2차와 핵심 차이! UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(authentication); } } filterChain.doFilter(request, response); } }
@Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/user/login", "/api/user/register").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") // 관리자만! .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of("http://localhost:3000")); config.setAllowedMethods(List.of("GET","POST","PUT","DELETE")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } }
// createToken() — role 파라미터 추가 public String createToken(String username, String nickname, String role) { return Jwts.builder() .subject(username) .claim("nickname", nickname) .claim("role", role) // role 추가! .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + expiration)) .signWith(getSigningKey()) .compact(); } // getRole() 추가 public String getRole(String token) { return getClaims(token).get("role", String.class); }
@RestController @RequiredArgsConstructor @RequestMapping("/api/board") public class BoardController { private final BoardService boardService; @GetMapping("/list") public ResponseEntity<?> list(@RequestParam(defaultValue="0") int page) { return ResponseEntity.ok(boardService.getBoardList(page)); } // 2차: User loginUser = (User) request.getAttribute("loginUser") // 3차: @AuthenticationPrincipal 로 Spring이 자동 주입! @PostMapping("/write") public ResponseEntity<?> write(@RequestBody BoardDto boardDto, @AuthenticationPrincipal CustomUserDetails userDetails) { boardService.write(boardDto, userDetails.getUser()); return ResponseEntity.ok(Map.of("message", "작성 완료")); } @PutMapping("/edit/{id}") public ResponseEntity<?> edit(@PathVariable Long id, @RequestBody BoardDto boardDto, @AuthenticationPrincipal CustomUserDetails userDetails) { boardService.update(id, boardDto, userDetails.getUser()); return ResponseEntity.ok(Map.of("message", "수정 완료")); } @DeleteMapping("/delete/{id}") public ResponseEntity<?> delete(@PathVariable Long id, @AuthenticationPrincipal CustomUserDetails userDetails) { boardService.delete(id, userDetails.getUser()); return ResponseEntity.ok(Map.of("message", "삭제 완료")); } }
@RestController @RequiredArgsConstructor @RequestMapping("/api/admin") // SecurityConfig에서 ROLE_ADMIN만 접근 허용 public class AdminController { private final AdminService adminService; @GetMapping("/users") public ResponseEntity<List<UserDto>> getAllUsers() { return ResponseEntity.ok(adminService.getAllUsers()); } @DeleteMapping("/users/{userId}") public ResponseEntity<?> deleteUser(@PathVariable Long userId) { adminService.deleteUser(userId); return ResponseEntity.ok(Map.of("message", "유저 삭제 완료")); } @GetMapping("/boards") public ResponseEntity<List<BoardDto>> getAllBoards() { return ResponseEntity.ok(adminService.getAllBoards()); } @DeleteMapping("/boards/{boardId}") public ResponseEntity<?> deleteBoard(@PathVariable Long boardId) { adminService.deleteBoard(boardId); return ResponseEntity.ok(Map.of("message", "게시글 삭제 완료")); } }
@Service @RequiredArgsConstructor public class AdminService { private final UserRepository userRepository; private final BoardRepository boardRepository; public List<UserDto> getAllUsers() { return userRepository.findAll().stream().map(user -> { UserDto dto = new UserDto(); dto.setId(user.getId()); dto.setUsername(user.getUsername()); dto.setNickname(user.getNickname()); dto.setRole(user.getRole()); return dto; }).collect(Collectors.toList()); } @Transactional public void deleteUser(Long userId) { boardRepository.deleteByUser(userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("없는 유저입니다."))); userRepository.deleteById(userId); } public List<BoardDto> getAllBoards() { return boardRepository.findAll().stream() .map(this::toDto).collect(Collectors.toList()); } public void deleteBoard(Long boardId) { boardRepository.deleteById(boardId); } }
public interface BoardRepository extends JpaRepository<Board, Long> { Page<Board> findAllByOrderByCreatedAtDesc(Pageable pageable); void deleteByUser(User user); // 추가! — 유저 삭제 시 게시글 먼저 삭제 }
// PrivateRoute는 2차와 동일 const PrivateRoute = ({ children }) => { const token = localStorage.getItem("token"); return token ? children : <Navigate to="/login" />; }; // 관리자 전용 Route 추가 const AdminRoute = ({ children }) => { const role = localStorage.getItem("role"); return role === "ROLE_ADMIN" ? children : <Navigate to="/board/list" />; }; // 라우트에 추가 <Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
const handleLogin = async () => { const res = await api.post("/api/user/login", { username, password }); localStorage.setItem("token", res.data.token); localStorage.setItem("role", res.data.role); // 추가! navigate("/board/list"); };
function AdminPage() { const [users, setUsers] = useState([]); const [boards, setBoards] = useState([]); const [activeTab, setActiveTab] = useState("users"); useEffect(() => { api.get("/api/admin/users").then(res => setUsers(res.data)); api.get("/api/admin/boards").then(res => setBoards(res.data)); }, []); const handleDeleteUser = async (userId) => { if (window.confirm("유저를 삭제하시겠습니까?")) { await api.delete(`/api/admin/users/${userId}`); setUsers(users.filter(u => u.id !== userId)); } }; // 탭 UI로 유저 목록 / 게시글 목록 전환 }
implementation 'org.springframework.boot:spring-boot-starter-aop'
@Aspect @Component @Slf4j public class LogAspect { // service 패키지 안의 모든 메서드에 적용 @Around("execution(* com.example.board.service.*.*(..))") public Object logAround(ProceedingJoinPoint jp) throws Throwable { String methodName = jp.getSignature().getName(); String className = jp.getTarget().getClass().getSimpleName(); log.info("[START] {}.{}()", className, methodName); long start = System.currentTimeMillis(); try { Object result = jp.proceed(); // 실제 메서드 실행 long elapsed = System.currentTimeMillis() - start; log.info("[END] {}.{}() — {}ms", className, methodName, elapsed); return result; } catch (Throwable e) { log.error("[ERROR] {}.{}() — {}", className, methodName, e.getMessage()); throw e; } } }
@Getter public class CustomException extends RuntimeException { private final HttpStatus status; public CustomException(String message, HttpStatus status) { super(message); this.status = status; } // 편의 메서드 public static CustomException badRequest(String message) { return new CustomException(message, HttpStatus.BAD_REQUEST); } public static CustomException forbidden(String message) { return new CustomException(message, HttpStatus.FORBIDDEN); } public static CustomException notFound(String message) { return new CustomException(message, HttpStatus.NOT_FOUND); } }
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(CustomException.class) public ResponseEntity<Map<String, Object>> handleCustom(CustomException e) { Map<String, Object> body = new LinkedHashMap<>(); body.put("status", e.getStatus().value()); body.put("message", e.getMessage()); body.put("timestamp", LocalDateTime.now().toString()); return ResponseEntity.status(e.getStatus()).body(body); } @ExceptionHandler(Exception.class) public ResponseEntity<Map<String, Object>> handleAll(Exception e) { Map<String, Object> body = new LinkedHashMap<>(); body.put("status", 500); body.put("message", "서버 오류가 발생했습니다."); return ResponseEntity.internalServerError().body(body); } }
// Before (3차) Board board = boardRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 게시글입니다.")); if (!board.getUser().getId().equals(user.getId())) throw new IllegalArgumentException("삭제 권한이 없습니다."); // After (심화) Board board = boardRepository.findById(id) .orElseThrow(() -> CustomException.notFound("존재하지 않는 게시글입니다.")); if (!board.getUser().getId().equals(user.getId())) throw CustomException.forbidden("삭제 권한이 없습니다.");
@Service @RequiredArgsConstructor @Transactional // 클래스 레벨: 기본적으로 모든 메서드에 트랜잭션 적용 public class BoardService { @Transactional(readOnly = true) // 조회 메서드: readOnly 오버라이드 public Page<BoardDto> getBoardList(int page) { ... } @Transactional(readOnly = true) public BoardDto getBoard(Long id) { ... } // 쓰기 메서드는 클래스 레벨 @Transactional 그대로 사용 public void write(BoardDto dto, User user) { ... } public void update(Long id, BoardDto dto, User user) { Board board = boardRepository.findById(id)...; board.setTitle(dto.getTitle()); // save() 없어도 더티체킹으로 자동 UPDATE board.setContent(dto.getContent()); } public void delete(Long id, User user) { ... } }
@Getter @Builder // 추가! @NoArgsConstructor // @Builder와 함께 쓸 때 필요 @AllArgsConstructor // @Builder와 함께 쓸 때 필요 public class BoardDto { private Long id; private String title; private String content; private String nickname; private String username; private int viewCount; private String createdAt; private Long userId; }