← 참고자료📚 전체 맵참고자료 →
Spring Framework

REST API 완전 정복

API 개념부터 Controller, Service, Repository, 예외처리, 유효성 검사, 환경설정까지 — 빠짐없이 담은 완전 가이드

Part 1 — API · REST · Controller · Service
Part 2 — Repository · 전체 흐름 · DTO
Part 3 — 예외처리 · Valid · application.yml
API란 무엇인가

REST API를 이해하려면 API가 뭔지부터 잡아야 한다. 단어를 뜯어보면 쉽다.

API = Application Programming Interface
Application
프로그램 / 앱
Programming
코드로
Interface
접점, 창구 ← 핵심!
Interface란? — 일상에서 이미 쓰고 있다
🎮 리모컨
TV 내부 회로를 몰라도
버튼만 누르면 채널이 바뀜
🏧 ATM기
은행 서버 코드를 몰라도
화면 버튼만 누르면 출금됨
🔌 콘센트
발전소 원리를 몰라도
꽂기만 하면 전기가 들어옴
Interface의 핵심

내부가 어떻게 돌아가는지 몰라도, 정해진 방식으로 쓰면 되는 것.
API = 프로그램과 프로그램 사이의 Interface
내 코드가 다른 프로그램의 내부를 몰라도 정해진 방식으로 요청하면 결과를 받을 수 있는 창구.

실무에서 API가 쓰이는 곳
날씨 앱
기상청 API로 날씨 데이터를 받아옴
카카오 로그인
카카오 API로 로그인 처리
결제
토스/카드사 API로 결제 처리
지도
구글/카카오 API로 지도 데이터 받아옴
REST란 무엇인가

API를 만드는 사람마다 제각각으로 만들면 쓰는 사람이 너무 힘들다. 그래서 "이렇게 만들자"고 약속한 규칙이 REST다.

REST = Representational State Transfer
Representational
표현
State
상태
Transfer
전달
"자원의 상태를 표현해서 전달한다"

/users/1 을 GET으로 요청하면 → 1번 사용자의 "현재 상태"를 JSON으로 표현해서 돌려줌
이 JSON이 바로 "1번 사용자의 상태를 표현한 것"

REST가 왜 필요한가 — API마다 제각각이면 안 된다
❌ REST 없을 때
사용자정보_가져오기() — A회사
getUser() — B회사
유저데이터_조회_요청() — C회사
fetchUserData() — D회사
✅ REST 있을 때
GET /users/1 — 어디서나 동일한 규칙
POST /users — 누구나 예측 가능
DELETE /users/1 — 문서 없어도 이해 가능
REST의 핵심 규칙 2가지
① URL은 "자원(명사)"으로
❌ /사용자정보_가져오기
❌ /getUser
✅ /users — 사용자들
✅ /users/1 — 1번 사용자
✅ /users/1/orders — 1번 사용자의 주문들
② 행동은 HTTP 메서드로
GET 조회 — 가져와줘
POST 생성 — 만들어줘
PUT 전체 수정 — 통째로 바꿔줘
PATCH 부분 수정 — 일부만 바꿔줘
DELETE 삭제 — 지워줘
PUT vs PATCH 차이
PUT vs PATCH
// 사용자 정보: { name: "라희", email: "[email protected]", age: 30 }

// PUT → 전체를 새 데이터로 통째로 교체
PUT /users/1
{ name: "라희2", email: "[email protected]", age: 30 }  // 전부 보내야 함

// PATCH → 일부만 수정
PATCH /users/1
{ name: "라희2" }  // name만 바꾸고 나머지는 그대로
REST의 6가지 원칙 (로이 필딩, 2000년 논문)
원칙의미설명
① 클라이언트-서버 구조역할 분리요청하는 쪽(클라이언트)과 처리하는 쪽(서버)을 분리
② 무상태 (Stateless)서버가 상태 저장 안 함요청할 때마다 필요한 정보를 다 담아서 보내야 함. 서버는 이전 요청 기억 안 함
③ 캐시 처리 가능응답 캐싱 가능같은 요청이면 저장해뒀다가 바로 돌려줄 수 있어야 함
④ 계층화중간 서버 허용클라이언트는 서버에 직접 연결됐는지, 중간에 다른 서버가 있는지 알 필요 없음
⑤ 인터페이스 일관성통일된 방식URL, HTTP 메서드 등 일관된 방식으로 통신
⑥ 코드 온 디맨드선택사항필요하면 서버가 클라이언트에 코드를 보낼 수 있음 (자바스크립트 등)
Stateless가 중요한 이유
❌ Stateful (상태 저장)
서버: "이 사람 아까 로그인했으니까 기억해둬야지"
문제: 서버가 수백만 명을 다 기억해야 함
문제: 서버가 여러 대면 다른 서버는 모름
✅ Stateless (상태 비저장)
클라이언트가 매 요청마다 "나 라희야, 토큰은 이거야" 직접 들고 옴
서버가 아무것도 기억 안 해도 됨
서버 여러 대여도 아무 서버에 요청해도 됨
그래서 JWT 토큰을 쓰는 이유

로그인 후에 토큰(JWT)을 발급받아서 매 요청마다 그걸 들고 다닌다. 서버가 "이 사람 로그인했어"를 기억하는 게 아니라, 클라이언트가 매번 증명하는 방식. 이게 Stateless 원칙을 지키는 것.

HTTP 상태코드 — REST 응답의 일부
200 OK — 성공
201 Created — 생성 성공
204 No Content — 삭제 성공 (바디 없음)
400 Bad Request — 요청 데이터 잘못됨
401 Unauthorized — 로그인 안 됨
403 Forbidden — 권한 없음
404 Not Found — 없는 자원
409 Conflict — 중복 (이미 있는 이메일 등)
500 Internal Server Error — 서버 내부 오류
Controller — 요청을 받고 응답을 돌려주는 역할

스프링에서 REST API 요청을 받는 첫 번째 관문. 전체 흐름에서 Controller의 위치를 먼저 잡자.

전체 레이어 구조
Controller — 요청 받고 응답 돌려줌 ← 지금 여기
Service — 비즈니스 로직 처리
Repository — DB와 대화
① DispatcherServlet — 요청이 Controller에 도달하기까지
클라이언트 요청 (GET /users/1)
DispatcherServlet — 스프링의 프론트 컨트롤러. 모든 요청이 여기를 먼저 거침
HandlerMapping — 어떤 Controller의 어떤 메서드로 보낼지 찾아줌
Controller 메서드 — 실제 처리
MessageConverter — 리턴값을 JSON으로 변환 (Jackson)
클라이언트 응답 (JSON)
개발자가 DispatcherServlet을 직접 건드릴 일은 거의 없음

그런데 "요청이 어떻게 Controller까지 오는가"를 알아야 에러났을 때 어디서 문제인지 찾을 수 있다.

② @RestController vs @Controller
@Controller — HTML 뷰를 돌려줌
@Controller
public class UserController {
  @GetMapping("/users/1")
  public String 페이지() {
    return "user";
    // → user.html 파일 찾아서 돌려줌
  }
}
@RestController — JSON 데이터를 돌려줌 ⭐
@RestController
public class UserController {
  @GetMapping("/users/1")
  public User 조회() {
    return new User(1, "라희");
    // → {"id":1,"name":"라희"} JSON으로 변환
  }
}
@RestController = @Controller + @ResponseBody

@ResponseBody가 없으면 리턴값을 "뷰 이름"으로 해석. "user" 리턴하면 user.html 파일을 찾으러 감.
@ResponseBody가 있으면 리턴값을 JSON으로 변환해서 바로 응답.
Jackson 라이브러리가 자바 객체 → JSON 변환을 자동으로 해줌. 개발자가 직접 변환 코드 짤 필요 없음.

Jackson이 자동 변환하는 것들
User 객체       →  {"id":1,"name":"라희"}
List<User>      →  [{"id":1},{"id":2}]
Map<String,Object> →  {"key":"value"}
③ @RequestMapping — URL 기본 경로 지정
@RequestMapping 사용법
@RestController
@RequestMapping("/users")  // 기본 경로 — 아래 모든 메서드 URL 앞에 자동으로 붙음
public class UserController {

  @GetMapping("/{id}")   // 실제 URL → /users/{id}
  public User 조회() { }

  @PostMapping           // 실제 URL → /users
  public User 생성() { }
}
④ HTTP 메서드별 매핑 애너테이션
HTTP 메서드 애너테이션
@GetMapping("/{id}")     // GET    /users/1   조회
@PostMapping              // POST   /users     생성
@PutMapping("/{id}")     // PUT    /users/1   전체 수정
@PatchMapping("/{id}")   // PATCH  /users/1   부분 수정
@DeleteMapping("/{id}") // DELETE /users/1   삭제

// 이것들은 전부 @RequestMapping의 단축 버전
// 아래 두 개는 완전히 같음
@GetMapping("/users")
@RequestMapping(value="/users", method=RequestMethod.GET)
⑤ @PathVariable — URL에서 값 받기
@PathVariable
// GET /users/1
@GetMapping("/{id}")
public User 조회(@PathVariable int id) {
    // id = 1
}

// GET /users/1/orders/5 — 여러 개도 가능
@GetMapping("/{userId}/orders/{orderId}")
public Order 주문조회(@PathVariable int userId,
                     @PathVariable int orderId) {
    // userId = 1, orderId = 5
    // 변수명이 {} 안과 같아야 자동 매핑됨
}
⑥ @RequestParam — 쿼리스트링에서 값 받기
@RequestParam
// GET /users?name=라희&age=30
@GetMapping
public List<User> 검색(
    @RequestParam String name,    // name = "라희"
    @RequestParam int age) {      // age = 30
}

// 필수가 아닌 경우 (없어도 됨)
@RequestParam(required = false) String name

// 기본값 지정
@RequestParam(defaultValue = "1") int page
@PathVariable vs @RequestParam

@PathVariable → /users/1 — URL 경로에 값이 있을 때
@RequestParam → /users?id=1 — ? 뒤에 값이 있을 때

⑦ @RequestBody — 요청 JSON을 자바 객체로 받기
@RequestBody
// POST /users
// Body: {"name":"라희","email":"[email protected]"}
@PostMapping
public User 생성(@RequestBody User user) {
    // user.getName() = "라희"
    // user.getEmail() = "[email protected]"
    // Jackson이 자동으로 JSON → 자바 객체로 변환
    // @ResponseBody의 반대 방향
    return userService.save(user);
}
⑧ @RequestHeader — 요청 헤더값 받기
@RequestHeader
@GetMapping("/users")
public User 조회(@RequestHeader("Authorization") String token) {
    // token = "Bearer eyJhbGci..."
    // 로그인 토큰을 헤더에서 꺼낼 때 주로 사용
}

// HTTP 요청 구조:
// Header → 부가 정보 (토큰, 콘텐츠 타입 등)
// Body   → 실제 데이터
⑨ ResponseEntity — 응답을 세밀하게 제어하기
ResponseEntity
// 단순히 객체만 리턴 → 상태코드 200이 자동으로 붙음
@GetMapping("/{id}")
public User 조회(@PathVariable int id) {
    return userService.findById(id);
}

// ResponseEntity로 상태코드까지 직접 제어 (권장)
@GetMapping("/{id}")
public ResponseEntity<User> 조회(@PathVariable int id) {
    User user = userService.findById(id);
    if (user == null) {
        return ResponseEntity.notFound().build();    // 404
    }
    return ResponseEntity.ok(user);                // 200 + JSON
}

@PostMapping
public ResponseEntity<User> 생성(@RequestBody User user) {
    User saved = userService.save(user);
    return ResponseEntity.status(201).body(saved);  // 201 + JSON
}

@DeleteMapping("/{id}")
public ResponseEntity<Void> 삭제(@PathVariable int id) {
    userService.delete(id);
    return ResponseEntity.noContent().build();     // 204
}
단순 리턴 vs ResponseEntity

단순 리턴 → 상태코드 제어 불가, 항상 200
ResponseEntity → 상태코드, 헤더, 바디 전부 직접 제어 가능
실무에서는 ResponseEntity를 쓰는 게 권장됨. 상황에 맞는 상태코드를 돌려줘야 제대로 된 REST API.

⑩ Controller 실전 전체 코드
UserController.java — 실전 전체
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    // GET /users/1 → 1번 사용자 조회
    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> 조회(@PathVariable int id) {
        return ResponseEntity.ok(userService.사용자조회(id));
    }

    // GET /users?name=라희&page=0 → 검색 + 페이징
    @GetMapping
    public ResponseEntity<List<UserResponse>> 목록(
            @RequestParam(required = false) String name,
            @RequestParam(defaultValue = "0") int page) {
        return ResponseEntity.ok(userService.목록조회(name, page));
    }

    // POST /users → 회원가입
    @PostMapping
    public ResponseEntity<UserResponse> 회원가입(
            @Valid @RequestBody UserCreateRequest request) {
        return ResponseEntity.status(201)
            .body(userService.회원가입(request));
    }

    // PUT /users/1 → 전체 수정
    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> 수정(
            @PathVariable int id,
            @Valid @RequestBody UserUpdateRequest request) {
        return ResponseEntity.ok(userService.수정(id, request));
    }

    // DELETE /users/1 → 삭제
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> 삭제(@PathVariable int id) {
        userService.삭제(id);
        return ResponseEntity.noContent().build();
    }
}
Service — 비즈니스 로직을 처리하는 역할

Controller와 Repository 사이에서 실제 일을 처리하는 레이어. "왜 분리하는가"부터 트랜잭션까지 빠짐없이.

① 왜 Service 레이어가 필요한가
❌ Controller에 다 넣은 경우
@PostMapping
public User 회원가입(@RequestBody User user) {
  // 비밀번호 암호화
  String pw = BCrypt.hashpw(user.getPassword());
  user.setPassword(pw);
  // 이메일 중복 체크
  if (repo.existsByEmail(user.getEmail())) {
    throw new Exception("중복");
  }
  // 저장
  repo.save(user);
  // 환영 이메일
  emailService.send(user.getEmail());
  return user;
}
문제점

Controller가 너무 뚱뚱해짐. 같은 로직을 다른 곳에서 써야 하면 복붙해야 함. 테스트/수정 어려움.

✅ Service로 분리한 경우
// Controller — 깔끔!
@PostMapping
public User 회원가입(@RequestBody User user) {
  return userService.회원가입(user);
}

// Service — 로직이 전부 여기에
@Service
public class UserService {
  public User 회원가입(User user) {
    // 암호화, 중복체크, 저장, 이메일
    // 전부 여기서 처리
  }
}
관심사 분리

Controller → 요청/응답만
Service → 비즈니스 로직만
Repository → DB만

② @Service
@Service
@Service
public class UserService { }

// @Component의 특수 버전. 기능은 @Component와 같음 → 스프링 빈으로 등록
// @Service를 쓰는 이유 → "이 클래스는 비즈니스 로직을 담당한다"는 의도를 명확하게 표현
③ Service에서 의존성 주입 (DI)
생성자 주입 (권장 방식)
@Service
public class UserService {

    private final UserRepository userRepository;
    private final EmailService emailService;

    // 생성자 주입
    public UserService(UserRepository userRepository,
                       EmailService emailService) {
        this.userRepository = userRepository;
        this.emailService = emailService;
    }
}

// Lombok @RequiredArgsConstructor 쓰면 생성자 코드 자동 생성
@Service
@RequiredArgsConstructor  // ← 이 한 줄로 위 생성자 코드 대체
public class UserService {
    private final UserRepository userRepository;
    private final EmailService emailService;
}
④ 트랜잭션 — Service에서 제일 중요한 개념
트랜잭션 = "전부 성공하거나, 전부 실패하거나"
❌ 트랜잭션 없을 때
① 내 계좌에서 10만원 출금 → 성공
② 상대방 계좌에 10만원 입금 → 에러!
결과: 내 돈만 사라지는 대참사
✅ 트랜잭션 있을 때
① 내 계좌에서 10만원 출금 → 성공
② 상대방 계좌에 입금 → 에러!
→ ①도 자동으로 rollback. 둘 다 없던 일로
@Transactional
@Service
public class AccountService {

    @Transactional  // 이 메서드 전체가 하나의 트랜잭션
    public void 송금(int 출금계좌, int 입금계좌, int 금액) {
        accountRepository.출금(출금계좌, 금액);  // ①
        accountRepository.입금(입금계좌, 금액);  // ②에서 에러나면 ①도 rollback
    }
}
⑤ @Transactional 옵션들
@Transactional 옵션
// 읽기 전용 (조회할 때 권장) — 성능 최적화 + 실수로 수정하는 것 방지
@Transactional(readOnly = true)
public User 조회(int id) { }

// 에러 발생해도 rollback 안 하고 싶을 때
@Transactional(noRollbackFor = 특정Exception.class)
public void 처리() { }

// 트랜잭션 전파 옵션
@Transactional(propagation = Propagation.REQUIRES_NEW)
// 항상 새 트랜잭션 시작 (기존 트랜잭션과 분리)
⑥ 트랜잭션 전파(Propagation)

Service가 다른 Service를 호출할 때 트랜잭션이 어떻게 되는지.

트랜잭션 전파
@Service
public class OrderService {
    @Transactional
    public void 주문하기() {
        orderRepository.save(주문);
        userService.포인트차감();  // ← UserService 호출
    }
}

@Service
public class UserService {
    @Transactional  // ← 이미 트랜잭션이 있는데 어떻게 되나?
    public void 포인트차감() { }
}
옵션동작
REQUIRED (기본값)이미 트랜잭션 있으면 그거 이어서 사용. 없으면 새로 시작.
REQUIRES_NEW항상 새 트랜잭션 시작. 기존 트랜잭션은 잠깐 중단.
MANDATORY반드시 기존 트랜잭션 있어야 함. 없으면 에러.
NEVER트랜잭션 없어야 함. 있으면 에러.
⑦ 예외 처리와 rollback — 중요!
커스텀 예외 + rollback
@Service
public class UserService {

    @Transactional
    public User 회원가입(User user) {
        if (userRepository.existsByEmail(user.getEmail())) {
            throw new DuplicateEmailException("이미 있는 이메일");
        }
        return userRepository.save(user);
    }
}

// 커스텀 예외 — RuntimeException 상속해야 자동 rollback!
public class DuplicateEmailException extends RuntimeException {
    public DuplicateEmailException(String message) {
        super(message);
    }
}
⚠️ 이거 모르면 큰일 납니다

RuntimeException을 상속받아야 @Transactional이 에러 났을 때 자동으로 rollback 해줌.
체크 예외(Exception 직접 상속)는 @Transactional이 자동 rollback 안 함!

⑧ Service 인터페이스 패턴
인터페이스 + 구현체 분리
// 인터페이스
public interface UserService {
    User 사용자조회(int id);
    User 회원가입(User user);
}

// 실제 구현체
@Service
public class UserServiceImpl implements UserService {

    @Override
    public User 사용자조회(int id) { ... }

    @Override
    public User 회원가입(User user) { ... }
}

// 언제 이렇게 나누냐?
// ① 나중에 구현체를 바꿔도 Controller는 그대로 (인터페이스만 보니까)
// ② 테스트할 때 가짜 구현체(Mock)로 교체하기 쉬움
// ③ 구현체가 하나뿐이면 굳이 나눌 필요 없음 (요즘 추세)
⑨ @Transactional은 AOP로 동작한다
@Transactional 내부 동작 = AOP @Around
// 우리가 쓰는 코드
@Transactional
public User 회원가입(User user) {
    return userRepository.save(user);
}

// 스프링이 내부적으로 하는 일 (AOP @Around와 완전히 동일)
public User 회원가입(User user) {
    트랜잭션시작();          // Before
    try {
        Object r = 진짜회원가입(user); // jp.proceed()
        트랜잭션커밋();         // 성공시
        return (User) r;
    } catch (Exception e) {
        트랜잭션롤백();         // 실패시
        throw e;
    }
}
// @Transactional 붙이면 스프링이 Proxy를 만들어서
// 트랜잭션 시작/커밋/롤백을 자동으로 앞뒤에 끼워줌
// AOP 배울 때 나온 Proxy, @Around 개념이 여기서 쓰이는 것!
⑩ Service 실전 전체 코드
UserService.java — 실전 전체
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final EmailService emailService;
    private final PasswordEncoder passwordEncoder;

    // 조회 — readOnly = true 로 성능 최적화
    @Transactional(readOnly = true)
    public User 사용자조회(int id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException("없는 사용자"));
    }

    // 회원가입
    @Transactional
    public User 회원가입(User user) {
        if (userRepository.existsByEmail(user.getEmail())) {
            throw new DuplicateEmailException("이미 있는 이메일");
        }
        user.setPassword(passwordEncoder.encode(user.getPassword()));
        User saved = userRepository.save(user);
        emailService.send(saved.getEmail(), "환영합니다!");
        return saved;
    }

    // 수정 — 변경 감지(Dirty Checking)로 자동 UPDATE
    @Transactional
    public User 수정(int id, User 수정데이터) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException("없는 사용자"));
        user.setName(수정데이터.getName());
        // save() 안 해도 트랜잭션 끝날 때 자동 UPDATE
        return user;
    }

    // 삭제
    @Transactional
    public void 삭제(int id) {
        if (!userRepository.existsById(id)) {
            throw new UserNotFoundException("없는 사용자");
        }
        userRepository.deleteById(id);
    }
}
Controller → Service → Repository — 각자 자기 역할만
Part 2에서 Repository · 전체 흐름 · DTO를 이어서 설명합니다