회원 / 게시글 / 댓글 / 좋아요 기능을 갖춘 Spring Boot 학습 프로젝트입니다. 먼저 메모리 저장소로 CRUD를 구현하고 DB 접근 기술을 단계적으로 교체(메모리 → MyBatis → JPA) 하며 각 기술의 차이를 익혔습니다. 지금은 여기에 기능을 붙이면서 마주치는 JPA 성능·동시성 문제를 직접 측정하고 해결하는 쪽으로 넓혀가고 있습니다.
학습 회고 (velog)
| 구분 | 내용 |
|---|---|
| Language | Java 21 |
| Framework | Spring Boot 3.4.5 |
| DB | MySQL 8.0 (Docker) |
| 접근 기술 | Spring Data JPA |
| 스키마 관리 | Flyway (ddl-auto: validate) |
| 인증 | Spring Security (세션 기반) |
| Test | JUnit5, AssertJ, RestClient |
| Build | Gradle |
MyBatis로 구현했던 버전은
mybatis브랜치에 있습니다. 게시글–댓글을@OneToMany컬렉션으로 매핑했던 버전은n-plus-1브랜치에 있습니다. 2편·3편의 컬렉션 N+1과@BatchSize실측이 그 코드 기준입니다.
flowchart LR
Client -->|JSON + JSESSIONID| Filter[Security 필터 체인] --> Controller --> Service --> Repository --> MySQL[(MySQL)]
Filter -.인증 실패.-> EntryPoint[AuthenticationEntryPoint<br/>401]
Service -.예외.-> Advice[GlobalExceptionHandler]
계층별 책임
| 계층 | 역할 |
|---|---|
| Security 필터 체인 | 세션에서 인증 정보 복원, 경로별 접근 제어 |
| Controller | 요청/응답 처리, @Valid 검증, 인증된 회원 id 추출 |
| Service | 비즈니스 로직, @Transactional, 작성자 조회 |
| Repository | 데이터 접근 (JpaRepository) |
| AuthenticationEntryPoint | 인증 실패(필터 단계) → 401 JSON |
| GlobalExceptionHandler | 전역 예외(컨트롤러 단계) → 일관된 에러 응답 |
| ErrorCode | 상태 코드 · 응답 코드 · 메시지를 모아둔 enum |
인증 실패는
DispatcherServlet보다 앞인 필터에서 발생해@RestControllerAdvice를 거치지 않습니다. 그래서 401만 별도 핸들러로 응답 형식을 맞춥니다.
Repository를 인터페이스로 분리해, 구현체만 교체(메모리 → MyBatis → JPA)하면 상위 계층은 그대로 유지됩니다.
erDiagram
MEMBER ||--o{ POST : writes
MEMBER ||--o{ COMMENT : writes
POST ||--o{ COMMENT : has
MEMBER ||--o{ POST_LIKE : likes
POST ||--o{ POST_LIKE : "liked by"
MEMBER ||--o{ COMMENT_LIKE : likes
COMMENT ||--o{ COMMENT_LIKE : "liked by"
Member(1) ─ (N)Post─ (N)Comment- 댓글은 게시글을
@ManyToOne이 아니라Long postId로 참조합니다. 댓글 서비스가 게시글 도메인을 몰라도 되고, 도메인 간 순환 의존이 생기지 않습니다 - 좋아요는 대상별로 테이블을 분리(
post_like/comment_like)하고, 각각 단방향@ManyToOne두 개로 매핑 UNIQUE(post_id, member_id)·UNIQUE(comment_id, member_id)로 중복 좋아요 방지@ManyToOne으로 객체 참조 매핑 (지연로딩)- 비밀번호는
DelegatingPasswordEncoder로 해싱해{bcrypt}$2a$10$...형태로 저장합니다. 접두사가 알고리즘을 기록해 두므로 나중에 알고리즘을 바꿔도 기존 값을 그대로 검증할 수 있습니다
| Method | URL | 설명 | 인증 |
|---|---|---|---|
POST |
/api/members |
회원 생성 | |
GET |
/api/members/{memberId} |
회원 단건 | |
POST |
/api/auth/login |
로그인 | |
POST |
/api/auth/logout |
로그아웃 | 필요 |
GET |
/api/auth/me |
내 정보 | 필요 |
POST |
/api/posts |
게시글 생성 | 필요 |
PUT |
/api/posts/{postId} |
게시글 수정 (작성자만) | 필요 |
DELETE |
/api/posts/{postId} |
게시글 삭제 (작성자만) | 필요 |
GET |
/api/posts?boardId={boardId} |
게시판별 게시글 목록 | |
GET |
/api/posts/{postId} |
게시글 단건 (조회수 증가) | |
POST |
/api/comments |
댓글 생성 | 필요 |
PUT |
/api/comments/{commentId} |
댓글 수정 (작성자만) | 필요 |
DELETE |
/api/comments/{commentId} |
댓글 삭제 (작성자만) | 필요 |
GET |
/api/posts/{postId}/comments |
게시글의 댓글 목록 | |
GET |
/api/comments/{commentId} |
댓글 단건 | |
POST |
/api/posts/{postId}/likes |
게시글 좋아요 | 필요 |
DELETE |
/api/posts/{postId}/likes |
게시글 좋아요 취소 | 필요 |
GET |
/api/posts/{postId}/likes/count |
게시글 좋아요 수 | |
POST |
/api/comments/{commentId}/likes |
댓글 좋아요 | 필요 |
DELETE |
/api/comments/{commentId}/likes |
댓글 좋아요 취소 | 필요 |
GET |
/api/comments/{commentId}/likes/count |
댓글 좋아요 수 |
읽기(
GET)는 인증 없이 열려 있고, 쓰기는 로그인이 필요합니다. 회원 가입과 로그인만 예외입니다. 인증이 필요한 요청에 세션이 없으면401과{"code":"UNAUTHENTICATED","status":401,"message":"로그인이 필요합니다."}가 나갑니다. 작성자가 아닌 회원이 수정을 시도하면403이 나갑니다. 인증 실패(401)는 필터에서, 인가 실패(403)는 서비스에서 판단합니다. 게시글·댓글 삭제는 소프트 삭제입니다.deleted_at을 채우고 조회에서 제외하므로 삭제된 리소스는404가 나갑니다. 게시글을 삭제하면 그 글의 댓글도 함께 제외됩니다. 게시글·댓글 응답에는 작성자 이름(authorName)과 작성일시(createdAt)가 포함됩니다. 게시글 응답에는boardId도 들어갑니다. 잘못된 요청은 상태 코드로 구분됩니다 — 파라미터 누락·타입 불일치·깨진 JSON은400, 없는 경로는404(ENDPOINT_NOT_FOUND), 지원하지 않는 메서드는405입니다. 좋아요 응답에는 갱신된 개수(likeCount)와 눌렀는지 여부(liked)가 포함됩니다.
- DTO 분리 — 요청/응답 DTO를 도메인과 분리, 응답에서 민감 정보(비밀번호) 제외
- Repository 인터페이스화 — 구현체 교체로 DB 기술 전환 (메모리 → MyBatis → JPA)
- 도메인 불변성 —
@Setter대신 생성자·의미 있는 메서드 사용 - 전역 예외 처리 —
@RestControllerAdvice로 404/400/409 등 일관된 에러 응답. 하부 기술 예외(DataIntegrityViolationException)는 서비스에서 도메인 예외로 변환 - 에러 코드 일원화 — 상태 코드와 메시지를
ErrorCodeenum 한 곳에 둡니다. 이전에는 상태 코드가ErrorResponse생성자와ResponseEntity.status()두 곳에 적혀 있어 실제로 어긋난 적이 있고(바디 422 / 헤더 401), 같은 메시지가 15곳에 흩어져 마침표까지 달랐습니다. 응답에code를 담아 클라이언트가 한글 메시지를 파싱하지 않고 분기할 수 있게 했습니다 - 예외 타입은 유지 —
ErrorCode만 주입하고NotFoundException·AccessDeniedException같은 타입은 남겼습니다.BusinessException하나로 합치면 핸들러는 줄지만 서비스 테스트가 무엇이 잘못됐는지 구분하지 못합니다 - 입력 검증 —
@Valid+ Bean Validation. 검증 상한을 DB 컬럼 길이와 맞춥니다. 한때PostUpdateRequest의content가 100자여서 3000자로 쓴 글을 수정하면400이 나갔습니다 - 요청 오류는 4xx로 —
@ExceptionHandler(Exception.class)가 스프링의DefaultHandlerExceptionResolver보다 먼저 실행돼서, 원래400으로 처리됐을 예외까지500이 되고 있었습니다. 파라미터 누락·타입 불일치·깨진 JSON·없는 경로·미지원 메서드에 개별 핸들러를 뒀습니다.5xx는 장애 알림을 울리고 게이트웨이가 재시도하며 서비스 에러율 지표를 오염시킵니다 - 스키마는 Flyway가 관리 —
ddl-auto: create는 재시작마다 데이터를 날려서 대량 데이터를 유지할 수 없습니다. 제약 이름을 규칙대로(fk_comment_post·uk_post_like_post_member) 붙이면 제약 위반 로그만 보고 무엇이 걸렸는지 압니다. 인덱스도 마이그레이션 파일로 남겨서 언제 무엇을 넣었는지가 이력에 남습니다 validate는 길이와 nullable을 검사하지 않습니다 — JDBC 타입 코드만 봅니다. 엔티티에서@Column(length = 3000)을 떼고 DB는 3000인 상태로 부팅해도 통과하는 것을 확인했습니다.@Column(length)는 문서 역할입니다- 조회는 게시판·게시글 단위로 — 목록 조회에
boardId가 필수이고, 댓글은GET /api/posts/{postId}/comments로만 가져옵니다. 전체를 주던GET /api/comments·GET /api/members는 제거했습니다 - 계층 책임 분리 — 내부용 도메인 반환 / API용 DTO 반환 구분
- 소프트 삭제는
deleted_at으로 —boolean이 아니라 시각을 남겨야 보관 기간 계산과 배치 정리가 됩니다. 사용자 콘텐츠(게시글·댓글)만 소프트 삭제하고 좋아요는 물리 삭제입니다. 좋아요도 행을 재사용해deleted_at을 껐다 켜면 소프트 삭제가 가능하지만, 남길 이력이 없고 행이 영구히 쌓이며 모든 좋아요 쿼리에 조건이 붙어서 그렇게 하지 않았습니다 - 변경 감지와 벌크 연산을 한 트랜잭션에서 쓸 때 — 게시글은 엔티티 변경 감지로, 그 글의 댓글은 벌크
UPDATE한 번으로 지웁니다. 벌크는 영속성 컨텍스트를 거치지 않으므로@Modifying(flushAutomatically = true, clearAutomatically = true)로 앞뒤를 맞춰야 변경 감지분이 유실되지 않습니다 - 인가는 서비스에서 확인한다 — 수정·삭제는 대상을 조회해야만 할 수 있고, 그 결과에 이미 작성자가 들어 있습니다.
post.isWrittenBy(memberId)로 비교합니다. 연관관계를 두 단계 타고 들어가는 코드(post.getMember().getId())를 서비스에 흩지 않으려고 판단 메서드는 엔티티에 뒀습니다 - 서비스는 인증을 모른다 — 컨트롤러가 세션에서 회원 id를 꺼내 서비스에 넘깁니다. 서비스 시그니처는
create(request, memberId)형태라 서비스 테스트에 인증 설정이 들어가지 않습니다 - 세션에는 최소 정보만 — 엔티티가 아니라
LoginMember(Long id)record를 담습니다. 엔티티를 담으면 준영속 상태로 남고, 비밀번호 해시까지 세션에 저장됩니다 - 로그인 시 세션 id 재발급 —
changeSessionId()로 세션 고정 공격을 막습니다. Security의 기본 방어는 자체 인증 필터를 거칠 때만 동작해서, 직접 인증하는 구조에서는 별도로 처리해야 합니다 - 인증 실패는 세션을 만들지 않는다 — 요청 캐시를 꺼서, 로그인 후 원래 요청으로 돌아가려고 세션을 만드는 동작을 없앴습니다
| 종류 | 내용 |
|---|---|
| Service 단위 테스트 | 로직 검증 (성공 / 예외 / 연관관계) |
| API 통합 테스트 | @SpringBootTest(RANDOM_PORT) + RestClient로 실제 HTTP 검증 |
공통 코드 (test/.../support)
| 클래스 | 역할 |
|---|---|
TestDataFactory / ApiTestDataFactory |
준비 데이터 생성 (서비스 호출 / HTTP 호출) |
DatabaseCleaner |
외래키 순서대로 전체 삭제 |
ServiceTestSupport / ApiTestSupport |
@SpringBootTest · 포트 · RestClient 세팅 · 로그인 헬퍼 |
검증 대상은 팩토리로 감싸지 않고 직접 호출합니다. 준비 데이터만 팩토리에 맡깁니다.
인증이 필요한 API 테스트 — RestClient는 JSESSIONID를 자동으로 유지하지 않아서, CookieManager를 붙인 JDK HttpClient를 요청 팩토리로 넣었습니다. @BeforeEach마다 새로 만들어 테스트 간 세션이 섞이지 않습니다. login(loginId) 호출이 "여기서부터 이 회원"이라는 경계가 됩니다.
완료
| 완료일 | 내용 |
|---|---|
| 2026-08-06 | REST API (Member / Post / Comment) · 단위 테스트 |
| 2026-08-07 | 전역 예외 처리 · 입력 검증 · API 통합 테스트 |
| 2026-08-10 | MySQL (Docker) + MyBatis 전환 |
| 2026-08-10 | JPA 전환 (@ManyToOne 연관관계, @Transactional) |
| 2026-08-11 | N+1 문제 실측 및 해결 (Fetch Join · @EntityGraph · Batch Size) |
| 2026-08-12 | 게시글 좋아요 (별도 테이블 · 유니크 제약 · COUNT 조회) |
| 2026-08-13 | 댓글 좋아요 (comment_like) |
| 2026-08-14 | 중복 좋아요 예외 처리 (선체크 + 제약 위반 변환 → 409 Conflict) |
| 2026-08-16 | 중복 코드 리팩토링 점검 · 테스트 공통 코드 추출 |
| 2026-08-18 | 게시글 조회수 (원자적 UPDATE · 읽기 전용 트랜잭션 분리) |
| 2026-08-19 | 인증 (Spring Security · BCrypt · 세션 로그인 · 경로별 접근 제어) |
| 2026-08-20 | 인가 (게시글 · 댓글 수정, 작성자 본인 확인 → 403) |
| 2026-08-23 | 소프트 삭제 (deleted_at · 게시글 삭제 시 댓글 연쇄 · 조회 제외) |
| 2026-08-26 | 삭제 여부를 확인하지 않던 세 곳 보완 (댓글 작성 · 좋아요 두 곳) |
| 2026-08-28 | 에러 응답 구조 정리 (ErrorCode enum · 응답에 code 추가 · 401 세분화) |
| 2026-09-03 | Flyway 도입 (ddl-auto: validate · 제약 이름 규칙화 · comment.post_id 인덱스 · post.content 3000자) |
| 2026-09-03 | 중복 아이디 409 (uk_member_login_id + 선체크 + 제약 위반 변환) |
| 2026-09-03 | 작성일시 (BaseTimeEntity + JPA Auditing) |
| 2026-09-03 | 요청 검증을 컬럼 길이에 맞춤 |
| 2026-09-04 | 게시판 구분 (board_id) · 목록 조회를 게시판 단위로 |
| 2026-09-12 | 요청 오류를 4xx로 (400 · 404 · 405) · 게시글별 댓글 조회 · 전체 목록 API 제거 |
진행 예정
실무에서 쓰는 게시판 구조를 모놀리식으로 만들면서, 요청이 늘었을 때 쿼리 수 · 응답 시간 · 동시 쓰기에서 생기는 문제를 측정하고 해결하는 순서입니다.
- 목록 응답 정리 — 목록에 본문이 실리지 않도록 DTO 분리, 댓글 수 · 좋아요 수 추가
- 페이징 · 인덱스 — 대량 데이터 적재 후
EXPLAIN측정 → 복합 인덱스(board_id, created_at desc, id desc)→ 커버링 인덱스 서브쿼리 조인 → count 상한 쿼리 → 키셋 무한 스크롤. offset 0 / 1,000 / 100,000 구간을 인덱스 전후로 잽니다 - 계층형 댓글 — 자기 참조 2단계 → materialized path, 자식 유무에 따른 삭제 처리, 댓글 페이징
- Redis 도입 — 조회수 어뷰징 차단(
SETNX+ TTL) ·INCR카운터 · 주기적 DB 백업, 그리고 세션 저장소 분리(앱 2대로 세션 깨짐을 재현한 뒤 옮깁니다) - 카운트 비정규화 · 동시성 —
COUNT병목 실측 → 카운트 테이블 → 갱신 유실 → 락 비교(비관적 · 낙관적 · 원자적 UPDATE) - 인기글 — 가중치 점수, Redis sorted set + 시간 윈도우
- QueryDSL — 동적 쿼리
- 테스트 공통 코드를 상속 → 커스텀 애노테이션 조합으로 전환
MSA 고유 요소(Kafka 이벤트 전파 · outbox · Snowflake 분산 ID · 조회 전용 서비스 분리)는 도입하지 않습니다. 조회 성능은 인덱스 · 페이징 · 카운트 비정규화로 풉니다.