[SpringBoot] 3주차 ERD 설계, Docker 연결 및 생성 조회 API 구현
이번 주차에는 백엔드 기초인 ERD 설계와 Docker 구성 및 DB 연결, 그리고 게시글 생성/조회 API를 구현했다.
이번 주차 공부 내용
|
ERD란
: ERD(Entity-Relationship Diagram)란 데이터베이스의 구조를 한눈에 볼 수 있도록 그린 데이터 설계도이다. 서비스에서 다루는 정보(Entity)들이 무엇인지, 그리고 그 정보들이 서로 어떻게 연결(Relationship)되어 있는지를 논리적으로 표현한다.
📌 ERD의 핵심 구성 요소
- 엔티티 (Entity): 관리하고자 하는 정보의 단위. (집합)
예시: 게시글(Post), 회원(User) - 속성 (Attribute): 엔티티가 가지는 세부 정보. (원소)
예시: 제목(title), 내용(content), 작성일 - 관계 (Relationship): 엔티티 간의 논리적 연결. (함수/대응)
예시: 한 명의 회원은 여러 개의 게시글을 작성할 수 있다.
💻 ERD 코드 설계
@Entity // 1. 이 클래스는 DB의 테이블(Entity)라고 선언
public class Post {
@Id // 2. ERD의 PK(Primary Key) 설정
@GeneratedValue(strategy = GenerationType.IDENTITY) // 3. Auto Increment(1, 2, 3...) 설정
private Long id;
@Column(nullable = false) // 4. 이 칸은 비어있으면 안 된다(NOT NULL)는 제약 조건
private String title;
@Column(columnDefinition = "TEXT", nullable = false) // 5. 데이터 타입을 'TEXT'로 지정
private String content;
}
PK(Primary Key)?테이블에서 각 레코드(행)를 유일하게 식별할 수 있는 하나의 키특징 :
|
인프라 구축 및 DB 연결
🐳 Docker란?
: 애플리케이션과 그게 실행되는 데 필요한 모든 것(라이브러리, 설정, DB 등)을 하나의 박스에 담아주는 기술
📌 핵심 역할
- 환경의 일관성
도커에서 표준 규격 위에서 실행되기 때문에, 어디서든 동일한 결과를 보장한다. - 격리와 보안(깔끔한 로컬 환경 유지)
도커는 컨테이너 안에 프로그램을 가두기 때문에, 내 실제 OS는 깨끗하게 유지된다. - 경량성(실행 고속화)
도커는 컴퓨터의 자원을 효율적으로 나눠 쓰면서 필요한 프로그램만 띄우기에 아주 가볍고 빠르다.
🧬 Docker의 사용 : docker-compose.yml → application.yml 연결
# docker-compose.yml
services:
db:
image: mysql:8.0 # MySQL 8.0 버전을 가져온다
container_name: mysql-db # 컨테이너의 별명
ports:
- "3306:3306" # 내 컴퓨터의 3306 포트와 도커의 3306 포트를 연결 (통로 개설)
environment:
MYSQL_DATABASE: springboot_study # 자동으로 생성할 데이터베이스 이름
MYSQL_ROOT_PASSWORD: 1234 # 접속 비밀번호
- 이미지(Image): 붕어빵 틀. MySQL 실행에 필요한 모든 게 들어있다.
- 컨테이너(Container): 틀에서 찍어낸 붕어빵. 실제로 컴퓨터 메모리 위에서 돌아가는 DB 서버이다.
# application.yml
spring:
datasource:
# 1. 주소: localhost의 3306 포트(도커가 열어둔 통로)로 접속
url: jdbc:mysql://localhost:3306/springboot_study?useSSL=false&allowPublicKeyRetrieval=true
# 2. 계정 정보: 도커 설정에서 정한 비밀번호와 일치 확인
username: root
password: 1234
# 3. 드라이버: MySQL과 대화할 수 있는 언어(Driver)를 사용
driver-class-name: com.mysql.cj.jdbc.Driver
docker-compose를 통해 인프라를 코드(IaC)로 관리하고, 이를 스프링 부트의 application.yml 설정과 매핑하여 환경에 구애받지 않는 안정적인 DB 연결을 구현했다.
기능 구현 Flow(코드 로직)
저번 주차 때 DTO 작성과 Validation 적용을 함으로써 보안과 데이터 정합성을 유치했다.
생성 API는 클라이언트의 데이터를 '검증'하고 '영속화'하는 쓰기(Write) 과정이고, 조회 API는 경로 변수를 통해 데이터를 '식별'하고 '예외 처리'를 수행하는 읽기(Read) 과정이다. 이 과정에서 DTO를 활용해 데이터 노출을 최소화하고, 전역 예외 처리를 통해 시스템의 안정성을 확보했다.
게시글 생성 API (POST)
1. 검증과 데이터 수신
// PostController.java
@PostMapping("/api/posts")
public ApiResponse<PostResponseDto> createPost(@RequestBody @Valid PostRequestDto requestDto) {
// 1. @RequestBody: 클라이언트가 보낸 JSON을 DTO 객체로 변환
// 2. @Valid: DTO에 설정한 @NotBlank 같은 제약 조건을 검증
PostResponseDto responseDto = postService.createPost(requestDto);
return ApiResponse.success(responseDto); // 3. ApiResponse로 감싸서 성공 응답 반환
}
2. 비즈니스 로직과 영속화
// PostService.java
@Transactional // 데이터를 수정, 저장할 때는 이 어노테이션이 꼭 필요하다.
public Long createPost(PostRequestDto request) {
// 1. DTO를 Entity(Post)로 변환한다.
// 외부에서 들어온 DTO를 규격(Entity)에 맞게 재포장한다.
// 빌더 패턴(Builder)을 써서 어떤 필드에 어떤 값이 들어가는지 명확하게 했다.
Post post = Post.builder()
.title(request.getTitle())
.content(request.getContent())
.build();
// 2. DB에 저장하고, 저장된 게시글의 번호(ID)를 반환한다.
// postRepository.save(post)가 실행되는 순간 MySQL에 실제 데이터가 쌓인다.
// 마지막에 .getId()를 붙여 방금 생성된 행의 고유 번호만 리턴하도록 설계했다.
return postRepository.save(post).getId();
}
게시글 조회 API Flow
1. 외부의 HTTP 요청을 자바의 영역으로 가져온다.
// PostController.java
@GetMapping("/api/posts/{id}")
public ApiResponse<PostResponseDto> getPost(@PathVariable Long id) {
// 1. @PathVariable: 주소창의 {id}(예: 5)를 Long id 변수에 대입
// 2. PostService의 getPostDetail 메서드를 호출하여 로직을 위임
PostResponseDto responseDto = postService.getPostDetail(id);
// 3. ApiResponse.success: 조회된 데이터를 표준 규격으로 감싸서 내보냄
return ApiResponse.success(responseDto);
}
2. 데이터를 찾고 없으면 예외를 던지는 판단을 한다.
// PostService.java
public PostResponseDto getPostDetail(Long postId) {
// 1. postRepository.findById(postId): DB 창고에서 해당 ID를 가진 상자를 찾는다.
// 2. .orElseThrow: 만약 상자가 없다면 프로그램이 터지지 않고 에러를 던진다.
Post post = postRepository.findById(postId)
.orElseThrow(() -> new GeneralException("POST404", "해당 게시글이 존재하지 않습니다."));
// 3. PostResponseDto.from(post): 찾은 Entity를 DTO(포장된 상품)로 변환한다.
return PostResponseDto.from(post);
}
3. 실제 MySQL DB와 연결하여 데이터를 가져온다.
// PostRepository.java
public interface PostRepository extends JpaRepository<Post, Long> {
// JpaRepository를 상속받는 것만으로 findById 같은 조회 기능이 자동 생성된다.
// 여기서 영속화된 데이터를 실제 객체(Post)로 복원해 서비스로 전달한다.
}
4. 서비스에서 던진 에러를 낚아채 JSON으로 변환한다.
// GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(GeneralException.class)
public ApiResponse<?> handleGeneralException(GeneralException e) {
// 서비스에서 던진 "POST404" 에러를 여기서 잡아서 ApiResponse 실패 포맷으로 반환한다.
return ApiResponse.fail(e.getCode(), e.getMessage());
}
}
실행 결과
테스트 파일 생성
### 1. 게시글 생성 (POST)
POST http://localhost:8080/api/posts
Content-Type: application/json
{
"title": "스프링부트 첫 DB",
"content": "10글자 채우기ㅣㅣㅣㅣㅣㅣ"
}
### 2. 게시글 조회 (GET)
GET http://localhost:8080/api/posts/1
게시글 생성 API 실행 결과

게시글 조회 API 실행 결과

❓궁금했던 점
1. 꼭 포트 번호를 3306으로 적어야 할까?
: 가능은 하지만 약속대로 가는게..
- 기본값(Default): MySQL의 기본 통신 포트가 3306으로 약속되어 있다.
- 포트 포워딩 (ports: - "3306:3306"):
- 앞의 3306: 내 컴퓨터(Host)에서 부를 번호
- 뒤의 3306: 도커 컨테이너 안에서 MySQL이 기다리는 번호
- 만약 바꾼다면?: 만약 내 컴퓨터에 이미 다른 MySQL이 깔려 있다면 "3307:3306"으로 설정하고, application.yml의 주소도 localhost:3307로 바꿔야 한다.
2. 영속화(Persistence)란?
수학에서 계산한 결과값을 휘발성 있는 연습장이 아니라, 지워지지 않는 칠판(DB)에 박제하는 것과 같다.
- 개념: 프로그램이 종료되어도 데이터가 사라지지 않고 유지되는 상태
- 코드에서의 영속화: postRepository.save(post)를 호출하는 순간, 메모리(RAM)에만 떠다니던 자바 객체가 MySQL(Disk)이라는 물리적 저장소에 기록되며 영속화된다.
3. 왜 Entity를 직접 노출하지 않고 DTO를 쓰는지?
수학으로 치면 Entity는 모든 중간 과정이 담긴 연습장이고, DTO는 채점자에게 제출하는 답안지다.
- 보안 (Security): Entity에는 비밀번호, 생성일자, 수정일자 등 외부로 나가면 안 되는 민감한 정보가 포함될 수 있다. DTO를 쓰면 딱 필요한 정보만 골라서 보낼 수 있다.
- 독립성 (Decoupling): DB 구조(Entity)가 바뀐다고 해서 클라이언트에게 주는 API 스펙이 매번 바뀌면 안 되므로 DTO가 중간에서 완충 작용을 해준다.
- 순환 참조 방지: 나중에 댓글(Comment) 등이 추가되어 Entity끼리 서로 참조하게 되면, JSON으로 변환할 때 무한 루프에 빠질 수 있는데 DTO는 이를 원천 차단한다.
현재 아키텍처&코드
src/main/java/com/study/springbootstudy/
│
├── common/ (공통 계층)
│ ├── exception/
│ │ ├── GeneralException.java <-- 커스텀 예외 정의
│ │ └── GlobalExceptionHandler.java <-- 전역 예외 처리 로직 (컨트롤러 어드바이스)
│ └── ApiResponse.java <-- 표준 API 응답 규격 (성공/실패 공통 포맷)
│
├── controller/ (표현 계층)
│ ├── PingController.java <-- 서버 상태 체크용
│ └── PostController.java <-- 게시글 관련 API 요청 수신
│
├── domain/ (도메인/데이터 모델)
│ └── Post.java <-- 핵심 비즈니스 객체 및 DB 엔티티
│
├── dto/ (데이터 전송 객체)
│ ├── PostRequestDto.java <-- 클라이언트로부터 받는 데이터 (생성용)
│ └── PostResponseDto.java <-- 클라이언트에게 주는 데이터 (조회용)
│
├── repository/ (데이터 접근 계층)
│ └── PostRepository.java <-- DB와의 통신을 담당하는 인터페이스
│
├── service/ (비즈니스 로직 계층)
│ └── PostService.java <-- 핵심 서비스 로직 구현체
│
└── SpringbootStudyApplication.java <-- 프로젝트의 시작점 (메인 클래스)
// PostController.java
package com.study.springbootstudy.controller;
import com.study.springbootstudy.common.ApiResponse;
import com.study.springbootstudy.dto.PostRequestDto;
import com.study.springbootstudy.dto.PostResponseDto;
import com.study.springbootstudy.service.PostService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequiredArgsConstructor // Service를 주입받기 위해 꼭 필요
@RequestMapping("/api/posts") // 이 컨트롤러의 기본 주소를 설정
public class PostController {
private final PostService postService;
// 1. 게시글 생성 API
@PostMapping
public ApiResponse<Long> createPost(@Valid @RequestBody PostRequestDto request) {
Long savedPostId = postService.createPost(request);
return ApiResponse.onSuccess(savedPostId); // 성공 시 저장된 게시글의 ID를 반환
}
// 2. 게시글 상세 조회 API
@GetMapping("/{postId}")
public ApiResponse<PostResponseDto> getPost(@PathVariable Long postId) {
PostResponseDto response = postService.getPostDetail(postId);
return ApiResponse.onSuccess(response); // 성공 시 게시글 상세 정보를 반환
}
}
// Post.java
package com.study.springbootstudy.domain;
import jakarta.persistence.*;
import lombok.*;
@Entity // 이 클래스가 DB의 테이블 역할을 한다는 선언
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Post {
@Id // 기본키(PK)로 지정
@GeneratedValue(strategy = GenerationType.IDENTITY) // ID를 1, 2, 3... 자동으로 늘려줌
private Long id;
@Column(nullable = false) // 빈 값을 허용하지 않음
private String title;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
}
// PostResponsedto.java
package com.study.springbootstudy.dto;
import com.study.springbootstudy.domain.Post;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class PostResponseDto {
private Long id;
private String title;
private String content;
// DB에서 꺼낸 Post(Entity)를 DTO로 변환해 주는 메서드
public static PostResponseDto from(Post post) {
return PostResponseDto.builder()
.id(post.getId())
.title(post.getTitle())
.content(post.getContent())
.build();
}
}
// PostRepository.java
package com.study.springbootstudy.repository;
import com.study.springbootstudy.domain.Post;
import org.springframework.data.jpa.repository.JpaRepository;
// JpaRepository를 상속받으면 기본적인 CRUD(생성, 조회, 수정, 삭제) 기능이 생긴다
public interface PostRepository extends JpaRepository<Post, Long> {
}
// PostService.java
package com.study.springbootstudy.service;
import com.study.springbootstudy.common.exception.GeneralException;
import com.study.springbootstudy.domain.Post;
import com.study.springbootstudy.dto.PostRequestDto;
import com.study.springbootstudy.dto.PostResponseDto;
import com.study.springbootstudy.repository.PostRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // 기본적으로 읽기 전용으로 설정하여 성능을 높인다.
public class PostService {
private final PostRepository postRepository;
@Transactional // 데이터를 수정, 저장할 때는 이 어노테이션이 꼭 필요하다.
public Long createPost(PostRequestDto request) {
// 1. DTO를 Entity(Post)로 변환
Post post = Post.builder()
.title(request.getTitle())
.content(request.getContent())
.build();
// 2. DB에 저장하고, 저장된 게시글의 번호(ID)를 반환
return postRepository.save(post).getId();
}
public PostResponseDto getPostDetail(Long postId) {
// 1. DB에서 ID로 게시글을 찾고, 없으면 예외를 띄움
Post post = postRepository.findById(postId)
.orElseThrow(() -> new GeneralException("POST404", "해당 게시글이 존재하지 않습니다."));
// 2. 찾은 게시글을 DTO에 담아 컨트롤러로 보냄
return PostResponseDto.from(post);
}
}