🦁멋쟁이사자처럼 백엔드 부트캠프 13기 🦁
TIL 회고 - [55]일차
🚀55일차에는 DTO를 Product 프로젝트에 적용해보고, Spring Boot Security에 대해 학습할 수 있었다.
학습 목표 : Entity뿐만 아니라 DTO를 통해서 데이터를 담는 방법을 구현 가능
학습 과정 : 회고를 통해 작성
파일 업로드 - 파일 정보 추가 (INFO)
// 파일 업로드
@PostMapping("/upload")
public ResponseEntity<String> uploadFile(
@RequestParam("file") MultipartFile file,
@RequestPart(name = "info", required = false) UploadInfo uploadInfo
){
log.info("파일명 : " + file.getOriginalFilename());
try(InputStream inputStream = file.getInputStream()){
StreamUtils.copy(inputStream, new FileOutputStream("c:/Temp/DumpFile/upload/" + UUID.randomUUID().toString() + file.getOriginalFilename()));
return ResponseEntity.ok().body("파일저장이 완료되었습니다. 파일명: " + file.getOriginalFilename());
}catch(IOException e){
return ResponseEntity
.badRequest()
.body("파일 업로드 실패 : " + file.getOriginalFilename());
}
}
- curl -X POST http://localhost:8080/upload -H "Content-Type: multipart/form-data" -F "file=@C:/Temp/DumpFile/upload/pingu.jpg" -F "info=@C:/Temp/DumpFile/upload/info.json;type=application/json"
➡️-H : 헤더를 입력할 것, Content의 타입은 multipart/form-data 로 전송할 것
➡️-F [file=@C] : 파일을 입력할 것, @(로컬)에서 가져오겠다는 의미, 파일의 경로를 적고
➡️-F [info=@C] : 파일을 입력할 것,@(로컬)에서 파일의 정보를 가져오겠다는 의미
파일의 타입은 json 타입일 것 - 파일에 대한 정보는 기존에 @RequestParam으로 파일업로드만 구현해봤던 것이
@RequestParam 이 아닌 @RequestPart로 info정보를 보내줘야한다. - 파일의 정보가 담긴 파일의 이름은 info이며, 반드시 필요하진 않도록 required=false로 두고
info.json
{
"description": "Sample File Upload",
"tag": "test"
}
@Getter@Setter
public class UploadInfo {
private String description; // 파일에 대한 설명
private String tag; // 파일의 태그
}
- info.json에 설정한 정보들은 domain/UploadInfo 클래스의 이 description, tag 필드들과 매핑이 정확하게 되어야한다.
POST 테스트
1.명령프롬프트로 실습 (curl 명령어 사용)
2. 확장프로그램으로 실습
- POST 결과 지정된 경로에 랜덤한 이름 값으로 잘 저장되어있고 이미지도 제대로 출력되는 것을 확인
❓Entity만을 쓰지 않고 DTO와 함께 사용하는 이유
➡️엔티티는 JPA가 동작할때 필요한 테이블 정보를 가지고 있다. (Data Layer에서 가져온 값을 담는 목적)
값을 담을때는 Entity를 Repository에서 사용하고
DTO를 Controller에서 사용하도록 하는 것
➡️Entity의 역할
데이터베이스 테이블과 매핑되는 객체
JPA에서 데이터 저장·조회 시 사용
일반적으로 데이터 저장·조회에 사용
➡️DTO의 역할
DTO(Data Transfer Object)는 필요한 데이터만 담아 클라이언트로 전달
➡️DTO를 사용하는 이유
Entity를 직접 Controller에서 반환하면, DB 구조가 노출될 수 있음
Service 계층에서 Entity를 DTO로 변환하여 Controller에 반환하면,Entity와 Controller 간의 강한 결합도를 줄일 수 있음
Entity는 Repository에서 사용하고, Controller에서는 DTO를 사용하도록 하는 것이 일반적
ex. Entity를 직접 반환
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductRepository productRepository;
@GetMapping("/{id}")
public Product getProduct(@PathVariable Long id) { // Entity 직접 반환
return productRepository.findById(id).orElseThrow(() -> new RuntimeException("상품을 찾을 수 없습니다."));
}
}
- Post 엔티티가 그대로 Controller에서 반환
정리하자면
Entity는 Repository에서 DB와 직접 연결되는 데이터 객체로 사용
DTO는 Controller에서 클라이언트에게 반환하는 객체로 사용
Service 계층에서 Entity에서 DTO로 변환하여 Controller가 Entity에 직접 접근하지 않도록 설계함
JPA는 Entity를 중심으로 동작하는 ORM 기술이므로, DTO를 필수적으로 사용하지 않음
즉, JPA에서 DTO를 사용하지 않아도 되지만 대부분 API의 응답 형식과 DB 구조를 분리하기 위해 DTO를 함께 사용
➡️JPA에서 DTO를 사용하는 이유
API 응답과 비즈니스 로직을 분리하기 위함
Product 프로젝트
Product 엔티티
@Getter@Setter@NoArgsConstructor
@Table(name = "products")
@Entity
@AllArgsConstructor
public class Product {
@Id@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
}
- @AllArgsConstructor
DTO를 통해 값을 복사하기 위해선 전체 필드를 가져오는 생성자도 필요
ProductDTO
@Getter@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ProductDTO {
private Long id;
private String name;
private double price;
}
ProductService
// 1. 상품 추가
@Transactional
public ProductDTO createProduct(ProductDTO productDTO){
// 1번 변환 방법. DTO를 Entity로 변환
// Product product = new Product(null, productDTO.getName(), productDTO.getPrice());
// 2번 변환 방법. Setter메소드 활용
product.setName(productDTO.getName());
product.setPrice(productDTO.getPrice());
// 실제 저장하는 메소드 (save)
Product createProduct = productRepository.save(product);
return new ProductDTO(createProduct.getId(), createProduct.getName(), createProduct.getPrice());
}
- Product product = new Product(null, productDTO.getName(), productDTO.getPrice());
➡️id는 가져올 필요없으므로 null로 설정 - 1번 변환 방법의 단점은 생성자를 통해 받아오기때문에, 필드가 많아질 경우 생성자 선언 순서를 지켜줘야하지만
- 2번 변환 방법은 순서가 상관없으므로 더 나은 방법일 수 있음
- return new ProductDTO(createProduct.getId(), createProduct.getName(), createProduct.getPrice());
➡️반환값으로 DTO를 새로 만들며 이 메소드에서 생성해주었던 Product 타입의 (ID, NAME, PRICE)를 전달
빌더 패턴 Builder Patter
▶️실습 - Pizza 클래스에 빌더패턴 적용
- Lombok 사용으로 빌더 생성
@AllArgsConstructor
@ToString
@Builder
public class Pizza {
private String size;
private boolean cheese;
private boolean onion;
private boolean potato;
}
@Builder.Default
private boolean cheese = false;
@Builder.Default
private boolean onion = true;
@Builder.Default
private boolean potato = false;
- @Builder.Default로 필드들의 기본값을 지정할 수도 있다.
- 직접 빌더 생성
public class Pizza{
private String size;
private boolean cheese;
private boolean onion;
private boolean potato;
public static class Builder{
private String size;
private boolean cheese = true;
private boolean onion = false;
private boolean potato = true;
// build()를 쓰기위한 기본 생성자
public Builder(){};
// 만약 size는 기본적으로 갖고싶다면 이처럼 구현 가능
//public Builder(String size){};
// 메소드 체이닝
public Builder size(String size){
this.size = size;
return this;
}
// ... 다른 필드들
public Pizza build(){
return new Pizza(this);
}
}
private Pizza(Builder builder){
this.size = builder.size;
this.cheese = builder.cheese;
this.onion = builder.onion;
this.potato = builder.potato;
}
public static Builder builder(){
return new Builder();
}
}
- 메소드 체이닝 또한 직접 구현이되고 , Builder 클래스에서 기본값을 지정
- build()
직접 구현한 build() 메소드를 호출하면 Pizza(Builder builder)가 호출되어 각각의 필드를 업데이트하게됨
Builder 테스트
public static void main(String[] args) {
// 1. 일반적인 객체 생성
Pizza pizza = new Pizza("Small", true, true, true);
// 2. 빌더패턴으로 객체 생성
Pizza pizza2 = Pizza.builder()
.size("Medium")
.cheese(false)
.onion(false)
.potato(true)
.build();
System.out.println("[일반피자] : " + pizza);
System.out.println("[빌더패턴 피자] : " + pizza2);
}
Product에 Builder 적용
- Product 엔티티와 ProductDTO에 모두 @Builder 추가
Product 엔티티
@Getter@Setter@NoArgsConstructor
@Table(name = "products")
@Entity
@AllArgsConstructor // DTO를 통해 값을 복사하기 위해선 전체 필드를 가져오는 생성자도 필요
@Builder
public class Product {
@Id@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private double price;
// DTO -> Entity 변환하는 메소드
public static Product fromDTO(ProductDTO productDTO){
return Product.builder()
.id(productDTO.getId())
.name(productDTO.getName())
.price(productDTO.getPrice())
.build();
}
}
- @Builder 어노테이션을 추가하고, DTO -> Entity변환 메소드 추가
ProductDTO
// Entity -> DTO 변환 메소드
public static ProductDTO fromEntity(Product product){
return ProductDTO.builder()
.id(product.getId())
.name(product.getName())
.price(product.getPrice())
.build();
}
ProductService
// 1. 상품 추가
@Transactional
public ProductDTO createProduct(ProductDTO productDTO){
Product product;
product.setName(productDTO.getName());
product.setPrice(productDTO.getPrice());
Product createProduct = productRepository.save(product);
// 빌더 패턴 사용 후 반환
return ProductDTO.fromEntity(createProduct);
}
- Product와 ProductDTO 변환을 추가 후 Service에 반환값을 변경
상품 가져오기 메소드 - 수정
// 2. 상품 가져오기
@Transactional(readOnly = true)
public List<ProductDTO> getProducts(){
return productRepository.findAll().stream()
.map(ProductDTO::fromEntity)
.collect(Collectors.toList());
}
- .stream()
➡️List<Product>를 Stream<Product> 형태로 변환함.
➡️스트림을 사용하여 데이터를 순차적 처리함 - .map(ProductDTO::fromEntity)
➡️.map()을 사용해 Entity → DTO 변환
➡️즉 Product 엔티티를 ProductDTO로 변환하는 과정
(=모든 Product 엔티티를 DTO로 변환)
- .collect(Collectors.toList())
스트림에서 처리한 데이터를 List<ProductDTO> 형태로 변환하여 반환
map()을 거치면서 모든 엔티티가 DTO로 변환되어 List<ProductDTO>가 반환 가능해짐
상품 조회, 수정, 삭제 메소드 - 수정
// 3. 특정 상품 조회
@Transactional(readOnly = true)
public ProductDTO getProductById(Long id){
Product product = productRepository.findById(id)
.orElseThrow( () -> new RuntimeException("상품이 없습니다! 아이디 : " + id));
return ProductDTO.fromEntity(product);
}
// 4. 상품 정보 업데이트
@Transactional
public ProductDTO updateProduct(ProductDTO productDTO){
Product product = productRepository.findById(productDTO.getId())
.orElseThrow( () -> new RuntimeException("상품이 없습니다! 아이디 : " + productDTO.getId()));
product.setName(productDTO.getName());
product.setPrice(productDTO.getPrice());
return ProductDTO.fromEntity(product);
}
// 5. 상품 삭제
@Transactional
public void deleteProduct(Long id){
if(!productRepository.existsById(id)){
throw new RuntimeException("삭제할 상품이 없습니다! 아이디 : " + id);
}
productRepository.deleteById(id);
}
- name의 경우에는 String으로써 타입이 체크하기가 용이하지만
- price는 double이어서 0.0의 기본값을 가져 Double을 쓰지 않으면 null체크가 어려움
❓double vs Double 사용의 차이점
1) Double로 null 체크
public void setPrice(Double price) {
if (price == null || price < 0) { // null 체크 가능
throw new IllegalArgumentException("가격은 null이거나 음수가 될 수 없습니다.");
}
this.price = price;
}
- Double은 객체 타입이므로 null 체크 가능
- 가격이 null이거나 0 미만이면 예외 발생
2) double로 null 체크
public void setPrice(double price) {
if (price < 0) { // null 체크 불가 (기본값 0.0)
throw new IllegalArgumentException("가격은 음수가 될 수 없습니다.");
}
this.price = price;
}
- double은 기본형이므로 null을 가질 수 없음
- 기본값(0.0)이 자동 할당됨
정리하자면
➡️가격 값이 null일 가능성이 있다면 Double을 사용하고 null 체크를 포함해야 함
➡️가격이 무조건 존재하는 경우 double을 사용하는 것이 좋음
➡️@NotNull 유효성 검사를 추가하여 API 요청 시 null을 방지할 수도 있음
public class ProductDTO {
@NotNull(message = "가격을 필수로 입력해주세요 !")
private Double price;
}
🚀실습 - DTO 적용 후 POST
- 결과로 id=1번으로 데이터가 잘 들어오고 있는 것을 확인 가능
- Content-Type은 application/json으로
Body에는 Text로써 JSON 형식으로 넣어서 보내주어야한다.
샘플 데이터 넣기
- 여러 데이터를 넣은 후 GET방식으로 실행
- 모든 데이터 조회 가능
🚀실습 - DTO 적용 후 PUT
- id=2번의 포도 → 샤인머스캣 (가격 5000 → 12000)으로 변경 완료
🚀실습 - DTO 적용 후 DELETE
💡Swagger API 사용으로 POST, PUT, DELETE 테스트를 하는 것도 가능
Validation
- 검증하기 위한 API 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-validation'
public class ProductDTO {
private Long id;
@NotBlank(message = "상품명은 반드시 입력해야합니다.")
private String name;
@Min(value = 1, message = "가격은 1 이상 이어야합니다.")
private double price;
...
}
- @NotBlank 등으로 공백을 허용하지 않게할 수 있다.
ProductController 수정
// 1. 상품 저장
@PostMapping
public ResponseEntity<ProductDTO> createProduct(@Valid @RequestBody ProductDTO productDTO){
return ResponseEntity.ok(productService.createProduct(productDTO));
}
- PostMapping 에서 값을 채울때 검증성을 체크하고 싶으므로 createProduct()메소드의 파라미터로 @Valid 를 추가
- Validation 적용 후 결과
- name은 공백, price는 음수가 입력되었을때 ResponseBody에 json형태로 예외 문구가 출력되는 것을 확인 가능
Spring Boot Security
- 서블릿(Servlet), 필터(Filter), 리스너(Listener)의 기본 개념
- Servlet 기반의 필터 체인(Filter Chain)을 활용하여 보안 기능을 제공
서블릿 (Servlet)
- 클라이언트 요청(Request)을 받아 처리하고, 응답(Response)을 반환하는 웹 컴포넌트
- Java의 HttpServlet 클래스를 상속받아 구현
- 요청을 처리하는 핵심 역할
- 서블릿의 동작 흐름
1) 클라이언트(웹 브라우저)가 요청을 보냄 (예: GET /login)
2) 서블릿 컨테이너가 요청을 특정 서블릿으로 전달
3) 서블릿이 요청을 처리 (doGet(), doPost() 메서드 실행)
4) 처리된 결과를 클라이언트에게 응답
필터 (Filter)
- 서블릿 앞에 위치하여 요청 중 끼어들어 추가 작업을 수행하는 컴포넌트
- 보안, 로깅, 인코딩, 요청 변환 등
- Spring Security는 기본적으로 필터 기반으로 동작
- 필터의 동작흐름
1) 클라이언트 요청 → 필터가 먼저 실행되어 요청을 검사
2) 검증 완료 후 → 서블릿으로 요청 전달 (or 차단)
3) 서블릿 처리 후 → 필터가 다시 실행되어 응답을 검사
4) 최종적으로 클라이언트에게 응답 반환
리스너 (Listener)
- 애플리케이션의 특정 이벤트 발생을 감지하고 동작하는 컴포넌트
- 서블릿 컨텍스트의 상태 변경, 세션 생성/삭제 등을 감지
전체적인 흐름
(1) 클라이언트 요청
⬇
+-----------------+
| 필터(Filter) | ← Spring Security 필터 체인
+-----------------+
⬇
+-----------------+
| 서블릿(Servlet) | ← 요청을 처리하는 핵심 (Controller 역할)
+-----------------+
⬇
+-----------------+
| 리스너(Listener) | ← 이벤트 감지 (세션, 요청 등)
+-----------------+
⬇
(2) 응답 반환
정리하자면
- 서블릿(Servlet) → 요청을 직접 처리하는 핵심
- 필터(Filter) → 요청을 가로채어 보안 검사를 수행 (Spring Security의 핵심)
- 리스너(Listener) → 이벤트 감지 (로그인/세션/요청 변화 등 감시)
필터 Filter 우선순위 설정
@Slf4j
@Component
public class UserFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
log.info("userFilter doFilter 실행 전");
filterChain.doFilter(servletRequest, servletResponse);
log.info("userFilter doFilter 실행 후");
}
}
- 필터들이 겹겹이 호출되고 있는데 우선순위를 @Order(1) Order(2) 처럼 순서를 정해서 넣어줄 수 있다.
ex. UserFilter에는 @Order(1)
ex. JunFilter에는 @Order(2)를 넣어보면
UserFilter가 먼저 실행된다. ➡️ 숫자가 낮을 수록 우선순위가 높은 것
인증 vs 인가
- 인증(Authentication)
로그인 과정으로 입력된 정보가 DB 또는 인증 서버에서 검증되어 이 후
인증이 완료되어 Spring Security의 SecurityContext에 사용자 정보 저장
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.formLogin() // 기본 로그인 폼 활성화 (인증)
.and()
.authorizeHttpRequests() // 인가 설정 시작
.anyRequest().authenticated(); // 모든 요청은 인증된 사용자만 접근 가능
return http.build();
}
}
- 인가(Authorization)
인증된 사용자가 특정 리소스에 접근할 수 있는지 확인하는 과정 (=권한(Role) 체크)
사용자가 특정 페이지에 접근 요청을 하면 Spring Security가 사용자의 권한을 확인 후 권한에 따라
접근 허용 및 접근 금지 (=403 Forbidden 반환)
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.formLogin()
.and()
.authorizeHttpRequests()
.requestMatchers("/admin/**").hasRole("ADMIN") // /admin 경로는 ADMIN만 접근 가능
.requestMatchers("/user/**").hasAnyRole("USER", "ADMIN") // USER, ADMIN 접근 가능
.anyRequest().authenticated();
return http.build();
}
}
- /admin/** → 관리자(ADMIN)만 접근 가능
- /user/** → 일반 사용자(USER)와 관리자(ADMIN) 접근 가능
- 그 외의 모든 요청 → 로그인(인증)된 사용자만 가능
🚀회고 결과 :
이번 회고에서는 POST 방식을 실습하기 위해 웹 브라우저의 확장프로그램을 통해 연습해보았다.
또한 Swagger라는 API를 이용해서도 이러한 테스트들을 진행할 수 있음을 배울 수 있었다.
Spring Security 부분은 아직 이해가 더 필요할 것 같다.
- POST, PUT, DELETE 방식으로 값 전달
- 서블릿, 필터, 리스너의 흐름 공부
느낀 점 :
CURL, JPA, REST API 등은 실습을 많이 진행해보니 조금씩 감이 잡히고 있는데
이제 새로 배울 Spring Security는 바로 이해하기는 쉽지 않은 개념인 것 같았다.
추가 공부나 다양한 예제를 많이 봐야할 것 같다.
향후 계획 :
- 인증 vs 인가
'Recording > 멋쟁이사자처럼 BE 13기' 카테고리의 다른 글
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_57일차_"Spring Security - 권한, 쿠키" (0) | 2025.02.28 |
---|---|
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_56일차_"ThreadLocal, Spring Security" (0) | 2025.02.27 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_54일차_"@RestController" (0) | 2025.02.25 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_53일차_"CURL" (0) | 2025.02.25 |
[멋쟁이사자처럼 부트캠프 TIL 회고] BE 13기_52일차_"RESTful API" (0) | 2025.02.21 |