API 개념부터 Controller, Service, Repository, 예외처리, 유효성 검사, 환경설정까지 — 빠짐없이 담은 완전 가이드
REST API를 이해하려면 API가 뭔지부터 잡아야 한다. 단어를 뜯어보면 쉽다.
내부가 어떻게 돌아가는지 몰라도, 정해진 방식으로 쓰면 되는 것.
API = 프로그램과 프로그램 사이의 Interface
내 코드가 다른 프로그램의 내부를 몰라도 정해진 방식으로 요청하면 결과를 받을 수 있는 창구.
API를 만드는 사람마다 제각각으로 만들면 쓰는 사람이 너무 힘들다. 그래서 "이렇게 만들자"고 약속한 규칙이 REST다.
/users/1 을 GET으로 요청하면 → 1번 사용자의 "현재 상태"를 JSON으로 표현해서 돌려줌
이 JSON이 바로 "1번 사용자의 상태를 표현한 것"
사용자정보_가져오기() — A회사getUser() — B회사유저데이터_조회_요청() — C회사fetchUserData() — D회사GET /users/1 — 어디서나 동일한 규칙POST /users — 누구나 예측 가능DELETE /users/1 — 문서 없어도 이해 가능❌ /사용자정보_가져오기❌ /getUser✅ /users — 사용자들✅ /users/1 — 1번 사용자✅ /users/1/orders — 1번 사용자의 주문들// 사용자 정보: { name: "라희", email: "[email protected]", age: 30 } // PUT → 전체를 새 데이터로 통째로 교체 PUT /users/1 { name: "라희2", email: "[email protected]", age: 30 } // 전부 보내야 함 // PATCH → 일부만 수정 PATCH /users/1 { name: "라희2" } // name만 바꾸고 나머지는 그대로
| 원칙 | 의미 | 설명 |
|---|---|---|
| ① 클라이언트-서버 구조 | 역할 분리 | 요청하는 쪽(클라이언트)과 처리하는 쪽(서버)을 분리 |
| ② 무상태 (Stateless) | 서버가 상태 저장 안 함 | 요청할 때마다 필요한 정보를 다 담아서 보내야 함. 서버는 이전 요청 기억 안 함 |
| ③ 캐시 처리 가능 | 응답 캐싱 가능 | 같은 요청이면 저장해뒀다가 바로 돌려줄 수 있어야 함 |
| ④ 계층화 | 중간 서버 허용 | 클라이언트는 서버에 직접 연결됐는지, 중간에 다른 서버가 있는지 알 필요 없음 |
| ⑤ 인터페이스 일관성 | 통일된 방식 | URL, HTTP 메서드 등 일관된 방식으로 통신 |
| ⑥ 코드 온 디맨드 | 선택사항 | 필요하면 서버가 클라이언트에 코드를 보낼 수 있음 (자바스크립트 등) |
로그인 후에 토큰(JWT)을 발급받아서 매 요청마다 그걸 들고 다닌다. 서버가 "이 사람 로그인했어"를 기억하는 게 아니라, 클라이언트가 매번 증명하는 방식. 이게 Stateless 원칙을 지키는 것.
스프링에서 REST API 요청을 받는 첫 번째 관문. 전체 흐름에서 Controller의 위치를 먼저 잡자.
그런데 "요청이 어떻게 Controller까지 오는가"를 알아야 에러났을 때 어디서 문제인지 찾을 수 있다.
@Controller public class UserController { @GetMapping("/users/1") public String 페이지() { return "user"; // → user.html 파일 찾아서 돌려줌 } }
@RestController public class UserController { @GetMapping("/users/1") public User 조회() { return new User(1, "라희"); // → {"id":1,"name":"라희"} JSON으로 변환 } }
@ResponseBody가 없으면 리턴값을 "뷰 이름"으로 해석. "user" 리턴하면 user.html 파일을 찾으러 감.
@ResponseBody가 있으면 리턴값을 JSON으로 변환해서 바로 응답.
Jackson 라이브러리가 자바 객체 → JSON 변환을 자동으로 해줌. 개발자가 직접 변환 코드 짤 필요 없음.
User 객체 → {"id":1,"name":"라희"} List<User> → [{"id":1},{"id":2}] Map<String,Object> → {"key":"value"}
@RestController @RequestMapping("/users") // 기본 경로 — 아래 모든 메서드 URL 앞에 자동으로 붙음 public class UserController { @GetMapping("/{id}") // 실제 URL → /users/{id} public User 조회() { } @PostMapping // 실제 URL → /users public User 생성() { } }
@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)
// 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 // 변수명이 {} 안과 같아야 자동 매핑됨 }
// 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 → /users/1 — URL 경로에 값이 있을 때
@RequestParam → /users?id=1 — ? 뒤에 값이 있을 때
// 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); }
@GetMapping("/users") public User 조회(@RequestHeader("Authorization") String token) { // token = "Bearer eyJhbGci..." // 로그인 토큰을 헤더에서 꺼낼 때 주로 사용 } // HTTP 요청 구조: // Header → 부가 정보 (토큰, 콘텐츠 타입 등) // Body → 실제 데이터
// 단순히 객체만 리턴 → 상태코드 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 }
단순 리턴 → 상태코드 제어 불가, 항상 200
ResponseEntity → 상태코드, 헤더, 바디 전부 직접 제어 가능
실무에서는 ResponseEntity를 쓰는 게 권장됨. 상황에 맞는 상태코드를 돌려줘야 제대로 된 REST API.
@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(); } }
Controller와 Repository 사이에서 실제 일을 처리하는 레이어. "왜 분리하는가"부터 트랜잭션까지 빠짐없이.
@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가 너무 뚱뚱해짐. 같은 로직을 다른 곳에서 써야 하면 복붙해야 함. 테스트/수정 어려움.
// Controller — 깔끔! @PostMapping public User 회원가입(@RequestBody User user) { return userService.회원가입(user); } // Service — 로직이 전부 여기에 @Service public class UserService { public User 회원가입(User user) { // 암호화, 중복체크, 저장, 이메일 // 전부 여기서 처리 } }
Controller → 요청/응답만
Service → 비즈니스 로직만
Repository → DB만
@Service public class UserService { } // @Component의 특수 버전. 기능은 @Component와 같음 → 스프링 빈으로 등록 // @Service를 쓰는 이유 → "이 클래스는 비즈니스 로직을 담당한다"는 의도를 명확하게 표현
@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 public class AccountService { @Transactional // 이 메서드 전체가 하나의 트랜잭션 public void 송금(int 출금계좌, int 입금계좌, int 금액) { accountRepository.출금(출금계좌, 금액); // ① accountRepository.입금(입금계좌, 금액); // ②에서 에러나면 ①도 rollback } }
// 읽기 전용 (조회할 때 권장) — 성능 최적화 + 실수로 수정하는 것 방지 @Transactional(readOnly = true) public User 조회(int id) { } // 에러 발생해도 rollback 안 하고 싶을 때 @Transactional(noRollbackFor = 특정Exception.class) public void 처리() { } // 트랜잭션 전파 옵션 @Transactional(propagation = Propagation.REQUIRES_NEW) // 항상 새 트랜잭션 시작 (기존 트랜잭션과 분리)
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 | 트랜잭션 없어야 함. 있으면 에러. |
@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 안 함!
// 인터페이스 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 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 @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); } }