엔지니어링 원칙 & 트레이드오프¶
요지¶
이 페이지는 컨벤션 목록이 아니라 우리가 왜 그렇게 결정하는가를 담는다. 작업 로그, 기술 스터디, 리팩토링 논의에서 반복적으로 같은 판단이 나오는 지점들을 모았다. 개별 컨벤션과 도메인 모델의 정본은 backend-architecture와 각 도메인 페이지에 있고, 결정의 배경은 tech-decisions에서 다룬다.
핵심 원칙¶
- 소스코드가 단일 원천(SSOT), DB는 유연성 레이어. 정합성·삭제 연쇄의 판단은 애플리케이션 코드(도메인/서비스)를 진실의 원천으로 둔다. 단 이게 "FK를 안 건다"는 뜻은 아니다 — 운영 DDL은 모든 연관 테이블에 FK 제약을 실제로 건다(JPA
@ManyToOne/@JoinColumn이 생성).Long teacherId만 필드로 들고 FK를 안 만드는 쪽이 오히려 안티패턴이다(§삭제·backend-architecture). 우리가 코드로 옮긴 건 삭제 연쇄 로직이다 — soft delete는 DBON DELETE CASCADE를 못 타기 때문(아래 표). (Flyway 컨벤션: 한 스크립트 = 한 관심사, 한 번 적용된 파일 수정 금지.) - 명령형 ≠ 조회형, 아키텍처를 분리한다. 조회용 API는
Controller → Service → Repository계층형, 명령형 API는Controller → Service → Domain ← Port ← Adapter포트-어댑터. 확실한 도메인 불변식이 아니면 로직은 Service에 둔다(도메인 과설계 회피). → backend-architecture - 벌크 우선, N+1을 구조로 차단. 루프 안 단건 처리 금지. ID 목록 기반 벌크 쿼리를 강제하는 모듈을 만들어 실수 자체를 막는다(§삭제). 영속화는 새 Entity 생성이 아니라 기존 Entity 업데이트.
- 새 클래스보다 ENUM 한 줄. 예외도 에러 코드도 새 타입을 늘리기보다 기존 구조(단일
BusinessException+ 도메인별ErrorCodeenum)에 한 줄 추가. - 개발비용과 저울질해 '지금은 안 한다'를 명시적으로 결정한다. "이상적 안" 대신 "현재 규모에 맞는 안"을 택하고, 전환 조건(예: 소셜로그인 도입 시 토큰 전략 재설계)을 미래 트리거로 기록한다.
- 의존성 방향을 지킨다. core는 infrastructure를 import하지 않는다(FE의 domain↛dto, BE의 도메인↛어댑터). 변환 책임은 항상 바깥 레이어(repository/adapter)에.
판단 기준 · 의사결정 룰 (이럴 땐 이렇게)¶
삭제 (Soft / Hard Delete) — 가장 많은 work log가 쌓인 주제¶
| 층위 | 결정 | 근거 |
|---|---|---|
| 정책 | 도메인별로 soft / hard 선택 — 사용자·콘텐츠(Member, Homework, StudyRoom…)는 soft(deleted_at), 휘발성·외부연동(Notification, MediaAsset, 초대링크)은 hard |
복구 필요성 대 데이터 위생을 도메인마다 따로 저울질 |
| 연쇄 | DB ON DELETE CASCADE가 아니라 앱(서비스) 코드가 처리 |
soft delete는 레코드를 안 지우고 deleted_at만 찍으므로 DB cascade가 안 탄다. 자식을 엔티티 단건으로 지우면 N+1이므로 연쇄는 벌크 @Modifying @Query("UPDATE … SET deleted_at = NOW() WHERE … IN :ids")로 한 번에. (DDL상 일부 FK는 ON DELETE CASCADE/SET NULL을 갖지만 soft 경로에선 의미가 없어 코드로 처리) |
| 구현 | 엔티티마다 @SQLDelete + @SQLRestriction("deleted_at IS NULL"), 공통 deletedAt은 SoftDeleteBaseEntity |
단건 soft delete는 매핑이 자동 처리(조회 시 deleted_at IS NULL 필터). 연쇄·순서는 각 서비스가 벌크 UPDATE로 직접 오케스트레이션한다 — 중앙 오케스트레이터 클래스는 없다(과거 문서의 SoftDeleteOrchestrator는 미구현 구상이었음) |
연쇄 삭제를 서비스마다 단건 루프로 짜면 N+1·삭제 순서 오류가 따라온다 — 그래서 연쇄는 벌크 UPDATE 한 번으로 모은다. 실제 매핑·적용 엔티티는 backend-architecture §4.
예외 처리¶
- 단일
BusinessException+ 도메인별ErrorCodeenum으로 모든 비즈니스 예외를 표현. 새 상황 = enum 상수 한 줄, 새 예외 클래스 안 만듦. ErrorCode인터페이스가status+message제공,ErrorAdvice(@RestControllerAdvice)가 일관 JSON({status, message})으로 변환 → 호출부는 포맷을 신경 안 씀.- 트레이드오프: 코드량·일관성을 얻는 대신 예외별 세분화 타입 캐치는 포기(enum 분기로 대체). 현재 규모에선 이득이 큼.
"안 한 결정"을 트리거와 함께 박제¶
- JWT 전달/보관: 현재 코드는 HttpCookie 방식 — access는
Authorization쿠키, refresh는refresh-token쿠키로 내려주고 필터(JwtAuthenticationFilter)도 쿠키에서 읽는다. refresh는 in-memoryConcurrentHashMap(RefreshTokenRepositoryImpl)에 저장해GET /api/auth/refresh에서 교차검증(서버 재기동 시 소실 — Redis 이관 후속). 인가는hasRole+RoleHierarchy(ADMIN > TEACHER/STUDENT/PARENT/MEMBER). 전송 계층(SameSite·도메인)은 infra §2-4, 구현은 backend-architecture §5. - 조회 쿼리 방식 선택: "쿼리 작성(Derived/JPQL/Native/QueryDSL)"과 "결과 매핑(Entity/Interface·Class Projection)" 두 축을 독립적으로 보고, 단순 단건은 Derived, 복잡/동적은 QueryDSL, 응답 전용은 Projection. 도구를 미리 정하지 않고 판단 기준을 공유(전체 결정 트리는 backend-architecture §2).
- 리팩토링은 배치로, 우선순위를 매겨: 테스트 패키지 재편(unit/integration 분리, mocking은 unit) 등 큰 변경은 일정에 묶어 리스트로 추적.
도메인 특화 룰¶
- 권한은 Role prefix로 URL에서 가른다. STUDENT/TEACHER/PARENT 역할별 prefix, 회원공통은
common, 비회원 공개는public(조건부 인증 — 분기는 Service에서). 콘텐츠별 접근 정책(스터디룸 PUBLIC/PRIVATE, 수업노트 6단계 공개범위 등)은 enum 정책값으로 모델링한다. 권한의 정본은 회원 도메인 (Member)의 역할별 접근 권한 절이다. - 연결은 "요청→수락" 2단계. 학생→선생님, 학부모→학생 연결 모두 단방향 입력 후 상대 수락으로 확정 → 무단 접근 차단.
- Entity는 ID 참조로 결합도를 낮춘다.
StudyRoomEntity가TeacherEntity객체를 직접@ManyToOne참조하던 것을 ID 참조로 끊는 방향(DDD 리팩토링). 사용하지 않는 도메인 객체 의존성 제거가 반복 작업. - 검증 규칙은 로그인·회원가입 공용 테이블로 통일(V-01~V-18: 이메일/비밀번호/약관).
프론트엔드 룰¶
- 레이어드(기능 단위로 폴더를 자르는 FSD풍) 의존성 단방향:
features(UI) → entities/core(domain) → entities/infrastructure(repository/dto) → shared/api. API 호출은 무조건entities/{f}/infrastructure에,features엔 금지. - DTO를 UI에 노출하지 않는다. 변환 없으면 domain 스키마 생략하고 dto에서 타입 export, 변환 있으면 domain 스키마(순수, dto import 금지) + repository가 변환 담당. 컴포넌트는 항상 "최종 형태" 타입만.
- 상태 분리: 서버 상태=TanStack Query, 클라 상태=Zustand(Props Drilling/useContext 회피).
- 기술 도입은 "이유가 확실할 때": 드로잉(PDF 위 필기, stroke JSON 저장 — perfect-freehand·AI 인식 유리), 인강(Vimeo API+Player SDK) 등은 스터디로 근거를 먼저 세운 뒤 채택.
하지 말 것 (안티패턴)¶
- 루프 안 단건 처리 — N+1을 부른다. ID 목록 벌크 쿼리(
@Modifying UPDATE … IN :ids)로 대체. - 연쇄 삭제를 서비스마다 흩어놓기 — 중복 코드·삭제 순서 오류를 부른다. 한 엔진에 모은다.
- 새 상황마다 예외 클래스 추가 —
ErrorCodeenum 한 줄로 끝낸다. Long teacherId필드로만 연관 들고 있기(= FK 제약 안 생성) — JPA@ManyToOne로 연관을 맺어 FK가 생성되게 한다. 무결성 판단은 코드가 책임지되, 영속 엔티티는 어댑터에서findById로 안전 조립. (안 쓰는 건 FK 자체가 아니라 soft delete 경로의 DB cascade다.)- core가 infra를 import (FE의
domain.ts가 dto import / BE 도메인이 어댑터 의존) — 의존성 역전 금지. features에 API 호출 코드 두기 —entities/{f}/infrastructure로만.- DTO를 컴포넌트에 직접 노출 — 항상 변환된 "최종 형태" 타입만 쓴다.
예시 · 사례¶
- 좋은 예 — 신규 도메인 soft delete: 엔티티에
@SQLDelete+@SQLRestriction선언 + 연쇄가 있으면 서비스에서 벌크@Modifying UPDATE … IN :ids1회. - 좋은 예 — 새 에러:
AuthErrorCode.INVALID_CREDENTIALSenum 상수 추가 +throw new BusinessException(...). - 나쁜 예 — 연쇄 삭제를 자식 엔티티 단건 루프(
repository.delete(child))로 돌려 statement가 자식 수만큼 나가는 구조(1+N). 벌크@Modifying @Query("UPDATE ... SET deleted_at = NOW() WHERE … IN :ids")1회로 대체. - 알려진 빚:
TeachingNoteComment는deleted_at은 있으나@SQLDelete누락(비일관),deleteAll의 타입 추론(targets.get(0).getClass())이 Member 상속 계층(Teacher/Student는@OneToOne+@MapsId조인 테이블)에서 깨질 수 있음 — 둘 다 검토 대기(회원 도메인 (Member)).
관련¶
backend-architecture · tech-decisions · version-control · 회원 도메인 (Member) · ai-collab-apply · version-control