Spring으로 로그인 댓글 만들기
Google 로그인한 방문자가 노트에 댓글을 달고, 본인 댓글만 수정·삭제하고, 관리자는 모든 댓글을 지울 수 있게 만든 과정이다. 만들면서 JPA와 Spring Security에서 실제로 부딪힌 문제들을 함께 정리한다.
설계 결정
작성자는 이메일이 아니라 sub로 식별
Google ID 토큰에는 sub(subject)라는 계정 고유 번호가 들어 있다. 이메일은 사용자가 바꿀 수 있지만 sub는 계정이 있는 동안 바뀌지 않는다.
| 컬럼 | 용도 |
|---|---|
author_sub |
본인 확인 (화면에 안 보임) |
author_name |
화면 표시용 이름 |
Important
이메일은 저장하지 않는다. 저장하면 템플릿 실수나 백업 유출로 공개될 수 있다. 필요 없는 개인정보는 모으지 않는다(최소 수집). 관리자 판정은 로그인할 때 ID 토큰의 이메일을 그때그때 읽으면 된다.
댓글은 평문 (저장형 XSS 방지)
댓글은 한 사람의 입력이 여러 사람의 브라우저에서 보이는 곳이라 저장형 XSS의 대표 표적이다.
sequenceDiagram
participant A as 공격자
participant S as 서버
participant V as 다른 방문자
A->>S: 댓글 "<script>...</script>"
S->>S: DB 저장
V->>S: 노트 열기
S->>V: 댓글 포함 HTML
Note over V: 이스케이프 안 하면<br/>방문자 브라우저에서 실행
마크다운을 렌더링하지 않고 th:text로만 출력한다. <가 <로 바뀌어 글자로 보인다. 줄바꿈은 CSS로 살린다.
<p class="comment-body" th:text="${c.body}">본문</p>
.comment-body { white-space: pre-wrap; overflow-wrap: anywhere; }
권한
| 동작 | 비로그인 | 로그인 사용자 | 관리자 |
|---|---|---|---|
| 읽기 | O | O | O |
| 작성 | 로그인으로 이동 | O | O |
| 수정 | X | 본인 것만 | 본인 것만 |
| 삭제 | X | 본인 것만 | 전부 |
관리자도 남의 댓글을 고칠 수는 없다. 다른 사람이 한 말을 바꿀 수 없게. 삭제(스팸 정리)만 가능하다.
구현
테이블 (Flyway V3)
create table comment (
id bigserial primary key,
note_id bigint not null references note (id) on delete cascade,
author_sub text not null,
author_name text not null,
body text not null,
created_at timestamptz not null,
updated_at timestamptz not null
);
create index comment_note_id_idx on comment (note_id);
on delete cascade: 노트가 지워지면 댓글도 DB가 알아서 지운다.
서비스: 본인 확인은 서버에서
@Transactional
public Comment edit(long id, String actorSub, String body) {
Comment comment = get(id);
if (!comment.getAuthorSub().equals(actorSub)) {
throw new AccessDeniedException("본인 댓글만 수정할 수 있습니다");
}
comment.edit(clean(body));
return comment;
}
private static String clean(String body) {
String text = body == null ? "" : body.strip();
if (text.isEmpty() || text.length() > 2000) {
throw new IllegalArgumentException("댓글은 1~2000자로 써 주세요");
}
return text;
}
- 컨트롤러에서
AccessDeniedException을 던지면 Spring Security가 403으로 바꿔 준다. - 화면에서 수정 버튼을 숨기는 건 편의일 뿐이다. 브라우저의
required,maxlength도 우회할 수 있으니 서버에서 다시 검사한다. 실제로required를 지우고 공백만 보내도 서버가 막는 걸 확인했다.
오류는 flash 속성으로
catch (IllegalArgumentException e) {
flash.addFlashAttribute("commentError", e.getMessage());
return "redirect:/notes/" + slug + "#comments";
}
flash 속성은 리다이렉트 직후 한 번만 모델에 들어가고 사라진다. PRG 패턴을 지키면서 오류를 보여줄 수 있고, 새로고침하면 메시지가 사라진다.
관리자 여부는 Authentication에서 읽는다
Warning
@AuthenticationPrincipal OidcUser user의 user.getAuthorities()에는 Google이 준 원래 권한만 있다. 로그인 때 GrantedAuthoritiesMapper가 추가한 ROLE_ADMIN은 Authentication 객체의 권한에 들어간다. 관리자 판정은 Authentication으로 해야 한다.
static boolean isAdmin(Authentication auth) {
return auth != null && auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
}
겪은 문제와 해결
1. cascade로 지웠는데 Hibernate가 저장을 거부
노트 삭제 테스트에서 TransientPropertyValueException이 났다. DB는 cascade로 버전·댓글 행을 지웠지만, 같은 트랜잭션 메모리(영속성 컨텍스트)에 남은 객체가 지워진 노트를 가리키고 있어서 Hibernate가 flush를 거부했다.
해결: 엔티티 삭제 대신 JPQL 한 번으로 지우고, 전후로 flush와 clear를 한다.
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("delete from Note n where n.slug = :slug")
int deleteBySlug(String slug);
Tip
DB cascade를 쓰기로 했다면 JPA 쪽 연관 객체가 메모리에 남지 않게 해야 한다. 삭제의 주인을 DB 한 곳으로 정하는 게 핵심.
2. 입력 오류 화면이 500 (롤백 뒤 지연 로딩)
편집기에서 잘못 입력하고 저장하면 오류 메시지 대신 500이 났다.
- 서비스에서
IllegalArgumentException→ 트랜잭션 롤백 - 롤백하면서 영속성 컨텍스트가 비워짐
- 요청 초반에 읽어 둔 사이드바 카테고리 목록이
notes컬렉션을 지연 로딩하려다LazyInitializationException
해결: 오류 화면을 그릴 때 목록을 다시 조회한다.
Caution
테스트 클래스 전체에 @Transactional을 걸면 서비스 안에서 롤백이 실제로 일어나지 않아서 이 버그가 안 잡힌다. 트랜잭션 없이 도는 테스트를 따로 만들어 재현한 뒤 고쳤다. 사용자가 브라우저에서 직접 눌러 보고 발견한 버그다.
3. 로그인하면 홈으로 가 버림
"로그인하고 댓글 달기" 링크가 /oauth2/authorization/google로 바로 가면, 로그인 뒤 보던 노트가 아니라 첫 화면으로 간다.
해결: 링크를 로그인이 필요한 주소 /notes/{slug}/comments/login으로 바꿨다.
sequenceDiagram
participant B as 브라우저
participant S as 서버
participant G as Google
B->>S: GET /notes/docker/comments/login
S->>S: 비로그인 → 이 요청을 기억(saved request)
S->>B: Google 로그인으로
B->>G: 로그인
G->>B: 서버로 돌아가기
B->>S: 로그인 완료
S->>B: 기억해 둔 /notes/docker/comments/login 으로
B->>S: GET (이제 로그인 상태)
S->>B: /notes/docker#comments 로 리다이렉트
Spring Security는 인증이 필요한 요청을 막을 때 원래 요청을 세션에 저장했다가 로그인 성공 후 돌려보낸다.
Important
?returnTo= 같은 파라미터로 돌아갈 곳을 받으면, 공격자가 returnTo=https://가짜사이트를 넣은 링크를 뿌릴 수 있다(오픈 리다이렉트). 돌아갈 곳을 서버가 정하는 방식이라 이 위험이 없다.
Playwright로 브라우저 확인
자동 테스트(MockMvc)는 통과했어도 실제 브라우저에서 확인하니 문제(위 3번)가 보였다. Playwright MCP로 Claude가 브라우저를 직접 조작하게 하고, Google 로그인만 사람이 직접 했다(자격 증명은 AI에게 넘기지 않는다).
- 여러 줄 댓글,
<script>가 글자로 보임, 화면에 이메일 없음 - 본인 댓글 수정, 공백 댓글은 서버가 거절하고 새로고침하면 메시지 사라짐
- 비로그인: 읽기만, "로그인하고 댓글 달기" 링크만 보임
- 로그인 후 보던 노트의 댓글 영역으로 복귀
- 관리 화면에서 남의 댓글 삭제
- 댓글 있는 노트 삭제 → 댓글도 함께 삭제
Tip
서버를 재시작하면 서버 세션은 사라지지만 브라우저의 Google 세션은 남아 있어서, 로그인 버튼을 누르면 비밀번호 없이 바로 다시 로그인된다. 반복 테스트할 때 편하다.
댓글 0
로그인하고 댓글 달기