Spring 입문 주차에서 배운 내용을 한 번에 정리해봤다. RestController로 요청을 처리하는 법부터 Docker, 데이터베이스 기본기, ORM/JPA 개념을 거쳐 3 Layer Architecture 기반으로 실제 CRUD API를 만드는 것까지, 순서대로 훑어본다.
백엔드는 화면을 직접 그리지 않고, 프론트에 뿌려줄 데이터를 JSON 형식으로 내려주는 역할을 한다. View를 신경 쓸 필요가 없다는 게 핵심이다.
@RestController는 @Controller에 @ResponseBody가 합쳐진 것이다. @ResponseBody가 붙은 메서드의 반환값은 View를 찾는 데 쓰이지 않고, HTTP 응답 본문에 그대로 작성된다.
@RequestMapping은 특정 URL로 들어온 요청을 Controller 내부의 메서드와 매핑하기 위해 쓴다. 클래스에 붙이느냐 메서드에 붙이느냐에 따라 용도가 다른데, 클래스에는 주로 URL 경로의 prefix를 붙이는 용도로 쓰고, 메서드에는 @GetMapping, @PostMapping처럼 전용 매핑 어노테이션을 쓰는 경우가 훨씬 많다.
데이터를 전달받는 방식은 네 가지로 정리된다.
쿼리 파라미터: URL의 ? 뒤에 오는 값들을 처리한다. @RequestParam(required = false) String sort 처럼 쓰면 필수값이 아니게 만들 수 있는데, 반대로 이 옵션을 안 주면 스프링 내부적으로는 기본값이 "필수"이므로 값이 없을 때 400 에러가 난다는 점을 주의해야 한다.
폼 데이터 / 여러 개의 쿼리 파라미터: 쿼리 스트링이 여러 개일 때 @RequestParam을 하나하나 쓰는 대신 @ModelAttribute로 받으면 하나의 DTO로 한 번에 바인딩할 수 있다. 다만 폼 데이터(Content-Type: application/x-www-form-urlencoded)는 최근엔 React 등 프레임워크와 JSON 통신이 대세라 실무에서는 거의 안 쓴다.
경로 변수: @PathVariable로 GET /users/{userId} 같은 URL 경로 자체에 포함된 값을 꺼낸다. 경로에 값이 반드시 포함돼 있어야 하므로 쿼리 파라미터와 헷갈리지 않도록 주의.
요청 본문: @RequestBody는 HTTP 요청 본문(JSON)을 객체로 변환할 때 쓰며, REST API에서 가장 많이 쓰이는 방식이다.
여기서 한 가지 짚을 부분은, @RequestBody로 들어온 JSON이 어떻게 자바 객체로 자동 변환되느냐는 것이다. 스프링은 내부적으로 HttpMessageConverter(기본적으로 Jackson 라이브러리)를 거쳐 JSON ↔ 자바 객체 변환을 처리해준다. @RestController가 편리한 이유도 결국 이 컨버터 체인 덕분에 개발자가 직렬화/역직렬화 코드를 직접 짤 필요가 없어서다.
Docker는 실행 환경과 애플리케이션을 함께 패키징해서 어디서든 동일하게 실행되도록 해주는 컨테이너 기술이다.
Docker를 쓰는 이유는 일관성(어디서든 같은 환경), 이식성(플랫폼 무관 실행), 확장성(마이크로서비스 아키텍처로 작은 서버를 여러 개 띄우기 쉬움) 세 가지로 정리된다.
Dockerfile 예시:
FROM eclipse-temurin:21-jdk-alpine
WORKDIR /app
COPY build/libs/*.jar /app/myapp.jar
ENTRYPOINT ["java", "-jar", "/app/myapp.jar"]
FROM: 어떤 환경(베이스 이미지)에서 실행할지WORKDIR: 작업 디렉토리COPY: 빌드된 jar 파일을 이미지 안 어디에 둘지ENTRYPOINT: 컨테이너가 시작될 때 실행할 명령어 (배열 안 문자열 사이 띄어쓰기 하나만 틀려도 에러가 난다)alpine은 최소한의 패키지만 포함한 경량 리눅스 배포판이라 이미지가 가볍고 빌드·배포 속도가 빠르다. 다만 딱 필요한 패키지만 있다 보니 원인 모를 에러가 나면 일반 이미지로 바꿔서 원인을 좁혀보는 것도 방법이다. (alpine은 표준 glibc 대신 musl libc를 쓰기 때문에, 특정 네이티브 라이브러리를 쓰는 프로그램에서 이 차이 때문에 동작이 달라지는 경우도 있다 — 원인 불명의 에러가 alpine에서만 난다면 이것도 의심해볼 만하다.)
이미지 빌드와 실행:
docker build -t test-docker-hello .
docker run -d -p 8080:8080 test-docker-hello
docker run -d -p 8080:8080 --name custom-container-name test-docker-hello
-t는 이미지 이름, .은 현재 디렉토리의 Dockerfile을 빌드하겠다는 뜻이다. -d는 detached(터미널에서 분리해서 백그라운드 실행), -p 8080:8080은 로컬과 컨테이너 내부의 분리된 네트워크를 연결해주는 포트 매핑이다. --name으로 컨테이너 이름을 지정할 수 있다.
로컬과 도커를 오가며 개발하다 보면 이미 쓰이고 있는 포트 때문에 에러가 자주 난다. 이럴 때 kill $(lsof -t -i:{port})로 포트를 점거한 프로세스를 강제로 닫을 수 있다는 팁이 있는데, 두 가지는 알아두는 게 좋다. 첫째, lsof는 macOS·Linux 명령어라 윈도우에서는 그대로 안 먹힌다 — 윈도우라면 netstat -ano | findstr :{port}로 PID를 찾아 taskkill /PID {PID} /F로 대체해야 한다. 둘째, 도커 컨테이너가 문제라면 애초에 포트를 붙잡은 프로세스를 강제 종료하기보다 docker ps로 컨테이너를 확인하고 docker stop {컨테이너명}으로 끄는 게 더 정확하다 — 특히 윈도우의 Docker Desktop 환경에서는 포트를 붙잡고 있는 프로세스가 그 컨테이너 하나만이 아니라 Docker Desktop의 공용 백엔드 프로세스인 경우가 있어서, 그걸 강제로 죽이면 컨테이너 하나가 아니라 Docker Desktop 전체가 같이 죽어버릴 수 있다.
데이터베이스는 여러 사람이 쓸 목적으로 데이터를 조직적으로 관리하는 저장소다. 크게 두 종류로 나뉜다.
트랜잭션은 데이터베이스에서 하나의 논리적 작업 단위로, 모두 성공하거나 모두 실패(롤백)한다. 이걸 지탱하는 게 ACID 속성이다.
고립성은 사실 정도의 차이가 있다. SQL 표준은 READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE 네 단계의 격리 수준을 정의하는데, 격리 수준이 높아질수록 동시성 문제(dirty read, non-repeatable read, phantom read)는 줄어들지만 그만큼 락을 많이 걸어서 성능은 떨어진다. MySQL(InnoDB)의 기본 격리 수준은 REPEATABLE READ다.
관계형 데이터베이스는 행(row)과 열(column)로 구성된 테이블에 데이터를 저장한다. 열은 속성과 타입을, 행은 관계된 데이터 묶음(튜플/레코드)을 의미한다. 테이블 간 관계는 1:1, 1:N, N:M 세 가지 유형으로 나뉜다.
키의 종류:
데이터 무결성은 엔터티 무결성(PK가 중복되지 않고 NULL이 아님), 참조 무결성(FK가 참조하는 데이터의 유효성 보장), 도메인 무결성(각 열이 정의된 타입·제약을 지킴) 세 가지로 나뉜다.
SQL은 목적에 따라 세 갈래로 나뉜다.
DDL (Data Definition Language) — 구조를 정의
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
email VARCHAR(100)
);
ALTER TABLE users ADD created_at TIMESTAMP;
DROP TABLE users;
DML (Data Manipulation Language) — 데이터를 조작
INSERT INTO users (id, username, email) VALUES (1, 'John', 'xxx123@gmail.com');
UPDATE users SET email = 'new.email@gmail.com' WHERE id = 1;
DELETE FROM users WHERE id = 1;
DQL (Data Query Language) — 데이터를 조회
SELECT * FROM users;
SELECT email FROM users WHERE username = 'John';
MySQL도 Docker로 바로 띄울 수 있다.
docker run -d --name mysql -e MYSQL_ROOT_PASSWORD=12345678 -p 3306:3306 mysql:8.4
이러면 로컬에 MySQL을 따로 설치하지 않아도 컨테이너 안에 SQL이 깔린 것처럼 쓸 수 있다. 인텔리제이 오른쪽의 Database 탭에서 + → Data Source → MySQL로 연결하면 되고, 여기서 테이블을 만들면 GUI로도 바로 확인할 수 있다. 이게 가능한 이유는 결국 포트 매핑 덕분인데, -p 3306:3306으로 컨테이너 내부 포트를 로컬 포트에 연결해두면 인텔리제이 입장에서는 localhost:3306에 진짜 MySQL이 설치되어 있는 것과 통신 방식이 완전히 동일하기 때문이다.
ERD는 데이터베이스 구조를 시각적으로 표현하는 도구다.
ERDCloud 같은 사이트에서 무료로 ERD를 그려볼 수 있고, 인텔리제이의 내장 데이터베이스 도구로도 테이블 우클릭 → 다이어그램 → Show Diagram으로 자동 생성할 수 있다.
ERD를 읽을 때는 테이블 사이를 잇는 선 끝의 기호(까마귀 발 표기법, Crow's Foot Notation)로 관계의 종류(1:1, 1:N, N:M)를 구분한다는 것도 알아두면 도움이 된다.
ORM(Object-Relational Mapping)은 객체지향 프로그래밍의 객체와 관계형 데이터베이스의 데이터를 서로 변환해주는 기술이다. ORM은 인터페이스에 가깝고, 실제 데이터베이스는 그 구현체라고 생각하면 이해하기 쉽다.
ORM이 필요한 이유는 자바 객체와 데이터베이스 사이의 "패러다임 불일치" 때문이다. 자바 객체는 필드에 리스트나 다른 객체를 자유롭게 가질 수 있지만, 관계형 데이터베이스의 테이블에는 그런 형태가 존재할 수 없다. 조금 더 들어가면 이 불일치는 리스트뿐 아니라 상속(객체는 상속 계층을 갖지만 테이블은 그렇지 않다), 참조 관계(객체는 다른 객체를 직접 참조하지만 테이블은 FK라는 값으로만 연결된다), 식별성(객체는 ==로 동일성을 비교하지만 테이블 로우는 PK 값으로 동일성을 따진다) 등 여러 층위에서 나타나는데, ORM은 이 격차를 메워주는 역할을 한다.
JPA는 자바와 데이터베이스를 연결해주는 ORM 표준(스펙)이다. JPA 자체는 인터페이스의 모음이고, 이를 실제로 구현한 것이 Hibernate다. Hibernate는 내부적으로 JDBC를 활용해 DB와 통신한다.
JpaRepository 인터페이스만 상속해도 기본 CRUD 메서드가 자동으로 생기는 게 바로 이 덕분이다.즉 레이어를 정리하면 "JPA(스펙) → Hibernate(구현체) → Spring Data JPA(더 편한 추상화)" 순서다.
Entity는 데이터베이스 테이블과 1대1로 대응되는 자바 클래스다.
@Entity는 클래스가 JPA 엔티티임을 선언한다.
@Entity
public class Member {
// 클래스 내용
}
이러면 member 테이블이 된다.
@Table은 엔티티와 매핑할 테이블 정보를 지정한다. 클래스명은 단수형을 쓰지만 테이블명은 복수형으로 적는 게 관례다.
@Entity
@Table(name = "members")
public class Member {
// 클래스 내용
}
@Id는 기본 키를 매핑하고, @GeneratedValue는 기본 키의 자동 생성 전략을 지정한다. MySQL에서는 보통 IDENTITY 전략을 쓴다.
@Entity
@Table(name = "members")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ...
}
@Column은 필드와 컬럼의 매핑 정보를 지정한다. 주로 쓰는 속성은 name, length(기본값 255), nullable(@Column을 아예 안 쓰면 기본이 true), unique다.
@Entity
@Table(name = "members")
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String email;
}
이런 데이터베이스 테이블이 있다면
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
address VARCHAR(255)
);
엔티티 클래스로 그대로 변환할 수 있다.
@Getter
@Entity
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 50, nullable = false)
private String name;
@Column(unique = true, nullable = false)
private String email;
private String address;
}
JPA는 내부적으로 Getter를 사용하도록 구현되어 있어서 @Getter를 반드시 붙여야 한다. 마찬가지로 JPA는 기본 생성자를 내부적으로 사용하는데, 외부에서 이 기본 생성자를 함부로 쓰지 못하게 막아야 하므로 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 붙여준다.
엔티티가 어떤 데이터베이스에 연결될지는 application.properties의 DDL 설정으로 정한다.
spring.datasource.url=jdbc:mysql://localhost:3306/test
spring.datasource.username=root
spring.datasource.password=12345678
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.jpa.hibernate.ddl-auto=create
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
hibernate.ddl-auto 옵션은 다섯 가지다.
create: 기존 테이블 삭제 후 재생성create-drop: create + 애플리케이션 종료 시 삭제update: 변경분만 반영 (컬럼 추가만 가능)validate: 엔티티와 테이블 매핑 정보만 확인none: DDL 자동 생성 사용 안 함show-sql은 실행되는 SQL을 콘솔에 보여주는 옵션이고, hibernate.format_sql을 false로 하면 SQL이 한 줄로 나온다. (참고로 update는 편해 보여도 컬럼 삭제나 타입 변경은 반영하지 않기 때문에, 운영 환경에서는 validate나 별도 마이그레이션 도구를 쓰는 게 안전하다 — 스키마가 꼬였는데 update만 믿고 있다가 뒤늦게 발견하는 경우가 실무에서 꽤 흔하다.)
3 Layer Architecture는 소프트웨어 시스템을 세 개의 논리적 계층으로 분리하는 아키텍처다.
목적은 관심사의 분리(각 계층이 고유한 책임만 담당해 변경 영향 범위를 최소화), 유지보수성 향상(가독성·이해도 증가, 독립적 테스트 가능), 재사용성 증대(계층별 독립적 재사용), 확장성 개선(특정 계층만 바꿔서 기능 확장) 네 가지다.
Controller Layer: 사용자와 상호작용하는 인터페이스·커뮤니케이션 계층.
@RestController
public class HelloController {
// ...
}
해야 할 일은 HTTP 요청 매핑, 파라미터 검증, 응답 데이터 변환, 예외 처리다. 반대로 비즈니스 로직 처리나 데이터베이스 직접 접근은 하면 안 된다.
Service Layer: 비즈니스 로직을 처리하는, 백엔드의 핵심 로직이 운영되는 계층.
@Service
public class HelloService {
// ...
}
비즈니스 규칙 구현, 데이터 유효성 검증, 여러 데이터 소스 조합, 트랜잭션 경계 설정을 담당하고, HTTP 처리나 DB 쿼리 작성, UI 로직은 하지 않는다.
Repository Layer: 데이터베이스와의 상호작용을 담당하는 계층.
public interface UserRepository extends JpaRepository<User, Long> {
// ...
}
JpaRepository를 extends하면 이미 @Repository 기능이 포함돼 있어서 별도로 @Repository를 붙일 필요가 없다.
레이어 간 통신 규칙은 명확하다. 상위 계층이 하위 계층을 호출하는 것(Controller → Service → Repository)과 같은 계층 내 통신은 허용되지만, 하위 계층이 상위 계층을 직접 호출하거나 계층을 건너뛰는 것(예: Controller가 Repository를 직접 호출)은 금지된다.
이 규칙 덕분에 얻는 실질적인 이점 하나는 테스트 용이성이다. Service가 Repository 인터페이스에만 의존하기 때문에, Service를 단위 테스트할 때 실제 DB 없이 Repository를 mock 객체로 대체해서 비즈니스 로직만 검증할 수 있다. 계층이 섞여 있었다면 이런 격리된 테스트 자체가 불가능했을 것이다.
DTO(Data Transfer Object)는 데이터를 전달하기 위한 순수 데이터 객체다. 스프링은 Client의 요청을 자바 코드로 변환해 처리하는데, 이때 Request 데이터를 담아 Controller로 옮겨줄 객체가 필요하고 그게 DTO다.
영화 목록에 새 항목을 추가하는 상황을 예로 들면, Client가 이런 JSON을 보냈을 때
{
"title": "범죄도시11"
}
DTO 클래스로 받는다.
@Getter
public class CreateMovieRequest {
private String title;
}
@RestController
public class MovieController {
@PostMapping("/movies")
public void createMovie(@RequestBody CreateMovieRequest request) {
// ...
}
}
DTO는 Client-Server 사이에서만 쓰이는 게 아니라, 비즈니스 로직이 없는 순수 데이터 객체이기 때문에 계층 간 어디서든 필요하면 쓸 수 있다. Request/Response와 자바 객체 간 변환은 스프링이 대신 처리해주므로, 개발자는 비즈니스 로직에만 집중하면 된다.
영속성 컨텍스트는 엔티티를 영구 저장하는 환경으로, 애플리케이션과 데이터베이스 사이에서 객체를 보관하는 가상의 데이터베이스 같은 역할을 한다. 쉽게 한마디로 정의하면 "JPA가 엔티티를 관리하는 임시 저장소"다.
영속성 컨텍스트가 하는 일은 엔티티를 임시로 보관하고, 변경 사항을 추적(더티체킹)하고, 데이터베이스와 동기화하는 것이다.
여기서 한 가지 더 알아두면 좋은 특징이 1차 캐시다. 같은 트랜잭션 안에서 같은 ID로 findById()를 두 번 호출하면, JPA는 두 번째부터는 DB에 다시 쿼리를 날리지 않고 영속성 컨텍스트에 이미 보관 중인 같은 엔티티 인스턴스를 그대로 반환한다. 그래서 같은 트랜잭션 안에서 꺼낸 같은 ID의 엔티티는 == 비교로도 동일한 객체임이 보장된다.
엔티티 생명주기는 엔티티 객체가 생성되어 소멸하기까지 거치는 상태 변화 과정을 뜻한다.
트랜잭션은 여러 작업을 하나의 단위로 묶어 모두 성공하거나 모두 실패하게 만드는 것(원자성)이다. 영속성 컨텍스트가 동작하려면 트랜잭션 경계 안이어야 한다는 전제가 있으므로, 다시 정의하면 "트랜잭션 내에서 데이터베이스를 한 번이라도 갔다 온 엔티티를 관리하는 것"이 영속성 컨텍스트다.
트랜잭션은 Service Layer의 비즈니스 로직 단위로 걸린다. 예를 들어 "A가 B에게 10,000원을 송금한다"는 로직은 A 계좌 차감 → B 계좌 추가 → 송금 내역 기록, 이 세 개의 DB 연산이 전부 성공하거나 전부 실패해야 한다.
@Service
@RequiredArgsConstructor
public class AccountService {
@Transactional
public void paymentTransaction() {
// 1. 사용자 A의 계좌에서 10,000원을 차감하는 로직
// 2. 사용자 B의 계좌에 10,000원을 추가하는 로직
// 3. 송금 내역을 장부에 기록하는 로직
}
}
이렇게 메서드에 @Transactional만 붙이면 스프링이 트랜잭션으로 묶어 관리해준다. 주요 속성은 다음과 같다.
| 속성 | 설명 |
|---|---|
readOnly | true면 읽기 전용으로 최적화 (CUD 불가) |
propagation | 트랜잭션 전파 규칙 (REQUIRED, REQUIRES_NEW 등) |
isolation | 트랜잭션 격리 수준 설정 |
rollbackFor | 특정 예외 발생 시 강제 롤백 지정 |
readOnly = true는 "이 메서드는 조회 전용"이라는 힌트를 줘서 불필요한 쓰기 작업을 줄이는 옵션이다. CRUD 중 R(Read)만 있을 때 사용한다. propagation, isolation, rollbackFor는 입문 주차에서는 자세히 다루지 않는다.
JpaRepository는 쿼리를 직접 작성하지 않아도 CRUD를 처리해주는 쿼리메서드를 기본 제공한다.
save(entity)findById, findAll 등delete(entity), deleteById(id), deleteAll()더티 체킹은 영속성 컨텍스트 내 엔티티의 변경 사항을 자동으로 감지해 DB에 반영하는 메커니즘이다. 업데이트 메서드를 명시적으로 호출하지 않아도, 엔티티의 필드 값만 바꾸면 커밋 시점에 UPDATE SQL이 자동 실행된다.
@Transactional
public void update() {
User user = userRepository.findById(1L).orElseThrow(
() -> new IllegalStateException("존재하지 않는 유저입니다.")
);
user.update("새로운 이름", "new-email@naver.com", "경기도");
}
userRepository에서 별도의 업데이트 메서드를 호출하지 않았는데도, ①트랜잭션 안에서 ②DB를 갔다 온 엔티티는 영속성 컨텍스트가 관리하고 있으므로 ③필드 값 변경만으로 UPDATE SQL이 나간다 — 이게 더티 체킹이다.
save(), findById(), existsById(), deleteById() 같은 메서드를 우리가 직접 만든 적이 없는데도 쓸 수 있는 이유는 JpaRepository에 이미 정의돼 있기 때문이고, 필요하면 이런 쿼리메서드를 직접 커스텀해서 만들 수도 있다.
앞서 배운 3 Layer Architecture와 JPA 개념을 합쳐서, 실제로 User에 대한 CRUD를 REST API로 만들어봤다.
먼저 패키지를 controller, service, repository, entity, dto로 3 Layer Architecture에 맞게 구성하고, Entity·Repository·Service·Controller의 뼈대를 잡는다.
@Getter
@Entity
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 50, nullable = false)
private String name;
@Column(unique = true, nullable = false)
private String email;
private String address;
public User(String name, String email, String address) {
this.name = name;
this.email = email;
this.address = address;
}
public void update(String name, String email, String address) {
this.name = name;
this.email = email;
this.address = address;
}
}
public interface UserRepository extends JpaRepository<User, Long> {
}
여기서 가장 중요한 규칙 하나: 3 Layer Architecture에 의해 Controller는 User라는 엔티티의 존재 자체를 알면 안 된다. 그래서 Service 메서드의 파라미터와 리턴 타입은 Entity가 아니라 반드시 DTO여야 한다.
이게 왜 중요하냐면, Entity를 Controller까지 그대로 노출하면 JPA의 지연 로딩 프록시가 JSON으로 직렬화되는 과정에서 LazyInitializationException이 튀어나오기 쉽고, 무엇보다 DB 스키마(Entity 구조)가 그대로 API 응답 형태가 되어버려서 스키마를 바꿀 때마다 API 명세까지 같이 흔들리게 된다. DTO를 한 겹 끼워두면 이 두 가지 문제에서 자유로워진다.
저장 로직:
// Request DTO — final 없음에 주의
@Getter
public class CreateUserRequest {
private String name;
private String email;
private String address;
}
// Response DTO — final과 생성자가 있음에 주의
@Getter
public class CreateUserResponse {
private final Long id;
private final String name;
private final String email;
private final String address;
public CreateUserResponse(Long id, String name, String email, String address) {
this.id = id;
this.name = name;
this.email = email;
this.address = address;
}
}
@Transactional
public CreateUserResponse save(CreateUserRequest request) {
User user = new User(request.getName(), request.getEmail(), request.getAddress());
User savedUser = userRepository.save(user);
return new CreateUserResponse(
savedUser.getId(), savedUser.getName(), savedUser.getEmail(), savedUser.getAddress()
);
}
전체/단 건 조회, 삭제도 같은 패턴으로 만든다. 조회 전용 메서드는 @Transactional(readOnly = true)를 붙이고, 조회할 때 필요없는 Request DTO는 만들지 않는다(DTO는 필요한 경우에만 작성).
@Transactional(readOnly = true)
public List<GetUserResponse> getALL() {
List<User> users = userRepository.findAll();
List<GetUserResponse> dtos = new ArrayList<>();
for (User user : users) {
dtos.add(new GetUserResponse(user.getId(), user.getName(), user.getEmail(), user.getAddress()));
}
return dtos;
}
@Transactional(readOnly = true)
public GetUserResponse getOne(Long userId) {
User user = userRepository.findById(userId).orElseThrow(
() -> new IllegalStateException("존재하지 않는 유저입니다.")
);
return new GetUserResponse(user.getId(), user.getName(), user.getEmail(), user.getAddress());
}
@Transactional
public void delete(Long userId) {
boolean existence = userRepository.existsById(userId);
if (!existence) {
throw new IllegalStateException("존재하지 않는 유저입니다.");
}
userRepository.deleteById(userId);
}
Controller에서는 ResponseEntity를 써서 HTTP 상태 코드를 정확히 전달한다. 아무 상태값도 지정하지 않으면 스프링은 기본적으로 200을 반환하는데, 생성은 201 Created, 삭제는 204 No Content처럼 의미에 맞는 상태 코드를 명시적으로 돌려주는 게 맞다. 사용법은 ResponseEntity.status(HttpStatus.상태).body(응답데이터) 형태다.
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/users")
public ResponseEntity<CreateUserResponse> create(@RequestBody CreateUserRequest request) {
CreateUserResponse result = userService.save(request);
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}
@GetMapping("/users")
public ResponseEntity<List<GetUserResponse>> getAll() {
return ResponseEntity.status(HttpStatus.OK).body(userService.getALL());
}
@GetMapping("/users/{userId}")
public ResponseEntity<GetUserResponse> getOne(@PathVariable Long userId) {
return ResponseEntity.status(HttpStatus.OK).body(userService.getOne(userId));
}
@PutMapping("/users/{userId}")
public ResponseEntity<UpdateUserResponse> update(
@PathVariable Long userId, @RequestBody UpdateUserRequest request) {
return ResponseEntity.status(HttpStatus.OK).body(userService.update(userId, request));
}
@DeleteMapping("/users/{userId}")
public ResponseEntity<Void> delete(@PathVariable Long userId) {
userService.delete(userId);
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
}
테스트해보면 생성은 201 Created, 전체 조회는 [] 리스트, 단 건 조회는 {} 객체, 삭제는 204 No Content로 응답이 오는 걸 확인할 수 있다.
강의를 아무리 봐도 실력은 늘지 않는다. 결국 직접 쳐보는 게 제일 빠른 길이라, Memo와 Member 엔티티로도 같은 패턴을 반복 연습해봤다.
정리하면:
@RestController/@RequestBody/@PathVariable/@RequestParam이 그 통로다.