Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

CRUD 게시판 REST API

회원 / 게시글 / 댓글 / 좋아요 기능을 갖춘 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]
Loading

계층별 책임

계층 역할
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"
Loading
  • 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$... 형태로 저장합니다. 접두사가 알고리즘을 기록해 두므로 나중에 알고리즘을 바꿔도 기존 값을 그대로 검증할 수 있습니다

API

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)는 서비스에서 도메인 예외로 변환
  • 에러 코드 일원화 — 상태 코드와 메시지를 ErrorCode enum 한 곳에 둡니다. 이전에는 상태 코드가 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 · 조회 전용 서비스 분리)는 도입하지 않습니다. 조회 성능은 인덱스 · 페이징 · 카운트 비정규화로 풉니다.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages