콘텐츠로 이동
✍️ 수정가능누구나 고쳐도 됩니다. 고치면 하단 frontmatter의 갱신일·작성자·변경요약을 남겨 주세요.작성 Claude · 2026-06-05 · 실제 코드(mvp-back/src) 정독 후 전면 정정 — soft delete·JWT·매퍼 위치·external·상속 전략

백엔드 코드 아키텍처

코드/시스템 3문서백엔드 코드 아키텍처(이 문서) · 프론트엔드 코드 아키텍처 · 시스템·인프라 아키텍처(전체 지도 + 서버·배포·운영).

Spring Boot 3.4.4 / Java 21 / Gradle. base 패키지 com.example.demo. 이 문서는 실제 코드(mvp-back/src/main/java/com/example/demo)를 정독해 작성했다 — 클래스명·패턴은 코드에서 확인한 것이다. 도메인 디테일은 회원·스터디룸 등 도메인 페이지로, 결정의 배경·트레이드오프는 engineering-principles·tech-decisions로 빠진다.


1. 레이어 — 포트·어댑터(헥사고날) + 계층형 혼합

presentation/    controller · dto · advice · aspect · response · util   (요청·응답 표면)
application/      service · dto                                          (유스케이스·트랜잭션 경계)
domain/           순수 도메인 객체 + Repository 포트(인터페이스)
infrastructure/   persistence/{entity,repository} · security · logging   (어댑터·기술)
external/         ai · s3 · sens                                         (외부 연동)
common/           annotation · exception · swagger · util
config/           Bean·프로퍼티 설정
  • 도메인별로 domain/<name>, application/service/<name>, presentation/{controller,dto}/<name>, infrastructure/persistence/{entity,repository}/<name>대칭으로 놓인다.
  • 포트·어댑터가 실제로 동작: 예) domain/member/MemberRepository는 JPA를 모르는 순수 인터페이스(포트), infrastructure/persistence/repository/member/MemberRepositoryAdapterimplements MemberRepository하며 SpringDataJpaMemberRepository를 주입해 구현한다(어댑터). 단 포트 위치는 도메인마다 일관되지 않다 — member처럼 domain/에 둔 곳도, homework·review·teachingnote·studyroom 등처럼 포트 인터페이스를 infrastructure/persistence/repository/에 둔 곳도 있다(서비스가 후자를 import하는 건 위반이 아님).
  • 도메인 ↔ 엔티티 분리: 도메인 객체와 JPA 엔티티는 서로를 모른다. 변환은 엔티티 클래스 안의 MemberEntity.from(member) / entity.toDomain() 메서드가 담당한다(별도 Mapper 클래스는 없다 — 변환 책임이 엔티티에 있음).
  • 일탈 지점(정직하게): 순수 헥사고날이 100%는 아니다. 예) MediaAssetService는 도메인 포트를 거치지 않고 infrastructure/.../mediaasset/MediaAssetRepository를 직접 import한다. 조회 위주 흐름은 계층형(Controller → Service → Repository)으로 가는 곳도 많다 — 명령형은 포트·어댑터, 조회형은 계층형이라는 분리 원칙은 engineering-principles.

2. JPA 조회 — 쿼리 작성 × 결과 매핑

두 축을 독립적으로 고른다: ①쿼리 작성(Derived / JPQL @Query / Native / QueryDSL) × ②결과 매핑(Entity / Interface Projection / Class Projection).

  • QueryDSL 실사용: config/QueryDslConfigJPAQueryFactory Bean을 등록하고, TeacherProfileQueryDslRepository·StudyRoomQueryDslRepository·HomeworkQueryDslRepository·ConnectionQueryDslRepository 등에서 Projections.constructor(XxxResponse.class, …)(Class Projection)로 응답 DTO를 직접 만든다.
  • Interface Projection도 혼용: 예) open_challenge/challengeattempt/MyCompletedChallengeProjection(interface)을 Spring Data JPA가 프록시로 반환.
  • 매핑 선택 기준: Entity=영속성 컨텍스트 관리(쓰기 적합), Interface Projection=런타임 프록시(Closed/Open/Nested), Class Projection=record/생성자(불변·테스트 용이). 결정 트리·근거는 engineering-principles.

3. 예외 처리 — 단일 예외 + ErrorCode enum

새 예외 클래스를 만들지 않고, 하나의 BusinessException + 도메인별 ErrorCode enum으로 모든 비즈니스 예외를 관리한다.

  • common/exception/BusinessExceptionRuntimeException 상속, ErrorCode 필드 1개.
  • common/exception/code/ErrorCodeinterface { int getStatus(); String getMessage(); }. 구현 enum이 약 26종: CommonErrorCode·AuthErrorCode·MemberErrorCode·StudyRoomErrorCode·TeachingNoteErrorCode·HomeworkErrorCode·QnaErrorCode·ReviewErrorCode·MediaAssetErrorCode·SecurityErrorCode… + open_challenge/ 하위 6종.
  • common/exception/response/ErrorResponseextends ApiResponse, code(enum name) 필드 추가. of(ErrorCode) / of(BusinessException) 팩토리.
  • presentation/advice/ErrorAdvice@RestControllerAdvice. BusinessException·MethodArgumentNotValidException 두 가지를 일관 JSON으로 변환.
  • 필터 단 예외: Spring MVC advice는 시큐리티 필터에서 난 예외를 못 잡으므로, infrastructure/security/ExceptionHandlerFilter가 필터 체인의 BusinessException을 직접 ErrorResponse로 write한다.
  • 새 에러 = enum 한 줄. 발생 = throw new BusinessException(AuthErrorCode.INVALID_CREDENTIALS).

4. 소프트 딜리트 — @SQLDelete + @SQLRestriction

⚠️ 과거 문서가 적었던 SoftDeleteOrchestrator/SoftDeletePolicy<T> 같은 클래스는 코드에 존재하지 않는다. 실제 구현은 아래와 같다(코드 확인).

  • soft delete 대상 엔티티마다 @SQLDelete(sql="UPDATE … SET deleted_at = NOW() WHERE id = ?") + @SQLRestriction("deleted_at IS NULL")를 직접 선언한다. 적용 엔티티 19종 확인(MemberEntity·ConnectionEntity·StudyRoomEntity·StudyRoomStudentEntity·TeachingNoteEntity·TeachingNoteGroupEntity·HomeworkEntity·QnaContextEntity·ReviewEntity·StudyNoteEntity·ColumnArticleEntity 등).
  • SoftDeleteBaseEntity(@MappedSuperclass) — BaseEntity를 상속해 LocalDateTime deletedAt 한 필드 추가.
  • 감사(audit): BaseEntity@PrePersist/@PreUpdatereg_date/mod_date를 직접 채운다(AuditingEntityListener 미사용 — @EnableJpaAuditingJpaAuditingConfig에 있으나 실제 리스너는 LikeEntity만 예외적으로 사용).
  • 삭제 연쇄 판단은 코드(도메인/서비스)에: FK는 실제로 걸되, soft delete는 DB ON DELETE CASCADE를 못 타므로 연쇄 삭제 순서·범위는 애플리케이션이 오케스트레이션한다(engineering-principles·DDL은 infra §2-6).

5. 인증 — JWT를 쿠키로 (헤더 아님)

⚠️ JWT는 Authorization 헤더/localStorage가 아니라 HttpCookie로 주고받는다(코드 확인).

  • 발급: AuthController(소셜은 OAuthController.setAuthCookies())가 로그인 성공 시 ResponseCookie.from("Authorization", accessToken) + ResponseCookie.from("refresh-token", refreshToken)Set-Cookie로 내려준다.
  • 수신: JwtAuthenticationFilter.getAccessToken()request.getCookies()를 순회해 이름 Authorization 쿠키 값을 읽는다(헤더 미사용). 없으면 BusinessException(AuthErrorCode.REQUIRED_ACCESS_TOKEN).
  • Refresh: refresh token은 in-memory ConcurrentHashMap(RefreshTokenRepositoryImplMap<Long,String>)에 저장하고 GET /api/auth/refresh에서 교차검증한다. ⚠️ 서버 재기동 시 소실·인스턴스 간 비공유(현 단일 인스턴스라 동작, Redis 이관은 후속). Redis는 알림·AI 레이트리밋·이메일 등 다른 용도에 쓴다.
  • 인가(SecurityConfig): SecurityFilterChain 3개@Order(1) Swagger, @Order(2) public, @Order(3) private. hasRole()/hasAnyRole() + RoleHierarchy(ADMIN > TEACHER, STUDENT, PARENT, MEMBER). 쿠키/CORS·SameSite 등 전송 계층은 infra §2-4.

6. API 명세·응답 래퍼 + role prefix

  • 응답 envelope: presentation/response/ApiResponse({status, message}) → SuccessResponse<T>(+ data, onSuccess(data) = {200, "성공입니다.", data}) / ErrorResponse(+ code).
  • 경로 role prefix: /api/public/**(permitAll) · /api/teacher/**·/api/student/**(역할별) · /api/common/**(TEACHER·STUDENT·PARENT, ADMIN은 RoleHierarchy로 포함) · /api/notification. 컨트롤러는 presentation/controller/<도메인>에 도메인 대칭으로 둔다.
  • Swagger 에러 명세 자동화: common/swagger/ErrorResponseAnnotation(메타 어노테이션)을 단 Api{Domain}ErrorResponses 어노테이션들을, ApiErrorResponsesCustomizer implements OperationCustomizer가 리플렉션으로 읽어 HTTP 상태별로 그룹화해 OpenAPI ApiResponse+Example을 생성한다. 새 도메인은 어노테이션 1개만 추가.
  • 사이트맵 API(SEO): GET /api/public/sitemap/{columns|profiles|previews} — 인증 불필요·페이지네이션 없음.

6.1 공통 Enums / 상수

분류
역할(Role) ROLE_ADMIN · ROLE_TEACHER · ROLE_STUDENT · ROLE_PARENT · ROLE_MEMBER(임시)
정렬 — 룸/선생 LATEST · OLDEST · ALPHABETICAL
정렬 — 질문/노트/과제 LATEST_EDITED · OLDEST_EDITED · TITLE_ASC · TAUGHT_AT_ASC · DEADLINE_IMMINENT · DEADLINE_RECENT
상태(공통) PENDING · COMPLETED · APPROVED · REJECTED · TERMINATED
제출(과제) NOT_SUBMIT · SUBMIT · LATE_SUBMIT
공개여부(visibility) PRIVATE · PUBLIC · TEACHER_ONLY · STUDENT_ONLY · STUDENT_AND_PARENT · SPECIFIC_STUDENTS_ONLY · STUDY_ROOM_STUDENTS_ONLY

7. 미디어 (Presigned URL / S3) — external/s3

첨부는 presigned URL 직접 업로드, 본문에는 media://{mediaId} 참조만 심고 조회 시 백엔드가 실제 URL로 치환한다.

단계 호출 / 클래스 골격
1) URL 발급(배치) MediaAssetService.createUploadUrl()POST /common/media/presign-batch req {mediaAssetList:[{fileName, contentType, sizeBytes}]} (※ targetType은 요청 바디가 아니라 이후 attachMediaAssets()에서 전달) → res [{fileName, mediaId, uploadUrl, headers}]
2) 직접 업로드 클라가 S3로 PUT {uploadUrl} Content-Type 헤더 필수, presigned 만료
3) 저장 리소스 POST content 안에 media://{mediaId} + mediaIds 동봉
4) 조회 치환 MediaAssetContentResolver.resolveMediaInContent() JSON 트리 DFS로 ^media://[\w-]+$를 presigned GET URL(기본 TTL 15분, s3.view-ttl-minutes)로 치환
  • S3 키: {keyPrefix}/uploader/{10자리 zero-pad id}/{image|file}/{mediaId}.{ext}(buildKey()).
  • TargetType enum 17종(TEACHING_NOTE·HOMEWORK·QNA·REVIEW·COLUMN_ARTICLE·STUDY_ROOM_THUMBNAIL·PROFILE_IMAGE·CHALLENGE_QUESTION…)으로 공개/비공개 S3 키 prefix를 나눈다. 프론트(Tiptap) 직렬화는 frontend-architecture §에디터·미디어.

8. 외부 연동 (external/)

  • external/ai — AI 코치. AiCoachingClient(interface) + 구현 3종 AnthropicCoachingClient·OpenAiCoachingClient·StubAiCoachingClient(프로바이더 전환), AiCoachingProperties. 오픈챌린지 사고력 코칭이 여기를 탄다(오픈챌린지).
  • external/s3S3Service(presigned 발급·조회, §7).
  • external/sens — 네이버 클라우드 SENS 알림톡 클라이언트(카카오 알림톡 발송, 카카오 알림 (알림 고도화)).

9. 데이터 모델 (상속 전략 · ERD)

  • Member 상속은 단일 테이블이 아니다: 도메인 계층은 Java 상속(Teacher extends Member)이지만, DB는 조인 전략TeacherEntity/StudentEntity@OneToOne(MemberEntity) + @MapsIdmember 본 테이블과 PK를 공유하는 별도 테이블(teacher·student). MemberEntityDTYPE 없음.
  • 전체 모델은 theEdu (erdcloud), 운영 DDL·테이블↔도메인·FK 매핑은 infra §2-6.

10. Config

config/: CorsProperties · JpaAuditingConfig(@EnableJpaAuditing) · QueryDslConfig(JPAQueryFactory) · RedisConfig · SwaggerConfig · WebClientConfig · WebConfig. config/properties/FrontendOriginProperties(@ConfigurationProperties("auth.frontend")baseUrl·allowedOrigins 등, 프리뷰 CORS는 infra §2-4).


관련

frontend-architecture · infra · version-control · tech-decisions · engineering-principles · 회원 도메인 (Member)