2장 · REST 컨트롤러 & DTO·검증
외부에서 들어온 HTTP 요청을 받아 처리하고 JSON으로 돌려주는 곳, REST 컨트롤러?를 배웁니다. 요청 데이터를 안전하게 받는 DTO?, 잘못된 입력을 걸러내는 입력 검증?, 그리고 결과를 응답 DTO로 바꿔 내보내는 흐름까지 한 번에 봅니다.
REST 컨트롤러란?
컨트롤러는 백엔드의 현관문입니다. 프론트(또는 브라우저)가 보낸 HTTP 요청이 가장 먼저 닿는 곳이고, 어떤 URL과 어떤 HTTP 메서드(GET/POST/…)가 들어왔는지에 따라 알맞은 메서드를 실행합니다. 메서드가 객체를 반환하면 스프링이 자동으로 JSON으로 바꿔서 응답해 줘요.
// study-backend/.../web/endpoint/UserAccountEndpoint.java
@RequiredArgsConstructor // final 필드를 받는 생성자 자동 생성 → 생성자 주입
@RestController // 이 클래스는 HTTP 요청을 받아 JSON으로 응답한다
@RequestMapping("/api/users") // 이 클래스의 모든 URL 앞에 /api/users 가 붙는다
public class UserAccountEndpoint {
private final UserRepo userRepo; // 스프링이 주입
private final UserRoleRepo userRoleRepo;
private final PasswordEncoder passwordEncoder;
}옛날 방식 @Controller는 보통 HTML 화면 이름을 반환했습니다. @RestController는 반환값을 그대로 응답 본문(JSON)으로 씁니다. 요즘처럼 프론트(React)와 백엔드를 나눈 구조에선 @RestController를 씁니다.
HTTP 메서드 매핑
웹에서 "무엇을 하려는지"는 HTTP 메서드로 구분합니다. 조회는 GET, 생성은 POST, 수정은 PUT, 삭제는 DELETE.
스프링은 각 메서드에 맞는 매핑 애너테이션을 제공해요. 괄호 안 문자열은 클래스의 @RequestMapping 경로 뒤에 이어 붙는 경로입니다.
| 애너테이션 | HTTP | 용도 | 이 프로젝트의 예 |
|---|---|---|---|
@GetMapping | GET | 조회(읽기) | GET /api/users 목록 |
@PostMapping | POST | 생성 | POST /api/users 계정 생성 |
@PutMapping | PUT | 수정 | PUT /api/users/update |
@DeleteMapping | DELETE | 삭제 | DELETE /api/users/{id} |
@GetMapping("") // 경로 그대로 → GET /api/users
public PageDTO<UserDTO> listByPage(UserFilter filter, Pageable pageable) { ... }
@GetMapping("/{id}") // 경로 변수 → GET /api/users/3f2a...
public UserDetailDTO get(@PathVariable UUID id) { ... }
@PostMapping("") // → POST /api/users
public void createUserAccount(@Valid @RequestBody UserCreateReq req) { ... }
@DeleteMapping("/{id}") // → DELETE /api/users/3f2a...
public void deleteById(@PathVariable UUID id) { ... }/{id} 처럼 중괄호로 적은 부분은 주소에 들어 있는 값입니다. 메서드 파라미터에 @PathVariable UUID id로 받으면 그 자리에 들어온 값을 자동으로 꺼내 줍니다.
요청 데이터 받기
요청에 담겨 오는 데이터는 위치가 다양합니다. 스프링은 각 위치별로 받는 방법을 제공해요.
① 본문(JSON) → @RequestBody + record DTO
POST/PUT처럼 데이터를 본문(body)에 담아 보낼 때는 @RequestBody로 받습니다.
본문의 JSON이 DTO?의 필드로 자동 매핑돼요.
이 프로젝트는 요청 DTO를 간결한 Java record로 만듭니다.
// web/dto/UserCreateReq.java — 요청 DTO (record + 검증)
public record UserCreateReq(
@NotEmpty String userid,
@NotEmpty String password,
@NotEmpty String name,
@NotEmpty @Email String email
) {}
// 컨트롤러: 본문 JSON 을 UserCreateReq 로 받는다
@PostMapping("")
public void createUserAccount(@Valid @RequestBody UserCreateReq req) {
// req.userid(), req.email() ... record 의 접근자로 값을 읽는다
}② 쿼리스트링 → 객체/Pageable 바인딩
?keyword=kim&active=true&page=0&size=20 같은 쿼리스트링은 @RequestBody 없이
그냥 파라미터로 받으면 스프링이 필드 이름에 맞춰 채워 줍니다.
검색 조건은 UserFilter(record)로, 페이징 정보(page/size/sort)는 스프링이 제공하는 Pageable로 자동으로 묶여요.
// core/model/dto/UserFilter.java — 검색 조건 (쿼리스트링이 자동 매핑)
public record UserFilter(
@Nullable String keyword,
@Nullable List<String> roles,
@Nullable Boolean active) {}
// 컨트롤러: ?keyword=...&active=...&page=0&size=20 가 자동 바인딩
@GetMapping("")
public PageDTO<UserDTO> listByPage(UserFilter filter, Pageable pageable) { ... }③ 로그인한 사용자 → @AuthenticationPrincipal
요청을 보낸 현재 로그인 사용자가 필요할 때가 있습니다(예: 내 프로필 수정).
앞단의 JWT? 인증 필터가 저장해 둔 사용자를 @AuthenticationPrincipal로 바로 받을 수 있어요.
요청 본문에서 "누구인지"를 받지 않으니 위조 위험이 줄어듭니다(자세히는 6장 보안).
@PutMapping("/profile")
public void updateProfile(@Valid @RequestBody UserProfileUpdateReq req,
@AuthenticationPrincipal User currentUser) {
currentUser.setName(req.name()); // 인증된 본인 정보를 수정
currentUser.setEmail(req.email());
userRepo.save(currentUser);
}입력 검증 (@Valid)
외부에서 들어온 값을 그대로 믿으면 안 됩니다. 빈 아이디, 형식이 틀린 이메일 등은 DB에 닿기 전에 걸러야 해요.
입력 검증?은 DTO 필드에 제약 애너테이션을 붙이고,
컨트롤러 파라미터에 @Valid를 붙이는 것만으로 자동으로 동작합니다.
| 애너테이션 | 의미 |
|---|---|
@NotEmpty | null·빈 문자열 금지 (String/컬렉션) |
@NotBlank | null·빈 문자열·공백만 금지 (String) |
@Email | 이메일 형식이어야 함 |
@Size(max=…) | 길이 제한 |
@Pattern(regexp=…) | 정규식 패턴에 맞아야 함 |
이 프로젝트에는 두 가지 DTO 스타일이 있습니다. record + 검증, 그리고 클래스 + Lombok + 메시지가 붙은 검증.
// 스타일 A: record + 검증 (UserCreateReq)
public record UserCreateReq(
@NotEmpty String userid,
@NotEmpty @Email String email,
...
) {}
// 스타일 B: class + Lombok + 메시지 (RoleCreateRequest)
@Getter @Setter
public class RoleCreateRequest {
@NotBlank(message = "역할 코드는 필수입니다")
@Size(max = 50, message = "역할 코드는 50자 이하여야 합니다")
@Pattern(regexp = "^ROLE_[A-Z0-9_]+$",
message = "역할 코드는 ROLE_로 시작하고 영문 대문자, 숫자, 밑줄만 사용 가능합니다")
private String code;
...
}@Valid 검사에 걸리면 MethodArgumentNotValidException이 발생합니다. 그러면 컨트롤러 본문은 아예 실행되지 않고, 전역 예외 처리기가 필드별 오류 메시지를 모아 표준 에러 응답으로 내려보내요. message=...를 적어 두면 그 메시지가 그대로 프론트에 전달됩니다. 전역 예외 처리는 7장에서 자세히 다룹니다.
응답 — DTO 변환 & 페이징
조회 결과인 엔티티?를 그대로 내보내면
비밀번호 같은 민감 필드까지 노출되고, DB 구조가 프론트에 그대로 새어 나갑니다.
그래서 응답 DTO로 변환해서 내보냅니다. 이 프로젝트는 modelmapper 같은 라이브러리를 쓰지 않고,
DTO에 fromEntity() 정적 메서드를 두어 직접 변환해요(명시적이라 추적이 쉽습니다).
// web/dto/UserDTO.java — 응답 DTO (record + 정적 변환)
public record UserDTO(UUID id, String userid, String name, String email, ...) {
public static UserDTO fromEntity(User user) { // 엔티티 → DTO
if (user == null) return null;
return new UserDTO(user.getId(), user.getUserid(),
user.getName(), user.getEmail(), ...);
}
}페이징 — 왜 PageDTO 가 필요한가
목록 조회는 보통 한 페이지씩 끊어 줍니다. 그런데 스프링의 Page 객체를 그대로 반환하면
전체 개수·총 페이지 수 같은 정보가 빠지고 내용(content)만 프론트로 전달되는 문제가 있어요.
이 프로젝트는 PageDTO(record)로 페이지 정보 + 내용을 함께 담아 해결합니다.
// web/dto/PageDTO.java — Page 를 그대로 주면 content 만 나오는 문제를 해결
public record PageDTO<T>(PageInfo pageInfo, List<T> content) {
public static <T> PageDTO<T> fromPage(Page<T> page) {
var pageInfo = new PageInfo(page.getTotalElements(), page.getTotalPages(),
page.getNumber(), page.getSize(), page.isFirst(), page.isLast(), ...);
return new PageDTO<>(pageInfo, page.getContent());
}
}
// 컨트롤러: 조회 → 각 엔티티를 DTO 로 map → PageDTO 로 감싼다
@GetMapping("")
public PageDTO<UserDTO> listByPage(UserFilter filter, Pageable pageable) {
var page = userRepo.listByPage(filter, pageable)
.map(UserDTO::fromEntity); // Page<User> → Page<UserDTO>
return PageDTO.fromPage(page); // → 페이지 정보까지 포함해 응답
}이 프로젝트의 현실 — 얇은 서비스
한 가지 짚고 넘어갈 점이 있습니다. 정석 흐름과 이 프로젝트의 실제 코드가 다릅니다.
일반적으로는 컨트롤러 → 서비스 → 리포지토리 순서로 업무 로직을 서비스 층?에 둡니다.
하지만 이 학습 프로젝트는 서비스 층이 얇아서, UserAccountEndpoint는 서비스를 거치지 않고
userRepo.save(...)처럼 리포지토리를 컨트롤러에서 직접 호출합니다.
그래서 @Transactional도 컨트롤러 메서드에 붙어 있어요. 개념은 3계층으로 이해하되,
코드에선 서비스가 생략돼 있다는 점을 기억하세요.
예를 들어 생성 메서드는 비밀번호 암호화 → 저장 → 중복 키 예외 처리까지 컨트롤러 안에서 다 합니다.
@PostMapping("")
@Transactional // 메서드 전체를 한 트랜잭션으로
public void createUserAccount(@Valid @RequestBody UserCreateReq req) {
String encryptedPassword = passwordEncoder.encode(req.password()); // BCrypt 해시
var newUser = new User();
newUser.setUserid(req.userid());
newUser.setEncryptedPassword(encryptedPassword);
newUser.setName(req.name());
newUser.setEmail(req.email());
try {
userRepo.save(newUser); // 리포지토리 직접 호출
} catch (DataIntegrityViolationException e) { // 아이디 중복 등 DB 제약 위반
throw handleUniqViolation(e, "userid", "로그인 아이디");
}
}아이디처럼 유일(unique)해야 하는 값을 중복으로 저장하려 하면 DB가 거부하고, 스프링은 이를 DataIntegrityViolationException으로 던집니다. 그대로 두면 사용자에게 불친절한 500 에러가 나가므로, handleUniqViolation(...)으로 잡아 "로그인 아이디가 중복됩니다" 같은 읽기 쉬운 메시지로 바꿔 줍니다.
- 새 API 추가 — 요청 record DTO 만들기 → 검증 애너테이션 붙이기 → 컨트롤러 메서드(@GetMapping 등) 작성 → 응답 DTO와
fromEntity추가 - "빈 값으로 저장됐어요" 버그 — 해당 DTO에
@NotEmpty/@NotBlank가 있는지, 컨트롤러에@Valid가 붙었는지부터 확인 - 목록에 총 개수·페이지 정보가 안 보임 —
Page를 그대로 반환하지 말고PageDTO.fromPage(...)로 감쌌는지 확인 - 민감 정보가 응답에 노출됨 — 엔티티를 직접 반환하고 있지 않은지, DTO 변환(
fromEntity)을 거쳤는지 확인
한눈에 정리
| 역할 | 도구 | 예 |
|---|---|---|
| 요청 받기(현관) | @RestController + @RequestMapping | UserAccountEndpoint |
| URL·메서드 매핑 | @GetMapping/@PostMapping/… | GET /api/users |
| 본문 받기 | @RequestBody + record DTO | UserCreateReq |
| 쿼리스트링·페이징 | 객체 바인딩 + Pageable | UserFilter, Pageable |
| 로그인 사용자 | @AuthenticationPrincipal | User currentUser |
| 입력 검증 | @Valid + 제약 애너테이션 | @NotEmpty, @Email, @Pattern |
| 응답 변환 | DTO + fromEntity() | UserDTO.fromEntity |
| 페이징 응답 | PageDTO.fromPage() | PageDTO<UserDTO> |
→ 컨트롤러가 부르는 DB 접근 층(리포지토리·QueryDSL)은 4장 · 데이터 접근에서, 검증 실패·예외 응답은 7장 · 예외 처리에서 이어집니다.