[TIL] Spring Boot | MVC & CRUD

GGangMY·1일 전

Spring Boot 환경에서 웹 서버의 기초 구조를 배우고, 메모 데이터를 처리하는 간단한 CRUD(Create, Read, Update, Delete) API의 전체적인 흐름을 구현해 보았습니다. 각 코드가 왜 이렇게 작성되어야 하는지 강의를 통해 이해한 원리와, 개발 과정에서 겪은 시행착오를 솔직하게 정리합니다.

1. 오늘의 학습 키워드

  • Spring Boot | 웹 애플리케이션 서버 구동을 위한 초기 설정 방식 이해
  • Spring MVC | 컨트롤러와 주소 매핑의 기본 개념
  • DTO Pattern | 클라이언트와 서버가 데이터를 안전하게 주고받기 위한 객체 분리 방식
  • Entity (Data Encapsulation) | 데이터 보호를 위해 객체 스스로 값을 통제하는 원본 객체
  • CRUD Controller | 데이터 처리 목적에 따른 POST, GET, PUT, DELETE 규칙 적용

2. 핵심 소스코드 분석 및 역할 정리

📌 Spring Boot | @SpringBootApplication

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringPrepareApplication {
    public static void main(String[] args) {
        SpringApplication.run(SpringPrepareApplication.class, args);
    }
}

자바 메인 메서드를 실행하는 것만으로 @SpringBootApplication 어노테이션이 하위 폴더 전체를 자동으로 훑어 컴포넌트 스캔을 수행합니다. 이 과정에서 웹 컨트롤러 등 필요한 부품들을 자동으로 찾아 메모리에 등록하고, 과거 스프링처럼 복잡한 XML 설정 파일 없이 내장 웹 서버(Tomcat)까지 스스로 구동하여 외부 통신 인프라를 한 번에 구축해 주므로 매우 편리했습니다.


📌 Spring MVC | Controller & Mapping

@RestController // HTML 파일이 아닌 JSON 텍스트를 반환하는 @ResponseBody 전용 API 엔드포인트
@RequestMapping("/api") // 컨트롤러 하위의 모든 엔드포인트 주소 앞에 공통으로 붙는 접두사 경로 지정
public class MemoController {

    // 특정 단일 자원을 식별할 때 유용한 경로 변수 방식
    // EX) http://localhost:8080/api/memos/20261001
    @GetMapping("/memos/{id}")
    public Long getResourceById(@PathVariable Long id) {
        return id;
    }

    // 조건에 따라 자원을 필터링 할 때 유용한 쿼리 파라미터 방식
	// EX) http://localhost:8080/api/memos?keyword=MVC
    @GetMapping("/memos")
    public String getResourcesByQuery(@RequestParam(required = false) String keyword) {
        if (keyword == null) {
            return "keyword is null.";
        }
        return keyword;
    }
}

@Controller는 외부 웹 브라우저의 HTTP 요청을 가장 전면에서 받아 알맞은 자바 코드로 연결하는 안내 창구와 같은 역할을 합니다. 경로 변수인 @PathVariable 단일 자원의 식별에, 쿼리 피라미터인 @RequestParam은 자원을 필터링 할 때 유용합니다. 클래스 상단에 @RequestMapping을 선언해 주소 코드의 중복을 줄일 수 있었습니다.

다만 실제 네이버나 유튜브 주소창을 관찰해보면 고유 ID를 쿼리 파라미터로 넘기는 경우도 있고, 자원 간의 계층 관계를 표현할 때는 경로 변수를 여러 번 쓰기도 한다는 예외적인 구조도 있으므로, "식별은 경로 변수, 검색은 쿼리"라는 편협한 사고에 갇히지 않도록 주의해야겠습니다.


📌 DTO Pattern

// 클라이언트가 보낸 JSON 데이터를 수신하는 객체
@Getter
public class MemoRequestDto {
    private String username;
    private String contents;
}

// 클라이언트에게 반환할 데이터만 담는 응답 전용 객체
@Getter
public class MemoResponseDto {
    private Long id;
    private String username;
    private String contents;

    // 내부 핵심 Entity가 외부 네트워크망에 노출되는 것을 막기 위한 변환 생성자
    public MemoResponseDto(Memo memo) {
        this.id = memo.getId();
        this.username = memo.getUsername();
        this.contents = memo.getContents();
    }
}

DTO(Data Transfer Object)는 클라이언트 화면과 서버 내부 데이터베이스 객체(Entity) 사이에서 독립적인 데이터 교환 창구 역할을 합니다. 화면 요구사항 때문에 필드 이름이 바뀌더라도 내부 핵심 비즈니스 로직이나 데이터베이스 테이블 구조를 직접 건드릴 필요가 없어집니다.

덕분에 데이터를 주고받을 때 외부 변화가 서버 내부 코드에 영향을 주지 않아, 각 계층을 독립적이고 안전하게 유지보수할 수 있습니다.


📌 Entity (Data Encapsulation)

@Getter
@Setter
@NoArgsConstructor // 기본 생성자 자동 생성
public class Memo {
    private Long id;
    private String username;
    private String contents;

    // 수신한 RequestDto 데이터를 기반으로 객체 초기화
    public Memo(MemoRequestDto requestDto) {
        this.username = requestDto.getUsername();
        this.contents = requestDto.getContents();
    }

    // 객체 내부에서 필드를 변경하도록 통제
    public void update(MemoRequestDto requestDto) {
        this.username = requestDto.getUsername();
        this.contents = requestDto.getContents();
    }
}

객체지향 프로그래밍의 핵심 원칙 중 하나인 데이터 캡슐화(Data Encapsulation)는 단순히 데이터를 저장하는 역할에 그치지 않고, 객체 스스로가 생성자 및 update() 메서드 인터페이스를 내장하여 자기 데이터의 변경 권한을 안전하게 통제하는 방식입니다.


📌 CRUD Controller

@RestController
@RequestMapping("/api")
public class MemoController {

    // 임시 인메모리 저장소
    private final Map<Long, Memo> memoList = new HashMap<>();

    // Create: 메모 등록 API
    @PostMapping("/memos")
    public MemoResponseDto createMemo(@RequestBody MemoRequestDto requestDto) {
        // RequestDto -> Entity
        Memo memo = new Memo(requestDto);

        // ID 자동 생성
        Long maxId = memoList.size() > 0 ? Collections.max(memoList.keySet()) + 1 : 1;
        memo.setId(maxId);

        // 메모리 저장
        memoList.put(memo.getId(), memo);

        // Entity -> ResponseDto
        MemoResponseDto memoResponseDto = new MemoResponseDto(memo);
        return memoResponseDto;
    }

    // Read: 메모 목록 조회 API
    @GetMapping("/memos")
    public List<MemoResponseDto> getMemos() {
        // Map 데이터를 List로 변환하여 반환
        List<MemoResponseDto> responseList = memoList.values().stream()
                .map(MemoResponseDto::new).toList();
        return responseList;
    }

    // Update: 기존 메모 수정 API
    @PutMapping("/memos/{id}")
    public Long updateMemo(@PathVariable Long id, @RequestBody MemoRequestDto requestDto) {
        // 메모리 존재 여부 확인
        if(memoList.containsKey(id)) {
            Memo memo = memoList.get(id);
            memo.update(requestDto); // 엔티티 수정 메서드 호출
            return memo.getId();
        } else {
            throw new IllegalArgumentException("선택한 메모는 존재하지 않습니다.");
        }
    }

    // Delete: 메모 삭제 API
    @DeleteMapping("/memos/{id}")
    public Long deleteMemo(@PathVariable Long id) {
        // 메모리 존재 여부 확인
        if(memoList.containsKey(id)) {
            memoList.remove(id); // 메모리 데이터 삭제
            return id;
        } else {
            throw new IllegalArgumentException("선택한 메모는 존재하지 않습니다.");
        }
    }
}

데이터 처리 목적에 맞춰 HTTP 표준 메서드(POST, GET, PUT, DELETE)를 구현했습니다. 특히 수정이나 삭제 처리를 할 때 코드를 실행하기 앞서, containsKey()를 통해 해당 자원이 실재하는지 검증한 후 예외(IllegalArgumentException)를 발생시켜 방어적인 설계 흐름을 배웠습니다.

테스트 환경 내에서 HTTP 요청을 주고받으며 데이터가 수신, 변환, 저장, 응답되는 전체 CRUD API의 생생한 흐름을 눈으로 직접 검증해 보며 웹 백엔드 서버의 구체적인 연동 프로세스를 확인할 수 있었습니다.


3. 개발 과정에서의 시행착오와 해결 방법

❌ Issue 1. POST 요청 시 데이터가 모두 null로 저장되는 현상

  • 문제 상황: 포스트맨으로 @PostMapping 엔드포인트에 JSON 데이터를 정상적으로 실어 보냈으나, 서버 저장 결과 확인 시 값이 바인딩되지 않고 전부 null로 적재됨.
  • 원인 및 해결: 외부에서 던지는 JSON 문자열을 자바 객체의 필드로 파싱하려면 DTO 계층에 데이터를 읽어오는 @Getter가 필수로 작동해야 함을 확인했습니다. 또한 컨트롤러 메서드 매개변수 앞에 HTTP 바디 데이터를 객체로 매핑해 주는 @RequestBody가 누락되어 발생한 현상이었으며, 두 코드를 추가하여 데이터가 정상적으로 수신되도록 해결했습니다.

❌ Issue 2. 수정/삭제 요청 시 타깃 자원을 찾지 못하는 400/404 에러

  • 문제 상황: 특정 메모를 고치기 위해 /api/memos/1 주소로 요청을 보냈으나 주소를 찾지 못하는 404 에러나 매개변수가 맞지 않는 400 에러가 발생함.
  • 원인 및 해결: URL 경로에 박아 넣은 {id} 플래그 주소와 메서드 파라미터의 명칭을 매치해 주기 위해 변수 앞에 @PathVariable 지시어가 누락되어 발생한 라우팅 오류였습니다. 엔드포인트 경로 변수 명칭과 자바 매개변수를 1:1로 매핑해 주어 매커니즘을 통일시켜 해결했습니다.

❌ Issue 3. Entity 객체 생성 시 기본 생성자가 없어 발생하는 컴파일/구동 에러

  • 문제 상황: Memo 엔티티 내부에 DTO를 받아 초기화하는 커스텀 생성자를 수동으로 선언하자마자, 잘 구동되던 프레임워크 초기화나 JSON 데이터 변환 과정에서 인스턴스 생성 실패 에러가 발생함.
  • 원인 및 해결: 자바에서 매개변수가 있는 생성자를 수동으로 만들면 컴파일러가 기본 생성자를 자동으로 추가하지 않는다는 점을 놓친 것이 원인이었습니다. 스프링 부트 라이브러리들이 객체를 정상 생성할 수 있도록 @NoArgsConstructor를 엔티티 클래스 상단에 추가하여 구동 오류를 해결했습니다.

4. 앞으로의 학습 계획

현재 구현한 메모장 CRUD 코드는 자바의 HashMap 자료구조를 활용해 데이터를 컴퓨터의 임시 메모리에만 보관하고 있습니다. 이로 인해 서버를 껐다 켜면 작성했던 모든 메모 데이터가 증발하는 휘발성 한계가 존재합니다.

내일은 오늘 연동 테스트를 마친 MySQL 데이터베이스의 SQL 데이터 정의어(CREATE TABLE)와 조작어(INSERT) 문법을 학습할 예정입니다. 이를 통해 오늘 구현해 둔 가이드 프로젝트의 임시 메모리 저장 방식을 영구 저장 방식으로 전환하고, 데이터의 유실 문제를 안전하게 해결하는 데이터베이스 마이그레이션을 직접 수행해 보는 것을 다음 목표로 삼겠습니다.

0개의 댓글