멋쟁이 사자처럼 13기 강의를 진행하며 만든 자료를 블로그에 공유합니다.
| Create | 데이터 생성 | POST |
|---|---|---|
| Read | 데이터 조회 | GET |
| Update | 데이터 수정 | PUT / PATCH |
| Delete | 데이터 삭제 | DELETE |
도메인은 실제 데이터베이스와 매핑되며 시스템의 핵심 데이터를 표현하는 클래스
각 필드는 데이터베이스의 컬럼과 연결되며, 비즈니스 중심의 핵심 데이터를 담고 있다
package com.likelion.likelioncrud.member.domain;
import com.likelion.likelioncrud.member.api.dto.request.MemberUpdateRequestDto;
import com.likelion.likelioncrud.post.domain.Post;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long memberId;
private String name;
private int age;
@Enumerated(EnumType.STRING)
@Column(length = 20)
private Part part;
@OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Post> posts = new ArrayList<>();
@Builder
private Member(String name, int age, Part part) {
this.name = name;
this.age = age;
this.part = part;
}
public void update(MemberUpdateRequestDto memberUpdateRequestDto) {
this.name = memberUpdateRequestDto.name();
this.age = memberUpdateRequestDto.age();
}
}데이터베이스에 직접 접근하는 계층으로, Spring Data JPA가 제공하는 JpaRepository를 상속받아 기본적인 CRUD 메서드를 사용할 수 있다
도메인 객체를 저장하거나 조회할 때 이 계층을 사용한다
package com.likelion.likelioncrud.member.domain.repository;
import com.likelion.likelioncrud.member.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, Long> {
}DTO는 클라이언트와 서버 간에 데이터를 전송하기 위한 객체이다
요청(Request)과 응답(Response)에 따라 서로 다른 구조로 설계한다
package com.likelion.likelioncrud.member.api.dto.request;
import com.likelion.likelioncrud.member.domain.Part;
public record MemberSaveRequestDto(
String name,
int age,
Part part
) {
}비즈니스 로직(실제 처리 내용)을 수행하는 계층이다
데이터 저장, 조회, 연산 등 핵심 처리 담당
package com.likelion.likelioncrud.member.application;
import java.util.List;
import com.likelion.likelioncrud.member.api.dto.request.MemberSaveRequestDto;
import com.likelion.likelioncrud.member.api.dto.request.MemberUpdateRequestDto;
import com.likelion.likelioncrud.member.api.dto.response.MemberInfoResponseDto;
import com.likelion.likelioncrud.member.api.dto.response.MemberListResponseDto;
import com.likelion.likelioncrud.member.domain.Member;
import com.likelion.likelioncrud.member.domain.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
// 사용자 정보 저장
@Transactional
public void memberSave(MemberSaveRequestDto memberSaveRequestDto) {
Member member = Member.builder()
.name(memberSaveRequestDto.name())
.age(memberSaveRequestDto.age())
.part(memberSaveRequestDto.part())
.build();
memberRepository.save(member);
}
// 사용자 모두 조회
public MemberListResponseDto memberFindAll() {
List<Member> members = memberRepository.findAll();
List<MemberInfoResponseDto> memberInfoResponseDtoList = members.stream()
.map(MemberInfoResponseDto::from)
.toList();
return MemberListResponseDto.from(memberInfoResponseDtoList);
}
// 단일 사용자 조회
public MemberInfoResponseDto memberFindOne(Long memberId) {
Member member = memberRepository
.findById(memberId)
.orElseThrow(IllegalArgumentException::new);
return MemberInfoResponseDto.from(member);
}
// 사용자 정보 수정
@Transactional
public void memberUpdate(Long memberId, MemberUpdateRequestDto memberUpdateRequestDto) {
Member member = memberRepository.findById(memberId).orElseThrow(IllegalArgumentException::new);
member.update(memberUpdateRequestDto);
}
// 사용자 정보 삭제
@Transactional
public void memberDelete(Long memberId) {
Member member = memberRepository.findById(memberId).orElseThrow(IllegalArgumentException::new);
memberRepository.delete(member);
}
}사용자의 HTTP 요청을 받아 처리하고 응답을 돌려주는 역할
적절한 HTTP 메서드와 함께 @RequestMapping, @PostMapping, @GetMapping 등을 사용하여 API를 정의한다
package com.likelion.likelioncrud.member.api;
import com.likelion.likelioncrud.member.api.dto.request.MemberSaveRequestDto;
import com.likelion.likelioncrud.member.api.dto.request.MemberUpdateRequestDto;
import com.likelion.likelioncrud.member.api.dto.response.MemberInfoResponseDto;
import com.likelion.likelioncrud.member.api.dto.response.MemberListResponseDto;
import com.likelion.likelioncrud.member.application.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {
private final MemberService memberService;
// 사용자 저장
@PostMapping("/save")
public ResponseEntity<String> memberSave(@RequestBody MemberSaveRequestDto memberSaveRequestDto) {
memberService.memberSave(memberSaveRequestDto);
return new ResponseEntity<>("사용자 저장!", HttpStatus.CREATED);
}
// 사용자 전체 조회
@GetMapping("/all")
public ResponseEntity<MemberListResponseDto> memberFindAll() {
MemberListResponseDto memberListResponseDto = memberService.memberFindAll();
return new ResponseEntity<>(memberListResponseDto, HttpStatus.OK);
}
// 회원 id를 통해 특정 사용자 조회
@GetMapping("/{memberId}")
public ResponseEntity<MemberInfoResponseDto> memberFindOne(@PathVariable("memberId") Long memberId) {
MemberInfoResponseDto memberInfoResponseDto = memberService.memberFindOne(memberId);
return new ResponseEntity<>(memberInfoResponseDto, HttpStatus.OK);
}
// 회원 id를 통한 사용자 수정
@PatchMapping("/{memberId}")
public ResponseEntity<String> memberUpdate(@PathVariable("memberId") Long memberId,
@RequestBody MemberUpdateRequestDto memberUpdateRequestDto) {
memberService.memberUpdate(memberId, memberUpdateRequestDto);
return new ResponseEntity<>("사용자 수정", HttpStatus.OK);
}
// 회원 id를 통한 사용자 삭제
@DeleteMapping("/{memberId}")
public ResponseEntity<String> memberDelete(@PathVariable("memberId") Long memberId) {
memberService.memberDelete(memberId);
return new ResponseEntity<>("사용자 삭제", HttpStatus.OK);
}
}URI는 정보의 리소스를 표현해야 한다
// REST를 제대로 적용하지 않은 URI 예시
GET /members/delete/1
// REST 적용 예시 1
GET /members/delete/1 (X)
DELETE /members/1 (O)
// REST 적용 예시 2
GET /members/show/1 (X)
GET /members/1 (O)
// REST 적용 예시 3
GET /members/insert/3 (X)
POST /members/3 (O)
EX) REST는 자원 중심 설계 지향 → /getUser는 REST 위반
올바른 방식 : /user/{id} + GET
| 예외 상황 | HTTP 코드 | 설명 |
|---|---|---|
| 잘못된 입력값 | 400 Bad Request | 필드 유효성 검증 실패 |
| 리소스 없음 | 404 Not Found | 존재하지 않는 ID |
| 서버 내부 오류 | 500 Internal Server Error | NullPointer, DB 다운 등 |
| 인증 실패 | 401 Unauthorized | JWT 누락, 로그인 필요 |
| 권한 없음 | 403 Forbidden | 접근 권한 없음 |
프로젝트 전반에서 사용될 성공, 실패 응답 코드를 한 곳에 정의
응답 메시지, 상태 코드, 코드명을 함께 정의하여 일관된 응답을 생성
예시:
NOT_FOUND_404("해당 리소스를 찾을 수 없습니다.")INTERNAL_SERVER_ERROR_500("서버 내부 오류입니다.")비즈니스 로직 중 발생 가능한 예외를 처리하기 위한 커스텀 예외 클래스
각 예외에 적절한 ErrorCode를 전달하여 일관된 응답 제공
package com.likelion.likelioncrud.common.exception;
import com.likelion.likelioncrud.common.error.ErrorCode;
import lombok.Getter;
@Getter // getter 메소드 자동 생성 lombok 어노테이션
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode; // 에러 코드 ex) NOT_FOUND
private final String customMessage; // 사용자 정의 예외 메시지(커스텀)
// 생성자
public BusinessException(ErrorCode errorCode, String customMessage) {
super(customMessage); // RuntimeException의 생성자에 메시지 전달
this.errorCode = errorCode;
this.customMessage = customMessage;
}
}모든 API 응답을 동일한 포맷으로 구성하기 위한 템플릿
code, message, data 구조
package com.likelion.likelioncrud.common.template;
import com.likelion.likelioncrud.common.error.ErrorCode;
import com.likelion.likelioncrud.common.error.SuccessCode;
import lombok.*;
@Getter // getter 메소드 자동 생성 lombok 어노테이션
@AllArgsConstructor(access = AccessLevel.PRIVATE) // 모든 필드 값을 매개변수로 받는 생성자 자동 생성
@RequiredArgsConstructor(access = AccessLevel.PRIVATE) // final 붙은 필드만 매개변수로 받는 생성자 자동 생성
@Builder // 빌더 패턴
public class ApiResTemplate<T> {
private final int code; // 응답 코드 (200, 404 등)
private final String message; // 응답 메시지
private T data; // 응답 데이터 (제네릭 형식으로 다양한 타입 수용 가능)
// 데이터 없는 성공 응답
public static ApiResTemplate successWithNoContent(SuccessCode successCode) {
return new ApiResTemplate<>(successCode.getHttpStatusCode(), successCode.getMessage());
}
// 데이터 포함한 성공 응답
public static <T> ApiResTemplate<T> successResponse(SuccessCode successCode, T data) {
return new ApiResTemplate<>(successCode.getHttpStatusCode(), successCode.getMessage(), data);
}
// 에러 응답 (커스텀 메시지 포함)
public static ApiResTemplate errorResponse(ErrorCode errorCode, String customMessage) {
return new ApiResTemplate<>(errorCode.getHttpStatusCode(), customMessage);
}
}
커스텀 예외 클래스를 정의하고 이를 처리
모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리
package com.likelion.likelioncrud.common.exception;
import com.likelion.likelioncrud.common.error.ErrorCode;
import com.likelion.likelioncrud.common.exception.BusinessException;
import com.likelion.likelioncrud.common.template.ApiResTemplate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.*;
@Slf4j // 로깅을 위한 Logger를 생성
@RestControllerAdvice // REST API 컨트롤러에 대한 예외 처리 어드바이스임을 나타내는 어노테이션
@Component // 클래스를 Spring 컴포넌트로 등록
@RequiredArgsConstructor
public class CustomExceptionAdvice {
/**
* 500 Internal Server Error
* 원인 모를 이유의 예외 발생 시
* 모든 종류의 Exception 처리
*/
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // HTTP 500 상태 코드 반환 어노테이션
@ExceptionHandler(Exception.class) // 모든 종류의 Exception 처리
public ApiResTemplate handleServerException(final Exception e) {
log.error("Internal Server Error: {}", e.getMessage(), e); // 로그 출력
return ApiResTemplate.errorResponse(ErrorCode.INTERNAL_SERVER_ERROR, e.getMessage());
}
/**
* custom error
* 내부 커스텀 에러
* BusinessException 처리
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResTemplate> handleCustomException(BusinessException e) {
log.error("CustomException: {}", e.getMessage(), e); // 로그 출력
ApiResTemplate<?> body = ApiResTemplate.errorResponse(e.getErrorCode(), e.getMessage());
// 에러 코드에 정의된 HTTP 상태 코드로 응답
return ResponseEntity
.status(e.getErrorCode().getHttpStatus())
.body(body);
}
}
엔티티의 하나의 데이터가 같은 엔티티의 다른 데이터와 관계를 가지는 것
즉, 엔티티가 자기 자신과 관계를 가지는 것
EX)

학생의 학부모가 한 명 있다고 가정하면 위 사진처럼 관계를 맺는다
하지만 학생이 자라서 학부모가 된다면 위 모델은 수용할 수 없다

그러면 위 사진처럼 학생 밑에 새로운 엔티티를 추가하면 해결이 가능하다
하지만 학생_1이 다시 자라 학부모가 된다면 이 모델은 다시 수용할 수 없어진다
수용하려면 다시 엔티티를 추가해야하는데 좋은 모델이란 엔티티의 최소한의 변경으로 변화에 대응해야 한다
이럴 때 사용하는 것이 자기참조관계이다

학부모와 학생은 모두 사람이기에 통합하고 자기참조관계선을 추가해 학생의 학부모가 누구인지 관리할 수 있다
여기서 필수적인 관계로 만들면 최상위 부모 개체가 만들어 질 수 없기에 Null값을 허용한 관계선을 설정해야 한다
JPA에서는 두 엔티티 간의 다대다 관계를 맺을 때 @ManyToMany 어노테이션을 사용할 수 있다
하지만 실무에서는 이를 직접 사용하는 것을 지양한다 →
⇒ 따라서 다대다 관계를 일대다 - 다대일 관계로 풀어서 구현한다
따라서 @ManyToMany 를 직접 사용하지 않고 중간 엔티티(PostTag)를 만들어
게시글(Post) ↔ 게시글-태그(PostTag) (일대다)태그(Tag) ↔ 게시글-태그(PostTag) (일대다)로 나누어 구현해보겠습니다~
멋쟁이 사자처럼 강의 자료와 운영진에게 질문을 통해 실습을 해 보아요~
기존 crud와 예외처리, 폼 검증을 하던 프로젝트에 이어서 진행할게요!

post 조회 시 tag들을 함께 출력
PostTag라는 중간 엔티티를 이용해 다대다 관계를 설정

crud 강의와 동일하게 tag의 저장, 전체 조회, 단건 조회, 수정, 삭제
→ 오늘 목표를 강의 시간에 다 만드려니 시간이 너무 짧죠??
그래서 오늘 강의 시간 목표는 따로 있고, 나머지 목표들은 과제로 해 주시면 됩니다!
오늘의 목표를 완성하신 분들은 귀가하시면 됩니다~ 그러니 운영진들에게 질문하며 진행해 주세요! 지피티는 사용하지 않기!!
게시글(Post)에는 여러 개의 태그(Tag)를 달 수 있습니다!
예를 들어 “Spring”, “CRUD”같은 태그를 붙일 수 있겠죠?
이런 구조는 다대다 관계이지만 우리는 Post와 Tag사이에 PostTag라는 중간 엔티티를 사용하여 설계하겠습니다!!
Tag엔티티와 PostTag 엔티티를 작성해 주세요
Post엔티티는 이미 CRUD 강의 시간에 작성했으니 그 코드에 Post와의 연관관계를 추가하시면 됩니다
<힌트>
Ex)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
아무런 매개변수가 없는 생성자를 생성하되 같은 패키지나 하위 클래스에서만 접근 가능
JPA가 클래스의 기본 생성자를 호출해서 객체를 만들고 그 위에 프록시를 씌워 지연 로딩을 가능하게 함
AllArgsConstructor, RequiredArgsConstructor
```java
@AllArgsConstructor // 모든 필드 값을 파라미터로 받는 생성자를 생성
@RequiredArgsConstructor // final이나 @NonNull으로 선언된 필드만을 파라미터로 받는 생성자를 생성
```
@Id // 기본 키(PK)를 지정
@GeneratedValue(strategy = GenerationType.IDENTITY)
GeneratedValue 어노테이션은 기본 키 값을 자동 생성
straregy의 속성은 4가지
- AUTH : 특정 DB에 맞게 자동 선택(기본값)
- IDENTITY : 기본 키 생성을 데이터베이스에 위임(ex.MYSQL등)
- SEQUENCE : 데이터베이스 시퀀스를 사용해 기본키 할당(ex.ORACLE, H2등)
- TABLE : 키 생성 전용 테이블을 만들어 SEQUENCE처럼 사용(ex. 모든 DBMS)
@Column(name = "member_id", nullable = false, length = 10)
Column어노테이션의 속성
- name : DB 컬럼명 지정
- nullable : null 허용 여부
- length : 문자 길이 제한
@ManyToOne(fetch = FetchType.LAZY)
fetch = FetchType.Lazy → 지연 로딩 : 연관 관계를 즉시 로딩하지 않고 실제로 사용할 때 DB에서 조회 (성능 최적화)
연관된 엔티티를 프록시 객체로 생성해 두었다가 실제 접근이 일어날 때 DB에서 로딩
프록시 : 실제 엔티티 객체 대신 데이터베이스 조회를 지연할 수 있는 가짜 객체
즉시 로딩
@ManyToOne(fetch = FetchType.EAGER)
즉시 로딩을 사용하게 되면 예를 들어 연관된 엔티티의 데이터를 수만 건 등록했을 때, 연간 된 엔티티를 로딩하는 수간 수만 건의 데이터가 함께 로딩될 것 → 그렇기에 연관된 엔티티가 하나면 즉시 로딩을, 아니라면 지연 로딩을 사용
ManyToOne, OneToMany에서는 지연 로딩이 권장됨
@JoinColumn(name = "member_id")
JoinColumn은 엔티티의 연관관계에서 외래 키를 매핑하기 위해 사용됨
내가 외래 키를 가지고 있을 때, 그 외래 키가 어떤 컬럼으로 매핑될지 지정함
name속성은 매핑할 외래 키의 이름을 지정함
+속성
결론적으로 참조하는 엔티티가 존재할 경우 JoinColumn으로 FK를 매핑하는 필드의 이름과 대상 엔티티의 PK로 FK 이름이 지정됨
JoinColumn은 외래 키를 소유하는 쪽에서 사용, 반대편에서는 mappedBy 사용
외래 키를 직접 소유 (vs mappedBy)
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
mappedBy = "post" → PostTag 엔티티의 post 필드가 연관관계의 주인
cascade = ALL → 게시글 저장/삭제 시 PostTag도 함께 처리됨
orphanRemoval = true → Post에서 PostTag 제거 시 DB에서도 삭제됨


Post 엔티티
package com.likelion.likelioncrud.post.domain;
import com.likelion.likelioncrud.member.domain.Member;
import com.likelion.likelioncrud.post.api.dto.request.PostUpdateRequestDto;
import com.likelion.likelioncrud.posttag.domain.PostTag;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
@Id
@Column(name = "post_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long postId;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String contents;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;
// 일대다 관계 하나의 게시글은 여러 PostTag와 연결 가능
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PostTag> postTags = new ArrayList<>();
@Builder
private Post(String title, String contents, Member member) {
this.title = title;
this.contents = contents;
this.member = member;
}
public void update(PostUpdateRequestDto postUpdateRequestDto) {
this.title = postUpdateRequestDto.title();
this.contents = postUpdateRequestDto.contents();
}
}Tag 엔티티
package com.likelion.likelioncrud.tag.domain;
import com.likelion.likelioncrud.posttag.domain.PostTag;
import com.likelion.likelioncrud.tag.api.dto.request.TagUpdateRequestDto;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Tag {
@Id
@Column(name = "tag_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
// PostTag와의 일대다 연관관계
@OneToMany(mappedBy = "tag", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PostTag> postTags = new ArrayList<>();
@Builder
public Tag(String name) {
this.name = name;
}
public void update(TagUpdateRequestDto tagUpdateRequestDto) {
this.name = tagUpdateRequestDto.name();
}
}
PostTag 엔티티
package com.likelion.likelioncrud.posttag.domain;
import com.likelion.likelioncrud.post.domain.Post;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import com.likelion.likelioncrud.tag.domain.Tag;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PostTag {
@Id
@Column(name = "post_tag_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 다대일 관계, 여러 PostTag가 하나의 Post에 연결 가능
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_id", nullable = false)
private Post post;
// 다대일 관계, 여러 PostTag가 하나의 Tag에 연결 가능
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "tag_id", nullable = false)
private Tag tag;
public PostTag(Post post, Tag tag) {
this.post = post;
this.tag = tag;
}
}