🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

첫 번째 REST API 만들기 완벽 가이드 - 슬라이드 1/7
A

AI Generated

2025. 12. 20. · 4 Views

첫 번째 REST API 만들기 완벽 가이드

처음 Spring Boot로 REST API를 개발하는 초급 개발자를 위한 실전 가이드입니다. 설계 원칙부터 실제 구현까지, 베스트 프랙티스를 이북처럼 술술 읽히는 스타일로 설명합니다. 실무에서 바로 적용할 수 있는 코드와 팁을 담았습니다.


목차

  1. REST API 설계 원칙
  2. @RestController 사용
  3. Service 계층 구현
  4. Repository 패턴
  5. DTO와 엔티티 분리
  6. HTTP 메서드와 상태 코드

1. REST API 설계 원칙

입사 첫날, 김개발 씨는 팀장님께 첫 번째 업무를 받았습니다. "사용자 정보를 조회하는 API를 만들어주세요." 간단해 보였지만, 막상 코드를 작성하려니 막막했습니다.

URL은 어떻게 만들어야 할까요?

REST API는 웹에서 데이터를 주고받는 표준화된 방식입니다. 마치 우체국에 정해진 규칙이 있듯이, REST API도 누구나 이해할 수 있는 일관된 규칙을 따릅니다.

이 원칙을 지키면 다른 개발자가 여러분의 API를 쉽게 이해하고 사용할 수 있습니다.

다음 코드를 살펴봅시다.

// RESTful한 사용자 API 설계 예시
@RestController
@RequestMapping("/api/users")
public class UserController {

    // GET /api/users - 전체 사용자 조회
    @GetMapping
    public List<User> getAllUsers() {
        return userService.findAll();
    }

    // GET /api/users/1 - 특정 사용자 조회
    @GetMapping("/{id}")
    public User getUserById(@PathVariable Long id) {
        return userService.findById(id);
    }

    // POST /api/users - 사용자 생성
    @PostMapping
    public User createUser(@RequestBody User user) {
        return userService.create(user);
    }
}

입사 첫날의 김개발 씨 이야기를 계속해봅시다. 팀장님은 친절하게도 선배 박시니어 씨를 멘토로 붙여주셨습니다.

박시니어 씨가 먼저 질문을 던집니다. "사용자 정보를 가져오는 URL을 어떻게 만들 건가요?" 김개발 씨는 자신있게 대답했습니다.

"저는 /getUserInfo?action=get 이렇게 만들려고요!" 박시니어 씨가 고개를 저으며 말합니다. "음, 그것보다 더 좋은 방법이 있어요." REST API의 핵심은 자원 중심 설계입니다.

쉽게 비유하자면, REST API는 마치 도서관의 책장과 같습니다. 도서관에서 책을 찾을 때 "소설/한국소설/김작가" 이런 식으로 분류되어 있는 것처럼, API도 자원을 계층적으로 표현합니다.

김개발 씨가 처음 생각했던 /getUserInfo 같은 URL은 행위를 URL에 포함시켰습니다. 이것은 REST 원칙에 어긋납니다.

행위는 URL이 아니라 HTTP 메서드로 표현해야 합니다. 그렇다면 올바른 방법은 무엇일까요?

REST API에서는 자원을 명사로 표현합니다. 사용자 정보라면 /users라고 표현하는 것이죠.

그리고 그 자원에 대한 행위는 HTTP 메서드로 구분합니다. 조회는 GET, 생성은 POST, 수정은 PUT이나 PATCH, 삭제는 DELETE를 사용합니다.

박시니어 씨가 화이트보드에 그림을 그리며 설명합니다. "자, 우리가 만들 API를 생각해봅시다.

전체 사용자 목록을 가져올 때는 GET /api/users, 특정 사용자 한 명을 가져올 때는 GET /api/users/1 이런 식으로 만드는 겁니다." 여기서 중요한 점이 또 있습니다. 자원은 복수형으로 표현하는 것이 관례입니다.

/user가 아니라 /users인 이유죠. 이것은 마치 도서관에서 "책" 하나가 아니라 "책들"이 모여있는 서가를 가리키는 것과 같습니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 쇼핑몰 서비스를 개발한다고 가정해봅시다.

상품(Product)이라는 자원이 있다면, 다음과 같이 설계할 수 있습니다. GET /api/products는 전체 상품 목록을 조회합니다.

GET /api/products/123은 상품 번호 123번 상품의 상세 정보를 조회합니다. POST /api/products는 새로운 상품을 등록합니다.

이렇게 일관된 패턴으로 API를 설계하면, API 문서를 보지 않아도 직관적으로 이해할 수 있습니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수 중 하나는 URL에 동사를 넣는 것입니다. /api/deleteUser/1 같은 URL은 REST 원칙에 어긋납니다.

대신 DELETE /api/users/1 이렇게 표현해야 합니다. 또 다른 실수는 계층이 너무 깊어지는 것입니다.

/api/users/1/orders/2/items/3/reviews/4 이런 식으로 깊어지면 오히려 복잡해집니다. 보통 2-3단계 정도가 적당합니다.

김개발 씨는 박시니어 씨의 설명을 들으며 메모를 열심히 했습니다. "아, 그래서 우리 회사 API들이 다 /api/명사 형태였구나!" REST API 설계 원칙을 제대로 이해하면 누구나 쉽게 사용할 수 있는 API를 만들 수 있습니다.

여러분도 오늘 배운 내용을 바탕으로 깔끔한 API를 설계해 보세요.

실전 팁

💡 - URL은 명사형 복수로, 행위는 HTTP 메서드로 표현하세요

  • 계층 구조는 2-3단계를 넘지 않도록 유지하세요
  • 일관된 네이밍 컨벤션을 사용하세요 (camelCase 또는 kebab-case)

2. @RestController 사용

김개발 씨가 코드를 작성하기 시작했습니다. 그런데 @Controller와 @RestController 중 어떤 것을 써야 할지 고민이 됩니다.

같은 컨트롤러인데 왜 두 가지가 있을까요? 박시니어 씨에게 물어보기로 했습니다.

@RestController는 Spring에서 RESTful API를 만들 때 사용하는 핵심 어노테이션입니다. 이것은 @Controller와 @ResponseBody를 합친 것으로, 메서드의 반환값이 자동으로 JSON이나 XML로 변환됩니다.

마치 번역기가 자동으로 한국어를 영어로 바꿔주듯이, 자바 객체를 JSON으로 변환해줍니다.

다음 코드를 살펴봅시다.

// @RestController를 사용한 API 컨트롤러
@RestController
@RequestMapping("/api/users")
public class UserController {

    @Autowired
    private UserService userService;

    // JSON 형태로 자동 변환되어 응답
    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        if (user == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(user);
    }

    // 요청 본문의 JSON이 자동으로 User 객체로 변환
    @PostMapping
    public ResponseEntity<User> createUser(@RequestBody User user) {
        User created = userService.create(user);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }
}

박시니어 씨가 김개발 씨의 질문에 답하기 시작했습니다. "좋은 질문이에요.

이 둘의 차이를 아는 게 정말 중요하답니다." 옛날 이야기를 잠시 해봅시다. Spring이 처음 나왔을 때는 @Controller만 있었습니다.

이것은 주로 웹 페이지를 만들 때 사용했죠. 컨트롤러 메서드가 "login.jsp"나 "home.html" 같은 뷰 이름을 반환하면, Spring이 그 페이지를 찾아서 보여주는 방식이었습니다.

하지만 시대가 변했습니다. 요즘은 프론트엔드와 백엔드를 분리해서 개발하는 경우가 많아졌습니다.

React나 Vue.js 같은 프론트엔드 프레임워크가 화면을 담당하고, 백엔드는 데이터만 JSON 형태로 전달하는 것이죠. 이런 상황에서 매번 @ResponseBody를 붙이는 게 번거로웠습니다.

김개발 씨처럼 깜빡하고 빼먹으면 에러가 발생하기도 했죠. 바로 이런 문제를 해결하기 위해 @RestController가 등장했습니다.

박시니어 씨가 코드를 보여주며 설명합니다. "@RestController를 클래스에 붙이면, 모든 메서드에 자동으로 @ResponseBody가 적용됩니다.

즉, 반환값이 뷰 이름이 아니라 실제 데이터로 처리되는 거죠." 쉽게 비유하자면, @RestController는 마치 자동 번역기와 같습니다. 여러분이 자바 객체를 반환하면, Spring이 알아서 JSON으로 번역해서 클라이언트에게 전달합니다.

직접 번역할 필요가 없는 것이죠. 위의 코드를 자세히 살펴봅시다.

먼저 클래스 레벨에 @RestController를 붙였습니다. 이것만으로 이 클래스의 모든 메서드는 REST API 엔드포인트가 됩니다.

@RequestMapping("/api/users")는 이 컨트롤러의 기본 URL 경로를 지정합니다. getUser 메서드를 보면 **ResponseEntity<User>**를 반환합니다.

ResponseEntity는 HTTP 응답을 더 세밀하게 제어할 수 있게 해주는 클래스입니다. 상태 코드, 헤더, 본문을 모두 설정할 수 있죠.

만약 사용자를 찾지 못하면 404 Not Found를 반환합니다. 찾았다면 200 OK와 함께 사용자 정보를 JSON으로 반환합니다.

이 모든 변환이 자동으로 이루어집니다. createUser 메서드에서는 @RequestBody를 사용했습니다.

이것은 클라이언트가 보낸 JSON 데이터를 자동으로 User 객체로 변환해줍니다. 마치 받은 편지를 자동으로 번역해주는 것과 같습니다.

실제 현업에서는 어떻게 활용할까요? 대부분의 현대적인 웹 애플리케이션은 프론트엔드와 백엔드를 분리합니다.

프론트엔드는 React나 Vue.js로 개발하고, 백엔드는 Spring Boot로 REST API를 제공하는 것이죠. 이런 아키텍처에서는 @RestController가 필수입니다.

예를 들어 모바일 앱을 개발한다면, 앱은 화면을 자체적으로 그리고 서버에는 데이터만 요청합니다. 이때 서버는 HTML 페이지가 아니라 JSON 데이터를 반환해야 합니다.

@RestController가 바로 이런 용도로 사용됩니다. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수는 @RestController와 @Controller를 혼용하는 것입니다. 만약 HTML 페이지를 반환하는 컨트롤러라면 @Controller를 사용해야 합니다.

REST API라면 @RestController를 사용하세요. 또 다른 실수는 ResponseEntity를 제대로 활용하지 않는 것입니다.

단순히 객체만 반환하면 항상 200 OK가 됩니다. 하지만 생성(201 Created), 에러(404 Not Found, 400 Bad Request) 등 적절한 상태 코드를 함께 반환하는 것이 좋은 API 설계입니다.

김개발 씨가 테스트를 해봤습니다. Postman으로 GET 요청을 보내니 깔끔한 JSON 응답이 돌아왔습니다.

"와, 정말 자동으로 변환되네요!" @RestController를 제대로 이해하면 RESTful API를 훨씬 쉽게 만들 수 있습니다. 여러분도 오늘 배운 내용을 바탕으로 깔끔한 API 컨트롤러를 작성해 보세요.

실전 팁

💡 - REST API는 @RestController, 웹 페이지는 @Controller를 사용하세요

  • ResponseEntity로 상태 코드를 명확하게 지정하세요
  • @RequestBody와 @PathVariable을 적재적소에 활용하세요

3. Service 계층 구현

코드를 작성하던 김개발 씨는 문득 궁금해졌습니다. "컨트롤러에 모든 로직을 다 작성하면 안 될까요?" 코드가 점점 길어지면서 뭔가 복잡해지는 느낌이 들었습니다.

박시니어 씨가 그 모습을 보고 다가왔습니다.

Service 계층은 비즈니스 로직을 담당하는 계층입니다. 마치 식당에서 웨이터(Controller)가 주문을 받으면 요리사(Service)가 실제 요리를 하는 것처럼, 컨트롤러는 요청을 받고 서비스가 실제 작업을 처리합니다.

이렇게 분리하면 코드의 재사용성과 테스트 용이성이 크게 향상됩니다.

다음 코드를 살펴봅시다.

// 서비스 인터페이스 정의
public interface UserService {
    User findById(Long id);
    List<User> findAll();
    User create(User user);
    User update(Long id, User user);
    void delete(Long id);
}

// 서비스 구현체
@Service
@Transactional
public class UserServiceImpl implements UserService {

    @Autowired
    private UserRepository userRepository;

    @Override
    public User findById(Long id) {
        // 비즈니스 로직: 유효성 검증
        return userRepository.findById(id)
            .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다"));
    }

    @Override
    public User create(User user) {
        // 비즈니스 로직: 중복 검사
        if (userRepository.existsByEmail(user.getEmail())) {
            throw new DuplicateEmailException("이미 존재하는 이메일입니다");
        }
        return userRepository.save(user);
    }
}

박시니어 씨가 김개발 씨의 화면을 보더니 말합니다. "컨트롤러가 너무 무거워 보이는데요?

한번 리팩토링을 해봅시다." 김개발 씨는 지금까지 컨트롤러에 모든 로직을 작성했습니다. 데이터베이스 조회도 하고, 유효성 검증도 하고, 비즈니스 규칙도 적용하고.

처음에는 괜찮았지만, 기능이 추가될수록 컨트롤러가 100줄, 200줄로 늘어났습니다. 더 큰 문제는 코드의 재사용이 어렵다는 것이었습니다.

예를 들어 사용자 생성 로직을 다른 곳에서도 사용하고 싶은데, 컨트롤러에 묶여있다 보니 복사 붙여넣기를 해야 했습니다. 바로 이런 문제를 해결하기 위해 계층 분리가 필요합니다.

박시니어 씨가 화이트보드에 그림을 그립니다. "웹 애플리케이션은 보통 세 개의 계층으로 나눕니다.

컨트롤러(Controller), 서비스(Service), 저장소(Repository)죠." 쉽게 비유하자면, 이것은 마치 식당의 역할 분담과 같습니다. 웨이터(Controller)는 손님의 주문을 받아서 주방에 전달합니다.

요리사(Service)는 실제로 요리를 만듭니다. 창고지기(Repository)는 재료를 보관하고 꺼내줍니다.

각자 역할이 명확하면 무슨 일이 생겼을 때 누구에게 가야 할지 분명합니다. 음식 맛이 이상하면 요리사에게, 재료가 없으면 창고지기에게 말하면 되는 것이죠.

위의 코드를 자세히 살펴봅시다. 먼저 인터페이스를 정의했습니다.

UserService 인터페이스는 이 서비스가 제공하는 기능의 목록을 선언합니다. 이렇게 인터페이스를 먼저 만들면 나중에 구현체를 쉽게 바꿀 수 있습니다.

UserServiceImpl 클래스에는 @Service 어노테이션을 붙였습니다. 이것은 Spring에게 "이 클래스는 비즈니스 로직을 담당하는 서비스입니다"라고 알려주는 표시입니다.

Spring이 자동으로 이 클래스의 인스턴스를 만들어서 관리합니다. @Transactional 어노테이션도 중요합니다.

이것은 메서드가 데이터베이스 트랜잭션 안에서 실행되도록 보장합니다. 만약 중간에 에러가 발생하면 자동으로 롤백됩니다.

마치 은행 거래처럼, 전부 성공하거나 전부 취소되거나 둘 중 하나만 가능한 것이죠. findById 메서드를 보면 단순히 조회만 하는 게 아닙니다.

비즈니스 규칙을 적용합니다. 만약 사용자를 찾지 못하면 의미있는 예외를 던집니다.

이런 검증 로직이 바로 서비스 계층의 핵심 역할입니다. create 메서드에서는 더 복잡한 로직이 들어갑니다.

새 사용자를 생성하기 전에 이메일 중복을 검사합니다. 이런 비즈니스 규칙은 컨트롤러가 아니라 서비스에 작성해야 합니다.

실제 현업에서는 어떻게 활용할까요? 예를 들어 쇼핑몰에서 주문을 처리한다고 생각해봅시다.

주문 생성은 단순한 작업이 아닙니다. 재고를 확인하고, 포인트를 차감하고, 결제를 진행하고, 배송 정보를 생성해야 합니다.

이런 복잡한 비즈니스 로직은 모두 OrderService에 작성됩니다. 컨트롤러는 그저 "주문 생성 요청이 왔습니다"라고 서비스를 호출하기만 하면 됩니다.

나머지는 서비스가 알아서 처리하는 것이죠. 하지만 주의할 점도 있습니다.

초보 개발자들이 흔히 하는 실수는 서비스가 너무 비대해지는 것입니다. 모든 로직을 하나의 서비스에 몰아넣으면 결국 컨트롤러가 비대했던 것과 같은 문제가 발생합니다.

적절히 서비스를 분리하는 것이 중요합니다. 또 다른 실수는 서비스에서 HTTP 관련 코드를 사용하는 것입니다.

HttpServletRequest나 HttpSession 같은 것들은 컨트롤러 계층에만 있어야 합니다. 서비스는 순수한 비즈니스 로직만 담당해야 테스트하기 쉽습니다.

김개발 씨가 코드를 리팩토링했습니다. 컨트롤러는 간결해졌고, 복잡한 로직은 모두 서비스로 옮겨갔습니다.

"이제 훨씬 깔끔하네요!" Service 계층을 제대로 이해하면 유지보수하기 쉬운 코드를 작성할 수 있습니다. 여러분도 오늘 배운 내용을 바탕으로 역할이 명확한 코드를 만들어 보세요.

실전 팁

💡 - 서비스는 비즈니스 로직만 담당하도록 하세요

  • 인터페이스를 먼저 정의하면 유연한 설계가 가능합니다
  • @Transactional로 데이터 일관성을 보장하세요

4. Repository 패턴

김개발 씨가 데이터베이스에서 사용자 정보를 가져오려고 SQL을 작성하기 시작했습니다. 그런데 박시니어 씨가 "잠깐, SQL을 직접 작성할 필요가 없어요"라고 말했습니다.

어떻게 SQL 없이 데이터베이스를 사용할 수 있을까요?

Repository는 데이터베이스 접근을 추상화한 패턴입니다. 마치 도서관 사서가 책의 정확한 위치를 알고 찾아주듯이, Repository가 데이터의 저장과 조회를 대신 처리해줍니다.

Spring Data JPA를 사용하면 인터페이스만 정의해도 기본적인 CRUD 기능이 자동으로 생성됩니다.

다음 코드를 살펴봅시다.

// 기본 Repository 인터페이스
public interface UserRepository extends JpaRepository<User, Long> {

    // 메서드 이름만으로 쿼리가 자동 생성됨
    Optional<User> findByEmail(String email);

    List<User> findByNameContaining(String keyword);

    boolean existsByEmail(String email);

    // 복잡한 쿼리는 @Query로 직접 작성
    @Query("SELECT u FROM User u WHERE u.age >= :minAge AND u.status = :status")
    List<User> findActiveUsersByAge(@Param("minAge") int minAge,
                                     @Param("status") String status);

    // 네이티브 SQL도 사용 가능
    @Query(value = "SELECT * FROM users WHERE created_at > :date", nativeQuery = true)
    List<User> findRecentUsers(@Param("date") LocalDateTime date);
}

박시니어 씨가 김개발 씨에게 옛날 이야기를 들려줍니다. "예전에는 데이터베이스를 사용하려면 JDBC 코드를 직접 작성해야 했어요." Connection을 열고, PreparedStatement를 만들고, ResultSet을 순회하면서 데이터를 하나씩 꺼내고.

간단한 조회 하나에도 수십 줄의 코드가 필요했습니다. 게다가 Connection을 제대로 닫지 않으면 메모리 누수가 발생하기도 했죠.

더 큰 문제는 중복 코드였습니다. 사용자를 조회하든, 상품을 조회하든, 주문을 조회하든 거의 비슷한 코드를 계속 반복해서 작성해야 했습니다.

바로 이런 문제를 해결하기 위해 Repository 패턴이 등장했습니다. 쉽게 비유하자면, Repository는 마치 도서관의 사서와 같습니다.

여러분이 "파이썬 책 좀 찾아주세요"라고 하면, 사서가 어느 서가의 몇 번째 칸에 있는지 정확히 알고 찾아줍니다. 여러분은 책이 어디에 있는지, 어떻게 정리되어 있는지 몰라도 됩니다.

Repository도 마찬가지입니다. "이메일로 사용자를 찾아주세요"라고 메서드를 호출하면, Repository가 알아서 데이터베이스에서 찾아옵니다.

SQL을 직접 작성할 필요가 없는 것이죠. 위의 코드를 자세히 살펴봅시다.

먼저 UserRepository는 JpaRepository를 상속받습니다. 이것만으로도 기본적인 CRUD 메서드가 자동으로 생성됩니다.

save(), findById(), findAll(), delete() 같은 메서드를 별도로 구현하지 않아도 사용할 수 있습니다. 제네릭 타입에 <User, Long>을 지정했습니다.

User는 엔티티 타입이고, Long은 기본 키의 타입입니다. 이 정보만으로 Spring Data JPA는 모든 것을 알아냅니다.

가장 신기한 것은 메서드 이름 규칙입니다. findByEmail이라는 메서드를 선언하기만 하면, Spring이 자동으로 "SELECT * FROM users WHERE email = ?" 같은 쿼리를 생성해줍니다.

findByNameContaining은 이름에 특정 키워드가 포함된 사용자를 찾습니다. LIKE 검색이 자동으로 만들어지는 것이죠.

existsByEmail은 이메일이 존재하는지 확인합니다. 실제로는 COUNT 쿼리가 실행됩니다.

하지만 모든 쿼리를 메서드 이름으로 표현할 수는 없습니다. 복잡한 조건이 필요하면 @Query 어노테이션을 사용합니다.

findActiveUsersByAge 메서드를 보면 JPQL로 쿼리를 직접 작성했습니다. JPQL은 SQL과 비슷하지만 테이블이 아니라 엔티티를 대상으로 합니다.

FROM User는 users 테이블이 아니라 User 엔티티를 의미합니다. 때로는 데이터베이스 고유의 기능을 사용해야 할 수도 있습니다.

그럴 때는 nativeQuery = true를 설정하고 실제 SQL을 작성하면 됩니다. 실제 현업에서는 어떻게 활용할까요?

대부분의 조회 로직은 Repository 메서드 이름만으로 해결할 수 있습니다. findByStatus, findByCreatedAtBetween, findByPriceGreaterThan 같은 식으로 직관적인 이름을 사용하면 됩니다.

복잡한 통계 쿼리나 집계 함수가 필요한 경우에는 @Query를 사용합니다. 예를 들어 "지난 7일간 가장 많이 팔린 상품 TOP 10"을 구하려면 복잡한 쿼리가 필요하겠죠.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수는 N+1 문제를 만드는 것입니다.

연관된 엔티티를 조회할 때 제대로 처리하지 않으면, 하나의 쿼리가 수십 개의 추가 쿼리를 발생시킬 수 있습니다. 이런 경우 @EntityGraph나 fetch join을 사용해야 합니다.

또 다른 실수는 너무 복잡한 메서드 이름을 만드는 것입니다. findByNameAndEmailAndAgeGreaterThanAndStatusEqualsAndCreatedAtBetween 같은 이름은 읽기도 어렵습니다.

이럴 때는 차라리 @Query를 사용하는 게 낫습니다. 김개발 씨가 Repository를 사용해봤습니다.

SQL 한 줄 작성하지 않았는데 데이터가 조회되었습니다. "정말 신기하네요!" Repository 패턴을 제대로 이해하면 데이터베이스 작업을 훨씬 쉽게 처리할 수 있습니다.

여러분도 오늘 배운 내용을 바탕으로 깔끔한 데이터 접근 계층을 만들어 보세요.

실전 팁

💡 - 간단한 쿼리는 메서드 이름 규칙을 활용하세요

  • 복잡한 쿼리는 @Query로 명확하게 작성하세요
  • N+1 문제를 방지하기 위해 fetch join을 고려하세요

5. DTO와 엔티티 분리

김개발 씨가 API를 완성하고 테스트를 하는데, 갑자기 에러가 발생했습니다. JSON 직렬화 에러였습니다.

박시니어 씨가 코드를 보더니 "엔티티를 직접 반환하면 안 되는데요"라고 말했습니다. 왜 그럴까요?

**DTO(Data Transfer Object)**는 계층 간 데이터 전송을 위한 객체입니다. 엔티티는 데이터베이스 테이블과 매핑되는 객체이고, DTO는 API 요청과 응답에 사용됩니다.

마치 집 안에서 입는 옷(엔티티)과 외출할 때 입는 옷(DTO)이 다르듯이, 내부 데이터 구조와 외부 인터페이스를 분리해야 합니다.

다음 코드를 살펴봅시다.

// 엔티티 클래스 - 데이터베이스 테이블과 매핑
@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;
    private String password;  // 민감한 정보
    private String name;
    private LocalDateTime createdAt;

    @OneToMany(mappedBy = "user")
    private List<Order> orders;  // 연관 관계
}

// DTO 클래스 - API 응답용
public class UserResponse {
    private Long id;
    private String email;
    private String name;
    private LocalDateTime createdAt;
    // password는 제외 - 보안
    // orders는 제외 - 불필요한 정보

    // 엔티티를 DTO로 변환하는 정적 메서드
    public static UserResponse from(User user) {
        UserResponse dto = new UserResponse();
        dto.setId(user.getId());
        dto.setEmail(user.getEmail());
        dto.setName(user.getName());
        dto.setCreatedAt(user.getCreatedAt());
        return dto;
    }
}

// 컨트롤러에서 DTO 사용
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
    User user = userService.findById(id);
    return ResponseEntity.ok(UserResponse.from(user));
}

박시니어 씨가 김개발 씨에게 문제의 코드를 보여줍니다. "여기서 User 엔티티를 그대로 반환했네요.

이게 문제예요." 김개발 씨는 고개를 갸우뚱했습니다. "엔티티에 있는 데이터를 그대로 보여주면 되는 거 아닌가요?" 박시니어 씨가 웃으며 설명하기 시작합니다.

"그렇게 생각할 수 있죠. 하지만 여러 가지 문제가 있어요." 첫 번째 문제는 보안입니다.

User 엔티티에는 password 필드가 있습니다. 만약 엔티티를 그대로 JSON으로 반환하면 비밀번호까지 노출될 수 있습니다.

@JsonIgnore를 붙일 수도 있지만, 그것도 완벽한 해결책은 아닙니다. 두 번째 문제는 순환 참조입니다.

User는 Order 목록을 가지고 있고, Order는 다시 User를 참조할 수 있습니다. 이런 관계를 JSON으로 변환하면 무한 루프에 빠져서 StackOverflowError가 발생합니다.

세 번째 문제는 불필요한 정보 노출입니다. 클라이언트는 사용자의 이름과 이메일만 필요한데, 엔티티에는 수십 개의 필드가 있을 수 있습니다.

모든 정보를 다 보내면 네트워크 낭비이고 보안 위험도 있습니다. 네 번째 문제는 API 변경의 어려움입니다.

데이터베이스 구조가 바뀌면 엔티티가 바뀌고, 그러면 API 응답도 바뀝니다. API를 사용하는 모든 클라이언트에 영향을 미치는 것이죠.

바로 이런 문제를 해결하기 위해 DTO 패턴을 사용합니다. 쉽게 비유하자면, 엔티티와 DTO의 관계는 집 안 옷과 외출 옷과 같습니다.

집에서는 편한 옷을 입지만, 외출할 때는 상황에 맞는 옷으로 갈아입습니다. 회사에 갈 때, 운동하러 갈 때, 결혼식에 갈 때 각각 다른 옷을 입는 것처럼, 상황에 따라 다른 DTO를 사용할 수 있습니다.

위의 코드를 자세히 살펴봅시다. User 엔티티는 데이터베이스 테이블과 완벽하게 매핑됩니다.

모든 필드를 가지고 있고, 연관 관계도 정의되어 있습니다. 이것은 내부 데이터 모델입니다.

UserResponse는 API 응답을 위한 DTO입니다. 필요한 필드만 선택적으로 포함합니다.

password는 보안상 제외했고, orders는 불필요하므로 제외했습니다. 이것은 외부 인터페이스입니다.

from() 정적 메서드는 변환 로직을 담당합니다. 엔티티를 받아서 DTO로 변환해줍니다.

이런 변환 로직을 한 곳에 모아두면 나중에 수정하기도 쉽습니다. 컨트롤러에서는 서비스에서 엔티티를 받아온 후, DTO로 변환해서 반환합니다.

클라이언트는 UserResponse만 보게 되는 것이죠. 실제 현업에서는 어떻게 활용할까요?

보통 하나의 엔티티에 대해 여러 개의 DTO를 만듭니다. UserCreateRequest는 사용자 생성 요청용, UserUpdateRequest는 수정 요청용, UserDetailResponse는 상세 조회 응답용, UserListResponse는 목록 조회 응답용 이런 식으로 구분합니다.

예를 들어 관리자가 보는 사용자 정보와 일반 사용자가 보는 정보가 다를 수 있습니다. 관리자용 DTO에는 더 많은 필드가 포함되고, 일반 사용자용 DTO에는 공개 정보만 포함됩니다.

또한 ModelMapper나 MapStruct 같은 라이브러리를 사용하면 변환 코드를 자동화할 수 있습니다. 반복적인 필드 매핑 코드를 줄일 수 있죠.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수는 DTO가 너무 많아지는 것입니다.

조금만 달라도 새로운 DTO를 만들다 보면 관리가 어려워집니다. 공통 부분은 상속이나 조합으로 재사용하세요.

또 다른 실수는 DTO에 비즈니스 로직을 넣는 것입니다. DTO는 단순한 데이터 컨테이너여야 합니다.

복잡한 로직은 서비스 계층에 두세요. 김개발 씨가 코드를 수정했습니다.

엔티티와 DTO를 분리하니 에러가 사라졌습니다. "이제 이해했어요.

내부 구조와 외부 인터페이스를 분리하는 거네요!" DTO 패턴을 제대로 이해하면 안전하고 유연한 API를 만들 수 있습니다. 여러분도 오늘 배운 내용을 바탕으로 깔끔한 데이터 전송 계층을 만들어 보세요.

실전 팁

💡 - 엔티티는 절대 API 응답으로 직접 노출하지 마세요

  • 용도별로 DTO를 구분하여 사용하세요 (요청용, 응답용)
  • 변환 로직은 한 곳에 모아서 관리하세요

6. HTTP 메서드와 상태 코드

김개발 씨가 만든 API를 프론트엔드 개발자가 테스트하다가 질문했습니다. "사용자를 삭제했는데 왜 200 OK가 나오나요?

204가 나와야 하는 거 아닌가요?" 김개발 씨는 상태 코드에 대해 다시 공부해야겠다고 생각했습니다.

HTTP 메서드는 자원에 대한 행위를 나타내고, 상태 코드는 요청의 처리 결과를 나타냅니다. GET은 조회, POST는 생성, PUT은 전체 수정, PATCH는 부분 수정, DELETE는 삭제를 의미합니다.

각 상황에 맞는 적절한 상태 코드를 반환하면 클라이언트가 결과를 명확하게 이해할 수 있습니다.

다음 코드를 살펴봅시다.

@RestController
@RequestMapping("/api/users")
public class UserController {

    // GET - 조회, 200 OK
    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok(UserResponse.from(user));
    }

    // POST - 생성, 201 Created
    @PostMapping
    public ResponseEntity<UserResponse> createUser(@RequestBody UserCreateRequest request) {
        User user = userService.create(request);
        URI location = URI.create("/api/users/" + user.getId());
        return ResponseEntity.created(location).body(UserResponse.from(user));
    }

    // PUT - 전체 수정, 200 OK
    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> updateUser(@PathVariable Long id,
                                                     @RequestBody UserUpdateRequest request) {
        User user = userService.update(id, request);
        return ResponseEntity.ok(UserResponse.from(user));
    }

    // PATCH - 부분 수정, 200 OK
    @PatchMapping("/{id}/email")
    public ResponseEntity<UserResponse> updateEmail(@PathVariable Long id,
                                                      @RequestBody String email) {
        User user = userService.updateEmail(id, email);
        return ResponseEntity.ok(UserResponse.from(user));
    }

    // DELETE - 삭제, 204 No Content
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.delete(id);
        return ResponseEntity.noContent().build();
    }

    // 에러 처리 - 404 Not Found
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(UserNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse(e.getMessage()));
    }
}

박시니어 씨가 김개발 씨에게 HTTP의 역사를 설명하기 시작했습니다. "HTTP는 단순한 프로토콜이지만, 매우 잘 설계되어 있어요." HTTP는 웹의 기초입니다.

팀 버너스리가 1989년에 만들었죠. 처음에는 HTML 문서를 주고받는 용도였지만, 지금은 모든 종류의 데이터를 전송하는 표준이 되었습니다.

HTTP의 핵심은 메서드상태 코드입니다. 이 둘을 제대로 이해하면 RESTful API를 올바르게 설계할 수 있습니다.

먼저 HTTP 메서드를 살펴봅시다. GET은 자원을 조회할 때 사용합니다.

쉽게 비유하자면, 도서관에서 책을 읽는 것과 같습니다. 책을 보기만 하고 가져가지는 않습니다.

GET 요청은 서버의 상태를 변경하지 않아야 합니다. 이것을 **안전(Safe)**하다고 표현합니다.

POST는 새로운 자원을 생성할 때 사용합니다. 도서관에 새 책을 등록하는 것과 같죠.

같은 요청을 여러 번 보내면 여러 개의 자원이 생성됩니다. 회원 가입을 두 번 하면 계정이 두 개 만들어지는 것처럼요.

PUT은 자원을 완전히 교체할 때 사용합니다. 책의 내용을 완전히 새로 쓰는 것과 같습니다.

모든 필드를 함께 보내야 합니다. 일부만 보내면 나머지는 null이나 기본값으로 바뀔 수 있습니다.

PATCH는 자원의 일부만 수정할 때 사용합니다. 책의 특정 페이지만 수정하는 것과 같죠.

이메일만 바꾸고 싶다면 PATCH로 이메일 필드만 보내면 됩니다. DELETE는 자원을 삭제할 때 사용합니다.

도서관에서 책을 폐기하는 것과 같습니다. 한 번 삭제하든 여러 번 삭제하든 결과는 같습니다.

이미 없는 것을 또 삭제해도 여전히 없으니까요. 다음으로 상태 코드를 살펴봅시다.

상태 코드는 크게 다섯 가지 범주로 나뉩니다. 2xx는 성공을 의미합니다.

200 OK는 가장 일반적인 성공 응답입니다. 201 Created는 새로운 자원이 생성되었을 때 사용합니다.

이때 Location 헤더에 새로 생성된 자원의 URL을 포함하면 좋습니다. 204 No Content는 성공했지만 응답 본문이 없을 때 사용합니다.

삭제 요청이 대표적인 예입니다. 4xx는 클라이언트 오류를 의미합니다.

400 Bad Request는 요청이 잘못되었을 때 사용합니다. 필수 필드가 누락되었거나 형식이 틀렸을 때죠.

401 Unauthorized는 인증이 필요할 때, 403 Forbidden은 권한이 없을 때, 404 Not Found는 자원을 찾을 수 없을 때 사용합니다. 5xx는 서버 오류를 의미합니다.

500 Internal Server Error는 예상치 못한 서버 에러가 발생했을 때 사용합니다. 503 Service Unavailable은 서버가 일시적으로 사용 불가능할 때 사용합니다.

위의 코드를 자세히 살펴봅시다. getUser 메서드는 단순히 200 OK를 반환합니다.

ResponseEntity.ok()는 상태 코드 200과 함께 본문을 반환하는 편리한 메서드입니다. createUser 메서드는 201 Created를 반환합니다.

또한 created() 메서드로 Location 헤더를 설정했습니다. 클라이언트는 이 헤더를 보고 새로 생성된 자원의 위치를 알 수 있습니다.

updateUser는 PUT 메서드를 사용합니다. 전체 사용자 정보를 받아서 업데이트합니다.

updateEmail은 PATCH 메서드를 사용합니다. 이메일 필드만 받아서 부분 업데이트합니다.

deleteUser는 204 No Content를 반환합니다. noContent().build()는 본문 없이 상태 코드만 반환합니다.

삭제가 성공했다는 의미를 명확하게 전달합니다. handleNotFound는 예외를 처리해서 적절한 상태 코드를 반환합니다.

사용자를 찾지 못하면 404를 반환하는 것이죠. 실제 현업에서는 어떻게 활용할까요?

API 설계 시 각 엔드포인트의 목적을 명확히 하고, 그에 맞는 메서드와 상태 코드를 사용합니다. 예를 들어 상품 검색은 GET /api/products?keyword=노트북, 장바구니 담기는 POST /api/cart/items 이런 식으로 직관적으로 설계합니다.

에러 응답도 일관성 있게 만듭니다. ErrorResponse 같은 공통 DTO를 만들어서 모든 에러가 같은 형식으로 반환되도록 합니다.

하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수는 모든 응답에 200 OK를 사용하는 것입니다.

에러가 발생해도 200을 반환하고 본문에 success: false를 넣는 방식은 HTTP를 제대로 활용하지 못하는 것입니다. 또 다른 실수는 PUT과 PATCH를 혼동하는 것입니다.

전체 교체는 PUT, 부분 수정은 PATCH를 사용해야 합니다. 의미가 명확해집니다.

김개발 씨가 모든 엔드포인트의 상태 코드를 수정했습니다. 프론트엔드 개발자가 테스트해보더니 만족스러워했습니다.

"이제 훨씬 명확하네요!" HTTP 메서드와 상태 코드를 제대로 이해하면 표준에 맞는 RESTful API를 만들 수 있습니다. 여러분도 오늘 배운 내용을 바탕으로 직관적인 API를 설계해 보세요.

실전 팁

💡 - 각 메서드의 의미를 정확히 이해하고 올바르게 사용하세요

  • 상태 코드로 결과를 명확하게 전달하세요
  • 에러 응답도 일관된 형식으로 만드세요

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#Spring#RestController#Service#Repository#DTO

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.

함께 보면 좋은 카드 뉴스

마이크로서비스 배포 완벽 가이드

Kubernetes를 활용한 마이크로서비스 배포의 핵심 개념부터 실전 운영까지, 초급 개발자도 쉽게 따라할 수 있는 완벽 가이드입니다. 실무에서 바로 적용 가능한 배포 전략과 노하우를 담았습니다.

쿠버네티스 리소스 완벽 가이드

쿠버네티스의 핵심 리소스들을 실무 중심으로 배워봅니다. Deployment부터 Namespace까지, 초급 개발자도 쉽게 이해할 수 있도록 친절하게 설명합니다. 실제 프로젝트에서 바로 활용할 수 있는 실전 예제와 함께 합니다.

관찰 가능한 마이크로서비스 완벽 가이드

마이크로서비스 환경에서 시스템의 상태를 실시간으로 관찰하고 모니터링하는 방법을 배웁니다. Resilience4j, Zipkin, Prometheus, Grafana, EFK 스택을 활용하여 안정적이고 관찰 가능한 시스템을 구축하는 실전 가이드입니다.

Prometheus 메트릭 수집 완벽 가이드

Spring Boot 애플리케이션의 메트릭을 Prometheus로 수집하고 모니터링하는 방법을 배웁니다. Actuator 설정부터 PromQL 쿼리까지 실무에 필요한 모든 내용을 다룹니다.

스프링 관찰 가능성 완벽 가이드

Spring Boot 3.x의 Observation API를 활용한 애플리케이션 모니터링과 추적 방법을 초급 개발자 눈높이에서 쉽게 설명합니다. 실무에서 바로 적용할 수 있는 메트릭 수집과 분산 추적 기법을 다룹니다.