POJO로 프로젝트를 진행할 일이 없을 것 같아 JPA 내용과 함께 포함하여 작성합니다!
(사실 경진대회, 시험때문에 밀림...)
REST API
모든 것은 Resource이고, 각 자원은 고유한 URI 로 식별되며, 자원에 대한 행위는 HTTP 메서드로 표현합니다
자원(Resource)
- 서버에 있는 데이터 (User, Post, Product 등)
행위(Verb)
- 자원에 대해 수행할 동작 (CRUD) : GET, POST, PUT, DELETE ...
- GET : 자원 조회
GET /users -> 모든 사용자 조회 - POST : 자원 생성
POST /users -> 새 사용자 생성 - PUT : 자원 전체 수정
PUT /users/1 -> ID 1번 사용자 전체 수정 - PATCH : 자원 일부 수정
PATCH /users/1 -> 일부 속성만 수정 - DELETE : 자원 삭제
DELETE /users/1 -> ID 1번 사용자 삭제
표현(Representation)
- 자원을 주고받는 형식 : JSON, XML (대부분 JSON 사용)
RESTful 하다
- 클라이언트-서버 구조 : 클라이언트(요청)와 서버(응답)가 분리되어 있음
- 무상태성 (Stateless) : 요청마다 필요한 모든 정보를 포함 (세션 X)
- 캐시 가능성 : GET 응답 등은 캐시로 성능 향상 가능
- 계층 구조 : 프록시, 게이트웨이 등을 중간에 둘 수 있음
- 일관된 인터페이스 : URL, 메서드, 응답 구조가 일관되어야 함
저는 대부분 Domain -> Repository, DTO -> Service -> Controller 순으로 구현합니다
Domain
domain 폴더는 일반적으로 DB의 테이블과 매핑될 엔티티나 모델 클래스를 넣는 곳입니다
package gdg.restapi.domain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Student {
private Long id;
private String name;
private String major;
}
@Data 어노테이션
@Getter/@Setter: 모든 필드에 대해 getter, setter 메서드 생성@ToString: 객체 정보를 문자열로 출력할 때 자동 포맷 생성@EqualsAndHashCode: 객체 비교를 위한 equals() / hashCode() 자동 생성@RequiredArgsConstructor: final 필드에 대한 생성자 생성 (이 클래스에선 final이 없으므로 무시됨)
즉, @Data 하나만 붙이면 객체 데이터 클래스로서 필요한 거의 모든 메서드를 자동으로 만들어줍니다
@NoArgsConstructor 어노테이션
기본 생성자를 자동으로 만들어줍니다
public Student() {}
@AllArgsConstructor 어노테이션
모든 필드를 매개변수로 받는 생성자를 자동으로 만들어줍니다
new Student(1L, "엄준식", "동탄시얼짱공학과") 같은 식으로 객체를 바로 생성할 수 있게 해줍니다
public Student(Long id, String name, String major) {
this.id = id;
this.name = name;
this.major = major;
}
클래스 정의 부분
public class Student {
private Long id;
private String name;
private String major;
}
이 클래스는 학생(Student)이라는 하나의 도메인을 표현합니다
- id: 학생의 고유 식별자 (번호)
- name: 학생 이름
- major: 전공 이름
Repository
레포지토리는 데이터베이스와 애플리케이션 사이에서 데이터를 CRUD 역할을 담당하는 계층입니다
StudentRepository (추상 클래스)
어떤 메서드가 필요한지 명세만 정의한 틀
이 인터페이스를 기반으로 실제 구현체(StudentRepositoryImpl)를 작성합니다
“우리는 이런 메뉴를 제공할 거야.
하지만 아직 주방장이 요리법을 구현하진 않았어.”
package gdg.restapi.repository;
import gdg.restapi.domain.Student;
import java.util.List;
import java.util.Optional;
public interface StudentRepository {
Student save(Student student);
List<Student> findAll();
Optional<Student> findById(Long id);
Student update(Long id, Student student);
boolean delete(Long id);
}
- save(Student student) : 새로운 학생을 저장 / 반환 타입 :
Student - 학생 데이터를 저장하고, 저장된 객체(보통 id가 포함된 상태)를 반환
- findAll() : 모든 학생 조회 / 반환 타입 :
List<Student> - 저장된 모든 학생 정보를 리스트로 반환
- findById(Long id) : 특정 학생 조회 / 반환 타입 :
Optional<Student> - 해당 id의 학생을 찾으면 반환, 없으면 비어있음
- update(Long id, Student student) : 학생 정보 수정 / 반환 타입 :
Student - 특정 id 학생의 정보를 새로운 값으로 업데이트
- delete(Long id) : 학생 삭제 / 반화 타입 :
boolean - 삭제 성공 여부를 true/false로 반환
StudentRepositoryImpl (구현체)
StudentRepository를 구현한 Repository 클래스로, “학생 데이터를 실제로 저장하고 관리하는 기능”을 담당하는 부분입니다
package gdg.restapi.repository;
import gdg.restapi.domain.Student;
import org.springframework.stereotype.Repository;
import java.util.*;
@Repository
public class StudentRepositoryImpl implements StudentRepository {
private final Map<Long, Student> store = new HashMap<>();
private Long sequence = 0L;
@Override
public Student save(Student student) {
student.setId(++sequence);
store.put(student.getId(), student);
return student;
}
@Override
public List<Student> findAll() {
return new ArrayList<>(store.values());
}
@Override
public Optional<Student> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public Student update(Long id, Student student) {
store.put(id, student);
return student;
}
@Override
public boolean delete(Long id) {
return store.remove(id) != null;
}
}
@Repository & implements StudentRepository
@Repository
public class StudentRepositoryImpl implements StudentRepository {
- @Repository
- 스프링이 자동으로 빈(bean) 으로 등록해서, 나중에
@Autowired나@RequiredArgsConstructor로 주입받을 수 있습니다 - implements StudentRepository
- 앞에서 정의한
StudentRepository인터페이스를 실제로 구현한다는 뜻
필드
private final Map<Long, Student> store = new HashMap<>();
private Long sequence = 0L;
- store : 학생 데이터를 임시로 저장하는 메모리 저장소데이터베이스 대신 사용하는 구조 (서버 꺼지면 데이터 사라짐)
- 키(Long id)와 값(Student)으로 이루어진 HashMap 형태
- 학생의 고유 id 값을 자동으로 1씩 증가시키는 시퀀스 역할
save(Student student)
public Student save(Student student) {
student.setId(++sequence); // id 자동 증가
store.put(student.getId(), student); // HashMap에 저장
return student; // 저장된 객체 반환
}
- 새로운 학생을 저장하는 메서드
- equence를 1 증가시켜 id 부여 -> store에 저장 -> 저장된 학생을 반환
findAll()
public List<Student> findAll() {
return new ArrayList<>(store.values());
}
- 저장소(store)에 들어 있는 모든 학생 데이터를 리스트로 반환
store.values()는 HashMap의 값들(Student)만 모아주는 컬렉션이며, ArrayList로 감싸서 반환
findById(Long id)
public Optional<Student> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
- 특정 id의 학생을 조회
store.get(id)가 없을 수도 있으므로 Optional로 감싸 반환 (Optional은 null 대신 안전하게 처리하기 위한 객체)
update(Long id, Student student)
public Student update(Long id, Student student) {
store.put(id, student);
return student;
}
- 기존 id의 학생 정보를 새로운 student로 교체
- 단순히 put()을 다시 호출해서 기존 데이터를 덮어씌움
delete(Long id)
public boolean delete(Long id) {
return store.remove(id) != null;
}
- 특정 id 학생 데이터를 삭제
- remove()는 삭제된 값을 반환하므로, null이 아니면 삭제 성공 -> 따라서 true / false로 성공 여부를 반환
DTO
DTO는 계층 간 데이터 전달을 위해 만들어진, 로직이 없는 순수한 데이터 객체
서버와 클라이언트, 또는 애플리케이션의 여러 계층 간에 데이터를 주고받을 때 사용합니다
- 보안 : DTO를 사용하면 필요한 정보만 선택적으로 전달할 수 있습니다
- 캡슐화 : DB 구조(Entity)를 외부에 직접 노출하지 않게 함으로써 내부 설계 변경에 유연하게 대응할 수 있습니다
- 책임 분리 : Entity는 DB와 직접 연결되는 클래스, DTO는 요청, 응답을 위한 데이터 전달용 클래스
StudentRequest
클라이언트 -> 서버로 데이터를 보낼 때 사용하는 객체
package gdg.restapi.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class StudentRequest {
private String name;
private String major;
}
- name, major 두 필드는 클라이언트가 보낼 데이터를 의미, 아래와 같은 json 데이터가 들어옴
{
"name": "침착맨",
"major": "차돌짬뽕먹으면지능낮아지는거연구학과"
}
StudentResponse
서버 -> 클라이언트로 데이터를 보낼 때 사용하는 객체
package gdg.restapi.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class StudentResponse {
private Long id;
private String name;
private String major;
}
- 서버에서 StudentResponse 객체로 반환하면 아래와 같은 json 데이터가 반환됨
{
"id": 1,
"name": "홍길동",
"major": "컴퓨터공학"
}
Service
애플리케이션의 핵심 비즈니스 로직을 처리하는 계층입니다
- 비즈니스 로직 수행 : “엄준식이 잘생겨지면, 인스타그램 팔로워가 줄어든다” 처럼 실제 서비스의 규칙을 처리
- 데이터 조합 및 가공 : 여러 Repository에서 가져온 데이터를 가공하여 Controller로 전달
- 트랜잭션 관리 : 여러 작업이 하나로 묶여야 할 때 트랜잭션 단위로 처리
- Controller 단순화 : Controller는 요청/응답에만 집중하고, 로직은 Service가 맡도록 분리
package gdg.restapi.service;
import gdg.restapi.domain.Student;
import gdg.restapi.dto.StudentRequest;
import gdg.restapi.dto.StudentResponse;
import gdg.restapi.repository.StudentRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor // final 필드 자동 생성자
public class StudentService {
private final StudentRepository repository;
public StudentResponse create(StudentRequest request) {
Student student = new Student(null, request.getName(), request.getMajor());
Student saved = repository.save(student);
return new StudentResponse(saved.getId(), saved.getName(), saved.getMajor());
}
public List<StudentResponse> getAll() {
return repository.findAll().stream()
.map(s -> new StudentResponse(s.getId(), s.getName(), s.getMajor()))
.collect(Collectors.toList());
}
public StudentResponse getById(Long id) {
return repository.findById(id)
.map(s -> new StudentResponse(s.getId(), s.getName(), s.getMajor()))
.orElse(null);
}
public StudentResponse update(Long id, StudentRequest request) {
return repository.findById(id).map(student -> {
student.setName(request.getName());
student.setMajor(request.getMajor());
Student updated = repository.update(id, student);
return new StudentResponse(updated.getId(), updated.getName(), updated.getMajor());
}).orElse(null);
}
public boolean delete(Long id) {
return repository.delete(id);
}
}
어노테이션
@Service
@RequiredArgsConstructor // final 필드 자동 생성자
public class StudentService {
- @Service : 스프링에 서비스 계층이라고 명시하는 어노테이션
- 스프링이 자동으로 빈(bean) 으로 등록해줌
- @RequiredArgsConstructor : final로 선언된 필드를 자동으로 생성자 주입해줌@Autowired를 직접 쓸 필요가 없음
- 아래의 private final StudentRepository repository;를 생성자 주입으로 자동 처리
의존성 주입 (DI)
private final StudentRepository repository;
- 의존성 주입된 RepositoryService는 이 repository를 이용해 비즈니스 로직만 담당합니다.
- 데이터 저장, 조회, 수정, 삭제 등 DB 관련 기능은 모두 repository를 통해 실행됨
학생 생성 (Create)
public StudentResponse create(StudentRequest request) {
Student student = new Student(null, request.getName(), request.getMajor());
Student saved = repository.save(student);
return new StudentResponse(saved.getId(), saved.getName(), saved.getMajor());
}
StudentRequest (요청 DTO)->Student(엔티티)로 변환repository.save()를 호출해 저장- 저장된 결과 (saved) 를 다시
StudentResponse (반환 DTO)로 변환하여 반환
즉, 요청 DTO -> 엔티티 -> 응답 DTO 변환 과정을 담당
Controller에서는 JSON을 받고, Repository에서는 엔티티를 저장하고, Service가 그 사이의 변환을 처리하는 구조
전체 조회 (Read All)
public List<StudentResponse> getAll() {
return repository.findAll().stream()
.map(s -> new StudentResponse(s.getId(), s.getName(), s.getMajor()))
.collect(Collectors.toList());
}
repository.findAll()로 전체 학생 리스트를 가져옴변환된 결과를List<StudentResponse>형태로 반환- Java Stream API를 이용해서 각 Student 객체를 StudentResponse로 변환
Entity → Response DTO 리스트로 변환하는 부분
단일 조회 (Read by ID)
public StudentResponse getById(Long id) {
return repository.findById(id)
.map(s -> new StudentResponse(s.getId(), s.getName(), s.getMajor()))
.orElse(null);
}
repository.findById(id)는Optional<Student>를 반환.map()을 사용해 Student를 StudentResponse로 변환하고, 값이 없을 경우.orElse(null)로 null을 반환
특정 id 학생을 조회하고 결과가 없으면 null을 반환하는 구조
수정 (Update)
public StudentResponse update(Long id, StudentRequest request) {
return repository.findById(id).map(student -> {
student.setName(request.getName());
student.setMajor(request.getMajor());
Student updated = repository.update(id, student);
return new StudentResponse(updated.getId(), updated.getName(), updated.getMajor());
}).orElse(null);
}
- findById(id)로 기존 데이터를 찾음
- 존재하면 이름과 전공을 새 값으로 변경
repository.update()로 저장 후, 응답 DTO로 변환
존재하지 않으면 null 반환, 존재하면 업데이트된 학생 정보를 반환
삭제 (Delete)
public boolean delete(Long id) {
return repository.delete(id);
}
repository.delete(id)호출만으로 삭제 수행- 성공하면 true, 실패하면 false 반환
Controller
Controller는 클라이언트의 요청을 받아 Service를 호출하고, 처리 결과를 응답으로 반환하는 계층입니다
- 요청 수신 (Request) : 사용자가 보낸 HTTP 요청을 받아 파라미터나 JSON 데이터 추출
- Service 호출 : 비즈니스 로직을 처리하는 Service 계층으로 요청 전달
- 응답 반환 (Response) : 처리 결과를 JSON 형태로 클라이언트에 전달
- 라우팅 관리 : GET, POST, PUT, DELETE 등 요청 방식을 구분하여 각각 다른 메서드 실행
package gdg.restapi.controller;
import gdg.restapi.dto.StudentRequest;
import gdg.restapi.dto.StudentResponse;
import gdg.restapi.service.StudentService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/students")
@RequiredArgsConstructor // final 필드 자동 생성자
public class StudentController {
private final StudentService service;
@PostMapping
public ResponseEntity<StudentResponse> create(@RequestBody StudentRequest request) {
return ResponseEntity.ok(service.create(request));
}
@GetMapping
public ResponseEntity<List<StudentResponse>> getAll() {
return ResponseEntity.ok(service.getAll());
}
@GetMapping("/{id}")
public ResponseEntity<StudentResponse> getById(@PathVariable Long id) {
StudentResponse response = service.getById(id);
return (response != null) ? ResponseEntity.ok(response) : ResponseEntity.notFound().build();
}
@PutMapping("/{id}")
public ResponseEntity<StudentResponse> update(@PathVariable Long id, @RequestBody StudentRequest request) {
StudentResponse response = service.update(id, request);
return (response != null) ? ResponseEntity.ok(response) : ResponseEntity.notFound().build();
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
return service.delete(id) ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
}
}
어노테이션
- @RestController : 이 클래스가 REST API 요청을 처리함을 명시 (@Controller + @ResponseBody 조합)
- @RequestMapping("/students") : 이 컨트롤러의 모든 엔드포인트 앞에는 /students가 붙음
- @RequiredArgsConstructor : final 필드(service)를 자동으로 생성자 주입해줌
즉, StudentController는 StudentService를 의존성 주입(DI) 받아서 사용하는 구조
학생 등록 (POST /students)
@PostMapping
public ResponseEntity<StudentResponse> create(@RequestBody StudentRequest request) {
return ResponseEntity.ok(service.create(request));
}
HTTP POST 요청을 처리
@RequestBody: 클라이언트가 보낸 JSON을 StudentRequest 객체로 자동 변환합니다.service.create()호출 -> 학생 등록 후 DTO 형태로 결과 반환ResponseEntity.ok(): HTTP 상태 코드 200(OK)와 함께 응답
전체 조회 (GET /students)
@GetMapping
public ResponseEntity<List<StudentResponse>> getAll() {
return ResponseEntity.ok(service.getAll());
}
GET /students 요청을 처리
service.getAll()-> 모든 학생을 조회List<StudentResponse>로 변환 후 JSON 배열 형태로 반환
단일 조회 (GET /students/{id})
@GetMapping("/{id}")
public ResponseEntity<StudentResponse> getById(@PathVariable Long id) {
StudentResponse response = service.getById(id);
return (response != null) ? ResponseEntity.ok(response) : ResponseEntity.notFound().build();
}
@PathVariable Long id -> URL 경로의 {id} 값을 가져옴
ex) /students/3 -> id = 3
- service.getById(id) -> 해당 학생을 조회
- 존재하면 200 OK, 없으면 404 Not Found 응답
수정 (PUT /students/{id})
@PutMapping("/{id}")
public ResponseEntity<StudentResponse> update(@PathVariable Long id, @RequestBody StudentRequest request) {
StudentResponse response = service.update(id, request);
return (response != null) ? ResponseEntity.ok(response) : ResponseEntity.notFound().build();
}
DELETE /students/{id} 요청 처리
- 존재하면 삭제 후 204 No Content
- 없으면 404 Not Found
Spring JPA
DB 데이터를 자바 객체로 다루게 해주는 도구
JPA는 자바 객체(Entity)를 DB 테이블과 자동으로 매핑해주는 ORM (Object-Relational Mapping) 기술 표준입니다
SQL을 직접 작성하지 않아도, 객체로 데이터를 CRUD 할 수 있게 해줍니다
ERD(Entity Relationship Diagram) 설계하기

해당 ERD는 가수와 음악 테이블 간의 1대다(One-to-Many) 관계를 나타냅니다.
테이블 간 관계
하나의 Singer -> 여러 개의 Music 을 가질 수 있음 : 1 : N (일대다 관계)
Domain - Music
package com.gdg.jpaexample.domain;
import jakarta.persistence.*;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Getter
@NoArgsConstructor
public class Music {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "singer_id")
private Singer singer;
@Builder
public Music(String title, Singer singer) {
this.title = title;
this.singer = singer;
}
public void update(String title, Singer singer) {
this.title = title;
this.singer = singer;
}
}
어노테이션
- @Entity : JPA가 이 클래스를 DB 테이블로 인식하게 합니다. -> music 테이블 생성됨
- @Getter : Lombok이 모든 필드의 getter 메서드 자동 생성
- @NoArgsConstructor : 기본 생성자 자동 생성 (JPA가 엔티티 생성 시 필요)
즉, 이 클래스는 JPA가 관리하는 영속성 객체(프로그램이 종료되어도 사라지지 않는 객체) 가 됨
기본 키(id)
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
- @Id : 기본 키(primary key) 지정DB가 자동으로 ID를 생성하도록 설정 (MySQL의 AUTO_INCREMENT 방식과 동일)
- 즉, DB에 레코드가 저장될 때 자동으로 id가 증가
- @GeneratedValue(strategy = GenerationType.IDENTITY)
일반 필드
private String title;
- 노래의 제목(title)을 나타내는 컬럼
- 특별한 어노테이션이 없으므로 기본적으로 title이라는 컬럼으로 매핑됩니다
가수와의 관계 설정
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "singer_id")
private Singer singer;
- @ManyToOne : 여러 곡(Music)이 하나의 가수(Singer)에 속함 -> N:1 관계
- fetch = FetchType.LAZY : 지연 로딩(Lazy Loading) 설정 -> 실제로 singer가 필요할 때만 DB에서 조회
- @JoinColumn(name = "singer_id") : DB에서 외래키 컬럼 이름을 singer_id로 지정 (FK)
생성자, 빌더
@Builder
public Music(String title, Singer singer) {
this.title = title;
this.singer = singer;
}
Builder는 Lombok의 어노테이션으로, 객체를 유연하게 생성할 수 있는 빌더 패턴을 제공
- Builder패턴 : new로 한 줄에 때려박기 복잡한 객체를 깔끔하게 만드는 방법
Music music = Music.builder()
.title("김치찌개맛있게끓여오는주파수로만든노래")
.singer(임정혁)
.build();
처럼 객체를 심플하게 생성할 수 있다
업데이트 메서드
public void update(String title, Singer singer) {
this.title = title;
this.singer = singer;
}
엔티티 내 데이터를 변경할 때 사용하는 도메인 로직 메서드
setTitle() , setSinger() 를 직접 노출하지 않고 명시적인 업데이트 메서드로 관리하면, 객체의 불변성과 명확한 변경 의도를 유지할 수 있음
Domain - Singer
한 명의 가수(Singer)가 여러 곡(Music)을 가질 수 있는 구조
(어노테이션, 기본 키 패스)
일반 필드
private String name;
@Column(name = "debut_year")
private int debutYear;
- name -> 가수 이름
- debutYear -> 데뷔년도 (DB 컬럼 이름을 debut_year로 설정)
일대다(1:N) 관계
@OneToMany(mappedBy = "singer", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
private List<Music> musics = new ArrayList<>();
- @OneToMany : 한 가수(Singer) 가 여러 음악(Music) 을 가질 수 있음을 의미
- mappedBy = "singer" : Music 엔티티의 singer 필드가 외래키 주인임을 명시 (연관관계의 주인은 Music 쪽)
- fetch = FetchType.LAZY : 지연 로딩 — 가수를 조회할 때 Music 리스트는 나중에 실제로 필요할 때만 조회
- cascade = CascadeType.ALL : 가수를 저장/삭제할 때 연관된 음악들도 함께 처리됨
- orphanRemoval = true : musics 리스트에서 음악이 제거되면 DB에서도 자동 삭제됨
가수 한 명이 여러 노래를 가지고, 그 노래들은 Music 테이블의 singer_id 컬럼으로 연결됨
Repository - SingerRepository & MusicRepository
Repository/SingerRepository
package com.gdg.jpaexample.repository;
import com.gdg.jpaexample.domain.Singer;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SingerRepository extends JpaRepository<Singer, Long> {
}
Repository/MusicRepository
package com.gdg.jpaexample.repository;
import com.gdg.jpaexample.domain.Music;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MusicRepository extends JpaRepository<Music, Long> {
}
- JpaRepository를 상속받으면, 기본 CRUD 기능을 자동으로 제공
DT0
package com.gdg.jpaexample.dto;
import com.gdg.jpaexample.domain.Music;
import com.gdg.jpaexample.domain.Singer;
import lombok.Builder;
import lombok.Getter;
@Builder
@Getter
public class MusicInfoResponseDto {
private Long id;
private String title;
private Long singerId;
private String singerName;
public static MusicInfoResponseDto from(Music music) {
return MusicInfoResponseDto.builder()
.id(music.getId())
.title(music.getTitle())
.singerId(music.getSinger().getId())
.singerName(music.getSinger().getName())
.build();
}
}
다른 내용들은 Rest Api 부분에서 다뤘으므로, 새로 추가된 Builder 내용만 작성하겠습니다
- @Builder : 빌더 패턴을 자동 생성해줌 -> 가독성 좋은 객체 생성 가능
public static MusicInfoResponseDto from(Music music) {
return MusicInfoResponseDto.builder()
.id(music.getId())
.title(music.getTitle())
.singerId(music.getSinger().getId())
.singerName(music.getSinger().getName())
.build();
}
Service - MusicService
package com.gdg.jpaexample.service;
import com.gdg.jpaexample.domain.Music;
import com.gdg.jpaexample.domain.Singer;
import com.gdg.jpaexample.dto.MusicInfoResponseDto;
import com.gdg.jpaexample.dto.MusicSaveRequestDto;
import com.gdg.jpaexample.repository.MusicRepository;
import com.gdg.jpaexample.repository.SingerRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class MusicService {
private final MusicRepository musicRepository;
private final SingerRepository singerRepository;
@Transactional
public MusicInfoResponseDto saveMusic(MusicSaveRequestDto musicSaveRequestDto) {
Singer singer = singerRepository.findById(musicSaveRequestDto.getSingerId())
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 가수입니다."));
Music music = Music.builder()
.singer(singer)
.title(musicSaveRequestDto.getTitle())
.build();
musicRepository.save(music);
return MusicInfoResponseDto.from(music);
}
@Transactional(readOnly = true)
public MusicInfoResponseDto getMusic(Long musicId) {
Music music = musicRepository.findById(musicId)
.orElseThrow(() -> new IllegalArgumentException("요청하신 음악 정보를 찾을 수 없습니다."));
return MusicInfoResponseDto.from(music);
}
@Transactional
public MusicInfoResponseDto updateMusic(Long musicId, MusicSaveRequestDto musicSaveRequestDto) {
Music music = musicRepository.findById(musicId)
.orElseThrow(() -> new IllegalArgumentException("요청하신 음악 정보를 찾을 수 없습니다."));
Singer singer = singerRepository.findById(musicSaveRequestDto.getSingerId())
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 가수입니다."));
music.update(musicSaveRequestDto.getTitle(), singer);
return MusicInfoResponseDto.from(music);
}
@Transactional
public void deleteMusic(Long musicId) {
musicRepository.deleteById(musicId);
}
@Transactional(readOnly = true)
public List<MusicInfoResponseDto> getAllMusic() {
return musicRepository.findAll()
.stream()
.map(MusicInfoResponseDto::from)
.toList();
}
}
어노테이션
- @Service : 스프링에게 이 클래스가 비즈니스 로직을 처리하는 서비스 계층임을 알려줍니다.
- 스프링이 자동으로 빈(bean)으로 등록합니다
- @RequiredArgsConstructor : final로 선언된 필드(musicRepository, singerRepository)를 자동으로 생성자를 주입해줍니다
주입받는 Repository
private final MusicRepository musicRepository;
private final SingerRepository singerRepository;
- MusicRepository : 음악 테이블(music)과 관련된 CRUD 수행
- SingerRepository : 가수 테이블(singer)과 관련된 CRUD 수행
음악 저장 (Create)
@Transactional
public MusicInfoResponseDto saveMusic(MusicSaveRequestDto musicSaveRequestDto) {
Singer singer = singerRepository.findById(musicSaveRequestDto.getSingerId())
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 가수입니다."));
Music music = Music.builder()
.singer(singer)
.title(musicSaveRequestDto.getTitle())
.build();
musicRepository.save(music);
return MusicInfoResponseDto.from(music);
}
singerRepository.findById()로 가수 존재 여부 확인- 없으면 예외 (IllegalArgumentException) 발생
- 있으면 Music 객체 생성 (빌더 패턴 사용)
musicRepository.save()로 DB에 저장- 저장된 Music 엔티티를 DTO로 변환하여 반환
@Transactional : DB에 변경이 일어나는 메서드이므로, 트랜잭션이 자동으로 관리됩니다
음악 단일 조회 (Read by ID)
@Transactional(readOnly = true)
public MusicInfoResponseDto getMusic(Long musicId) {
Music music = musicRepository.findById(musicId)
.orElseThrow(() -> new IllegalArgumentException("요청하신 음악 정보를 찾을 수 없습니다."));
return MusicInfoResponseDto.from(music);
}
- Transactional(readOnly = true) : 읽기 전용 트랜잭션 (성능 최적화)
- 존재하지 않으면 예외 발생
- 존재하면 MusicInfoResponseDto로 변환 후 반환
음악 수정 (Update)
@Transactional
public MusicInfoResponseDto updateMusic(Long musicId, MusicSaveRequestDto musicSaveRequestDto) {
Music music = musicRepository.findById(musicId)
.orElseThrow(() -> new IllegalArgumentException("요청하신 음악 정보를 찾을 수 없습니다."));
Singer singer = singerRepository.findById(musicSaveRequestDto.getSingerId())
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 가수입니다."));
music.update(musicSaveRequestDto.getTitle(), singer);
return MusicInfoResponseDto.from(music);
}
- 기존 Music 조회 (없으면 예외)
- 수정할 Singer 조회 (없으면 예외)
- 엔티티 내부의
update()메서드로 수정 수행 @Transactional덕분에 변경 감지 (Dirty Checking) 로 자동 UPDATE 실행
음악 삭제 (Delete)
@Transactional
public void deleteMusic(Long musicId) {
musicRepository.deleteById(musicId);
}
- 단순히 ID로 삭제 수행
- 존재하지 않는 ID일 경우 내부적으로 예외 발생 가능
- @Transactional : 삭제도 DB 변경이므로 트랜잭션 필요
전체 음악 조회 (Read All)
@Transactional(readOnly = true)
public List<MusicInfoResponseDto> getAllMusic() {
return musicRepository.findAll()
.stream()
.map(MusicInfoResponseDto::from)
.toList();
}
musicRepository.findAll()로 모든 음악 조회- Stream API를 사용해 MusicInfoResponseDto로 변환
- 변환된 리스트를 반환
readOnly = true 로 설정해 성능을 높이고, 데이터 변경을 방지합니다
컨트롤러 내용도 Rest api에서 다룬 내용과 다름이 없기에 넘어가도록 하겠습니다..
복습
GPT 돌리거나 구글에 검색했던 내용들을 정리했습니다
레포지토리 클래스를 추상 클래스랑 구현체로 나누는 이유
역할과 구현의 분리” (의존성 역전 원칙, DIP)
- 추상 클래스(또는 인터페이스)는 무엇을 할지만 정의하고, 실제 “어떻게 할지”는 구현체에서 담당합니다
public interface StudentRepository {
Student save(Student student);
Optional<Student> findById(Long id);
}
이건 “저장하고 찾는 기능이 필요하다”는 규약을 정의한 것뿐, 그리고 실제 동작은 구현체가 담당
@Repository
public class StudentRepositoryImpl implements StudentRepository {
private final Map<Long, Student> store = new HashMap<>();
private Long sequence = 0L;
public Student save(Student student) { ... }
public Optional<Student> findById(Long id) { ... }
}
이렇게 분리하면 서비스 계층은 구현에 의존하지 않고, 인터페이스(추상화) 에 의존하게 됨
변경에 닫히고, 확장에 열려 있는 구조 (OCP, 개방폐쇄원칙)
테스트, 유지보수, 교체 용이성
예를 들어, 지금은 데이터를 메모리에 저장하지만 나중에 DB(MySQL, JPA, MongoDB 등)를 붙여야 할 경우, 이때 서비스 코드는 그대로 두고, 구현체만 바꾸면 됩니다
// 기존
@Repository
public class MemoryStudentRepository implements StudentRepository { ... }
// 나중에 교체
@Repository
public class JpaStudentRepository implements StudentRepository { ... }
코드 수정 없이 Repository 구현체만 바꿔서 연결하면 끝, 유지보수성과 확장성을 높이는 이유입니다
스프링의 DI(의존성 주입) 구조와 잘 맞기 때문
Spring은 자동으로 인터페이스 타입에 맞는 구현체를 주입합니다
아래처럼 작성해도 자동으로 연결됨
@Service
@RequiredArgsConstructor
public class StudentService {
private final StudentRepository studentRepository; // 인터페이스
// 구현체는 @Repository 붙은 클래스가 자동 주입됨
}
서비스는 어떤 저장소를 쓰는지 몰라도 동작 -> (메모리든 JPA든 MySQL이든 알아서 교체 가능)
JPA에서는 이 패턴이 자동화되어 있음
JPA에서는 직접 구현체를 작성하지 않아도 됩니다
public interface StudentRepository extends JpaRepository<Student, Long> { }
JPA가 자동으로 StudentRepository 의 구현체 (SimpleJpaRepository) 를 만들어 주입합니다
'스프링이 자동으로 빈(bean) 으로 등록' 이 무슨 뜻
스프링 빈이란?
- Bean : 스프링이 관리하는 객체
자바에서는 우리가 직접 객체를 이렇게 생성함
StudentService service = new StudentService();
하지만 스프링은 이런 객체 생성, 관리, 주입(Injection)까지 자동으로 해주는 컨테이너입니다
이 컨테이너 안에서 관리되는 객체를 Bean(빈) 이라고 부름
new 키워드로 직접 만들지 않고, 스프링이 대신 생성해서 관리하는 객체
자동 등록의 의미
예를 들어 아래처럼 클래스 위에 특정 어노테이션을 붙이면
@Service
public class StudentService { ... }
@Repository
public class StudentRepositoryImpl { ... }
@Controller
public class StudentController { ... }
이 세 가지는 전부 스프링이 자동으로 스캔해서 등록하는 대상입니다
스프링 부트는 실행 시
@ComponentScan기능으로 프로젝트를 훑으면서자동으로 스프링 컨테이너(ApplicationContext) 에 “빈(Bean)”으로 등록합니다- @Service, @Repository, @Controller는 전부 @Component의 하위 어노테이션
@Component계열 어노테이션이 붙은 클래스들을 찾아서
스프링이 Bean을 관리한다는 건
Bean으로 등록된 객체들은 스프링 컨테이너 (ApplicationContext) 에 의해 관리됩니다
스프링은 컨테이너 내부에서 객체를 생성 -> 초기화 -> 소멸까지 관리하며, 필요한 곳에 자동으로 의존성 주입(DI) 도 해줍니다
@Service
public class StudentService {
private final StudentRepository repository;
@Autowired // (또는 @RequiredArgsConstructor)
public StudentService(StudentRepository repository) {
this.repository = repository;
}
}
StudentRepository 타입의 Bean이 이미 등록되어 있으면, 스프링이 자동으로 찾아서 넣어줍니다
우리가 직접 new StudentRepositoryImpl() 하지 않아도 되는거임!
실제 Bean 등록 과정 (스프링 부트 실행 시)
- 애플리케이션 시작 (@SpringBootApplication)
- 내부적으로 @ComponentScan 동작
- 지정된 패키지 내의 @Component, @Service, @Repository, @Controller 등을 탐색
- 해당 클래스를 Bean으로 등록
- 필요한 곳(@Autowired, 생성자 주입 등)에 자동 주입
final로 선언된 필드를 자동으로 생성자 주입해줍니다
생성자 주입이란
스프링에서는 필요한 객체(의존성, dependency) 를 자동으로 넣어주는 기능을 의존성 주입 (Dependency Injection, DI) 이라고 합니다
일반적인 생성자 주입 예시
@Service
public class MusicService {
private final MusicRepository musicRepository;
public MusicService(MusicRepository musicRepository) { // ← 생성자 주입
this.musicRepository = musicRepository;
}
}
스프링이 MusicService 객체를 만들 때 MusicRepository 빈(Bean) 을 찾아서 자동으로 넣어줍니다
입받을 객체를 new로 만들지 않아도 스프링이 알아서 넣어줍니다
그런데 @RequiredArgsConstructor를 쓰면?
이 Lombok 어노테이션은 final로 선언된 필드를 자동으로 생성자의 매개변수로 만들어주는 기능 입니다
@Service
@RequiredArgsConstructor
public class MusicService {
private final MusicRepository musicRepository;
}
// Lombok이 자동 생성해주는 코드
public MusicService(MusicRepository musicRepository) {
this.musicRepository = musicRepository;
}
이 코드만으로, Lombok이 자동으로 아래 생성자를 만들어줍니다
그리고 스프링이 이 생성자를 인식해서 자동으로 주입(DI) 해줍니다
그래서 우리는 @Autowired를 쓸 필요가 없습니다
왜 final이 붙어야 할까?
final은 “한 번 초기화된 후 절대 바뀌지 않는 변수”라는 뜻 -> 생성 시점에 반드시 값이 들어와야 한다
Lombok의 @RequiredArgsConstructor는 바로 이 필수값(final) 을 위한 생성자를 자동 생성해줍니다.
@RequiredArgsConstructor
private final AService aService;
private BService bService; // final 아님 → 생성자에 포함되지 않음
생성자에는 AService만 포함됩니다
'Spring' 카테고리의 다른 글
| GitHub Actions로 스프링 CI/CD 구축하기 (0) | 2025.12.28 |
|---|