아키텍처 원칙
- Controller - 요청/응답 처리 , repository, entity 사용X
- Service - 비즈니스 로직 , Entity ↔ DTO 변환 담당. request,response 등 웹과 관련된 내용 X
- Repository - DB 데이터. Entity, DTO 둘다 반환 가능.
- 당연한거지만 Contorller에서 repository 호출 금지.
- DB조회나 해당 domain에 관한 로직은 service지만 파일이나 날짜 등과같은 공통기능은 Util로 이름짓기.
API 설계 규칙
- SSR에서는 Controller에서 그냥 aService.method1(),bService.method2() 호출. Facade 패턴은 필요할 때만. 근데 여기선 댓글과 파일은 그냥 CSR처럼 화면에서 따로 API 호출
- CSR은 1 API = 1 Controller 메소드 = 1 Service 메소드
프론트엔드에서 진행 흐름을 받고 API 여러번 호출하는 방식. 단 CSR도 API 여러번 호출이 부담되서 서버에서 한번에 처리해야된다면 이 때 Facade 패턴 적용해볼 것 - aService, bService등에서 사용하는 파라미터는 aService, bService에서만 사용하기 위한 작은 DTO로.
컨트롤러 (또는 facadeServie)에서 넘겨받은 DTO를 aService, bService에서 필요한 DTO로 변환해서 사용.
Entity와 DTO
- 변환메소드는 DTO 측에서, DTO → Entity : DTO에서 toEntity() 메소드로 변환 Entity → DTO : DTO에서 from(entity) 메소드로 변환 ModelMapper, MapStruct 등 변환해주는 lib 있음.
- Entity - DB 매핑, 비즈니스 편의 메소드 (상태 변경) 편의메소드에서 exception 발생시켜도 공통처리 됨.
-
DTO - 변환 메소드, API 요청/응답 될 수 있으면 매 요청,응답마다 DTO 분리 innerclasss는 공통필드 처리를 위한게 아니라 그냥 여러 java파일 개수 줄이는 것뿐
-
공통DTO 기준
- 중복코드 해결을 위해 공통DTO 만들 수 있지만 왠만하면 만들지말고 공통된 필드 다 DTO마다 넣는걸 권장.
- 필수여부 - 등록, 임시저장에서 공통으로 사용되는 필드들도 필수여부가 다르다면 공통DTO X
- 아무리 공통된 필드여도 요청용공통DTO, 응답용공통DTO는 분리하는걸 권장.
- 상속, compostiion으로 공통DTO 만들 수있지만 왠만하면 공통DTO 안 만드는 쪽으로.. DTO 상속은 오히려 헷갈리고 좋지않은 경우가 많음. composition을 쓰면 API 스펙이 변경됨. { commonData: {필드1, 필드2}}
로그인
User
- security 대비 입력은 username,password
Long id는 JPA용. 실제 로그인id역할을 하는건 username
LoginCheckInterceptor
@Component로 등록된 HandlerInterceptor로, preHandle()에서 세션에 loginUser가 없으면 /login으로 redirect하고, 있으면 Controller 진입을 허용한다.
적용 범위 (WebConfig)
| 적용 O | 적용 X | |——–|———————————–| |
/community/write(글쓰기) |/,/community(홈, 목록) | |/community/*/edit(글수정) |/community/*(상세 조회) | |/community/*/delete(글삭제) |/login,/signup| |/mypage|/uploads/**,/css/**,/js/**| | |/api/**(댓글 API - 자체 인증) | - api도 LoginCheckInterceptor에서 if문으로 하거나 따로 ApiLoginCheckInterceptor 만들어서 /api/**에 적용하는 방법도 있지만 댓글 API는 자체적으로 로그인 체크해서 401 JSON 응답하는게 더 적절하므로 Controller에서 직접 처리
Paging
- Pageable → Controller 파라미터에서 직접 바인딩
(시작이 0,1 인건 클라이언트가 처리) - PageResponse
로 응답형태 지정. Page<>의 필요한 필드만 추출. - 물론 SSR에서는 PageResponse 필요없지만 그냥 일관성 위해 SSR에서도 PageResponse 사용 중.
Controller에서 Page
로 Model에 담으면 Thymeleaf가 page.content, page.number, page.totalPages 등 getter를 직접 호출해서 렌더링하므로 불필요한 필드가 클라이언트에 노출되지 않기때문에 SSR에선 꼭 PageResponse로 감싸지 않아도 됨.
🗄️ JPA 설계
연관관계 원칙
- 양방향 지양 : Entity에서 지양하는거지 DTO는 상관없음.
- 글 상세보기 화면
글1 -댓글N (파일도 마찬가지 )- Service에서 글 조회, 댓글 조회 후 응답DTO에 세팅
- service에서 글+조회 조회 후 응답DTO에 세팅
- 글 조회 후 프론트에서 댓글 조회 API (여기서는 이걸 선택) 여기는 어쨋든 부모글 한번, 댓글List 한번 호출 = 총2번 호출
-
메인 데이터 여러개 연관관계 조회 (댓글목록 한번에 조회 + 각 댓글에 user username이 필요한상황) 방법 1. 메인 쿼리 실행 후 서브쿼리id 모아서
IN쿼리로 한번에 처리 (여기서는 이거) 방법 2. 조인 - jpql, @EntityGraph 등으로 한번에 쿼리 실행 상황에 맞게 선택. 이후 성능 문제가 있다면 개선. 방법1,2 둘다 repository(queryDSL)에서 처리.
연관관계가 너무 많을 때만 service에서 처리하는거 고려. - 부모글 삭제 시 고아 데이터
- Controller에서 파일 고아 처리 → 댓글 하드 삭제 → 게시글 하드 삭제 (여기서는 이걸 선택) Controller에서 fileService, commentService, communityService 순서대로 호출
- orphanRemoval 이나 Cascade 설정으로 부모글 삭제 되면 자동으로 삭제
- 프론트에서 부모글 삭제 API + 댓글 삭제 API ( CSR에서는 이걸 선택) (부모글 삭제를 하면 SSR방식에서는 redirect하는게 일반적)
배포
파일
- 배포 환경에서는 supbase나 s3 같은 클라우드 스토리지로 파일 저장하는게 정석.
- 이미지 서빙에은 서버에서 직접하지말고 CDN방식으로 변경
| 개발 | 로컬 파일시스템 |
supabase.enabled: false| | 운영 | Supabase Storage |supabase.enabled: true| - application.yml 에 따라 운영, 로컬에서 수파베이스용, 로컬용 파일 처리 빈이 주입
댓글
방식: 완전 REST API (@RestController + JS fetch)
게시글(SSR)과 달리 댓글은 detail.html 안에서 JavaScript로 비동기 렌더링함.
| 기능 | URL | HTTP | 응답 |
|——|—–|——|——|
| 목록 (페이징) | GET /api/communities/{id}/comments?page=0&size=10 | GET | JSON (PageResponse) |
| 작성 | POST /api/communities/{id}/comments | POST | JSON (201 Created) |
| 수정 | PUT /api/communities/{id}/comments/{commentId} | PUT | JSON |
| 삭제 | DELETE /api/communities/{id}/comments/{commentId} | DELETE | JSON |
detail.html에서의 동작
페이지 로드 시 loadComments(0) JS 함수가 호출되어 fetch로 댓글 목록을 JSON으로 받아온다. renderComments()로 댓글 목록을 DOM으로 렌더링하고, renderPagination()으로 페이징 버튼을 렌더링한다.
로그인 체크 (Interceptor가 아닌 Controller 자체 처리)
CommentApiController에서 세션의 loginUser가 null이면 401 UNAUTHORIZED JSON 응답을 반환한다. Interceptor는 SSR 페이지에 적용하여 redirect하고, REST API는 401 JSON 응답이 적절하므로 Controller에서 직접 처리한다.
📁 파일
파일 종류 구분 (FileEntity)
- RefType: COMMUNITY (커뮤니티 게시글), USER (사용자 프로필 등)
- Usage: THUMBNAIL (썸네일, 현재 미사용), IMAGES (본문 내 이미지), ATTACHMENT (첨부파일)
설계 원칙
- 파일테이블은 첨부파일 테이블 한개+ 각 도메인 구분자 필드 or 각 도메인별로 ~~File테이블 등
- CSR방식에서는 파일 업로드는 별도 API로 분리 (게시판 API + 파일들 API) 프론트엔드가 각각의 API 호출하게 하면 서버코드(특히 controller)는 간단해지고 SRP에 잘 맞게됨.
- SSR방식에서는 한번의 요청에서 글 등록 후 글번호로 파일업로드 진행 보기에서도 한번의 API에서 글 + 첨부파일 조회 후 DTO 세팅하는게 보통인데 여기서는 글 조회 후 API 호출로 첨부파일 조회 (첨부파일 조회 API는 글 상세보기, 글 수정 화면에서 사용)
- 트랜잭션 불일치(파일만 업로드됨) → 스케줄러로 해결
적용
- 첨부파일 업로드/삭제: CommunityController에서 form submit으로 한번에 처리
- 글 등록: 파일저장 및 글ID를 이용해 DB저장
- 글 수정: 파일저장 및 글ID를 이용해 DB저장
- 새롭게 추가된 파일은 등록과 마찬가지로 파일 및 DB저장
- 새롭게 삭제된 파일은 따로
deleteFileIds[]로 전달받아 DB에서 고아 처리 (update refId=0)함. (실제 삭제는 스케줄러가 수행)
- 다운로드: FileController REST API (
GET /files/download/{fileId}) -
첨부파일 보기 : 글 상세보기 , edit 화면에서 DTO에 setting하는게 아니라 프론트에서 글ID로 해당하는 첨부파일들 API 호출
- 이미지 서빙: FileController REST API (
GET /uploads/{filename}) — 정석은 WebConfig ResourceHandler이지만 Controller에서 일관 처리 (배포 후에는 S3나 수파베이스에 CDN방식으로 하는게 정석.) - 에디터 이미지:
- 이미지 선택 - 업로드: 즉시 REST API 호출 (
POST /api/files/upload) → 파일 저장 및 DB Insert (refId=0임시 상태) - 에디터 내 삭제: 서버 호출 안 함 (화면과 JS 배열
editorFileIds에서만 제거, 서버 파일은 유지 DB도 refid=0인 상태 ) - 글 등록/수정 완료(
submit)- 글 등록:
editorFileIds에 포함된 파일만refId=글ID로 연결 (UPDATE). 제외된 파일은refId=0유지 (스케줄러 삭제) - 글 수정:
- 새로 추가된 이미지 :
editorFileIds에 있는 모든 파일(기존+새파일)의refId를 글ID로 업데이트 - 기존 에디터 이미지 중 삭제된 이미지 : DB에 저장된 기존 파일 중
editorFileIds에 없는 파일을 찾아 고아 처리 (update refId=0)
- 새로 추가된 이미지 :
- 글 등록:
- 이미지 보기 - 글 수정할 때 글 내용에 img태그 있음. src=”/uploads/uuid.png”.
이걸 브라우저가 FileController의GET /uploads/{filename}API로 호출해서 이미지서빙
- 이미지 선택 - 업로드: 즉시 REST API 호출 (
- 이후 삭제가 필요한 건 스케줄러에서 삭제
- refId 값은 null 대신 0으로 통일성 유지 . 0은 연결 안됨을 의미 (null은 외래키에서 사용할 수 없는 값)
FileController (@Controller) — 브라우저 직접 요청
| 기능 | URL | 용도 |
|——|—–|——|
| 다운로드 | GET /files/download/{fileId} | 첨부파일 다운로드 (브라우저 파일 저장 다이얼로그) |
| 이미지 서빙 | GET /uploads/{filename} | 에디터 본문 이미지 표시 |
정석은 WebConfig의 addResourceHandlers()로 정적 리소스 매핑하는 것이지만 Controller에서 일관 처리
FileApiController (@RestController) — JS fetch 호출
| 기능 | URL | HTTP | 용도 |
|——|—–|——|——|
| 목록 조회 | GET /api/files?refId={refId}&refType={refType}&usage={usage} | GET | 첨부파일/에디터 이미지 목록 |
| 업로드 | POST /api/files/upload | POST | 에디터 이미지 즉시 업로드. 첨부파일은 글 등록 컨트롤러에서 파일 업로드 |
- 삭제 API는 없음. 파일삭제에서 파일을 직접삭제하는건 스케줄러. 여기서 말하는 삭제는 DB에서 update. 첨부파일 - 글 수정시 삭제할 fileId가지고 fileService.deleteFilesSafely() 호출 (해당 글의 파일인지 체크 후 고아 처리) 에디터 이미지 - 글 등록/수정 완료시 editorFileIds에 없는 fileId들 찾아서 고아 처리 즉 삭제API는 없고, service만 있음
FileController와 FileApiController를 분리한 이유:
@Controller와@RestController를 섞으면 전역 에러 처리(@ControllerAdvicevs@RestControllerAdvice)가 제대로 분리되지 않음. 브라우저 직접 요청(다운로드, 이미지)은 FileController, JS fetch 요청(목록, 업로드)은 FileApiController로 나누어 각각 GlobalExceptionHandler(error.html), ApiExceptionHandler(JSON ErrorResponse)가 올바르게 적용됨.
Community
| 동작 | 처리 위치 | 방식 |
|——|———–|——|
| 첨부파일 업로드 | POST /community/write, POST /community/{id}/edit | form submit (multipart) → fileService.saveUploadedFiles() |
| 첨부파일 삭제 | POST /community/{id}/edit | form submit (deleteFileIds[]) → fileService.deleteFilesSafely() (해당 글 파일인지 체크 후 고아 처리, 물리 파일은 스케줄러가 정리) |
- 첨부파일 업로드 : 글 등록/수정 할 때 form submit으로 multipart로 파일업로드 및 DB저장
- 첨부파일 삭제 : 글 수정할 때 삭제할 fileId들을 deleteFileIds[]로 전달받아 DB에서 고아 처리 (update refId=0)
-
첨부파일 보기 - 글 상세보기/edit controller에서 안하고 프론트에서 따로 API 호출 (
GET /api/files?refId=...&refType=COMMUNITY&usage=ATTACHMENT) - 에디터 이미지 파일 업로드 : 에디터에서 이미지 선택 시 즉시
POST /api/files/uploadAPI로 업로드 → 파일 저장 및 DB Insert 되어있음 (refId=0 임시 상태) 글 등록/수정할 때 editorFileIds[]로 이미지 파일ID들 전달받아 DB에서 refId 업데이트 (글ID로 연결) - 에디터 이미지 삭제 - 에디터 내에서 삭제 시 서버 호출 안 함 (처음 수정화면에서는 editorFileIds가 기존 이미지파일들만큼 fileId로 채워져있음. 화면과 JS editorFileIds에서만 제거, 서버 파일은 유지 DB도 refId=0인 상태) 글 등록/수정할 때 editorFileIds[]에 없는 파일ID들은 DB에서 고아 처리 (update refId=0).
- 에디터 이미지 보기 - 글 내용에 img태그 있음. src=”/uploads/uuid.png”. 이걸 브라우저가 FileController의
GET /uploads/{filename}API로 호출해서 이미지서빙
물리 파일 저장 경로
- 설정:
file.upload-dir=./uploads(application.yml) - 실제 경로:
C:/workspace/ch_lecture/spring_ssr/uploads/ - 파일명 규칙: UUID + 원본 확장자 (예:
35cb63ec-5aed-4b52-a2dc-c357a235c7f6.png) 참고로 editor내에서는 editorFileIds[] 로 fileId를 관리.
공통 예외 처리
커스텀 예외 계층
RuntimeException을 상속하는 BusinessException (추상 클래스, status와 errorCode 공통 필드)이 있고, 이를 상속하는 EntityNotFoundException(404), AccessDeniedException(403), BusinessRuleException(400), DuplicateResourceException(409)이 있다.
@Responebody와 공통에러처리
- 공통에러처리인 @ControllerAdvice와 @RestControllerAdvice 는 @Responebody가 아니라 @Controller, @RestController에 각각 적용되어 있어서 @Controller에서는 error.html 렌더링, @RestController에서는 JSON 응답으로 에러를 처리한다.. 따라서 Controller에서 @Responebody 붙은 메소드를 작성하고 에러나면 @ControllerAdvice에서 처리 이 때 보통 error.html을 응답하기 때문에 프론트쪽에서는 응답데이터로 json이 아닌 html을 받게되서 안 좋음 그래서 보통 @Controller, @RestController 따로따로 만드는게…
Service에서 예외 사용
- 조회 실패 시:
EntityNotFoundException.of("게시글", id)→ 404 - 권한 없음 시:
AccessDeniedException.forUpdate("게시글")→ 403 - 중복 리소스:
DuplicateResourceException.alreadyExists("메시지")→ 409GlobalExceptionHandler — SSR용 (적용됨)
@ControllerAdvice(annotations = Controller.class)로 등록되어 있으며, @Controller에서만 동작. EntityNotFoundException → 404, AccessDeniedException → 403, BusinessException → 400, Exception → 500으로 처리한다. 각각 상태코드와 메시지를 Model에 담아 error.html을 렌더링한다. 즉 에러가 나면 사용자는 error.html 화면을 보게된다.
ApiExceptionHandler — REST API용 (적용됨)
@RestControllerAdvice(annotations = RestController.class)로 등록되어 있으며, @RestController에서만 동작. GlobalExceptionHandler와 같은 예외를 잡지만 JSON(ErrorResponse)으로 응답한다.
{ "status": 404, "errorCode": "NOT_FOUND", "message": "게시글을(를) 찾을 수 없습니다: 99" }
화면이 바뀌는게 아니라 fetch/axios의 콜백(.then/.catch)으로 응답이 가므로, 프론트에서 alert이든 토스트 메시지든 JS에서 에러 처리 가능.
| 구분 | GlobalExceptionHandler | ApiExceptionHandler |
|---|---|---|
| 대상 | @Controller (SSR) |
@RestController (API) |
| 응답 | error.html 렌더링 | JSON (ErrorResponse) |
| 받는 쪽 | 브라우저 (페이지 전환) | fetch/axios 콜백 |
ErrorResponse
REST API 에러 응답 DTO. 모든 API 에러 상황에서 일관된 JSON 구조로 응답.
- status: HTTP 상태 코드 (404, 403, 400 등)
- errorCode: 에러 코드 문자열 (클라이언트 분기용)
- message: 사용자에게 보여줄 에러 메시지
스케줄러
설정
@EnableScheduling— DemoApplication에 선언 (Spring Boot가@Scheduled메서드를 주기적으로 실행)@Scheduled(cron = "...")— 실행 주기 설정 (cron 표현식: 초 분 시 일 월 요일)
고아객체 처리
FileCleanupScheduler — 고아 파일 물리 삭제
- 실행 시간: 매일 새벽 4시 (
0 0 4 * * *) - 대상:
refId=0(연결된 게시글 없음) +updatedAt < 24시간 전 - 처리:
- 고아 파일 목록 조회
- 물리 파일 삭제 (디스크에서 삭제)
- DB 하드 삭제
- 고아 파일이 생기는 경우:
- 에디터 이미지 업로드 후 글 등록을 안 한 경우 (refId=0 임시 상태 유지)
- 에디터 이미지 삭제 시 editorFileIds에서만 제거 → 글 등록/수정 시 고아 처리(refId=0)
- 글 수정 시 첨부파일 삭제 → deleteFilesSafely()로 고아 처리(refId=0)
- 글 삭제 시 → detachFilesByRefId()로 해당 글의 모든 파일 고아 처리(refId=0)
- ※ 게시글/댓글은 하드 삭제이므로 OrphanDataScheduler는 불필요. 게시글 삭제 시 Controller에서 댓글 하드 삭제 + 파일 고아 처리를 즉시 수행.
소프트/하드 삭제
| 대상 | 삭제 방식 | 이유 |
|——|———–|——|
| User | 소프트 삭제 (isDeleted=true) | 계정 복구 가능성, 탈퇴 유예 기간 등 |
| Community | 하드 삭제 (DELETE) | 즉시 삭제, 복구 불필요 |
| Comment | 하드 삭제 (DELETE) | 게시글 삭제 시 FK 때문에 먼저 삭제 |
| File | 고아 처리 (refId=0) → 스케줄러가 물리 삭제 + DB 삭제 | 물리 파일과 DB 분리 삭제 |
- User 조회 시 매번
isDeleted=false조건 추가하지 않도록 Entity에@SQLRestriction("is_deleted = false")적용 → findById, findByUsername 등 모든 JPA 조회에 자동으로 조건이 붙음 - 게시글 삭제 흐름 (CommunityController.delete):
fileService.detachFilesByRefId(id)— 파일 고아 처리 (refId=0)commentService.deleteCommentsByCommunityId(id)— 댓글 하드 삭제communityService.deleteCommunity(id, username)— 게시글 하드 삭제 (권한 체크 포함) 적용
외부API 호출 이랑 엑셀까지 하자 … 어려운거 아니니까..
- CORS :내 spring 서버는 개발자가 의도한 대로만 동작, 브라우저는 해커가 조작가능. 그래서 기본적으로 브라우저는 다른 도메인으로의 요청을 막음.
- CORS 설정으로 허용된 도메인에서만 요청 허용. CORS 정리 다 했으면 외부 API 호출하는 기능 추가해보기