웹/통신 기초 2/2📚 전체 맵2차 프로젝트
1차 프로젝트 · Thymeleaf + Session

01. SESSION AUTH

서버가 HTML을 직접 렌더링하고, 세션으로 로그인 상태를 유지하는 전통적인 방식

이 파일의 학습 목적
왜 1차 프로젝트를 먼저 배우나?

1차 → 2차 → 3차 프로젝트는 단순히 기능을 추가하는 게 아니다. 인증 방식과 아키텍처가 단계적으로 진화하는 과정이다. 1차의 불편함을 직접 경험해야 2차·3차가 왜 그렇게 바뀌었는지 이해된다.

📍 전체 프로젝트 진화 흐름 — 지금 여기서 시작
📌 지금 여기 · 1차
Thymeleaf + Session
서버가 HTML 직접 생성
HttpSession으로 로그인
Controller마다 인증 반복
→ 반복 코드가 문제
2차
React + JWT
React 분리 (프론트/백)
JWT 토큰 인증
JwtFilter 인증 위임
→ 반복 코드 해결
3차
Spring Security
Security FilterChain
ROLE 기반 권한 분리
@AuthenticationPrincipal
→ 보안 체계화
3차 심화
AOP + 리팩토링
AOP 로깅 자동화
전역 예외처리
@Transactional 최적화
@Builder 패턴
→ 코드 품질 향상
이후 선택적으로 진행 가능
4차 — Docker + AWS 배포
Dockerfile 작성 · docker-compose 구성
EC2 배포 · RDS 연결 · 실제 서비스 운영
5차 — 고도화
파일 업로드 · 댓글 기능
검색(QueryDSL) · 알림 기능
✅ 3차 심화까지 완성하면 실무 수준의 기본기 완성!
1차에서 배울 것
@Controller · Thymeleaf
HttpSession 직접 관리
@ModelAttribute 바인딩
Page<Board> 반환
더티체킹으로 UPDATE
2차에서 바뀌는 것
@RestController 교체
JWT 토큰 발급/검증
@RequestBody 교체
Page<BoardDto> 반환
JwtFilter 인증 위임
3차에서 추가되는 것
SecurityConfig · FilterChain
CustomUserDetails
@AuthenticationPrincipal
hasRole("ADMIN")
AdminController
3차 심화에서 추가
AOP 로깅 자동화
전역 예외처리
@Transactional readOnly
@Builder 패턴
try/catch 전부 제거
Overview
1차 프로젝트 핵심 개념

브라우저가 URL을 요청하면 Spring이 직접 HTML 파일을 만들어서 돌려준다. 로그인하면 서버 메모리에 세션을 저장하고, 이후 요청마다 세션을 확인해서 누가 접속했는지 알아낸다. 프론트엔드와 백엔드가 하나의 서버 안에 있는 구조다.

항목내용
인증 방식HttpSession — 서버 메모리에 로그인 정보 저장
렌더링서버사이드 렌더링 (SSR) — Thymeleaf가 HTML 생성
컨트롤러@Controller — String(뷰 이름) 반환
데이터 전달Model.addAttribute() → Thymeleaf 템플릿에서 th:text로 출력
포트8081 (백엔드 + 프론트 통합)
브랜치master
Before You Start
작업 전 반드시 알아야 할 것

코드를 보기 전에 이 프로젝트가 어떤 방식으로 동작하는지 먼저 파악하자. DB 테이블 생성 방식과 인증 방식 두 가지가 핵심이다.

① DB 테이블 — JPA 자동 생성 방식

이 프로젝트는 schema.sql을 직접 작성하지 않는다. JPA가 Entity 클래스를 읽고 DB 테이블을 자동으로 만들어준다. User.javaBoard.java에 정의된 필드가 곧 테이블 컬럼이 된다. 서버를 처음 실행하면 테이블이 없어도 자동 생성되고, 이후 실행에서는 변경분만 반영된다.

📋 근거 — 이 3가지가 있기 때문에 자동 생성된다

1. build.gradlespring-boot-starter-data-jpa 의존성이 추가되어 있다. JPA를 사용한다는 선언이다.

2. Entity 클래스User.java, Board.java@Entity, @Table 어노테이션이 붙어 있다. JPA가 이 클래스를 보고 테이블 구조를 파악한다. Repository도 JpaRepository를 상속하고 있어 SQL 없이 DB 접근이 가능하다.

3. application.propertiesspring.jpa.hibernate.ddl-auto=update 설정이 있다. 이 한 줄 덕분에 서버 시작 시 Entity를 기반으로 테이블이 자동 생성·업데이트된다.

ddl-auto=update 설정이 있어 서버 시작할 때 JPA가 자동으로 실행

순서로 보면:
서버 시작
1. JPA가 User.java, Board.java 의 @Entity 클래스들을 스캔
2. MariaDB에 테이블이 없으면 → 자동 CREATE TABLE
3. 테이블이 이미 있으면 → 변경된 필드만 자동 ALTER TABLE

여기에 작성된 테이블 구조는 "실제로 이렇게 만들어진다"는 참고용이며, 직접 실행할 필요가 없다.
단 한 가지만 직접 해줘야 한다 — DB 자체(boarddb)는 JPA가 만들 수 없으므로 MariaDB에서 CREATE DATABASE boarddb; 를 실행한다면, 나머지 테이블은 전부 자동 생성된다.

🗄️ 생성되는 테이블 구조

users 테이블 (@Table(name="users"))
id (PK, AUTO_INCREMENT) · username (UNIQUE, NOT NULL) · password (NOT NULL) · nickname (UNIQUE, NOT NULL) · created_at

board 테이블 (@Table(name="board"))
id (PK, AUTO_INCREMENT) · title (NOT NULL) · content (TEXT, NOT NULL) · view_count · created_at · updated_at · user_id (FK → users.id)

② 인증 — Spring Security 없이 세션 직접 관리

일반적으로 Spring에서 로그인 인증은 Spring Security가 담당한다. 하지만 이 프로젝트는 Spring Security를 사용하지 않는다. 대신 HttpSession을 Controller에서 직접 다룬다. 로그인 성공 시 세션에 User 객체를 직접 저장하고, 이후 모든 요청마다 Controller가 세션을 직접 꺼내서 로그인 여부를 확인한다.

⚠️ 이 방식의 단점 — 2차로 넘어가는 이유

세션 인증 체크 코드(session.getAttribute("loginUser"))를 BoardController의 모든 메서드마다 반복 작성해야 한다. 메서드가 10개면 같은 코드가 10번 등장한다. 2차에서는 이 반복을 없애기 위해 JWT + Filter 방식으로 전환하고, Spring Security도 도입한다.

구분1차 (지금)2차 (다음)
인증HttpSession 직접 관리JWT + Spring Security Filter
테이블 생성JPA ddl-auto=update 자동 생성동일
인증 체크 위치Controller 메서드마다 직접Filter가 일괄 처리
로그인 반환값User 객체 → 세션 저장JWT String → 클라이언트 저장
Flow — Login
로그인 전체 흐름
로그인 요청이 처리되는 순서
Browser
POST /user/login 요청
HTML form 제출 → username, password 전송
user/login.html method="post"
Controller
UserController.login()
@ModelAttribute로 form 데이터 받음 → userService.login() 호출
@PostMapping("/login") @ModelAttribute UserDto HttpSession session
Service
UserService.login()
DB에서 유저 조회 → 비밀번호 일치 확인 → User 객체 반환
userRepository.findByUsername() bCryptPasswordEncoder.matches()
Session
세션에 유저 저장
서버 메모리에 로그인 정보 저장 → 브라우저에 세션 쿠키(JSESSIONID) 발급
session.setAttribute("loginUser", user)
Redirect
redirect:/board/list 로 이동
로그인 성공 → 게시판 목록 페이지로 리다이렉트
return "redirect:/board/list"
게시글 작성 전체 흐름
로그인 확인 → 작성 → 저장 → 리다이렉트
Browser
GET /board/write 요청 (작성폼)
글쓰기 버튼 클릭 → 작성 폼 페이지 요청
인증체크
세션에서 loginUser 꺼내기
null이면 로그인 페이지로 강제 이동 — 모든 메서드에서 직접 체크!
session.getAttribute("loginUser") 1차의 문제점
Browser
Thymeleaf가 board/write.html 렌더링
서버가 HTML을 완성해서 브라우저로 전송
return "board/write" Thymeleaf 템플릿
Browser
POST /board/write 제출
제목, 내용 입력 후 등록 버튼 → form submit
@ModelAttribute BoardDto
Service
BoardService.write()
Board 객체 생성 → user 연결 → DB 저장
boardRepository.save(board)
Redirect
redirect:/board/list
목록 페이지로 이동
return "redirect:/board/list"
수정 · 삭제 흐름 요약
동작URL핵심 로직핵심 코드
목록 조회GET /board/list세션 체크 → PageRequest → Thymeleaf 렌더링boardService.getBoardList(page)
상세 조회GET /board/detail/{id}세션 체크 → 조회수 증가 → 본인 글 여부 Model에 담기boardService.getBoard(id, loginUser)
수정 폼GET /board/edit/{id}세션 체크 → 기존 내용 Model에 담기model.addAttribute("board", ...)
수정 처리POST /board/edit/{id}세션 체크 → 권한 확인 → 더티체킹으로 UPDATE@Transactional + board.setTitle()
삭제POST /board/delete/{id}세션 체크 → 권한 확인 → DELETEboardRepository.delete(board)
⚠️ 1차의 핵심 문제 — 2차로 넘어가는 이유

모든 Controller 메서드마다 session.getAttribute("loginUser")로 로그인을 직접 확인해야 한다. 메서드가 10개면 10번 같은 코드를 반복해야 한다. 이 반복을 없애기 위해 2차에서 JWT + Filter 방식으로 전환한다.

Project Structure
파일 구조 & 계층별 역할

왼쪽은 실제 파일 목록 전체, 오른쪽은 각 계층이 하는 일.

📁 전체 파일 목록
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
   │
   └─ static/
      └─ (css, js, 이미지)
🔍 계층별 역할
Config
build.gradle · application.properties
의존성 관리, DB 연결·JPA 설정, 포트 8081 지정
Entity
User.java · Board.java
DB 테이블과 1:1 매핑. @PrePersist 로 생성·수정 시각 자동 기록. Board는 @ManyToOne 으로 User 참조
DTO
UserDto.java · BoardDto.java
폼 데이터 전용 수신 객체. @ModelAttribute 로 자동 바인딩. Entity를 직접 노출하지 않음
Repository
UserRepository · BoardRepository
JPA 인터페이스. SQL 없이 DB 접근. findByUsername() existsByNickname() findAllByOrderByCreatedAtDesc()
Service
UserService · BoardService
핵심 비즈니스 로직. BCrypt 암호화·검증, 게시글 CRUD. @Transactional + 더티체킹으로 UPDATE. 로그인 성공 시 User 객체 반환 (2차에서는 JWT String)
Controller
UserController · BoardController
URL 요청 수신, 세션에서 loginUser 직접 꺼내 인증 체크 (메서드마다 반복). Model에 데이터 담아 Thymeleaf로 전달
Templates
user/ · board/ HTML 6개
서버가 HTML을 완성해서 브라우저로 전송 (SSR). th:text th:each th:if th:action 으로 데이터 출력·폼 처리
Full Code
전체 코드
build.gradle
build.gradle
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만
    developmentOnly "org.springframework.boot:spring-boot-devtools"
    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
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
entity/User.java User codeReview
@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/Board.java Board codeReview
@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();
        this.updatedAt = LocalDateTime.now();
    }
    @PreUpdate
    public void preUpdate() {
        this.updatedAt = LocalDateTime.now();
    }
}
Repository
repository/UserRepository.java User codeReview
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
    boolean existsByUsername(String username);
    boolean existsByNickname(String nickname);
}
repository/BoardRepository.java User codeReview
public interface BoardRepository extends JpaRepository<Board, Long> {
    Page<Board> findAllByOrderByCreatedAtDesc(Pageable pageable);
}
DTO
dto/UserDto.java User codeReview
@Getter @Setter
public class UserDto {
    private String username;
    private String password;
    private String nickname;
}
dto/BoardDto.java User codeReview
@Getter @Setter
public class BoardDto {
    private String title;
    private String content;
    // 1차는 필드 최소화
    // Thymeleaf가 Entity를 직접 써도 되니까
}
잠깐 — @ModelAttribute 가 뭔가요?
💡 @ModelAttribute — HTML form 데이터를 객체에 자동으로 넣어주는 어노테이션

HTML form에서 name="username", name="password" 로 데이터를 보내면,
Spring이 필드명이 일치하는 객체(UserDto)를 자동으로 만들고 값을 채워준다.

예를 들어 <input name="username"> 로 "hong" 을 보내면
→ Spring이 new UserDto() 생성 → userDto.setUsername("hong") 자동 호출
→ Controller 파라미터로 이미 값이 채워진 UserDto가 들어옴

주의: @ModelAttribute는 HTML form 전송 방식(application/x-www-form-urlencoded)에서만 동작한다.
2차에서 React + axios로 JSON을 보낼 때는 @RequestBody로 바꿔야 한다.

구분@ModelAttribute (1차)@RequestBody (2차~)
전송 방식HTML form submitaxios JSON 전송
Content-Typeapplication/x-www-form-urlencodedapplication/json
Spring 처리필드명 매칭 → Setter 자동 호출JSON → 객체 역직렬화
사용 시점Thymeleaf 기반 프로젝트REST API 프로젝트
BCryptPasswordEncoder — Bean 등록은 어디서?
⚠️ UserService에서 BCryptPasswordEncoder를 주입받는데, 어디서 Bean으로 등록하나?

UserServiceprivate final BCryptPasswordEncoder bCryptPasswordEncoder; 가 있다.
@RequiredArgsConstructor가 생성자 주입을 자동으로 만들어주는데,
Spring 컨테이너에 BCryptPasswordEncoder Bean이 등록되어 있어야 주입이 가능하다.

1차 프로젝트는 spring-security-crypto 라이브러리만 추가했으므로 (Security 전체 아님)
BCryptPasswordEncoder를 직접 @Bean으로 등록해야 한다.

config/AppConfig.java — BCryptPasswordEncoder Bean 등록 User codeReview
@Configuration  // 이 클래스가 설정 파일임을 Spring에 알림
public class AppConfig {

    @Bean  // Spring 컨테이너에 BCryptPasswordEncoder 객체를 등록
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
    // 이렇게 등록해야 UserService에서 @RequiredArgsConstructor로 주입받을 수 있다
    // 3차에서는 SecurityConfig 안에 @Bean으로 옮겨진다
}
📌 Bean 등록 흐름 한눈에

① AppConfig.java@Bean 으로 BCryptPasswordEncoder 등록
② Spring 컨테이너 — 서버 시작 시 BCryptPasswordEncoder 객체 생성해서 보관
③ UserService@RequiredArgsConstructor 가 생성자 주입 코드 자동 생성
④ 주입 완료 — UserService 안에서 bCryptPasswordEncoder.encode(), matches() 사용 가능

3차에서는? Spring Security 전체를 도입하면서 SecurityConfig 안에 @Bean으로 옮겨진다.

Service
service/UserService.java User codeReview
@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);
    }

    // 반환: 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; // 2차에서는 JWT 토큰(String)을 반환하게 바뀜
    }
}
service/BoardService.java User codeReview
@Service @RequiredArgsConstructor
public class BoardService {
    private final BoardRepository boardRepository;

    // 반환: Page<Board> — Entity 그대로 반환 (Thymeleaf는 Entity 직접 사용 가능)
    // 2차에서는 Page<BoardDto>로 바뀜 (React는 JSON 필요)
    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("없는 게시글입니다."));
        if (loginUser == null || !board.getUser().getId().equals(loginUser.getId()))
            board.setViewCount(board.getViewCount() + 1); // 본인 글 제외 조회수 증가
        boardRepository.save(board);
        return board;
    }

    public void write(BoardDto boardDto, User user) {
        Board board = new Board();
        board.setTitle(boardDto.getTitle());
        board.setContent(boardDto.getContent());
        board.setUser(user);
        boardRepository.save(board);
    }

    @Transactional
    public void update(Long id, BoardDto boardDto, User user) {
        Board board = boardRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("없는 게시글입니다."));
        if (!board.getUser().getId().equals(user.getId()))
            throw new IllegalArgumentException("수정 권한이 없습니다.");
        board.setTitle(boardDto.getTitle()); // 더티체킹으로 자동 UPDATE
        board.setContent(boardDto.getContent());
    }

    @Transactional
    public void delete(Long id, User user) {
        Board board = boardRepository.findById(id)
            .orElseThrow(() -> new IllegalArgumentException("없는 게시글입니다."));
        if (!board.getUser().getId().equals(user.getId()))
            throw new IllegalArgumentException("삭제 권한이 없습니다.");
        boardRepository.delete(board);
    }
}
Controller   
@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(@ModelAttribute UserDto userDto,
                        HttpSession session, RedirectAttributes ra) {
        try {
            User user = userService.login(userDto.getUsername(), userDto.getPassword());
            session.setAttribute("loginUser", 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/BoardController.java
codeReview
@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("currentPage", page);
        return "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";
    }

    @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("/edit/{id}")
    public String editForm(@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));
        return "board/edit";
    }

    @PostMapping("/edit/{id}")
    public String edit(@PathVariable Long id, @ModelAttribute BoardDto boardDto,
                       HttpSession session, RedirectAttributes ra) {
        User loginUser = (User) session.getAttribute("loginUser");
        if (loginUser == null) return "redirect:/user/login";
        try {
            boardService.update(id, boardDto, loginUser);
            return "redirect:/board/detail/" + id;
        } catch (IllegalArgumentException e) {
            ra.addFlashAttribute("error", e.getMessage());
            return "redirect:/board/edit/" + 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 HTML 템플릿 User codeReview
templates/user/login.html
<!-- xmlns:th 선언 필수 -->
<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>
templates/user/register.html
<html xmlns:th="http://www.thymeleaf.org">
<body>
  <h2>회원가입</h2>
  <p th:text="${error}" style="color:red"></p>
  <form th:action="@{/user/register}" method="post">
    <input type="text" name="username" placeholder="아이디">
    <input type="password" name="password" placeholder="비밀번호">
    <input type="text" name="nickname" placeholder="닉네임">
    <button type="submit">회원가입</button>
  </form>
</body></html>
templates/board/list.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><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>
      <td th:text="${board.createdAt}"></td>
    </tr>
  </table>
  <!-- 페이징 -->
  <a th:if="${currentPage > 0}"
     th:href="@{/board/list(page=${currentPage-1})}">이전</a>
  <span th:each="i : ${#numbers.sequence(0, boardList.totalPages-1)}">
    <a th:href="@{/board/list(page=${i})}" th:text="${i+1}"></a>
  </span>
  <a th:if="${currentPage < boardList.totalPages-1}"
     th:href="@{/board/list(page=${currentPage+1})}">다음</a>
</body></html>
templates/board/detail.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>
  <a th:href="@{/board/list}">목록</a>
</body></html>
templates/board/write.html
<html xmlns:th="http://www.thymeleaf.org">
<body>
  <h2>글쓰기</h2>
  <form th:action="@{/board/write}" method="post">
    <input type="text" name="title" placeholder="제목">
    <textarea name="content" placeholder="내용"></textarea>
    <button type="submit">등록</button>
  </form>
</body></html>
templates/board/edit.html
<html xmlns:th="http://www.thymeleaf.org">
<body>
  <h2>수정</h2>
  <form th:action="@{/board/edit/{id}(id=${board.id})}" method="post">
    <input type="text" name="title" th:value="${board.title}">
    <textarea name="content" th:text="${board.content}"></textarea>
    <button type="submit">수정완료</button>
  </form>
</body></html>
Thymeleaf 문법 요약
Thymeleaf — 자주 쓰는 문법 한눈에

위 템플릿 코드에 나온 th:* 속성들이 뭔지 모르면 코드를 읽기 어렵다. Java 초급 기준으로 핵심만 정리한다.

💡 Thymeleaf 기본 원리

일반 HTML에 th: 속성을 붙이면 서버가 HTML을 완성할 때 그 자리에 실제 데이터를 채워 넣는다.
브라우저에서 직접 열면 th: 속성이 무시되고 기본값만 보인다 → 서버를 통해야 데이터가 보인다.
xmlns:th="http://www.thymeleaf.org" 선언이 html 태그에 반드시 있어야 th: 속성이 동작한다.

문법설명예시
${...} Model에서 변수 값 꺼내기 th:text="${board.title}" → 게시글 제목 출력
th:text 태그 안 텍스트를 변수로 교체 <span th:text="${user.nickname}"></span>
th:each Java for-each 와 동일. 리스트 반복 출력 th:each="board : ${boardList.content}"
th:if 조건이 true일 때만 태그 출력 th:if="${board.user.id == loginUser.id}"
th:action form의 action URL 지정 (CSRF 토큰 자동 포함) th:action="@{/user/login}"
th:href a 태그의 href URL 지정 th:href="@{/board/detail/{id}(id=${board.id})}"
th:value input의 value 값 지정 (수정 폼에서 기존 값 채울 때) th:value="${board.title}"
@{...} URL 표현식. 경로 변수 삽입 가능 @{/board/detail/{id}(id=${board.id})}
th:block HTML 태그 없이 조건/반복 블록 묶기 <th:block th:if="..."> ... </th:block>
#numbers.sequence() 숫자 범위 생성 (페이징 버튼에 사용) ${#numbers.sequence(0, totalPages-1)}
Page<Board> vs Page<BoardDto> — 왜 2차에서 바뀌나?

1차 BoardService는 Page<Board>를 반환한다. Thymeleaf는 서버 안에서 돌아가니까 Entity를 직접 써도 된다. 하지만 2차 React는 JSON으로 데이터를 받아야 하므로 Entity 대신 DTO로 변환해야 한다.

구분1차 — Page<Board>2차~ — Page<BoardDto>
반환 대상Entity 그대로DTO로 변환 후 반환
사용처Thymeleaf (서버 내부)React (JSON 직렬화)
문제점@ManyToOne 연관 Entity가 같이 직렬화되면 순환참조 위험필요한 필드만 담아서 안전하게 전송
변환 코드없음 (Entity 직접 사용)boardService.toDto(board)
Next — 1차 → 2차 변경 포인트
2차에서 무엇이 왜 바뀌나
핵심 변경 포인트 5가지
@Controller → @RestController
Thymeleaf를 버리고 React를 도입. 서버는 HTML 대신 JSON만 반환하면 되므로 @RestController로 변경. String 반환 → ResponseEntity<?> 반환.
HttpSession → JWT 토큰
세션은 서버 메모리에 저장 → 서버가 여러 대면 세션 공유 문제 발생. JWT는 토큰 자체에 정보가 담겨 있어서 서버가 상태를 저장하지 않아도 됨(Stateless).
@ModelAttribute → @RequestBody
HTML form 제출 방식 → JSON 전송 방식. React는 axios로 JSON을 보내므로 @RequestBody로 받아야 함.
UserService.login() 반환 타입 변경: User → String(JWT)
세션에 User 객체를 저장하던 방식에서, JWT 토큰 문자열을 발급해서 클라이언트가 보관하는 방식으로 변경.
BoardService.getBoardList() 반환 타입: Page<Board> → Page<BoardDto>
Thymeleaf는 Entity를 직접 써도 됐지만, React로 JSON 반환할 때는 Entity 대신 DTO로 변환해야 함 (Entity 내부 구조 노출 방지 + 순환참조 방지).
웹/통신 기초 2/2📚 전체 맵2차 프로젝트