Notice
Recent Posts
Recent Comments
Link
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
Tags
more
Archives
Today
Total
관리 메뉴

우당탕탕 개발일지

JPA - 데이터 모델링 본문

Spring

JPA - 데이터 모델링

YUDENG 2024. 5. 20. 19:58

선행조건

1. build.gradle에 필요한 의존성 추가

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-security'

	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'

	//MySQL
	runtimeOnly 'com.mysql:mysql-connector-j'

	//JWT
	implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'

	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

 

2. application.yml 설정

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/apiserver?serverTimezone=Asia/Seoul
    username: root
    password: 0000abc

  jpa:
    database: mysql
    hibernate:
      ddl-auto: create # (1) 스키마 자동 생성
    show-sql: true # (2) SQL 쿼리 출력
    generate-ddl: true
    properties:
      hibernate:
        format_sql: true # (3) SQL pretty print

    defer-datasource-initialization: true

  jwt:
    header: Authorization
    secret: YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd6eXoxMjMxMjMxMjMxMjMxMjMxMzEyMzEyMzEzMTIzMTIzMTIzMTMxMjMxMzEzMTMxMjM
    accessTokenValidityInSeconds: 3600

계층형 구조

  • controller, web → 웹 계층
  • service → 비즈니스 로직, 트랜잭션 처리
  • repository ( dao 이용 )  → JPA를 직접 사용하는 계층, 엔티티 매니저 사용
  • entity → 엔티티가 모여 있는 계층, 모든 계층에서 사용

* repository와 service 차이

@ Repository → DB에 직접적으로 접근하는 것
@ Service → DB와 유저 인터페이스 간의 정보 교환을 다루는 알고리즘

<< 개발 순서 >>
서비스, 리포지토리 계층 개발 → 테스트 케이스 작성 및 검증 → 웹 계층 적용

 

1. 엔티티 구성하기

package com.example.apiServer.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

import java.time.LocalDateTime;

@Entity
@Table(name = "treat")
@Getter @Setter
public class Treat {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 기본키를 자동으로 1씩 증가
    @Column(name = "treatId")
    private Long id;
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "medication_id")
    private Medication medication; // 처방내역
    private LocalDateTime treatStartDate; // 진료개시일
    private int treatSubject; //진료과목
    private String hospitalName;
    private String visitDays; //방문일수
    private String userName; // 이름
    private String userIdentity; //주민번호
    private int prescribeCnt; //처방횟수
    private int deductibleAmt; //본인부담금
    private int publicCharge; //공단부담금
}
package com.example.apiServer.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

@Entity
@Table(name = "medication")
@Getter @Setter
public class Medication {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY) // 기본키를 자동으로 1씩 증가
    @JoinColumn(name = "medicationId")
    private Long id;
    @OneToOne(mappedBy = "medication", fetch = FetchType.LAZY)
    private Treat treat;
    @OneToMany(mappedBy = "medication", cascade = CascadeType.ALL)
    private List<Medidetail> medidetails = new ArrayList<>();
    private String diseaseId; //질병분류
    private int prescribeDays; //복용기간
    private Date treatDate; //진료,처방일자
}
package com.example.apiServer.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

@Entity
@Table(name = "medidetail")
@Getter @Setter
public class Medidetail {
    @Id
    @GeneratedValue
    @Column(name = "medidetailId")
    private int id; //처방상세번호
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "medication_id")
    private Medication medication;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "drug_id")
    private Drug drug;
}

 

 

@Data

@Getter / @Setter / @ToString / @EqualsAndHashCode / @RequiredArgsConstructor 를 합쳐놓은 어노테이션이다.

 

@Builder

빌더 패턴은 생성 패턴 중 하나로, 생성자가 많을 경우 사용한다. 빌더 패턴을 이용하게 되면 생성자의 필드 순서를 알 필요 없이 필드명을 통해서 손쉽게 객체 생성을 할 수 있다. 그러나 매 객체마다 빌더 패턴을 만드는 것은 번거로운 작업인데 Builder 어노테이션을 사용하면 해결이 가능하다. 

 

@NoArgsConstructor

파라미터가 없는 디폴트 생성자를 생성한다.

 

@AllArgsConstructor

모든 필드 값을 파라미터로 받는 생성자를 생성한다.

 

@RequiredArgsConstructor

final이나 @NonNull으로 선언된 필드만 파라미터로 받는 생성자를 생성한다.

 

< 엔티티 ISSUE >

더보기

ISSUE 1. Column명

CREATE TABLE Treat (
    treatId Int PRIMARY KEY,
    treatStartDate DATE NOT NULL,
    treatSubject INT NOT NULL,
    hospitalName VARCHAR(50) NOT NULL,
    visitDays INT NOT NULL,
    userName VARCHAR(10) NOT NULL,
    userIdentity VARCHAR(13) NOT NULL,
    prescribeCnt INT NOT NULL,
    deductibleAmt INT NOT NULL,
    publicCharge INT NOT NULL
);

SQL 쿼리문으로 테이블을 작성해주었음에도 불구하고, column명 에러가 계속 발생하였다. entity 클래스를 확인한 결과 column 이름을 지정해 줄 때 언더바( _ )를 넣어서 DB에서도 언더바로 지정이 되고 있었다.

ex) @Column(name = "treat_id") @Column(name = "treatId") 

 

ISSUE 2. seq 테이블 중복 생성

PK를 생성할 때 @GeneratedValue 타입을 지정안해줘서 seq 테이블이 생성이 되었다. 기본값이 AUTO로 지정이 되어있기 때문에 @GeneratedValue(strategy = GenerationType.IDENTITY)로 지정을 해주어야 한다.

 

 

2. 레포지토리 구성하기

Spring Data JPA는 내부적으로 여러 디자인 패턴과 OOP 원칙을 활용한다.

간단한 CRUD 기능이나 표준적인 데이터 액세스 작업은 JpaRepository 인터페이스를 사용하여 처리하고, 더 복잡하거나 특수한 경우에는 엔티티 매니저를 직접 주입하여 처리한다.

package com.example.apiServer.repository;

import com.example.apiServer.entity.Treat;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface TreatRepository extends JpaRepository<Treat, Long> {
    Optional<Treat> findById(Long id);
    List<Treat> findAllByIdentity(String identity);
}
package com.example.apiServer.repository;

import com.example.apiServer.entity.Medication;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface MedicationRepository extends JpaRepository<Medication, Long> {
    Optional<Medication> findById(Long id);
}
  • 클래스 선언 앞에 @Repository 어노테이션을 붙이면 해당 인터페이스가 JpaRepository임을 나타낸다.
  • JpaRepository 인터페이스는 JPA를 좀 더 쉽게 사용할 수 있도록 도와주며, CRUD 기능을 기본적으로 제공한다.

Optional

Optional<T>라는 크래스가 있다. .get()의 경우 결과값이 null일 경우 NoSuchElementException이 발생한다. 값이 없을 경우 orElseThrow()를 통해 예외를 던져줘야 한다. 레포지토리에서 Optional을 반환하는 경우 원하는 값이 있으면 원하는 객체로 받고 없으면 Exception 처리를 할 수 있다.

 

3. 서비스 작성하기

package com.example.apiServer.service;

import com.example.apiServer.entity.Treat;
import com.example.apiServer.repository.TreatRepository;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class TreatService {
    @Autowired
    private TreatRepository treatRepository;

    // 진료 내역
    @Transactional
    public Treat findById(Long id){
        Optional<Treat> treat = treatRepository.findById(id);
        return treat.get();
    }

    @Transactional
    public List<Treat> findAllByIdentity(String identity){
        return treatRepository.findAllByIdentity(identity);
    }
}
package com.example.apiServer.service;

import com.example.apiServer.entity.Medication;
import com.example.apiServer.repository.MedicationRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

public class MedicationService {
    @Autowired
    private MedicationRepository medicationRepository;

    // 처방 정보
    @Transactional
    public Medication findById(Long id){
        Optional<Medication> medication = medicationRepository.findById(id);
        return medication.get();
    }
}

 

4. DTO 작성하기

DTO는 프레젠테이션 계층에서 클라이언트와 데이터를 주고 받기 위해 존재하는 객체이다. 클라이언트와 프레젠테이션 계층의 통신은 JSON 방식으로 이루어진다. 엔티티는 테이블과 매핑된 데이터로 굉장히 민감한 정보이다. DTO를 사용하면 엔티티는 내부에 숨기고 대신 필요한 데이터만 클라이언트에 넘길 수 있다.

package com.example.apiServer.dto.treat;

import com.example.apiServer.entity.Medication;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TreatResponse {
    Long id; //진료내역
    Long medicationId; //처방내역
    LocalDateTime treatStartDate; //진료개시일
    int treatSubject; //진료과목
    String hospitalName;
    String visitDays; //방문일수
    int prescribeCnt; //복용기간
    int deductibleAmt; //본인부담금
    int publicCharge; //공단부담금
}

 

@NoArgsConstructor

파라미터가 없는 디폴트 생성자를 자동으로 생성한다. 이 어노테이션을 생성하면, 클래스에 명시적으로 선언된 생성자가 없더라도 인스턴스를 생성할 수 있다.

@AllArgsConstructor

클래스의 모든 필드 값을 파라미터로 받는 생성자를 자동으로 생성한다. 이 어노테이션을 사용하면, 클래스의 모든 필드를 한 번에 초기화할 수 있다.

 

< DTO 이슈 >

더보기

스프링은 클라이언트로 객체를 전달하는 과정에서 Json 직렬화를 실행하고 이를 도와주는 것은 Jackson 라이브러리이다.

즉, JSON에서 JavaObject로 변환하는 과정, medicationRequest가 완성되는 과정에서는 변수 이름을 기반으로 Getter와 Setter 둘 중 하나를 이용해서 DTO 필드를 가져오며, Reflection을 통해 DTO 필드에 주입한다. Setter는 보안상의 이유로 Getter를 사용하고, Reflection을 위해서는 기본 생성자도 필요하다. 빌더 패턴으로 DTO를 만들면 위와 같다.

RequestDTO ResponseDTO
Getter + 기본 생성자 Getter

 

* 직렬화 - 자바의 객체를 클라이언트로 전달하는 과정에서 객체를 JSON 형식을 변환해주는 과정

* 역직렬화 - 클라이언트로부터 받아오는 JSON 값을 자바의 객체로 변환해주는 과정

 

5. 공통 응답 형식 생성

모든 Response에서 공통적으로 들어가야 하는 값을 사용하거나, Error가 발생했을 때 일관되게 처리할 수 있도록 하기 위해서 Response를 구조화 할 필요성이 있다.

 

import com.example.MyPage.api.status.BaseCode;
import com.example.MyPage.api.status.SuccessStatus;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@JsonPropertyOrder
public class CommonResponse<T> {
    private final Boolean isSuccess;
    private final String code;
    private final String message;
    private T result;

    public static <T> CommonResponse<T> ok(T result) {
        return new CommonResponse<>(true, SuccessStatus._OK.getCode(),
                SuccessStatus._OK.getMessage(), result);
}

public static <T> CommonResponse<T> of(BaseCode code, T result) {
    return new CommonResponse<>(true, code.getCode(), code.getMessage(), result);
}

public static <T> CommonResponse<T> onFailure(String code, String message, T result) {
        return new CommonResponse<>(false, code, message, result);
    }
}
public interface BaseCode {
    String getCode();
    String getMessage();
}
import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
public enum ErrorStatus implements BaseCode {
    //common
    _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."),
    _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."),
    _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."),
    _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."),

    ErrorStatus(HttpStatus httpStatus, String code, String message) {
        this.httpStatus = httpStatus;
        this.code = code;
        this.message = message;
    }

    @Override
    public String getCode() {
        return code;
    }

    @Override
    public String getMessage() {
        return message;
    }

    public HttpStatus getHttpStatus() {
        return httpStatus;
    }
}

 

6. 예외처리 구현

Exception이 발생하도록 로직을 구현했을 때, 잘못된 요청임에도 불구하고 500에러가 발생한다. 클라이언트 입장에서는 500 이외의 정보를 얻을 수 없기 때문에 서버에 문제가 있다고 생각한다.

 

import com.example.MyPage.api.status.ErrorStatus;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public class GeneralException extends RuntimeException {
    private final ErrorStatus errorStatus;

    public String getErrorCode() {
        return errorStatus.getCode();
    }

    public String getErrorReason() {
        return errorStatus.getMessage();
    }

    public HttpStatus getHttpStatus() {
        return errorStatus.getHttpStatus();
    }
}
import com.example.MyPage.api.CommonResponse;
import com.example.MyPage.api.status.ErrorStatus;
import jakarta.servlet.http.HttpServletRequest;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@RestControllerAdvice(annotations = {RestController.class})
public class ExceptionAdvice extends ResponseEntityExceptionHandler {

    @ExceptionHandler(value = GeneralException.class)
    public ResponseEntity<Object> generalException(GeneralException e, HttpServletRequest request) {
        return handleExceptionInternal(e, null, request);
    }

    @ExceptionHandler
    public ResponseEntity<Object> exception(Exception e, WebRequest request) {
        return handleExceptionInternal(e, ErrorStatus._INTERNAL_SERVER_ERROR, HttpHeaders.EMPTY,
                ErrorStatus._INTERNAL_SERVER_ERROR.getHttpStatus(), request, e.getMessage());
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException e,
                                                                  HttpHeaders headers, HttpStatusCode status,
                                                                  WebRequest request) {
        LinkedHashMap<String, String> errors = new LinkedHashMap<>();
        e.getBindingResult().getFieldErrors().stream()
                .forEach(fieldError -> {
                    String fieldName = fieldError.getField();
                    String errorMessage = Optional
                            .ofNullable(fieldError.getDefaultMessage())
                            .orElse("");
                    errors.merge(fieldName, errorMessage,
                            (existingMessage, newMessage) ->
                                    String.join(", ", existingMessage, newMessage));
                });

        return handleExceptionInternalArgs(e, HttpHeaders.EMPTY, ErrorStatus._BAD_REQUEST, request, errors);
    }

    private ResponseEntity<Object> handleExceptionInternal(GeneralException e, HttpHeaders headers,
                                                           HttpServletRequest request) {
        CommonResponse<Object> body = CommonResponse
                .onFailure(e.getErrorCode(), e.getErrorReason(), null);
        WebRequest webRequest = new ServletWebRequest(request);
        return super.handleExceptionInternal(
                e,
                body,
                headers,
                e.getHttpStatus(),
                webRequest
        );
    }

    private ResponseEntity<Object> handleExceptionInternal(Exception e, ErrorStatus errorStatus, HttpHeaders headers,
                                                           HttpStatus status, WebRequest request, String errorPoint) {
        CommonResponse<String> body = CommonResponse
                .onFailure(errorStatus.getCode(), errorStatus.getMessage(), errorPoint);
        return super.handleExceptionInternal(
                e,
                body,
                headers,
                status,
                request
        );
    }

    private ResponseEntity<Object> handleExceptionInternalArgs(Exception e, HttpHeaders headers,
                                                               ErrorStatus errorStatus, WebRequest request,
                                                               Map<String, String> errorArgs) {
        CommonResponse<Map<String, String>> body = CommonResponse
                .onFailure(errorStatus.getCode(), errorStatus.getMessage(), errorArgs);
        return super.handleExceptionInternal(
                e,
                body,
                headers,
                errorStatus.getHttpStatus(),
                request
        );
    }
}

 

7. 컨트롤러 작성하기

스프링에서 비동기 처리를 하는 경우 @RequestBody, @ResponseBody, @RequestParam 를 사용한다.

ResponseEntity는 결과 데이터와 HTTP 상태 코드를 직접 제어할 수 있는 클래스이다. ResponseEntity에는 사용자의 HttpRequest에 대한 응답 데이터가 포함된다.  

package com.example.apiServer.controller;

import com.example.apiServer.dto.medication.MedicationRequest;
import com.example.apiServer.dto.medication.MedicationResponse;
import com.example.apiServer.dto.user.UserRequest;
import com.example.apiServer.entity.Treat;
import com.example.apiServer.service.MedicationService;
import com.example.apiServer.service.TreatService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/v1/treat")
@RequiredArgsConstructor
public class TreatController {
    @Autowired
    private TreatService treatService;
    @Autowired
    private MedicationService medicationService;

    @PostMapping("/")
    public ResponseEntity<List<Treat>> treat(@RequestBody UserRequest userRequest){
        List<Treat> treat = treatService.findAllByIdentity(userRequest.getUserIdentity());
        return ResponseEntity.ok()
                .body(treat);
    }

    @PostMapping("/medication")
    public ResponseEntity<MedicationResponse> medication(@RequestBody MedicationRequest medicationRequest){
        MedicationResponse medication = new MedicationResponse(medicationService.findById(medicationRequest.getId()));
        return ResponseEntity.ok()
                .body(medication);
    }
}

 

@RequestBody

XML이나 JSON 형태의 데이터를 Java 객체에 매핑할 때 사용하는 어노테이션이다. 주로 POST, PUT 요청에서 사용되며, HTTP 요청의 body 부분에 있는 데이터를 Java 객체로 매핑할 때 사용이 된다.@RequestBody로 지정한 객체는 DTO에 기본 생성자가 있어야 한다.

 

@ResponseBody

return하는 객체를 해당 타입으로 변환해서 클라이언트로 전달한다.

 

@RequestParam

주로 GET 요청에서 사용되며, URL의 쿼리 파라미터들을 메소드의 파라미터로 매핑할 때 사용된다.

 

< 상태 코드 >

200 OK 요청 수행 성공
201 Created 요청 수행 성공 및 새로운 리소스생성
400 Bad Request 요청 값이 잘못되어 요청에 실패
403 Forbidden 권한이 없어 요청에 실패
404 Not Found 요청 값으로 찾은 리소스가 없어 요청에 실패
500 Internal Server Error 서버 상 문제가 있어 요청에 실패

 

비동기 통신을 하기 위해서는 클라이언트에서 서버로 요청 메세지를 보낼 때 본문에 데이터를 담아서 보내야 하고, 서버에서 클라이언트로 응답을 보낼 때에도 본문에 데이터를 담아서 보내야 한다. 본문에 담기는 데이터 형식에서 가장 대표적으로 사용되는 것은 JSON 이다.

 

* HTTP Request 구조

  • Start line
  • Headers
  • Body

* HTTP Response 구조

  • Status line (응답 상태 코드)
  • Headers
  • Body

 

8. DB 구축

use apiserver;

CREATE TABLE Treat (
    treatId Int PRIMARY KEY,
    treatStartDate DATE NOT NULL,
    treatSubject INT NOT NULL,
    hospitalName VARCHAR(50) NOT NULL,
    visitDays INT NOT NULL,
    userName VARCHAR(10) NOT NULL,
    userIdentity VARCHAR(13) NOT NULL,
    prescribeCnt INT NOT NULL,
    deductibleAmt INT NOT NULL,
    publicCharge INT NOT NULL
);

CREATE TABLE Medication (
	medicationId INT PRIMARY KEY,
    treatId INT NOT NULL,
    diseaseId VARCHAR(50),
    prescribeDays INT ,
    treatDate DATE NOT NULL,
    FOREIGN KEY (treatId) REFERENCES Treat(treatId)
);
show columns from treat;
select * from treat;

INSERT INTO Treat (treatId, treatStartDate, treatSubject, hospitalName, visitDays, userName, userIdentity, prescribeCnt, deductibleAmt, publicCharge) 
VALUES 
(1, '2024-04-04', 23, '수연합의원', 1, '김유진', '1234561234567', 1, 10000, 5000);

-- (2, 2, '2024-04-08', 23, '늘푸른연세의원', 1, '김유진', '12345671234567', 1, 8000, 4000),
-- (3, 3, '2024-04-18', 23, '늘푸른연세의원', 1, '김유진', '1234561234567', 1, 8000, 4000),


show columns from medication;
select * from medication;

INSERT INTO Medication (medicationId, treatId, diseaseId, prescribeDays, treatDate)
VALUES
(1, 1, 'J0100', 5, '2024-04-04');
-- (2, 2, 'J0100', 5, '2024-04-08'),
-- (3, 3, 'J0100', 5, '2024-04-18'),

show columns from medidetail;
select * from treat;

 

728x90

'Spring' 카테고리의 다른 글

[Spring] Swagger - API 명세서 작성  (1) 2024.09.15
JWT 서비스  (0) 2024.07.11
AWS Cognito를 활용한 회원가입  (0) 2024.05.20
SpringBoot - MVC  (0) 2023.06.16