서버가 HTML을 직접 렌더링하고, 세션으로 로그인 상태를 유지하는 전통적인 방식
1차 → 2차 → 3차 프로젝트는 단순히 기능을 추가하는 게 아니다. 인증 방식과 아키텍처가 단계적으로 진화하는 과정이다. 1차의 불편함을 직접 경험해야 2차·3차가 왜 그렇게 바뀌었는지 이해된다.
브라우저가 URL을 요청하면 Spring이 직접 HTML 파일을 만들어서 돌려준다. 로그인하면 서버 메모리에 세션을 저장하고, 이후 요청마다 세션을 확인해서 누가 접속했는지 알아낸다. 프론트엔드와 백엔드가 하나의 서버 안에 있는 구조다.
| 항목 | 내용 |
|---|---|
| 인증 방식 | HttpSession — 서버 메모리에 로그인 정보 저장 |
| 렌더링 | 서버사이드 렌더링 (SSR) — Thymeleaf가 HTML 생성 |
| 컨트롤러 | @Controller — String(뷰 이름) 반환 |
| 데이터 전달 | Model.addAttribute() → Thymeleaf 템플릿에서 th:text로 출력 |
| 포트 | 8081 (백엔드 + 프론트 통합) |
| 브랜치 | master |
코드를 보기 전에 이 프로젝트가 어떤 방식으로 동작하는지 먼저 파악하자. DB 테이블 생성 방식과 인증 방식 두 가지가 핵심이다.
이 프로젝트는 schema.sql을 직접 작성하지 않는다. JPA가 Entity 클래스를 읽고 DB 테이블을 자동으로 만들어준다.
User.java와 Board.java에 정의된 필드가 곧 테이블 컬럼이 된다. 서버를 처음 실행하면 테이블이 없어도 자동 생성되고, 이후 실행에서는 변경분만 반영된다.
1. build.gradle — spring-boot-starter-data-jpa 의존성이 추가되어 있다. JPA를 사용한다는 선언이다.
2. Entity 클래스 — User.java, Board.java에 @Entity, @Table 어노테이션이 붙어 있다. JPA가 이 클래스를 보고 테이블 구조를 파악한다. Repository도 JpaRepository를 상속하고 있어 SQL 없이 DB 접근이 가능하다.
3. application.properties — spring.jpa.hibernate.ddl-auto=update 설정이 있다. 이 한 줄 덕분에 서버 시작 시 Entity를 기반으로 테이블이 자동 생성·업데이트된다.
순서로 보면:
서버 시작
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에서 로그인 인증은 Spring Security가 담당한다. 하지만 이 프로젝트는 Spring Security를 사용하지 않는다.
대신 HttpSession을 Controller에서 직접 다룬다. 로그인 성공 시 세션에 User 객체를 직접 저장하고, 이후 모든 요청마다 Controller가 세션을 직접 꺼내서 로그인 여부를 확인한다.
세션 인증 체크 코드(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 → 클라이언트 저장 |
| 동작 | 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} | 세션 체크 → 권한 확인 → DELETE | boardRepository.delete(board) |
모든 Controller 메서드마다 session.getAttribute("loginUser")로 로그인을 직접 확인해야 한다. 메서드가 10개면 10번 같은 코드를 반복해야 한다. 이 반복을 없애기 위해 2차에서 JWT + Filter 방식으로 전환한다.
왼쪽은 실제 파일 목록 전체, 오른쪽은 각 계층이 하는 일.
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, 이미지)
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" }
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(); this.updatedAt = 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); }
@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를 직접 써도 되니까 }
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 submit | axios JSON 전송 |
| Content-Type | application/x-www-form-urlencoded | application/json |
| Spring 처리 | 필드명 매칭 → Setter 자동 호출 | JSON → 객체 역직렬화 |
| 사용 시점 | Thymeleaf 기반 프로젝트 | REST API 프로젝트 |
UserService에 private final BCryptPasswordEncoder bCryptPasswordEncoder; 가 있다.
@RequiredArgsConstructor가 생성자 주입을 자동으로 만들어주는데,
Spring 컨테이너에 BCryptPasswordEncoder Bean이 등록되어 있어야 주입이 가능하다.
1차 프로젝트는 spring-security-crypto 라이브러리만 추가했으므로 (Security 전체 아님)
BCryptPasswordEncoder를 직접 @Bean으로 등록해야 한다.
@Configuration // 이 클래스가 설정 파일임을 Spring에 알림 public class AppConfig { @Bean // Spring 컨테이너에 BCryptPasswordEncoder 객체를 등록 public BCryptPasswordEncoder bCryptPasswordEncoder() { return new BCryptPasswordEncoder(); } // 이렇게 등록해야 UserService에서 @RequiredArgsConstructor로 주입받을 수 있다 // 3차에서는 SecurityConfig 안에 @Bean으로 옮겨진다 }
① AppConfig.java — @Bean 으로 BCryptPasswordEncoder 등록
② Spring 컨테이너 — 서버 시작 시 BCryptPasswordEncoder 객체 생성해서 보관
③ UserService — @RequiredArgsConstructor 가 생성자 주입 코드 자동 생성
④ 주입 완료 — UserService 안에서 bCryptPasswordEncoder.encode(), matches() 사용 가능
3차에서는? Spring Security 전체를 도입하면서 SecurityConfig 안에 @Bean으로 옮겨진다.
@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 @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 @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 @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"; } }
<!-- 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>
<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>
<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>
<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>
<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>
<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>
위 템플릿 코드에 나온 th:* 속성들이 뭔지 모르면 코드를 읽기 어렵다. Java 초급 기준으로 핵심만 정리한다.
일반 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)} |
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) |