📚 전체 맵 🗺 로드맵 프로젝트 전체 코드 레퍼런스
PROJECT CODE REFERENCE

전체 코드
레퍼런스

1차 Thymeleaf+Session → 2차 React+JWT → 3차 Spring Security → 3차 심화
환경설정부터 전체 코드까지 차수별로 한 페이지에서 확인
1차 Thymeleaf 2차 React+JWT 3차 Security 3차 심화
1차 프로젝트
1차 — Thymeleaf + Session 인증
서버 사이드 렌더링 · HttpSession 로그인 · Thymeleaf 템플릿
📋 프로젝트 개요
  • Spring Boot + Thymeleaf로 서버에서 HTML을 만들어 브라우저에 전달하는 방식
  • 로그인 인증은 HttpSession — 세션에 User 객체를 저장하고 요청마다 꺼내 확인
  • Controller는 @Controller → Thymeleaf 템플릿 이름을 반환 (뷰 이름)
  • 별도 프론트엔드 없음 — HTML 파일이 src/main/resources/templates/ 안에 존재
Spring Boot Thymeleaf Spring Data JPA MariaDB BCrypt HttpSession Lombok
📁 프로젝트 파일 구조
1차 전체 파일 레이아웃
project-root/ ├─ build.gradle ├─ settings.gradle │ ├─ src/main/java/com/example/ │ ├─ Application.java │ │ │ ├─ entity/ │ │ ├─ User.java │ │ └─ Board.java │ │ │ ├─ dto/ │ │ ├─ UserDto.java │ │ └─ BoardDto.java │ │ │ ├─ repository/ │ │ ├─ UserRepository.java │ │ └─ BoardRepository.java │ │ │ ├─ service/ │ │ ├─ UserService.java │ │ └─ BoardService.java │ │ │ └─ controller/ │ ├─ UserController.java │ └─ BoardController.java │ └─ src/main/resources/ ├─ application.properties └─ templates/ ├─ user/ │ ├─ login.html │ └─ register.html └─ board/ ├─ list.html ├─ detail.html ├─ write.html └─ edit.html
⚙️ 환경설정
build.gradle · application.properties
build.gradleGradle
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"
}
application.properties
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
User.java · Board.java — DB 테이블과 1:1 매핑되는 클래스
User.javaJava
@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();
    }
}
Board.javaJava
@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(); }
}
🗄 Repository
UserRepository · BoardRepository — JPA 쿼리 인터페이스
UserRepository.javaJava
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
    boolean existsByUsername(String username);
    boolean existsByNickname(String nickname);
}
BoardRepository.javaJava
public interface BoardRepository extends JpaRepository<Board, Long> {
    Page<Board> findAllByOrderByCreatedAtDesc(Pageable pageable);
    // 1차: Board Entity 그대로 반환 — Thymeleaf는 Entity 직접 사용 가능
    // 2차에서 Page<BoardDto>로 변경됨 (React는 JSON 필요)
}
📦 DTO
UserDto · BoardDto — 계층 간 데이터 전달 객체
UserDto.javaJava
@Getter @Setter
public class UserDto {
    private String username;
    private String password;
    private String nickname;
}
BoardDto.javaJava
@Getter @Setter
public class BoardDto {
    private String title;
    private String content;
    // 1차는 필드 최소화 — Thymeleaf가 Entity를 직접 써도 되니까
    // 2차에서 id, nickname, username, viewCount, createdAt 등 추가됨
}
⚙️ Service
UserService · BoardService — 핵심 비즈니스 로직
UserService.javaJava
@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 반환으로 변경됨
    }
}
BoardService.javaJava
@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
UserController · BoardController — HTTP 요청 처리 + 세션 인증
UserController.javaJava
@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";
    }
}
BoardController.javaJava
@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";
    }
}
🖼 Thymeleaf 템플릿
login.html · register.html · list.html · detail.html · write.html · edit.html
user/login.htmlThymeleaf
<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>
board/list.htmlThymeleaf
<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>
board/detail.htmlThymeleaf
<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>
1차 → 2차 핵심 변화 포인트
① @Controller → @RestController (뷰 반환 → JSON 반환)
② HttpSession → JWT 토큰 (서버 상태 → Stateless)
③ Thymeleaf 제거, React 추가 (SSR → CSR)
④ /board → /api/board (REST API URL 구조)
⑤ UserService.login() 반환 타입 User → String(JWT)
2차 프로젝트
2차 — React + JWT 인증
REST API 백엔드 · JWT 토큰 인증 · React 프론트엔드 분리
📋 프로젝트 개요
  • 백엔드: Spring Boot REST API (@RestController) — JSON 응답만 처리
  • 프론트엔드: React SPA — 브라우저에서 직접 렌더링 (CSR)
  • 인증: JWT 토큰 — 로그인 시 토큰 발급, 이후 요청마다 Header에 첨부
  • JwtFilter가 모든 요청을 가로채서 토큰 검증 후 request.setAttribute()로 User 전달
  • Thymeleaf 제거, CORS 설정 필수 (백엔드 8080, 프론트엔드 3000 포트 분리)
Spring Boot REST JWT (jjwt 0.12.3) React axios MariaDB + JPA CORS Config
📁 프로젝트 파일 구조
1차 대비 변경 사항 — 초록=신규추가 · 빨강=삭제 · 노랑=변경
project-root/ ├─ build.gradle ← JWT 의존성 추가 ├─ settings.gradle │ ├─ src/main/java/com/example/ │ ├─ Application.java │ │ │ ├─ config/ ← 신규 패키지 │ │ ├─ CorsConfig.java ← 신규 (CORS 설정) │ │ └─ FilterConfig.java ← 신규 (JwtFilter 수동 등록) │ │ │ ├─ security/ ← 신규 패키지 │ │ ├─ JwtUtil.java ← 신규 (토큰 생성/검증) │ │ └─ JwtFilter.java ← 신규 (요청마다 토큰 확인) │ │ │ ├─ entity/ │ │ ├─ User.java │ │ └─ Board.java │ │ │ ├─ dto/ │ │ ├─ UserDto.java │ │ └─ BoardDto.java ← 변경 (필드 대폭 추가) │ │ │ ├─ repository/ │ │ ├─ UserRepository.java │ │ └─ BoardRepository.java │ │ │ ├─ service/ │ │ ├─ UserService.java ← 변경 (login() → JWT 반환) │ │ └─ BoardService.java ← 변경 (Page<Board> → Page<BoardDto>) │ │ │ └─ controller/ │ ├─ UserController.java ← 변경 (@RestController, JWT 반환) │ └─ BoardController.java ← 변경 (@RestController, ResponseEntity) │ ├─ src/main/resources/ │ ├─ application.properties ← JWT 시크릿키 추가 │ └─ templates/ ← 전체 삭제 (Thymeleaf 제거) │ ├─ user/login.html ← 삭제 │ ├─ user/register.html ← 삭제 │ └─ board/ (list/detail/write/edit) ← 삭제 │ └─ frontend/ (React 프로젝트 별도) ← 신규 └─ src/ ├─ api.js ← 신규 (axios 인스턴스 + interceptor) ├─ App.jsx ← 신규 (라우팅) ├─ Login.jsx ← 신규 ├─ Register.jsx ← 신규 ├─ BoardList.jsx ← 신규 └─ BoardDetail.jsx ← 신규
🔄 1차 → 2차 변경 포인트
무엇이 왜 바뀌었나
CHANGE 01
Thymeleaf 제거 → React 추가
서버에서 HTML을 만들던 방식에서, 브라우저(React)가 직접 화면을 그리는 방식으로 전환. 백엔드는 JSON만 반환하면 됨.
CHANGE 02
HttpSession → JWT 토큰
세션은 서버 메모리를 쓰고 스케일아웃이 어려움. JWT는 토큰 자체에 정보가 담겨있어 서버가 상태를 저장하지 않음 (Stateless).
CHANGE 03
@Controller → @RestController
@Controller는 뷰 이름(String)을 반환. @RestController는 객체를 JSON으로 자동 변환하여 반환. @ResponseBody가 자동 포함됨.
CHANGE 04
BoardService 반환 타입 변경
Page<Board> → Page<BoardDto>. React는 JSON이 필요하므로 Entity 대신 DTO로 변환하여 반환.
⚙️ 환경설정
build.gradle · application.properties — 1차와 달라진 부분 표시
build.gradleGradle ★ 변경사항 표시
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"
}
application.properties
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시간 (밀리초)
🔑 JWT 유틸리티
JwtUtil.java · JwtFilter.java · CorsConfig.java
JwtUtil.javaJava
@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();
    }
}
JwtFilter.javaJava
@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);
    }
}
CorsConfig.javaJava
@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;
    }
}
📦 BoardDto — 2차 확장
React가 필요로 하는 모든 필드 추가
BoardDto.javaJava
@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 — 2차 변경
UserService · BoardService — 변경된 부분만 표시
UserService.javaJava login() 반환타입 변경
@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());
    }
}
BoardService.javaJava toDto() 추가, 반환타입 변경
@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차와 동일
}
🎮 Controller — 2차 변경
@RestController + ResponseEntity + @RequestBody
UserController.javaJava
@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()));
        }
    }
}
⚛️ React 프론트엔드
api.js · App.jsx · Login.jsx · BoardDetail.jsx
api.jsJavaScript
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;
App.jsxJSX
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>
    );
}
Login.jsxJSX
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);
        }
    };
}
BoardDetail.jsxJSX
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일 때 수정/삭제 버튼 표시
}
2차 → 3차 핵심 변화 포인트
① spring-security-crypto → spring-boot-starter-security (Security 전체 도입)
② JwtFilter(수동) → JwtAuthenticationFilter (Security FilterChain에 통합)
③ request.getAttribute("loginUser") → @AuthenticationPrincipal (Spring이 자동 주입)
④ User.role 필드 추가 → ROLE_USER / ROLE_ADMIN 권한 분리
⑤ AdminController 신규 추가
3차 프로젝트
3차 — Spring Security + 관리자
Spring Security FilterChain · UserDetails · @AuthenticationPrincipal · 권한 분리
📋 프로젝트 개요
  • Spring Security 전체 도입 — FilterChain이 모든 요청을 가로채서 인증/인가 처리
  • CustomUserDetails로 User Entity를 UserDetails로 감싸서 Spring Security와 연결
  • JwtAuthenticationFilter가 SecurityContext에 인증 객체 저장 → @AuthenticationPrincipal로 꺼냄
  • ROLE_USER / ROLE_ADMIN 권한 분리 → /api/admin/** 관리자만 접근 가능
  • AdminController 신규 추가 — 유저/게시글 전체 조회 및 삭제
Spring Security JWT React ROLE_USER / ROLE_ADMIN SecurityFilterChain
📁 프로젝트 파일 구조
2차 대비 변경 사항 — 초록=신규추가 · 빨강=삭제 · 노랑=변경
project-root/ ├─ build.gradle ← spring-security-crypto 제거 → spring-boot-starter-security 추가 ├─ settings.gradle │ ├─ src/main/java/com/example/ │ ├─ Application.java │ │ │ ├─ config/ │ │ ├─ CorsConfig.java ← 삭제 (SecurityConfig로 통합) │ │ └─ FilterConfig.java ← 삭제 (SecurityConfig가 필터 관리) │ │ └─ SecurityConfig.java ← 신규 (CORS + FilterChain + 권한 설정 통합) │ │ │ ├─ security/ ← 파일 변경 │ │ ├─ JwtUtil.java ← 변경 (role 파라미터 추가, getRole() 신규) │ │ ├─ JwtFilter.java ← 삭제 │ │ ├─ JwtAuthenticationFilter.java ← 신규 (SecurityContext에 인증 객체 저장) │ │ ├─ CustomUserDetails.java ← 신규 (User를 UserDetails로 래핑) │ │ └─ CustomUserDetailsService.java ← 신규 (DB에서 유저 조회) │ │ │ ├─ entity/ │ │ ├─ User.java ← 변경 (role 필드 추가) │ │ └─ Board.java │ │ │ ├─ dto/ │ │ ├─ UserDto.java │ │ └─ BoardDto.java │ │ │ ├─ repository/ │ │ ├─ UserRepository.java │ │ └─ BoardRepository.java ← 변경 (deleteByUser() 추가) │ │ │ ├─ service/ │ │ ├─ UserService.java │ │ ├─ BoardService.java │ │ └─ AdminService.java ← 신규 │ │ │ └─ controller/ │ ├─ UserController.java ← 변경 (request.getAttribute() → @AuthenticationPrincipal) │ ├─ BoardController.java ← 변경 (@AuthenticationPrincipal 적용) │ └─ AdminController.java ← 신규 (관리자 전용 API) │ ├─ src/main/resources/ │ └─ application.properties ← 관리자 코드 추가 │ └─ frontend/ (React) └─ src/ ├─ api.js ← 변경 (role 저장) ├─ App.jsx ← 변경 (AdminRoute 추가) ├─ Login.jsx ← 변경 (role localStorage 저장) ├─ Register.jsx ← 변경 (adminCode 입력 추가) ├─ BoardList.jsx ← 변경 (관리자 삭제 버튼 조건부 표시) ├─ BoardDetail.jsx └─ AdminPage.jsx ← 신규 (관리자 전용 페이지)
⚙️ 환경설정
build.gradle · application.properties
build.gradleGradle
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"
}
application.properties
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
🗃 User Entity — role 추가
ROLE_USER / ROLE_ADMIN 권한 분리를 위한 role 필드
User.javaJava
@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(); }
}
🛡 Spring Security 핵심 파일
CustomUserDetails · CustomUserDetailsService · JwtAuthenticationFilter · SecurityConfig
CustomUserDetails.javaJava
@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; }
}
CustomUserDetailsService.javaJava
@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로 감싸서 반환
    }
}
JwtAuthenticationFilter.javaJava
@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);
    }
}
SecurityConfig.javaJava
@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;
    }
}
🔑 JwtUtil — role 추가
createToken()에 role 파라미터 추가, getRole() 신규
JwtUtil.javaJava 변경부분만 표시
// 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);
}
🎮 Controller — @AuthenticationPrincipal
request.getAttribute() 제거 → @AuthenticationPrincipal 자동 주입 + AdminController 추가
BoardController.javaJava
@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", "삭제 완료"));
    }
}
AdminController.javaJava 신규 추가
@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", "게시글 삭제 완료"));
    }
}
AdminService.javaJava
@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);
    }
}
🗄 BoardRepository — deleteByUser 추가
BoardRepository.javaJava
public interface BoardRepository extends JpaRepository<Board, Long> {
    Page<Board> findAllByOrderByCreatedAtDesc(Pageable pageable);
    void deleteByUser(User user); // 추가! — 유저 삭제 시 게시글 먼저 삭제
}
⚛️ React — 관리자 기능 추가
AdminRoute · AdminPage · 로그인 시 role 저장
App.jsxJSX AdminRoute 추가
// 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>} />
Login.jsxJSX role 저장 추가
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");
};
AdminPage.jsxJSX
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로 유저 목록 / 게시글 목록 전환
}
3차 심화
3차 심화 — AOP · 예외처리 · @Transactional · @Builder
3차 코드를 실무 패턴으로 업그레이드 — feature/advanced 브랜치
📋 심화과정 개요
  • STEP 1 — AOP 로깅: LogAspect.java 하나로 모든 Service 메서드에 자동 로그+실행시간 측정
  • STEP 2 — Java 컬렉션: 이론 정리 (코드 변경 없음)
  • STEP 3 — 예외처리 심화: CustomException + GlobalExceptionHandler → Controller try/catch 전부 제거
  • STEP 4 — @Transactional 심화: readOnly 적용, 더티체킹 완전 이해
  • STEP 5 — @Builder 패턴: toDto() 메서드 Builder 방식으로 변경
AOP (@Aspect) @ControllerAdvice @Transactional @Builder (Lombok) spring-boot-starter-aop
📁 3차 심화 최종 파일 구조
3차 대비 변경 사항 — 초록=신규추가 · 노랑=변경 · 흰색=변경없음
project-root/ ├─ build.gradle ← STEP 1 AOP 의존성 추가 ├─ settings.gradle │ ├─ src/main/java/com/example/ │ ├─ Application.java │ │ │ ├─ aspect/ ← 신규 패키지 (STEP 1) │ │ └─ LogAspect.java ← 신규 · @Aspect @Around로 모든 Service 메서드 실행시간 로깅 │ │ │ ├─ exception/ ← 신규 패키지 (STEP 3) │ │ ├─ CustomException.java ← 신규 · RuntimeException 상속, ErrorCode enum 포함 │ │ └─ GlobalExceptionHandler.java ← 신규 · @RestControllerAdvice, CustomException 일괄 처리 │ │ │ ├─ config/ │ │ └─ SecurityConfig.java ← 변경 없음 │ │ │ ├─ security/ │ │ ├─ JwtUtil.java ← 변경 없음 │ │ ├─ JwtAuthenticationFilter.java ← 변경 없음 │ │ ├─ CustomUserDetails.java ← 변경 없음 │ │ └─ CustomUserDetailsService.java ← 변경 없음 │ │ │ ├─ entity/ │ │ ├─ User.java ← 변경 없음 │ │ └─ Board.java ← 변경 없음 │ │ │ ├─ repository/ │ │ ├─ UserRepository.java ← 변경 없음 │ │ └─ BoardRepository.java ← 변경 없음 │ │ │ ├─ dto/ │ │ ├─ UserDto.java ← 변경 없음 │ │ └─ BoardDto.java ← STEP 5 · @Builder 적용, toDto() 빌더 방식으로 변경 │ │ │ ├─ service/ │ │ ├─ UserService.java ← STEP 4 · 쓰기 메서드에 @Transactional 추가 │ │ ├─ BoardService.java ← STEP 3 · CustomException 적용 / STEP 4 · 조회에 readOnly=true │ │ └─ AdminService.java ← 변경 없음 │ │ │ └─ controller/ │ ├─ UserController.java ← STEP 3 · try/catch 전부 제거 (GlobalExceptionHandler가 처리) │ ├─ BoardController.java ← STEP 3 · try/catch 전부 제거 │ └─ AdminController.java ← STEP 3 · try/catch 전부 제거 │ └─ frontend/ (React) └─ src/ ├─ api.js ← 변경 없음 ├─ App.jsx ← 변경 없음 ├─ Login.jsx ← 변경 없음 ├─ Register.jsx ← 변경 없음 ├─ BoardList.jsx ← 변경 없음 ├─ BoardDetail.jsx ← 변경 없음 └─ AdminPage.jsx ← 변경 없음
STEP 1
AOP 로깅 — LogAspect.java
build.gradle에 AOP 의존성 추가 + aspect/ 패키지 신규 생성
build.gradleGradle 추가
implementation 'org.springframework.boot:spring-boot-starter-aop'
aspect/LogAspect.javaJava 신규 파일
@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;
        }
    }
}
적용 결과: BoardService, UserService, AdminService의 모든 메서드에 LogAspect.java 하나만으로 자동 로그 적용. 각 Service 코드는 전혀 건드릴 필요 없음.
STEP 3
예외처리 심화 — CustomException + GlobalExceptionHandler
Controller의 모든 try/catch 제거 → 전역 예외 처리기가 일괄 처리
exception/CustomException.javaJava 신규
@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);
    }
}
exception/GlobalExceptionHandler.javaJava 신규
@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 → AFTER
BoardService — IllegalArgumentException → CustomException
기존 코드의 throw new IllegalArgumentException(...)을 전부 CustomException으로 교체
BoardService.javaJava 예외 교체
// 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("삭제 권한이 없습니다.");
Controller try/catch 완전 제거: GlobalExceptionHandler가 있으므로 Controller에서는 예외를 잡지 않아도 됨. boardService.write(...)만 호출하면 예외는 핸들러가 알아서 처리.
STEP 4
@Transactional 심화 — readOnly 적용
조회 메서드에 readOnly = true → 더티체킹 비활성화 + 성능 향상
BoardService.javaJava @Transactional 적용
@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) { ... }
}
STEP 5
@Builder 패턴 — toDto() 변경
BoardDto에 @Builder 적용 → toDto()를 빌더 방식으로 변경
BoardDto.javaJava @Builder 추가
@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;
}
Before — Setter 방식
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(...);
return dto;
After — Builder 방식
return BoardDto.builder()
.id(board.getId())
.title(board.getTitle())
.content(board.getContent())
.viewCount(board.getViewCount())
.nickname(board.getUser().getNickname())
.username(board.getUser().getUsername())
.userId(board.getUser().getId())
.createdAt(...)
.build();
📚 전체 맵 🗺 로드맵