devkobe24.com
잡학사전
AWS
Algorithm
2024
Architecture
Archive
AWS_archive
CPP_DS
CS_archive
DataStructure
Database
HackTheSwift
Java_archive
Leet-Code
MySQL
Network_archive
OS
Post
Read English Book
SQL_archive
Spring & Spring Boots
TIL
Web
Backend Development
CS
2024
2025
CS50
Code Review
DB
Data Structure
Development tools and environments
English
Interview
Java
Java多識
Java
Math
Network
2024
Others
SQL
2024
Server
Spring
Troubleshooting
Home
Contact
Copyright © 2024 |
Yankos
Home
> Backend Development
Now Loading ...
Backend Development
📚[Backend Development] 🏗️ Spring MVC @ModelAttribute
🎯 Spring MVC @ModelAttribute @ModelAttribute와 데이터 바인딩 원리에 대한 아주 좋은 질문입니다. Spring MVC의 핵심 기능 중 하나이며, 이 원리를 이해하면 컨트롤러를 훨씬 더 깔끔하고 효율적으로 작성할 수 있습니다. 🪄 @ModelAttribute란 무엇인가? @ModelAttribute는 HTTP 요청 파라미터들을 ‘객체’에 자동으로 담아주는(바인딩해주는) 마법사와 같습니다. ✨ 자동 바인딩 과정 Spring MVC가 @ModelAttribute를 만나면 다음과 같은 마법을 자동으로 처리합니다: // 🔗 HTTP 요청: GET /api/admin/orders?memberName=홍길동&shippingStatus=PENDING&startDate=2024-01-01 @GetMapping public ResponseEntity<?> getAllOrders(@ModelAttribute OrderSearchRequest searchRequest) { // ✨ 이미 모든 값이 채워진 객체가 전달됨! // searchRequest.getMemberName() => "홍길동" // searchRequest.getShippingStatus() => PENDING // searchRequest.getStartDate() => 2024-01-01 } 🔄 내부 동작 단계 🔍 분석: URL 쿼리 스트링이나 Form 데이터 분석 🏗️ 생성: OrderSearchRequest 객체 인스턴스 생성 📝 매핑: 요청 파라미터 이름과 객체 필드 이름 매칭 💉 주입: 매칭된 필드에 값을 자동으로 채워 넣음 🎁 전달: 완성된 객체를 컨트롤러 메서드 파라미터로 전달 🛠️ @Setter와 @NoArgsConstructor의 동작 원리 @ModelAttribute가 마법처럼 동작할 수 있는 이유는 JavaBeans 규약을 따르기 때문입니다. 📋 JavaBeans 규약이란? Java 객체가 지켜야 하는 표준 규칙으로, Spring의 데이터 바인더가 이 규약을 기반으로 동작합니다. 🔍 단계별 동작 원리 Step 1: 객체 생성 - @NoArgsConstructor의 역할 // ❌ 기본 생성자가 없다면? public class OrderSearchRequest { public OrderSearchRequest(String memberName) { // 🔴 파라미터가 있는 생성자만 존재 this.memberName = memberName; } } // Spring: "어떤 값을 넣어서 생성자를 호출해야 하지? 😵💫" // ✅ @NoArgsConstructor로 기본 생성자 제공 @NoArgsConstructor public class OrderSearchRequest { // 🟢 Spring이 new OrderSearchRequest() 호출 가능! } Step 2: 값 주입 - @Setter의 역할 // ❌ Setter가 없다면? public class OrderSearchRequest { private String memberName; // 🔴 private 필드에 직접 접근 불가 // Setter 없음 - Spring이 값을 주입할 방법이 없음! } // ✅ @Setter로 공개된 통로 제공 @Setter public class OrderSearchRequest { private String memberName; // 🟢 public void setMemberName(String memberName) 자동 생성! // Spring이 setMemberName("홍길동") 호출 가능! } 🎭 전체 과정 시뮬레이션 // 🎬 Spring 내부에서 일어나는 일 (의사 코드) // 1️⃣ 요청 파라미터 파싱 Map<String, String> params = parseRequestParams("?memberName=홍길동&shippingStatus=PENDING"); // 2️⃣ 객체 생성 (@NoArgsConstructor 덕분에 가능) OrderSearchRequest request = new OrderSearchRequest(); // 3️⃣ 값 주입 (@Setter 덕분에 가능) request.setMemberName(params.get("memberName")); // "홍길동" request.setShippingStatus(params.get("shippingStatus")); // PENDING // 4️⃣ 완성된 객체를 컨트롤러로 전달 controller.getAllOrders(request); 🚀 베스트 프랙티스 & 실무 예제 핵심 원칙: “요청 DTO에 유효성 검사(Validation)를 추가하여 Controller의 책임을 줄이기” 📌 Best Practice 체크리스트 🗂️ 관련 파라미터 그룹화: 여러 검색 조건을 하나의 DTO로 묶기 ✅ 유효성 검사 추가: Jakarta Bean Validation 어노테이션 활용 🛡️ @Valid 적용: 컨트롤러에서 유효성 검사 활성화 💼 실무 예제: 완전한 검증 시스템 dto/request/OrderSearchRequest.java (유효성 검사 추가) package com.kobe.productmanagement.dto.request; import com.kobe.productmanagement.common.ShippingStatus; import jakarta.validation.constraints.Size; import jakarta.validation.constraints.PastOrPresent; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import org.springframework.format.annotation.DateTimeFormat; import java.time.LocalDate; @Getter @Setter @NoArgsConstructor public class OrderSearchRequest { // 🔍 회원 이름 검증 @Size(min = 2, max = 50, message = "회원 이름은 2자 이상 50자 이하로 입력해주세요.") private String memberName; // 📦 배송 상태 (Enum이므로 자동으로 유효성 검사됨) private ShippingStatus shippingStatus; // 📅 시작 날짜 검증 @DateTimeFormat(pattern = "yyyy-MM-dd") @PastOrPresent(message = "시작 날짜는 현재 또는 과거 날짜여야 합니다.") private LocalDate startDate; // 📅 종료 날짜 검증 @DateTimeFormat(pattern = "yyyy-MM-dd") @PastOrPresent(message = "종료 날짜는 현재 또는 과거 날짜여야 합니다.") private LocalDate endDate; // 🔧 비즈니스 로직 검증 메서드 public boolean isDateRangeValid() { if (startDate == null || endDate == null) { return true; // null은 선택적 조건이므로 유효 } return !startDate.isAfter(endDate); // 시작일이 종료일보다 늦으면 안됨 } } controller/OrderAdminController.java (@Valid 적용) package com.kobe.productmanagement.controller; import com.kobe.productmanagement.common.ApiResponse; import com.kobe.productmanagement.dto.request.OrderSearchRequest; import com.kobe.productmanagement.dto.response.OrderResponse; import com.kobe.productmanagement.service.OrderService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequiredArgsConstructor @RequestMapping("/api/admin/orders") public class OrderAdminController { private final OrderService orderService; @GetMapping public ResponseEntity<ApiResponse<List<OrderResponse>>> getAllOrders( @Valid @ModelAttribute OrderSearchRequest searchRequest, // 🛡️ 유효성 검사 활성화 BindingResult bindingResult // 🔍 검사 결과 수집 ) { // 🚨 기본 필드 유효성 검사 실패 시 if (bindingResult.hasErrors()) { String errorMessage = bindingResult.getAllErrors().get(0).getDefaultMessage(); throw new IllegalArgumentException(errorMessage); } // 🔧 비즈니스 로직 검증 if (!searchRequest.isDateRangeValid()) { throw new IllegalArgumentException("시작 날짜가 종료 날짜보다 늦을 수 없습니다."); } // ✅ 모든 검증 통과 - 안전한 데이터로 비즈니스 로직 실행 List<OrderResponse> orderResponses = orderService.getAllOrders(searchRequest); ApiResponse<List<OrderResponse>> response = ApiResponse.success( "전체 주문 목록이 성공적으로 조회되었습니다.", orderResponses ); return ResponseEntity.ok(response); } } 📊 전통적 방식 vs @ModelAttribute 비교 ❌ 기존 방식: 파라미터 하나씩 받기 @GetMapping public ResponseEntity<?> getAllOrders( @RequestParam(required = false) String memberName, @RequestParam(required = false) ShippingStatus shippingStatus, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startDate, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endDate ) { // 🔴 파라미터가 많아질수록 메서드 시그니처가 복잡해짐 // 🔴 각 파라미터마다 개별적으로 검증 로직 필요 // 🔴 관련 있는 데이터임에도 불구하고 응집도가 떨어짐 } ✅ @ModelAttribute 방식: 객체로 받기 @GetMapping public ResponseEntity<?> getAllOrders(@Valid @ModelAttribute OrderSearchRequest searchRequest) { // 🟢 깔끔한 메서드 시그니처 // 🟢 DTO에서 일괄 검증 처리 // 🟢 관련 데이터가 하나의 객체로 응집 // 🟢 재사용성과 유지보수성 향상 } 📈 비교표 구분 개별 @RequestParam @ModelAttribute 가독성 ❌ 파라미터 폭발로 복잡 ✅ 깔끔한 메서드 시그니처 응집도 ❌ 관련 데이터가 분산 ✅ 관련 데이터가 객체로 응집 검증 ❌ 개별 검증 로직 필요 ✅ DTO에서 일괄 검증 재사용성 ❌ 다른 컨트롤러에서 재사용 어려움 ✅ DTO 재사용으로 일관성 확보 유지보수 ❌ 파라미터 추가 시 여러 곳 수정 ✅ DTO만 수정하면 됨 💡 실무 팁 🎯 네이밍 컨벤션 // ✅ 좋은 DTO 네이밍 OrderSearchRequest // 주문 검색 요청 UserRegistrationForm // 사용자 등록 폼 ProductFilterCriteria // 상품 필터 조건 // ❌ 나쁜 DTO 네이밍 OrderDto // 용도가 불분명 SearchForm // 무엇을 검색하는지 불분명 RequestData // 너무 일반적 🔧 검증 전략 // 🎪 복합 검증 어노테이션 활용 @Target({ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = DateRangeValidator.class) public @interface ValidDateRange { String message() default "시작 날짜가 종료 날짜보다 늦을 수 없습니다."; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; } // DTO에 적용 @ValidDateRange public class OrderSearchRequest { // 필드들... } 🚀 성능 최적화 // 🎯 자주 사용되는 검색 조건은 캐시 활용 @Cacheable(value = "orderSearch", key = "#searchRequest.toString()") public List<OrderResponse> getAllOrders(OrderSearchRequest searchRequest) { // 검색 로직... } 🏆 결론 @ModelAttribute는 HTTP 요청 데이터를 객체지향적으로 처리할 수 있는 Spring MVC의 핵심 기능입니다. 🎯 핵심 가치 🪄 자동 바인딩: 복잡한 파라미터 처리를 간단하게 📦 데이터 응집: 관련 있는 요청 데이터를 하나의 객체로 묶음 🛡️ 검증 통합: DTO 레벨에서 일관된 검증 로직 구현 🔄 재사용성: 여러 컨트롤러에서 동일한 DTO 활용 가능 이처럼 유효성 검사까지 DTO와 컨트롤러에서 처리하면, Service 계층은 이미 검증된 안전한 데이터만 가지고 비즈니스 로직에만 집중할 수 있게 되어 역할과 책임이 더욱 명확해지는 견고한 코드가 됩니다!
Backend Development
· 2025-09-06
📚[Backend Development] 🏗️ JpaSpecificationExecutor
🎯 JpaSpecificationExecutor JpaSpecificationExecutor는 동적 쿼리를 다루는 데 있어 매우 중요한 개념입니다. 🤔 JpaSpecificationExecutor를 추가로 상속받는 의미는? OrderRepository가 JpaSpecificationExecutor를 추가로 상속받는 것은, 기본 리포지토리(JpaRepository)에 “동적 쿼리 조립 능력”이라는 강력한 추가 기능을 장착하는 것을 의미합니다. 🏪 식당 비유로 이해하기 🍽️ JpaRepository = 기본 메뉴판 public interface OrderRepository extends JpaRepository<Order, String> { // 🍕 정해진 메뉴들 List<Order> findAll(); // "모든 주문 조회" Optional<Order> findById(String id); // "ID로 주문 찾기" Order save(Order order); // "주문 저장" void deleteById(String id); // "주문 삭제" } 특징: findById, findAll, save 등 정해진 규칙의 단순한 쿼리만 실행 가능 🎨 JpaSpecificationExecutor = 주방장 특선 주문 public interface OrderRepository extends JpaRepository<Order, String>, JpaSpecificationExecutor<Order> { // ✨ 이제 추가로 사용 가능한 "맞춤형 주문" 기능들 List<Order> findAll(Specification<Order> spec); Page<Order> findAll(Specification<Order> spec, Pageable pageable); Optional<Order> findOne(Specification<Order> spec); long count(Specification<Order> spec); } 특징: 정해지지 않은 여러 조건들을 조합하여 맞춤형 쿼리 실행 가능 예: “회원 이름이 ‘홍’으로 시작하고, 주문 상태가 ‘배송중’이며, 지난주에 주문한 내역” 🔄 변화 요약 구분 JpaRepository만 상속 + JpaSpecificationExecutor 상속 쿼리 타입 ❌ 정적 쿼리만 가능 ✅ 동적 쿼리 조립 가능 조건 조합 ❌ 미리 정의된 메서드만 ✅ 런타임에 자유로운 조합 유연성 ❌ 제한적 ✅ 무한한 확장성 🛠️ 상속으로 인한 구체적 변경사항 가장 큰 변경점은 OrderRepository를 사용하는 서비스 계층에서 새로운 메서드들을 사용할 수 있게 된다는 것입니다. 📋 추가되는 핵심 메서드들 JpaSpecificationExecutor 인터페이스가 OrderRepository에 추가해주는 대표적인 메서드들: // 🔍 조건에 맞는 모든 결과 조회 List<Order> findAll(Specification<Order> spec); // 📄 조건에 맞는 결과를 페이징하여 조회 Page<Order> findAll(Specification<Order> spec, Pageable pageable); // 🎯 조건에 맞는 첫 번째 결과 조회 Optional<Order> findOne(Specification<Order> spec); // 📊 조건에 맞는 결과 개수 조회 long count(Specification<Order> spec); // ✅ 조건에 맞는 결과가 존재하는지 확인 boolean exists(Specification<Order> spec); 🎪 핵심 특징: Specification<Order> 파라미터 모든 메서드가 Specification<Order> 타입의 객체를 파라미터로 받는다는 점이 가장 중요합니다. // ❌ 이전: 정해진 메서드만 호출 가능 List<Order> orders = orderRepository.findAll(); // ✅ 이후: 동적 조건을 담은 Specification으로 자유로운 쿼리 실행 Specification<Order> spec = OrderSpecification.withMemberName("홍길동") .and(OrderSpecification.withShippingStatus(SHIPPED)); List<Order> orders = orderRepository.findAll(spec); 🚀 베스트 프랙티스: 실무 예제 코드 핵심 원칙: “재사용 가능한 검색 조건들을 별도의 Specification 클래스로 분리하여 관리하기” 이렇게 하면 서비스 로직은 검색 조건이 어떻게 만들어지는지에 대한 복잡한 내용과 분리되어 훨씬 깔끔해집니다. 🔧 Step 1: Specification 클래스 생성 (Best Practice) 검색 조건들을 정의하는 OrderSpecification 클래스를 새로 만듭니다. 각 조건은 재사용 가능하도록 static 메서드로 정의합니다. repository/specification/OrderSpecification.java (신규 파일) package com.kobe.productmanagement.repository.specification; import com.kobe.productmanagement.common.ShippingStatus; import com.kobe.productmanagement.domain.Member; import com.kobe.productmanagement.domain.Order; import jakarta.persistence.criteria.Join; import org.springframework.data.jpa.domain.Specification; import org.springframework.util.StringUtils; import java.time.LocalDate; import java.time.LocalTime; public class OrderSpecification { // 🔍 회원 이름으로 검색하는 Specification public static Specification<Order> withMemberName(String memberName) { return (root, query, criteriaBuilder) -> { // 🟢 null/빈 문자열 체크로 안전성 확보 if (!StringUtils.hasText(memberName)) { return null; // 조건 값이 없으면 무시 } Join<Order, Member> memberJoin = root.join("member"); return criteriaBuilder.like(memberJoin.get("memberName"), "%" + memberName + "%"); }; } // 📦 배송 상태로 검색하는 Specification public static Specification<Order> withShippingStatus(ShippingStatus status) { return (root, query, criteriaBuilder) -> { if (status == null) return null; return criteriaBuilder.equal(root.get("shippingStatus"), status); }; } // 📅 주문 날짜 범위로 검색하는 Specification public static Specification<Order> betweenDates(LocalDate startDate, LocalDate endDate) { return (root, query, criteriaBuilder) -> { if (startDate == null || endDate == null) return null; return criteriaBuilder.between( root.get("createdAt"), startDate.atStartOfDay(), endDate.atTime(LocalTime.MAX) ); }; } // 💰 최소 주문 금액으로 검색하는 Specification public static Specification<Order> withMinAmount(Integer minAmount) { return (root, query, criteriaBuilder) -> { if (minAmount == null) return null; return criteriaBuilder.greaterThanOrEqualTo(root.get("totalAmount"), minAmount); }; } // ⭐ VIP 회원 주문 검색하는 Specification public static Specification<Order> isVipOrder() { return (root, query, criteriaBuilder) -> { Join<Order, Member> memberJoin = root.join("member"); return criteriaBuilder.equal(memberJoin.get("memberType"), "VIP"); }; } } 🎨 Step 2: Service에서 Specification 조합하여 사용 이제 OrderServiceImpl에서는 private 메서드로 검색 로직을 직접 구현하는 대신, 새로 만든 OrderSpecification의 메서드들을 레고 블록처럼 조합하여 사용합니다. service/OrderServiceImpl.java (리팩토링 후) package com.kobe.productmanagement.service; import com.kobe.productmanagement.domain.Order; import com.kobe.productmanagement.dto.request.OrderSearchRequest; import com.kobe.productmanagement.dto.request.OrderStatusUpdateRequest; import com.kobe.productmanagement.dto.response.OrderResponse; import com.kobe.productmanagement.repository.OrderRepository; import com.kobe.productmanagement.repository.specification.OrderSpecification; // 🟢 신규 import import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.stream.Collectors; @Service @RequiredArgsConstructor @Transactional public class OrderServiceImpl implements OrderService { private final OrderRepository orderRepository; @Override @Transactional(readOnly = true) public List<OrderResponse> getAllOrders(OrderSearchRequest searchRequest) { // 🧩 Specification들을 레고 블록처럼 체인 형태로 조합 Specification<Order> spec = Specification .where(OrderSpecification.withMemberName(searchRequest.getMemberName())) .and(OrderSpecification.withShippingStatus(searchRequest.getShippingStatus())) .and(OrderSpecification.betweenDates(searchRequest.getStartDate(), searchRequest.getEndDate())) .and(OrderSpecification.withMinAmount(searchRequest.getMinAmount())); // ✨ JpaSpecificationExecutor가 제공하는 findAll(spec) 메서드 사용 return orderRepository.findAll(spec).stream() .map(OrderResponse::from) .collect(Collectors.toList()); } // 🆕 페이징 처리가 포함된 검색 메서드 추가 @Override @Transactional(readOnly = true) public Page<OrderResponse> getOrdersWithPaging(OrderSearchRequest searchRequest, Pageable pageable) { Specification<Order> spec = Specification .where(OrderSpecification.withMemberName(searchRequest.getMemberName())) .and(OrderSpecification.withShippingStatus(searchRequest.getShippingStatus())) .and(OrderSpecification.betweenDates(searchRequest.getStartDate(), searchRequest.getEndDate())); // ✨ 페이징까지 지원하는 findAll(spec, pageable) 메서드 사용 return orderRepository.findAll(spec, pageable) .map(OrderResponse::from); } // 🆕 복잡한 조건의 검색 예시 @Override @Transactional(readOnly = true) public List<OrderResponse> getVipOrders(String memberName) { Specification<Order> spec = Specification .where(OrderSpecification.withMemberName(memberName)) .and(OrderSpecification.isVipOrder()) .or(OrderSpecification.withMinAmount(100000)); // 10만원 이상 주문 return orderRepository.findAll(spec).stream() .map(OrderResponse::from) .collect(Collectors.toList()); } @Override public OrderResponse updateOrderState(String orderId, OrderStatusUpdateRequest request) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new IllegalArgumentException("해당 주문을 찾을 수 없습니다 ID: " + orderId)); order.updateDetails(request.getStatus()); return OrderResponse.from(order); } // 🗑️ 기존의 복잡한 private searchOrders 메서드는 이제 삭제! } 📊 리팩토링 전후 비교 ❌ 리팩토링 전: 복잡한 서비스 로직 @Service public class OrderServiceImpl { // 🔴 서비스에 복잡한 쿼리 생성 로직이 섞여 있음 private List<Order> searchOrders(OrderSearchRequest request) { CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<Order> query = cb.createQuery(Order.class); Root<Order> root = query.from(Order.class); List<Predicate> predicates = new ArrayList<>(); if (StringUtils.hasText(request.getMemberName())) { Join<Order, Member> memberJoin = root.join("member"); predicates.add(cb.like(memberJoin.get("memberName"), "%" + request.getMemberName() + "%")); } if (request.getShippingStatus() != null) { predicates.add(cb.equal(root.get("shippingStatus"), request.getShippingStatus())); } // ... 더 복잡한 로직들 query.where(predicates.toArray(new Predicate[0])); return entityManager.createQuery(query).getResultList(); } } ✅ 리팩토링 후: 깔끔한 선언적 코드 @Service public class OrderServiceImpl { // 🟢 선언적이고 명확한 의도 표현 public List<OrderResponse> getAllOrders(OrderSearchRequest searchRequest) { Specification<Order> spec = Specification .where(OrderSpecification.withMemberName(searchRequest.getMemberName())) .and(OrderSpecification.withShippingStatus(searchRequest.getShippingStatus())) .and(OrderSpecification.betweenDates(searchRequest.getStartDate(), searchRequest.getEndDate())); return orderRepository.findAll(spec).stream() .map(OrderResponse::from) .collect(Collectors.toList()); } } 💡 실무 활용 팁 🎯 복잡한 조건 조합 예시 // 🎪 실무에서 자주 사용되는 복잡한 검색 조건 public List<OrderResponse> getComplexOrderSearch(OrderSearchRequest request) { Specification<Order> spec = Specification .where(OrderSpecification.withMemberName(request.getMemberName())) .and(OrderSpecification.withShippingStatus(request.getStatus())) .and(OrderSpecification.betweenDates(request.getStartDate(), request.getEndDate())) .and(OrderSpecification.withMinAmount(request.getMinAmount())) .or(OrderSpecification.isVipOrder()) .and(Specification.not(OrderSpecification.withShippingStatus(ShippingStatus.CANCELLED))); return orderRepository.findAll(spec).stream() .map(OrderResponse::from) .collect(Collectors.toList()); } 🔧 성능 최적화 팁 // 📈 카운트 쿼리 최적화 public long getOrderCount(OrderSearchRequest request) { Specification<Order> spec = Specification .where(OrderSpecification.withMemberName(request.getMemberName())) .and(OrderSpecification.withShippingStatus(request.getStatus())); return orderRepository.count(spec); // ✨ count 전용 메서드 활용 } // 📄 페이징 성능 최적화 public Page<OrderResponse> getOrdersWithOptimizedPaging(OrderSearchRequest request, Pageable pageable) { Specification<Order> spec = Specification .where(OrderSpecification.withMemberName(request.getMemberName())); return orderRepository.findAll(spec, pageable) // ✨ 페이징 최적화된 쿼리 .map(OrderResponse::from); } 🧪 단위 테스트 작성 @Test public void testOrderSpecification() { // 🧩 각 Specification을 독립적으로 테스트 가능 Specification<Order> spec = OrderSpecification.withMemberName("홍길동"); List<Order> orders = orderRepository.findAll(spec); assertThat(orders).allMatch(order -> order.getMember().getMemberName().contains("홍길동")); } 🏆 결론 JpaSpecificationExecutor는 JpaRepository의 기본 기능에 강력한 동적 쿼리 조립 능력을 추가해주는 핵심 인터페이스입니다. 🎯 핵심 가치 🧩 레고 블록식 조합: 검색 조건들을 자유자재로 조립 📖 선언적 코드: 비즈니스 의도가 명확하게 드러나는 코드 🔄 높은 재사용성: 한 번 만든 Specification을 여러 곳에서 활용 🛡️ 관심사 분리: 복잡한 쿼리 생성 로직을 서비스에서 분리 📄 페이징 지원: 대용량 데이터 처리를 위한 효율적인 페이징 🚀 적용 효과 이처럼 Specification을 별도 클래스로 분리하면, 서비스 계층은 “어떤 조건으로 검색하는지”만 선언적으로 명시하면 되므로 코드가 훨씬 더 깔끔해지고, 각 Specification은 다른 곳에서도 재사용할 수 있게 되어 유지보수성이 크게 향상됩니다! 복잡한 검색 기능이 필요한 실무 프로젝트에서는 반드시 고려해볼 만한 강력한 도구입니다.
Backend Development
· 2025-09-06
📚[Backend Development] 🎯 백엔드 개발자를 위한 요구사항 명세서 작성 가이드
🎯 백엔드 개발자를 위한 요구사항 명세서 작성 가이드 백엔드 개발자가 단순히 코드를 작성하는 것을 넘어, 비즈니스의 성공에 기여하는 시스템을 만들기 위해 반드시 이해해야 할 핵심 개념들입니다. 요구사항 명세서의 비즈니스 규칙, 예외 상황 처리, 데이터 생명주기 정책에 대해 베스트 프랙티스와 ‘온라인 슈퍼마켓’ 프로젝트에 바로 적용할 수 있는 실무 예시를 중심으로 명확하게 설명합니다. 1. 비즈니스 규칙 (Business Rules) 개념 정의 비즈니스 규칙은 “특정 조건에서 시스템이 따라야 하는 명시적인 지침이나 제약사항”을 의미합니다. ‘무엇을’ 개발할지 정의하는 기능 요구사항과 달리, 비즈니스 규칙은 ‘어떻게’ 또는 ‘어떤 조건에서’ 기능이 동작해야 하는지를 구체적으로 정의합니다. 핵심 역할: 데이터의 무결성 보장 정책 강제 적용 비즈니스 의사결정 자동화 베스트 프랙티스 명확하고 원자적 작성 하나의 규칙에는 하나의 조건과 결과만 포함 복합 조건은 개별 규칙으로 분리 ❌ 잘못된 예시: "14세 이상이고, 마케팅 수신에 동의한 회원에게만 할인 쿠폰을 발급한다" ✅ 올바른 예시: - [BR-001] 만 14세 이상만 회원가입 가능 - [BR-002] 마케팅 수신 동의 회원에게만 쿠폰 발급 고유 ID 부여 각 규칙에 BR-001, BR-002 와 같은 고유 식별자를 부여하여: 기획서에서 코드까지 추적 가능 팀 간 소통 시 명확한 참조 테스트 케이스와 연결 중앙 집중식 관리 설정 파일(configuration) 데이터베이스 테이블 Rule Engine 도구 활용 코드 여러 곳에 분산 금지 비즈니스 언어 사용 ❌ 개발자 관점: "User 테이블의 age 컬럼 값이 14 미만이면 INSERT를 막는다" ✅ 비즈니스 관점: "만 14세 미만 사용자는 회원으로 가입할 수 없다" 실무 예시: 온라인 슈퍼마켓 프로젝트 회원 관리 규칙 [BR-SIGNUP-001] 회원가입 연령 제한 - 조건: 사용자의 나이가 만 14세 미만 - 결과: 회원가입 절차를 중단하고 "만 14세 이상만 가입 가능합니다." 메시지 반환 주문 관리 규칙 [BR-ORDER-002] 최소 주문 금액 - 조건: 장바구니 총 상품 금액(할인 적용 전)이 15,000원 미만 - 결과: 주문 진행 불가, "최소 주문 금액은 15,000원입니다." 안내 표시 프로모션 규칙 [BR-PROMO-003] 신규가입 쿠폰 중복 방지 - 조건: 신규가입 웰컴 쿠폰 발급 요청 - 결과: 계정 생성 후 1회만 발급, 타 쿠폰과 중복 사용 불가 리뷰 관리 규칙 [BR-REVIEW-004] 리뷰 작성 자격 - 조건: 상품 리뷰 작성 요청 - 결과: 해당 상품을 '구매 확정' 상태인 회원만 작성 가능 2. 예외 상황 처리 방안 (Exception Handling) 개념 정의 예외 상황 처리는 소프트웨어가 정상적인 흐름을 벗어나는 예기치 못한 문제가 발생했을 때, 시스템이 중단되지 않고 상황을 적절하게 처리하고 복구하는 메커니즘입니다. 대표적인 예외 상황: 존재하지 않는 리소스 접근 네트워크 연결 장애 외부 서비스 응답 지연 데이터 검증 실패 베스트 프랙티스 구체적인 에러 코드 정의 ❌ 일반적인 응답: 500 Internal Server Error ✅ 구체적인 응답: { "errorCode": "ITEM_OUT_OF_STOCK", "message": "요청하신 상품의 재고가 부족합니다.", "timestamp": "2024-03-15T10:30:00Z" } 사용자 친화적 메시지 시스템 내부 오류(NullPointerException 등) 노출 금지 사용자가 이해할 수 있는 명확한 안내 해결 방법 또는 대안 제시 상세한 로깅 사용자에게는 간단한 메시지, 서버 로그에는 상세 정보 기록: 사용자 식별 정보 요청 내용 (Request Body, Parameters) 발생 위치 (Stack Trace) 발생 시간 및 환경 정보 트랜잭션 안전성 부분적 실행 상태 방지 실패 시 모든 작업 롤백 데이터 일관성 유지 실무 예시: 온라인 슈퍼마켓 프로젝트 주문 프로세스 예외 처리 [EH-ORDER-001] 주문 시 재고 부족 상황: 결제 요청 순간 장바구니 상품이 품절된 경우 처리 방법: - HTTP 상태: 409 Conflict - 응답 형식: { "errorCode": "ITEM_OUT_OF_STOCK", "message": "'[상품명]'의 재고가 부족하여 주문할 수 없습니다.", "data": { "productId": "PROD_12345", "requestedQuantity": 3, "availableQuantity": 0 } } - 후속 조치: 결제 프로세스 즉시 중단, 장바구니 상태 업데이트 결제 시스템 예외 처리 [EH-PAYMENT-002] PG사 연동 실패 상황: Payment Gateway 서버 장애로 결제 승인 요청 실패 처리 방법: - 주문 상태: '결제 실패'로 기록 - 사용자 메시지: "결제 서비스 장애로 주문에 실패했습니다. 잠시 후 다시 시도해주세요." - 서버 로그: PG사 원본 에러 메시지 전체 기록 - 복구 전략: 자동 재시도 (최대 3회), 관리자 알림 발송 인증/인가 예외 처리 [EH-AUTH-003] 유효하지 않은 쿠폰 사용 상황: 이미 사용했거나 만료된 쿠폰 코드 적용 시도 처리 방법: - HTTP 상태: 400 Bad Request - 응답 형식: { "errorCode": "INVALID_COUPON", "message": "사용할 수 없는 쿠폰입니다.", "details": { "couponCode": "WELCOME2024", "reason": "ALREADY_USED", "usedDate": "2024-03-10T14:20:00Z" } } 3. 데이터 생명주기 정책 (Data Lifecycle Policy) 개념 정의 데이터 생명주기 정책은 데이터가 생성(Creation)되고, 사용(Usage)되며, 보관(Archive)되고, 최종적으로 파기(Destruction)되기까지의 전 과정을 관리하는 규정입니다. 정책 수립 이유: 법적 규제 준수 (개인정보보호법, GDPR 등) 스토리지 비용 최적화 데이터 보안 유지 비즈니스 연속성 보장 베스트 프랙티스 데이터 분류 체계 | 분류 | 설명 | 보관 기간 | 처리 방법 | |——|——|———|———-| | 개인 식별 정보 (PII) | 이름, 주소, 연락처 등 | 법정 기간 | 암호화 저장, 엄격한 접근 제어 | | 거래 정보 | 주문, 결제, 배송 기록 | 5년 (전자상거래법) | 백업 보관, 감사 로그 | | 서비스 로그 | 접속, 행동 패턴 분석 | 1년 | 통계 처리 후 익명화 | | 마케팅 데이터 | 선호도, 쿠폰 사용 이력 | 동의 철회 시까지 | 동의 범위 내 활용 | 명확한 보관 기간 ❌ 모호한 정책: "오래된 데이터는 삭제한다" ✅ 명확한 정책: "주문 데이터는 전자상거래법 제6조에 따라 5년간 보관 후 파기한다" 파기 방법 정의 Soft Delete: 논리적 삭제 (복구 가능) Hard Delete: 물리적 삭제 (복구 불가능) 암호화 키 파기: 데이터 접근 불가능하게 만듦 휴면 정책 수립 정보통신망법상 ‘개인정보 유효기간제’ 준수: 1년 이상 미접속 시 휴면 전환 사전 통지 (30일 전) 별도 분리 보관 실무 예시: 온라인 슈퍼마켓 프로젝트 회원 데이터 관리 [DLP-USER-001] 휴면 회원 처리 정책 대상: 최종 로그인 후 1년 이상 접속 기록이 없는 회원 프로세스: 1. 사전 통지: 전환 30일 전 이메일 발송 2. 휴면 전환: 개인정보를 별도 분리된 DB 테이블로 이관 3. 데이터 처리: - 이름, 주소, 연락처 → 암호화 후 분리 보관 - 주문 이력 → 개인정보 제거 후 통계 목적으로 보관 4. 복구 절차: 본인인증 완료 시 휴면 해제 및 데이터 복원 회원 탈퇴 처리 [DLP-USER-002] 회원 탈퇴 시 데이터 처리 즉시 파기 대상: - 개인 식별 정보 (이름, 주소, 연락처) - 마케팅 관련 데이터 (선호도, 쿠폰 이력) 보관 대상 (5년간): - 주문/결제 기록 (전자상거래법 준수) - 분쟁 해결 관련 데이터 특별 처리: - 이메일/ID: 재가입 방지를 위해 해시화하여 보관 - 리뷰 데이터: "탈퇴한 회원"으로 익명화 처리 비회원 데이터 관리 [DLP-CART-003] 비회원 장바구니 데이터 보관 기간: 마지막 활동 후 24시간 자동 삭제 조건: - 세션 종료 시 - 24시간 비활성 상태 - 브라우저 쿠키 만료 시 데이터 처리: - 개인정보 수집 최소화 - 암호화되지 않은 평문 저장 금지 - 정기적 배치 작업으로 만료 데이터 정리 적용 체크리스트 비즈니스 규칙 검증 각 규칙이 명확하고 원자적으로 정의되었는가? 고유 ID가 부여되어 추적 가능한가? 비즈니스 언어로 작성되어 이해하기 쉬운가? 중앙에서 관리 가능한 구조인가? 예외 처리 검증 구체적인 에러 코드가 정의되었는가? 사용자 친화적인 메시지를 제공하는가? 상세한 로그가 기록되는가? 트랜잭션 안전성이 보장되는가? 데이터 생명주기 검증 데이터가 민감도별로 분류되었는가? 법적 근거에 기반한 보관 기간이 명시되었는가? 파기 방법이 구체적으로 정의되었는가? 휴면 정책이 수립되었는가? 결론 성공적인 백엔드 시스템 구축을 위해서는 기능 구현뿐만 아니라 비즈니스 규칙, 예외 처리, 데이터 관리 정책을 체계적으로 설계해야 합니다. 이러한 요소들이 제대로 정의되고 구현되었을 때, 안정적이고 확장 가능하며 법적 요구사항을 만족하는 시스템을 만들 수 있습니다.
Backend Development
· 2025-09-06
📚[Backend Development] 🏗️ Spring Data JPA Specification
🏗️ Spring Data JPA Specification Spring Data JPA Specification은 동적 쿼리를 매우 우아하고 객체지향적으로 처리할 수 있는 강력한 도구입니다. 🤔 Spring Data JPA Specification이란? JPA Specification(사양) 이란, 여러 검색 조건을 ‘객체’처럼 조립하여 동적 쿼리를 생성하게 해주는 프로그래밍 인터페이스(API) 입니다. ✨ 핵심 특징 복잡한 if-else문이나 문자열 쿼리 조합 없음 타입에 안전한(Type-safe) 코드로 검색 조건 처리 필요한 검색 조건들을 마치 레고 블록처럼 붙였다 뗐다 가능 내부적으로는 JPA의 Criteria API를 사용하여 동작 🍕 직관적 비유: 맞춤형 피자 주문 Specification은 마치 피자 가게에서 토핑을 자유롭게 추가하고 빼는 것과 같습니다. ❌ 전통적인 방식 (Repository 메서드 폭발) // 모든 경우의 수를 메서드로 만들어야 함 List<Order> findByMemberName(String memberName); List<Order> findByShippingStatus(ShippingStatus status); List<Order> findByMemberNameAndShippingStatus(String memberName, ShippingStatus status); List<Order> findByMemberNameAndShippingStatusAndCreatedAtBetween(...); // 🔴 조합이 늘어날수록 메서드가 기하급수적으로 증가! 문제점: “페퍼로니는 빼고 올리브와 버섯을 추가해주세요” 같은 복잡한 요청은 메뉴에 없으면 처리하기 힘듭니다. ✅ Specification 방식 (동적 조합) // 검색 조건들을 레고 블록처럼 자유롭게 조합 Specification<Order> spec = Specification .where(OrderSpecification.withMemberName(memberName)) // 🧩 토핑 1 .and(OrderSpecification.withShippingStatus(status)) // 🧩 토핑 2 .and(OrderSpecification.betweenDates(startDate, endDate)); // 🧩 토핑 3 List<Order> orders = orderRepository.findAll(spec); // 🍕 맞춤형 피자 완성! 장점: 손님(서비스 계층)이 토핑(Specification 객체)들을 고르면, 주방장(JPA)이 즉석에서 맞춤형 피자(동적 쿼리)를 만들어 줍니다. 🚀 베스트 프랙티스: 실무 예제 코드 가장 효과적인 Specification 사용법은 “재사용 가능한 Specification들을 별도의 클래스로 분리하여 관리하는 것” 입니다. 📁 Step 1: Specification 클래스 생성 (Best Practice) 검색 조건들을 정의하는 OrderSpecification 클래스를 새로 만듭니다. repository/specification/OrderSpecification.java (신규 파일) package com.kobe.productmanagement.repository.specification; import com.kobe.productmanagement.common.ShippingStatus; import com.kobe.productmanagement.domain.Member; import com.kobe.productmanagement.domain.Order; import jakarta.persistence.criteria.Join; import org.springframework.data.jpa.domain.Specification; import org.springframework.util.StringUtils; import java.time.LocalDate; import java.time.LocalTime; public class OrderSpecification { // 🔍 회원 이름으로 검색하는 Specification public static Specification<Order> withMemberName(String memberName) { return (root, query, criteriaBuilder) -> { // 🟢 null/빈 문자열 체크로 안전성 확보 if (!StringUtils.hasText(memberName)) { return null; // 조건이 없으면 무시됨 } Join<Order, Member> memberJoin = root.join("member"); return criteriaBuilder.like(memberJoin.get("memberName"), "%" + memberName + "%"); }; } // 📦 배송 상태로 검색하는 Specification public static Specification<Order> withShippingStatus(ShippingStatus status) { return (root, query, criteriaBuilder) -> { if (status == null) { return null; } return criteriaBuilder.equal(root.get("shippingStatus"), status); }; } // 📅 주문 날짜 범위로 검색하는 Specification public static Specification<Order> betweenDates(LocalDate startDate, LocalDate endDate) { return (root, query, criteriaBuilder) -> { if (startDate == null || endDate == null) { return null; } return criteriaBuilder.between( root.get("createdAt"), startDate.atStartOfDay(), endDate.atTime(LocalTime.MAX) ); }; } } 🔧 Step 2: Service에서 Specification 조합하여 사용 이제 OrderServiceImpl에서는 새로 만든 OrderSpecification의 메서드들을 조합하여 훨씬 더 깔끔하게 쿼리를 실행할 수 있습니다. service/OrderServiceImpl.java (리팩토링) package com.kobe.productmanagement.service; import com.kobe.productmanagement.domain.Order; import com.kobe.productmanagement.dto.request.OrderSearchRequest; import com.kobe.productmanagement.dto.request.OrderStatusUpdateRequest; import com.kobe.productmanagement.dto.response.OrderResponse; import com.kobe.productmanagement.repository.OrderRepository; import com.kobe.productmanagement.repository.specification.OrderSpecification; // 🟢 신규 import import lombok.RequiredArgsConstructor; import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.stream.Collectors; @Service @RequiredArgsConstructor @Transactional public class OrderServiceImpl implements OrderService { private final OrderRepository orderRepository; @Override @Transactional(readOnly = true) public List<OrderResponse> getAllOrders(OrderSearchRequest searchRequest) { // 🧩 Specification들을 레고 블록처럼 체인 형태로 조합 Specification<Order> spec = Specification .where(OrderSpecification.withMemberName(searchRequest.getMemberName())) .and(OrderSpecification.withShippingStatus(searchRequest.getShippingStatus())) .and(OrderSpecification.betweenDates(searchRequest.getStartDate(), searchRequest.getEndDate())); return orderRepository.findAll(spec).stream() .map(OrderResponse::from) .collect(Collectors.toList()); } @Override public OrderResponse updateOrderState(String orderId, OrderStatusUpdateRequest request) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new IllegalArgumentException("해당 주문을 찾을 수 없습니다 ID: " + orderId)); order.updateDetails(request.getStatus()); return OrderResponse.from(order); } // 🗑️ 기존의 복잡한 private searchOrders 메서드는 이제 삭제! } 🎯 이 방식의 핵심 장점 1. 🔄 재사용성 (Reusability) // 다른 서비스에서도 동일한 Specification 재사용 가능 public class AdminService { public List<Order> getVipOrders() { return orderRepository.findAll( OrderSpecification.withShippingStatus(ShippingStatus.DELIVERED) .and(OrderSpecification.withMemberName("VIP")) ); } } 2. 📖 가독성 (Readability) // ❌ 기존: 복잡한 쿼리 생성 로직이 서비스에 섞임 if (memberName != null) { // 복잡한 Criteria API 코드... } if (status != null) { // 또 다른 복잡한 코드... } // ✅ 개선: 선언적이고 명확한 의도 표현 Specification<Order> spec = Specification .where(OrderSpecification.withMemberName(memberName)) .and(OrderSpecification.withShippingStatus(status)); 3. 🧩 조합의 유연성 (Flexibility) // 🎪 복잡한 조건도 자유자재로 조합 가능 Specification<Order> complexSpec = Specification .where(OrderSpecification.withMemberName("김철수")) .and(OrderSpecification.withShippingStatus(ShippingStatus.SHIPPED)) .or(OrderSpecification.betweenDates(startDate, endDate)) .and(Specification.not(OrderSpecification.withShippingStatus(ShippingStatus.CANCELLED))); 📊 전통적 방식 vs Specification 비교 구분 전통적 방식 Specification 방식 메서드 수 ❌ 조합별로 메서드 폭발 ✅ 재사용 가능한 조각들 유지보수 ❌ 조건 추가 시 메서드 증가 ✅ Specification만 추가 가독성 ❌ 복잡한 쿼리 생성 로직 ✅ 선언적이고 명확한 의도 타입 안정성 ❌ 문자열 기반 쿼리 위험 ✅ 컴파일 타임 체크 테스트 ❌ 각 메서드별 개별 테스트 ✅ Specification 단위 테스트 💡 실무 팁 🎨 네이밍 컨벤션 // ✅ 좋은 네이밍: 의도가 명확함 OrderSpecification.withMemberName() OrderSpecification.betweenDates() OrderSpecification.hasShippingStatus() // ❌ 나쁜 네이밍: 의도가 불분명 OrderSpecification.memberName() OrderSpecification.dates() OrderSpecification.status() 🔍 null 체크는 필수 // 🟢 항상 null 체크를 통해 안전성 확보 public static Specification<Order> withMemberName(String memberName) { return (root, query, criteriaBuilder) -> { if (!StringUtils.hasText(memberName)) { return null; // null 반환 시 해당 조건은 무시됨 } // 실제 조건 로직... }; } 🎪 복잡한 조건 조합 예시 // 실무에서 자주 사용되는 복잡한 검색 조건 Specification<Order> spec = Specification .where(OrderSpecification.withMemberName(searchRequest.getMemberName())) .and(OrderSpecification.withShippingStatus(searchRequest.getStatus())) .and(OrderSpecification.betweenDates(searchRequest.getStartDate(), searchRequest.getEndDate())) .and(OrderSpecification.withMinAmount(searchRequest.getMinAmount())) .or(OrderSpecification.isVipOrder()); 🏆 결론 Spring Data JPA Specification은 복잡한 동적 쿼리를 우아하고 객체지향적으로 처리할 수 있는 최고의 솔루션 중 하나입니다. 🎯 핵심 가치 🧩 레고 블록식 조합: 필요한 검색 조건들을 자유자재로 조립 🛡️ 타입 안정성: 컴파일 타임에 오류 발견 가능 📖 선언적 코드: 비즈니스 의도가 명확하게 드러나는 코드 🔄 높은 재사용성: 한 번 만든 Specification을 여러 곳에서 활용 복잡한 검색 기능이 필요한 실무 프로젝트에서는 반드시 고려해볼 만한 강력한 도구입니다!
Backend Development
· 2025-09-05
📚[Backend Development] 🗺️ 핵심 설계도의 작성 순서와 실질적인 작업 흐름
🗺️ 핵심 설계도의 작성 순서와 실질적인 작업 흐름. 🚀 결론부터 말하자면, 엄격한 폭포수 방식(Waterfall Methodology)이 아닌, 서로 영향을 주고받으며 점진적으로 완성해 나가는 것이 일반적 입니다. 하지만 개념적인 순서는 명확하게 존재하며, 그 순서를 이해하는 것은 매우 중요합니다. 📌 이상적인 작성 순서 (개념적 흐름) 마치 집을 짓는 과정처럼, 가장 추상적이고 근본적인 것에서부터 구체적인 것으로 나아갑니다. 1️⃣ 요구사항 명세서 (무엇을 만들 것인가?) 🤔 가장 먼저 시작이되어야 합니다. 이유 : 어떤 기능을 만들지, 어떤 비즈니스 규칙을 담을지 결정되지 않으면, 기술적인 설계(아키텍처)나 데이터구조(DB)를 논하는 것 자체가 불가능합니다. “고객이 원하는 집”이 어떤 모습인지(방 개수, 층수, 스타일 등)를 먼저 정하는 단계입니다. 산출물 : 기능 목록, 비즈니스 규칙, 정책, 제약 조건 등 2️⃣ 시스템 아키텍처 설계서 (어떻게 구조를 잡을 것인가?) 🏗️ 요구사항이 어느 정도 윤곽을 드러내면 시작됩니다. 이유 : 정의된 요구사항을 안정적이고 효율적으로 만족시키기 위해 어떤 기술을 사용하고, 시스템을 어떤 큰 덩어리(모듈, 컴포넌트)로 나눌지 결정해야 합니다. “고객이 원하는 집”을 짓기 위해 어떤 공법(목조, 철근 콘크리트)을 사용할지, 전기/수도 시스템은 어떻게 배치할지 큰 그림을 그리는 단계입니다. 산출물 : 기술 스택(Tech Stack), 시스템 구성도, 외부 시스템 연동 방식, 핵심 컴포넌트 설계 3️⃣ 데이터베이스 설계서 (무엇을 저장할 것인가?) 💾 요구사항과 아키텍처가 구체화되면서 함께 진행됩니다. 이유 : 요구사항에서 필요한 데이터(회원, 상품, 주문)가 무엇인지 식별하고, 아키텍처에서 선택한 데이터베이스 기술(RDBMS, NoSQL 등)에 맞춰 데이터를 어떻게 구조화하여 저장할지 설계합니다. “집의 각 공간”에 어떤 가구를 배치하고 수납공간을 어떻게 만들지 구체적으로 설계하는 단계입니다. 산출물 : ERD(Entity-Relationship Diagram), 테이블 명세, 인덱스 설계 🛠️ 실무에서의 현실적인 작업 흐름 - 반복적인 작업 (Iteractive Process) 실제 프로젝트에서는 이 세 가지 설계가 퍼즐을 끼워 맞추듯이 순서대로 진행되지 않습니다. 서로가 서로에게 영향을 미치며 함께 발전해 나가는 반복적인(Iteractive) 과정을 거칩니다. 1. (요구사항) : “실시간 인기 상품 추천 기능”이라는 요구사항이 나옵니다. 2. (아키텍처) : “이 기능을 구현하려면 대용량 데이터를 빠르게 처리해야 하니, 기존 RDBMS 외에 Redis 같은 인메모리 데이터베이스를 도입하자”라고 아키텍처 결정에 영향을 줍니다. 3. (데이터베이스) : Redis를 사용하기로 했으므로, “실시간 인기(랭킹) 데이터를 어떤 구조(예: Sorted Set)로 저장할지” 구체적인 데이터 설계를 진행합니다. 4. (다시 요구사항) : 설계를 하다 보니, “추천 상품을 사용자 그룹별로 다르게 보여주는 것”이 기술적으로 가능하다는 것을 발견하고, 이를 원래의 요구사항에 역으로 제안하여 기능을 더 풍부하게 만들 수도 있습니다. 🙋♂️ 이처럼 세 설계서는 톱니바귀처럼 맞물려 돌아가며, 프로젝트가 진행됨에 따라 함께 구체화되고 상세해집니다. ⭐️ Important Advice. 각 설계 단계를 “완벽” 하게 끝내고 다음으로 넘어가려는 생각보다는, 핵심적인 내용을 먼저 정의하고 개발을 진행하면서 필요한 부분을 계속해서 구체화하고 개선해 나가는 애자일(Agile)한 접근 방식이 현대 소프트웨어 개발 환경에 더 적합합니다. 🥴 Made By Team WAN / BMC Crew
Backend Development
· 2025-09-02
📚[Backend Development] 🗺️ 핵심 설계도의 작성과 작업 흐름.
🗺️ 핵심 설계도의 작성과 작업 흐름. 실제 진행 중인 프로젝트를 기반으로 하여 애자일(Agile)한 접근 방식으로 세 가지 설계를 해보도록 하겠습니다. 처음부터 모든 기능을 완벽하게 설계하는 대신, 가장 핵심적인 1단계(Iteration 1) 를 먼저 정의하고, 이를 바탕으로 점진적으로 확장해 나가는 방식으로 설계하겠습니다. 📌 1단계 (Iteration 1): 핵심 기능 - 주문 목록 조회 및 상태 변경 가장 먼저 필요한 최소 기능은 “들어온 주문을 확인하고, 배송 상태를 ‘결제완료’에서 ‘배송중’으로 바꾸는 것” 입니다. 요구사항 (MVP) : 관리자는 시스템에 들어온 모든 주문의 목록을 최신순으로 조회할 수 있다. 관리자는 특정 주문의 배송 상태를 변경할 수 있다. (예: 결제완료 -> 배송중) 시스템 아키텍처 (단순하고 명확하게) : 새로운 Controller/Service 추가 : 기존 아키텍처를 그대로 활용하여 OrderAdminController와 OrderService를 새로 추가합니다. 복잡한 외부 시스템 연동 없이, 내부적으로 처리 가능한 간단한 CRUD 구조로 시작합니다. API Endpoints 정의 : GET /api/admin/orders : 전체 주문 목록 조회 PATCH /api/admin/orders/{orderId}/status : 특정 주문의 상태 변경 데이터베이스 설계 (핵심 데이터부터) : 새로운 ORDERS 와 ORDER_ITEM 두 개의 테이블을 설계합니다. ORDERS 테이블 : 회원(MEMBER) 이 언제 주문했는지, 현재 배송 상태는 무엇인지를 저장합니다. ORDER_ITEM 테이블 : 해당 주문에 어떤 상품(PRODUCT) 이 몇 개, 얼마에 포함되었는지를 저장합니다.(ORDER와 PRODUCT의 M:N 관계를 해소하는 중간 테이블) 📌 2단계 (Iteration 2): 기능 확장 - 검색 및 상세 조회 핵심 기능이 완성되면, 이제 관리의 편의성을 높이는 기능을 추가합니다. 요구사항 (기능 확장) : 주문 목록에서 특정 조건(주문 상태, 회원 이름, 주문 날짜 등)으로 검색하는 기능을 추가합니다. 주문 목록의 각 항목을 클릭하면, 해당 주문에 포함된 상품 목록과 배송지 정보 등 상세 내역을 조회하는 기능을 추가합니다. 시스템 아키텍처 (기존 구조 강화) : GET /api/admin/orders API가 Query Parameter 를 받을 수 있도록 확장합니다. 예: ?status=SHIPPING&memberName=홍길동 Service 계층에 JPA의 Specification 이나 QueryDSL을 도입하여 동적 쿼리 생성 로직을 추가합니다. 데이터베이스 설계 (성능 최적화) : 검색 조건으로 자주 사용될 ORDERS 테이블의 status, order_date 컬럼에 인덱스(Index) 를 추가하여 조회 성능을 향상시킵니다. 📋 주문 관리 요구사항 명세서. 위의 애자일 설계를 바탕으로 1단계(Iteration 1) MVP에 해당하는 요구사항 명세서를 작성해보겠습니다. [요구사항 명세서] UC-002: 관리자 주문 관리 설명: 관리자는 고객이 주문한 내역을 확인하고, 배송 상태를 관리하여 원활한 상품 배송 프로세스를 지원합니다. 1. 기능 요구사항 (Features) OM-01: 주문 목록 조회 기본 규칙: 관리자는 전체 주문 목록을 조회할 수 있습니다. 목록은 가장 최근에 주문된 순서(내림차순)로 정렬되어야 합니다. 각 목록 항목에는 주문 ID, 주문자 이름, 주문일시, 총 주문 금액, 주문 상태가 표시되어야 합니다. 예외 처리: 조회할 주문이 하나도 없을 경우, 빈 목록과 함께 “주문 내역이 없습니다.”라는 메시지를 반환합니다. OM-02: 주문 배송 상태 변경 기본 규칙: 관리자는 특정 주문의 배송 상태를 변경할 수 있습니다. 변경 가능한 상태 흐름은 다음과 같습니다: 결제완료 ➞ 배송준비중 ➞ 배송중 ➞ 배송완료 상태 변경이 성공하면, 변경된 주문의 상세 정보를 즉시 반환합니다. 예외 처리: 존재하지 않는 주문 ID로 요청 시, “존재하지 않는 주문입니다.”라는 오류 메시지를 반환합니다.(HTTP 404 Not Found) 이미 배송완료 또는 주문취소 된 주문의 상태를 변경하려고 할 경우, “이미 처리 완료된 주문은 상태를 변경할 수 없습니다.”라는 오류 메시지를 반환합니다.(HTTP 400 Bad Request) 유효하지 않은 상태 값(예: SHIPPED 대신 SHIPPING)으로 요청 시, “유효하지 않은 주문 상태입니다.”라는 오류 메시지를 반환합니다.(HTTP 400 Bad Request) 2. 데이터 정책 모든 주문 상태의 변경 이력(언제, 어떤 상태에서 어떤 상태로 변경되었는지)은 별도의 로그 테이블에 기록되어야 합니다.(향후 확장을 위한 정책) 주문 데이터는 고객의 재주문 및 통계 분석을 위해 영구적으로 보관하는 것을 원칙으로 합니다.
Backend Development
· 2025-09-02
📚[Backend Development] 🏗️ 애자일 방법론 & MVP 학습 가이드
🏗️ 애자일 방법론 & MVP 학습 가이드. 📚 학습 목표 이 가이드를 통해 다음을 이해할 수 있습니다: 애자일 방법론의 핵심 철학과 원칙 스크럼과 칸반의 차이점과 활용법 애자일 설계 방식의 특징 MVP(최소기능제품)의 개념과 실제 적용법 1️⃣ 애자일이란 무엇인가? 정의 애자일은 소프트웨어를 개발하는 방법론이자 철학으로, “민첩하고 유연하게” 개발하는 것을 목표로 합니다. 핵심 철학: “함께, 자주, 짧게” 🤝 전통적인 폭포수(Waterfall) 방식의 문제점 거대한 계획을 한 번에 세워 몇 달/몇 년간 개발 중간에 요구사항이 바뀌면 대응하기 어려움 완성 후 문제 발견 시 수정 비용이 매우 큼 애자일의 해결책 비유: 케이크를 한 번에 다 먹지 않고, 작게 나누어 한 조각씩 맛보며 개선해나가는 방식 3가지 핵심 원칙: 함께 (Collaboration) 기획자, 개발자, 디자이너 등 모든 팀원의 긴밀한 소통 처음부터 끝까지 협력하여 제품 개발 자주 (Iteration) 1-4주의 짧은 개발 주기 반복 이 주기를 스프린트(Sprint) 또는 이터레이션(Iteration)이라 함 짧게 (Increment) 매 스프린트마다 실제 동작하는 작은 기능 완성 지속적인 피드백 수집 및 개선 2️⃣ 주요 애자일 방법론 스크럼(Scrum) 🏈 특징 럭비에서 유래한 용어 정해진 주기마다 팀이 목표를 설정하고 협력하여 개발 핵심 구성요소 스프린트: 1-4주의 짧은 개발 기간 데일리 스크럼: 매일 아침 짧은 공유 회의 어제 한 일 오늘 할 일 발생한 문제점 스프린트 회고: 스프린트 종료 후 개선점 논의 칸반(Kanban) 📋 특징 작업 흐름을 시각적 보드로 관리 작업량 제한을 통한 병목 현상 방지 주요 구성 To-Do: 해야 할 일 In Progress: 진행 중 Done: 완료 3️⃣ 애자일 설계 방식 전통적 설계 vs 애자일 설계 구분 전통적 설계 (BDUF) 애자일 설계 철학 “설계는 코딩 전에 완벽해야 한다” “설계는 진화하는 유기체” 접근법 완벽한 최종 설계도 먼저 완성 최소한의 설계로 시작하여 점진적 발전 변경 대응 어려움, 비용 많이 듦 유연함, 지속적 개선 비유 건물 설계도 작은 오두막에서 시작해 확장하는 방식 애자일 설계의 3가지 핵심 원칙 1. JEDU (Just Enough Design Upfront) “딱 필요한 만큼의 초기 설계” 프로젝트 방향을 잃지 않을 정도의 최소한 아키텍처만 설계 예시: 주문 관리 기능 개발 시 ✅ 초기: Order 엔티티, Controller/Service 구조만 설계 ❌ 미리 환불, 취소, 배송 추적 등까지 설계하지 않음 2. YAGNI (You Ain’t Gonna Need It) “그 기능, 지금 당장 필요 없잖아?” 미래 확장성을 위한 예측 설계 금지 현재 요구사항을 해결하는 가장 단순한 방법 선택 불필요한 복잡성 제거 3. 리팩토링을 통한 지속적 개선 “설계는 코드를 통해 숨쉬고 발전한다” 새 기능 추가나 변경 시 기존 구조 개선 현재 요구사항에 최적화된 상태 유지 점진적 진화를 통한 설계 발전 4️⃣ MVP (Minimum Viable Product) 정의 최소 기능 제품 - 핵심 아이디어를 검증할 수 있는 가장 최소한의 기능만 담은 초기 버전 MVP의 진짜 목적: 학습 💡 ❌ 잘못된 이해 미완성된 1차 버전 대충 만든 프로토타입 ✅ 올바른 이해 가설 검증: “고객들이 이 제품을 정말 원할까?” 학습 도구: 가장 적은 노력으로 시장 반응 확인 위험 감소: 큰 투자 전 아이디어 검증 실제 예시: 온라인 슈퍼마켓 최종 목표 멋진 추천 기능 + 간편 결제 + 빠른 배송을 갖춘 완벽한 서비스 잘못된 MVP ❌ 상품 목록 페이지만 대충 만들어 보여주기 → 핵심 가치인 ‘온라인 주문’을 경험할 수 없음 올바른 MVP ✅ 핵심 기능: 상품 조회 → 장바구니 담기 → 주문 완료 최소화된 부분: 결제: 무통장 입금만 지원 디자인: 투박해도 괜찮음 제외 기능: 추천, 리뷰 기능 과감히 제거 MVP 구성 요소 Minimum (최소한의) 핵심 가치 전달에 꼭 필요한 기능만 나머지 기능은 모두 제외 Viable (실행 가능한) 최소 기능이지만 처음부터 끝까지 완전히 작동 사용자가 가치를 경험할 수 있는 높은 완성도 필요 💡 핵심 메시지 애자일의 본질 “완벽한 계획이 아니라 빠른 실행과 적응” 애자일 설계의 핵심 “불확실성을 두려워하지 않고, 변화를 성장의 기회로 삼는 설계 방식” MVP의 핵심 “가장 저렴하게 실패하고, 가장 빠르게 배우기 위한 현명한 전략” 🎯 학습 점검 질문 애자일의 3가지 핵심 원칙 “함께, 자주, 짧게”를 설명할 수 있나요? 스크럼과 칸반의 차이점은 무엇인가요? YAGNI 원칙이 왜 중요한지 예시와 함께 설명할 수 있나요? MVP와 단순한 프로토타입의 차이점은 무엇인가요? 본인의 프로젝트에 애자일 방식을 어떻게 적용할 수 있을까요? 📖 추가 학습 방향 실무 적용: 현재 진행 중인 프로젝트에 스프린트 방식 도입 도구 활용: Jira, Trello 등 애자일 도구 사용법 학습 심화 학습: 테스트 주도 개발(TDD), 지속적 통합(CI/CD) 연계 팀 협업: 데일리 스크럼, 회고 미팅 진행 방법 연습 Made by 🚀 TEAM WAN / BMC CREW 🚀
Backend Development
· 2025-09-02
📚[Backend Development] 🚀 백엔드 개발자 핵심 참고 문서 가이드
🚀 백엔드 개발자 핵심 참고 문서 가이드 “API 명세서는 ‘무엇을’ 주고받을지에 대한 약속이라면, 백엔드 개발자는 그 약속을 지키기 위해 ‘어떻게’ 동작해야 하는지에 대한 구체적인 설계도와 지침서를 참고하여 내부 로직을 구현합니다.” 백엔드 개발자가 핵심적으로 참고하는 것은 크게 세 가지입니다. 1. 요구사항 명세서 (기획서, User Story) 2. 데이터베이스 설계서 (ERD, 스키마 정의) 3. 시스템 아키텍처 설계서 🎯 개요: 백엔드 개발의 삼각형 📐 백엔드 개발 참고 문서의 관계도 // API 명세서 (외부 계약) @RestController @RequestMapping("/api/users") public class UserController { @PostMapping public ResponseEntity<UserResponse> createUser(@RequestBody UserCreateRequest request) { // 📝 요구사항 명세서 → 비즈니스 로직 구현 // 💾 데이터베이스 설계서 → 데이터 저장/조회 구조 // 🏛️ 시스템 아키텍처 → 외부 시스템 연동 방식 UserResponse response = userService.createUser(request); return ResponseEntity.ok(response); } } 핵심 이해: API 명세서는 겉모습(Interface) 이고, 나머지 세 문서는 내부 구현(Implementation) 을 위한 설계도입니다. 📝 1. 요구사항 명세서 (기획서, User Story) “가장 중요하고 근본적인 참고 자료 - 비즈니스 로직의 모든 것이 담긴 보물지도” ❌ 요구사항 무시한 잘못된 구현 // 요구사항을 제대로 파악하지 않고 단순하게 구현한 예 @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; // 너무 단순한 구현 - 비즈니스 로직 누락 @Transactional public Order createOrder(OrderCreateRequest request) { Order order = Order.builder() .userId(request.getUserId()) .productId(request.getProductId()) .quantity(request.getQuantity()) .status(OrderStatus.CONFIRMED) // 무조건 확정? 문제! .build(); return orderRepository.save(order); } } 🔥 문제점들: 비즈니스 규칙 누락: 재고 확인, 결제 처리, 배송비 계산 등 예외 상황 미처리: 품절, 미성년자 주문, 시간 제한 등 데이터 정합성 부족: 주문 상태 관리, 이력 추적 등 ✅ 요구사항 기반 올바른 구현 // 실제 요구사항 명세서 예시 /** * [요구사항 명세서 발췌] * * UC-001: 상품 주문 처리 * * 1. 기본 규칙: * - 오후 10시 이후 주문은 다음 날 아침 배송으로 처리 * - VIP 등급 회원은 상품 가격의 5% 추가 할인 * - 재고가 부족할 경우 주문을 대기 상태로 변경 * * 2. 예외 처리: * - 미성년자는 주류 주문 불가 (주문 반려 + 안내 메시지) * - 1회 주문 최대 수량: 10개 * - 품절 상품은 주문 불가 * * 3. 데이터 정책: * - 주문 취소 시에도 이력은 7년간 보관 * - 개인정보는 회원 탈퇴 시 즉시 파기, 주문 내역은 5년 보관 */ @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final ProductService productService; private final UserService userService; private final InventoryService inventoryService; private final PaymentService paymentService; private final DiscountCalculator discountCalculator; private final DeliveryScheduler deliveryScheduler; private final NotificationService notificationService; @Transactional public OrderResult createOrder(OrderCreateRequest request) { // 1. 사용자 검증 User user = userService.findById(request.getUserId()); validateUserOrderPermission(user, request); // 2. 상품 검증 Product product = productService.findById(request.getProductId()); validateProductOrderable(product, request); // 3. 재고 확인 및 예약 InventoryReservation reservation = inventoryService.reserveStock( request.getProductId(), request.getQuantity() ); if (!reservation.isSuccess()) { // 재고 부족 시 대기 상태로 처리 return handleOutOfStock(request, user); } try { // 4. 할인 계산 (VIP 5% 추가 할인 등) DiscountResult discount = discountCalculator.calculate(product, user, request.getQuantity()); // 5. 배송 일정 결정 (오후 10시 이후는 다음날 배송) DeliverySchedule schedule = deliveryScheduler.scheduleDelivery(LocalDateTime.now()); // 6. 주문 생성 Order order = Order.builder() .userId(user.getId()) .productId(product.getId()) .quantity(request.getQuantity()) .originalPrice(product.getPrice() * request.getQuantity()) .discountAmount(discount.getAmount()) .finalPrice(discount.getFinalPrice()) .status(OrderStatus.PENDING_PAYMENT) .expectedDeliveryDate(schedule.getDeliveryDate()) .reservationId(reservation.getId()) .build(); Order savedOrder = orderRepository.save(order); // 7. 결제 처리 PaymentResult payment = paymentService.processPayment( PaymentRequest.builder() .orderId(savedOrder.getId()) .amount(discount.getFinalPrice()) .paymentMethod(request.getPaymentMethod()) .build() ); if (payment.isSuccess()) { savedOrder.confirmPayment(payment.getTransactionId()); orderRepository.save(savedOrder); // 8. 후속 처리 inventoryService.confirmReservation(reservation.getId()); notificationService.sendOrderConfirmation(user, savedOrder); return OrderResult.success(savedOrder); } else { // 결제 실패 시 재고 예약 해제 inventoryService.releaseReservation(reservation.getId()); return OrderResult.paymentFailed(payment.getErrorMessage()); } } catch (Exception e) { // 예외 발생 시 재고 예약 해제 inventoryService.releaseReservation(reservation.getId()); throw new OrderProcessingException("주문 처리 중 오류가 발생했습니다", e); } } // 요구사항: 미성년자 주류 주문 검증 private void validateUserOrderPermission(User user, OrderCreateRequest request) { Product product = productService.findById(request.getProductId()); if (product.isAlcohol() && user.isMinor()) { throw new OrderNotAllowedException( "미성년자는 주류를 주문할 수 없습니다", OrderErrorCode.MINOR_ALCOHOL_ORDER ); } if (request.getQuantity() > 10) { throw new OrderNotAllowedException( "1회 주문 최대 수량은 10개입니다", OrderErrorCode.EXCEED_MAX_QUANTITY ); } } // 요구사항: 품절 상품 주문 불가 private void validateProductOrderable(Product product, OrderCreateRequest request) { if (product.isOutOfStock()) { throw new ProductNotOrderableException( "품절된 상품입니다", ProductErrorCode.OUT_OF_STOCK ); } if (!product.isActive()) { throw new ProductNotOrderableException( "판매가 중단된 상품입니다", ProductErrorCode.INACTIVE_PRODUCT ); } } // 요구사항: 재고 부족 시 대기 상태 처리 private OrderResult handleOutOfStock(OrderCreateRequest request, User user) { WaitingOrder waitingOrder = WaitingOrder.builder() .userId(request.getUserId()) .productId(request.getProductId()) .quantity(request.getQuantity()) .status(WaitingStatus.WAITING_FOR_STOCK) .createdAt(LocalDateTime.now()) .build(); waitingOrderRepository.save(waitingOrder); // 재고 알림 신청 notificationService.subscribeStockNotification(user.getEmail(), request.getProductId()); return OrderResult.waitingForStock(waitingOrder); } } // VIP 5% 추가 할인 정책 구현 @Component public class DiscountCalculator { public DiscountResult calculate(Product product, User user, int quantity) { int originalPrice = product.getPrice() * quantity; int discountAmount = 0; // 기본 할인 if (originalPrice >= 50000) { discountAmount += (int) (originalPrice * 0.1); // 10% 기본 할인 } // VIP 추가 할인 (요구사항 반영) if (user.isVip()) { discountAmount += (int) (originalPrice * 0.05); // 5% 추가 할인 } // 수량 할인 if (quantity >= 5) { discountAmount += (int) (originalPrice * 0.03); // 3% 수량 할인 } int finalPrice = originalPrice - discountAmount; return DiscountResult.builder() .originalPrice(originalPrice) .discountAmount(discountAmount) .finalPrice(finalPrice) .appliedRules(buildAppliedRules(user, quantity, originalPrice)) .build(); } } // 오후 10시 이후 다음날 배송 규칙 구현 @Component public class DeliveryScheduler { private static final int CUTOFF_HOUR = 22; // 오후 10시 public DeliverySchedule scheduleDelivery(LocalDateTime orderTime) { LocalDate deliveryDate; // 요구사항: 오후 10시 이후는 다음날 배송 if (orderTime.getHour() >= CUTOFF_HOUR) { deliveryDate = orderTime.toLocalDate().plusDays(2); // 다음날 배송 } else { deliveryDate = orderTime.toLocalDate().plusDays(1); // 당일 배송 } // 주말/공휴일 처리 while (isWeekendOrHoliday(deliveryDate)) { deliveryDate = deliveryDate.plusDays(1); } return DeliverySchedule.builder() .deliveryDate(deliveryDate) .cutoffTime(orderTime.toLocalDate().atTime(CUTOFF_HOUR, 0)) .isNextDayDelivery(orderTime.getHour() < CUTOFF_HOUR) .build(); } } 🎉 요구사항 명세서 활용 효과: 정확한 비즈니스 로직: 모든 업무 규칙이 코드에 반영 예외 상황 완벽 대응: 미성년자 주문, 재고 부족 등 모든 케이스 처리 데이터 정합성 보장: 주문 상태 관리, 이력 추적 완벽 구현 사용자 경험 향상: 명확한 에러 메시지와 대안 제시 💾 2. 데이터베이스 설계서 (ERD, 스키마 정의) “데이터의 청사진 - 엔티티 관계와 제약 조건이 코드 구조를 결정한다” ❌ ERD를 무시한 잘못된 구현 // ERD를 제대로 분석하지 않고 구현한 문제 코드 @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // 잘못된 연관관계 - ERD 무시 private Long userId; // 외래키를 단순 숫자로 처리 private Long productId; // 연관관계 매핑 누락 private String productName; // 비정규화 - 데이터 중복 private int price; // Product 테이블에 이미 있는 데이터 중복 // 제약 조건 무시 private int quantity; // 최소값 제약 없음 private String status; // Enum 사용하지 않음 } @Service public class OrderService { public Order createOrder(OrderCreateRequest request) { // 비효율적인 데이터 조회 - N+1 문제 발생 Order order = new Order(); order.setUserId(request.getUserId()); order.setProductId(request.getProductId()); // 별도 조회로 데이터 중복 저장 Product product = productService.findById(request.getProductId()); order.setProductName(product.getName()); // 데이터 중복! order.setPrice(product.getPrice()); // 데이터 중복! return orderRepository.save(order); } // 비효율적인 주문 조회 public OrderDetailResponse getOrderDetail(Long orderId) { Order order = orderRepository.findById(orderId); // N+1 문제 - 매번 별도 쿼리 User user = userService.findById(order.getUserId()); Product product = productService.findById(order.getProductId()); return OrderDetailResponse.builder() .order(order) .user(user) .product(product) .build(); } } 🔥 문제점들: 데이터 중복: Product 정보를 Order에 중복 저장 연관관계 누락: JPA 연관관계 매핑 미사용으로 N+1 문제 제약 조건 무시: 데이터 무결성 보장 불가 성능 저하: 비효율적인 쿼리로 성능 문제 ✅ ERD 기반 올바른 구현 // ERD 설계서 예시: // User (1) ←→ (N) Order (N) ←→ (1) Product // Order (1) ←→ (N) OrderHistory // User (1) ←→ (N) Review ←→ (1) Product // 1. ERD 기반 엔티티 설계 @Entity @Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, unique = true) // ERD 제약 조건 반영 private String email; @Column(nullable = false) private String name; @Enumerated(EnumType.STRING) private UserGrade grade; // VIP, REGULAR, BRONZE @Column(nullable = false) private LocalDate birthDate; // ERD 관계 정의에 따른 연관관계 매핑 @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) private List<Order> orders = new ArrayList<>(); @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) private List<Review> reviews = new ArrayList<>(); // 비즈니스 메서드 - 요구사항 반영 public boolean isMinor() { return Period.between(birthDate, LocalDate.now()).getYears() < 19; } public boolean isVip() { return UserGrade.VIP.equals(grade); } } @Entity @Table(name = "products") public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String name; @Column(nullable = false) private int price; @Column(nullable = false) private int stockQuantity; @Enumerated(EnumType.STRING) private ProductCategory category; @Column(nullable = false) private boolean isActive; // ERD 관계 정의에 따른 연관관계 매핑 @OneToMany(mappedBy = "product", cascade = CascadeType.ALL) private List<Order> orders = new ArrayList<>(); @OneToMany(mappedBy = "product", cascade = CascadeType.ALL) private List<Review> reviews = new ArrayList<>(); // 비즈니스 메서드 public boolean isAlcohol() { return ProductCategory.ALCOHOL.equals(category); } public boolean isOutOfStock() { return stockQuantity <= 0; } public boolean canOrder(int requestQuantity) { return isActive && stockQuantity >= requestQuantity; } } @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // ERD 외래키 관계를 JPA 연관관계로 정확히 매핑 @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "user_id", nullable = false) private User user; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "product_id", nullable = false) private Product product; // ERD 제약 조건 반영 @Column(nullable = false) @Min(value = 1, message = "주문 수량은 1개 이상이어야 합니다") @Max(value = 10, message = "1회 주문 최대 수량은 10개입니다") private int quantity; @Column(nullable = false) private int originalPrice; @Column(nullable = false) private int discountAmount; @Column(nullable = false) private int finalPrice; @Enumerated(EnumType.STRING) @Column(nullable = false) private OrderStatus status; @Column(nullable = false) private LocalDateTime orderDate; private LocalDate expectedDeliveryDate; private String paymentTransactionId; // ERD 관계 정의에 따른 이력 관리 @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) private List<OrderHistory> histories = new ArrayList<>(); // 비즈니스 메서드 public void confirmPayment(String transactionId) { this.paymentTransactionId = transactionId; this.status = OrderStatus.CONFIRMED; addHistory(OrderStatus.CONFIRMED, "결제 완료"); } public void cancel(String reason) { if (status == OrderStatus.SHIPPED) { throw new OrderCancelNotAllowedException("배송 중인 주문은 취소할 수 없습니다"); } this.status = OrderStatus.CANCELLED; addHistory(OrderStatus.CANCELLED, reason); } private void addHistory(OrderStatus status, String memo) { OrderHistory history = OrderHistory.builder() .order(this) .status(status) .memo(memo) .createdAt(LocalDateTime.now()) .build(); histories.add(history); } } // ERD 이력 관리 요구사항 반영 @Entity @Table(name = "order_histories") public class OrderHistory { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "order_id", nullable = false) private Order order; @Enumerated(EnumType.STRING) @Column(nullable = false) private OrderStatus status; private String memo; @Column(nullable = false) private LocalDateTime createdAt; } // 2. ERD 관계를 활용한 효율적인 Repository @Repository public interface OrderRepository extends JpaRepository<Order, Long> { // ERD 관계를 활용한 Fetch Join - N+1 문제 해결 @Query("SELECT o FROM Order o " + "JOIN FETCH o.user " + "JOIN FETCH o.product " + "WHERE o.id = :orderId") Optional<Order> findByIdWithUserAndProduct(@Param("orderId") Long orderId); // ERD 인덱스 활용한 효율적 조회 @Query("SELECT o FROM Order o " + "JOIN FETCH o.user " + "WHERE o.user.id = :userId " + "AND o.orderDate BETWEEN :startDate AND :endDate " + "ORDER BY o.orderDate DESC") List<Order> findUserOrdersBetween( @Param("userId") Long userId, @Param("startDate") LocalDateTime startDate, @Param("endDate") LocalDateTime endDate ); // ERD 집계 함수 활용 @Query("SELECT COUNT(o) FROM Order o " + "WHERE o.product.id = :productId " + "AND o.status = :status") int countByProductAndStatus( @Param("productId") Long productId, @Param("status") OrderStatus status ); } // 3. ERD 기반 서비스 구현 @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; // ERD 관계를 활용한 효율적 조회 @Transactional(readOnly = true) public OrderDetailResponse getOrderDetail(Long orderId) { // 한 번의 쿼리로 모든 관련 데이터 조회 Order order = orderRepository.findByIdWithUserAndProduct(orderId) .orElseThrow(() -> new OrderNotFoundException(orderId)); return OrderDetailResponse.builder() .orderId(order.getId()) .userName(order.getUser().getName()) .userEmail(order.getUser().getEmail()) .productName(order.getProduct().getName()) .productPrice(order.getProduct().getPrice()) .quantity(order.getQuantity()) .finalPrice(order.getFinalPrice()) .status(order.getStatus()) .orderDate(order.getOrderDate()) .histories(order.getHistories()) .build(); } // ERD 제약 조건을 활용한 데이터 검증 @Transactional public Order createOrder(OrderCreateRequest request) { // ERD 외래키 제약 조건 활용 User user = userRepository.findById(request.getUserId()) .orElseThrow(() -> new UserNotFoundException()); Product product = productRepository.findById(request.getProductId()) .orElseThrow(() -> new ProductNotFoundException()); // ERD 체크 제약 조건 반영 validateOrderConstraints(request, user, product); Order order = Order.builder() .user(user) // 엔티티 연관관계 활용 .product(product) // 엔티티 연관관계 활용 .quantity(request.getQuantity()) .originalPrice(product.getPrice() * request.getQuantity()) .status(OrderStatus.PENDING_PAYMENT) .orderDate(LocalDateTime.now()) .build(); return orderRepository.save(order); } } 🎉 ERD 활용 효과: 데이터 정합성: 외래키와 제약 조건으로 잘못된 데이터 방지 성능 최적화: Fetch Join으로 N+1 문제 해결 유지보수성: 명확한 엔티티 관계로 이해하기 쉬운 코드 확장성: 새로운 엔티티 추가 시 기존 관계 유지 🏛️ 3. 시스템 아키텍처 설계서 “외부 세계와의 소통 방식 - 시스템 간 상호작용의 설계도” ❌ 아키텍처 무시한 잘못된 구현 // 아키텍처 설계 없이 무작정 구현한 문제 코드 @Service public class PaymentService { public PaymentResult processPayment(PaymentRequest request) { // 하드코딩된 외부 API 호출 - 설계서 무시 try { // 카카오페이 API를 직접 호출 String url = "https://kapi.kakao.com/v1/payment/ready"; String response = restTemplate.postForObject(url, request, String.class); // 응답 파싱도 하드코딩 if (response.contains("SUCCESS")) { return new PaymentResult(true, "결제 성공"); } else { return new PaymentResult(false, "결제 실패"); } } catch (Exception e) { // 에러 처리도 대충 return new PaymentResult(false, "시스템 오류"); } } // 이미지 업로드도 아키텍처 고려 없이 로컬에 저장 public String uploadProductImage(MultipartFile file) { String uploadDir = "/var/uploads/"; // 하드코딩 String fileName = System.currentTimeMillis() + "_" + file.getOriginalFilename(); try { file.transferTo(new File(uploadDir + fileName)); return "/images/" + fileName; } catch (IOException e) { throw new FileUploadException("파일 업로드 실패"); } } } 🔥 문제점들: 하드코딩된 연동: URL, 설정값이 코드에 박혀있음 에러 처리 부족: 외부 시스템 장애 상황 미고려 확장성 부족: 다른 PG사 추가 시 전체 코드 수정 필요 보안 취약: API 키, 인증 정보 하드코딩 ✅ 아키텍처 설계서 기반 올바른 구현 // 아키텍처 설계서 예시: /** * [시스템 아키텍처 설계서 발췌] * * 1. 외부 시스템 연동: * - 결제: 카카오페이 API → 향후 토스페이먼츠 추가 예정 * - 파일 저장: AWS S3 → CDN 연동 * - 메시지 큐: Kafka → 비동기 처리 * * 2. 보안 정책: * - API 키는 환경변수 또는 AWS Secrets Manager 사용 * - 모든 외부 API 호출은 Circuit Breaker 패턴 적용 * - 개인정보는 AES-256 암호화 * * 3. 성능 요구사항: * - 결제 API 응답시간: 3초 이내 * - 파일 업로드: 10MB 이하, 5초 이내 * - 대용량 처리: 메시지 큐 활용한 비동기 처리 */ // 1. 외부 결제 시스템 연동 - 아키텍처 설계 반영 @Configuration @ConfigurationProperties(prefix = "payment.kakao") @Data public class KakaoPayConfig { private String apiUrl; private String secretKey; private int timeoutSeconds; private int retryCount; } // Circuit Breaker 패턴 적용 (아키텍처 설계서 보안 정책) @Component @RequiredArgsConstructor public class KakaoPayGateway { private final KakaoPayConfig config; private final RestTemplate restTemplate; private final CircuitBreaker circuitBreaker; public PaymentResult processPayment(PaymentRequest request) { return circuitBreaker.executeSupplier(() -> { try { // 설정 기반 API 호출 HttpHeaders headers = createHeaders(); HttpEntity<KakaoPayRequest> entity = new HttpEntity<>( convertToKakaoPayRequest(request), headers ); ResponseEntity<KakaoPayResponse> response = restTemplate.exchange( config.getApiUrl() + "/payment/ready", HttpMethod.POST, entity, KakaoPayResponse.class ); return convertToPaymentResult(response.getBody()); } catch (HttpClientErrorException e) { // 아키텍처 설계서의 에러 처리 정책 handleClientError(e); throw new PaymentClientException("결제 요청 오류: " + e.getMessage()); } catch (HttpServerErrorException e) { handleServerError(e); throw new PaymentServerException("결제 서버 오류: " + e.getMessage()); } catch (ResourceAccessException e) { throw new PaymentTimeoutException("결제 시스템 응답 지연"); } }); } private HttpHeaders createHeaders() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(config.getSecretKey()); // 환경변수에서 로드 return headers; } } // 2. 파일 저장 - AWS S3 연동 (아키텍처 설계) @Configuration @ConfigurationProperties(prefix = "aws.s3") @Data public class S3Config { private String bucketName; private String region; private String accessKey; private String secretKey; private String cdnUrl; } @Component @RequiredArgsConstructor public class S3FileUploader { private final S3Config s3Config; private final AmazonS3 s3Client; public FileUploadResult uploadFile(MultipartFile file, String directory) { // 아키텍처 설계서의 파일 정책 반영 validateFile(file); try { String fileName = generateFileName(file.getOriginalFilename()); String s3Key = directory + "/" + fileName; // S3 업로드 ObjectMetadata metadata = new ObjectMetadata(); metadata.setContentType(file.getContentType()); metadata.setContentLength(file.getSize()); s3Client.putObject( s3Config.getBucketName(), s3Key, file.getInputStream(), metadata ); // CDN URL 반환 (아키텍처 설계서 반영) String fileUrl = s3Config.getCdnUrl() + "/" + s3Key; return FileUploadResult.builder() .originalFileName(file.getOriginalFilename()) .storedFileName(fileName) .fileUrl(fileUrl) .fileSize(file.getSize()) .uploadedAt(LocalDateTime.now()) .build(); } catch (IOException e) { throw new FileUploadException("파일 업로드 중 오류가 발생했습니다", e); } } // 아키텍처 설계서의 파일 정책 반영 private void validateFile(MultipartFile file) { if (file.isEmpty()) { throw new InvalidFileException("빈 파일은 업로드할 수 없습니다"); } if (file.getSize() > 10 * 1024 * 1024) { // 10MB 제한 throw new FileSizeExceededException("파일 크기는 10MB를 초과할 수 없습니다"); } String contentType = file.getContentType(); if (!isAllowedContentType(contentType)) { throw new UnsupportedFileTypeException("지원하지 않는 파일 형식입니다"); } } } // 3. 대용량 처리 - Kafka 메시지 큐 (아키텍처 설계) @Component @RequiredArgsConstructor public class OrderEventPublisher { private final KafkaTemplate<String, Object> kafkaTemplate; @Async public void publishOrderCreated(Order order) { OrderCreatedEvent event = OrderCreatedEvent.builder() .orderId(order.getId()) .userId(order.getUser().getId()) .productId(order.getProduct().getId()) .amount(order.getFinalPrice()) .orderDate(order.getOrderDate()) .build(); // 아키텍처 설계서의 메시지 큐 토픽 반영 kafkaTemplate.send("order-events", event); } } @KafkaListener(topics = "order-events", groupId = "email-service") @Component @RequiredArgsConstructor public class OrderEmailHandler { private final EmailService emailService; // 비동기 이메일 발송 - 아키텍처 설계서 반영 public void handleOrderCreated(OrderCreatedEvent event) { try { User user = userService.findById(event.getUserId()); Product product = productService.findById(event.getProductId()); EmailMessage message = EmailMessage.builder() .to(user.getEmail()) .subject("주문이 완료되었습니다") .template("order-confirmation") .templateData(Map.of( "userName", user.getName(), "productName", product.getName(), "amount", event.getAmount() )) .build(); emailService.sendEmail(message); } catch (Exception e) { // 메시지 큐 재처리를 위한 예외 처리 log.error("주문 이메일 발송 실패: orderId={}", event.getOrderId(), e); throw new EmailSendException("이메일 발송에 실패했습니다", e); } } } // 4. 보안 정책 구현 - Spring Security (아키텍처 설계서) @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz -> authz .requestMatchers("/api/admin/**").hasRole("ADMIN") // 관리자 API 제한 .requestMatchers("/api/users/**").hasAnyRole("USER", "ADMIN") .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt.jwtDecoder(jwtDecoder())) ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ); return http.build(); } } // 5. 설정 중심 구성 - 아키텍처 유연성 확보 @Configuration public class ExternalSystemConfig { @Bean @ConditionalOnProperty(name = "payment.provider", havingValue = "kakao") public PaymentGateway kakaoPayGateway() { return new KakaoPayGateway(); } @Bean @ConditionalOnProperty(name = "payment.provider", havingValue = "toss") public PaymentGateway tossPayGateway() { return new TossPayGateway(); } @Bean @ConditionalOnProperty(name = "file.storage", havingValue = "s3") public FileUploader s3FileUploader() { return new S3FileUploader(); } @Bean @ConditionalOnProperty(name = "file.storage", havingValue = "local") public FileUploader localFileUploader() { return new LocalFileUploader(); } } 🎉 아키텍처 설계서 활용 효과: 유연한 시스템: 설정 변경만으로 다른 외부 시스템 연동 가능 안정성: Circuit Breaker, 재시도 정책으로 장애 상황 대응 보안 강화: 인증/인가, 데이터 암호화 체계적 적용 성능 최적화: 비동기 처리, 캐싱 전략 반영 ⚖️ 언제 어떤 문서를 중점적으로 봐야 할까? 🟢 프로젝트 초기 단계 (1-2주차) 💾 데이터베이스 설계서 우선 분석 // 먼저 ERD를 분석하여 도메인 모델 파악 @Entity public class User { // ERD 분석 → 엔티티 설계 → JPA 매핑 } @Entity public class Order { // 관계 정의 → 연관관계 매핑 → Repository 설계 } // ERD 기반으로 기본 CRUD 먼저 구현 @Repository public interface UserRepository extends JpaRepository<User, Long> { // ERD 인덱스 기반 쿼리 메서드 } 🟡 개발 진행 단계 (3-6주차) 📝 요구사항 명세서 중심 구현 // 요구사항 하나씩 Service 계층에 구현 @Service public class OrderService { // 요구사항: "VIP 회원 5% 추가 할인" public DiscountResult calculateVipDiscount(User user, int amount) { if (user.isVip()) { return DiscountResult.of(amount * 0.05); } return DiscountResult.empty(); } // 요구사항: "오후 10시 이후는 다음날 배송" public DeliveryDate calculateDeliveryDate(LocalDateTime orderTime) { if (orderTime.getHour() >= 22) { return DeliveryDate.nextDay(orderTime.toLocalDate().plusDays(1)); } return DeliveryDate.sameDay(orderTime.toLocalDate()); } } 🟠 시스템 통합 단계 (7-8주차) 🏛️ 아키텍처 설계서 중심 구현 // 외부 시스템 연동 및 인프라 구성 @Configuration public class ExternalIntegrationConfig { // 결제 시스템 연동 @Bean public PaymentGateway paymentGateway() { return PaymentGatewayFactory.create(paymentConfig); } // 파일 스토리지 연동 @Bean public FileStorage fileStorage() { return FileStorageFactory.create(storageConfig); } // 메시지 큐 설정 @Bean public MessageProducer messageProducer() { return new KafkaMessageProducer(kafkaConfig); } } 🚨 자주 하는 실수들과 해결책 ❌ 실수 1: 문서 간 불일치 무시 // API 명세서에는 간단해 보이지만... @PostMapping("/orders") public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderCreateRequest request) { // 단순해 보이는 API } // 실제 요구사항은 복잡함을 간과 @Service public class OrderService { public Order createOrder(OrderCreateRequest request) { // 재고 확인은? // 할인 계산은? // 배송비는? // 결제 처리는? // → 요구사항 명세서를 제대로 안 봤다! return orderRepository.save(new Order()); } } ✅ 해결책: 문서 간 일관성 확인 // API 명세서 + 요구사항 + ERD + 아키텍처를 모두 고려한 구현 @Service @RequiredArgsConstructor public class OrderService { // ERD → Repository 연관관계 private final OrderRepository orderRepository; private final UserRepository userRepository; // 아키텍처 → 외부 시스템 연동 private final PaymentGateway paymentGateway; private final InventoryService inventoryService; // 요구사항 → 비즈니스 로직 구현 private final DiscountCalculator discountCalculator; private final DeliveryScheduler deliveryScheduler; @Transactional public OrderResult createOrder(OrderCreateRequest request) { // 문서 기반 체계적 구현 // 1. ERD 기반 데이터 조회 // 2. 요구사항 기반 비즈니스 로직 // 3. 아키텍처 기반 외부 연동 } } ❌ 실수 2: ERD와 JPA 매핑 불일치 // ERD에서는 User와 Order가 1:N 관계인데... @Entity public class Order { private Long userId; // 단순 외래키 - 연관관계 매핑 누락! } // 비효율적인 조회 발생 public OrderDetailResponse getOrderDetail(Long orderId) { Order order = orderRepository.findById(orderId); User user = userService.findById(order.getUserId()); // N+1 문제! } ✅ 해결책: ERD 충실한 매핑 @Entity public class Order { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; // ERD 관계 정확히 매핑 } // ERD 기반 효율적 조회 @Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.id = :orderId") Optional<Order> findByIdWithUser(@Param("orderId") Long orderId); ❌ 실수 3: 아키텍처 설계 무시한 하드코딩 // 하드코딩된 외부 시스템 연동 @Service public class PaymentService { public void processPayment() { String url = "https://api.kakaopay.com"; // 하드코딩! String apiKey = "12345"; // 보안 취약! // ... } } ✅ 해결책: 설정 기반 유연한 구조 @Configuration @ConfigurationProperties(prefix = "external") public class ExternalSystemConfig { private PaymentConfig payment; private StorageConfig storage; private NotificationConfig notification; } @Service @RequiredArgsConstructor public class PaymentService { private final PaymentConfig config; // 설정 주입 private final PaymentGateway gateway; // 추상화된 게이트웨이 } 🎓 실무 적용 로드맵 🏃♂️ 1단계: 문서 읽기 훈련 (1주) 📋 체크리스트 만들기 /** * 프로젝트 시작 전 필수 체크리스트 * * □ 요구사항 명세서 * - 비즈니스 규칙 모두 파악했는가? * - 예외 상황 처리 방안 이해했는가? * - 데이터 생명주기 정책 확인했는가? * * □ ERD 설계서 * - 엔티티 간 관계 정확히 이해했는가? * - 제약 조건과 인덱스 파악했는가? * - 성능 고려사항 확인했는가? * * □ 아키텍처 설계서 * - 외부 시스템 연동 방식 이해했는가? * - 보안 정책과 인증 방식 파악했는가? * - 성능 요구사항과 제약사항 확인했는가? */ 🚶♂️ 2단계: 단계별 구현 (2-3주) Phase 1: ERD 기반 도메인 모델링 // 1주차: ERD → JPA Entity 매핑 @Entity public class User { } @Entity public class Order { } @Repository public interface OrderRepository { } Phase 2: 요구사항 기반 비즈니스 로직 // 2주차: 요구사항 → Service 계층 구현 @Service public class OrderService { // 모든 비즈니스 규칙 구현 } Phase 3: 아키텍처 기반 시스템 연동 // 3주차: 아키텍처 → 외부 시스템 연동 @Component public class PaymentGateway { } @Component public class FileUploader { } 🏃♂️ 3단계: 통합 및 최적화 (1-2주) 전체 플로우 통합 테스트 @SpringBootTest @TestPropertySource(properties = { "payment.provider=kakao", "file.storage=s3", "message.queue=kafka" }) class OrderIntegrationTest { @Test void 주문_전체_플로우_테스트() { // Given: 사용자, 상품, 재고 준비 // When: 주문 생성 API 호출 // Then: 요구사항에 따른 모든 로직 검증 // - 재고 차감 // - 결제 처리 // - 배송 일정 생성 // - 이메일 발송 // - 이력 기록 } } 💡 팀 단위 적용 전략 👥 작은 팀 (2-3명) // 핵심 문서에만 집중 @Service @RequiredArgsConstructor public class OrderService { // 요구사항 명세서 → 핵심 비즈니스 로직만 집중 구현 // ERD → 기본 연관관계만 정확히 매핑 // 아키텍처 → 주요 외부 연동만 설정 기반 구현 } 👥 중간 팀 (4-6명) // 모듈별 담당자 지정, 문서별 전문가 양성 // 요구사항 전문가: 비즈니스 로직 담당 // ERD 전문가: 데이터 모델링 담당 // 아키텍처 전문가: 인프라/연동 담당 @Service public class OrderDomainService { // 각 도메인별로 전문성 확보 } 👥 큰 팀 (7명 이상) // 완전한 문서 기반 개발 프로세스 // 1. 문서 리뷰 → 2. 설계 검토 → 3. 구현 → 4. 문서 기반 테스트 @DomainService public class OrderDomainService { // 모든 문서의 내용이 완벽하게 반영된 구현 } 📊 성공 지표와 측정 📈 정량적 지표 // 1. 요구사항 반영률 // Before: 기능 구현률 60% (핵심 비즈니스 로직 누락) // After: 기능 구현률 95% (모든 요구사항 체계적 반영) // 2. 데이터 무결성 // Before: 데이터 오류 월 10건 이상 // After: ERD 기반 제약 조건으로 데이터 오류 0건 // 3. 외부 연동 안정성 // Before: 외부 API 장애 시 전체 시스템 다운 // After: Circuit Breaker로 99.9% 가용성 확보 📋 정성적 지표 개발 속도: 문서 기반 체계적 개발로 재작업 시간 단축 버그 감소: 요구사항 누락으로 인한 버그 90% 감소 팀 협업: 공통 문서 기반으로 의사소통 명확화 🎪 핵심 원칙 정리 🏆 성공하는 백엔드 개발자의 문서 활용 마인드셋 “코딩하기 전에 문서부터 - 급할수록 설계부터” 3가지 실천 원칙 📝 요구사항 우선: “이 기능이 왜 필요한지, 어떤 조건에서 어떻게 동작해야 하는지 먼저 파악한다” 💾 데이터 중심: “ERD를 보고 엔티티 관계를 정확히 이해한 후 JPA 매핑을 구현한다” 🏛️ 아키텍처 고려: “외부 시스템 연동은 항상 설정 기반으로, 장애 상황을 고려하여 구현한다” 🚀 마무리: 실무에서 살아남는 백엔드 개발 ⚡ 실무 적용의 황금률 “문서는 제약이 아니라 자유를 주는 설계도다” 백엔드 개발자가 이 세 가지 문서를 제대로 활용하는 이유는 문서 자체가 목적이 아니라, 다음을 위해서입니다: 🔧 정확한 구현: 6개월 후에도 왜 이렇게 구현했는지 알 수 있는 코드 🚀 빠른 개발: 문서 기반 체계적 접근으로 시행착오 최소화 🧪 안정성: 모든 요구사항과 제약조건이 반영된 견고한 시스템 👥 협업: 기획자, 디자이너, QA와 원활한 소통 🎯 실무 적용 3단계 요약 1️⃣ 준비 단계: 문서 이해 // 세 문서를 먼저 정독하고 핵심 포인트 정리 // 요구사항 → 비즈니스 로직 플로우차트 작성 // ERD → 엔티티 관계도 그려보기 // 아키텍처 → 시스템 연동 시퀀스 다이어그램 작성 2️⃣ 구현 단계: 문서 기반 코딩 // ERD → Entity → Repository → Service (요구사항) → Controller (API 명세서) // 아키텍처 → 외부 연동 구현 public class OrderService { // 모든 문서의 내용이 코드에 정확히 반영 } 3️⃣ 검증 단계: 문서 기반 테스트 @Test void 요구사항_시나리오_테스트() { // 요구사항 명세서의 모든 시나리오를 테스트 케이스로 작성 // Given: 문서에 정의된 전제 조건 // When: 문서에 정의된 액션 // Then: 문서에 정의된 기대 결과 } 🎁 마지막 조언 세 문서를 맹목적으로 따르지 마세요. 프로젝트의 규모, 팀의 역량, 일정의 현실성을 고려해서 적절한 수준에서 활용하는 것이 중요합니다. 작은 프로젝트: 요구사항 중심 + 기본 ERD 매핑 중간 프로젝트: 세 문서 균형 있게 활용 큰 프로젝트: 모든 문서 내용 완벽 반영 + 문서 기반 코드 리뷰 “오늘의 문서 이해가 6개월 후의 나를 만든다” 지금 당장은 번거로워 보일 수 있지만, 문서 기반 개발을 체득한 백엔드 개발자는 더 정확하고, 더 빠르게, 더 안전하게 개발할 수 있습니다. 📚 추가 학습 자료 🔍 심화 학습 주제 Domain Driven Design (DDD): 요구사항을 도메인 모델로 변환하는 방법론 Event Sourcing: 데이터 변경 이력을 이벤트로 관리하는 패턴 CQRS (Command Query Responsibility Segregation): 읽기와 쓰기 모델 분리 Hexagonal Architecture: 외부 의존성으로부터 비즈니스 로직 보호 📖 권장 도서 “도메인 주도 설계” - 에릭 에반스 “클린 아키텍처” - 로버트 C. 마틴 “마이크로서비스 패턴” - 크리스 리처드슨 “자바 ORM 표준 JPA 프로그래밍” - 김영한 🚀 TEAM WAN / BMC CREW 🚀
Backend Development
· 2025-09-01
📚[Backend Development] 🎯 SOLID 원칙 - 단일 책임 원칙(SRP) 트러블슈팅 가이드
🎯 SOLID 원칙 - 단일 책임 원칙(SRP) 트러블슈팅 가이드 Spring Boot 개발에서 자주 발생하는 SRP(Single Responsibility Principle) 위반 사례와 해결 방법을 정리했습니다. 🔍 문제 1: 하나의 서비스가 너무 많은 일을 담당 📋 에러 상황 하나의 서비스 클래스에서 여러 가지 책임을 동시에 처리하는 코드를 발견했습니다. @Service public class BadOrderService { private final OrderRepository orderRepository; private final JavaMailSender mailSender; public Order createOrder(OrderRequest request) { // 주문 처리 + 이메일 발송 + 로깅 + 검증... 😵 Order order = new Order(request.getProductId(), request.getQuantity()); Order savedOrder = orderRepository.save(order); SimpleMailMessage message = new SimpleMailMessage(); message.setTo(request.getUserEmail()); mailSender.send(message); return savedOrder; } } 🎯 원인 분석 하나의 클래스가 여러 책임을 가지면서 SRP(Single Responsibility Principle)를 위반했습니다. 변경의 이유가 여러 개: 주문 로직 변경, 이메일 템플릿 변경 등 높은 결합도: 이메일 시스템 장애가 주문 시스템에 영향 테스트 복잡성: 여러 의존성을 모두 모킹해야 함 🔧 해결 방법 1단계: 책임별로 클래스 분리 // ✅ 주문 처리 책임만 담당 @Service public class OrderService { private final OrderRepository orderRepository; private final NotificationService notificationService; // 🎯 알림은 다른 서비스에 위임 public Order createOrder(OrderRequest request) { // 1️⃣ 주문 생성과 저장에만 집중 Order order = new Order(request.getProductId(), request.getQuantity()); Order savedOrder = orderRepository.save(order); // 2️⃣ 알림 발송은 전문 서비스에 위임 notificationService.sendOrderCompletionNotification(savedOrder, request.getUserEmail()); return savedOrder; } } // ✅ 알림 발송 책임만 담당 @Service public class NotificationService { private final JavaMailSender mailSender; public void sendOrderCompletionNotification(Order order, String userEmail) { // 🎯 알림 발송에만 집중 try { SimpleMailMessage message = new SimpleMailMessage(); message.setTo(userEmail); message.setSubject("주문이 완료되었습니다."); message.setText("주문 번호: " + order.getId()); mailSender.send(message); } catch (MailException e) { // 🚨 실패 처리 로직 System.err.println("이메일 발송 실패: " + e.getMessage()); } } } 2단계: 각 클래스의 단일 책임 확인 변경의 이유 분석 | 클래스 | 변경 이유 | 책임 | |——–|———–|——| | OrderService | 주문 정책 변경 | 주문 생성, 저장, 관리 | | NotificationService | 알림 방식 변경 | 이메일, SMS 등 알림 발송 | 📚 SRP 핵심 개념 원칙 설명 혜택 Single Responsibility 하나의 클래스는 하나의 책임만 높은 응집도, 낮은 결합도 One Reason to Change 변경의 이유가 오직 하나 유지보수성 향상 Cohesion 관련 기능들의 응집 코드 가독성 증대 🔍 문제 2: 데이터와 비즈니스 로직의 혼재 📋 에러 상황 Entity나 DTO에 비즈니스 로직이 섞여있어 책임이 모호한 경우입니다. // 😵 Entity에 비즈니스 로직이 혼재 @Entity public class BadUser { private String email; private String password; // ❌ 데이터 저장 + 비즈니스 로직이 함께 public boolean validatePassword(String inputPassword) { return BCrypt.checkpw(inputPassword, this.password); } public void sendWelcomeEmail() { // ❌ Entity가 이메일 발송까지 담당 JavaMailSender mailSender = ApplicationContextProvider.getBean(JavaMailSender.class); // ... 이메일 발송 로직 } } 🎯 원인 분석 데이터 표현과 비즈니스 로직이 한 곳에 섞여있어 책임 분리가 안되었습니다. Entity의 역할 오버로드: 데이터 + 검증 + 외부 서비스 호출 테스트 어려움: Entity 테스트에 외부 의존성 필요 재사용성 저하: 다른 컨텍스트에서 로직 재사용 불가 🔧 해결 방법 1단계: Entity는 데이터만 담당 // ✅ 순수한 데이터 표현에만 집중 @Entity public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String email; private String password; // 🎯 단순한 데이터 접근자만 제공 public String getEmail() { return email; } public String getPassword() { return password; } // 생성자, equals, hashCode 등... } 2단계: 비즈니스 로직은 전용 서비스로 분리 // ✅ 사용자 인증 책임만 담당 @Service public class AuthenticationService { public boolean validatePassword(User user, String inputPassword) { // 🔐 패스워드 검증 로직에만 집중 return BCrypt.checkpw(inputPassword, user.getPassword()); } public boolean isValidEmail(String email) { // ✉️ 이메일 형식 검증 return email.matches("^[A-Za-z0-9+_.-]+@(.+)$"); } } // ✅ 사용자 관련 알림 책임만 담당 @Service public class UserNotificationService { private final JavaMailSender mailSender; public void sendWelcomeEmail(User user) { // 🎉 환영 이메일 발송에만 집중 SimpleMailMessage message = new SimpleMailMessage(); message.setTo(user.getEmail()); message.setSubject("회원가입을 환영합니다!"); message.setText("안녕하세요, " + user.getEmail() + "님!"); mailSender.send(message); } } 🎨 계층별 책임 분리 패턴 // 🏗️ 사용자 생성 전체 과정을 조율하는 서비스 @Service @RequiredArgsConstructor public class UserRegistrationService { private final UserRepository userRepository; private final AuthenticationService authenticationService; private final UserNotificationService notificationService; @Transactional public User registerUser(UserRegistrationRequest request) { // 1️⃣ 입력 검증은 AuthenticationService에 위임 if (!authenticationService.isValidEmail(request.getEmail())) { throw new InvalidEmailException("유효하지 않은 이메일입니다."); } // 2️⃣ 사용자 생성 및 저장 User user = new User(request.getEmail(), encryptPassword(request.getPassword())); User savedUser = userRepository.save(user); // 3️⃣ 환영 이메일 발송은 NotificationService에 위임 notificationService.sendWelcomeEmail(savedUser); return savedUser; } private String encryptPassword(String rawPassword) { return BCrypt.hashpw(rawPassword, BCrypt.gensalt()); } } 🔍 문제 3: 컨트롤러에 비즈니스 로직 포함 📋 에러 상황 Controller에서 HTTP 요청 처리 외에 비즈니스 로직까지 담당하는 경우입니다. // 😵 Controller가 너무 많은 책임을 가짐 @RestController @RequestMapping("/api/orders") public class BadOrderController { private final OrderRepository orderRepository; private final JavaMailSender mailSender; @PostMapping public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) { // ❌ HTTP 처리 + 비즈니스 로직 + 외부 서비스 호출 // 1. 입력 검증 if (request.getQuantity() <= 0) { return ResponseEntity.badRequest().build(); } // 2. 비즈니스 로직 Order order = new Order(request.getProductId(), request.getQuantity()); Order savedOrder = orderRepository.save(order); // 3. 이메일 발송 SimpleMailMessage message = new SimpleMailMessage(); message.setTo(request.getUserEmail()); mailSender.send(message); return ResponseEntity.ok(savedOrder); } } 🎯 원인 분석 Controller가 웹 계층 책임을 넘어서 비즈니스 로직까지 처리하고 있습니다. 계층간 책임 혼재: 프레젠테이션 + 비즈니스 + 데이터 액세스 테스트 복잡성: 웹 테스트에 비즈니스 로직 검증까지 포함 재사용 불가: 웹이 아닌 다른 방식으로 호출 불가 🔧 해결 방법 Controller는 HTTP 처리에만 집중 // ✅ HTTP 요청/응답 처리에만 집중 @RestController @RequestMapping("/api/orders") @RequiredArgsConstructor public class OrderController { private final OrderService orderService; // 🎯 비즈니스 로직은 서비스에 위임 @PostMapping public ResponseEntity<OrderResponse> createOrder(@RequestBody @Valid OrderRequest request) { // 1️⃣ HTTP 요청을 받아서 서비스에 전달 try { Order order = orderService.createOrder(request); // 2️⃣ 비즈니스 결과를 HTTP 응답으로 변환 OrderResponse response = OrderResponse.from(order); return ResponseEntity.ok(response); } catch (InvalidOrderException e) { // 3️⃣ 예외를 적절한 HTTP 상태로 변환 return ResponseEntity.badRequest().build(); } } } 📊 계층별 책임 분리 가이드라인 계층 주요 책임 포함하면 안되는 것 Controller HTTP 요청/응답 처리 비즈니스 로직, DB 접근 Service 비즈니스 로직 수행 HTTP 관련 코드, SQL Repository 데이터 액세스 비즈니스 검증, 외부 API Entity 데이터 표현 외부 서비스 호출 📊 SRP 체크리스트 ✅ 클래스 단일 책임 확인 이 클래스를 수정해야 할 이유가 2개 이상인가? 클래스명에 ‘And’, ‘Or’, ‘Manager’ 등이 들어가는가? 하나의 메서드가 50줄을 넘어가는가? ✅ 의존성 체크 한 클래스가 5개 이상의 의존성을 가지는가? 서로 다른 계층의 책임이 한 곳에 있는가? 테스트 시 많은 Mock 객체가 필요한가? ✅ 응집도 확인 클래스 내 메서드들이 같은 데이터를 사용하는가? 메서드들이 하나의 목적으로 그룹화되는가? 클래스명만 보고 역할을 예측할 수 있는가? 🎯 실전 SRP 적용 전략 1단계: 기존 코드 분석 // 🔍 SRP 위반 징후 찾기 @Service public class UserService { // ⚠️ 너무 많은 의존성 = 책임 과다 의심 private final UserRepository userRepository; private final JavaMailSender mailSender; private final PasswordEncoder passwordEncoder; private final FileStorageService fileService; private final PaymentService paymentService; // 🚨 결제? 사용자 서비스에서? // 🔍 메서드명으로 책임 분석 public void createUser() { } // ✅ 사용자 관리 public void sendEmail() { } // ⚠️ 알림 책임? public void processPayment() { } // ⚠️ 결제 책임? public void generateReport() { } // ⚠️ 보고서 생성 책임? } 2단계: 책임별 서비스 분리 // ✅ 사용자 관리 책임만 담당 @Service public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; public User createUser(CreateUserRequest request) { // 사용자 생성 로직만 집중 } public User updateUser(Long userId, UpdateUserRequest request) { // 사용자 수정 로직만 집중 } } // ✅ 사용자 알림 책임만 담당 @Service public class UserNotificationService { private final JavaMailSender mailSender; public void sendWelcomeEmail(User user) { } public void sendPasswordResetEmail(User user, String token) { } } // ✅ 사용자 결제 책임만 담당 @Service public class UserPaymentService { private final PaymentService paymentService; public void processUserPayment(User user, PaymentRequest request) { } } 3단계: 조합 서비스로 전체 플로우 관리 // 🎭 여러 서비스를 조합하여 복잡한 비즈니스 플로우 처리 @Service @RequiredArgsConstructor public class UserRegistrationFacade { private final UserService userService; private final UserNotificationService notificationService; private final UserPaymentService paymentService; @Transactional public User registerUser(UserRegistrationRequest request) { // 1️⃣ 사용자 생성 User user = userService.createUser(request); // 2️⃣ 환영 이메일 발송 notificationService.sendWelcomeEmail(user); // 3️⃣ 초기 결제 처리 (프리미엄 회원인 경우) if (request.isPremium()) { paymentService.processUserPayment(user, request.getPaymentInfo()); } return user; } } 🎉 마무리 이제 SRP를 적용하여 유지보수하기 쉽고 테스트하기 용이한 코드를 작성할 수 있습니다! 🚀 다음 단계 권장사항 개방-폐쇄 원칙(OCP): 확장에는 열려있고 수정에는 닫힌 구조 설계 의존성 역전 원칙(DIP): 인터페이스 기반의 느슨한 결합 구조 리팩토링 도구 활용: IntelliJ IDEA의 Extract Service 기능 활용 📞 추가 학습 리소스 Clean Code by Robert Martin: 클린 코드의 바이블 Effective Java 3판: 자바 개발자 필수 서적 Spring Boot Reference Documentation: 스프링 부트 공식 가이드 💡 핵심 기억할 점 “하나의 클래스는 하나의 변경 이유만 가져야 한다” - 이 원칙만 지켜도 80%의 코드 품질 문제가 해결됩니다!
Backend Development
· 2025-08-23
📚[Backend Development] 🚀 SOLID 원칙 - 개방-폐쇄 원칙(OCP) 트러블슈팅 가이드
🚀 SOLID 원칙 - 개방-폐쇄 원칙(OCP) 트러블슈팅 가이드 Spring Boot 개발에서 개방-폐쇄 원칙(Open/Closed Principle)을 적용하는 과정에서 자주 발생하는 문제와 해결 방법을 정리했습니다. 🔍 문제 1: 기능 추가 시 기존 코드 수정 불가피 📋 에러 상황 새로운 알림 방식을 추가할 때마다 기존 서비스 클래스를 직접 수정해야 하는 상황이 발생합니다. @Service public class NotificationService { public void sendNotification(String type, String message) { if ("KAKAO".equals(type)) { System.out.println("카카오톡 발송: " + message); } else if ("FACEBOOK".equals(type)) { System.out.println("페이스북 발송: " + message); } else if ("SLACK".equals(type)) { // 😱 새로운 기능 추가 시 기존 코드 수정 필요! System.out.println("슬랙 발송: " + message); } // 계속해서 else if 구문이 늘어남... } } 🎯 원인 분석 개방-폐쇄 원칙(OCP)을 위반한 설계로, 확장성과 유지보수성이 저하됩니다. 수정에 닫혀있지 않음: 새로운 기능 추가 시 기존 코드 직접 수정 필요 확장성 부족: if-else 블록이 계속 증가하여 복잡도 상승 테스트 어려움: 새 기능 테스트 시 전체 서비스를 테스트해야 함 단일 책임 원칙 위반: 하나의 클래스가 모든 알림 로직을 담당 🔧 해결 방법 1단계: 공통 기능 추상화 // 🎯 알림 발송의 공통 계약 정의 public interface Notifier { void send(String message); boolean supports(String type); } 핵심 포인트 send(): 실제 알림 발송 로직 supports(): 지원하는 알림 타입 확인 2단계: 구체적인 구현 클래스 생성 // ✅ 카카오톡 알림 구현 @Component public class KakaoNotifier implements Notifier { @Override public void send(String message) { System.out.println("카카오톡 발송: " + message); // 실제 카카오톡 API 호출 로직 } @Override public boolean supports(String type) { return "KAKAO".equalsIgnoreCase(type); } } // ✅ 페이스북 알림 구현 @Component public class FacebookNotifier implements Notifier { @Override public void send(String message) { System.out.println("페이스북 발송: " + message); // 실제 페이스북 API 호출 로직 } @Override public boolean supports(String type) { return "FACEBOOK".equalsIgnoreCase(type); } } 3단계: OCP 준수하는 서비스 클래스 // 🚀 수정에 닫혀있고 확장에 열려있는 서비스 @Service @RequiredArgsConstructor public class NotificationService { // 🎯 Spring이 Notifier 구현체들을 자동으로 주입 private final List<Notifier> notifiers; // ✅ 새로운 알림 방식이 추가되어도 이 코드는 절대 수정되지 않음! public void sendNotification(String type, String message) { Notifier notifier = findNotifier(type); notifier.send(message); } private Notifier findNotifier(String type) { return notifiers.stream() .filter(n -> n.supports(type)) .findFirst() .orElseThrow(() -> new IllegalArgumentException( "지원하지 않는 알림 타입입니다: " + type)); } } 📚 OCP 핵심 개념 원칙 의미 Spring Boot 구현 방법 확장에 열려있음 새로운 기능 추가 가능 @Component로 새 구현체 생성 수정에 닫혀있음 기존 코드 변경 없음 인터페이스와 DI 활용 추상화 활용 구체적 구현에서 분리 Interface 또는 Abstract Class 🔍 문제 2: 새로운 기능 확장의 복잡성 📋 에러 상황 슬랙 알림 기능을 추가해야 하는데, 기존 코드 여러 곳을 수정해야 한다고 생각하는 경우입니다. 🎯 원인 분석 OCP를 제대로 구현했다면 새로운 기능 추가가 매우 간단해야 합니다. 🔧 해결 방법 OCP 준수 시 - 새로운 기능 추가는 이렇게 간단합니다! // 🎉 새로운 슬랙 알림 기능 - 단 한 개의 새 클래스만 추가! @Component public class SlackNotifier implements Notifier { @Value("${slack.webhook.url}") private String webhookUrl; @Override public void send(String message) { // 🔧 슬랙 웹훅 API 호출 로직 System.out.println("슬랙 발송: " + message); // RestTemplate 또는 WebClient로 실제 API 호출 } @Override public boolean supports(String type) { return "SLACK".equalsIgnoreCase(type); } } 놀라운 점 기존 NotificationService 코드: 0줄 수정 🎯 기존 다른 Notifier 클래스들: 0줄 수정 🎯 새로 추가한 코드: 단 1개 클래스 ✨ Spring이 자동으로 Bean 등록 및 주입 처리 🚀 🔍 문제 3: 복잡한 비즈니스 로직에서의 OCP 적용 📋 에러 상황 할인 정책, 결제 방식, 배송 방식 등 복잡한 비즈니스 로직에서 OCP를 어떻게 적용할지 막막한 경우입니다. 🎯 원인 분석 단순한 if-else 분기를 넘어서, 실제 비즈니스 도메인에서 OCP를 적용하는 패턴을 이해하지 못한 경우입니다. 🔧 해결 방법 실전 예시: 전자상거래 할인 정책 시스템 // 🎯 할인 정책의 공통 계약 public interface DiscountPolicy { BigDecimal calculateDiscount(Order order); boolean isApplicable(Order order); int getPriority(); // 여러 할인 정책 적용 시 우선순위 } // 🛍️ 회원 등급 할인 @Component public class MembershipDiscountPolicy implements DiscountPolicy { @Override public BigDecimal calculateDiscount(Order order) { Customer customer = order.getCustomer(); BigDecimal totalAmount = order.getTotalAmount(); return switch (customer.getMembershipLevel()) { case GOLD -> totalAmount.multiply(new BigDecimal("0.15")); case SILVER -> totalAmount.multiply(new BigDecimal("0.10")); case BRONZE -> totalAmount.multiply(new BigDecimal("0.05")); default -> BigDecimal.ZERO; }; } @Override public boolean isApplicable(Order order) { return order.getCustomer().getMembershipLevel() != MembershipLevel.NONE; } @Override public int getPriority() { return 1; // 높은 우선순위 } } // 🎊 쿠폰 할인 @Component public class CouponDiscountPolicy implements DiscountPolicy { private final CouponService couponService; @Override public BigDecimal calculateDiscount(Order order) { return order.getCoupons().stream() .filter(coupon -> couponService.isValid(coupon)) .map(Coupon::getDiscountAmount) .reduce(BigDecimal.ZERO, BigDecimal::add); } @Override public boolean isApplicable(Order order) { return !order.getCoupons().isEmpty(); } @Override public int getPriority() { return 2; } } // 📦 수량 할인 @Component public class BulkDiscountPolicy implements DiscountPolicy { @Override public BigDecimal calculateDiscount(Order order) { int totalQuantity = order.getItems().stream() .mapToInt(OrderItem::getQuantity) .sum(); if (totalQuantity >= 10) { return order.getTotalAmount().multiply(new BigDecimal("0.20")); } else if (totalQuantity >= 5) { return order.getTotalAmount().multiply(new BigDecimal("0.10")); } return BigDecimal.ZERO; } @Override public boolean isApplicable(Order order) { return order.getItems().stream() .mapToInt(OrderItem::getQuantity) .sum() >= 5; } @Override public int getPriority() { return 3; } } 할인 적용 서비스 // 🎯 할인 정책들을 조합하여 최적의 할인 계산 @Service @RequiredArgsConstructor public class DiscountService { private final List<DiscountPolicy> discountPolicies; // ✅ 모든 적용 가능한 할인 정책 중 최대 할인 금액 계산 public BigDecimal calculateMaxDiscount(Order order) { return discountPolicies.stream() .filter(policy -> policy.isApplicable(order)) .map(policy -> policy.calculateDiscount(order)) .max(BigDecimal::compareTo) .orElse(BigDecimal.ZERO); } // 📊 적용 가능한 모든 할인 정책 정보 반환 public List<DiscountInfo> getApplicableDiscounts(Order order) { return discountPolicies.stream() .filter(policy -> policy.isApplicable(order)) .sorted(Comparator.comparing(DiscountPolicy::getPriority)) .map(policy -> DiscountInfo.builder() .policyName(policy.getClass().getSimpleName()) .discountAmount(policy.calculateDiscount(order)) .priority(policy.getPriority()) .build()) .collect(Collectors.toList()); } } 🎨 OCP 실무 활용 패턴 비즈니스 도메인 인터페이스 예시 구현체 예시 결제 처리 PaymentProcessor CreditCardProcessor, PayPalProcessor, BankTransferProcessor 배송 방식 ShippingCalculator StandardShipping, ExpressShipping, OvernightShipping 인증 방식 AuthenticationProvider DatabaseAuthProvider, OAuthProvider, LdapAuthProvider 파일 저장 FileStorage LocalFileStorage, S3FileStorage, GoogleCloudStorage 📊 OCP 체크리스트 ✅ 설계 검증 새로운 기능 추가 시 기존 코드를 수정하지 않아도 되는가? 인터페이스나 추상 클래스를 통한 추상화가 적절한가? Spring의 DI를 활용하여 구현체들이 자동으로 주입되는가? ✅ 구현 품질 각 구현 클래스가 단일 책임을 가지고 있는가? if-else 분기문이 제거되었는가? 새로운 구현체 추가가 단순한가? (하나의 클래스만 추가) ✅ 확장성 고려 우선순위나 조건부 적용이 필요한 경우 고려되었는가? 여러 구현체를 조합하여 사용할 수 있는가? 성능에 영향을 주지 않는가? 🎯 실전 활용 예시 E-commerce 주문 처리 시스템에서의 OCP 활용 // 🎯 주문 처리 단계별 전략 패턴 + OCP 적용 public interface OrderProcessor { void process(Order order); boolean canProcess(OrderType orderType); int getProcessingOrder(); } @Component public class RegularOrderProcessor implements OrderProcessor { @Override public void process(Order order) { // 📦 일반 주문 처리 로직 validateStock(order); reserveInventory(order); createShippingLabel(order); sendConfirmationEmail(order); } @Override public boolean canProcess(OrderType orderType) { return OrderType.REGULAR.equals(orderType); } @Override public int getProcessingOrder() { return 1; } } @Component public class PreOrderProcessor implements OrderProcessor { @Override public void process(Order order) { // 🔮 예약 주문 처리 로직 validatePreOrderConditions(order); scheduleInventoryReservation(order); sendPreOrderConfirmation(order); createPaymentSchedule(order); } @Override public boolean canProcess(OrderType orderType) { return OrderType.PRE_ORDER.equals(orderType); } @Override public int getProcessingOrder() { return 2; } } // 🎯 주문 처리 조정자 @Service @RequiredArgsConstructor public class OrderService { private final List<OrderProcessor> orderProcessors; @Transactional public void processOrder(Order order) { OrderProcessor processor = findProcessor(order.getOrderType()); processor.process(order); // 📊 공통 후처리 작업 order.markAsProcessed(); publishOrderProcessedEvent(order); } private OrderProcessor findProcessor(OrderType orderType) { return orderProcessors.stream() .filter(processor -> processor.canProcess(orderType)) .min(Comparator.comparing(OrderProcessor::getProcessingOrder)) .orElseThrow(() -> new IllegalArgumentException( "지원하지 않는 주문 타입입니다: " + orderType)); } } 새로운 주문 타입 추가 - 긴급 주문 // 🚨 긴급 주문 처리기 - 새 클래스만 추가하면 끝! @Component public class UrgentOrderProcessor implements OrderProcessor { private final PriorityInventoryService priorityInventoryService; private final ExpressShippingService expressShippingService; @Override public void process(Order order) { // ⚡ 긴급 주문 전용 고속 처리 로직 priorityInventoryService.immediateReservation(order); expressShippingService.scheduleUrgentDelivery(order); sendUrgentOrderNotification(order); applyUrgentProcessingFee(order); } @Override public boolean canProcess(OrderType orderType) { return OrderType.URGENT.equals(orderType); } @Override public int getProcessingOrder() { return 0; // 최우선 처리 } } 결과: 기존 OrderService나 다른 Processor 클래스들은 단 한 줄도 수정하지 않고 새로운 긴급 주문 기능이 완벽하게 추가됩니다! 🎉 📈 성능 고려사항 ✅ 최적화 팁 List<T> 주입보다는 Map<String, T> 활용으로 O(1) 조회 @Lazy 어노테이션으로 필요한 시점에만 초기화 캐싱 전략 적용으로 반복 계산 방지 // 🚀 성능 최적화된 버전 @Service @RequiredArgsConstructor public class OptimizedNotificationService { private final Map<String, Notifier> notifierMap; // 🎯 생성자에서 Map 구성 (O(1) 조회를 위해) public OptimizedNotificationService(List<Notifier> notifiers) { this.notifierMap = notifiers.stream() .collect(Collectors.toMap( notifier -> extractType(notifier), Function.identity() )); } public void sendNotification(String type, String message) { Notifier notifier = notifierMap.get(type.toUpperCase()); if (notifier == null) { throw new IllegalArgumentException("지원하지 않는 알림 타입: " + type); } notifier.send(message); } } 🎉 마무리 이제 SOLID OCP 원칙을 활용한 확장 가능하고 유지보수가 쉬운 Spring Boot 애플리케이션을 만들 수 있습니다! 🚀 다음 단계 권장사항 다른 SOLID 원칙 학습: SRP, LSP, ISP, DIP와 OCP의 조합 활용 디자인 패턴 적용: Strategy, Factory, Template Method 패턴과 OCP Spring Boot 고급 기능: @Conditional 어노테이션을 활용한 동적 Bean 등록 📞 추가 학습 리소스 Clean Code (Robert C. Martin): SOLID 원칙의 바이블 Spring Boot Reference Documentation: 의존성 주입 및 자동 설정 Effective Java 3rd Edition: 인터페이스와 추상화 활용법 💡 핵심 기억할 점 OCP는 “변화에 유연하게 대응하는 시스템”을 만드는 핵심 원칙입니다. Spring Boot의 DI 컨테이너와 인터페이스를 활용하면 새로운 기능은 쉽게 추가하고, 기존 코드는 안정적으로 유지할 수 있는 견고한 아키텍처를 구축할 수 있습니다!
Backend Development
· 2025-08-23
📚[Backend Development] 🚀 SOLID 원칙 - 리스코프 치환 원칙(LSP) 트러블슈팅 가이드
🚀 SOLID 원칙 - 리스코프 치환 원칙(LSP) 트러블슈팅 가이드 Spring Boot 개발에서 리스코프 치환 원칙(Liskov Substitution Principle)을 적용하는 과정에서 자주 발생하는 문제와 해결 방법을 정리했습니다. 🔍 문제 1: 자식 클래스에서 예외 발생으로 인한 런타임 오류 📋 에러 상황 결제 시스템에서 포인트 결제를 추가했는데, 환불 기능 호출 시 UnsupportedOperationException이 발생하는 상황입니다. // 💥 LSP 위반으로 인한 런타임 에러! @Service public class PaymentService { private final Map<String, PaymentProcessor> processors; public void processRefund(String type, double amount) { PaymentProcessor processor = processors.get(type); processor.refund(amount); // 💥 포인트 결제 시 예외 발생! } } @Component("point") public class PointPaymentProcessor implements PaymentProcessor { @Override public void refund(double amount) { // 😱 부모의 행위 규약 위반! throw new UnsupportedOperationException("포인트 결제는 환불이 불가능합니다."); } } 🎯 원인 분석 리스코프 치환 원칙(LSP)을 위반한 설계로, 자식 클래스가 부모의 계약을 지키지 못해 다형성이 안전하지 않습니다. 계약 위반: 자식 클래스가 부모 인터페이스의 행위 규약을 지키지 않음 타입 안전성 부족: 클라이언트 코드에서 구체 타입을 확인해야 함 런타임 에러: 컴파일 시점에 발견되지 않는 예외 발생 다형성 파괴: 부모 타입으로 안전하게 치환할 수 없음 🔧 해결 방법 1단계: 역할에 따른 인터페이스 분리 // 🎯 결제 기본 기능만 정의 public interface Payable { void processPayment(double amount); } // 🔄 환불까지 포함하는 확장 인터페이스 public interface Refundable extends Payable { void refund(double amount); } 핵심 포인트 Payable: 모든 결제 수단의 최소 공통 기능 Refundable: 환불까지 지원하는 결제 수단의 확장 기능 2단계: 역할에 맞는 구현체 작성 // ✅ 카드 결제: 결제와 환불 모두 가능 @Component("card") public class CardPaymentProcessor implements Refundable { @Override public void processPayment(double amount) { System.out.println(amount + "원을 카드로 결제합니다."); // 카드 결제 API 호출 } @Override public void refund(double amount) { System.out.println(amount + "원을 카드로 환불합니다."); // 카드 환불 API 호출 - LSP 준수! } } // ✅ 포인트 결제: 결제만 가능 (예외 발생 없음) @Component("point") public class PointPaymentProcessor implements Payable { @Override public void processPayment(double amount) { System.out.println(amount + " 포인트를 사용하여 결제합니다."); // 포인트 차감 로직 - LSP 준수! } // 🎯 refund() 메서드가 없어서 예외 발생 자체가 불가능! } 3단계: LSP 준수하는 안전한 서비스 클래스 // 🚀 타입 안전성이 보장된 결제 서비스 @Service @RequiredArgsConstructor public class PaymentService { private final Map<String, Payable> payableProcessors; private final Map<String, Refundable> refundableProcessors; // ✅ 모든 결제 수단에서 안전하게 동작 public void processPayment(String type, double amount) { Payable processor = payableProcessors.get(type); if (processor == null) { throw new IllegalArgumentException("지원하지 않는 결제 타입입니다: " + type); } processor.processPayment(amount); // LSP 보장! } // ✅ 환불 지원 결제 수단에서만 안전하게 동작 public void processRefund(String type, double amount) { Refundable processor = refundableProcessors.get(type); if (processor == null) { throw new UnsupportedOperationException("이 결제 타입은 환불을 지원하지 않습니다: " + type); } processor.refund(amount); // LSP 보장! } } 📚 LSP 핵심 개념 원칙 의미 Spring Boot 구현 방법 행위 규약 준수 자식은 부모의 계약을 지켜야 함 인터페이스 메서드에서 예외 발생 금지 안전한 치환 부모 타입으로 완전 대체 가능 적절한 추상화와 인터페이스 분리 사전조건 강화 금지 자식이 더 엄격한 조건 요구 불가 파라미터 검증 완화만 허용 사후조건 약화 금지 자식이 더 약한 결과 반환 불가 반환값의 계약 준수 🔍 문제 2: 자식 클래스의 사전조건 강화로 인한 호환성 문제 📋 에러 상황 도형 계산 시스템에서 정사각형이 직사각형을 상속받았는데, 너비와 높이 설정에서 제약이 생기는 상황입니다. // 😱 LSP 위반: 자식 클래스가 더 강한 사전조건을 요구 @Component public class Rectangle { protected double width; protected double height; public void setWidth(double width) { this.width = width; } public void setHeight(double height) { this.height = height; } public double getArea() { return width * height; } } @Component public class Square extends Rectangle { @Override public void setWidth(double width) { // 💥 사전조건 강화! 부모보다 더 엄격한 제약 if (width != height && height != 0) { throw new IllegalArgumentException("정사각형은 너비와 높이가 같아야 합니다!"); } this.width = width; this.height = width; // 강제로 같게 만듦 } @Override public void setHeight(double height) { // 💥 사전조건 강화! 부모보다 더 엄격한 제약 if (height != width && width != 0) { throw new IllegalArgumentException("정사각형은 너비와 높이가 같아야 합니다!"); } this.height = height; this.width = height; // 강제로 같게 만듦 } } 🎯 원인 분석 자식 클래스가 부모 클래스보다 강화된 사전조건을 요구하여 LSP를 위반합니다. 사전조건 강화: 정사각형이 직사각형보다 더 엄격한 조건 요구 예상치 못한 동작: 클라이언트 코드가 예상과 다르게 동작 다형성 파괴: Rectangle 타입으로 Square 사용 시 예외 발생 설계 오류: “is-a” 관계를 잘못 적용 🔧 해결 방법 1단계: 불변 객체 패턴으로 재설계 // 🎯 도형의 공통 기능만 추상화 public abstract class Shape { public abstract double getArea(); public abstract double getPerimeter(); public abstract String getShapeType(); } // ✅ 불변 직사각형 - LSP 준수 @Component public class Rectangle extends Shape { private final double width; private final double height; public Rectangle(double width, double height) { if (width <= 0 || height <= 0) { throw new IllegalArgumentException("너비와 높이는 양수여야 합니다."); } this.width = width; this.height = height; } @Override public double getArea() { return width * height; } @Override public double getPerimeter() { return 2 * (width + height); } @Override public String getShapeType() { return "Rectangle"; } // Getter만 제공 (불변성 보장) public double getWidth() { return width; } public double getHeight() { return height; } } // ✅ 불변 정사각형 - LSP 준수 @Component public class Square extends Shape { private final double side; public Square(double side) { if (side <= 0) { throw new IllegalArgumentException("한 변의 길이는 양수여야 합니다."); } this.side = side; } @Override public double getArea() { return side * side; // 부모와 동일한 계약 준수 } @Override public double getPerimeter() { return 4 * side; // 부모와 동일한 계약 준수 } @Override public String getShapeType() { return "Square"; } public double getSide() { return side; } } 2단계: 팩토리 패턴으로 안전한 생성 // 🏭 도형 생성을 위한 팩토리 @Service public class ShapeFactory { public Rectangle createRectangle(double width, double height) { return new Rectangle(width, height); } public Square createSquare(double side) { return new Square(side); } // 🎯 타입에 따른 안전한 도형 생성 public Shape createShape(String type, double... dimensions) { return switch (type.toUpperCase()) { case "RECTANGLE" -> { if (dimensions.length != 2) { throw new IllegalArgumentException("직사각형은 너비와 높이가 필요합니다."); } yield createRectangle(dimensions[0], dimensions[1]); } case "SQUARE" -> { if (dimensions.length != 1) { throw new IllegalArgumentException("정사각형은 한 변의 길이가 필요합니다."); } yield createSquare(dimensions[0]); } default -> throw new IllegalArgumentException("지원하지 않는 도형 타입: " + type); }; } } 3단계: LSP 준수하는 도형 계산 서비스 // 🧮 모든 도형에서 안전하게 동작하는 계산 서비스 @Service @RequiredArgsConstructor public class ShapeCalculationService { private final ShapeFactory shapeFactory; // ✅ 모든 Shape 하위 클래스에서 안전하게 동작 public double calculateTotalArea(List<Shape> shapes) { return shapes.stream() .mapToDouble(Shape::getArea) // LSP 보장! .sum(); } // ✅ 도형 타입에 관계없이 안전하게 동작 public String generateReport(List<Shape> shapes) { StringBuilder report = new StringBuilder(); for (Shape shape : shapes) { report.append(String.format("%s - 넓이: %.2f, 둘레: %.2f%n", shape.getShapeType(), // LSP 보장! shape.getArea(), // LSP 보장! shape.getPerimeter() // LSP 보장! )); } return report.toString(); } // 🎯 다양한 도형 조합 계산 public ShapeStatistics calculateStatistics(List<Shape> shapes) { return ShapeStatistics.builder() .totalShapes(shapes.size()) .totalArea(calculateTotalArea(shapes)) .averageArea(shapes.stream().mapToDouble(Shape::getArea).average().orElse(0)) .largestArea(shapes.stream().mapToDouble(Shape::getArea).max().orElse(0)) .build(); } } 🔍 문제 3: 상속 계층에서의 복잡한 LSP 위반 📋 에러 상황 새와 펭귄의 상속 관계에서 fly() 메서드 때문에 LSP가 위반되는 복잡한 상황입니다. // 😱 LSP 위반: 모든 새가 날 수 있다는 잘못된 추상화 public abstract class Bird { public abstract void fly(); public abstract void eat(); } public class Penguin extends Bird { @Override public void fly() { // 💥 LSP 위반! 펭귄은 날 수 없음 throw new UnsupportedOperationException("펭귄은 날 수 없습니다!"); } @Override public void eat() { System.out.println("물고기를 먹습니다."); } } 🎯 원인 분석 잘못된 추상화로 인해 일부 자식 클래스가 부모의 모든 기능을 지원할 수 없어 LSP를 위반합니다. 과도한 추상화: 모든 새가 날 수 있다는 잘못된 가정 기능별 분리 부족: 비행 능력과 새 자체를 분리하지 못함 인터페이스 분리 원칙 위반: 사용하지 않는 기능을 강제로 구현 다중 책임: 하나의 클래스가 여러 능력을 모두 처리 🔧 해결 방법 1단계: 능력별 인터페이스 분리 // 🐦 모든 새의 기본 능력 public interface Bird { void eat(); void makeSound(); String getSpecies(); } // ✈️ 비행 능력 (별도 인터페이스) public interface Flyable { void fly(); double getFlightSpeed(); double getMaxAltitude(); } // 🏊 수영 능력 (별도 인터페이스) public interface Swimmable { void swim(); double getSwimmingSpeed(); double getMaxDepth(); } // 🚶 보행 능력 (별도 인터페이스) public interface Walkable { void walk(); double getWalkingSpeed(); } 2단계: 능력에 따른 구체적 구현 // 🦅 독수리: 날고 걸을 수 있음 @Component public class Eagle implements Bird, Flyable, Walkable { @Override public void eat() { System.out.println("작은 동물을 잡아먹습니다."); } @Override public void makeSound() { System.out.println("끼야악!"); } @Override public String getSpecies() { return "독수리"; } @Override public void fly() { System.out.println("높이 날아오릅니다."); } @Override public double getFlightSpeed() { return 80.0; // km/h } @Override public double getMaxAltitude() { return 3000.0; // m } @Override public void walk() { System.out.println("땅에서 걸어다닙니다."); } @Override public double getWalkingSpeed() { return 5.0; // km/h } } // 🐧 펭귄: 수영하고 걸을 수 있음 (날지 못함!) @Component public class Penguin implements Bird, Swimmable, Walkable { @Override public void eat() { System.out.println("물고기를 먹습니다."); } @Override public void makeSound() { System.out.println("꽥꽥!"); } @Override public String getSpecies() { return "펭귄"; } @Override public void swim() { System.out.println("물속에서 빠르게 수영합니다."); } @Override public double getSwimmingSpeed() { return 25.0; // km/h } @Override public double getMaxDepth() { return 500.0; // m } @Override public void walk() { System.out.println("뒤뚱뒤뚱 걸어다닙니다."); } @Override public double getWalkingSpeed() { return 2.0; // km/h } } // 🐔 닭: 걸을 수만 있음 (날지도 헤엄치지도 못함!) @Component public class Chicken implements Bird, Walkable { @Override public void eat() { System.out.println("곡식을 쪼아먹습니다."); } @Override public void makeSound() { System.out.println("꼬끼오!"); } @Override public String getSpecies() { return "닭"; } @Override public void walk() { System.out.println("땅에서 걸어다닙니다."); } @Override public double getWalkingSpeed() { return 15.0; // km/h } } 3단계: LSP 준수하는 새 관리 서비스 // 🎯 능력별로 안전하게 새들을 관리하는 서비스 @Service @RequiredArgsConstructor public class BirdManagementService { private final List<Bird> allBirds; // ✅ 모든 새에서 안전하게 동작 public void feedAllBirds() { allBirds.forEach(bird -> { System.out.println(bird.getSpecies() + "에게 먹이를 줍니다."); bird.eat(); // LSP 보장! }); } // ✅ 비행 가능한 새들만 안전하게 처리 public void organizeFlightShow() { List<Flyable> flyingBirds = allBirds.stream() .filter(bird -> bird instanceof Flyable) .map(bird -> (Flyable) bird) .collect(Collectors.toList()); flyingBirds.forEach(flyable -> { System.out.println("비행 쇼 시작!"); flyable.fly(); // LSP 보장! }); } // ✅ 수영 가능한 새들만 안전하게 처리 public void organizeSwimmingCompetition() { List<Swimmable> swimmingBirds = allBirds.stream() .filter(bird -> bird instanceof Swimmable) .map(bird -> (Swimmable) bird) .collect(Collectors.toList()); swimmingBirds.forEach(swimmable -> { System.out.println("수영 대회 시작!"); swimmable.swim(); // LSP 보장! }); } // 🏃 모든 새의 보행 시합 (대부분의 새가 걸을 수 있음) public void organizeWalkingRace() { allBirds.stream() .filter(bird -> bird instanceof Walkable) .map(bird -> (Walkable) bird) .forEach(walkable -> { System.out.println("걷기 시합 참가!"); walkable.walk(); // LSP 보장! }); } // 📊 새들의 능력 통계 public BirdCapabilityStatistics getCapabilityStatistics() { long flyingCount = allBirds.stream().mapToLong(bird -> bird instanceof Flyable ? 1 : 0).sum(); long swimmingCount = allBirds.stream().mapToLong(bird -> bird instanceof Swimmable ? 1 : 0).sum(); long walkingCount = allBirds.stream().mapToLong(bird -> bird instanceof Walkable ? 1 : 0).sum(); return BirdCapabilityStatistics.builder() .totalBirds(allBirds.size()) .flyingBirds(flyingCount) .swimmingBirds(swimmingCount) .walkingBirds(walkingCount) .build(); } } 🎨 LSP 실무 활용 패턴 비즈니스 도메인 잘못된 추상화 올바른 LSP 설계 파일 처리 FileProcessor.compress() (모든 파일이 압축 가능?) Compressible 인터페이스 분리 사용자 권한 User.adminAction() (모든 사용자가 관리자?) AdminUser, RegularUser 별도 타입 결제 수단 Payment.refund() (모든 결제가 환불 가능?) Refundable 인터페이스 분리 운송 수단 Vehicle.fly() (모든 운송 수단이 비행?) Flyable, Drivable 능력별 분리 📊 LSP 체크리스트 ✅ 설계 검증 자식 클래스에서 부모 메서드 호출 시 예외가 발생하지 않는가? 자식 클래스가 부모보다 강한 사전조건을 요구하지 않는가? 자식 클래스가 부모보다 약한 사후조건을 제공하지 않는가? instanceof 체크 없이 다형성을 사용할 수 있는가? ✅ 구현 품질 인터페이스가 클라이언트의 필요에 따라 적절히 분리되었는가? 불변 객체 패턴을 활용하여 상태 변경 문제를 방지했는가? 팩토리 패턴으로 객체 생성을 안전하게 관리하는가? 각 클래스가 자신의 책임만 가지고 있는가? ✅ 확장성 고려 새로운 하위 타입 추가 시 기존 코드가 깨지지 않는가? 능력별로 인터페이스가 분리되어 유연한 조합이 가능한가? 컴파일 타임에 타입 안전성이 보장되는가? 🎯 실전 활용 예시 E-commerce 상품 관리 시스템에서의 LSP 활용 // 🎯 상품의 기본 기능 public interface Product { String getName(); BigDecimal getPrice(); String getDescription(); boolean isAvailable(); } // 📦 실물 상품 능력 public interface PhysicalProduct extends Product { double getWeight(); Dimensions getDimensions(); ShippingInfo calculateShipping(String destination); } // 💾 디지털 상품 능력 public interface DigitalProduct extends Product { String getDownloadUrl(); long getFileSize(); List<String> getSupportedFormats(); void sendDownloadLink(String email); } // 📚 구독 상품 능력 public interface SubscriptionProduct extends Product { Period getSubscriptionPeriod(); BigDecimal getRecurringPrice(); LocalDate getNextBillingDate(); void renewSubscription(); } // ✅ 책 (실물 상품) @Component public class Book implements PhysicalProduct { private final String title; private final BigDecimal price; private final String author; private final double weight; private final Dimensions dimensions; // PhysicalProduct의 모든 메서드를 완전히 구현 (LSP 준수) @Override public String getName() { return title; } @Override public BigDecimal getPrice() { return price; } @Override public String getDescription() { return String.format("저자: %s", author); } @Override public boolean isAvailable() { return true; // 재고 확인 로직 } @Override public double getWeight() { return weight; } @Override public Dimensions getDimensions() { return dimensions; } @Override public ShippingInfo calculateShipping(String destination) { // 실제 배송비 계산 - LSP 준수! return ShippingCalculator.calculate(weight, dimensions, destination); } } // ✅ 전자책 (디지털 상품) @Component public class EBook implements DigitalProduct { private final String title; private final BigDecimal price; private final String downloadUrl; private final long fileSize; // DigitalProduct의 모든 메서드를 완전히 구현 (LSP 준수) @Override public String getName() { return title; } @Override public BigDecimal getPrice() { return price; } @Override public String getDescription() { return String.format("파일 크기: %d MB", fileSize / 1024 / 1024); } @Override public boolean isAvailable() { return true; // 디지털 상품은 항상 사용 가능 } @Override public String getDownloadUrl() { return downloadUrl; } @Override public long getFileSize() { return fileSize; } @Override public List<String> getSupportedFormats() { return List.of("PDF", "EPUB", "MOBI"); } @Override public void sendDownloadLink(String email) { // 실제 이메일 발송 - LSP 준수! EmailService.sendDownloadLink(email, downloadUrl); } } LSP 준수하는 상품 관리 서비스 // 🛒 타입별로 안전하게 상품을 처리하는 서비스 @Service @RequiredArgsConstructor public class ProductManagementService { private final List<Product> allProducts; private final ShippingService shippingService; private final EmailService emailService; // ✅ 모든 상품에서 안전하게 동작 public List<ProductSummary> getAllProductSummaries() { return allProducts.stream() .map(product -> ProductSummary.builder() .name(product.getName()) // LSP 보장! .price(product.getPrice()) // LSP 보장! .description(product.getDescription()) // LSP 보장! .available(product.isAvailable()) // LSP 보장! .build()) .collect(Collectors.toList()); } // ✅ 실물 상품만 안전하게 처리 public List<ShippingEstimate> calculateShippingEstimates(String destination) { return allProducts.stream() .filter(product -> product instanceof PhysicalProduct) .map(product -> (PhysicalProduct) product) .map(physical -> ShippingEstimate.builder() .productName(physical.getName()) .shippingInfo(physical.calculateShipping(destination)) // LSP 보장! .build()) .collect(Collectors.toList()); } // ✅ 디지털 상품만 안전하게 처리 public void sendDigitalDeliveries(String customerEmail) { allProducts.stream() .filter(product -> product instanceof DigitalProduct) .map(product -> (DigitalProduct) product) .forEach(digital -> { System.out.printf("디지털 상품 전달: %s%n", digital.getName()); digital.sendDownloadLink(customerEmail); // LSP 보장! }); } // 🔄 구독 상품 갱신 public void renewSubscriptions() { allProducts.stream() .filter(product -> product instanceof SubscriptionProduct) .map(product -> (SubscriptionProduct) product) .forEach(subscription -> { System.out.printf("구독 갱신: %s%n", subscription.getName()); subscription.renewSubscription(); // LSP 보장! }); } } 새로운 상품 타입 추가 - 멤버십 // 🎯 새로운 멤버십 상품 - 기존 코드 수정 없이 추가! @Component public class MembershipProduct implements SubscriptionProduct { private final String membershipName; private final BigDecimal monthlyFee; private final List<String> benefits; // SubscriptionProduct의 모든 메서드를 완전히 구현 (LSP 준수) @Override public String getName() { return membershipName; } @Override public BigDecimal getPrice() { return monthlyFee; } @Override public String getDescription() { return String.format("혜택: %s", String.join(", ", benefits)); } @Override public boolean isAvailable() { return true; } @Override public Period getSubscriptionPeriod() { return Period.ofMonths(1); } @Override public BigDecimal getRecurringPrice() { return monthlyFee; } @Override public LocalDate getNextBillingDate() { return LocalDate.now().plusMonths(1); } @Override public void renewSubscription() { // 멤버십 갱신 로직 - LSP 준수! System.out.println("멤버십이 자동으로 갱신되었습니다."); // 결제 처리 및 혜택 연장 } } 결과: 기존 ProductManagementService나 다른 상품 클래스들은 단 한 줄도 수정하지 않고 새로운 멤버십 상품 기능이 완벽하게 추가됩니다! 🎉 📈 성능 고려사항 ✅ 최적화 팁 instanceof 체크를 최소화하여 런타임 성능 향상 인터페이스별 Map 활용으로 O(1) 조회 구현 캐싱으로 반복적인 타입 체크 방지 // 🚀 성능 최적화된 LSP 구현 @Service @RequiredArgsConstructor public class OptimizedProductService { // 타입별로 미리 분류하여 성능 최적화 private final Map<Class<?>, List<Product>> productsByType; private final Map<String, PhysicalProduct> physicalProductsById; private final Map<String, DigitalProduct> digitalProductsById; @PostConstruct public void initializeProductMaps() { // 애플리케이션 시작 시 한 번만 분류 List<Product> allProducts = productRepository.findAll(); this.productsByType = allProducts.stream() .collect(Collectors.groupingBy(Object::getClass)); this.physicalProductsById = allProducts.stream() .filter(product -> product instanceof PhysicalProduct) .collect(Collectors.toMap( Product::getId, product -> (PhysicalProduct) product )); this.digitalProductsById = allProducts.stream() .filter(product -> product instanceof DigitalProduct) .collect(Collectors.toMap( Product::getId, product -> (DigitalProduct) product )); } // 🎯 O(1) 조회로 빠른 배송비 계산 public ShippingInfo calculateShippingFast(String productId, String destination) { PhysicalProduct product = physicalProductsById.get(productId); if (product == null) { throw new IllegalArgumentException("실물 상품이 아닙니다: " + productId); } return product.calculateShipping(destination); // LSP 보장! } } 🎉 마무리 이제 SOLID LSP 원칙을 활용한 안전하고 확장 가능한 Spring Boot 애플리케이션을 만들 수 있습니다! 🚀 다음 단계 권장사항 다른 SOLID 원칙과의 조합: SRP + LSP + ISP를 함께 적용 디자인 패턴 활용: Strategy, State, Template Method 패턴과 LSP 테스트 전략: LSP 준수를 검증하는 단위 테스트 작성 📞 추가 학습 리소스 Clean Architecture (Robert C. Martin): 올바른 추상화와 경계 설정 Effective Java 3rd Edition: 상속보다는 컴포지션 활용법 Spring Boot Testing: LSP 준수를 검증하는 테스트 방법 💡 핵심 기억할 점 LSP는 “자식이 부모의 약속을 반드시 지켜야 한다”는 원칙입니다. Spring Boot의 인터페이스 분리와 DI를 활용하면 타입 안전하고 예측 가능한 다형성을 구현할 수 있어 견고한 객체 지향 시스템을 구축할 수 있습니다!
Backend Development
· 2025-08-23
📚[Backend Development] 🌊 Java Stream API 트러블슈팅 가이드
🌊 Java Stream API 트러블슈팅 가이드 Java 8 Stream API를 사용하는 과정에서 자주 발생하는 문제와 해결 방법을 정리했습니다. 🔍 문제 1: Stream 코드 이해 어려움 📋 에러 상황 다음과 같은 Stream API 코드를 만났을 때 동작 방식을 이해하기 어려운 상황이 발생합니다. List<Stock> newStocks = IntStream.range(0, request.getQuantity()) .mapToObj(i -> createStockEntity(product)) .collect(Collectors.toList()); 🎯 원인 분석 Stream API는 함수형 프로그래밍 패러다임으로, 기존의 명령형 프로그래밍과 다른 사고방식이 필요합니다. 데이터 흐름 방식: 전통적인 for 반복문과 달리 데이터가 파이프라인을 통해 흐르는 방식 체이닝 메서드: 여러 메서드가 연결되어 하나의 작업을 수행 람다 표현식: i -> createStockEntity(product) 같은 익명 함수 사용 🔧 해결 방법 1단계: Stream 파이프라인 단계별 이해 // 🔍 단계별 분석 List<Stock> newStocks = IntStream.range(0, request.getQuantity()) // 1️⃣ 숫자 스트림 생성 .mapToObj(i -> createStockEntity(product)) // 2️⃣ 객체로 변환 .collect(Collectors.toList()); // 3️⃣ 리스트로 수집 1️⃣ 숫자 스트림 생성 IntStream.range(0, request.getQuantity()) // request.getQuantity()가 5라면: [0, 1, 2, 3, 4] 생성 2️⃣ 객체로 변환 .mapToObj(i -> createStockEntity(product)) // 각 숫자 i에 대해 createStockEntity(product) 실행 // 결과: [Stock객체1, Stock객체2, Stock객체3, Stock객체4, Stock객체5] 3️⃣ 리스트로 수집 .collect(Collectors.toList()) // Stream을 List<Stock>으로 변환 2단계: 기존 for문과 비교 전통적인 방식 // ❌ 명령형 프로그래밍 - "어떻게" 할지를 지시 List<Stock> newStocks = new ArrayList<>(); for (int i = 0; i < request.getQuantity(); i++) { Stock newStock = createStockEntity(product); newStocks.add(newStock); } Stream API 방식 // ✅ 선언형 프로그래밍 - "무엇을" 할지를 선언 List<Stock> newStocks = IntStream.range(0, request.getQuantity()) .mapToObj(i -> createStockEntity(product)) .collect(Collectors.toList()); 📚 Stream API 핵심 개념 메서드 역할 예시 IntStream.range(start, end) 정수 범위 스트림 생성 IntStream.range(0, 5) → [0,1,2,3,4] .mapToObj() 각 요소를 객체로 변환 i -> new Stock() .collect() 최종 결과물로 수집 Collectors.toList() 🔍 문제 2: Stream API 성능 오해 📋 에러 상황 “Stream API가 for문보다 느리다”는 잘못된 인식으로 인해 사용을 기피하는 경우가 있습니다. 🎯 원인 분석 Stream API의 성능 특성을 제대로 이해하지 못한 경우입니다. 병렬 처리: parallelStream()으로 멀티코어 활용 가능 지연 평가: 필요할 때까지 연산을 미루어 최적화 메모리 효율성: 중간 컬렉션 생성 없이 처리 🔧 해결 방법 성능 비교 예시 @Service @RequiredArgsConstructor public class StockService { // ✅ Stream API - 가독성과 성능 모두 우수 public List<Stock> createStocksStreamWay(Product product, int quantity) { return IntStream.range(0, quantity) .mapToObj(i -> createStockEntity(product)) .collect(Collectors.toList()); } // ✅ 병렬 처리로 성능 향상 (대량 데이터 시) public List<Stock> createStocksParallel(Product product, int quantity) { return IntStream.range(0, quantity) .parallel() // 🚀 병렬 처리 활성화 .mapToObj(i -> createStockEntity(product)) .collect(Collectors.toList()); } // 📊 전통적인 방식 public List<Stock> createStocksTraditionalWay(Product product, int quantity) { List<Stock> stocks = new ArrayList<>(quantity); // 크기 미리 할당 for (int i = 0; i < quantity; i++) { stocks.add(createStockEntity(product)); } return stocks; } private Stock createStockEntity(Product product) { return Stock.builder() .product(product) .barcodeNumber(generateBarcodeNumber()) .build(); } } 📊 성능 가이드라인 상황 권장 방식 이유 소량 데이터 (< 1000개) Stream API 가독성 우선, 성능 차이 미미 대량 데이터 (> 10000개) Parallel Stream 멀티코어 활용으로 성능 향상 CPU 집약적 작업 parallelStream() 병렬 처리 효과 극대화 🔍 문제 3: Stream 체이닝 복잡성 📋 에러 상황 여러 Stream 연산이 체이닝되어 코드가 복잡해 보이는 경우입니다. // 😵 복잡해 보이는 Stream 체이닝 List<StockResponse> result = stockRepository.findByProductCategory(category) .stream() .filter(stock -> stock.getProduct().getStockQuantity() > 0) .map(stock -> StockResponse.from(stock)) .sorted(Comparator.comparing(StockResponse::productName)) .limit(20) .collect(Collectors.toList()); 🎯 원인 분석 Stream의 각 연산 단계를 명확히 이해하지 못해 복잡하게 느껴집니다. 🔧 해결 방법 1단계: 주석으로 각 단계 설명 // ✅ 단계별 주석으로 명확하게 List<StockResponse> result = stockRepository.findByProductCategory(category) .stream() // 📊 데이터를 스트림으로 변환 .filter(stock -> stock.getProduct().getStockQuantity() > 0) // 🔍 재고가 있는 상품만 필터링 .map(stock -> StockResponse.from(stock)) // 🔄 Stock을 StockResponse로 변환 .sorted(Comparator.comparing(StockResponse::productName)) // 📈 상품명으로 정렬 .limit(20) // ✂️ 상위 20개만 선택 .collect(Collectors.toList()); // 📦 최종 결과를 리스트로 수집 2단계: 메서드 분리로 가독성 향상 @Service @RequiredArgsConstructor public class StockQueryService { // ✅ 주 메서드는 간단하게 public List<StockResponse> getTopStocksByCategory(String category) { return stockRepository.findByProductCategory(category) .stream() .filter(this::hasStock) // 🎯 메서드 참조로 가독성 향상 .map(StockResponse::from) // 🎯 정적 메서드 참조 활용 .sorted(byProductName()) // 🎯 별도 메서드로 정렬 로직 분리 .limit(20) .collect(Collectors.toList()); } // 🔧 보조 메서드들로 로직 분리 private boolean hasStock(Stock stock) { return stock.getProduct().getStockQuantity() > 0; } private Comparator<StockResponse> byProductName() { return Comparator.comparing(StockResponse::productName); } } 🎨 Stream API 실무 패턴 // 🏪 상품별 재고 집계 Map<String, Integer> stockByCategory = stocks.stream() .collect(Collectors.groupingBy( stock -> stock.getProduct().getCategory(), Collectors.summingInt(stock -> stock.getProduct().getStockQuantity()) )); // 📊 가격대별 상품 분류 Map<String, List<Product>> productsByPriceRange = products.stream() .collect(Collectors.groupingBy(product -> { BigDecimal price = product.getPrice(); if (price.compareTo(new BigDecimal("100000")) < 0) return "저가"; if (price.compareTo(new BigDecimal("500000")) < 0) return "중가"; return "고가"; })); // 🔍 조건부 필터링과 변환 Optional<Product> mostExpensiveInCategory = products.stream() .filter(product -> "전자제품".equals(product.getCategory())) .max(Comparator.comparing(Product::getPrice)); 📊 Stream API 체크리스트 ✅ 기본 사용법 확인 IntStream.range()로 반복 횟수 생성 이해됨? .mapToObj()로 객체 변환 과정 이해됨? .collect(Collectors.toList())로 최종 수집 이해됨? ✅ 성능 최적화 대량 데이터 처리 시 parallelStream() 고려? 불필요한 중간 연산 제거됨? ArrayList 초기 크기 설정 고려됨? ✅ 가독성 개선 복잡한 Stream 체이닝은 메서드로 분리됨? 람다 표현식 대신 메서드 참조 활용? 각 단계별 주석 추가됨? 🎯 실전 활용 예시 재고 관리 시스템에서의 Stream API 활용 @Service @RequiredArgsConstructor public class InventoryService { private final StockRepository stockRepository; // 📈 입고 처리 - 수량만큼 Stock 엔티티 생성 @Transactional public void processInbound(InboundRequest request) { Product product = findProductById(request.getProductId()); // 🌟 핵심: Stream API로 여러 Stock 엔티티 생성 List<Stock> newStocks = IntStream.range(0, request.getQuantity()) .mapToObj(i -> Stock.builder() .product(product) .barcodeNumber(generateBarcodeNumber(product, i)) .build()) .collect(Collectors.toList()); stockRepository.saveAll(newStocks); // 📊 상품 재고 수량 업데이트 product.addStock(request.getQuantity()); } // 🔍 카테고리별 재고 현황 조회 public Map<String, Long> getStockCountByCategory() { return stockRepository.findAllWithProduct() .stream() .collect(Collectors.groupingBy( stock -> stock.getProduct().getCategory(), Collectors.counting() )); } // ⚠️ 재고 부족 상품 알림 public List<LowStockAlert> getLowStockAlerts(int threshold) { return stockRepository.findAllWithProduct() .stream() .filter(stock -> stock.getProduct().getStockQuantity() < threshold) .map(stock -> LowStockAlert.builder() .productName(stock.getProduct().getName()) .currentStock(stock.getProduct().getStockQuantity()) .threshold(threshold) .build()) .distinct() .collect(Collectors.toList()); } } 🎉 마무리 이제 Java Stream API를 활용한 효율적이고 가독성 높은 코드를 작성할 수 있습니다! 🚀 다음 단계 권장사항 Optional과 Stream 조합: findFirst(), findAny() 등 Optional 반환 메서드 활용 Custom Collector 작성: 복잡한 집계 로직을 위한 커스텀 컬렉터 구현 성능 측정: JMH(Java Microbenchmark Harness)를 활용한 정확한 성능 측정 📞 추가 학습 리소스 Oracle Java Stream API 문서: https://docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.html 이펙티브 자바 3판: 아이템 45-48 (스트림 관련) 💡 핵심 기억할 점 Stream API는 “무엇을 할지”를 선언하는 방식으로, 코드의 의도를 명확히 표현하고 유지보수성을 높이는 현대적인 Java 개발의 핵심 기술입니다!
Backend Development
· 2025-08-20
📚[Backend Development] API 일관성 가이드 :)
🚀 Spring Boot API 일관성 완벽 가이드 Java Backend 개발에서 일관되고 예측 가능한 API를 구축하기 위한 실무 중심 가이드입니다. 🎯 왜 API 일관성이 중요한가? 📊 일관성 없는 API의 문제점 // ❌ BAD: 일관성 없는 API 응답들 // GET /users/1 { "id": 1, "name": "김개발" } // GET /products/1 { "success": true, "data": { "product_id": 1, "product_name": "맥북" } } // POST /orders (에러 발생) { "error": "Invalid request", "code": 400 } 클라이언트 개발자의 고통: 🤯 API마다 다른 응답 구조로 인한 혼란 🐛 예측할 수 없는 에러 처리 로직 ⏰ 개발 시간 증가 및 유지보수 어려움 💡 해결책 1: 공통 응답 래퍼 클래스 🏗️ ApiResponse 클래스 설계 // ✅ 모든 API가 사용할 공통 응답 구조 @Getter @Builder @NoArgsConstructor @AllArgsConstructor public class ApiResponse<T> { private boolean success; private T data; private ApiError error; private LocalDateTime timestamp; // 성공 응답 생성 메서드 public static <T> ApiResponse<T> success(T data) { return ApiResponse.<T>builder() .success(true) .data(data) .timestamp(LocalDateTime.now()) .build(); } // 실패 응답 생성 메서드 public static <T> ApiResponse<T> failure(String errorCode, String message) { return ApiResponse.<T>builder() .success(false) .error(ApiError.of(errorCode, message)) .timestamp(LocalDateTime.now()) .build(); } } @Getter @Builder @NoArgsConstructor @AllArgsConstructor public class ApiError { private String code; private String message; private List<FieldError> details; public static ApiError of(String code, String message) { return ApiError.builder() .code(code) .message(message) .build(); } } @Getter @Builder @NoArgsConstructor @AllArgsConstructor public class FieldError { private String field; private String message; private Object rejectedValue; } 🎯 실제 컨트롤러 적용 예시 @RestController @RequestMapping("/api/v1/products") @RequiredArgsConstructor public class ProductController { private final ProductService productService; // ✅ 상품 생성 - 일관된 성공 응답 @PostMapping public ResponseEntity<ApiResponse<ProductResponse>> createProduct( @Valid @RequestBody ProductCreateRequest request) { ProductResponse product = productService.createProduct(request); ApiResponse<ProductResponse> response = ApiResponse.success(product); return ResponseEntity.status(HttpStatus.CREATED).body(response); } // ✅ 상품 조회 - 일관된 성공 응답 @GetMapping("/{id}") public ResponseEntity<ApiResponse<ProductResponse>> getProduct(@PathVariable Long id) { ProductResponse product = productService.getProduct(id); ApiResponse<ProductResponse> response = ApiResponse.success(product); return ResponseEntity.ok(response); } // ✅ 상품 목록 조회 - 페이징 포함 @GetMapping public ResponseEntity<ApiResponse<PageResponse<ProductResponse>>> getProducts( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { PageResponse<ProductResponse> products = productService.getProducts(page, size); ApiResponse<PageResponse<ProductResponse>> response = ApiResponse.success(products); return ResponseEntity.ok(response); } } 🌟 일관된 응답 결과 // ✅ GOOD: 모든 API가 동일한 구조 사용 // POST /api/v1/products (성공) { "success": true, "data": { "id": 1, "name": "맥북 프로 16인치", "price": 2490000, "stockQuantity": 10 }, "error": null, "timestamp": "2025-08-19T10:00:00" } // GET /api/v1/products/999 (실패 - 존재하지 않는 상품) { "success": false, "data": null, "error": { "code": "PRODUCT_NOT_FOUND", "message": "상품을 찾을 수 없습니다.", "details": null }, "timestamp": "2025-08-19T10:00:00" } 💡 해결책 2: 글로벌 예외 처리 🛡️ @RestControllerAdvice로 통합 예외 처리 @RestControllerAdvice @Slf4j public class GlobalExceptionHandler { // ✅ 비즈니스 예외 처리 @ExceptionHandler(BusinessException.class) public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) { log.warn("Business exception occurred: {}", ex.getMessage()); ApiResponse<Void> response = ApiResponse.failure(ex.getErrorCode(), ex.getMessage()); return ResponseEntity.status(ex.getHttpStatus()).body(response); } // ✅ 유효성 검사 실패 처리 @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity<ApiResponse<Void>> handleValidationException( MethodArgumentNotValidException ex) { List<FieldError> fieldErrors = ex.getBindingResult() .getFieldErrors() .stream() .map(error -> FieldError.builder() .field(error.getField()) .message(error.getDefaultMessage()) .rejectedValue(error.getRejectedValue()) .build()) .collect(Collectors.toList()); ApiError apiError = ApiError.builder() .code("VALIDATION_ERROR") .message("입력 값이 유효하지 않습니다.") .details(fieldErrors) .build(); ApiResponse<Void> response = ApiResponse.<Void>builder() .success(false) .error(apiError) .timestamp(LocalDateTime.now()) .build(); return ResponseEntity.badRequest().body(response); } // ✅ 예상치 못한 서버 오류 처리 @ExceptionHandler(Exception.class) public ResponseEntity<ApiResponse<Void>> handleUnexpectedException(Exception ex) { log.error("Unexpected exception occurred", ex); ApiResponse<Void> response = ApiResponse.failure( "INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다." ); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } } 🎯 커스텀 비즈니스 예외 클래스 @Getter public class BusinessException extends RuntimeException { private final String errorCode; private final HttpStatus httpStatus; public BusinessException(String errorCode, String message, HttpStatus httpStatus) { super(message); this.errorCode = errorCode; this.httpStatus = httpStatus; } // 자주 사용하는 예외들을 정적 팩토리 메서드로 제공 public static BusinessException notFound(String resource) { return new BusinessException( "RESOURCE_NOT_FOUND", resource + "을(를) 찾을 수 없습니다.", HttpStatus.NOT_FOUND ); } public static BusinessException badRequest(String message) { return new BusinessException( "BAD_REQUEST", message, HttpStatus.BAD_REQUEST ); } } 💡 해결책 3: 페이징 응답 표준화 📄 PageResponse 클래스 설계 @Getter @Builder @NoArgsConstructor @AllArgsConstructor public class PageResponse<T> { private List<T> content; private PageInfo pageInfo; public static <T> PageResponse<T> from(Page<T> page) { return PageResponse.<T>builder() .content(page.getContent()) .pageInfo(PageInfo.from(page)) .build(); } } @Getter @Builder @NoArgsConstructor @AllArgsConstructor public class PageInfo { private int page; // 현재 페이지 (0-based) private int size; // 페이지 당 아이템 수 private long totalElements; // 전체 아이템 수 private int totalPages; // 전체 페이지 수 private boolean first; // 첫 번째 페이지 여부 private boolean last; // 마지막 페이지 여부 public static PageInfo from(Page<?> page) { return PageInfo.builder() .page(page.getNumber()) .size(page.getSize()) .totalElements(page.getTotalElements()) .totalPages(page.getTotalPages()) .first(page.isFirst()) .last(page.isLast()) .build(); } } 🎯 서비스 레이어에서 활용 @Service @RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; public PageResponse<ProductResponse> getProducts(int page, int size) { Pageable pageable = PageRequest.of(page, size); Page<Product> productPage = productRepository.findAll(pageable); // Entity를 DTO로 변환 Page<ProductResponse> responsePage = productPage.map(ProductResponse::from); return PageResponse.from(responsePage); } } 🌟 일관된 페이징 응답 // ✅ GOOD: 모든 목록 API가 동일한 페이징 구조 사용 { "success": true, "data": { "content": [ { "id": 1, "name": "맥북 프로 16인치", "price": 2490000 }, { "id": 2, "name": "아이폰 15 Pro", "price": 1350000 } ], "pageInfo": { "page": 0, "size": 10, "totalElements": 25, "totalPages": 3, "first": true, "last": false } }, "error": null, "timestamp": "2025-08-19T10:00:00" } 💡 해결책 4: 네이밍 컨벤션 통일 🎨 JSON 필드 네이밍 규칙 Jackson 설정으로 자동 변환 # application.yml spring: jackson: property-naming-strategy: SNAKE_CASE # camelCase -> snake_case 변환 # 또는 LOWER_CAMEL_CASE (기본값, camelCase 유지) DTO 클래스 예시 // ✅ GOOD: 일관된 네이밍 컨벤션 @Getter @Builder @NoArgsConstructor @AllArgsConstructor public class ProductResponse { private Long productId; // JSON: product_id (snake_case 설정 시) private String productName; // JSON: product_name private BigDecimal unitPrice; // JSON: unit_price private Integer stockQuantity; // JSON: stock_quantity private String categoryName; // JSON: category_name private LocalDateTime createdAt; // JSON: created_at public static ProductResponse from(Product product) { return ProductResponse.builder() .productId(product.getProductId()) .productName(product.getName()) .unitPrice(product.getPrice()) .stockQuantity(product.getStockQuantity()) .categoryName(product.getCategory()) .createdAt(product.getCreatedAt()) .build(); } } 🌐 REST API URL 컨벤션 // ✅ GOOD: RESTful하고 일관된 URL 구조 @RequestMapping("/api/v1/products") // 복수형 명사 사용 public class ProductController { @GetMapping // GET /api/v1/products @GetMapping("/{id}") // GET /api/v1/products/1 @PostMapping // POST /api/v1/products @PutMapping("/{id}") // PUT /api/v1/products/1 @DeleteMapping("/{id}") // DELETE /api/v1/products/1 // 하위 리소스 접근 @GetMapping("/{id}/stocks") // GET /api/v1/products/1/stocks } 💡 해결책 5: HTTP 상태 코드 표준화 📊 상태 코드 매핑 가이드 @RestController @RequestMapping("/api/v1/products") @RequiredArgsConstructor public class ProductController { // ✅ 201 Created - 리소스 생성 성공 @PostMapping public ResponseEntity<ApiResponse<ProductResponse>> createProduct( @Valid @RequestBody ProductCreateRequest request) { ProductResponse product = productService.createProduct(request); return ResponseEntity.status(HttpStatus.CREATED) .body(ApiResponse.success(product)); } // ✅ 200 OK - 조회/수정 성공 @GetMapping("/{id}") public ResponseEntity<ApiResponse<ProductResponse>> getProduct(@PathVariable Long id) { ProductResponse product = productService.getProduct(id); return ResponseEntity.ok(ApiResponse.success(product)); } // ✅ 204 No Content - 삭제 성공 @DeleteMapping("/{id}") public ResponseEntity<Void> deleteProduct(@PathVariable Long id) { productService.deleteProduct(id); return ResponseEntity.noContent().build(); } } 🎯 상태 코드별 사용 기준 HTTP 상태 코드 사용 상황 응답 바디 200 OK 조회, 수정 성공 ✅ 데이터 포함 201 Created 생성 성공 ✅ 생성된 리소스 정보 204 No Content 삭제 성공 ❌ 바디 없음 400 Bad Request 유효성 검사 실패 ✅ 에러 정보 401 Unauthorized 인증 실패 ✅ 에러 정보 403 Forbidden 권한 없음 ✅ 에러 정보 404 Not Found 리소스 없음 ✅ 에러 정보 500 Internal Server Error 서버 내부 오류 ✅ 에러 정보 🛠️ 실무 적용 체크리스트 ✅ API 응답 구조 통일 ApiResponse<T> 공통 래퍼 클래스 구현됨 성공/실패 응답이 동일한 구조를 가짐 타임스탬프가 모든 응답에 포함됨 ✅ 예외 처리 표준화 @RestControllerAdvice로 글로벌 예외 처리 구현 비즈니스 예외와 시스템 예외를 구분하여 처리 유효성 검사 실패 시 상세한 필드 오류 정보 제공 ✅ 페이징 응답 통일 PageResponse<T> 클래스로 페이징 정보 표준화 Spring Data JPA의 Page 객체 활용 페이징 메타데이터 (총 개수, 페이지 수 등) 포함 ✅ 네이밍 컨벤션 통일 JSON 필드 네이밍 규칙 (camelCase 또는 snake_case) 선택 및 적용 REST API URL 구조 표준화 (복수형 명사, 계층 구조) 직관적이고 일관된 변수/메서드명 사용 ✅ HTTP 상태 코드 표준화 상황별 적절한 HTTP 상태 코드 사용 상태 코드와 응답 바디 구조의 일관성 유지 클라이언트가 상태 코드만으로 결과를 예측할 수 있음 🎉 API 일관성의 효과 📈 개발 생산성 향상 클라이언트 개발자: 예측 가능한 API로 빠른 개발 Backend 개발자: 표준화된 구조로 일관된 코드 작성 QA 테스터: 명확한 응답 구조로 효율적인 테스트 🔧 유지보수성 개선 에러 디버깅: 일관된 에러 구조로 빠른 문제 파악 API 문서화: 표준화된 구조로 자동화된 문서 생성 코드 리뷰: 일관된 패턴으로 리뷰 시간 단축 🚀 확장성 확보 새로운 API 추가: 기존 패턴을 따라 빠른 개발 팀 확장: 새로운 개발자도 쉽게 패턴 학습 마이크로서비스: 서비스 간 일관된 통신 구조 📚 추가 학습 자료 Spring Boot 공식 문서: https://spring.io/projects/spring-boot OpenAPI/Swagger: https://swagger.io/specification/ REST API 베스트 프랙티스: RESTful Web Services 설계 가이드 HTTP 상태 코드 상세: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status API 일관성은 한 번 잘 설계해두면 모든 팀원이 오랫동안 혜택을 받는 투자입니다! 🎯
Backend Development
· 2025-08-19
📚[Backend Development] 🚨 Spring Boot + Lombok 트러블슈팅 가이드
🚨 Spring Boot + Lombok 트러블슈팅 가이드 Spring Boot와 Lombok을 사용하는 과정에서 직접 맞닥뜨린 에러를 해결하는 과정을 기록해 보았습니다. 🔍 문제 1: Lombok Getter 메서드를 찾을 수 없음 📋 에러 상황 ProductManagementApplication 실행 시 다음과 같은 컴파일 에러가 발생했습니다. /Users/kobe/Desktop/ProductManagement/src/main/java/com/kobe/productmanagement/dto/response/StockResponse.java:35: error: cannot find symbol stock.getStockId(), ^ symbol: method getStockId() location: variable stock of type Stock 🎯 원인 분석 cannot find symbol 에러는 Java 컴파일러가 코드에서 참조하는 메서드나 변수를 찾지 못할 때 발생합니다. 컴파일 시점 문제: Stock 클래스에서 getStockId() 메서드를 찾을 수 없음 Lombok 동작 실패: @Getter 어노테이션이 제대로 처리되지 않아 getter 메서드가 생성되지 않음 IDE vs 실제 빌드: IDE에서는 정상 작동하지만 실제 빌드 시 실패 🔧 해결 방법 1단계: Stock 엔티티 클래스 확인 @Entity @Table(name = "stocks") @Getter // ⭐ 이 어노테이션이 있는지 반드시 확인! @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Stock extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "stock_id") private Long stockId; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "product_id", nullable = false) private Product product; @Column(name = "barcode_number", unique = true) private String barcodeNumber; // Lombok이 자동으로 생성할 메서드들: // - getStockId() // - getProduct() // - getBarcodeNumber() } 2단계: 클린 빌드 실행 # Gradle 사용자 ./gradlew clean build # Maven 사용자 ./mvnw clean install ✅ 확인 포인트 @Getter 어노테이션이 클래스에 추가되어 있는가? import lombok.Getter; import 구문이 있는가? 클린 빌드를 실행했는가? 🔍 문제 2: 대량의 Lombok 메서드 누락 에러 📋 에러 상황 ./gradlew clean build 실행 시 25개의 컴파일 에러가 한번에 발생했습니다. > Task :compileJava FAILED error: cannot find symbol symbol: method getStockId() location: variable stock of type Stock error: cannot find symbol symbol: method getProduct() location: variable stock of type Stock error: cannot find symbol symbol: method builder() location: class Product error: cannot find symbol symbol: method getName() location: variable request of type ProductCreateRequest 25 errors 🎯 원인 분석 IDE에서는 Lombok이 정상 작동하지만, Gradle 빌드 시에는 Lombok 어노테이션 프로세서가 동작하지 않고 있습니다. 💡 핵심 개념 이해 IDE의 Lombok 플러그인: 개발 중 코드 하이라이팅과 자동완성을 위함 빌드 도구의 어노테이션 프로세서: 실제 .class 파일 생성 시 메서드를 만드는 역할 🔧 해결 방법 build.gradle 설정 수정 dependencies { // 기존 의존성들 implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' // ⭐ Lombok 설정 추가 (가장 중요!) compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' // 테스트용 어노테이션 프로세서도 추가 testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' // 데이터베이스 드라이버 runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'com.h2database:h2' // 테스트용 H2 DB // 테스트 의존성 testImplementation 'org.springframework.boot:spring-boot-starter-test' } 실무 코드 예시 설정 후 다음과 같은 코드들이 정상 작동합니다: // ✅ ProductService.java @Service @RequiredArgsConstructor public class ProductService { private final ProductRepository productRepository; public Long createProduct(ProductCreateRequest request) { Product newProduct = Product.builder() // @Builder 어노테이션으로 생성 .name(request.getName()) // @Getter로 생성된 메서드 .price(request.getPrice()) .stockQuantity(request.getStockQuantity()) .category(request.getCategory()) .costPrice(request.getCostPrice()) .productSupplier(request.getProductSupplier()) .barcodeNumber(request.getBarcodeNumber()) .build(); Product savedProduct = productRepository.save(newProduct); return savedProduct.getProductId(); // @Getter로 생성된 메서드 } } // ✅ StockResponse.java public record StockResponse( Long stockId, String productName, BigDecimal costPrice, BigDecimal sellingPrice, Integer stockQuantity, String category, LocalDateTime createdAt, LocalDateTime updatedAt, String productSupplier ) { public static StockResponse from(Stock stock) { return new StockResponse( stock.getStockId(), // ✅ 정상 작동 stock.getProduct().getName(), // ✅ 정상 작동 stock.getProduct().getCostPrice(), stock.getProduct().getCostPrice(), stock.getProduct().getStockQuantity(), stock.getProduct().getCategory(), stock.getCreatedAt(), // BaseTimeEntity에서 상속 stock.getUpdatedAt(), stock.getProduct().getProductSupplier() ); } } 📚 어노테이션 프로세서 설정 설명 설정 역할 compileOnly 컴파일 시점에만 필요하고, 런타임에는 불필요한 의존성 annotationProcessor 핵심! 컴파일 시 어노테이션을 실제 코드로 변환하는 프로세서 testCompileOnly 테스트 코드 컴파일 시에만 필요한 의존성 testAnnotationProcessor 테스트 코드에서도 Lombok 어노테이션 처리 🔍 문제 3: 테스트 실행 시 데이터베이스 연결 실패 📋 에러 상황 빌드는 성공했지만 테스트 실행 시 다음 에러가 발생했습니다. > Task :test FAILED ProductManagementApplicationTests > contextLoads() FAILED java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180 Caused by: org.springframework.beans.factory.BeanCreationException Caused by: org.hibernate.service.spi.ServiceException Caused by: org.hibernate.HibernateException at DialectFactoryImpl.java:191 FAILURE: Build failed with an exception. 🎯 원인 분석 Hibernate가 데이터베이스 방언(Dialect)을 결정하지 못해 발생하는 문제입니다. 테스트 환경의 드라이버 부족: runtimeOnly로 선언된 MySQL 드라이버는 테스트에서 사용할 수 없음 설정 파일 중복: 테스트도 메인 application.properties를 사용해 MySQL 연결 시도 🔧 해결 방법 1단계: 테스트용 설정 파일 생성 📁 src/test/resources/application.yml 파일을 새로 생성합니다. # =============================== # 🧪 TEST DATABASE (H2 In-Memory) # =============================== # Spring Boot가 H2 데이터베이스를 자동으로 설정하도록 합니다. spring: # JPA/Hibernate 설정 jpa: hibernate: ddl-auto: create-drop show-sql: true properties: hibernate: format_sql: true # H2 Console 활성화 (디버깅용) h2: console: enabled: true path: /h2-console # 로깅 설정 logging: level: org.springframework.web: DEBUG com.kobe.productmanagement: DEBUG 2단계: 실제 테스트 코드 예시 @SpringBootTest @Transactional class ProductServiceTest { @Autowired private ProductService productService; @Autowired private ProductRepository productRepository; @Test @DisplayName("상품 생성 테스트 - H2 데이터베이스 사용") void createProduct_Success() { // given ProductCreateRequest request = ProductCreateRequest.builder() .name("맥북 프로 16인치") .price(new BigDecimal("2490000")) .stockQuantity(10) .category("전자제품") .costPrice(new BigDecimal("2000000")) .productSupplier("Apple Korea") .barcodeNumber("8801234567890") .build(); // when Long productId = productService.createProduct(request); // then assertThat(productId).isNotNull(); Optional<Product> savedProduct = productRepository.findById(productId); assertThat(savedProduct).isPresent(); assertThat(savedProduct.get().getName()).isEqualTo("맥북 프로 16인치"); } @Test @DisplayName("재고 조회 테스트 - 연관관계 포함") void findStockWithProduct_Success() { // given - 테스트 데이터 준비 Product product = Product.builder() .name("아이폰 15 Pro") .price(new BigDecimal("1350000")) .stockQuantity(5) .category("스마트폰") .costPrice(new BigDecimal("1100000")) .productSupplier("Apple Korea") .barcodeNumber("8801234567891") .build(); Product savedProduct = productRepository.save(product); Stock stock = Stock.builder() .product(savedProduct) .barcodeNumber("STOCK_8801234567891") .build(); // when & then - H2 데이터베이스에서 정상 작동 assertThat(stock.getProduct().getName()).isEqualTo("아이폰 15 Pro"); } } 🏗️ 환경별 설정 구조 src/ ├── main/ │ └── resources/ │ └── application.yml # 🚀 운영/개발용 (MySQL) └── test/ └── resources/ └── application.yml # 🧪 테스트용 (H2) 운영 환경 설정 (main/resources) # MySQL 데이터베이스 연결 spring: datasource: url: jdbc:mysql://localhost:3306/product_management username: root password: password jpa: hibernate: ddl-auto: validate 테스트 환경 설정 (test/resources) # H2 인메모리 데이터베이스 (자동 설정) spring: jpa: hibernate: ddl-auto: create-drop h2: console: enabled: true 📊 문제 해결 체크리스트 ✅ Lombok 설정 확인 @Getter, @Builder 등 어노테이션이 클래스에 있는가? build.gradle에 annotationProcessor 'org.projectlombok:lombok' 추가됨? IDE에 Lombok 플러그인이 설치되어 있는가? ✅ 데이터베이스 설정 확인 테스트용 application.yml 파일이 별도로 있는가? testImplementation 'com.h2database:h2' 의존성이 추가됨? 운영 환경과 테스트 환경의 데이터베이스가 분리되어 있는가? ✅ 빌드 및 테스트 ./gradlew clean build 명령어가 성공하는가? 모든 테스트가 통과하는가? IDE에서 개별 테스트 실행이 가능한가? 🎉 마무리 이제 Spring Boot + Lombok 프로젝트에서 발생하는 주요 문제들을 해결할 수 있습니다! 🚀 다음 단계 권장사항 CI/CD 파이프라인 구축: GitHub Actions나 Jenkins를 활용한 자동 빌드 테스트 커버리지 측정: JaCoCo를 활용한 코드 커버리지 확인 프로파일별 설정 분리: application-dev.yml, application-prod.yml 등 📞 추가 도움이 필요하다면? Spring Boot 공식 문서: https://spring.io/projects/spring-boot Lombok 공식 문서: https://projectlombok.org/
Backend Development
· 2025-08-18
📚[Backend Development] Java 백엔드 개발자를 위한 클래스 완전 정복!!
🏗️ Java 백엔드 개발자를 위한 클래스 완전 정복 !! 🤔 클래스란 무엇인가? 클래스(Class)는 객체(Object)를 만들어내기 위한 ‘설계도’ 또는 ‘틀’입니다. 🥮 붕어빵으로 이해하는 클래스 붕어빵 틀 = 클래스 (설계도) 실제 붕어빵 = 객체 (인스턴스) 팥, 슈크림 등 = 필드 (속성) 굽기, 포장하기 등 = 메서드 (행위) // 🏪 Product 클래스 (상품 설계도) public class Product { // 붕어빵의 '맛'처럼 각 상품이 가지는 고유한 속성들 private String name; // 상품명 private int price; // 가격 private int stockQuantity; // 재고수량 } // 실제 상품 객체들 생성 Product apple = new Product(); // 🍎 사과 객체 Product banana = new Product(); // 🍌 바나나 객체 🧩 클래스의 구성요소 클래스는 크게 두 가지 핵심 요소로 이루어져 있습니다. 1. 📦 필드 (Fields) - 속성과 상태 객체가 가질 데이터를 정의하는 부분입니다. public class Product { // ✅ 필드들 - "이 객체는 어떤 정보를 가지고 있는가?" private String name; // 상품명 private int price; // 가격 private int stockQuantity; // 재고수량 private String category; // 카테고리 private boolean isActive; // 판매중 여부 } 특징: 객체의 상태(State)를 나타냅니다 각 객체마다 고유한 값을 가집니다 데이터 타입을 명시해야 합니다 2. ⚙️ 메서드 (Methods) - 행위와 기능 객체가 수행할 수 있는 동작을 정의하는 부분입니다. public class Product { private String name; private int price; private int stockQuantity; // ✅ 메서드들 - "이 객체는 무엇을 할 수 있는가?" // 재고 감소 public void decreaseStock(int quantity) { if (quantity <= 0) { throw new IllegalArgumentException("수량은 양수여야 합니다"); } if (this.stockQuantity < quantity) { throw new IllegalStateException("재고가 부족합니다"); } this.stockQuantity -= quantity; } // 가격 변경 public void changePrice(int newPrice) { if (newPrice <= 0) { throw new IllegalArgumentException("가격은 0보다 커야 합니다"); } this.price = newPrice; } // 재고 확인 public boolean hasStock() { return this.stockQuantity > 0; } // 상품 정보 조회 public String getProductInfo() { return String.format("상품명: %s, 가격: %d원, 재고: %d개", name, price, stockQuantity); } } 특징: 객체의 행동(Behavior)을 나타냅니다 필드 값을 이용해 비즈니스 로직을 수행합니다 매개변수와 반환값을 가질 수 있습니다 🎯 언제 클래스를 사용할까? ✅ 적합한 상황 동일한 구조의 객체가 여러 개 필요한 경우 // 🛒 쇼핑몰에서 수많은 상품들을 관리해야 할 때 Product laptop = new Product("노트북", 1500000, 10); Product mouse = new Product("마우스", 25000, 50); Product keyboard = new Product("키보드", 80000, 30); 현실 세계의 개념을 코드로 표현해야 하는 경우 // 🏪 전자상거래 도메인 모델링 public class Customer { /* 고객 */ } public class Order { /* 주문 */ } public class Payment { /* 결제 */ } public class Delivery { /* 배송 */ } 관련된 데이터와 기능을 묶어서 관리해야 하는 경우 // 📊 계산기 기능을 하나의 클래스로 묶기 public class Calculator { private double result; // 계산 결과 저장 public void add(double value) { /* 더하기 */ } public void subtract(double value) { /* 빼기 */ } public double getResult() { /* 결과 조회 */ } } 🚀 왜 클래스를 사용할까? 1. 🔄 코드의 재사용성 (Reusability) // ❌ 클래스 없이 개발하면... String product1Name = "노트북"; int product1Price = 1500000; int product1Stock = 10; String product2Name = "마우스"; int product2Price = 25000; int product2Stock = 50; // 매번 변수를 반복해서 선언해야 함 😵 // ✅ 클래스를 사용하면! Product product1 = new Product("노트북", 1500000, 10); Product product2 = new Product("마우스", 25000, 50); // 깔끔하고 일관된 구조! 😊 2. 📂 체계적인 코드 관리 (Organization) // ✅ Product 관련 모든 것이 한 곳에! public class Product { // 상품 데이터 private String name; private int price; // 상품 기능 public void validatePrice() { /* 가격 검증 */ } public void applyDiscount() { /* 할인 적용 */ } public void updateStock() { /* 재고 업데이트 */ } } 3. 🌍 현실 세계 모델링 (Domain Modeling) // 🏦 은행 시스템 예시 public class Account { private String accountNumber; // 계좌번호 private long balance; // 잔액 public void deposit(long amount) { // 입금 this.balance += amount; } public void withdraw(long amount) { // 출금 if (balance >= amount) { this.balance -= amount; } } } 4. 💊 데이터 보호 (캡슐화, Encapsulation) public class BankAccount { private long balance; // ✅ private으로 직접 접근 차단 // ✅ 안전한 방법으로만 잔액 변경 가능 public void deposit(long amount) { if (amount <= 0) { throw new IllegalArgumentException("입금액은 양수여야 합니다"); } this.balance += amount; } // ❌ 외부에서 balance에 직접 접근 불가 // account.balance = -1000000; // 컴파일 에러! } 🛠️ 실전 클래스 작성 가이드 📋 클래스 설계 체크리스트 // ✅ 좋은 클래스 예시 public class Product { // 1. 필드는 private으로 보호 private ProductId id; private String name; private Money price; private Stock stock; // 2. 생성자로 필수 데이터 보장 public Product(ProductId id, String name, Money price) { this.id = Objects.requireNonNull(id); this.name = validateName(name); this.price = Objects.requireNonNull(price); this.stock = Stock.zero(); } // 3. 비즈니스 로직을 메서드로 표현 public void changePrice(Money newPrice) { if (newPrice.isLessThanOrEqual(Money.zero())) { throw new IllegalArgumentException("상품 가격은 0보다 커야 합니다"); } this.price = newPrice; } // 4. 의미있는 메서드명 사용 public boolean isAvailable() { return stock.hasQuantity(); } // 5. 필요한 경우에만 getter 제공 public String getName() { return name; } } ⚠️ 피해야 할 안티패턴 // ❌ 나쁜 클래스 예시 public class Product { // 1. 모든 필드가 public (캡슐화 위반) public String name; public int price; public int stock; // 2. 의미없는 getter/setter만 존재 (빈약한 도메인 모델) public String getName() { return name; } public void setName(String name) { this.name = name; } public int getPrice() { return price; } public void setPrice(int price) { this.price = price; } // 3. 비즈니스 로직이 없음 // 실제 상품의 행동이나 규칙이 표현되지 않음 } 🎯 백엔드 개발에서의 클래스 활용 🏪 전자상거래 도메인 예시 // 📦 주문 애그리게이트 public class Order { private OrderId id; private CustomerId customerId; private List<OrderItem> items = new ArrayList<>(); private OrderStatus status; private LocalDateTime orderedAt; public void addItem(Product product, int quantity) { validateCanAddItem(); OrderItem item = new OrderItem(product, quantity); items.add(item); } public void confirm() { if (status != OrderStatus.PENDING) { throw new IllegalStateException("대기 중인 주문만 확정할 수 있습니다"); } this.status = OrderStatus.CONFIRMED; } } // 💰 결제 서비스 @Service public class PaymentService { public PaymentResult processPayment(Order order, PaymentMethod method) { validateOrder(order); Money totalAmount = order.calculateTotal(); Payment payment = Payment.create(order.getId(), totalAmount, method); return paymentGateway.process(payment); } } 📚 핵심 용어 정리 용어 영어 설명 예시 클래스 Class 객체를 만들기 위한 설계도 public class Product { } 객체 Object 클래스로부터 생성된 실체 Product apple = new Product(); 인스턴스 Instance 메모리에 할당된 객체 apple은 Product의 인스턴스 필드 Field 객체의 상태를 나타내는 변수 private String name; 메서드 Method 객체의 행동을 나타내는 함수 public void changePrice() { } 생성자 Constructor 객체를 초기화하는 특별한 메서드 public Product(String name) { } 캡슐화 Encapsulation 데이터와 메서드를 하나로 묶고 보호 private 접근 제어자 사용 🎉 마무리 클래스는 Java 백엔드 개발의 핵심 기초입니다! 🚦 다음 단계 학습 로드맵 상속 (Inheritance) - 클래스 간의 관계 이해 다형성 (Polymorphism) - 같은 메서드, 다른 동작 추상화 (Abstraction) - 인터페이스와 추상 클래스 컬렉션 (Collections) - 객체들을 효율적으로 관리 디자인 패턴 - 검증된 설계 해법들 💡 실습 추천 간단한 쇼핑몰 상품 관리 시스템 만들기 은행 계좌 클래스로 입출금 기능 구현하기 학생 성적 관리 시스템 설계해보기 클래스를 정복하면 객체지향 프로그래밍의 문이 활짝 열립니다! 🚀
Backend Development
· 2025-08-16
📚[Backend Development] Java 백엔드 개발자를 위한 DDD 뿌수기 !!
🏗️ Java 백엔드 개발자를 위한 DDD 뿌수기 !! 📚 DDD란 무엇인가? Domain-Driven Design(DDD)는 복잡한 비즈니스 도메인을 소프트웨어 모델로 효과적으로 변환하는 설계 방법론입니다. 🎯 핵심 철학 비즈니스 도메인이 설계의 중심 도메인 전문가와 개발자의 긴밀한 협업 지속적인 모델 개선과 발전 ⏰ 언제 DDD를 사용할까? ✅ 적합한 상황 복잡한 비즈니스 규칙: 단순 CRUD를 넘어서는 도메인 로직 대규모 시스템: 여러 팀이 협업하는 환경 장기 프로젝트: 지속적인 기능 추가와 변경이 예상됨 도메인 전문성 필요: 비즈니스 규칙의 정확한 이해가 중요 ❌ 부적합한 상황 단순한 CRUD 애플리케이션 소규모 프로젝트 (개발자 1-2명) 명확하고 변경이 적은 도메인 🏢 어디서 DDD를 활용할까? 🎯 주요 적용 분야 전자상거래: 주문, 결제, 배송, 재고 관리 금융 서비스: 계좌, 거래, 대출, 보험 의료 시스템: 진료, 예약, 처방, 청구 물류: 운송, 창고, 추적 ERP: 인사, 회계, 구매, 판매 🛠️ DDD 핵심 구성요소와 Java 구현 1. 📦 Entity (엔티티) 특징: 고유한 식별자를 가지며, 생명주기 동안 식별성이 유지됩니다. // ❌ 잘못된 Entity 예시 public class Product { private String name; private BigDecimal price; // getter, setter만 있는 빈약한 도메인 모델 } // ✅ 올바른 Entity 예시 @Entity public class Product { @Id private ProductId id; private String name; private Money price; private Stock stock; private ProductStatus status; // 생성자는 필수 데이터만 받음 public Product(ProductId id, String name, Money price) { this.id = Objects.requireNonNull(id); this.name = validateName(name); this.price = Objects.requireNonNull(price); this.status = ProductStatus.ACTIVE; this.stock = Stock.zero(); } // 비즈니스 로직을 메서드로 표현 public void changePrice(Money newPrice) { if (newPrice.isLessThanOrEqual(Money.zero())) { throw new IllegalArgumentException("상품 가격은 0보다 커야 합니다"); } this.price = newPrice; } public void addStock(int quantity) { if (quantity <= 0) { throw new IllegalArgumentException("추가할 재고 수량은 양수여야 합니다"); } this.stock = stock.add(quantity); } public boolean isAvailable() { return status == ProductStatus.ACTIVE && stock.hasQuantity(); } // 도메인 이벤트 발생 public void discontinue() { this.status = ProductStatus.DISCONTINUED; // ProductDiscontinuedEvent 발행 } } 2. 💎 Value Object (값 객체) 특징: 값 자체로 동일성을 판단하며, 불변성을 가집니다. // Money Value Object public class Money { private final BigDecimal amount; private final Currency currency; public Money(BigDecimal amount, Currency currency) { this.amount = Objects.requireNonNull(amount); this.currency = Objects.requireNonNull(currency); if (amount.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException("금액은 음수일 수 없습니다"); } } public static Money won(long amount) { return new Money(BigDecimal.valueOf(amount), Currency.getInstance("KRW")); } public Money add(Money other) { validateSameCurrency(other); return new Money(this.amount.add(other.amount), this.currency); } public boolean isGreaterThan(Money other) { validateSameCurrency(other); return this.amount.compareTo(other.amount) > 0; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null || getClass() != obj.getClass()) return false; Money money = (Money) obj; return Objects.equals(amount, money.amount) && Objects.equals(currency, money.currency); } } // Address Value Object public class Address { private final String zipCode; private final String street; private final String city; public Address(String zipCode, String street, String city) { this.zipCode = validateZipCode(zipCode); this.street = validateNotEmpty(street, "도로명"); this.city = validateNotEmpty(city, "도시"); } // 불변 객체이므로 변경 시 새로운 인스턴스 반환 public Address changeStreet(String newStreet) { return new Address(this.zipCode, newStreet, this.city); } } 3. 🎯 Aggregate (애그리게잇) 특징: 관련된 객체들을 하나의 일관성 있는 단위로 묶습니다. // Order Aggregate @Entity public class Order { @Id private OrderId id; private CustomerId customerId; private OrderStatus status; private List<OrderItem> items = new ArrayList<>(); private Money totalAmount; private Address deliveryAddress; // Aggregate Root는 모든 변경의 진입점 public void addItem(ProductId productId, int quantity, Money unitPrice) { validateCanAddItem(); OrderItem newItem = new OrderItem(productId, quantity, unitPrice); items.add(newItem); recalculateTotal(); // 도메인 이벤트 발행 DomainEvents.raise(new ItemAddedToOrderEvent(this.id, productId, quantity)); } public void confirm() { if (status != OrderStatus.PENDING) { throw new IllegalStateException("대기 중인 주문만 확정할 수 있습니다"); } if (items.isEmpty()) { throw new IllegalStateException("주문 항목이 없습니다"); } this.status = OrderStatus.CONFIRMED; DomainEvents.raise(new OrderConfirmedEvent(this.id, this.customerId)); } // 내부 일관성 유지 private void recalculateTotal() { this.totalAmount = items.stream() .map(OrderItem::getSubtotal) .reduce(Money.zero(), Money::add); } // Aggregate 내부 Entity @Entity public static class OrderItem { private ProductId productId; private int quantity; private Money unitPrice; public Money getSubtotal() { return unitPrice.multiply(quantity); } } } 4. 🏪 Repository (리포지토리) 특징: 도메인 객체의 컬렉션처럼 동작하는 저장소 추상화입니다. // 도메인 레이어의 Repository 인터페이스 public interface ProductRepository { void save(Product product); Optional<Product> findById(ProductId id); List<Product> findByCategory(Category category); List<Product> findAvailableProducts(); void remove(Product product); } // 인프라스트럭처 레이어의 구현체 @Repository public class JpaProductRepository implements ProductRepository { @PersistenceContext private EntityManager entityManager; @Override public void save(Product product) { entityManager.persist(product); } @Override public Optional<Product> findById(ProductId id) { return Optional.ofNullable( entityManager.find(Product.class, id.getValue()) ); } @Override public List<Product> findAvailableProducts() { return entityManager.createQuery( "SELECT p FROM Product p WHERE p.status = :status AND p.stock.quantity > 0", Product.class) .setParameter("status", ProductStatus.ACTIVE) .getResultList(); } } 5. 🔧 Domain Service (도메인 서비스) 특징: 특정 엔티티나 값 객체에 속하지 않는 도메인 로직을 담당합니다. @Service public class PricingService { public Money calculateDiscountedPrice(Product product, Customer customer, DiscountPolicy policy) { Money basePrice = product.getPrice(); if (customer.isVip()) { basePrice = policy.applyVipDiscount(basePrice); } if (product.isOnSale()) { basePrice = policy.applySaleDiscount(basePrice); } return basePrice; } public Money calculateShippingFee(Order order, Address deliveryAddress) { Money totalAmount = order.getTotalAmount(); // 비즈니스 규칙: 50,000원 이상 무료배송 if (totalAmount.isGreaterThanOrEqual(Money.won(50000))) { return Money.zero(); } // 지역별 배송비 계산 로직 return ShippingCalculator.calculate(deliveryAddress); } } 🗺️ DDD 아키텍처 레이어 📋 레이어 구조 📁 com.example.shop ├── 📁 interfaces (표현 계층) │ ├── 📁 rest │ │ └── ProductController.java │ └── 📁 dto │ └── ProductDto.java ├── 📁 application (응용 계층) │ ├── ProductService.java │ └── 📁 dto │ └── CreateProductCommand.java ├── 📁 domain (도메인 계층) │ ├── 📁 model │ │ ├── Product.java │ │ ├── Money.java │ │ └── ProductRepository.java │ └── 📁 service │ └── PricingService.java └── 📁 infrastructure (인프라 계층) ├── 📁 repository │ └── JpaProductRepository.java └── 📁 config └── JpaConfig.java 🎮 Application Service 예시 @Service @Transactional public class OrderApplicationService { private final OrderRepository orderRepository; private final ProductRepository productRepository; private final PricingService pricingService; public OrderId createOrder(CreateOrderCommand command) { // 1. 고객 검증 Customer customer = customerRepository.findById(command.getCustomerId()) .orElseThrow(() -> new CustomerNotFoundException()); // 2. 주문 생성 Order order = new Order( OrderId.generate(), command.getCustomerId(), command.getDeliveryAddress() ); // 3. 상품 추가 for (OrderItemCommand itemCmd : command.getItems()) { Product product = productRepository.findById(itemCmd.getProductId()) .orElseThrow(() -> new ProductNotFoundException()); if (!product.isAvailable()) { throw new ProductNotAvailableException(); } Money discountedPrice = pricingService.calculateDiscountedPrice( product, customer, DiscountPolicy.standard() ); order.addItem(itemCmd.getProductId(), itemCmd.getQuantity(), discountedPrice); } // 4. 주문 저장 orderRepository.save(order); // 5. 도메인 이벤트 처리는 별도 핸들러에서 return order.getId(); } } 🔄 도메인 모델링 실습 과정 1단계: 유비쿼터스 언어 정의 📝 비즈니스 팀과 함께 용어 통일 ✅ 통일된 용어 예시: - "상품" → Product - "재고" → Stock - "주문" → Order - "배송" → Delivery - "할인" → Discount ❌ 피해야 할 혼용: - 상품/제품/아이템 혼재 - 주문/오더/요청 혼재 2단계: 핵심 엔티티 식별 🎯 식별자가 중요한 개념들을 찾아보세요 // 쇼핑몰 도메인의 핵심 엔티티들 - Product (상품ID로 식별) - Customer (고객ID로 식별) - Order (주문번호로 식별) - Category (카테고리ID로 식별) 3단계: 값 객체 추출 💎 엔티티의 속성 중 값 자체가 중요한 것들을 분리 // Money, Address, PhoneNumber, Email 등 // 불변성과 자가 검증 로직 포함 4단계: 애그리게잇 경계 설정 📦 트랜잭션 일관성이 필요한 범위 결정 // Order Aggregate Order (Root) ├── OrderItem ├── DeliveryInfo └── PaymentInfo // Product Aggregate Product (Root) ├── Stock └── ProductImage 🚀 DDD 구현 시 주의사항 ✅ 해야 할 것들 도메인 로직을 도메인 객체에 위치 // ✅ 좋은 예 product.changePrice(newPrice); // Product 내부에서 검증 // ❌ 나쁜 예 if (newPrice > 0) product.setPrice(newPrice); // 서비스에서 검증 불변 객체 활용 // ✅ Value Object는 항상 불변 public class Money { private final BigDecimal amount; // setter 없음, 변경 시 새 인스턴스 반환 } 의미있는 이름 사용 // ✅ 도메인 언어 반영 order.confirm(); product.discontinue(); customer.upgradeToVip(); ❌ 하지 말아야 할 것들 빈약한 도메인 모델 // ❌ getter/setter만 있는 데이터 클래스 public class Order { private String status; public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } } 애그리게잇 경계 위반 // ❌ 다른 애그리게잇 직접 수정 order.getCustomer().changeEmail(newEmail); // ✅ 각 애그리게잇은 독립적으로 관리 customerService.changeEmail(customerId, newEmail); 🎯 DDD 용어 완전 정리 한글 영어 역할 Java 구현 예시 엔티티 Entity 고유 식별자를 가진 객체 @Entity class Product { @Id private ProductId id; } 값 객체 Value Object 값으로 식별되는 불변 객체 class Money { private final BigDecimal amount; } 애그리게잇 Aggregate 관련 객체들의 일관성 단위 Order + OrderItem 묶음 리포지토리 Repository 도메인 객체 저장소 추상화 interface ProductRepository 도메인 서비스 Domain Service 도메인 로직 서비스 @Service class PricingService 응용 서비스 Application Service 유스케이스 실행 서비스 @Service class OrderService 팩토리 Factory 복잡한 객체 생성 담당 OrderFactory.createFromCart() 도메인 이벤트 Domain Event 도메인에서 발생한 사건 class OrderCreatedEvent 🎉 마무리 DDD는 단순한 설계 패턴이 아니라 비즈니스 중심의 사고방식입니다. 🚦 시작하는 방법 작은 도메인부터 시작 - 전체를 한번에 바꾸려 하지 마세요 도메인 전문가와 협업 - 기획자, PM과 긴밀히 소통하세요 점진적 개선 - 완벽한 모델을 처음부터 만들 필요 없습니다 테스트 코드 작성 - 도메인 로직의 정확성을 보장하세요 🎯 기대 효과 유지보수성 향상 - 비즈니스 변경이 코드에 자연스럽게 반영 팀 커뮤니케이션 개선 - 개발자와 기획자가 같은 언어로 소통 확장성 확보 - 새로운 기능 추가 시 기존 구조 활용 가능 성공적인 DDD 적용을 위해서는 인내심을 가지고 단계적으로 접근하는 것이 중요합니다! 🚀
Backend Development
· 2025-08-12
📚[Backend Development] ERD(Entity-Relationship Diagram) 설계 완벽 가이드
🎯 ERD(Entity-Relationship Diagram) 설계 완벽 가이드 🤔 왜 이 가이드를 만들었나? Java Spring 백엔드 프로젝트를 진행하면서 ERD 작성 방법을 이해하지 못하고, ERD 작성 순서와 무엇을 기준 삼아서 설계해야 하는지 명확한 원칙과 패턴을 알지 못해 정리한 실전 가이드입니다. 🎯 학습 목표 데이터베이스 설계의 핵심인 ERD 작성 방법과 원칙, 그리고 체계적인 작성 순서를 완벽히 마스터하자! 📋 목차 ERD란 무엇인가? ERD 작성 기준과 출발점 ERD 작성 4단계 프로세스 ERD 작성 핵심 원칙 실전 ERD 작성 예시 ERD 검토 체크리스트 🎨 ERD란 무엇인가? 💡 정의: “데이터베이스 구조를 시각화한 설계도” ERD는 시스템에서 다루는 데이터의 종류(Entity), 데이터가 갖는 속성(Attribute), 그리고 데이터 간의 연관성(Relationship) 을 그림으로 표현한 데이터베이스 설계도입니다. 🎯 ERD의 핵심 목적 데이터 구조 시각화: 복잡한 데이터 관계를 한눈에 파악 설계 검증: 데이터베이스 구축 전 구조적 문제점 미리 발견 팀 커뮤니케이션: 개발팀과 기획팀 간의 명확한 소통 도구 개발 가이드: 실제 테이블 설계와 JPA Entity 작성의 기준점 🔍 ERD 작성 기준과 출발점 📊 핵심 기준: “서비스의 핵심 기능과 비즈니스 요구사항” ERD는 만들고자 하는 서비스의 핵심 기능과 요구사항을 기준으로 작성해야 합니다. 💭 기준점 찾기 질문들 “이 애플리케이션은 무엇을 해야 하는가?” “사용자는 어떤 데이터를 생성, 조회, 수정, 삭제하는가?” “비즈니스 규칙은 무엇인가?” (예: 한 명의 회원은 여러 주문 가능) “데이터 간의 제약사항은 무엇인가?” (예: 주문에는 반드시 회원이 필요) 🎯 요구사항 분석 예시: 온라인 쇼핑몰 핵심 기능: - 회원가입/로그인 - 상품 조회/검색 - 장바구니 담기 - 주문/결제 - 주문내역 조회 ➜ 필요한 주요 데이터: 회원, 상품, 장바구니, 주문, 주문상품 🔄 ERD 작성 4단계 프로세스 1️⃣ 엔티티(Entity) 도출 - 핵심 명사 찾기 🎯 작업 내용 시스템의 핵심 ‘명사’들을 찾아내어 엔티티로 정의합니다. 💡 도출 방법 요구사항에서 명사 추출 독립적으로 존재할 수 있는 객체인지 확인 여러 개의 인스턴스를 가질 수 있는지 확인 📝 예시: 온라인 쇼핑몰 추출된 명사: 회원, 상품, 카테고리, 주문, 장바구니, 리뷰, 쿠폰 엔티티 후보: ✅ Member (회원) ✅ Product (상품) ✅ Category (카테고리) ✅ Order (주문) ✅ Cart (장바구니) ✅ Review (리뷰) ✅ Coupon (쿠폰) 2️⃣ 속성(Attribute) 정의 - 각 엔티티의 정보 나열 🎯 작업 내용 각 엔티티가 가져야 할 속성(컬럼)들을 정의하고 기본 키(PK)를 지정합니다. 💡 속성 정의 원칙 기본 키(PK): 각 레코드를 유일하게 식별 필수 속성: 비즈니스 로직상 반드시 필요한 정보 선택 속성: 있으면 좋지만 필수가 아닌 정보 📝 예시: 주요 엔티티별 속성 Member (회원) ├── memberId (PK) - 회원 고유 ID ├── email - 이메일 (유니크) ├── password - 비밀번호 ├── memberName - 회원명 ├── phoneNumber - 전화번호 ├── address - 주소 ├── createdAt - 가입일시 └── status - 회원상태 Product (상품) ├── productId (PK) - 상품 고유 ID ├── productName - 상품명 ├── productPrice - 상품가격 ├── stockCount - 재고수량 ├── description - 상품설명 ├── categoryId (FK) - 카테고리 ID ├── createdAt - 등록일시 └── status - 상품상태 Order (주문) ├── orderId (PK) - 주문 고유 ID ├── memberId (FK) - 회원 ID ├── orderDate - 주문일시 ├── totalAmount - 총 주문금액 ├── deliveryAddress - 배송주소 └── orderStatus - 주문상태 3️⃣ 관계(Relationship) 설정 - 엔티티 간 연결고리 정의 🎯 작업 내용 엔티티 간의 관계(1:1, 1:N, N:M)를 정의하고 외래 키(FK)를 설정합니다. 📊 관계 유형별 특징 관계 유형 설명 예시 구현 방법 1:1 일대일 관계 회원 ↔ 회원상세정보 어느 쪽이든 FK 보유 1:N 일대다 관계 회원 ↔ 주문 (한 회원이 여러 주문) N쪽에 FK 보유 N:M 다대다 관계 상품 ↔ 주문 (한 주문에 여러 상품) 중간 테이블 생성 📝 예시: 관계 설정 주요 관계: 1. Member 1:N Order (한 회원이 여러 주문 가능) 2. Category 1:N Product (한 카테고리에 여러 상품) 3. Member 1:N Cart (한 회원이 여러 장바구니 아이템) 4. Order N:M Product (한 주문에 여러 상품, 한 상품이 여러 주문) ➜ OrderProduct 중간 테이블 생성 4️⃣ 검토 및 정규화 - 구조 최적화 🎯 작업 내용 데이터 중복을 제거하고 일관성을 보장하는 정규화 과정을 통해 ERD를 완성합니다. 📋 정규화 체크포인트 중복 데이터 제거: 같은 정보가 여러 곳에 저장되지 않도록 참조 무결성: FK 관계가 올바르게 설정되었는지 데이터 일관성: 업데이트 시 모순이 발생하지 않는지 🎨 ERD 작성 핵심 원칙 💡 1. 정규화 (Normalization) “각 데이터는 제자리에, 단 한 번만 저장한다” ❌ 잘못된 예시: Order 테이블에 memberName, memberEmail 직접 저장 ✅ 올바른 예시: Order 테이블에 memberId(FK)만 저장, 회원 정보는 Member 테이블 참조 🏷️ 2. 명확한 이름 규칙 일관된 네이밍 컨벤션 적용 엔티티명: 단수 명사 (Member, Product, Order) 속성명: camelCase (memberId, productName, createdAt) 기본키: 엔티티명 + Id (memberId, productId, orderId) 외래키: 참조엔티티명 + Id (memberId, categoryId) 🔑 3. 기본 키(PK) 정의 모든 엔티티는 유일한 식별자 필수 권장사항: - 의미 없는 대리키 사용 (UUID, Auto Increment) - 복합키보다는 단일 컬럼 기본키 - 변하지 않는 값으로 설정 🔗 4. 관계의 명확성 다대다(N:M) 관계는 중간 테이블로 해결 N:M 관계 해결 패턴: Order ←→ Product (다대다) ↓ Order 1:N OrderProduct N:1 Product (일대다 × 2) 🚀 실전 ERD 작성 예시 📁 온라인 쇼핑몰 ERD 구조 erDiagram Member ||--o{ Order : "주문한다" Member ||--o{ Cart : "장바구니에 담는다" Member ||--o{ Review : "리뷰를 작성한다" Category ||--o{ Product : "포함한다" Product ||--o{ Cart : "담겨진다" Product ||--o{ Review : "리뷰가 달린다" Product ||--o{ OrderProduct : "주문된다" Order ||--o{ OrderProduct : "상품을 포함한다" Member { string memberId PK string email UK string password string memberName string phoneNumber string address datetime createdAt string status } Product { string productId PK string categoryId FK string productName int productPrice int stockCount string description datetime createdAt string status } Category { string categoryId PK string categoryName string description } Order { string orderId PK string memberId FK datetime orderDate int totalAmount string deliveryAddress string orderStatus } OrderProduct { string orderProductId PK string orderId FK string productId FK int quantity int price } Cart { string cartId PK string memberId FK string productId FK int quantity datetime addedAt } Review { string reviewId PK string memberId FK string productId FK int rating string content datetime createdAt } ✅ ERD 검토 체크리스트 📝 엔티티 검토 모든 엔티티가 독립적으로 존재 가능한가? 엔티티명이 단수 명사로 명확하게 작명되었는가? 비즈니스 요구사항을 모두 충족하는가? 🔑 속성 검토 모든 엔티티에 기본 키(PK) 가 정의되었는가? 속성명이 의미를 명확히 전달하는가? 필수 속성과 선택 속성이 적절히 구분되었는가? 데이터 타입이 적절히 선택되었는가? 🔗 관계 검토 모든 관계의 카디널리티(1:1, 1:N, N:M)가 올바른가? 외래 키(FK) 가 적절한 위치에 배치되었는가? N:M 관계가 중간 테이블로 정규화되었는가? 참조 무결성 제약조건이 고려되었는가? 🎯 정규화 검토 중복 데이터가 제거되었는가? 갱신 이상, 삽입 이상, 삭제 이상이 없는가? 비즈니스 규칙이 ERD에 올바르게 반영되었는가? 🏷️ 네이밍 검토 전체적으로 일관된 네이밍 규칙이 적용되었는가? 약어 사용이 최소화되었는가? 예약어와 충돌하지 않는가? 💡 핵심 정리 🎯 ERD 설계의 핵심 비즈니스 요구사항에서 출발: 서비스가 해야 할 일을 명확히 파악 체계적인 4단계 프로세스: 엔티티 → 속성 → 관계 → 정규화 정규화를 통한 데이터 무결성: 중복 제거와 일관성 보장 명확하고 일관된 네이밍: 팀 전체가 이해할 수 있는 명명 규칙 이제 Java Spring 백엔드 프로젝트에서 체계적이고 효율적인 ERD를 설계할 수 있을 거예요! 🚀 이 가이드가 도움이 되었다면, 다음에는 JPA Entity 매핑과 연관관계 설정법도 정리해보겠습니다! 👋
Backend Development
· 2025-08-10
📚[Backend Development] CRUD 각 기능별 Request/Response Body 원칙과 패턴 완벽 이해하기
🌏 CRUD 각 기능별 Request/Response Body 원칙과 패턴 완벽 이해하기 왜 이 문서를 작성했나? 🤔 API 명세서 작성 과정에서 CRUD의 Request / Response Body 예시를 구현하면서, Request / Response Body에 대한 작성 원칙과 패턴을 제대로 알지 못하여 정리한 학습 노트입니다. 🎯 핵심 목표. CRUD의 Request / Response Body 작성 원칙과 방법을 완벽히 이해하자! 🪵🦶 목차 CRUD의 Request / Response Body 작성 원칙. CRUD의 Request / Response Body 작성 방법. 📦 CRUD의 Request / Response Body 작성 원칙 API 명세서의 Body는 ‘최소한의 정보로 명확하게 소통한다’ 는 대원칙을 따릅니다. 각 HTTP Method의 역할에 따라 필요한 정보를 주고받도록 설계합니다. C (Create - POST): Request : 새로운 리소스를 만드는 데 필요한 모든 정보를 담습니다. 단, 서버가 자동으로 생성하는 값(ID, 생성일시 등)은 제외합니다. Response : 생성된 리소스의 완전한 상태를 반환하여, 클라이언트가 ID나 서버 생성 값을 다시 요청할 필요가 없게 합니다. R (Read - GET): Request : Body를 사용하지 않습니다. 모든 조건은 URL 경로(Path Variable)나 쿼리 파라미터(Query Parameter)로 전달합니다. Response : 조회된 리소스의 정보를 담습니다. 단일 리소스는 객체({}), 목록은 배열([]) 로 반환합니다. U (Update - PATCH / PUT): Request : 변경하려는 데이터만 담습니다. PATCH는 변경할 필드만, PUT는 리소스 전체를 보냅니다. Response : 수정이 완료된 리소스의 완전한 상태를 반환하여, 클라이언트가 수정 결과를 명확히 알 수 있게 합니다. D (Delete - DELETE): Request : Body를 사용하지 않습니다. 삭제할 대상은 URL 경로로 식별합니다. Response : 성공적으로 삭제되었음을 알리기 위해 Body를 비워두는 것이 원칙입니다. (상태 코드 204 No Content 사용) 📦 CRUD의 Request / Response Body 작성 방법 위 원칙에 따라 예시인 ‘상품 관리 API’의 각 Body를 어떻게 작성하는지 보여드리겠습니다. C: 상품 등록 (POST /products) Request Body: 목적: 신규 상품 정보와 초기 재고 정보를 전달합니다. 내용: productName, productSaleCost, initialStockCount, expirationDate 등 사용자가 직접 입력해야 하는 모든 필드를 포함합니다. productId는 포함하지 않습니다. { "productName": "신선한 목장 우유 1L", "productSaleCost": 2500, "initialStockCount": 5, "expirationDate": "2025-08-18" } Response Body: 목적: 서버에서 생성된 productId, createdAt과 계산된 margin 등을 포함한 완전한 상품 정보를 클라이언트에게 알려줍니다. 내용: productId를 포함한 모든 필드와 계산된 값을 담습니다. { "productId": "8801234567890", "productName": "신선한 목장 우유 1L", "totalStockCount": 8, "createdAt": "2025-08-09", "margin": 25.5, ... } R: 상품 검색 (GET /products?name=...) Request Body: 사용하지 않습니다. Response Body: 목적: 검색 조건에 맞는 상품 목록을 전달합니다. 내용: 각 상품의 요약 정보를 담은 객체등의 배열([]) 형태입니다. 결과가 없으면 빈 배열([])을 반환합니다. [ { "productId": "8801234567890", "productName": "신선한 목장 우유 1L", ...}, { "productId": "8801234145725", "productName": "진라면 순한맛 1ea", ... }, ] U: 상품 수정 (PATCH /products/{productId}) Request Body: 목적: 변경할 정보만 전달하여 효율성을 높입니다. 내용: productSaleCost, productDiscountRate 등 변경이 필요한 필드만 포함합니다. { "productSaleCost": 3200 "productDiscountRate": 20 } Response Body: 목적: 수정이 반영된 최종 결과를 클라이언트가 확인할 수 있게합니다. 내용: 수정된 필드뿐만 아니라, 그로 인해 함께 변경된 계산값(margin 등)까지 포함한 상품의 전체 정보를 담습니다. { "productId": "8801234567890", "productSaleCost": 3200, "productDiscountRate": 20, "margin": 31.2 // 재계산된 값 ... } D: 상품 삭제 (DELETE /products/{productId}) Request Body: 사용하지 않습니다. Response Body: 비어 있습니다. 삭제 성공 여부는 HTTP 상태 코드(204 No Content)로 전달합니다.
Backend Development
· 2025-08-09
📚[Backend Development] CRUD 각 기능별 Request/Response Body 설계 완벽 가이드
🎯 CRUD API Request/Response Body 설계 완벽 가이드 🤔 왜 이 가이드를 만들었나? Java Spring 백엔드 프로젝트에서 API 명세서를 작성하는 과정에서, CRUD의 Request/Response Body를 어떻게 설계해야 하는지 명확한 원칙과 패턴을 이해하지 못해 정리한 실전 가이드입니다. 🎯 학습 목표 RESTful API 설계의 핵심인 CRUD 각 기능별 Request/Response Body 작성 원칙과 패턴을 완벽히 마스터하자! 📋 목차 RESTful API Body 설계의 핵심 원칙 CRUD별 Request/Response Body 패턴 Spring Boot 실전 적용 예시 API 명세서 작성 체크리스트 🎨 RESTful API Body 설계의 핵심 원칙 💡 대원칙: “최소한의 정보로 명확하게 소통한다” RESTful API의 Body 설계는 각 HTTP Method의 본래 목적에 맞춰 필요한 정보만 주고받는 것이 핵심입니다. 📊 HTTP Method별 Body 사용 원칙 HTTP Method Request Body Response Body 핵심 원칙 POST 📝 ✅ 필수 ✅ 필수 생성에 필요한 모든 정보 → 완전한 생성 결과 GET 🔍 ❌ 사용 안함 ✅ 필수 URL 파라미터로 조건 → 조회 결과 반환 PATCH/PUT ✏️ ✅ 필수 ✅ 필수 변경할 정보만 → 완전한 수정 결과 DELETE 🗑️ ❌ 사용 안함 ❌ 비워둠 URL로 식별 → 204 상태 코드로 성공 표시 🔄 CRUD별 Request/Response Body 패턴 📝 CREATE (POST) - 새로운 리소스 생성 🎯 설계 원칙 Request: 서버가 자동 생성하는 값(ID, 생성일시)을 제외한 모든 필수 정보 Response: 서버에서 생성된 값을 포함한 완전한 리소스 상태 💻 예시: 상품 등록 API POST /api/products 📤 Request Body { "productName": "신선한 목장 우유 1L", "productSaleCost": 2500, "initialStockCount": 50, "expirationDate": "2025-12-31" } 📥 Response Body (201 Created) { "productId": "PROD-001", "productName": "신선한 목장 우유 1L", "productSaleCost": 2500, "totalStockCount": 50, "expirationDate": "2025-12-31", "createdAt": "2025-08-09T10:30:00", "updatedAt": "2025-08-09T10:30:00", "status": "ACTIVE" } 🔍 READ (GET) - 리소스 조회 🎯 설계 원칙 Request: Body 사용하지 않음, 모든 조건은 URL 파라미터로 Response: 단일 객체 {} 또는 배열 [] 형태로 반환 💻 예시: 상품 조회 API 단일 상품 조회 GET /api/products/PROD-001 📥 Response Body (200 OK) { "productId": "PROD-001", "productName": "신선한 목장 우유 1L", "productSaleCost": 2500, "totalStockCount": 50, "status": "ACTIVE" } 상품 목록 조회 GET /api/products?name=우유&status=ACTIVE&page=0&size=10 📥 Response Body (200 OK) { "content": [ { "productId": "PROD-001", "productName": "신선한 목장 우유 1L", "productSaleCost": 2500, "status": "ACTIVE" }, { "productId": "PROD-002", "productName": "저지 우유 500ml", "productSaleCost": 1800, "status": "ACTIVE" } ], "totalElements": 2, "totalPages": 1, "currentPage": 0 } ✏️ UPDATE (PATCH/PUT) - 리소스 수정 🎯 설계 원칙 PATCH: 변경할 필드만 전송 (부분 업데이트) PUT: 리소스 전체 교체 Response: 수정 완료된 전체 리소스 상태 💻 예시: 상품 수정 API PATCH /api/products/PROD-001 📤 Request Body { "productSaleCost": 2800, "totalStockCount": 30 } 📥 Response Body (200 OK) { "productId": "PROD-001", "productName": "신선한 목장 우유 1L", "productSaleCost": 2800, "totalStockCount": 30, "expirationDate": "2025-12-31", "updatedAt": "2025-08-09T15:45:00", "status": "ACTIVE" } 🗑️ DELETE - 리소스 삭제 🎯 설계 원칙 Request: Body 사용하지 않음 Response: 빈 Body + 204 No Content 상태 코드 💻 예시: 상품 삭제 API DELETE /api/products/PROD-001 📥 Response (204 No Content) (비어있음) 🚀 Spring Boot 실전 적용 예시 📁 프로젝트 구조에서 DTO 활용 // Request DTO @Data @NoArgsConstructor public class ProductCreateRequest { @NotBlank(message = "상품명은 필수입니다") private String productName; @Min(value = 0, message = "가격은 0 이상이어야 합니다") private Integer productSaleCost; @Min(value = 0, message = "재고는 0 이상이어야 합니다") private Integer initialStockCount; @Future(message = "유통기한은 미래 날짜여야 합니다") private LocalDate expirationDate; } // Response DTO @Data @Builder public class ProductResponse { private String productId; private String productName; private Integer productSaleCost; private Integer totalStockCount; private LocalDate expirationDate; private LocalDateTime createdAt; private LocalDateTime updatedAt; private ProductStatus status; } 🎯 Controller에서의 활용 @RestController @RequestMapping("/api/products") public class ProductController { @PostMapping public ResponseEntity<ProductResponse> createProduct( @Valid @RequestBody ProductCreateRequest request) { ProductResponse response = productService.createProduct(request); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @GetMapping("/{productId}") public ResponseEntity<ProductResponse> getProduct( @PathVariable String productId) { ProductResponse response = productService.getProduct(productId); return ResponseEntity.ok(response); } @PatchMapping("/{productId}") public ResponseEntity<ProductResponse> updateProduct( @PathVariable String productId, @RequestBody ProductUpdateRequest request) { ProductResponse response = productService.updateProduct(productId, request); return ResponseEntity.ok(response); } @DeleteMapping("/{productId}") public ResponseEntity<Void> deleteProduct(@PathVariable String productId) { productService.deleteProduct(productId); return ResponseEntity.noContent().build(); } } ✅ API 명세서 작성 체크리스트 📝 Request Body 체크포인트 POST: 자동 생성 값(ID, 생성일시) 제외한 모든 필수 정보 포함 GET/DELETE: Body 사용하지 않음 PATCH: 변경할 필드만 포함 PUT: 전체 리소스 정보 포함 Validation: @Valid, @NotNull 등 검증 어노테이션 적용 📤 Response Body 체크포인트 POST/PATCH/PUT: 완전한 리소스 상태 반환 GET: 단일 객체 {} 또는 목록 배열 [] 반환 DELETE: 빈 Body + 204 상태 코드 페이징: content, totalElements, totalPages 등 메타데이터 포함 에러 응답: 일관된 에러 응답 형식 적용 🎯 공통 설계 원칙 일관성: 프로젝트 전체에서 동일한 네이밍 컨벤션 사용 보안: 민감한 정보(패스워드, 토큰 등) 응답에서 제외 성능: 불필요한 필드 제외, 필요시 별도 API 제공 문서화: Swagger/OpenAPI 등을 활용한 명세서 자동화 💡 핵심 정리 🎯 RESTful API Body 설계의 핵심 각 HTTP Method의 목적에 맞는 Body 설계 Request는 최소한, Response는 완전하게 일관된 패턴으로 예측 가능한 API Spring Boot의 DTO/Entity 분리 원칙 준수 이제 Java Spring 백엔드 프로젝트에서 자신 있게 API 명세서를 작성할 수 있을 거예요! 🚀 이 가이드가 도움이 되었다면, 다음에는 에러 처리와 상태 코드 활용법도 정리해보겠습니다! 👋
Backend Development
· 2025-08-09
📚[Backend Development] Spring Boot Service Layer와 ORM 완벽 이해하기
🚀 Spring Boot Service Layer와 ORM 완벽 이해하기 왜 이 문서를 작성했나? 🤔 Java Spring Boot 백엔드 프로젝트에서 Service Layer의 CRUD 메서드를 구현하면서, Response DTO 생성과 반환 과정에서 ORM과 서비스 로직의 핵심을 제대로 이해하지 못해 정리한 학습 노트입니다. 🎯 핵심 목표 Service Layer에서 Entity → DTO 변환 과정과 ORM의 역할을 완벽히 이해하자! 📖 목차 ORM을 이해하기 위한 객체(Object) 개념 정리 ORM이란 무엇인가? Spring Boot Service Layer에서의 실전 적용 Entity vs DTO: 언제, 왜 변환하는가? 🧩 ORM을 이해하기 위한 객체(Object) 개념 정리 🔍 객체(Object)의 두 가지 관점 1️⃣ 프로그래밍 언어의 객체 // 단순한 데이터와 메서드의 조합 Map<String, Object> user = new HashMap<>(); user.put("name", "홍길동"); user.put("age", 25); 정의: 데이터(속성)와 기능(메서드)를 하나로 묶은 프로그래밍 단위 특징: 언어별로 다양한 형태로 존재 (JavaScript 객체, Python 딕셔너리 등) 2️⃣ 객체지향 프로그래밍(OOP)의 객체 // 클래스 기반의 체계적인 객체 public class User { private String name; private int age; public void introduce() { System.out.println("안녕하세요, " + name + "입니다!"); } } User user = new User(); // 클래스로부터 생성된 인스턴스 정의: 클래스(설계도)를 기반으로 생성된, 캡슐화/상속/다형성/추상화를 따르는 독립적 단위 특징: 객체 간 협력과 상호작용이 핵심 💡 핵심 차이점 | 구분 | 프로그래밍 언어의 객체 | OOP의 객체 | |——|———————-|————| | 생성 방식 | 다양한 방법 | 클래스 기반 | | 설계 원칙 | 자유로움 | OOP 4대 원칙 준수 | | 목적 | 데이터 구조화 | 현실 세계 모델링 | 🔗 ORM이란 무엇인가? 📊 ORM의 핵심 개념 Object-Relational Mapping: OOP의 객체와 관계형 데이터베이스의 테이블을 자동으로 연결해주는 기술 // 데이터베이스 테이블 /* users 테이블 +----+---------+-----+ | id | name | age | +----+---------+-----+ | 1 | 홍길동 | 25 | | 2 | 김철수 | 30 | +----+---------+-----+ */ // ↕️ ORM이 자동 매핑 ↕️ // Java 객체 (Entity) @Entity @Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private int age; // getters, setters... } 🎯 ORM에서 말하는 “Object” 답: OOP의 객체(클래스 인스턴스) JPA Entity는 클래스 기반으로 정의 OOP 원칙을 따라 설계 데이터베이스 테이블과 1:1 매핑되는 도메인 객체 ⚡ Spring Boot Service Layer에서의 실전 적용 🏗️ 전체 아키텍처 흐름 Controller → Service → Repository → Database ↑ ↓ DTO Entity 💻 실제 코드 예시 1️⃣ Entity 정의 @Entity @Table(name = "users") public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String email; private int age; // constructors, getters, setters... } 2️⃣ Repository Layer @Repository public interface UserRepository extends JpaRepository<User, Long> { Optional<User> findByEmail(String email); List<User> findByAgeGreaterThan(int age); } 3️⃣ Service Layer (핵심! 🔥) @Service @Transactional public class UserService { private final UserRepository userRepository; // CREATE: DTO → Entity → DTO public UserResponseDto createUser(UserCreateDto createDto) { // 1. DTO를 Entity로 변환 User user = User.builder() .name(createDto.getName()) .email(createDto.getEmail()) .age(createDto.getAge()) .build(); // 2. ORM이 Entity를 DB에 저장 User savedUser = userRepository.save(user); // 3. Entity를 Response DTO로 변환하여 반환 return UserResponseDto.from(savedUser); } // READ: Entity → DTO public UserResponseDto getUser(Long userId) { // 1. ORM이 DB에서 데이터를 조회하여 Entity 생성 User user = userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다.")); // 2. Entity를 Response DTO로 변환 return UserResponseDto.from(user); } // UPDATE: DTO + Entity → DTO public UserResponseDto updateUser(Long userId, UserUpdateDto updateDto) { // 1. 기존 Entity 조회 User user = userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다.")); // 2. Entity 상태 변경 (Dirty Checking) user.updateInfo(updateDto.getName(), updateDto.getAge()); // 3. @Transactional에 의해 자동 저장 // 4. Entity를 Response DTO로 변환 return UserResponseDto.from(user); } } 4️⃣ DTO 정의 // Response DTO public class UserResponseDto { private Long id; private String name; private String email; private int age; // Entity → DTO 변환 메서드 public static UserResponseDto from(User user) { return UserResponseDto.builder() .id(user.getId()) .name(user.getName()) .email(user.getEmail()) .age(user.getAge()) .build(); } } 🔄 Entity vs DTO: 언제, 왜 변환하는가? 🎭 각자의 역할 🏛️ Entity의 역할 도메인 로직 담당: 비즈니스 규칙과 제약사항 포함 데이터베이스와 직접 매핑: ORM이 관리하는 영속성 객체 생명주기 관리: JPA가 추적하고 관리 📦 DTO의 역할 데이터 전송 전용: 계층 간 데이터 이동 API 스펙 정의: 클라이언트와의 인터페이스 보안: 필요한 데이터만 노출 ⚙️ 변환하는 이유 보안 🔒: Entity의 모든 필드를 노출하면 안 됨 유지보수 🔧: API 스펙과 도메인 모델의 독립성 성능 ⚡: 필요한 데이터만 전송 유연성 🤸: 클라이언트 요구사항에 맞는 응답 구조 💡 Service Layer에서의 핵심 패턴 // 📥 Input: 클라이언트로부터 DTO 받기 // 🔄 Process: Entity로 변환하여 비즈니스 로직 처리 // 📤 Output: Entity를 DTO로 변환하여 응답 🎉 정리 🔑 핵심 포인트 ORM의 “Object”는 OOP의 객체(Entity) 를 의미 Service Layer는 DTO ↔ Entity 변환의 중심지 Entity는 도메인 로직, DTO는 데이터 전송의 역할 분담 ORM은 Entity와 DB 테이블 간의 자동 매핑 담당 💪 실무에서 기억할 것 Entity는 비즈니스 로직과 데이터베이스 매핑을 담당 DTO는 API 계층에서만 사용하여 외부 의존성 차단 Service Layer에서 두 객체 간 변환 로직을 명확히 구현 @Transactional과 Dirty Checking을 활용한 효율적인 UPDATE 처리
Backend Development
· 2025-08-08
📚[Backend Development] Request/Response DTO의 상세 구조.
📚[Backend Development] Request/Response DTO의 상세 구조. DTO의 상세 구조는 ‘어떤 역할을 하느냐’ 에 따라 달라집니다. Request DTO는 데이터를 받아 검증하는 것에, Response DTO는 데이터를 보기 좋게 가공하여 보여주는 것에 초점을 맞춥니다. 📦 Request DTO의 상세 구조. Request DTO는 클라이언트로부터 들어오는 데이터를 안전하게 받아내기 위한 구조를 가집니다. 필드 (Fields) API 요청 본문(Request Body)의 JSON key와 일치하는 멤버 변수들로 구성됩니다. 검증 어노테이션 (Validation Annotations) @NotBlank, @NotNull, @Size, @Pattern 등 jakarta.validation 어노테이션을 사용하여, 비즈니스 로직에 도달하기 전에 데이터가 유효한지 검사합니다. 이것이 Request DTO의 가장 중요한 특징입니다. 기본 생성자와 Getter JSON 데이터를 객체로 변환(Deserialization)하기 위해 Lombok의 @NoArgsConstructor와 @Getter를 주로 사용합니다. 코드 예시 : ProductCreateRequestDto.java @Getter @NoArgsConstructor // JSON 변환을 위한 기본 생성자 public class ProductCreateRequestDto { @NotBlank(message = "상품명은 필수입니다.") @Size(max = 100, message = "상품명은 100자를 넘을 수 없습니다.") private String productName; @NotNull(message = "판매가는 필수입니다.") @Positive(message = "판매가는 0보다 커야 합니다.") private BigDecimal productSaleCost; // ... 요청에 필요한 다른 필드와 검증 규칙들 ... } 📦 Response DTO의 상세 구조. Response DTO는 서버의 처리 결과를 클라이언트에게 명확하고 사용하기 편한 형태로 보여주기 위한 구조를 가집니다. 필드 (Fields) Entity의 데이터를 그대로 보여주기도 하고, 여러 Entity의 정보를 조합하거나 Service에서 계산된 값을 포함하기도 합니다. 예: margin, totalStockCount 불변성 (Immutability) 응답으로 나가는 데이터가 중간에 변경되지 않도록 final 필드와 생성자(@Builder 활용)를 사용하여 불변 객체로 만드는 것이 좋은 패턴입니다. setter는 사용하지 않습니다. 정적 팩토리 메서드 (Static Factory Method) Entity 객체를 DTO 객체로 변환하는 로직을 DTO 내부에 정적 메서드(e.g, from(Product product))로 만들어두면, Service 계층의 코드를 더 깔끔하게 유지할 수 있습니다. 코드 예시: ProductResponseDto.java @Getter public class ProductResponseDto { private final Long productId; private final String productName; private final BigDecimal fianlSalePrice; // 계산된 최동 판매가 private final int totalStockCount; // 계산된 총재고 // DTO는 불변성을 위해 Builder를 통한 생성자만 허용 @Builder private ProductResponseDto(Long productId, String productName, BigDecimal finalSalePrice, int totalStockCount) { this.productId = productId; this.productName = productName; this.finalSalePrice = finalSalePrice; this.totalStockCount = totalStockCount; } // Entity를 DTO로 변환하는 정적 팩토리 메서드 public static ProductResponseDto from(Product product, int totalStockCount) { return ProductResponseDto.builder() .productId(product.getProductId()) .productName(product.getProductName()) .finalSalePrice(calculateFinalPrice(product.getProductSaleCost(), product.getProductDiscountRate())) .totalStockCount(totalStockCount) .build(); } private static BigDecimal calculateFinalPrice(BigDecimal saleCost, Integer discountRate) { // ... 가격 계산 로직 ... } }
Backend Development
· 2025-08-08
📚[Backend Development] API 명세서 개발 과정 5단계
📚[Backend Development] API 명세서 개발 과정 5단계 API 명세서 설계는 요구사항 분석부터 시작하여 점진적으로 구체화하는 순서로 진행됩니다. ✅ 1. 요구사항 분석 및 기능 정의. 가장 먼저 ‘무엇을 만들 것인가’를 정의합니다. 개발할 기능 목록을 구체적으로 작성하고, 각 기능에 필요한 데이터가 무엇인지 파악합니다. 산출물 : 기능 목록 (예: 상품 목록, 상품 검색, 주문 생성 등) ✅ 2. 리소스 식별 및 URL 설계 기능 목록을 바탕으로 API가 다룰 핵심 대상, 즉 리소스(Resource) 를 식별하고 URL을 설계합니다. RESTful 원칙에 따라 URL은 자원의 ‘명사’를, HTTP Method는 ‘동사’를 나타내도록 구성합니다. 산출물 : API 엔드포인트 목록 (POST /products, GET /products, PATCH /products/{productId} 등) ✅ 3. 데이터 모델링 (Request/Response DTO 설계) 각 엔드포인트가 주고 받을 데이터의 구체적인 형태를 설계합니다. 이때 요청(Request)과 응답(Response)에 사용할 DTO(Data Transfer Object)의 필드, 데이터 타입, 필수 여부 등을 정의합니다. 산출물 : 각 API별 Request/Response DTO의 상세 구조 ✅ 4. 상태 코드 및 에러 처리 정의 API가 성공했을 때뿐만 아니라, 다양한 실패 상황(입력값 오류, 권한 없음, 서버 오류 등)에 대한 어떤 HTTP 상태 코드를 반환하고, 어떤 에러 메시지를 보여줄지 상세하게 정의합니다. 산출물 : 상태 코드별 응답 형식(201 Created, 400 Bad Request, 404 Not Found 등) ✅ 5. 검토 및 문서화 완성된 설계를 바탕으로 최종 API 명세서를 작성합니다. 팀원들과 함께 검토하며 피드백을 통해 설계를 개선하고 확정합니다. 이 단계에서 Swagger/OpenAPI와 같은 도구를 사용해 문서를 작성하면 협업에 매우 효율적입니다. 산출물 : 최종 API 명세 문서 (Swagger, Notion, Confluence 등)
Backend Development
· 2025-08-08
📚[Backend Development] DTO(Data Transfer Object) 구현 4대 원칙
📚[Backend Development] DTO(Data Transfer Object) 구현 4대 원칙 DTO(Data Transfer Object)는 계층 간 데이터 전송을 위해 사용하는 객체입니다. DTO 구현 4대 원칙에 대해 알아보기 전에 DTO의 핵심 개념과 DTO를 사용하는 이유에 대해 간단하게 알아본 후 본격적으로 DTO 구현 4대 원칙에 대해 알아보도록 하겠습니다. 📝 목차 DTO DTO 핵심 개념. DTO를 사용하는 이유. DTO 구현 4대 원칙 단순한 데이터 컨테이너여야 합니다.(Be a Simple Data Container) 불변(Immutable) 객체로 만드세요(Be Immutable). 엔티티와 철저히 분리하세요(Be Decoupled from the Entity) 목적에 따라 분리해서 만드세요(Be Purpost-Specific) 📦 DTO ✅ 1. DTO 핵심 개념. DTO는 시스템의 내부 로직과 외부 인터페이스를 분리하기 위한 ‘데이터 상자’ 또는 ‘데이터 운반용 객체’입니다. 복잡한 비즈니스 로직 없이, 오직 데이터를 담아 전달하는 용도로만 사용됩니다. ✅ 2. DTO를 사용하는 이유. 가장 중요한 이유는 내부 데이터 모델(Entity)과 외부 API 계약(DTO)을 분리하기 위함입니다. 관심사 분리 데이터베이스와 직접 연결된 Entity를 외부에 그대로 노출하면 보안에 취약하고, 내부 구조가 변경될 때마다 API 명세 전체가 영향을 받게 됩니다. DTO는 API 명세에 필요한 데이터만 골라 담아 이 문제를 해결합니다. 유연성 및 안정성 데이터베이스 Entity의 구조가 변경되더라도 DTO를 사용하는 한 API 명세는 그대로 유지할 수 있어, 시스템이 훨씬 유연하고 안정적이게 됩니다. 보안 Entity에 포함된 비밀번호와 같은 민감함 정보나, 외부에 불필요한 내부 데이터가 클라이언트에게 노출되는 것을 방지합니다. 📦 DTO 구현 4대 원칙 ✅ 1. 단순한 데이터 컨테이너여야 합니다 (Be a Simple Data Container) DTO의 유일한 역한은 “데이터를 담아 계층(Layer) 간에 전달하는 것입니다.” 가격 계산이나 유효성 검증과 같은 “비즈니스 로직(Business Logic)을 절대 포함해서는 안 됩니다.” 이러한 로직은 서비스 계층(Service Layer)의 책임입니다. ✅ 2. 불변(Immutable) 객체로 만드세요. (Be Immutable) setter를 제공하지 않고, “생성자나 빌더(@Builder)를 통해 생성 시점에만 값을 할당하세요.” 데이터가 여러곳으로 전달되는 동안 값이 변경될 위험을 원칙적으로 차단하여 시스템의 안정성을 크게 높입니다. ✅ 3. 엔티티와 철저히 분리하세요 (Be Decoupled from the Entity) “DTO는 API의 외부 명세(계약)를, 엔티티는 내부 데이터베이스 구조를 나타냅니다.” DTO가 엔티티를 직접 참조하거나 의존해서는 안 됩니다. 이 원칙은 내부 구조가 변경되더라도 외부 API에 영향을 주지 않는 유연한 시스템을 만듭니다. ✅ 4. 목적에 따라 분리해서 만드세요 (Be Purpost-Specific) 하나의 거대한 DTO를 여러 곳에서 사용하는 대신, 각 API의 목적에 맞는 별개의 DTO를 만드세요. 예를 들어, ‘상품 생성 요청’에는 ProductCreateRequestDto를,’상품 목록 조회 응담’에는 ProductListResponseDto를 사용하는 것이 좋습니다. 이는 DTO를 명확하고 간결하게 유지시켜줍니다.
Backend Development
· 2025-08-07
📚[Backend Development] `@NoArgsConstructor`와 `@AllArgsConstructor` 어노테이션
📚[Backend Development] @NoArgsConstructor와 @AllArgsConstructor 어노테이션 📦 @NoArgsConstructor 어노테이션. @NoArgsConstructor는 파라미터가 없는 기본 생성자(no-arg constructor)를 자동으로 만들어주는 Lombok 어노테이션입니다. ✅ 1. @NoArgsConstructor 어노테이션은 무엇인가요? @NoArgsConstructor는 Lombok 라이브러리의 어노테이션 중 하나로, public MyClass() {}와 같이 아무런 인자도 받지 않는 생성자 코드를 컴파일 시점에 자동으로 생성해줍니다. ✅ 2. @NoArgsConstructor 어노테이션은 언제 사용하나요? JPA Entity 클래스를 만들 때 거의 항상 사용됩니다. 또한, JSON 데이터를 객체로 변환하는 라이브러리(e.g, Jackson)를 사용할 때도 필요합니다. ✅ 3. @NoArgsConstructor 어노테이션은 어디서 사용하나요? 클래스(Class) 레벨에서 선언하여 사용합니다. ✅ 4. @NoArgsConstructor 어노테이션은 어떻게 사용하나요? 클래스 위에 어노테이션을 붙여주기만 하면 됩니다. JPA Entity에서는 protected 접근 제어자를 사용하는 것이 좋은 패턴입니다. import lombok.AccessLevel; import lombok.NoArgsConstructor; @Entiry @NoArgsConstructor(access = AccessLevel.PROTECTED) // protected 기본 생성자를 자동 생성 public class Product { @Id private Long id; private String name; /* // 아래 생성자가 컴파일 시점에 자동으로 생성됩니다. protected Product() { } */ } ✅ 5. @NoArgsConstructor 어노테이션은 왜 사용하나요? @NoArgsConstructor를 사용하는 주된 이유는 프레임워크의 요구사항을 만족시키고 객체 생성의 안정성을 높이기 위함입니다. 프레임워크 호환성 : JPA와 같은 프레임워크는 내부적으로 객체를 생성하기 위해 기본 생성자를 필요로 합니다. @NoArgsConstructor는 이 요구사항을 충족시켜줍니다. 안전성 : @NoArgsConstructor(access = AccessLevel.PROTECTED)로 설정하면, 개발자가 new Product() 처럼 실수로 불완전한 객체를 생성하는 것을 막을 수 있습니다. 객체 생성은 @Builder나 정적 팩토리 메서드 등을 사용하도록 강제하여 코드의 안정성을 높입니다. 코드 간결성 : 개발자가 직접 생성자 코드를 작성할 핑요가 없어 코드가 깔끔해집니다. 📦 @AllArgsConstructor 어노테이션. @AllArgsConstructor 어노테이션는 클래스의 모든 필드를 인자로 받는 생성자를 자동으로 만들어주는 Lombok 어노테이션입니다. ✅ 1. @AllArgsConstructor 어노테이션은 무엇인가요? @AllArgsConstructor는 Lombok 라이브러리의 어노테이션 중 하나로, 클래스에 선언된 모든 필드를 파라미터로 순서대로 받는 생성자 코드를 컴파일 시점에 자동으로 생성해줍니다. ✅ 2. @AllArgsConstructor 어노테이션은 언제 사용하나요? 클래스의 모든 필드를 초기화해야 하는 객체를 만들 때. 다른 어노테이션, 특히 @Builder와 함께 사용하여 객체 생성을 더 편리하게 만들고 싶을 때. 의존성 주입(Dependency Injection) 테스트 등에서 모든 필드를 외부에서 주입받아야 할 때. ✅ 3. @AllArgsConstructor 어노테이션은 어디서 사용하나요? 클래스(Class) 레벨에 선언하여 사용합니다. ✅ 4. @AllArgsConstructor 어노테이션은 어떻게 사용하나요? 클래스 위에 어노테이션을 붙여주기만 하면 됩니다. import lombok.AllArgsConstructor; @AllArgsConstructor // 이 어노테이션이 아래 생성자 코드를 자동으로 만듭니다. public class Product { private Long id; private String name; /* // 아래 생성자가 컴파일 시점에 자동으로 생성됩니다. public Product(Long id, String name) { this.id = id; this.name = name; } */ } ✅ 5. @AllArgsConstructor 어노테이션은 왜 사용하나요? @AllArgsConstructor를 사용하는 주된 이유는 코드의 간결성과 편의성 때문입니다. 보일러플레이트 코드 제거 : 클래스에 필드가 추가되거나 순서가 변경될 때마다 생성자 코드를 직접 수정해야 하는 번거로움을 없애줍니다. @Builder와의 시너지 : @Builder 어노테이션은 객체를 생성할 때 모든 필드를 받는 생성자를 필요로 합니다. 이 때 @AllArgsConstructor를 함께 사용하면 개발자가 직접 생성자를 작성할 필요 없이 빑더 패턴을 쉽게 구현할 수 있습니다.
Backend Development
· 2025-08-06
📚[Backend Development] Domain Layer, JPA Entity 생성시 반드시 지켜야 할 중요 원칙들
📚[Backend Development] Domain Layer, JPA Entity 생성시 반드시 지켜야 할 중요 원칙들 Java Spring 프레임워크로 프로젝트를 진행할 때 Domain Layer, 특히 JPA Entity를 생성할 때 반드시 지켜야 할 중요한 원칙들이 있습니다. 이 원칙들은 코드의 안정성, 유지보수성, 그리고 예상치 못한 버그를 방지하기 위해 꼭 필요합니다. ✅ 1. 기본 생성자(No-Arg Constructor)를 반드시 제공하세요. JPA는 데이터베이스에서 조회한 데이터로 객체를 생성할 때, 먼저 빈 객체를 만든 후 각 필드에 값을 채워 넣습니다. 이때 빈 객체를 만들기 위해 파라미터가 없는 기본 생성자가 반드시 필요합니다. 방법 : Lombok의 @NoArgsConstructor(access = AccessLevel.PROTECTED)를 사용하는 것이 가장 좋습니다. 이유 : protexted로 접근을 제한하면, 개발자가 비즈니스 로직에서 new Product() 처럼 불완전한 객체를 실수로 생성하는 것을 막아 안정성을 높일 수 있습니다. ✅ 2. Setter 사용을 지양하고, 불변성(Immutability)을 추구하세요. Entity 객체에 무분별한 setter 메서드를 열어두면, 애플리케이션의 여러 곳에서 객체의 상태가 의도치 않게 변경될 수 있어 데이터의 일관성을 해치고 버그를 유발하기 쉽습니다. 방법 : 객체 생성은 @Builder를 통해 명확하게 합니다. setter를 만드는 대신, 상태를 변경해야 할 때는 그 의도가 명확히 드러나는 비즈니스 메서드를 만드세요. (예: product.changePrice(newPrice) ✅ 3. 모든 필드를 포함하는 equals()와 hashCode()를 피하세요. JPA Entity에 일반적인 Lombok의 @EqualsAndHashCode를 사용하면, 연관관계 필드로 인해 예기치 않은 문제가 발생할 수 있습니다. 두 엔티티가 서로를 참조하는 경우 무한 루프에 빠질 수 있습니다. 방법 : 객체의 고유성을 보장하는 PK(@Id) 필드만을 비교하도록 구현하는 것이 가장 안전합니다. Lombok 사용 시 : @EqualsAndHashCode(of = "productId") 와 같이 of 속성을 사용하여 PK 필드만 명시적으로 지정해 주세요. ✅ 4. toString() 사용에 주의하세요. @ToString 어노테이션을 무심코 사용하면, 연관된 모든 엔티티를 조회하려는 쿼리가 발생하여 성능 저하를 일으키거나, 양방향 연관관계에서 무한 루프와 StackOverflowError를 유발할 수 있습니다. 방법 : 연관관계를 맺고 있는 필드는 @ToString.Exclude를 사용하여 toString() 결과에서 제외하는 것이 안전합니다. ✅ 5. Entity를 API 요청(Request)/응답(Response)에 직접 사용하지 마세요. Entity는 데이터베이스 테이블과 직접 연결된, 시스템의 가장 핵심적인 데이터 모델입니다. 이를 외부에 그대로 노출하면 보안에 취약하고, 내부 로직의 변경이 API 명세에 직접적인 영향을 주게 되어 유연성이 떨어집니다. 방법 : 반드시 DTO(Data Transfer Object) 를 사용하여, API의 요청과 응답 데이터를 Entity와 분리하세요. 이는 지금까지 우리가 API 명세서를 설계하며 지켜온 가장 중요한 원칙입니다.
Backend Development
· 2025-08-06
📚[Backend Development] `@Builder`와 `@RequiredArgsConstructor`
📚[Backend Development] @Builder와 @RequiredArgsConstructor Lombok의 @Builder와 @RequiredArgsContructor 어노테이션에 대해 알아겠습니다. 📦 @Builder 어노테이션 ✅ 1. @Builder 어노테이션은 언제 사용하나요? @Builder는 객체를 생성할 때 사용하며, 특히 아래와 같은 상황에서 매우 유용합니다. 객체에 설정해야 할 필드가 많을 때 일부 필드는 선택적으로 설정하고 싶을 때 객체 생성 시 코드의 가독성을 높이고 싶을 때 생성된 객체의 불변성(Immutability) 을 보장하고 싶을 때 ✅ 2. @Builder 어노테이션은 어디서 사용하나요? @Builder 어노테이션은 주로 클래스(Class) 또는 생성자(Constructor) 위에 붙여서 사용합니다. // 1. 클래스에 적용하는 경우 @Builder public class Product { private Long productId; private String productName; // ... } // 2. 생성자에 적용하는 경우 (특정 필드만 빌더에 포함하고 싶을 때) public class Product { private Long productId; private String productName; @Builder public Produc(String productName) { this.productName = productName; } } ✅ 3. @Builder 어노테이션은 어떻게 사용하나요? @Builder를 클래스에 붙이면, Lombok이 컴파일 시점에 자동으로 빌더 코드를 생성해줍니다. 우리는 아래와 같이 메서드 체이닝(Method Chaining) 방식으로 직관적인 코드를 작성할 수 있습니다. Java 코드 예시 // 빌더를 사용하여 객체 생성 Product product = Product.builder() .productName("신선한 유기농 우유 1L") .productSaleCost(BigDecimal.valueOf(2500)) .supplier("서울 우유") .builder(); // 마지막에 build()를 호출하여 객체 생성 완료 .필드명(값) 형태로 원하는 값만 설정하고, 순서에 상관없이 자유롭게 작성할 수 있습니다. ✅ 4. @Builder 어노테이션은 왜 사용하나요? @Builder는 객체 생성을 더 안전하고, 유연하며, 읽기 쉽게 만들기 위해 사용합니다. 이는 ‘빌더 디자인 패턴(Builder Design Pattern)’을 자동으로 구현해주는 것입니다. 가독성 (Readability) : new Product(1L, "우유", ...) 처럼 생성자를 사용하는 것보다, builder().productName("우유").build()처럼 어떤 필드에 어떤 값이 들어가는지 명확하게 알 수 있습니다. 유연성 (Flexibility) : 생성자와 달리 필요한 필드만 선택적으로 설정할 수 있고, 순서에 구애받지 않습니다. 객체 일관성 / 불변성 (Consistency / Immutability) : bulid() 메서드가 호출되기 전까지는 객체가 생성되지 않습니다. 따라서 여러 줄의 setter를 사용하는 방식과 달리, 객체가 불완전한 상태로 외부에 노출될 위험이 없습니다. 📦 @RequiredArgsConstructor 어노테이션 @RequiredArgsConstructor는 필수 인자(final 필드)만을 받는 생성자를 자동으로 만들어주는 Lombok 어노테이션입니다. ✅ 1. @RequiredArgsConstructor 어노테이션은 무엇인가요? @RequiredArgsContructor는 Lombok 라이브러리 어노테이션 중 하나로, 클래스 내에서 final 키워드가 붙어 있거나 @NonNull 어노테이션이 붙은 필드만을 인자로 받는 생성자를 컴파일 시점에 자동으로 생성해줍니다. ✅ 2. @RequiredArgsConstructor 어노테이션은 언제사용하나요? 주로 Spring 프레임워크에서 생성자 기반의 의존성 주입(DI, Dependency Injection) 을 구현할 때 사용됩니다. ✅ 3. @RequiredArgsConstructor 어노테이션은 어디서 사용하나요? 클래스(Class) 레벨에 선언하여 사용합니다. ✅ 4. @RequiredArgsConstructor 어노테이션은 어떻게 사용하나요? 아래와 같이 클래스 위에 어노테이션을 붙여주기만 하면 됩니다. import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor // 이 어노테이션이 final 필드를 위한 생성자를 자동으로 만듭니다. public class ProductService { private final ProductRepository productRepository; // final로 선언된 필수 의존성 private final StockRepository stockRepository; // final로 선언된 필수 의존성 private String optionalField; // final이 아니므로 생성자에 포함되지 않음 /* // 아래 생성자가 컴파일 시점에 자동으로 생성됩니다. public ProductService(ProductRepository productRepository, StockRepository stockRepository) { this.productRepository = productRepository; this.stockRepository = stockRepository; } */ } ✅ 5. 왜 사용하나요? (Why) @RequiredArgsConstructor를 사용하는 이유는 코드의 간결성과 안정성을 높이기 위함입니다. 보일러플레이트 코드 제거 : 의존성이 추가되거나 변경될 때마다 생성자 코드를 직접 수정할 필요 없이, final 필드를 선언하기만 하면 되므로 코드가 매우 깔끔해집니다. 안전한 객체 생성 : final 필드는 반드시 생성 시점에 초기화되어야 하므로, 의존성이 누락되는 것을 컴파일 단계에서 방지할 수 있습니다. 불변성 확보 : final로 선언된 의존성은 변경이 불가능하므로, 객체의 불변성을 보장하여 애플리케이션의 안정성을 높입니다.
Backend Development
· 2025-08-06
📚[Backend Development] API 명세서 작성을 뒷받침하는 핵심적인 개념들
📚[Backend Development] API 명세서 작성을 뒷받침하는 핵심적인 개념들 ✅ 1. API 명세서 작성 시 사용자의 편의성을 만족하게 작성해야 하는 이유. API의 ‘사용자’는 일반 고객이 아니라, 이 API를 호출하여 개발하는 다른 개발자(프론트엔드, 모바일 앱 등)입니다. 이들의 편의성을 만족시켜야 하는 이유는 개발 전체의 생산성과 안정성을 높이기 위함입니다. 생산성 향상 : API가 직관적이고 사용하기 편리하면, 클라이언트 개발자는 불필요한 질문이나 추측 없이 빠르게 기능을 구현할 수 있습니다. 이는 전체 프로젝트의 개발 속도를 높입니다. 오류 감소 : API의 동작 방식이 예측 가능하고 일관되면(예: 검색 결과는 항상 배열), 클라이언트 개발자가 실수할 가능성이 줄어들어 버그 발생률이 낮아집니다. 쉬운 유지보수 : 잘 설계된 API는 나중에 기능을 수정하거나 확장할 때도 이해하기 쉬워 유지보수가 용이합니다. 결론 : 동료 개발자를 위한 ‘좋은 도구’를 만든다고 생각하시면 됩니다. 좋은 도구는 사용하기 편하고, 실수를 줄여주며, 결과적으로 더 나은 결과물을 더 빨리 만들게 해줍니다. ✅ 2. 데이터의 정합성이란 무엇인가요? 데이터의 정합성(Data Integrity) 이란, 데이터가 특정 조건이나 규칙을 항상 만족하여 모순 없이 정확하고 일관된 상태를 유지하는 것을 의미합니다. 예시 1: ‘재고’ 데이터는 음수(-)가 될 수 없다는 규칙을 항상 지켜야 합니다. 예시 2: ‘주문’내역에 있는 productId는 반드시 ‘상품’테이블에 실제로 존재하는 ID여야 합니다. 예시 3: 이익률(margin)은 항상 실제 가격 데이터에 기반한 계산 결과와 일치해야 합니다. 결론 : 데이터베이스 속 데이터들이 서로 충돌하거나 논리적으로 말이 안 되는 상황 없이, 언제나 ‘올바른’ 상태로 존재하는 것을 의미합니다. ✅ 3. API 명세서 작성 시 데이터의 정합성을 만족하게 작성해야 하는 이유. API는 데이터베이스의 ‘문지기’ 역할을 하기 때문입니다. API의 설계는 데이터의 정합성을 지키거나 깨뜨리는 데 결정적인 영향을 미칩니다. 잘못된 데이터 방지 : API는 요청 데이터를 검증하여, 정합성을 해치는 데이터(예: 재고를 음수로 만들려는 요청)가 데이터베이스에 저장되지 않도록 막아야 합니다. 트랜잭션 보장 : ‘상품 등록과 초기 재고 입력’처럼 여러 작업을 하나로 묶어, 모두 성공하거나 모두 실패하게 만들어 데이터가 모순된 상태로 남지 않도록 보장해야 합니다. 계산된 값 보호 : margin이나 totalStockCount처럼 계산되는 값을 클라이언트가 직접 수정할 수 없도록 API를 설계해야 합니다. 오직 원본 데이터(가격, 재고량 등)만 수정 가능하게 하여 정합성을 유지합니다. 결론: API는 데이터 정합성을 지키기 위한 규칙을 강제하는 최전성입니다. API 설계가 허술하면, 시스템 전체의 데이터가 망가질 수 있습니다. ✅ 4. RESTful 디자인 원칙이란 무엇인가요? RESTful 디자인 원칙은 웹 서비스를 만들기 위한 일종의 ‘모범 설계 양식’ 또는 ‘가이드라인’입니다. 이 원칙을 따르면 누구나 이해하기 쉽고, 확장하기 편하며, 일관된 API를 만들 수 있습니다. 핵심적인 원칙은 다음과 같습니다. 자원(Resource) : 모든 데이터는 상품, 주문과 같은 ‘자원’으로 표현되며, 각 자원은 고유한 URL 주소를 가집니다.(예: /products, /products/123) 행위(Verd) : 자원에 대한 행위는 HTTP Method(GET, POST, PUT, PATCH, DELETE)로 표현합니다.(예: GET /products - 상품 조회) 표현(Representation) : 자원의 상태는 JSON이나 XML 같은 형태로 주고받습니다. 결론 : ‘URL로는 대상을 표현하고, HTTP Method로는 행위를 표현하자’ 는 것이 RESTful 디자인의 가장 기본적인 철학입니다. ✅ 5. API 명세서 작성 시 RESTful 디자인 원칙을 만족하게 작성해야 하는 이유. RESTful 디자인 원칙은 전 세계 개발자들 사이의 ‘공통 언어’ 또는 ‘문법’ 과 같기 때문입니다. 예측 가능성 : GET /products/1 이라는 URL만 봐도, 개발자 누구나 ‘1번 상품의 정보를 조회하는구나’라고 쉽게 예측할 수 있습니다. 이는 API를 배우고 사용하는 시간을 크게 단축시킵니다. 플랫폼 독립성 : 웹, 모바일 앱, 다른 서버 등 HTTP 통신만 가능하다면 어떤 클라이언트든 동일한 방식으로 API를 사용할 수 있습니다. 확장성 : 명확한 규칙과 구조를 가지므로, 나중에 새로운 기능을 추가하거나 시스템을 확장할 때 더 수월합니다. 일관성 : 프로젝트 내 모든 API가 동일한 규칙을 따르므로, 전체적인 코드의 품질과 유지보수성이 향상됩니다. 결론 : 우리가 맞춤법과 문법에 맞춰 글을 쓰는 것처럼, API도 RESTful이라는 ‘문법’에 맞춰 작성해야 다른 개발자들이 쉽게 이해하고 협업할 수 있습니다.
Backend Development
· 2025-08-05
📚[Backend Development] Java Spring 프레임워크 프로젝트 구현 시 계층 구현 순서와 이유.
📚[Backend Development] Java Spring 프레임워크 프로젝트 구현 시 계층 구현 순서와 이유. 각 계층의 역할과 의존성을 고려했을 때, 프로젝트를 구현하는 데 권장되는 안정적인 순서가 있습니다. ✅ 1. Java Spring 프레임워크 프로젝트를 구현 시 어떤 계층 순서로 구현해야 하는가? 가장 안정적이고 추천되는 순서는 ‘안에서 밖으로(Inside-Out)’ 구현하는 방식입니다. 즉, 데이터의 가장 핵심적인 부분부터 만들어서 바깥으로 확장해 나가는 순서입니다. ✌️ 추천 순서: Domain Layer(도메인 계층) ➞ Data Access Layer(데이터 접근 계층) ➞ Business Layer(비즈니스 계층) ➞ Presentation Layer(표현 계층) Domain Layer(도메인 계층) Product, Stock등 @Entity 클래스와 ERD 설계를 먼저 완성합니다. 이것이 모든 데이터의 뼈대가 됩니다. Data Access Layer(데이터 접근 계층) ProductRepository 등 @Repository 인터페이스를 만들어, 데이터베이스에 실제로 데이터를 CRUD하는 방법을 정의합니다. Business Layer(비즈니스 계층) ProductService 등 @Service 클래스를 만들어, Repository를 활용한 비즈니스 로직, 트랜잭션 처리, 계산 로직 등을 구현합니다. Presentation Layer(표현 계층) @RestController를 만들어, 외부의 요청을 받고 Service를 호출하여 그 결과를 반환하는 API 엔드포인트를 완성합니다. ✅ 2. 왜 ‘안에서 밖으로(Inside-Out)’ 방식으로 계층을 구현해야 할까? 이 순서는 ‘건물을 짓는 순서’ 에 비유할 수 있습니다. 외벽과 인테리어부터 할 수 없듯이, 소프트웨어도 뼈대와 기반부터 쌓아 올리는 것이 가장 안정적입니다. 1단계: 설계도와 뼈대(Domain Layer + Data Access Layer) 집을 짓기 전 설계도(ERD)를 그리고, 땅을 파고 철골(Entity, Repository)을 세우는 것과 같습니다. 이 기반이 튼튼해야만 그 위에 무엇이든 안전하게 올릴 수 있습니다. 2단계: 내부 설비(Business Layer) 뼈대가 완성된 후, 전기/배수/가스 설비(비즈니스 로직, 트랜잭션)를 설치합니다. 이 설비들은 뼈대 구조에 맞춰서 만들어집니다. 3단계: 외벽과 인테리어(Presentation Layer) 모든 내부 구조가 완성된 후, 사람들이 보고 사용할 수 있도록 외벽을 꾸미고 문과 창문(API 엔드포인트)을 답니다. 이 순서를 따랐을 때의 기술적인 장점은 다음과 같습니다. 의존성 순방향 개발 Spring의 계층은 Presentation ➞ Business ➞ Data Access 순서로 의존합니다. 의존되는 대상(안쪽 계층)을 먼저 만들어야, 이를 사용하는 바깥 계층을 안정적으로 구현할 수 있습니다. 탄탄한 기반 위에서의 개발 핵심 데이터 구조와 로직이 이미 완성되고 테스트된 상태에서 UI/API를 개발하므로, 나중에 구조를 뒤엎는 큰 변경이 발생할 확률이 줄어듭니다. 계층별 단위 테스트 용이 안쪽 계층부터 만들면, 각 계층이 완성될 때마다 독립적으로 단위 테스트를 수행하기 매우 편리하여 코드의 안정성을 높일 수 있습니다.
Backend Development
· 2025-08-05
📚[Backend Development] Java Spring 프레임워크의 계층 - Dmain Layer (도메인 계층)
📚[Backend Development] Java Spring 프레임워크의 계층 - Dmain Layer (도메인 계층) ✅ Domain Layer (도메인 계층) 주요 컴포넌트 : @Entity(JPA 사용시), DTO(Data Transfer Object) 역할 : 애플리케이션에서 사용하는 데이터의 구조를 정의합니다. 설명 : 이 계층은 다른 모든 계층에서 사용되는 핵심 데이터 객체를 포함합니다. 도메인 객체/Entity : 데이터베이스 테이블과 직접 매핑되는 객체로, 데이터와 관련된 비즈니스 로직을 포함하기도 합니다. DTO (Data Transfer Object) : 계층 간 데이터 전송을 위해 사용하는 객체입니다. 예를 들어, Controller에서 요청 데이터를 받을 때나, 사용자에게 필요한 데이터만 가공하여 응답할 때 사용됩니다. 이러한 계층 분리는 각 부분의 독립성을 높여주어, 한 계층의 수정이 다른 계층에 미치는 영향을 최소화하고 코드의 재사용성을 높이는 장점이 있습니다. Domain Layer는 애플리케이션의 핵심 데이터 모델을 정의하고, 모든 계층에서 일관된 데이터 구조를 사용하도록 보장하기 위해 사용합니다. ✅ 1. ‘데이터의 구조를 정의한다’의 의미 이는 애플리케이션에서 다루는 핵심 대상들의 속성과 관계를 코드로 명시하는 것을 의미합니다. 예를 들어 ‘게시글’은 ‘제목’, ‘내용’, ‘작성자’를 속성으로 가지고, 하나의 ‘작성자’는 여러 ‘게시글’을 가질 수 있다는 관계를 자바 클래스로 설계하는 것입니다. ✅ 2. ‘핵심 데이터 객체’란 무엇인가요? 애플리케이션의 존재 이유가 되는 핵심적인 데이터를 메모리상에서 표현하는 자바 객체입니다. 쇼핑몰이라면 상품, 주문, 회원 객체가 여기에 해당하며, 이 객체들을 중심으로 모든 비즈니스 로직이 동작합니다. ✅ 3. ‘도메인 객체/Entity’란 무엇인가요? 핵심 데이터 객체 중, 데이터베이스 테이블과 직접적으로 일대일 매핑되는 객체를 말합니다. 보통 JPA의 @Entity 어노테이션이 붙으며, 데이터의 영속성(Persistence)을 관리하는 대상이 됩니다. 데이터베이스의 한 행(Row)이 하나의 Entity 객체라고 볼 수 있습니다. ✅ 4. ‘DTO (Data Transfer Object)’는 무엇인가요? 계층 간 데이터 전송을 위해 사용되는 전용 객체입니다. Entity가 데이터베이스와 직접 연결된 ‘살아있는 객체’라면, DTO는 특정 요청이나 응답에 필요한 데이터들만 담아 전달하는 단순한 ‘데이터 상자’입니다. 예를 들어, 사용자의 비밀번호까지 포함된 User Entity와 달리, 화면에 보여줄 이름과 이메일만 담은 UserResponseDTO를 만들어 사용합니다. ✅ 5. ‘DTO’와 ‘DAO’의 차이점 DTO (Data Transfer Object) : 데이터를 담아 옮기는 ‘객체(Object)’ 입니다. getter/setter 외에 다른 로직을 갖지 않는 순수한 데이터 컨데이너 입니다. DAO (Data Access Object) : 데이터에 접근하는 로직을 담은 ‘객체(Object)’ 입니다. 데이터베이스에 연결하여 CRUD(생성, 조회, 수정, 삭제) 작업을 수행하는 메서드들을 포함하는 설계 패턴입니다. 현대 스프링에서는 @Respository 인터페이스가 이 역할을 대신합니다. 구분 DTO (Data Transfer Object) DAO (Data Access Object) 목적 데이터 전달 데이터 접근 역할 데이터 컨테이너 (데이터를 담는 상자) 데이터 접근 로직 (데이터를 꺼내는 도구) 로직 없음 (Getter/Setter만 가짐) 데이터 CRUD 로직 포함 현대 스프링 DTO Class @Repository Interface ✅ 6. Domain Layer는 언제 사용하나요? 애플리케이션을 설계하는 가장 첫 단계부터 사용하며, 개발 과정 내내 모든 계층에서 참조됩니다. 비즈니스 로직을 처리하거나, 데이터베이스에 데이터를 저장하거나, 화면에 데이터를 표시할 때 항상 이 계층에 정의된 객체를 기준으로 작업을 수행합니다. ✅ 7. Domain Layer는 어디서 사용하나요? 특정 위치에 국한되지 않고, 애플리케이션의 모든 계층(Presentation, Business, Data Access)에서 사용되는 공통 분모입니다. 아키텍처의 중심에 위치하여 애플리케이션의 핵심 데이터 모델을 형성합니다. ✅ 8. Domain Layer는 어떻게 사용하나요? 주로 순수 자바 객체(POJO, Plain Old Java Object) 로 구현합니다. @Entity 클래스 : 데이터베이스 테이블과 매핑되는 핵심 도메인 객체를 정의합니다. DTO(Data Transfer Object) 클래스 : 계층 간 데이터 전송을 위한 데이터 상자를 정의합니다. Enum 클래스 : ‘회원 등급’ (BRONZE, SILVER, GOLD)처럼 정해진 값들의 집합을 정의합니다. ✅ 9. Domain Layer는 왜 사용하나요? 가장 중요한 이유는 애플리케이션의 데이터 모델을 중앙에서 관리하여 일관성을 유지하기 위함입니다. 관심사 분리 : 데이터의 ‘구조’와 데이터를 ‘처리’하는 로직을 분리하여 시스템을 명확하게 만듭니다. 재사용성 및 일관성 : 모든 계층이 동일한 데이터 구조(객체)를 공유하므로, 코드의 재사용성이 높아지고 데이터 불일치 문제가 줄어듭니다. 애플리케이션의 본질 표현 : 이 계층은 애플리케이션이 ‘무엇’에 관한 것인지(e.g, 게시글, 댓글, 회원)를 명확하게 보여주는 청사진 역할을 합니다. ✅ @Entity 컴포넌트 @Entity는 자바 클래스를 데이터베이스 테이블과 매핑하여 JPA(Java Persistence API)가 관리할 수 있도록 할 때 사용합니다. ✅ 1. @Entity는 언제 사용하나요? 데이터베이스 테이블에 저장하고 관리해야 할 데이터를 객체로 만들 때 사용합니다. 예를 들어 ‘회원’, ‘게시글’, ‘상품’처럼 영속적으로 저장되어야 하는 도메인 객체를 정의할 때 사용됩니다. ✅ 2. @Entity는 어디서 사용하나요? 애플리케이션 아키텍처의 Domain Layer(도메인 계층)에서 사용됩니다. 이 계층에 속한 클래스들은 애플리케이션의 핵심 데이터 구조를 나타냅니다. ✅ 3. @Entity는 어떻게 사용하나요? 클래스 선언부 위에 @Entity 어노테이션을 붙여서 사용합니다. 또한, 테이블의 기본 키(Primary Key)에 해당하는 필드에는 @Id 어노테이션을 반드시 붙여줘야 합니다. import javax.persistence.Entity; import javax.persistence.Id; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; // 이 클래스가 데이터베이스 테이블과 매핑되는 엔티티임을 선언합니다. @Entity public class User { // 이 필드가 테이블의 기본 키(Primary Key)임을 나타냅니다. @Id // 기본 키 값을 자동으로 생성하는 방식을 지정합니다. (예: 데이터베이스에 위임) @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; private String email; // Getter, Setter, 기본 생성자 등 } ✅ 4. @Entity는 왜 사용하나요? 가장 중요한 이유는 객체지향 프로그래밍과 관계형 데이터베이스 사이의 패러다임 불일치를 해결하기 위함입니다. @Entity를 사용하여 객체와 테이블을 매핑하면, JPA가 해당 객체를 영속성 컨텍스트(Persistence Context)에서 관리할 수 있게 됩니다. 객체 중심 개발 : 개발자는 SQL 쿼리가 아닌 자바 객체를 다루는 데 집중할 수 있습니다. user.setUsername("new name") 처럼 객체의 상태만 변경하면, JPA가 알아서 적절한 UPDATE 쿼리를 생성하여 데이터베이스에 반영해줍니다. 생산성 및 유지보수성 : 반복적인 CRUD SQL 작성을 줄여주고, 데이터베이스에 독립적인 코드를 작성할 수 있어 생산성과 유지보수성이 향상됩니다.
Backend Development
· 2025-08-04
📚[Backend Development] Java Spring 프레임워크의 계층 - Data Access Layer (데이터 접근 계층)
📚[Backend Development] Java Spring 프레임워크의 계층 - Data Access Layer (데이터 접근 계층) ✅ Data Access Layer (데이터 접근 계층) Data Access Layer는 비즈니스 로직과 데이터베이스를 분리하여 시스템을 유연하고 확장 가능하게 만들기 위해 사용합니다. 주요 컴포넌트 : @Repository 역할 : 데이터베이스(DB)에 직접 접근하여 데이터를 CRUD(Create, Read, Update, Delete)하는 역할을 담당합니다. 설명 : 비즈니스 로직과 데이터베이스 사이의 다리 역할을 합니다. JPA(Java Persistence API)와 같은 기술을 사용하여 SQL 쿼리를 직접 다루고, 데이터의 영속성(Persistence)을 관리합니다. ✅ 1. JPA (Java Persistence API) JPA는 자바 애플리케이션에서 관계형 데이터베이스를 사용하는 방식을 정의한 ‘명세’ 또는 ‘규칙’ 입니다. 개념 : 개발자가 직접 SQL 쿼리를 작성하는 대신, 일반 자바 객체(Object)를 다루듯이 코드를 짜면 JPA가 알아서 적절한 SQL을 생성하여 데이터베이스와 통신합니다. 이처럼 객체와 관계형 데이터베이스를 자동으로 연결해주는 기술을 ORM(Object-Relational Mapping) 이라고 하며, JPA는 이 ORM 기술의 표준 규격입니다. 역할 : SQL 중심의 개발에서 객체 중심의 개발로 전환하여, 개발자가 비즈니스 로직에 더 집중할 수 있게 도와줍니다. 가장 유명한 JPA 구현체로는 하이버네이트(Hibernate) 가 있습니다. 📝 비유 : JPA는 ‘자동 번역기’와 같습니다. 개발자는 ‘자바 객체’라는 언어로 말하면, JPA가 ‘SQL’이라는 언어로 번역하여 데이터베이스와 소통하고 그 결과를 다시 ‘자바 객체’로 번역해서 돌려줍니다. ✅ 2. 데이터의 영속성 (Persistence) 영속성은 데이터가 프로그램이 종료되어도 사라지지 않고 영구적으로 저장소에 남아있는 특성을 의미합니다. 일반적 개념 : 애플리케이션을 껐다 켜도 데이터가 그대로 남아있는 상태를 말합니다. 이러한 영속성을 보장하기 위해 데이터베이스, 파일 시스템 등을 사용합니다. JPA에서의 개념 (영속성 컨텍스트) : JPA에서는 조금 더 구체적인 의미로 사용됩니다. ‘영속성 컨텍스트(Persistence Context)’ 는 JPA가 엔티티(Entity) 객체들을 관리하는 가상의 공간(캐시)입니다. 데이터베이스에서 조회하거나 저장할 엔티티는 이 영속성 컨텍스트에 들어갑니다. 일단 영속성 컨텍스트에 들어온 객체는 JPA가 계속 추적하며 변화를 감지합니다. 트랜잭션이 끝나는 시점에, JPA는 영속성 컨텍스트 안에서 변경된 객체들을 감지하여 자동으로 UPDATE 쿼리를 날려 데이터베이스에 그 변경사항을 반영합니다. 이 과정을 통해 데이터의 영속성이 유지됩니다. ✅ 3. Data Access Layer는 언제 사용하나요? 애플리케이션의 데이터를 영구 저장소(주로 데이터베이스)에 저장하거나 조회하는 모든 작업에 사용됩니다. 비즈니스 계층(Service)이 데이터 처리를 요청하면, 이 계층이 실질적인 데이터베이스 통신을 담당합니다. ✅ 4. Data Access Layer는 어디서 사용하나요? 애플리케이션 아키텍처의 가장 안쪽, 데이터베이스와 가장 가까운 곳에 위치합니다. 비즈니스 계층(Service)의 요청을 받아 데이터베이스와 직접 상호작용하는 최전선 역할을 수행합니다. ✅ 5. Data Access Layer는 어떻게 사용하나요? 주로 @Repository 어노테이션을 붙인 인터페이스나 클래스로 구현합니다. 특히 Spring Data JPA를 사용하면, JpaRepository와 같은 인터페이스를 상속받는 것만으로도 기본적인 CRUD 기능이 구현되어 매우 편리하게 사용할 수 있습니다. @Repository public interface UserRepository extends JpaRepository<User, Long> { // 이메일로 사용자를 찾는 커스텀 메서드 Optional<User> findByEmail(String email); } ✅ 6. Data Access Layer는 왜 사용하나요? 가장 중요한 이유는 데이터 접근 기술의 캡슐화와 관심사의 분리입니다. 유연선 및 확장성 : 나중에 데이터베이스를 MySQL에서 Oracle이나 다른 종류로 변경하더라도, 비즈니스 로직 코드는 전혀 수정할 필요 없이 이 Data Access Layer의 구현만 바꾸면 됩니다. 유지보수 용이성 : 데이터베이스와 관련된 모든 코드가 한곳에 모여있어, SQL 쿼리 최적화나 테이블 구조 변경 시 수정할 부분을 찾기 쉽고 관리가 용이합니다. 역할 분리 : 비즈니스 계층은 ‘무엇을 할지’에만 집중하고, 데이터 접근 계층은 ‘어떻게 데이터를 가져올지’에만 집중하게 하여 코드의 가독성과 명확성을 높입니다. ✅ @Repository @Repository는 데이터베이스에 직접 접근하는 클래스(DAO)에 사용되며, 예외를 스프링의 일관된 방식으로 변환해주는 역할을 합니다. ✅ 1. @Repository는 언제 사용되나요? 데이터베이스와 직접 통신하여 데이터를 CRUD(생성, 조회, 수정, 삭제)하는 객체를 만들 때 사용합니다. 주로 데이터베이스의 테이블에 접근하는 DAO(Data Access Object) 클래스나 인터페이스에 적용됩니다. ✅ 2. @Repository는 어디서 사용되나요? 애플리케이션 아키텍처의 Data Access Layer(데이터 접근 계층)에서 사용됩니다. 이 계층은 비즈니스 로직을 처리하는 Service 계층과 실제 데이터베이스 사이에서 데이터를 주고 받는 역할을 담당합니다. ✅ 3. @Repository는 어떻게 사용되나요? 클래스나 인터페이스 선언부 위에 @Repository 어노테이션을 붙여주기만 하면 됩니다. 특히 Spring Data JPA를 사용하면, JpaRepository 인터페이스를 상속받는 것만으로도 자동으로 Repository로 등록되어 기본적인 CRUD 메서드를 바로 사용할 수 있습니다. // 이 인터페이스가 데이터베이스에 접근하는 Repository임을 선언합니다. @Repository public interface UserRepository extends JpaRepository<User, Long> { // JpaRepository를 상속받으면 기본적인 CRUD(save, findById, findAll, delete 등) // 메서드가 자동으로 제공됩니다. // 메서드 이름을 규칙에 맞게 작성하면, Spring Data JPA가 알아서 // 쿼리를 생성해줍니다. (예: 이메일로 사용자 찾기) Optional<User> findByEmail(String email); } ✅ 4. @Repository는 왜 사용되나요? @Repository를 사용하는 주된 이유는 두 가지입니다. 역할 명시 (코드 가독성) : 이 클래스가 ‘데이터 저장소에 접근하는 객체’ 임을 명확하게 나타냅니다. 이를 통해 개발자는 클래스의 역할을 쉽게 파악할 수 있습니다. 예외 변환 (Exception Translation) : @Repository의 가장 중요한 기능입니다. 데이터베이스마다 발생하는 예외(e.g, MySQL의 MySQLIntegrityConstraintViolationException)가 다릅니다. @Repository가 붙어 있으면, 스프링이 이러한 특정 기술에 종속적인 예외들을 스프링의 일관된 예외 계층인 DataAccessException으로 변환해줍니다. 덕분에 개발자는 데이터베이스 종류와 상관없이 일관된 방식으로 예외를 처리할 수 있습니다.
Backend Development
· 2025-08-04
📚[Backend Development] API 명세서
📚[Backend Development] API 명세서. ✅ API 명세서 (API Specification, API Documentation) API 명세서는 개발자 간의 API 사용 설명서이자 서로의 약속(Contract)입니다. ✅ 1. API 명세서란 무엇인가요? API 명세서는 해당 API의 기능, 사용 방법, 요청/응답 형식, 규칙 등 API와 관련된 모든 정보를 상세하게 기록한 공식 문서입니다. ✅ 2. API 명세서는 언제 사용하나요? 소프트웨어 개발 전 과정에 걸쳐 사용됩니다. 기획/설계 단계 : API의 전체 구조를 미리 구상하고 정의합니다. 개발 단계 : 프론트엔드와 백엔드 개발자가 이 명세서를 기준으로 각자의 작업을 동시에 진행합니다. 테스트 단계 : API가 명세서대로 정확히 동작하는지 검증하는 기준으로 삼습니다. 유지보수 단계 : API를 수정하거나 기능을 추가할 때 참고하는 공식 문서로 활용됩니다. ✅ 3. API 명세서는 어떻게 사용하나요? 개발자들은 명세서를 ‘참조’하여 코드를 작성합니다. 백엔드 개발자 : 명세서에 정의된 대로 요청을 처리하고, 약속된 형식의 응답을 반환하는 코드를 구현합니다. 프론트엔드 개발자 : 명세서를 보고 서버에 어떤 URL로, 어떤 데이터를 보내야 하는지 확인하고, 서버로부터 받을 응답 데이터 구조에 맞춰 화면을 구현합니다. ✅ 4. API 명세서는 어디서 사용하나요? 개발팀 내부의 협업 도구에서 주로 사용하고 공유합니다. 문서화 도구 : Swagger/OpenAPI, Postman 등 API 문서를 전문적으로 관리하는 툴 위키/협업 툴 : Confluence, Notion, GitHub Wiki 등 팀이 공유하는 문서 공간 ✅ 5. API 명세서는 왜 사용하나요? API 명세서는 개발 과정의 불확실성을 제거하고 생산성을 극대화하기 위해 사용합니다. 명확한 소통 : 모든 개발자가 동일한 문서를 보고 작업하므로, “이 데이터는 어떤 형식으로 보내야 해요?”와 같은 불필요한 소통 비용이 사라집니다. 독립적인 병렬 개발 : 백엔드 API가 완성되지 않았더라도, 프론트엔드 개발자는 명세서만 보고 API가 완성된 것처럼 가정하고 개발을 진행할 수 있습니다. 의존성 감소 : 사람의 기억이나 구두 설명이 아닌, 문서에 기반하여 개발하므로 담당자가 바뀌어도 업무 공백을 최소화할 수 있습니다. ✅ 6. API 명세서는 왜 중요한가요? 협업 효율 증가 : 프론트엔드와 백엔드 개발자가 약속된 명세서를 기준으로 동시에 개발을 진행할 수 있어 전체 개발 시간이 단축됩니다. 의사소통 오류 감소 : “데이터를 어떤 형식으로 보내야 해요?”와 같은 불필요한 질문 없이, 문서를 통해 소통하여 오해를 줄입니다. 체계적인 API 설계 : 명세서를 작성하는 과정에서 API의 구조를 미리 고민하게 되어, 더 일관성 있고 안정적인 설계가 가능해집니다. ✅ 7. 무엇을 포함해야 하나요? 하나의 API 엔드포인트(Endpoint)는 최소한 다음 정보를 포함해야 합니다. API 이름 및 설명 : 이 API가 어떤 기능을 하는지 한글로 명확하게 설명합니다. (예: 회원 가입, 특정 게시글 조회) HTTP Method : GET, POST, PUT, DELETE 등 어떤 요청 방식을 사용하는지 명시합니다. URL (Endpoint) : API를 호출하기 위한 고유 주소를 기입합니다. (예: /api/v1/users/{userId}) 요청 (Request) : 클라이언트가 서버에 보내야 할 데이터 정보를 상세히 기술합니다. Header : 인증 토큰 등 요청 헤더에 담길 정보 Path Variable : URL 경로에 포함되는 변수 정보 (예: {userId}) Query Parameter : URL 뒤에 ?로 붙는 파라미터 정보 (예: /posts?page=1&size=10) Body : POST 나 PUT 요청 시 전송할 JSON 데이터의 구조 (필드명, 데이터 타입, 필수 여부, 설명 등) 응답 (Response) : 서버가 클라이언트에게 보내주는 결과 데이터 정보를 상세히 기술합니다. 상태 코드 (Status Code) : 성공(200, 201), 실패(400, 404, 500) 등 상황별 응답 코드를 명시합니다. Body : 성공 또는 실패 시 전달할 JSON 데이터의 구조 (필드명, 데이터 타입, 설명 등) ✅ 8. 어떻게 작성하나요? 간단한 방법 (마크다운) : Notion, Confluence, GitHub Wiki 등에 마크다운 표를 이용해 직접 작성합니다. 팀 내부에서 빠르고 간단하게 공유하기 좋습니다. 전문적인 방법 (OpenAPI Specification) : Swagger와 같은 도구를 사용해 작성합니다. 정해진 양식(YAML, JSON)에 따라 작성하면, 자동으로 보기좋고 체계적인 API 문서를 생성해주고 테스트 기능까지 제공하여 가장 표준적으로 사용되는 방식입니다. ✅ 9. 작성 예시 (마크 다운) 간단한 ‘특정 회원 정보 조회’ API에 대한 명세서 예시입니다. 특정 회원 정보 조회 Description : 사용자 ID를 이용해 특정 회원의 상세 정보를 조회합니다. HTTP Method : GET URL : /api/v1/users/{userId} Request 구분 필드명 데이터 타입 필수 설명 Path Variable userId Long O 조회할 회원의 고유 ID Response 성공 : 200 OK { "id": 1, "email": "user@example.com", "username": "강유저", "createdAt": "2025-08-04T07:26:00" } 실패 : 404 Not Found { "errorCode": "USER_NOT_FOUND", "message": "해당 사용자를 찾을 수 없습니다." }
Backend Development
· 2025-08-04
📚[Backend Development] Java Spring 프레임워크의 계층 - Presentation Layer(표현 계층)
📚[Backend Development] Java Spring 프레임워크의 계층 1️⃣ ✅ Presentation Layer(표현 계층) 주요 컴포넌트 : @Controller, @RestController 역할 : 사용자의 HTTP 요청(Request)을 받고, 그 결과를 응답(Response)으로 보내주는 애플리케이션의 진입점입니다. 설명 : 사용자의 입력을 받아 유효성을 검사하고, 비즈니스 계층으로 데이터 처리를 위임합니다. 비즈니스 계층에서 처리된 결과는 다시 이 계층에서 사용자에게 보여줄 형식(HTML, JSON 등)으로 변환되어 전달됩니다. ✅ 1. Presentation Layer는 언제 사용하나요? 외부 클라이언트(웹 브라우저, 모바일 앱 등)가 애플리케이션의 기능에 접근해야 할 때 항상 사용합니다. 사용자가 웹사이트의 버튼을 클릭하거나, 앱이 서버로부터 데이터를 가져오는 모든 순간에 이 계층이 가장 먼저 동작합니다. ✅ 2. Presentation Layer는 어디서 사용하나요? 애플리케이션 아키텍처의 가장 바깥쪽 계층에 위치합니다. 외부 클라이언트와 내부 비즈니스 계층(Service Layer) 사이의 중간 다리 역할을 하며, 시스템의 ‘현관’ 또는 ‘안내 데스크’라고 생각할 수 있습니다. ✅ 3. Presentation Layer는 어떻게 사용하나요? 주로 어노테이션 기반으로 사용하며, 요청을 처리하고 응답을 반환하는 방식으로 동작합니다. @RestController, @Controller 어노테이션으로 클래스를 지정해 요청을 받을 수 있는 상태로 만듭니다. @GetMapping, @PostMapping 등으로 특정 URL과 HTTP Method에 맞는 처리 메서드를 연결합니다. 메서드 내에서는 클라이언트가 보낸 데이터를 받아 유효성을 검사한 후, 처리를 서비스 계층(Service Layer)에 위임합니다. 서비스로부터 받은 결과를 클라이언트에게 JSON 데이터나 HTML 화면의 형태로 반환합니다. ✅ 4. Presentation Layer는 왜 사용하나요? 가장 중요한 이유는 관심사의 분리(Separation of Concerns, SoC) 를 통해 시스템을 더 체계적이고 유연하게 만들기 위함입니다. 역할과 책임 명확화 : 이 계층은 ‘웹 요청과 처리’라는 책임만 가집니다. 덕분에 비즈니스 계층은 ‘핵심 로직 처리’에만 집중할 수 있어 코드 이해가 쉬워집니다. 유지보수 용이성 : 프론트엔트에 보여주는 데이터 형식을 변경해야 할 때, 다른 비즈니스 로직은 건드리지 않고 Presentation Layer만 수정하면 되므로 관리가 편합니다. 유연성 및 확장성 : 웹(HTML)으로 서비스를 제공하다가 모바일 앱을 위한 API가 추가로 필요해져도, 기존 비즈니스 로직은 그대로 두고 새로운 Controller만 추가하면 되므로 확장이 용이합니다. ✅ Controller의 역할은 무엇안가요? Controller의 핵심 역할은 HTTP 요청을 받아, 그에 맞는 서비스(기능)를 호출하고, 처리된 결과를 다시 사용자에게 응답(Response)하는 것입니다. 조금 더 구체적으로는 다음과 같은 역할을 담당합니다. 요청 접수 (Endpoint Mapping) : 사용자가 보낸 URL과 HTTP Method(GET, POST 등)를 보고, 어떤 메서드가 이 요청을 처리해야 할지 결정합니다. 데이터 수신 및 검증 (Data Binding & Validation) : 사용자가 보낸 데이터(예: 회원가입 정보)를 자바 객체로 변환하고, 데이터가 유효한지(예: 이메일 형식이 맞는지) 검사합니다. 서비스에 작업 위임 (Delegate to Service) : Controller는 비즈니스 로직을 직접 처리하지 않습니다. 데이터 처리가 필요한 실제 작업은 서비스(Service) 계층에 위임합니다. 결과 반환 (Return Response) : 서비스로부터 받은 처리 결과를 사용자에게 적절한 형태로(주로 JSON 또는 HTML) 변환하여 반환합니다. 📝 비유 : 레스토랑의 ‘웨이터’와 같습니다. 손님(Client)의 주문(Request)을 받아 주방(Service)에 전달하고, 완성된 요리(Data)를 손님에게 다시 서빙(Response)하는 역할을 합니다. 웨이터가 직접 요리하지 않는 것처럼, Controller도 직접 비즈니스 로직을 처리하지 않습니다. ✅ Controller는 언제 사용하나요? 웹을 통해 외부에서 애플리케이션의 특정 기능에 접근해야 할 때 항상 사용합니다. 사용자가 웹사이트에서 버튼을 클릭하거나 페이지를 요청할 때 모바일 앱에서 서버의 데이터를 불러오거나 저장할 때 다른 서버(시스템)가 우리 애플리케이션의 API를 호출할 때 즉, 클라이언트(브라우저, 앱 등)와 서버의 비즈니스 로직을 연결하는 모든 접점에서 Controller가 사용됩니다. ✅ Controller는 어떻게 사용하나요? 주로 어노테이션(@)을 사용하여 클래스와 메서드의 역할을 지정하는 방식으로 사용합니다. 아래는 간단한 사용자 정보 조회 API의 Controller 예시 코드입니다. // 이 클래스가 API 요청을 처리하는 Controller임을 선언합니다. // @RestController는 각 메서드의 반환 값을 자동으로 JSON 형태로 변환해줍니다. @RestController public class UserController { // 비즈니스 로직을 처리할 UserService를 연결합니다. (의존성 주입) private final UserService userService; public UserController(UserService userService) { this.userService = userService; } /** * 모든 사용자 목록을 조회하는 API * HTTPGET 요청 & URL "/api/users"에 매핑됩니다. */ @GetMapping("/api/users") public List<User> getAllUsers() { // 실제 데이터 조회는 Service에게 위임합니다. return userService.findAllUsers(); } /** * 특정 ID의 사용자 한 명을 조회하는 API * URL 경로의 {id} 부분을 파라미터로 받습니다. * 예: /api/users/1 -> id 변수에 1이 담깁니다. */ @GetMapping("/api/users/{id}") public User getUserById(@PathVariable Long id) { // Service에게 id를 전달하여 데이터 조회를 위임합니다. return userService.findUserById(id); } } 코드 사용법 요약: 클래스 위에 @RestController 를 붙여 Controller임을 명시합니다. 요청을 처리할 메서드 위에 @GetMapping, @PostMapping 등으로 어떤 HTTP 요청과 URL에 응답할지 지정합니다. URL에 포함된 값은 @PathVariable 로, 요청 본문에 담긴 데이터는 @RequestBody 로 받습니다. 처리할 로직은 Service 객체의 메서드를 호출하여 위임합니다. 메서드의 반환 값은 Spring이 자동으로 클라이언트에게 보낼 응답 데이터(주로 JSON)로 만들어 줍니다. ✅ Controller와 RestController의 차이점은? @RestController는 @Controller에 @ResponseBody가 추가된 것으로, API를 만들 때 사용합니다. @Controller와 @RestController의 가장 큰 차이점은 반환하는 값의 종류에 있습니다. @Controller : 주로 View(HTML 페이지) 를 반환하기 위해 사용합니다. 메서드가 문자열을 반환하면, Spring은 그 문자열을 View의 이름으로 해석하여 해당 화면을 렌더링합니다. 만약 @Controller에서 JSON 같은 데이터를 반환하려면, 메서드에 @ResponseBody 어노테이션을 별도로 붙여줘야 합니다. @RestController : 데이터(주로 JSON, XML) 를 반환하기 위해 사용합니다. 이 어노테이션은 @Controller + @ResponseBody 를 합쳐 놓은 것 입니다. 클래스에 @RestController를 붙이면, 그 안의 모든 메서드는 View가 아닌 데이터 자체를 반환하는 것이 기본 동작이 됩니다. RESTful API를 만들 때 사용됩니다. 구분 @Controller @RestController 주요 목적 MVC 패턴의 View(화면) 반환 REST API의 데이터(JSON 등) 반환 메서드 반환 값 View 이름 (String) 객체, 데이터 (자동으로 JSON으로 변환) @ResponseBody 데이터 반환 시, 메서드에 별도 추가 필요 클래스에 자동으로 포함되어 있어 불필요 사용 사례 서버 사이드 렌더링 (JSP, Thymeleaf) 웹 페이지 개발 모바일 앱, 프론트엔드(React, Vue)와 연동하는 API 개발 ✅ 그 외 Presentation Layer의 컴포넌트 Controller 외에도 Presentation Layer에서는 요청을 처리하고 예외를 관리하기 위한 여러 컴포넌트를 사용합니다. @ControllerAdvice / @RestControllerAdvice : 전역 예외 처리를 담당합니다. 여러 Controller에서 발생하는 특정 예외(Exception)들을 한 곳에서 공통으로 처리할 수 있게 해줍니다. 코드 중복을 줄이고 예외 관리를 중앙화하는 데 필수적입니다. @ExceptionHandler : @Controller나 @ControllerAdvice 클래스 내에서 특정 예외가 발생했을 때 실행될 메서드를 지정하는 어노테이션입니다. 예를 들어, NullPointException이 발생하면 특정 에러 페이지나 JSON 응답을 보내도록 처리할 수 있습니다. Filter / Interceptor : Controller에 요청이 도달하기 전후에 공통된 작업을 처리하기 위해 사용됩니다. Filter: 사용자의 요청/응답 내용을 변경하거나, 인코딩 변환, 인코딩 변환, 보안 검사 등을 수행합니다. Spring 프레임워크 바깥인 Servlet 단계에서 동작합니다. Interceptor: Filter와 유사하지만 Spring MVC의 일부입니다. 사용자 인증(로그인) 여부를 확인하거나, Controller로 넘어가는 데이터에 추가 정보를 더하는 등 비즈니스 로직에 더 가까운 작업을 처리할 때 사용됩니다.
Backend Development
· 2025-08-03
📚[Backend Development] Java Spring 프레임워크의 계층 - Business Layer(비즈니스 계층)
📚[Backend Development] Java Spring 프레임워크의 계층 - Business Layer(비즈니스 계층) ✅ Business Layer (비즈니스 계층) 주요 컴포넌트 : @Service 역할 : 애플리케이션의 핵심 비즈니스 로직을 처리합니다. 설명 : 사용자의 요청에 대한 실제 작업(e.g, “게시글을 저장한다”, “사용자 등급을 업데이트한다”)을 수행합니다. 여러 데이터 접근 계층의 기능을 조합하여 하나의 트랜잭션으로 묶거나, 복잡한 계산을 처리하는 등 비즈니스의 ‘규칙’과 ‘정책’을 구현하는 부분입니다. ✅ 1. 핵심 비즈니스 로직 (Core Business Logic) “이 서비스를 무엇 때문에 사용하는가?”라는 질문에 대한 답을 코드로 구현한 것입니다. 즉, 애플리케이션의 존재 이유이자 핵심 기능 그 자체를 의미합니다. 개념 : 데이터를 단순히 저장하고 보여주는 것을 넘어, 특정 목적을 위해 데이터를 가공하고 처리하는 모든 과정입니다. 예시 (쇼핑몰) : ‘주문 처리’ 로직: 사용자가 ‘주문하기’ 버튼을 누르면, 시스템은 상품의 재고를 확인하고, 사용자의 쿠폰을 적용하여 최종 결제 금액을 계산하고, 사용자의 등급에 따라 포인트를 적립한 후, 배송 정보를 생성합니다. 이 모든 과정의 조합이 바로 ‘주문 처리’라는 하나의 핵심 비즈니스 로직입니다. ✅ 2. 트랜잭션 (Transaction) 서로 관련된 여러 개의 작업을 하나의 묶음으로 처리하는 것을 의미합니다. 이 묶음 안의 작업들은 ‘모두 성공하거나 모두 실패해야’ 합니다 (All or Nothing). 개념 : 데이터의 일관성과 무결성을 보장하기 위한 매우 중요한 기능입니다. 중간에 하나의 작업이라도 실패하면, 이전에 성공했던 모든 작업을 원래 상태로 되돌립니다(Rollback). 예시 (계좌 이체) : A의 계좌에서 1만 원을 출금하고, B의 계좌에서 1만 원을 입금하는 것은 두 개의 작업입니다. 만약 출금은 성공했는데, 시스템 오류로 입금이 실패하면 돈이 공중으로 사라지게 됩니다. ‘트랜잭션’은 이 두 작업을 하나로 묶어, 입금이 실패하면 성공했던 출금까지 취소시켜 데이터가 틀어지는 것을 막아줍니다. Spring에서는 @Transactional 어노테이션으로 간단하게 적용할 수 있습니다. ✅ 3. 비즈니스의 규칙 (Business Rule) 애플리케이션이 따라야 하는 구체적이고 명확한 조건이나 제약사항을 말합니다. 보통 ‘참/거짓’으로 판별할 수 있는 명제 형태를 띱니다. 개념 : 데이터나 프로세스가 유효한 상태를 유지하기 위한 개별적인 검사 항목입니다. 예시 (쇼핑몰) : “회원가입 시 비밀번호는 반드시 8자 이상이어야 한다.” “상품의 재고가 0이면 ‘품절’ 상태로 표시해야 한다.” “미성년자는 주류를 구매할 수 없다.” ✅ 4. 비즈니스의 정책 (Business Policy) 여러 비즈니스 규칙들이 모여서 만들어진, 더 넓은 범위의 운영 방침이나 전략을 의미합니다. 개념 : 하나의 규칙이라기보다는, 특정 상황에서 비즈니스가 어떻게 운영될지에 대한 전반적인 가이드라인입니다. 예시 (쇼핑몰) : “배송비 정책” : “기본 배송비는 3,000원이다(규칙1). 하지만, 5만 원 이상 구매 시 배송비는 무료다(규칙2). 또한, 제주 및 도서 산간 지역은 추가 배송비 5,000원이 붙는다(규칙3).” 이 규칙들의 집합이 ‘배송비 정책’이라는 하나의 정책을 이룹니다. ‘환불 정책’ : “구매 후 7일 이내에만 환불이 가능하다(규칙1). 상품의 포장이 훼손되지 않아야 한다(규칙2).” ✅ Service의 역할은 무엇인가요? Service의 핵심 역할은 비즈니스 로직을 구현하는 것입니다. Controller로부터 요청을 받아, 데이터를 가공하거나 여러 데이터 소스를 조합하는 등 실제적인 ‘업무’를 처리합니다. 비즈니스 로직 중앙화 : 애플리케이션의 핵심 로직을 한 곳에 모아 관리함으로써 코드의 일관성을 유지하고 중복을 방지합니다. 트랜잭션 관리 : 여러 데이터베이스 작업을 하나의 단위(트랜잭션)로 묶어 데이터의 일관성을 보장합니다. 예를 들어, 출금과 입금이 모두 성공해야만 계좌이체가 완료되도록 관리합니다. Controller와 Repository 분리 : Service는 Controller가 비즈니스 로직을 직접 알지 못하게 하고, Repository가 단순한 데이터 CRUD(생성, 조회, 수정, 삭제)에만 집중하도록 하는 중간 다리 역할을 수행합니다. ✅ Service는 언제 사용하나요? Controller가 받은 요청을 처리하기 위해 단순한 데이터 전달 이상의 작업이 필요할 때 사용합니다. 하나의 기능이 여러 데이터베이스 작업을 필요로 할 때 (e.g, 게시글 작성 시 게시글 저장 후, 사용자 포인트 업데이트) 데이터를 가공하거나 비즈니스 규칙을 적용해야할 때 (e.g, 사용자 나이를 계산하거나, 주문 금액에 따라 배송비를 결정할 때) 트랜잭션 처리가 필요하여 데이터의 원자성(All or Nothing)을 보장해야 할 때 ✅ Service는 어떻게 사용하나요? @Service 어노테이션을 클래스에 붙여 사용하며, 데이터베이스 접근을 위해 Repository를 주입받아 비즈니스 로직을 구현합니다. 아래는 id로 사용자를 조회하되, 없을 경우 예외를 발생시키는 간단한 Service 예시 코드입니다. // 이 클래스가 비즈니스 로직을 처리하는 Service임을 선언합니다. @Service public class UserService { // 데이터베이스 접근을 위한 UserRepository를 연결합니다. (의존성 주입) private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository = userRespository; } /** * 사용자 ID로 사용자를 조회하는 메서드 * @Transactional(readOnly = true)는 이 메서드가 데이터베이스를 읽기만 하는 * 작업이며, 약간의 성능 최적화를 제공합니다. */ @Transactional(readOnly = true) public User findUserById(Long id) { // Repository를 통해 ID로 사용자를 찾고, // 만약 사용자가 없으면 예외를 발생시키는 '비즈니스 로직'을 수행합니다. return userRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("해당 사용자가 없습니다. id=" + id)); } } // Controller에서는 이 Service를 주입받아 사용합니다. @RestController public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/api/users/{id}") public User getUserById(@PathVariable Long id) { // Controller는 비즈니스 로직을 직접 수행하지 않고 Service의 메서드를 호출합니다. return userService.findUserById(id); } } ✅ 1. Business Layer는 언제 사용하나요? 애플리케이션의 핵심 기능을 수행해야 할 때 항상 사용합니다. Controller로부터 요청을 받아, 단순히 데이터를 전달하는 것을 넘어 데이터를 가공하거나, 비즈니스 규칙을 적용하고, 여러 단계의 작업을 하나의 묶음(트랜잭션)으로 처리해야 할 때 이 계층이 중심적인 역할을 합니다. ✅ 2. Business Layer는 어디서 사용하나요? 애플리케이션 아키텍처에서 Presentation Layer(Controller)와 Data Access Layer(Repositoty) 사이의 중간 계층에 위치합니다. 외부의 요청을 처리하는 부분과 데이터베이스에 직접 접근하는 부분을 분리하여, 그 사이에서 실질적인 업무를 처리하는 ‘두뇌’역할을 담당합니다. ✅ 3. Business Layer는 어떻게 사용하나요? 주로 @Service 어노테이션을 사용하여 클래스를 정의하고, 이 클래스 안에서 비즈니스 로직을 메서드로 구현합니다. @Service 클래스는 데이터베이스 접근을 위해 @Repository를 주입(Injection) 받습니다. Controller로부터 전달받은 데이터를 사용하여, Repository를 통해 데이터를 조회하거나 저장합니다. 데이터를 가공하고, 비즈니스 규칙을 적용하며, @Transactional 어노테이션을 통해 데이터 처리의 일관성을 보장합니다. ✅ 4. Business Layer는 왜 사용하나요? 가장 중요한 이유는 관심사의 분리(Separation of Concerns) 를 통해 얻는 여러 이점 때문입니다. 유지보수성 향상 : 비즈니스 정책(e.g, 할인율 변경)이 바뀔 때, 다른 계층은 건드리지 않고 Business Layer의 관련 코드만 수정하면 되므로 관리가 매우 용이합니다. 코드 재사용성 증가 : 하나의 비즈니스 로직(e.g, 회원가입)을 만들어주면, 웹 Controller, 모바일 API Controller 등 여러 곳에서 해당 Service를 호출하여 재사용할 수 있습니다. 트랜잭션 관리 : 여러 개의 데이터베이스 변경 작업을 하나의 논리적인 단위로 묶어 처리하기에 가장 적합한 위치입니다. 이를 통해 데이터의 정합성을 안전하게 지킬 수 있습니다. 테스트 용이성 : 웹 서버나 데이터베이스 없이도 순수 비즈니스 로직 자체의 정확성을 독립적으로 테스트하기 편리합니다.
Backend Development
· 2025-08-03
📚[Backend Development] 스프링 부트의 핵심 개념 - 1. 의존성 주입(DI, Dependency Injection)🙌
Backend Development
· 2025-08-02
📚[Backend Development] 스프링과 스프링 부트 🙌
✅ Intro. Spring과 Spring Boot는 서로 밀접한 관계에 있지만, 목적과 사용 방식, 개발 편의성에서 큰 차이가 있습니다. 아래에 구조적으로 차이점을 정리해드릴게요 🙌 ✅ 요약: 한 줄 차이 구분 설명 Spring 순수한 프레임워크, 유연하지만 설정이 많음 Spring Boot Spring을 쉽게 쓰기 위한 자동 설정 기반의 도구 세트 ✅ 1. Spring Framework란? 자바 기반 웹 애플리케이션 개발을 위한 오픈소스 프레임워크 핵심 개념: IoC (제어의 역전), DI (의존성 주입), AOP (관점 지향 프로그래밍) 사용 시에는 XML 또는 자바 코드로 직접 많은 설정을 해야 함 <!-- Spring (전통적) 방식 예시 --> <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource">...</bean> ✅ 2. Spring Boot란? Spring을 더 쉽고 빠르게 개발하기 위해 나온 확장 도구입니다. 자동 설정(AutoConfiguration), 내장 서버(Embedded Tomcat), 스타터(Starter)등을 통해 설정 없이도 바로 실행 가능한 스프링 환경을 제공합니다. @SpringBootApplication public class MyApp { public static void main(String[] args) { SpringApplication.run(MyApp.class, args); } } ✅ 3. 차이점 비교표 항목 Spring Spring Boot 💡 목적 유연하고 확장 가능한 프레임워크 제공 설정 없이 빠른 개발 환경 제공 ⚙️ 설정 수동 설정 많음 (XML, JavaConfig 등) 자동 설정 중심 (AutoConfiguration) 🚀 실행 톰캣 설치 필요 내장 톰캣으로 단독 실행 가능 (java -jar) 📦 의존성 직접 관리 Starter로 간단히 관리 (예: spring-boot-starter-web) 🛠️ 프로젝트 구조 구조 설계부터 직접 구성 관례 기반 기본 구조 제공 🧪 테스트 환경 복잡하게 구성 내장된 테스트 도구 쉽게 사용 가능 📈 생산성 초반 진입 장벽 있음 매우 높음 (간편한 설정, 빠른 실행) ✅ 결론: 언제 사용하나? 상황 추천 유연한 아키텍처 필요, 라이브러리 직접 제어 🔹 Spring (Core Framework) 빠르게 웹 서비스 시작, 실무 생산성 중시 🔸 Spring Boot (현대 개발의 표준) 🎯 최종 요약 Spring Boot는 Spring Framework를 기반으로 개발을 더 쉽게 만들어주는 “자동화 도구 세트”입니다. 따라서 Spring Boot를 사용하면 Spring을 더 효율적이고 간편하게 활용할 수 있습니다.
Backend Development
· 2025-08-01
📚[Backend Development] 🎢롤러코스터 타이쿤 속에 살아있는 프레임워크(Framework)와 라이브러리(Library)
✅ Intro. 제가 어릴 적 가장 기억에 남는 게임을 고르라면 저는 “롤러코스터 타이쿤” 을 고를 것입니다. 얼마나 이 게임에 애정이 있었냐 하면, 이런 일화도 있었답니다. 제가 너무 이 게임에 빠져서 밤새 게임을 하다 보니 게임 시간을 정해주셔서 밤에는 못하게 되었습니다. 그러나 저는 부모님 몰래 “롤러코스터 타이쿤”을 플레이하기 위해 부모님께서 주무시는 시간에 이불로 모니터를 덮고 그 이불 속에서 땀을 뻘뻘 흘리며 게임을 했었답니다. 이런 일화를 떠올리니 웃음이 나네요 😊 프레임워크(Framework)와 라이브러리(Library)에 대해서 포스팅을 하는데 왜 Intro에 “롤러코스터 타이쿤” 이야기를 하는지 궁금하신 분들이 계실 거예요!! 사실 “롤러코스터 타이쿤” 은 “프레임워크(Framework)와 라이브러리(Library)” 로 플레이 하는 게임이라고 말해도 무방하거든요 !! 이제, 본격적으로 “롤러코스터 타이쿤”과 “프레임워크(Framework) 그리고 라이브러리(Library)”가 어떤 관계인지 한 번 알아봅시다 🙌 🎡 1. 나만의 놀이동산을 만들어봅시다! 우리의 목표는 멋진 테마파크를 만들어서 일정 기간 안에 정해진 수익을 내는 것이에요!! 그러기 위해서는 테마파크에 재미있는 볼거리, 먹거리 그리고 가장 중요한 🎢”재미있는 놀이기구”🎡 가 있어야겠죠?! 🛠️ 2. 놀이기구를 만드는 2가지 방법 ✌️ 롤러코스터 타이쿤에서 “놀이기구를 만드는 방법은 두 가지”로 나뉩니다. 1️⃣ 내가 직접 손수 만들기 ! 직접 손수 만드는 것은 레일 조각을 하나씩 붙여서 롤러코스터를 원하는 모양으로 만드는 것이죠. 🎢 2️⃣ 이미 만들어져 있는 놀이기구 설치하기 ! 이미 만들어져 있는 놀이기구는 원하는 위치에 설치만 하면 됩니다. 🎡 🧱 2. 라이브러리 == 레일 조각. <img src=”https://github.com/devKobe24/images2/blob/main/rc_tycoon_4.png?raw=true” whidth = 350, height = 350> 다양한 모양의 레일 조각을 내가 직접 선택해서 조립해요. 코너, 루프, 경사, 상승 등 내가 주도해서 연결하죠. 내가 설계한 대로, 내가 의도한 대로 구성됩니다. 🛠️ 즉, 라이브러리란! 내가 필요한 기능만 골라서 조립하는 도구 모음 입니다 :) 🧑💻 개발자 비유: Lombok, Jackson, Apache, Commons, Retrofit, QueryDSL 등은 모두 레일 조각 같은 존재 ! 내가 직접 호출하고 사용할지 결정합니다. 🏗️ 3. 프레임워크 == 놀이기구 자동 운영 시스템. <img src=”https://github.com/devKobe24/images2/blob/main/rc_tycoon_3.png?raw=true” whidth = 350, height = 350> 내가 놀이기구를 설치하면 탑승 대기열 만들기 ➞ 입장 ➞ 출발 ➞ 운행 ➞ 하차 이 모든 과정은 게임이 자동으로 제어합니다. 🧑💻 개발자 비유: Spring, Django, Rails, Angular 등은 바로 이 시스템과 같아요. 전체 흐름은 프레임워크가 갖고 있고, 나는 필요한 부품만 넣습니다. 📢 이것이 바로 프로그래밍에서 말하는 제어의 역전 (Inversion of Control) 입니다. 프레임워크가 나의 코드를 호출하고 전체 흐름을 통제해요! ⚔️ 4. 정면 비교: 프레임워크(Framework) 라이브러리(Library) 비교 항목 🧱 라이브러리(레일 조각) 🏗️ 프레임워크 (놀이기구 시스템) 주도권 개발자 (내가 호출) 프레임워크 (프레임워크가 내 코드를 호출) 유연성 매우 높음 낮음 (틀 안에서 동작) 사용 방식 필요한 것만 골라 사용 틀을 따르고 필요한 부분만 채움 진입 난이도 낮음 (직관적) 중간 이상 (구조 이해 필요) 예시 Lombok, Jackson, Retrofit Spring, Django, Angular 🌈 5. 결론: 당신은 지금 어떤 놀이공원을 짓고 있나요? 🎢 직접 레일을 조립하는 자유로운 설계자? ➞ 라이브러리 중심의 개발 🎠 자동 운영 시스템을 활용하는 시스템 설계자? ➞ 프레임워크 기반 개발 🎁 부록: 필자의 한마디. “라이브러리는 내가 꺼내 쓰는 도구고, 프레임워크는 나를 껴안은 큰 구조다.” 🎢 롤러코스터 타이쿤 덕분에 개념이 잘 들어왔나요? 그랬다면 너무 기분이 좋을것 같아요 😆 그렇다면 오늘은 여기까지 !! 다음에 또 만나요 안녕 🙌 📎 이미지 출처 Pinterest imgur 직접 인게임에서 스크린샷
Backend Development
· 2025-07-31
📚[Backend Development] @Bean에 대해서 알아보기 🙌
✅ Intro. Java 기반의 Spring Framework에서 @Bean은 의존성 주입(Dependency Injection)과 객체 관리를 이해하는 데 핵심적인 개념입니다. 아래에서 하나씩 알아봅시다. 🙌 ✅ 1. @Bean은 무엇인가요? @Bean은 Spring Framework에서 개발자가 직접 정의한 객체를 Spring 컨테이너에 등록할 때 사용하는 애노테이션(Annotation) 입니다. 이 애노테이션은 @Configuration 클래스 내에서 사용되며, 메서드에 붙어서 해당 메서드가 반환하는 객체를 Bean으로 등록합니다. @Configuration public class AppConfig { @Bean public MyService myService() { return new MyServiceImpl(); } } ✅ 2. @Bean의 역할은 무엇인가요? Spring 컨테이너에 직접 Java 코드로 객체를 생성하고 등록합니다. 등록된 객체는 Spring이 관리하게 되며, 다른 Bean에 의존성 주입(DI) 될 수 있습니다. 보통 다음과 같은 경우 사용됩니다.: 외부 라이브러리나 우리가 직접 구현한 클래스인데, Spring이 자동으로 생성해주지 않는 경우 XML 설정을 대신해서 Java 코드로 설정을 하고 싶을 때 ✅ 3. @Bean은 언제 사용하나요? 📌 주요 사용 시점: 컴포넌드 스캔(@Component)으로 등록할 수 없는 객체를 등록할 때 (예: 외부 라이브러리 클래스, 제3자 라이브러리에서 제공하는 클래스 등) 객체 생성 로직이 복잡해서 수동으로 등록하고 싶을 때 설정 클래스를 통해 설정값에 따라 Bean을 조건부로 생성할 때 테스트 환경에서 특정 객체만 대체해서 사용하고 싶을 때 예시: @Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); return template; } } ✅ 요약 질문 요약 답변 @Bean은 무엇인가요? 개발자가 수동으로 Spring Bean을 등록할 수 있게 해주는 애노테이션 역할은? Spring 컨테이너에 메서드가 반환하는 객체를 Bean으로 등록 언제 사용하나요? 컴포넌트 스캔이 어려운 외부 라이브러리나 복잡한 설정 객체를 등록할 때
Backend Development
· 2025-07-31
📚[Backend Development] Spring에서의 컴포넌트란?
📚[Backend Development] Spring에서의 컴포넌트란? Spring에서 자주 쓰이는 컴포넌트(Component) 개념을 정리해봅시다. 🎁 ✅ 1. 컴포넌트란 무엇인가요? Spring에서 관리하는 객체(Bean) 를 의미합니다. @Component 애노테이션을 불티면 해당 클래스는 Spring 컨테이너가 자동으로 탐색(컴포넌트 스캔)하여 Bean으로 등록합니다. 즉, 개발자가 직접 @Bean으로 등록하지 않아도 Spring이 자동으로 객체를 생성하고 관리합니다. @Component public class MyService { public void doSomething() { System.out.println("Service logic!"); } } ✅ 2. 컴포넌트의 역할은 무엇인가요? 자동 등록 : 클래스에 @Component를 붙이면 스프링이 자동으로 객체를 생성하고, 의존성을 주입할 수 있도록 관리합니다. 의존성 주입(DI) : 등록된 컴포넌트는 다른 클래스에 @Autowired 또는 생성자 주입으로 쉽게 사용할 수 있습니다. 애플리케이션 구성 요소 구분 : @Service, @Repository, @Controller 등은 모두 @Component의 특화된 버전입니다. 역할별로 구분하기 좋게 도와줍니다. ✅ 3. 컴포넌트는 언제 사용하나요? 일반적인 애플리케이션 로직 클래스를 Bean으로 등록하고 싶을 때 사용합니다. 자동 등록이 가능한 경우 사용합니다.(Spring이 직접 생성할 수 있는 클래스) 주요 사례: 서비스 클래스(@Service) : 비즈니스 로직 담당 리포지토리 클래스(@Repository) : DB 접근 담당 컨트롤러 클래스(@Controller, @RestController) : API/화면 담당 위 애노테이션들은 모두 내부적으로 @Component를 포함하고 있어서 자동 등록됩니다. 📌 요약 질문 요약 답변 컴포넌트란? Spring이 자동으로 Bean으로 등록하는 클래스 역할은? 객체를 자동 생성 및 관리하고 DI를 지원 언제 사용하나요? 서비스, 리포지토리, 컨트롤러 등 자동 등록 가능한 클래스일 때 🙌 추가로 알아두면 좋은 점 @Component는 자동 등록, @Bean은 수동 등록이라는 차이가 있습니다. 자동 등록이 불가능한 외부 라이브러리나 복잡한 생성 로직이 있으면 @Bean을 쓰고, 일반적인 애플리케이션 클래스라면 @Component를 쓰는 것이 기본입니다.
Backend Development
· 2025-07-30
📚[Backend Development] CS에서의 컴포넌트란?
📚[Backend Development] CS에서의 컴포넌트란? 컴퓨터 과학(CS, Computer Science) 전반에서의 “컴포넌트(component)” 개념에 대해서 알아봅시다 🎁 ✅ 1. CS에서의 컴포넌트는 무엇인가요? 컴포넌트(Component) 란 소프트웨어 시스템을 구성하는 독립적이고 재사용 가능한 모듈입니다. 보통 하나의 컴포넌트는 특정 기능을 수행하는 단위이며, 명확한 인터페이스를 가지고 있습니다. 다른 컴포넌트와 느슨하게 결합(loose coupling) 되어 있으며, 각자 자율적인 동작이 가능합니다. 💡 흔히 “Component-Based Software Engineering(CBSE)”에서 중요한 개념입니다. ✅ 2. CS에서의 컴포넌트의 역할은 무엇인가요? 컴포넌트의 역할은 크게 다음과 같습니다: 역할 설명 기능 분리 복잡한 시스템을 작은 단위로 나누어 이해하고 개발하기 쉽게 합니다. 재사용성 한 번 만든 컴포넌트를 여러 시스템이나 프로젝트에서 재사용할 수 있습니다. 유지보수성 향상 각 컴포넌트를 독립적으로 수정할 수 있어 전체 시스템 안정성이 향상됩니다. 인터페이스 기반 통신 컴포넌트 간에는 명확한 계약(인터페이스)만 맞추면 내부 구현을 몰라도 됩니다. ✅ 3. CS에서의 컴포넌트는 언제 사용하나요? 컴포넌트는 모듈화와 재사용성이 중요한 시스템에서 사용됩니다. 📌 주요 사용 시점: 대규모 소프트웨어 아키텍처 설계 시 마이크로서비스 아키텍처(MSA) 모놀리식 아키텍처 내의 모듈 분리 UI 라이브러리나 프레임워크에서 React, Angular, Vue.js 등의 UI 프레임워크에서 각각의 UI 단위를 “컴포넌트”로 관리 OS나 임베디드 시스템 커널의 드라이버나 플러그인도 일종의 컴포넌트로 봄 플러그인 시스템 웹 브라우저의 확장 기능이나 게임 엔진의 모듈 시스템 📌 요약 정리 질문 요약 답변 컴포넌트란? 특정 기능을 수행하는 독립적이고 재사용 가능한 소프트웨어 단위 역할은? 모듈화, 재사용, 유지보수성 향상, 인터페이스 기반 통신 언제 사용하나요? 복잡한 시스템 분할, MSA, UI 프레임워크, 플러그인 구조 등에서 🔍 참고 이미지 (개념 예시) [전체 시스템] ├── 사용자 인증 컴포넌트 ├── 결제 컴포넌트 ├── 상품 관리 컴포넌트 └── 알림 전송 컴포넌트 각 컴포넌트는 독립적으로 작동하고, 인터페이스로 연결됩니다.
Backend Development
· 2025-07-29
📚[Backend Development] 기본 단위 테스트는 어떤 단위로 해야할까?
📚[Backend Development] 기본 단위 테스트는 어떤 단위로 해야할까? 실무에서는 기본 단위 테스트를 보통 각 계층(레이어) 별로 나누어 작성하는 것이 일반적이며 권장되는 방식입니다. 아래와 같이 계층별로 나누어 관리함으로써 테스트의 목적과 범위를 명확히 구분할 수 있고, 유지보수성과 가독성이 좋아집니다. ✅ 실무에서 계층별로 나누는 단위 테스트 구조 계층 테스트 명칭 예시 파일명 주요 테스트 대상 Entity Entity 단위 테스트 UserTest.java 비즈니스 메서드, equals/hashCode, 유효성 검사 등 Repository Repository 단위 테스트 UserRepositoryTest.java 쿼리 메서드, JPA 동작 검증, 쿼리 결과 확인 Service Service 단위 테스트 UserServiceTest.java 서비스 로직, 트랜잭션 처리, 예외 처리 Controller Controller 단위 테스트 UserControllerTest.java API 요청/응답, 상태 코드, DTO 매핑 Integration 통합 테스트 UserIntegrationTest.java 서비스 + DB + 인증 등 복합 시나리오 ✅ 예시 구조 (Spring Boot 기준) src/test/java/com/example/project/ ├── entity/ │ └── UserTest.java ├── repository/ │ └── UserRepositoryTest.java ├── service/ │ └── UserServiceTest.java ├── controller/ │ └── UserControllerTest.java └── integration/ └── UserIntegrationTest.java ✅ 이유: 왜 계층별로 나누는가? 이유 설명 책임 명확화 어떤 문제가 발생했는지 빠르게 파악 가능 테스트 유지보수 용이 테스트 범위가 좁아져 디버깅 및 수정이 쉬움 계층 분리 설계와 맞물림 도메인/서비스/컨트롤러 레이어 설계 철학 반영 테스트 커버리지 향상 각 레이어를 독립적으로 검증하므로 빠짐없이 테스트 가능 ✅ 실무 팁 @SpringBootTest 는 통합 테스트에서만 사용하고, 단위 테스트에서는 Mockito/MockMvc 조합을 주로 사용 Repository 테스트는 H2 + @DataJpaTest 로, Service 테스트는 @MockBean으로 Repository 주입 Entity 테스트는 순수 Java 단위 테스트로 처리 🙌 결론 ✅ 실무에서는 Entity, Repository, Service, Controller 단위로 테스트 코드를 나누는 것이 일반적이고 바람직한 방식입니다.
Backend Development
· 2025-07-26
📚[Backend Development] @Builder 애너테이션
📚[Backend Development] @Builder 애너테이션 @Builder 애너테이션은 Lombok 라이브러리가 제공하는 기능으로, Java에서 Builder 패턴을 쉽게 구현할 수 있도록 도와줍니다. ✅ 1. 빌더 패턴이란? Builder 패턴은 복잡한 객체를 단계적으로 생성할 수 있게 하는 생성 디자인 패턴입니다. 생성자나 setter 대신 사용 선택적 필드, 불변성, 가독성 향상 특히 파라미터가 많은 객체를 생성할 때 유용함 예: LoginUser user = LoginUser.builder() .email("user@example.com") .nickname("user") .age(25) .build(); ✅ 2. Lombok의 @Builder로 빌더 패턴 적용 예제. 🧩 엔티티 예시 @Getter @NoArgsConstructor @AllArgsConstructor @Builder public class LoginUser { private String email; private String nickname; private int age; } 🧩 사용법 LoginUser user = LoginUser.builder() .email("user@example.com") .nickname("Kobe") .age(28) .build(); 순서 상관없이 필드 명시 가능 가독성 뛰어남 null-safe 하게 생성 가능 ✅ 3. @Builder의 특징 및 내부 동작 내부적으로 정적 빌더 클래스(LoginUserBuilder)를 생성 각 필드는 withXxx() 메서드 형태로 설정 가능 build() 메서드가 최종 객체를 반환 빌더 메서드 예시 (Lombok이 내부 생성하는 구조): public class LoginUser { public static class LoginUserBuilder { private String email; private String nickname; private int age; public LoginUserBuilder email(String email) { this.email = email; return this; } public LoginUser build() { return new LoginUser(email, nickname, age); } } } ✅ 4. @Builder의 사용 주의사항. 상황 주의점 @Builder + JPA Entity 생성자에 @Builder를 붙이는 방식 추천(기본 생성자 필요) @Builder + Default 값 설정 필드 초기화 시 @Builder.Default를 함께 사용해야 적용됨 Setter 없음 불변 객체처럼 사용할 수 있어 안정성 ↑ ✅ 5. @Builder.Default 예시. @Builder public class LoginUser { private String email; @Builder.Default private String role = "USER"; // 기본값 설정 } ✅ 요약 장점 설명 가독성 필드명 기반으로 객체 생성 가능 유지보수 필드 순서 신경 쓸 필요 없음 안전성 setter 없이 불변 객체처럼 사용 가능 유연성 선택적 필드 조합으로 생성 가능
Backend Development
· 2025-06-19
📚[Backend Development] 제어의 역전(IoC)와 의존성 주입(DI)
📚[Backend Development] 제어의 역전(IoC)와 의존성 주입(DI) ✅ 1. 제어의 역전(IoC: Inversion of Control) 📌 정의 객제의 생성, 생명주기, 의존성 관리 등 제어권을 개발자가 아닌 프레임워크가 담당하는 것 📍쉽게 말하면? 원래는 개발자가 new 키워드로 객체를 생성하고 연결해야 했지만, 이제는 그 책임을 스프링 프레임워크에 넘긴 것입니다. 🔁 전통적 방식 (제어권 = 개발자) UserService userService = new UserService(); ArticleService articleService = new ArticleService(userService); ✅ IoC 방식 (제어권 = 스프링) @Component public class ArticleService { private final UserService userService; @Autowired public ArticleService(UserService userService) { this.userService = userService; } } ➞ 객체 생성 및 주입은 스프링 컨테이너가 알아서 해줍니다. ✅ 2. 의존성 주입(DI: Dependency Injection) 📌 정의 객체가 의존하는 다른 객체를 “직접 생성하지 않고” 외부에서 주입받는 방식 DI는 IoC의 구현 방식 중 하나입니다. 대표적으로 생성자 주입, 필드 주입, 세터 주입이 있습니다. 🔧예: 생성자 주입 @Component public class ArticleService { private final UserService userService; @Autowired public ArticleService(UserService userService) { this.userService = userService; } } 🔧예: 필드 주입 @Component public class ArticleService { @Autowired private UserService userService; } ✅ IoC vs DI 요약 구분 제어의 역전(IoC) 의존성 주입(DI) 개념 제어권을 프레임워크에 넘김 객체 간 의존성을 외부에서 주입 역할 전체 흐름의 제어를 바꿈 객체 간 관계를 설정함 관계 DI는 IoC를 구현하는 방식 중 하나 IoC보다 좁은 개념 예시 스프링이 Bean을 만들고 관리 스프링이 필요한 의존 객체를 주입 🎯 결론 IoC는 누가 객체를 만들고 관리하느냐의 문제 → 스프링이 대신 함 DI는 어떻게 객체 간 연결을 하느냐의 문제 → 생성자, 필드 등을 통해 주입
Backend Development
· 2025-06-17
📚[Backend Development] 빈(Bean)과 스프링 컨테이너(Spring Container)
📚[Backend Development] 빈(Bean)과 스프링 컨테이너(Spring Container) Bean과 Spring Container는 Spring Framework의 핵심 개념으로, 스프링이 객체를 관리하는 방식을 이해하는 데 매우 중요합니다. ✅ 1. 빈(Bean)이란? 📌 정의 스프링 컨테이너에 의해 생성되고 관리되는 객체 개발자가 @Component, @Service, @Repository, @Configuration, @Bean 등의 어노테이션으로 등록하면 스프링이 해당 객체를 생성하고, 의존성을 주입하며, 생명 주기를 관리합니다. 🔧Bean의 예시 @Component public class UserService { // 이 클래스는 스프링 빈이다. } 또는 @Configuration public class AppConfig { @Bean public ArticleService articleService() { retutn new ArticleService(); } } ✅ 2. 스프링 컨테이너(Spring Container)란? 📌 정의 빈을 생성하고 관리하는 객체 저장소 ApplicationContext 또는 BeanFactory가 대표적인 컨테이너입니다. 애플리케이션 실행 시 컨테이너가 초기화되며, 내부에 Bean 객체들을 싱글톤으로 보관하고 관리합니다. 🧠 비유 “스프링 컨테이너는 빈을 담는 객체 창고다. 필요한 객체(Bean)를 꺼내 쓰면 된다.” ⛓️ 주요 역할 Bean 생성 및 초기화 의존성 주입 (DI) Bean 생명주기 관리(@PostConstruct, @PreDestory 등) AOP 기능 제공 ✅ 관계 정리 개념 설명 Bean 스프링이 관리하는 객체 Container Bean들을 생성하고 관리하는 스프링의 핵심 엔진 등록 방법 @Component, @Service, @Repository, @Configuration + @Bean 등 제공 클래스 ApplicationContext, AnnotationConfigApplicatioonContext, WebAppplicationContext 등 ✅ 그림으로 비유 [Spring Container] ├── UserService (Bean) ├── ArticleService (Bean) ├── UserRepository (Bean) └── DataSource (Bean) ➞ 개발자는 직접 new 하지 않고, 스프링이 대신 생성해주는 Bean을 받아서 사용합니다. ✅ 요약 개념 설명 Bean 스프링이 생성하고 관리하는 객체 Spring Container Bean의 생성, 주입, 생명주기를 관리하는 객체 저장소 의미 IoC/DI를 실현하는 핵심 구조
Backend Development
· 2025-06-17
📚[Backend Development] Build system의 그레이들과 메이븐의 차이
“📚[Backend Development] Build system의 그레이들과 메이븐의 차이” Gradle과 Maven은 모두 Java 기반 프로젝트를 빌드하고 의존성을 관리하는 대표적인 빌드 도구입니다. 둘 다 널리 사용되지만, 철학과 사용 방식, 성능, 유연성 등에서 차이가 있습니다. ✅ Gradle vs Maven: 핵심 차이점 비교. 항목 Gradle Maven 빌드 언어 Groovy 또는 Kotilin DSL 기반 스크립트 XML(pom.xml) 구문 유연하고 간결한 DSL 선언적이고 정형화된 구조 성능 빠름 (Incremental Build, Daemon, Build Cache) 비교적 느림 (모든 작업 다시 수행) 의존성 관리 Gradle의 dependencies 블럭으로 선언 Maven의 <dependencies> 블럭 사용 사용성 복잡한 로직/조건 처리에 유리 구조가 단순해 입문자에게 적합 빌드 속도 ✅ 빠름(캐싱, 병렬 빌드) ❌ 느림 생태계 통합 Android, Kotilin 등 다양한 언어에 강함 Java, Spring 등 Java 생태계에 강함 확장성 플러그인 개발 및 커스터마이징 용이 플러그인 생태계는 있지만 제한적 설정 파일 build.gradle 또는 build.gradle.kts pom.xml 🔍 예제 비교 Maven(pom.xml) <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> Gradle (build.gradle) dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' } ✅ 선택 기준(실무 기준) 상황 추천 빌드 도구 단순한 Java/Spring 프로젝트 Maven(구조가 명확하고 안정적) 빌드 속도 중시, Android 프로젝트, 유연성 필요 Gradle(유연하고 빠름) CI/CD 파이프라인 통합 둘 다 지원되지만, Gradle은 더 커스터마이징 가능 복잡한 조건 분기, 모듈화, 스크립트 작성 필요 Gradle(스크립트 기반 처리 유리) 📝 요약 Gradle Maven DSL 기반, 빠르고 유연함 XML 기반, 안정적이고 구조적 빌드 속도 빠름(캐싱, 병렬) 느리지만 표준화된 방식 학습 난이도 있음 입문자에게 친숙
Backend Development
· 2025-06-16
📚[Backend Development] Covering Index
“📚[Backend Development] Covering Index.” 📝 Intro Covering Index(커버링 인덱스)는 쿼리 실행 시, 인덱스만으로 필요한 데이터를 모두 충족하는 경우를 의미합니다. 즉, 테이블(원본 데이터)에 접근하지 않고 인덱스만으로 결과를 가져올 수 있는 인덱스입니다. ✅1️⃣ Covering Index의 핵심 개념. 1️⃣ 인덱스 스캔(Index Scan)만으로 필요한 모든 데이터를 조회. 일반적으로 인덱스를 사용해도 일부 컬럼만 검색한 후, 추가적으로 테이블에서 데이터를 가져와야 하는 경우가 있음. 하지만 Covering Index는 필요한 모든 데이터가 인덱스에 포함되어 있어, 테이블 조회를 생략할 수 있음. 2️⃣ 쿼리 성능 최적화. 디스크 I/O 감소 : 테이블을 읽지 않으므로 I/O 비용 절감. 쿼리 속도 향상 : 인덱스만 조회하면 되므로 실행 속도가 빨라짐. Random I/O 감소 : 테이블 데이터 접근이 필요 없으므로 불필요한 I/O를 최소화함. ✅2️⃣ Covering Index 동작 방식. 💎 예제 테이블 (MySQL 기준): CREATE TABLE users ( id INT PRIMARY KEY, name VARCHAR(100), email VARCHAR(100), age INT, city VARCHAR(100) ); 💎 일반적인 인덱스 사용 (테이블 조회 필요) SELECT name FROM users WHERE age = 30; age 컬럼에 인덱스가 있어도, name 컬럼을 가져오기 위해 테이블 조회(데이터 페이지 접근)가 필요함. 💎 Covering Index 사용 CREATE INDEX idx_users_age_name ON users (age, name); 인덱스(idx_users_age_name)가 age와 name 컬럼을 모두 포함하므로, 테이블을 조회할 필요 없음. 인덱스에서 직접 데이터를 가져오므로 쿼리 속도가 크게 향상됨. ✅3️⃣ Covering Index 확인 방법 (MySQL) 쿼리가 Covering Index를 사용하는지 확인하려면 EXPLAIN 명령어를 사용하면 됩니다. EXPLAIN SELECT name FROM users WHERE age = 30; 📌 Extra 컬럼에 “Using index”가 표시되면 Covering Index가 적용된 것 입니다. 🚀 결론 ✅ Covering Index는 테이블을 조회하지 않고, 인덱스만으로 데이터를 가져올 수 있는 최적화 기법 ✅ 디스크 I/O와 Random I/O를 줄여 성능을 크게 향상시킬 수 있음 ✅ 인덱스 크기가 커질 수 있으므로, 적절한 컬럼만 포함하여 생성하는 것이 중요.
Backend Development
· 2025-03-20
📚[Backend Development] 대표적인 인덱스 자료구조
“📚[Backend Development] SQL에서 INDEX” 📝 Intro. 인덱스는 데이터베이스에서 검색 성능을 최적화하기 위해 사용되며, 데이터의 특성과 쿼리 유형에 따라 다양한 자료구조가 활용됩니다. ✅1️⃣ B+ Tree (Balanced Plus Tree) ✅ 개념 대부분의 관계형 데이터베이스(RDBMS)에서 기본적으로 사용하는 인덱스 구조. B-Tree의 확장형으로, 리프 노트(Leaf Node)에만 데이터를 저장하며 범위 검색이 빠름. 균형 트리(Balanced Tree) 구조로, 검색,삽입,삭제 연산이 $O(log N)$. 순차적 검색(ORDER BY, RANGE QUERY)에 최적화됨. ✅ 특징 📌 검색, 삽입, 삭제 모두 $O(log N)$ 📌 ORDER BY, BETWEEN 검색이 빠름 📌 데이터가 정렬된 상태로 유지됨 ✅ 사용 예시 CREATE INDEX idx_user_name ON users(name); SELECT * FROM users WHERE name LIKE 'A%'; LIKE ‘A%’ 같은 접듀서 검색(범위 검색) 시 최적화됨. ✅ 사용 DBMS 📌 MySQL (InnoDB) 📌 PostgreSQL 📌 Oracle 📌 SQL Server ✅2️⃣ Hash Index ✅ 개념 정확한 값 검색(Equality Search)에 최적화된 해시 테이블 기반 인덱스. WHERE column = ‘value’ 같은 조건에 최적화. 순차적 검색이나 범위 검색을 지원하지 않음. ✅ 특징. 📌 검색이 O(1)에 가까움 (해시 충돌이 없을 경우) 📌 WHERE 절의 = 연산에 최적 📌 ORDER BY, 범위 검색 불가능 ✅ 사용 예시 CREATE INDEX idx_email_hash USING HASH ON users(email); SELECT * FROM users WHERE email = 'user@example.com'; 이메일 주소 검색 같은 정확한 값 조회에 적합. ✅ 사용 DBMS 📌 MySQL (MEMORY Engine) 📌 PostgreSQL 📌 Redis 📌 DynamoDB ✅3️⃣ LSM Tree (Log-Structured Merge Tree) ✅ 개념 쓰기 성능 최적화를 위한 NoSQL 및 시계열 데이터베이스(TSDB)에서 많이 사용됨 데이터를 메모리에 먼저 저장 후, 일정 크기가 되면 디스크에 병합(Merge)하는 방식. 읽기 성능이 상대적으로 낮지만, 쓰기(Writes) 성능이 뛰어남. ✅ 특징 📌 쓰기 성능이 뛰어남 (배치 삽입/삭제 최적화) 📌 NoSQL, 로그 데이터, 시계열 데이터에 최적 📌 랜덤 읽기가 느리면, 병합(Compaction) 비용 발생 ✅ 사용 예시 Cassandra, RocksDB, LevelDB에서 기본 인덱스로 사용됨 ✅ 사용 DBMS 📌 Apache Cassandra 📌 RocksDB 📌 LevelDB 📌 Amazon DynamoDB ✅4️⃣ R-Tree (Rectangle Tree) ✅ 개념 공간(Spatial) 데이터 검색에 최적화된 트리 구조 위도/경도, 좌표 정보(GIS 데이터)를 검색할 때 주로 사용됨 2D, 3D 범위 검색(WHERE lat BETWEEN … AND lon BETWEEN …)에 유리. ✅ 특징 📌 위치 기반 검색(GIS) 최적화 📌 범위 검색(RANGE QUERY) 가능 📌 이진 트리가 아닌 공간 분할 트리 구조 ✅ 사용 예시 CREATE INDEX idx_location ON locatioons USING GIST (geom); SELECT * FROM locations WHERE ST_Within(geom, ST_GeomFromText('POLYGON((...))')); PostGIS, Oracle Spatial에서 사용됨 ✅ 사용 DBMS 📌 PostgreSQL (PostGIS) 📌 Oracle Spatial 📌 MySQL (GIS 가능) ✅5️⃣ Bitmap Index ✅ 개념 중복 값이 많은 컬럼(성별, 상태값 등)에 최적화된 인덱스. 각 값에 대해 비트맵(Bit Map)으로 저장하여 검색 속도를 향상. 데이터 웨어하우스, OLAP(Online Analytical Processing)에서 주로 사용. ✅ 특징 📌 중복이 많은 데이터에 최적 📌 저장 공간이 적게 들고, 빠른 검색 가능 📌 쓰기(INSERT/UPDATE/DELETE) 성능이 낮음 ✅ 사용 예시 CREATE BITMAP INDEX idx_status ON orders(status); SELECT * FROM orders WHERE status = 'completed'; 상태(status) 컬럼처럼 중복이 많은 데이터 검색 시 빠름. ✅ 사용 DBMS 📌 Oracle 📌 PostgreSQL 📌 ClickHouse ✅6️⃣ Skip List ✅ 개념 연결 리스트 기반 인덱스 구조로, 정렬된 데이터를 빠르게 검색. Redis의 Sorted Set(순위 랭킹)에서 사용됨. ✅ 특징 📌 B+Tree보다 간단한 구조 📌 읽기/쓰기 속도가 균형적 📌 Redis에서 순위 기반 랭킹(Key-Value 저장소)에 사용 ✅ 사용 예시 (Redis) redisTemplate.opsForZSet().add("ranking", "user1", 100); redisTemplate.opsForZSet().add("ranking", "user2", 150); 점수 기반 랭킹을 빠르게 검색 가능. ✅ 사용 DBMS 📌 Redis 📌 Apache Ignite ✅7️⃣ 결론 ✅ 관계형 데이터베이스(RDBMS)에서 가장 많이 사용하는 인덱스 ➞ B+ Tree ✅ 빠른 키-값 조회(Hash 검색)에 적합한 인덱스 ➞ Hash Index ✅ 쓰기 성능 최적화 (NoSQL, 로그 데이터) ➞ LSM Tree ✅ 공간 데이터(GIS, 위치 정보) 검색 ➞ R-Tree ✅ 중복 데이터(성별, 상태값) 검색 최적화 ➞ Bitmap Index ✅ 순위 랭킹, Redis Sorted Set ➞ Skip List
Backend Development
· 2025-03-19
📚[Backend Development] 가장 중요한 두 가지 인덱스 유형.
“📚[Backend Development] 가장 중요한 두 가지 인덱스 유형.” 📝 Intro 데이터베이스에서 Index는 검색 성능을 최적화하는 핵심 요소입니다. 그 중에서 Clustered Index(클러스터형 인덱스)와 Secondary Index(보조 인덱스, Non-Clustered Index)는 가장 중요한 두 가지 인덱스 유형입니다. ✅1️⃣ Clustered Index (클러스터형 인덱스) ✅ 개념 테이블의 데이터를 물리적으로 정렬하는 인덱스. 한 테이블에 하나만 존재할 수 있음. 기본 키(Primary Key) 에 자동으로 생성됨. 데이터 자체가 인덱스 트리(B+ Tree) 구조로 저장됨. ✅ 특징 ✅ 데이터 자체가 정렬된 형태로 저장됨. ✅ Primary Key(기본 키)에 자동 생성됨. ✅ 범위 검색(BETWEEN, ORDER BY)이 빠름. ✅ 테이블당 하나만 존재. ✅ 예제 CREATE TABLE users ( id INT PRIMARY KEY, -- 자동으로 Clusterd Index 생성 name VARCHAR(100), age INT ); 위 테이블에서 id는 기본 키(Primary Key) 이므로 자동으로 Clustered Index가 생성됩니다. 즉, 테이블의 데이터가 id 값을 기준으로 정렬된 상태로 저장됩니다. ✅2️⃣ Secondary Index (보조 인덱스, Non-Clustered Index) ✅ 개념 Clustered Index와 별개로 추가적인 검색 속도를 높이기 위해 사용하는 인덱스. 데이터와 별도로 저장되며, Clustered Index의 값(Primary Key)을 참조. 한 테이블에 여러 개 생성 가능. ✅ 특징 ✅ 데이터 정렬에는 영향을 주지 않음 ✅ 테이블당 여러 개 생성 가능 ✅ Clustered Index를 기반으로 추가적인 검색 속도 향상 ✅ 범위 검색보다는 특정 값 검색(WHERE 조건)에 적합 ✅ 예제 CREATE INDEX idx_users_name ON users(name); name 컬럼에 보조 인덱스(Secondary Index)를 생성하여 이름 검색 속도를 최적화. ✅3️⃣ Clustered Index vs Secondary Index 비교 구분 Clustered Index Secondary Index (Non-Clustered Index) 정렬 방식 데이터 자체가 인덱스 트리에 정렬됨 데이터 정렬에 영향을 주지 않음 저장 방식 데이터 자체가 인덱스 노드에 저장됨 인덱스에 Primary Key를 저장 후 데이터 참조 속도 범위 검색(BETWEEN, ORDER BY) 최적 특정 값 검색(WHERE) 최적 개수 한 테이블에 하나만 존재 여러 개 존재 가능 예제 PRIMARY KEY (id) CREATE INDEX idx_name ON users(name) ✅4️⃣ Clustered Index & Secondary Index 검색 과정. 📌 Clustered Index 검색 과정 SELECT * FROM users WHERE id = 100; Clustered Index는 데이터 자체가 정렬된 상태이므로, id = 100을 바로 찾을 수 있음. B+ Tree에서 한 번의 검색으로 데이터까지 도달 → 빠른 조회 속도. 📌 Secondary Index 검색 과정 SELECT * FROM users WHERE name = 'Alice'; name 컬럼은 Secondary Index이므로, 먼저 보조 인덱스를 검색한 후, Primary Key 값을 찾아 Clustered Index에서 데이터 검색. “Secondary Index ➞ Clustered Index” 두 단계 검색 과정이 필요하여 Clustered Index보다 속도가 약간 느림. ✅5️⃣ 언제 Clustered Index & Secondary Index를 사용해야 할까? ✅ Clustered Index 추천 PRIMARY KEY와 같이 데이터 정렬이 중요한 경우. ORDER BY, BETWEEN, RANGE QUER를 자주 사용해야 하는 경우. ✅ Secondary Index 추천 특정 컬럼을 WHERE 조건으로 자주 검색해야 할 때. JOIN 또는 GROUP BY 연산이 많은 경우. 보조적인 검색 속도를 높이고 싶을 때. 📌 결론. ✅ Clustered Index는 데이터 자체를 정렬하여 저장하며, Primary Key에 자동으로 생성됨 ✅ Secondary Index는 추가적인 검색 최적화를 위해 사용되며, 테이블당 여러 개 생성 가능 ✅ 범위 검색(ORDER BY, BETWEEN)은 Clustered Index가 유리 ✅ 특정 컬럼 검색(WHERE 조건)은 Secondary Index가 유리.
Backend Development
· 2025-03-19
📚[Backend Development] SQL에서 INDEX
“📚[Backend Development] SQL에서 INDEX” 📝 Intro. 인덱스(INDEX)는 데이터베이스에서 검색 성능을 최적화하기 위해 사용하는 자료구조입니다. 마치 책의 목차(Table of Contents)처럼, 테이블에서 데이터를 더 빠르게 찾을 수 있도록 돕는 기능입니다. ✅1️⃣ 인덱스의 역할 ✅ 검색 속도 향상 ➞ WHERE, ORDER BY, JOIN 시 빠르게 데이터 조회 가능 ✅ 데이터 정렬 최적화 ➞ ORDER BY 연산 시 정렬 속도 향상 ✅ 중복 방지 ➞ UNIQUE INDEX를 사용하여 중복 데이터 삽입 방지 📝 예제 (인덱스가 없는 경우) SELECT * FROM users WHERE email = 'example@email.com'; 데이터베이스는 모든 행을 하나씩 검사(Full Table Scan) 해야 함 데이터가 많을수록 검색 속도가 느려짐 📝 예제 (인덱스가 있는 경우) CREATE INDEX idx_email ON users(email); SELECT * FROM users WHERE email = 'example@email.com'; 이진 탐색(Binary Search)을 통해 빠르게 검색 가능 Full Table Scan을 방지하고 인덱스를 활용하여 검색 속도 향상 ✅2️⃣ 인덱스의 종류 1️⃣ 기본(B-Tree) 인덱스 가장 일반적인 인덱스, B-Tree(Balanced Tree) 구조 사용 검색, 정렬, 범위 조회에 최적화 CREATE INDEX idx_name ON users(name); 2️⃣ UNIQUE 인덱스 중복방지를 위한 인덱스 (중복된 값 삽입 불가) CREATE UNIQUE INDEX idx_unique_email ON users(email); INSERT INTO users (id, email) VALUES (1, 'test@email.com'); INSERT INTO users (id, email) VALUES (2, 'test@email.com'); -- ❌ 오류 발생 (중복) 3️⃣ 복합(Composite) 인덱스 두 개 이상의 컬럼을 결합하여 인덱스를 생성 검색 조건이 여러 개일 때 유용 CREATE INDEX idx_name_email ON users(name, email); SELECT * FROM users WHERE name = 'Alice' AND email = 'alice@email.com'; 4️⃣ FULLTEXT 인덱스 (MySQL 전용) 긴 텍스트 데이터(TEXT, VARCHAR)에서 단어 검색이 필요할 때 사용 LIKE '%keyword%'보다 훨씬 빠른 검색 가능 CREATE FULLTEXT INDEX idx_content ON posts(content); SELECT * FROM posts WHERE MATCH(content) AGAINST ('database'); 5️⃣ HASH 인덱스 정확한 일치검색(Equality Search)에 최적화됨 범위 검색에는 적합하지 않음 CREATE INDEX idx_hash_email USING HASH ON users(email); SELECT * FROM users WHERE email = 'test@email.com'; -- ✅ 빠름 SELECT * FROM users WHERE email = LIKE 'test%'; -- ❌ 느림 (HASH 인덱스는 범위 검색 지원 안 함) ✅3️⃣ 인덱스의 성능 고려 사항 ✅ 인덱스는 빠른 검색을 제공하지만, 무조건 많이 만든다고 좋은 것은 아닙니다. ✅ 인덱스가 많을수록 삽입(INSERT), 수정(UPDATE), 삭제(DELETE) 연산 속도가 느려집니다. ✅ 자주 사용하는 조회 쿼리에 맞춰 필요한 인덱스만 생성하는 것이 중요합니다 ✅4️⃣ 인덱스 최적화 및 활용 📌 실행 계획(EXPLAIN) 확인 인덱스를 잘 활용하는지 확인하려면 EXPLAIN 명령어를 사용하면 됩니다. EXPLAIN SELECT * FROM users WHERE email = 'test@email.com'; Using index ➞ 인덱스를 사용하여 최적화된 쿼리 Using full table scan ➞ 테이블 전체 검색 (느림) ✅5️⃣ 결론 ✅ 인덱스는 데이터 검색 속도를 최적화하는 핵심 도구 ✅ 너무 많은 인덱스는 오히려 성능을 저하시킬 수 있음 ✅ 조회 성능을 높이려면 EXPLAIN을 사용하여 인덱스 최적화
Backend Development
· 2025-03-18
📚[Backend Development] 대규모 데이터에서 게시글 목록 조회가 복잡한 이유
“📚[Backend Development] 대규모 데이터에서 게시글 목록 조회가 복잡한 이유 📝 Intro 대규모 데이터에서 게시글 목록 조회가 복잡한 이유는 여러 가지가 있습니다. 일반적으로 소규모 데이터베이스에서는 단순한 SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 같은 쿼리로 쉽게 해결할 수 있지만, 수백만~수억 개의 데이터를 다뤄야 하는 시스템에서는 여러 복잡한 문제가 발생합니다. ✅1️⃣ 대규모 데이터에서 게시글 목록 조회가 복잡한 이유 1️⃣ 데이터의 양이 방대함 게시글이 많아질수록 ORDER BY와 LIMIT 연산의 부담이 커짐 인덱스를 사용하더라도 디스크의 I/O 비용이 증가하고 캐싱이 어렵게 됨 📝 예제 쿼리 SELECT * FROM posts ORDER BY created_at DESC LIMIT 10; ❌ 문제점 ORDER BY create_at DESC 실행 시 모든 게시글을 정렬해야 함 (데이터가 클수록 느려짐) 최신 게시글이 많으면 새로운 데이터가 계속 추가되며 인덱스가 자주 변경됨 2️⃣ 페이지네이션 (Pagination) 성능 저하 게시판은 보통 페이지네이션을 지원해야 합니다. 즉, LIMIT과 OFFSET을 사용해 특정 페이지의 게시글을 조회해야 하는데, 대규모 데이터에서는 OFFSET이 클수록 성능이 저하됩니다. SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 1000000; ❌ 문제점 OFFSET 1000000을 사용하면 앞의 100만 개 데이터를 스캔하고 버린 후 그 다음 10개를 반환합니다. 데이터가 많을수록 불필요한 연산이 많아지고 성능이 급격히 저하됩니다. ✅ 해결 방법 Keyset pagination (Seek 방식) 활용 ➞ OFFSET 없이 WHERE 조건을 활용 LIMIT을 활용해 이전 조회된 마지막 ID를 기준으로 다음 데이터를 가져오기 SELECT * FROM posts WHERE created_at < '2024-03-11 10:00:00' ORDER BY created_at DESC LIMIT 10; 3️⃣ 인덱스 사용 최적화 문제 📌 왜 인덱스만으로 해결되지 않을까? 인덱스를 사용하면 정렬이 빨라지지만, 데이터가 많을 경우에도 여전히 디스크 I/O 비용이 증가 새로운 게시글이 추가될 때마다 B-Tree 인덱스가 갱신되며 성능에 부담을 줌 ✅ 해결 방법 커버링 인덱스 (Covering Index) 활용 ➞ SELECT에 포함된 모든 컬럼을 인덱스에 미리 포함 파티셔닝 (Partitioning) ➞ 날짜별 테이블 분리 (posts_202403 같은 테이블) CREATE INDEX idx_posts_created ON posts(created_at, title, content); 4️⃣ 트래픽 분산 문제 게시글 목록은 대부분 서비스에서 조회 트래픽이 많고, 쓰기 트래픽도 꾸준히 발생하는 구조입니다. 즉, 읽기(READ)가 많지만, 동시에 새로운 게시글이 추가되며 정렬 순서가 계속 바뀝니다. ❌ 문제점 캐시 사용이 어렵다 ➞ 새로운 게시글이 추가되면 정렬 순서가 바뀌어 기존 캐시 무효화됨 동시성이 증가하면 DB 부하가 커짐 ➞ 많은 유저가 같은 목록을 요청하면 DB 부하 증가 ✅ 해결 방법 1️⃣ 캐싱 (Redis, Memcached) 활용 최신 게시글 목록을 Redis에 저장하고, 데이터가 변경될 때만 업데이트. redisTemplate.opsForValue().set("latest_posts", posts, Duration.ofSeconds(60)); 2️⃣ 읽기 전용 데이터베이스(Read Replica) 활용 Master-Slave Replication을 사용하여 읽기 트래픽을 Slave DB로 분산 SELECT * FROM posts ORDER BY created_at DESC LIMIT 10; -- 이 쿼리를 Slave DB에서 실행하여 Master DB 부하 감소 3️⃣ CQRS 패턴 적용 게시글 조회와 생성/수정을 다른 DB로 분리 (읽기 전용 DB, 쓰기 전용 DB) 5️⃣ JOIN 연산 성능 문제 게시글 목록을 조회할 때 보통 작성자 정보, 좋아요 수, 댓글 수 등을 함께 조회해야 합니다. 즉, JOIN을 수행해야 하는데, 데이터가 많을수록 성능이 저하됩니다. SELECT p.id, p.title, p.content, u.name, COUNT(c.id) as comment_count FROM posts p LEFT JOIN users u ON p.user_id = u.id LEFT JOIN comments c ON p.id = c.post_id GROUP BY p.id, u.name ORDER BY p.created_at DESC LIMIT 10; ❌ 문제점 JOIN을 수행할 때 데이터가 많으면 메모리와 CPU가 필요 GROUP BY를 수행하면 정렬 및 집계 연산이 필요하여 성능이 더 느려짐 ✅ 해결 방법 NoSQL(예: Redis, Elasticsearch) 캐시 활용 ➞ 좋아요 수, 댓글 수는 미리 저장해둠 CQRS 패턴 적용 ➞ 별도 테이블에 미리 계산된 카운트 값을 저장 SELECT p.id, p.title, p.title, u.name, p.comment_count FROM posts p LEFT JOIN users u ON p.user_id = u.id ORDER BY p.created_at DESC LIMIT 10; p.comment_count는 미리 계산된 값이므로 JOIN을 줄여 성능을 개선할 수 있습니다. ✅2️⃣ 대규모 게시글 조회 시 최적화 방법 1️⃣ Keyset Pagination 사용 SELECT * FROM posts WHERE created_at < '2024-03-11 10:00:00' ORDER BY created_at DESC LIMIT 10; OFFSET 없이 이전 조회된 마지막 ID를 기준으로 다음 데이터를 가져오는 방식 2️⃣ 캐싱 활용 (Redis, Elasticsearch) redisTemplate.opsForValue().set(CACHE_KEY, posts, Duration.ofSeconds(60)) 최신 게시글을 캐시에 저장하여 DB 부하를 줄임 3️⃣ 읽기 전용 DB(Read Replica) 활용 SELECT * FROM posts ORDER BY created_at DESC LIMIT 10; 읽기 트래픽을 Replica DB로 분산하여 성능 최적화 4️⃣ 미리 계산된 값 사용 SELECT p.id, p.title, p.content, u.name, p.comment_count FROM posts p LEFT JOIN users u ON p.user_id = u.id ORDER BY p.created_at DESC LIMIT 10; 댓글 개수, 좋아요 수 등을 미리 계산된 값으로 저장하여 JOIN 연산 줄이기 ✅3️⃣ 결론 ✅ 대규모 데이터에서 게시글 목록 조회가 복잡한 이유는? 데이터 양이 많아 ORDER BY LIMIT가 비효율적 OFFSET이 클수록 성능 저하 (페이징 문제) JOIN 연산이 많을수록 성능 저하 쓰기 트래픽이 많아지면 인덱스 관리 부담 증가 캐시가 자주 무효와되어 DB 부하가 커짐 ✅ 최적화 방법 1️⃣ Keyset Pagination ➞ OFFSET 대신 마지막 ID 기반 조회 2️⃣ Redis, Elasticsearch 캐싱 ➞ 최신 데이터 미리 저장 3️⃣ 읽기 전용 DB(Read Replica) 활용 ➞ 조회 트래픽 분산 4️⃣ 미리 계산된 데이터 활용 ➞ JOIN 최소화
Backend Development
· 2025-03-17
📚[Backend Development] 페이징 방식.
“📚[Backend Development] 페이징 방식.” 📝 Intro. 대규모 데이터에서 게시글 목록을 조회할 때 효율적인 페이징(Pagination) 처리는 성능 최적화의 핵심입니다. 데이터가 많아질수록 OFFSET 방식의 성능 저하가 발생하므로, Keyset Pagination(Seek 방식) 등의 최적화 기법을 적용하는 것이 중요합니다. ✅1️⃣ 페이징 방식 비교. 대규모 데이터를 조회할 때 사용할 수 있는 대표적인 페이징 방법은 다음과 같습니다. 방식 장점 단점 적용 예제 OFFSET + LIMIT 간단한 구현, SQL 표준 지원 OFFSET이 커질수록 성능 저하 블로그, 게시판 Keyset Pagination (Seek 방식) 성능 최적화, 인덱스 효율적 사용 특정 컬럼(정렬 기준)이 필요 뉴스 피드, 타임라인 Cursor 기반 페이징 유저 맞춤형 데이터 최적화 구현이 복잡 페이스북, 인스타그램 Redis 캐싱 활용 조회 속도 향상 데이터 동기화 문제 발생 가능 인기 게시글 목록 ✅2️⃣ 기본적인 OFFSET + LIMIT 방식 📌 개념 OFFSET을 사용하여 특정 위치부터 LIMIT 개수만큼 데이터를 조회하는 방식 일반적인 게시판에서 많이 사용하지만, 대규모 데이터에서는 OFFSET 값이 커질수록 성능이 저하됨 📝 SQL 예제 SELECT * FROM posts ORDER BY created_at DESC LIMIT 10 OFFSET 10000; OFFSET 10000은 앞의 10,000개 행을 스캔한 후 버리고, 그 다음 10개만 가져옴. 데이터가 많아질수록 성능이 급격히 저하됨 → “페이징이 깊어질수록 느려지는 문제 발생” 📝 Java (Spring Data JPA) 예제 Pageable pageable = PageRequest.of(page, 10, Sort.by(Sort.Direction.DESC, "createdAt")); Page<Post> posts - postRepository.findAll(pageable); ❌ 문제점 OFFSET이 크면 불필요한 데이터 검색으로 인해 성능 저하 발생 인덱스가 있어도 데이터를 읽고 버리는 비용(I/O 비용)이 발생 ✅3️⃣ Keyset Pagination (Seek 방식) 📌 개념 OFFSET을 사용하지 않고, 마지막 조회한 게시글의 ID(또는 created_at)을 기준으로 다음 데이터를 가져오는 방식 “마지막 조회된 데이터의 키를 기억하고, 그 이후 데이터를 조회” 📝 SQL 예제 SELECT * FROM posts WHERE created_at < '2024-03-11 12:00:00' ORDER BY created_at DESC LIMIT 10; created_at을 기준으로 이전 페이지의 마지막 게시글 시간보다 작은 데이터만 조회 데이터 양이 많아도 OFFSET이 없으므로 빠르게 조회 가능 📝 Java (Spring Data JPA) 예제 public List<Post> getNextPage(LocalDateTime lastCreatedAt, int limit) { return postRepository.findByCreatedAtBeforeOrderByCreatedAtDesc(lastCreatedAt, PageRequest.of(0, limit)); } lastCreatedAt을 기준으로 다음 게시글을 조회 LIMIT을 적용하여 페이징 처리 ✅ 장점. ✅ OFFSET이 없으므로 속도가 빠름 ✅ 인덱스를 활용하여 빠른 검색 가능 ✅ 데이터가 많아도 성능이 일정하게 유지됨 ❌ 단점. ❌ created_at 또는 id가 반드시 있어야 함 ❌ ORDER BY를 위한 적절한 인덱스 설정 필요 ✅4️⃣ Cursor 기반 페이징. 📌 개념 페이스북, 인스타그램 같은 SNS에서 사용하는 방식 이전 페이지의 마지막 ID(또는 created_at)를 클라이언트에서 저장하고, 이를 이용해 다음 데이터를 조회 Keyset Pagination과 유사하지만, API에서 cursor 값을 반환하고 이를 사용 📝 SQL 예제. SELECT * FROM posts WHERE id > 1050 ORDER BY id ASC LIMIT 10; id가 1050보다 큰 게시글을 가져옴 ORDER BY id ASC를 사용하여 오름차순 정렬 📝 Java (Spring Data JPA) 예제 public List<Post> getNextPosts(Long lastPostId, int limit) { return postRepository.findByIdGreaterThanOrderByIdAsc(lastPostId, PageRequest.of(0, limit)); } 클라이언트에서 이전 페이지의 마지막 id 값을 저장하고 이를 이용하여 다음 데이터를 조회 ✅ 장점 ✅ Keyset Pagination과 마찬가지로 OFFSET 없이 성능 최적화 ✅ 페이스북, 인스타그램 같은 무한 스크롤(Scroll) UI에 적합 ✅ API 응답에서 cursor(마지막 id) 값을 포함하여 다음 요청에 사용 가능 ❌ 단점 ❌ 클라이언트에서 cursor(마지막 id) 값을 저장해야 함 ❌ 특정 필드(id, created_at) 기준으로 정렬해야 하므로 복잡한 정렬이 어려움. ✅5️⃣ Redis를 활용한 캐싱 페이징 📌 개념 인기 게시글, 조회수가 많은 데이터는 DB에서 직접 조회하지 않고 Redis에 저장하여 빠르게 제공 일정 시간마다(예: 5분) 인기 게시글 목록을 업데이트 ZSET(Sorted Set)을 사용하여 정렬된 데이터를 빠르게 조회 📝 Java(Spring + Redis) 예제 @Service public class PostCacheService { private static final String CACHE_KEY = "latest_posts"; @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private PostRepository postRepository; public List<Post> getLatestPosts() { List<Post> cachedPosts = (List<Post>) redisTemplate.opsForValue().get(CACHE_KEY); if (cachedPosts != null) { return cachedPosts; } // 캐시가 없으면 DB에서 조회 후 Redis에 저장 List<Post> posts = postRepository.findTop10ByOrderByCreatedAtDesc(); redisTemplate.opsForValue().set(CACHE_KEY, posts, Duration.ofSeconds(60)); // 60초 캐싱 return posts; } } ✅ 장점 ✅ 인기 게시글을 빠르게 조회 가능 ✅ DB 부하를 줄이고, 응답 속도 향상 ❌ 단점 ❌ 실시간 최신 데이터가 필요할 경우 캐시 동기화 문제가 발생 ✅6️⃣ 최적의 페이징 방식 선택 페이징 방식 성능 장점 단점 사용 사례 OFFSET + LIMIT ❌ 느림 간단한 구현 데이터 많아지면 성능 저하 기본적인 페이지네이션 Keyset Pagination ✅ 빠름 인덱스 최적화, 성능 일정 특정 정렬 필드 필요 뉴스 피드, 블로그 Cursor 기반 ✅ 빠름 SNS, 무한 스크롤 최적 클라이언트에서 cursor 저장 필요 인스타그램, 페이스북 Redis 캐싱 🚀 매우 빠름 인기 게시글 빠른 조회 실시간 데이터 반영 어려움 인기 게시글, 랭킹 ✅7️⃣ 결론 ✅ 대규모 데이터에서 OFFSET 방식은 비효율적 ✅ Keyset Pagination(Seek 방식)이 성능 최적화에 유리 ✅ Cursor 방식은 SNS나 무한 스크롤에 적합 ✅ Redis를 활용하여 캐싱하면 조회 속도를 극대화할 수 있음
Backend Development
· 2025-03-17
📚[Backend Development] 샤딩(Sharding)
“📚[Backend Development] 샤딩(Sharding)” ✅1️⃣ 샤딩(Sharding)이란? 샤딩은 하나의 데이터베이스를 여러 개의 노드(서버)로 나누어 저장하는 기술입니다. 즉, 데이터를 여러 개의 작은 데이터베이스(샤드, Shard)로 분할하여 저장하고, 분산된 데이터베이스를 하나의 시스템처럼 동작하도록 만드는 방법입니다. 💡 샤딩의 목적. ✅ 수평 확장(Scale-Out) : 서버를 추가하여 성능을 확장할 수 있습니다. ✅ 데이터 처리 속도 향상 : 특정 샤드에서만 데이터를 처리하므로 성능 향상. ✅ 부하 분산(Load Balancing) : 트래픽을 여러 서버에 분산할 수 있습니다. ✅2️⃣ 샤딩의 종류. 샤딩 방법에는 여러 가지 방식이 있으며, 대표적으로 다음과 같은 방식들이 있습니다. 1️⃣ 범위 샤딩 (Range Sharding) 📌 개념 데이터를 특정 범위에 따라 나누어 저장하는 방식 예를 들어, user_id 또는 날짜(date)를 기준으로 일정 범위별로 나누는 방법 ✅ 장점 설계가 단순하고 직관적임 특정 범위의 데이터를 조회할 때 빠름 ❌ 단점 특정 샤드에 부하가 집중될 수 있음 (Hotspot 문제) 데이터가 불균형하게 분포될 가능성이 있음 📝 예제 -- Shard 1 (User ID 1 ~ 10000) INSERT INTO users_shard_1 VALUES (1001, 'Alice'); -- Shard 1 (User ID 10001 ~ 20000) INSERT INTO users_shard_2 VALUES (15001, 'Bob') 🙋♂️ 사용 사례 사용자 ID 범위별 샤딩 1~10만번 유저 → Shard 1 10만~20만 유저 ➞ Shard 2 날짜 기준 샤딩 2023년 데이터 ➞ Shard 1 2024년 데이터 ➞ Shard 2 2️⃣ 해시 샤딩 (Hash Sharding) 📌 개념 데이터를 특정 키(예: user_id)에 해시 함수를 적용하여 샤드에 배정하는 방식 예: Shard = Hash(user_id) % 3 (3개의 샤드가 있을 경우) ✅ 장점 균등한 데이터 분배 가능 (Hotspot 문제 해결) 특정 샤드에 데이터가 집중되지 않음 ❌ 단점 특정 샤드에 범위 검색(Range Query)이 어려움 조인(Join) 연산이 어려움 📝 예제 -- 해시 함수 적용 (user_id % 3) INSERT INTO users_shard_1 VALUES (1001, 'Alice'); -- 1001 % 3 = 1번 샤드 INSERT INTO users_shard_2 VALUES (15001, 'Bob'); -- 15001 % 3 = 2번 샤드 INSERT INTO users_shard_0 VALUES (23001, 'Charlie'); -- 23001 % 3 = 0번 샤드 🙋♂️ 사용 사례 랜덤한 데이터 분산이 필요한 경우 (예: 유저 로그인 정보, 세션 데이터) 트래픽 균형 유지가 중요한 시스템 (예: 글로벌 서비스, 결제 시스템) 3️⃣ 리스트 샤딩 (List Sharding) 📌 개념 특정 기준(예: 국가, 지역, 언어 등)에 따라 데이터를 나누어 저장하는 방식 예시 Korea 데이터 ➞ Shard 1 USA 데이터 ➞ Shard 2 ✅ 장점 특정 그룹의 데이터를 빠르게 검색할 수 있음 특정 지역/카테고리에 최적화 가능 ❌ 단점 특정 샤드가 과부하될 가능성이 있음 (예: Korea는 100만 명, Brazil은 10만 명) 📝 예제 -- Korean Users ➞ Shard 1 INSERT INTO users_korea VALUES (1001, 'Alice', 'Korea'); -- USA Users ➞ Shard 2 INSERT INTO users_usa VALUES (2001, 'Bob', 'USA'); 🙋♂️ 사용 사례 국가별 사용자 데이터 저장 (예: 한국 ➞ Shard 1, 미국 ➞ Shard 2) 지역별 상품 데이터 저장 (예: 서울 ➞ Shard 1, 부산 ➞ Shard 2) 4️⃣ 동적 샤딩 (Dynamic Sharding) 📌 개념 사용자의 수요에 따라 자동으로 샤드를 확장하는 방식 기존 샤딩 방법과 달리 사전에 샤드를 미리 정의하지 않음 데이터가 증가하면 자동으로 새로운 샤드 추가 ✅ 장점 데이터 양이 늘어나도 쉽게 확장 가능 (Auto-Scaling) 특정 샤드가 과부하될 경우 자동으로 데이터 재분배 가능 ❌ 단점 데이터 이동 (Rebalancing) 시 오버헤드 발생 구현이 복잡하고, 추가적인 관리 시스템 필요 📝 예제 CockroachDB, Google Spanner, TiDB 같은 시스템에서 자동으로 동적 샤딩을 지원 🙋♂️ 사용 사례 클라우드 기반 확장형 데이터베이스 (예: AWS Aurora, Google Spanner) 데이터가 빠르게 증가하는 대규모 시스템 ✅3️⃣ 결론 ✅ 샤딩은 시스템 성능 향상과 확장성을 위한 필수 기술 ✅ 해시 샤딩은 균등한 데이터 분배에 유리하지만, 범위 검색이 어려움 ✅ 범위 샤딩은 특정 범위 검색에 유리하지만, Hotspot 문제가 발생할 수 있음 ✅ 클라우드 기반에서는 동적 샤딩이 점점 중요해지고 있음
Backend Development
· 2025-03-16
📚[Backend Development] 분산 관계형 데이터베이스(DRDB, Distributed Relational Database)
“📚[Backend Development] 분산 관계형 데이터베이스(DRDB, Distributed Relational Database)” ✅1️⃣ 분산 관계형 데이터베이스란? 📌1️⃣ 개념. 분산 관계형 데이터베이스(DRDB, Distributed Relational Database, DRDB)는 하나의 데이터베이스 시스템이 여러 개의 서버(또는 노드)에 분산되어 저장되고 운영되는 관계형 데이터베이스(RDBMS) 시스템을 의미합니다. 즉, 데이터를 하나의 중앙 서버에 저장하는 것이 아니라, 여러 개의 서버에 나누어 저장하고 관리하는 방식입니다. 📌2️⃣ 왜 분산 관계형 데이터베이스를 사용하는가? ✅ 고가용성(High Availability) ➞ 특정 서버가 장애가 나더라도 다른 서버가 대신 동작 가능 ✅ 확장성(Scalability) ➞ 데이터 양이 증가해도 서버를 추가하여 확장 가능 ✅ 성능 향상(Performance Improvement) ➞ 데이터 읽기/쓰기 성능을 높일 수 있음 ✅ 지연시간 감소(Latency Reduction) ➞ 사용자와 가까운 서버에서 데이터를 제공 가능 📌3️⃣ 분산 관계형 데이터베이스의 특징. 💎 CAP 이론 : 분산 시스템은 일관성(Consistency), 가용성(Availability), 네트워크 분할 내성(Partition Tolerance) 중 두 가지만 보장할 수 있음 💎 ACID 보장 : 관계형 데이터베이스 특성상 트랜잭션의 무결성(Atomicity, Consistency, Isolation, Durability, ACID)을 보장해야 함 💎 데이터 분산(Sharding, Replication) : 데이터를 여러 서버에 나누어 저장해야 함 ✅2️⃣ 분산 관계형 데이터 베이스 아키텍처 분산 관계형 데이터베이스를 구성하는 주요 요소들은 다음과 같습니다. 📌1️⃣ 주요 컴포넌트 1️⃣ 데이터 노트(Data Nodes) 데이터를 저장하는 서버(물리적 또는 가상 머신) 여러 개의 노드로 구성되며, 각 노드는 데이터의 일부를 저장 예: MySQL, Cluster, CockroachDB의 노드 2️⃣ 쿼리 노드(Query Nodes) 클라이언트의 요청을 받아 데이터 노드로 전달 분산된 데이터에서 SQL 쿼리를 실행하고 결과를 조합 예: MySQL, Proxy, Vitess, TiDB 3️⃣ 메타데이터 노드(Metadata Nodes) 분산 데이터베이스의 전체 구조 및 데이터 위치 정보를 저장 데이터 노드 간 트랜잭션을 조율하고 데이터 정합성을 유지 4️⃣ 로드 밸런서(Load Balancer) 클라이언트 요청을 여러 데이터 노드로 분산하여 부하를 줄임 예: HAProxy, MySQL Router ✅3️⃣ 분산 관계형 데이터베이스의 데이터 분산 방식 📌1️⃣ 샤딩(Sharding) 📌 개념 샤딩은 데이터를 여러 개의 노드(서버)에 나누어 저장하는 기법입니다. 즉, 하나의 테이블을 여러 개의 작은 테이블로 분할하여 서로 다른 서버에 분산 저장하는 방식입니다. ✅ 장점 트래픽이 증가해도 서버를 추가하여 성능 확장이 가능 (수평 확장, Scale-Out) 특정 샤드에서만 데이터를 처리하므로 빠른 조회 가능 읽기/쓰기 성능이 향상됨 ❌ 단점 샤드 키(Shard Key) 설계가 잘못되면 특정 샤드에 부하가 집중될 수 있음 데이터 연관성(Consistency) 유지가 어려움 조인(Join) 연산이 어려워짐 📝 예제 사용자 데이터를 ID 기준으로 샤딩하는 경우 User ID Name Server (Shard) 1001 Alice Shard 1 1002 Bob Shard 2 1003 Charlie Shard 3 샤딩 전략으로는 범위 샤딩(Range Sharding), 해시 샤딩(Hash Sharding), 동적 샤딩(Dynamic Sharding) 등이 있습니다. 📌2️⃣ 레플리케이션(Replication) 📌 개념 레플리케이션은 데이터를 여러 개의 서버에 복제하여 저장하는 방식입니다. 이 방식은 데이터를 항상 여러 개의 서버에 보관하여 장애 복구(High Availability)와 읽기 성능(Read Performance) 향상을 목표로 합니다. ✅ 장점 한 서버에 장애가 발생해도 다른 복제 서버에서 데이터를 제공할 수 있음 (Failover) 읽기(Read) 성능을 개선할 수 있음 (읽기 요청을 여러 서버로 분산 가능) ❌ 단점 실시간 데이터 동기화가 어려울 수 있음 여러 개의 복제본을 유지해야 하므로 저장 공간이 많이 필요함 📝 예제 Master-Slave Replication (MySQL 기준) 1.Primary(마스터) : 데이터의 쓰기(Write) 작업을 처리 2.Replica(슬레이브) : Primary에서 변경된 데이터를 복제하여 읽기(Read) 처리 전담 Primary(마스터) ---> Replica 1(슬레이브) ---> Replica 2(슬레이브) 📌3️⃣ 분산 트랜잭션 관리(Distributed Transactions) 분산 데이터베이스에서는 여러 개의 노드에서 동시에 트랜잭션을 처리해야 하기 때문에 일관성을 유지하는 것이 중요합니다. 이를 해결하는 대표적인 방법이 2PC (Two-Phase Commit) 프로토콜) 입니다. 📌 Two-Phase Commit (2PC) 1️⃣ Prepare Phase : 모든 노드에 “트랜잭션을 커밋할 준비가 되었는가?”를 물어봄 2️⃣ Commit Phase : 모든 노드가 “OK”를 응답하면 실제 커밋 수행 💎 장점 : 데이터 일관성을 보장 💎 단점 : 느린 성능 (네트워크 지연 발생 가능) ✅4️⃣ 대표적인 분산 관계형 데이터베이스 시스템 DBMS 특징 사용 사례 Google Spanner 글로벌 트랜잭션 지원, 높은 확장성 Google, YouTube CookroachDB PostgreSQL 호환, 자동 샤딩 지원 금융, e-commerce MySQL Cluster 실시간 데이터 처리, 트랜잭션 지원 텔레콤, 게임 서버 Vitess MySQL 기반 샤딩 지원 YouTube, Slack TiDB MySQL 호환, Auto-Scaling 지원 핀테크, 빅데이터 ✅5️⃣ 분산 관계형 데이터베이스 설계 시 고려할 점 ✅ 샤딩 키(Shard Key) 선정 ➞ 특정 샤드에 부하가 몰리지 않도록 설계 ✅ 읽기/쓰기 부하 분산 ➞ 레플리케이션을 활용하여 읽기 성능 최적화 ✅ 트랜잭션 관리 ➞ 2PC, Saga 패턴 등을 활용하여 데이터 정합성 유지 ✅ 데이터 일관성(Consistency) vs 가용성(Availability) 선택 ➞ CAP 이론을 고려한 설계 필요 ✅6️⃣ 결론 ✅ 분산 관계형 데이터베이스는 고가용성, 확장성, 성능 향상을 위해 사용됨 ✅ 샤딩(Sharding)과 레플리케이션(Replication)이 주요 개념 ✅ 트랜잭션 관리(2PC, Saga 패턴)가 중요 ✅ 대표적인 시스템: Google Spanner, CockroachDB, MySQL Cluster, TiDB
Backend Development
· 2025-03-15
📚[Backend Development] 시스템 아키텍처란?
“📚[Backend Development] 시스템 아키텍처란?” ✅1️⃣ 시스템 아키텍처란? 📌1️⃣ 시스템 아키텍처의 개념. 시스템 아키텍처는 소프트웨어 시스템을 설계하고 구성하는 구조를 의미합니다. 쉽게 말해, 하나의 소프트웨어가 어떻게 구성되고, 데이터가 어떻게 흐르며, 성능과 확장성을 어떻게 고려할지 결정하는 과정입니다. 📌2️⃣ 시스템 아키텍처의 역할. 확장성(Scalability) : 사용자가 증가해도 성능이 유지되도록 설계 가용성(Availability) : 장애 발생 시에도 지속적으로 운영 가능하도록 설계 보안(Security) : 데이터 보호 및 인증, 권한 관리 유지보수성(Maintainability) : 코드 수정 및 기능 추가가 쉽게 가능하도록 구조 설계 성능(Performance) : 빠르게 동작하도록 최적화 ✅2️⃣ 시스템 아키텍처의 주요 구성 요소, 시스템은 여러 컴포넌트로 구성되며, 각각의 역할이 다릅니다. 📌1️⃣ 클라이언트(Client) 사용자가 직접 조작하는 부분 (웹 브라우저, 모바일 앱) 요청을 서버에 전달하고 결과를 표시하는 역할 예: React, Vue.js, Android, iOS 📌2️⃣ API 게이트웨이 (API Gateway) 클라이언트 요청을 내부 서비스로 라우팅하는 역할 인증, 로드 밸런싱, 캐싱등의 시능 수행 예: Kong, Nginx, AWS API Gatewat 📌3️⃣ 애플리케이션 서버 (Application Server) 비즈니스 로직을 처리하는 핵심 컴포넌트 REST API 또는 GraphQL을 통해 데이터를 제공 예: Spring Boot, Node.js, Django, FastAPI 📌4️⃣ 데이터베이스 (Database) 데이터를 저장하고 조회하는 역할 RDBMS (MySQL, PostgreSQL) vs NoSQL (MongoDB, Cassandra) 📌5️⃣ 캐시 서버 (Cache Server) 자주 사용되는 데이터를 빠르게 제공하는 역할 예: Redis, Memcached 📌6️⃣ 메시지 큐 (Message Queue, MQ) 비동기 이벤트 처리를 위한 시스템 예: Kafka, RabbitMQ, AWS SQS 📌7️⃣ 로드 밸런서 (Load Balancer) 요청을 여러 서버로 분산하여 부하를 줄이는 역할 예: Nginx, HAProxy, AWS ELB 📌8️⃣ 모니터링 시스템 (Monitoring System) 서버 상태, 트래핑, 장애 발생 감지 예: Prometheus, Grafana, AWS CloudWatch ✅3️⃣ 주요 시스템 아키텍처 패턴 📌1️⃣ Monolithic Architecture (모놀리식 아키텍처) 📌 특징 하나의 애플리케이션이 모든 기능을 포함하는 구조 모든 코드가 하나의 프로젝트로 구성됨 ✅ 장점 개발 초기 단계에서 간단한 구조로 빠르게 개발 가능 배포가 단순함 ❌ 단점 하나의 기능 변경 시 전체 시스템을 다시 배포해야 함 특정 기능만 확장하기 어렵고, 트래픽 증가에 취약함 📝 예제 전통적인 웹 애플리케이션 (Spring Boot + MySQL) 📌2️⃣ Microservices Architecture (마이크로서비스 아키텍처, MSA) 📌 특징 애플리케이션을 여러 개의 작은 서비스로 분리하여 개발 각 서비스가 독립적으로 배포 가능 서비스 간 통신은 API (REST, gRPC) 또는 메시지 큐(Kafka)로 이루어짐 ✅ 장점 개별 서비스 확장이 가능하여 성능 최적화가 쉬움 특정 서비스만 업데이트 가능하여 유지보수가 쉬움 ❌ 단점 서비스 간 통신 비용이 증가하여 네트워크 성능 저하 가능 각 서비스 간 데이터 일관성 유지가 어려움 📝 예제 Netflix, Uber, 카카오, 토스 같은 대규모 서비스에서 사용됨 📌3️⃣ Layerd Architecture (계층형 아키텍처) 📌 특징 애플리케이션을 계층(Layer)별로 나누어 관리하는 방식 일반적으로 3-Tier 또는 4-Tier 구조로 설계됨 📌 3-Tier 구조 1️⃣ Presentation Layer (프레젠테이션 계층) ➞ UI & 클라이언트 2️⃣ Application Layer (애플리케이션 계층) ➞ 비즈니스 로직 3️⃣ Data Layer (데이터 계층) ➞ 데이터 저장 및 조회 ✅ 장점 역할 분리가 명확하여 유지보수성이 뛰어남 재사용 가능한 코드 구조 ❌ 단점 다층 구조로 인해 응답 속도가 느려질 수 있음 📌4️⃣ Event-Driven Architecture (이벤트 기반 아키텍처) 📌 특징 이벤트가 발생하면 특정 서비스에서 이를 처리하는 방식 메시지 큐(Kafka, RabbitMQ)를 활용하여 비동기 처리를 함 ✅ 장점 비동기 방식으로 트래픽을 분산할 수 있어 성능 최적화 가능 각 서비스가 독립적으로 작동하여 장애 발생 시 영향이 적음 ❌ 단점 이벤트 흐름을 추적하기 어려워 디버깅이 어려움 메시지 지연(latency)이 발생할 수 있음 📝 예제 주문 시스템: 주문이 발생하면 Order Service에서 Payment Service로 이벤트 전송 ✅4️⃣ 확장 가능한 시스템 설계 원칙. 📌1️⃣ Scal-Up vs Scale-Out Scale-Up : 서버의 성능을 업그레이드 (RAM, CPU 추가) Scalce-Out : 서버 개수를 늘려 부하를 분산 (로드 밸런서 활용) 📌2️⃣ 데이터베이스 최적화 샤딩(Sharding) : 데이터베이스를 여러 개로 나누어 저장 레플리케이션(Replication) : 동일한 데이터를 여러 서버에 복제하여 읽기 성능 향상 📌3️⃣ 로드 밸런싱 (Load Balancing) Round Robin : 서버에 순차적으로 요청 전달 Least Connections : 현재 접속자가 가장 적은 서버로 요청 전달 📌4️⃣ 장애 복구 (Fault Tolerance) Failover : 장애 발생 시 대체 서버로 자동 전환 Circuit Breaker : 일정 횟수 이상 실패하면 서비스 차단 ✅5️⃣ 결론 시스템 아키텍처는 소프트웨어의 성능과 확장성, 유지보수성을 결정하는 중요한 요소입니다. 초반에는 모놀리식 아키텍처로 빠르게 개발 트래픽이 증가하면 마이크로서비스 아케텍처로 확장 비동기 처리가 필요하면 이벤트 기반 아키텍처 도입
Backend Development
· 2025-03-14
📚[Backend Development] 대규모 시스템 아키텍처 핵심 요소
“📚[Backend Development] 대규모 시스템 아키텍처 핵심 요소” ✅1️⃣ 로드 밸런서 (Load Balancer) 1️⃣ 로드 밸런서란? 로드 밸런서 (Load Balancer)는 클라이언트의 요청을 여러 서버로 분산하여 부하를 줄이고, 장애가 발생한 서버를 감지하여 트래픽을 자동으로 다른 서버로 우회하는 역할을 합니다. 2️⃣ 로드 밸런서 종류 L4 (Network Layer) 로드 밸런서 : IP 주소와 포트 기반으로 트래픽을 분산 (예: AWS NLB, HAProxy) L7 (Application Layer) 로드 밸런서 : HTTP 요청을 분석하여 특정 URL이나 쿠키 정보 기반으로 분산 (예: Nginx, AWS ALB, Traefix) 3️⃣ 로드 밸런싱 방식 Round Robin : 서버에 순차적으로 요청을 분배 Least Connections : 현재 접속자가 가장 적은 서버로 분배 IP Hashing : 클라이언트의 IP를 기반으로 특정 서버로 항상 연결 (세션 유지 필요할 때 사용) Weighted Round Robin : 서버 성능에 따라 가중치를 두고 트래픽을 분배 4️⃣ 예제 AWS ALB(Application Load Balancer) 설정 시, 특정 URL 경로에 따라 다른 서버 그룹으로 요청을 보낼 수 있음. https://example.com/api/* ➞ API 서버 그룹 https://example.com/images/* ➞ 이미지 서버 그룹 ✅2️⃣ 웹 서버(Web Server)와 애플리케이션 서버(Application Server) 1️⃣ 웹 서버(Web Server)란? 웹 서버는 정적 콘텐츠(HTML, CSS, JavaScript)를 제공하는 역할을 하며, 대표적으로 Nginxm Apache가 있습니다. 2️⃣ 애플리케이션 서버(Application Server)란? 애플리케이션 서버는 비즈니스 로직을 처리하는 서버, 보통 Spring Boot, Django, Express 같은 프레임워크를 사용합니다. 3️⃣ 웹 서버(Web Server) + 애플리케이션 서버(Application Server) 연동 방식. Reverse Proxy : 웹 서버(Nginx)가 클라이언트 요청을 받고, 내부 애플리케이션 서버(Spring Boot)로 전달 예제 lcation /api/ { proxy_pass http://localhost:8080/; } ✅3️⃣ 데이터베이스 (Database, DB) 1️⃣ 데이터베이스 종류. 📌1️⃣ RDBMS (Relational Database Management System) MySQL, PostgreSQL, Oracle 데이터 정합성(ACID 보장)이 중요할 때 사용 📌2️⃣ NoSQL MongoDB, Cassandra, Redis 트래픽이 많고 빠른 읽기/쓰기 성능이 필요한 경우 사용 2️⃣ 데이터 분산 전략 샤딩(Sharding) : 데이터를 여러 서버에 나눠서 저장 레플리케이션(Replication) : 데이터를 여러 서버에 복제하여 장애 대비 3️⃣ 예제 MySQL Master-Slave Replication: Master DB: 쓰기 작업 처리 Slave DB: 읽기 작업 처리 ✅4️⃣ 캐시 시스템 (Cache System) 1️⃣ 캐시(Cache)란? 자주 사용하는 데이터를 빠르게 제공하기 위해 메모리에 저장하는 기술 2️⃣ 캐시 저장소 종류 📌1️⃣ 메모리 캐시. Redis Memcached 📌2️⃣ CDN 캐시. Cloudflare AWS CloudFront 3️⃣ 캐시 정책 TTL(Time-To-Live) : 일정 시간이 지나면 캐시 삭제 LRU(Least Recently Used) : 가장 오래 사용되지 않은 데이터부터 삭제 4️⃣ 예제 Spring Boot + Redis 캐시 적용 @Cacheable(value = "userCache", key = "#userId") public User getUserById(Long userId) { return userRepository.findById(userId).orElse(null); } ✅5️⃣ 메시지 큐 (Message Queue, MQ) 1️⃣ 메시지 큐란? 비동기 처리를 위해 메시지를 저장하고 전달하는 시스템 2️⃣ 메시지 큐 종류. Kafka : 대량의 데이터 처리 가능, 로그 처리, 실시간 스트리밍 지원 RabbitMQ : 빠른 메시지 큐잉, 트랜잭션 처리 가능 AWS SQS : 관리형 메시지 큐 서비스 3️⃣ 예제. Spring Boot + Kafka @KafakaListener(topics = "order-topic", groupId = "order-group") public void consume(String message) { System.out.println("Received Message: " + message); } ✅6️⃣ CDN (Content Delivery Network) 1️⃣ CDN이란? 정적 콘텐츠(이미지, 동영상, CSS)를 전 세계 여러 서버에 배포하여 빠르게 제공하는 기술 2️⃣ CDN 동작 방식 사용자가 웹사이트 접속 CDN 서버가 가장 가까운 위치에서 콘텐츠 제공 원본 서버 부하 감소 3️⃣ 예제 AWS ClouldFront를 사용하여 정적 콘텐츠 캐싱 ✅7️⃣ 모니터링 & 로깅 시스템 1️⃣ 모니터링 시스템 Prometheus + Grafana : 서버 메트릭 수집 및 시각화 AWS CloudWatch : AWS 리소스 모니터링 2️⃣ 로깅 시스템 ELK Stack (Elasticsearch, Logstash, Kibana) : 로그 수집 및 분석 Fluetd : 경량 로그 수집기 3️⃣ 예제 Spring Boot + ELK logging.file.name=logs/app.log ✅8️⃣ 확장성 (Scalability) 1️⃣ 확장 방법 📌1️⃣ 수직 확장 (Scale-Up) 서버 성능 업그레이드 (CPU, RAM 추가) 한계가 존재함 📌2️⃣ 수평 확장 (Scale-Out) 서버 개수를 늘려 부하 분산 로드 밸런서를 활용 ✅9️⃣ 장애 대응 (Fault Tolerance) 1️⃣ 장애 대응 기법 Failover : 장애 발생 시 자동으로 대체 서버로 전환 Circuit Breaker 패턴 : 서비스가 일정 횟수 이상 실패하면 자동으로 차단 2️⃣ 예제 Spring Cloud + Resilience4j Circuit Breaker @CircuiteBreaker(name = "backendA", fallbackMethod = "fallback") public String callService() { return restTemplate.getForObject("http://example.com/api", String.class); }
Backend Development
· 2025-03-12
📚[Backend Development] 대규모 시스템 서버 인프라
“📚[Backend Development] 대규모 시스템 서버 인프라” ✅1️⃣ 대규모 시스템 서버 인프라란? 대규모 시스템 서버 인프라는 “많은 사용자가 동시에 접속해도 원활하게 동작할 수 있도록 설계된 서버 환경”을 의미합니다. 대표적인 예 클라우드 서비스(AWS, GCP, Azure) 대형 웹사이트(네이버, 토스, 카카오톡) 온라인 게임 서버(베틀그라운드, LOL) etc. 이러한 시스템에서는 트래픽 처리, 확장성(Scalability), 가용성(Availability), 복원력(Resilience)등이 매우 중요합니다. ✅2️⃣ 대규모 시스템 아키텍처의 핵심 요소. 1️⃣ 로드 밸런서 (Load Balancer) 서버에 들어오는 트래픽을 여러 서버로 분산하는 역할을 합니다. 대표적인 로드 밸런서 Nginx HAProxy AWS ELB etc. 예시: 사용자가 많아지면 한 대의 서버만으로 감당하기 어려우므로 여러 대의 서버에 요청을 분배합니다. 2️⃣ 웹 서버 (Web Server)와 애플리케이션 서버 (Application Server) 웹 서버(Nginx, Apache) ➞ 정적 파일(HTML, CSS, JS) 제공. 애플리케이션 서버(Spring Boot, Django, Express) ➞ 비즈니스 로직 수행. 예시: 클라이언트가 로그인하면, 애플리케이션 서버에서 DB를 조회해 사용자 정보를 제공합니다. 3️⃣ 데이터베이스 (Database, DB) 데이터를 저장하고 관리하는 시스템. RDBMS (MySQL, PostgreSQL, Oracle) ➞ 강력한 데이터 무결성 보장. NoSQL (MongoDB, Redis, Cassandra) ➞ 대량의 데이터 처리에 적합. 샤딩(Sharding), 레플리케이션(Replication)을 활용해 성능과 안정성을 높임. 4️⃣ 캐시 시스템 (Cache System) 자주 조회되는 데이터를 빠르게 제공하기 위해 사용됩니다. 대표적인 캐시 기술 Redis Memcached 예시: 인기 게시슬을 캐시에 저장해 DB 부하를 줄입니다. 5️⃣ 메시지 큐(Message Queue, MQ) 비동기 처리를 위해 사용됩니다. 대표적인 메시지 큐 시스템 Kafka RabbitMQ AWS SQS 예시: 유저가 글을 작성하면, MQ에 메시지를 보내고 나중에 비동기로 처리함. 6️⃣ CDN (Content Delivery Network) 정적 콘텐츠 (이미지, 동영상)를 전 세계 여러 서버에 배포하여 빠르게 제공하는 기술. 대표적인 CDN 서비스 Clouldflare AWS CloudFront 예시: 해외 사용자가 한국 서버에서 이미지 다운로드 시, 가까운 CDN 서버에서 제공해 속도를 높입니다. 7️⃣ 모니터링 & 로깅 시스템 서버 상태를 지속적으로 체크하고, 장애 발생 시 빠르게 대응할 수 있도록 합니다. 대표적인 모니터링 도구 Prometheus Grafana AWS CloudWatch 대표적인 로깅 도구 ELK Stack (Elasticsearch, Logstach, Kibana) Fluentd 예시: CPU 사용량이 80%를 초과하면 경고 알림을 발생시켜 장애를 예방합니다. ✅3️⃣ 대규모 시스템 설계의 핵심 개념 ✅ 확장성 (Scalability) 시스템이 사용자 증가에 따라 성능 저하 없이 확장할 수 있는 능력 수평 확장(Scale-Out) : 서버를 여러 대 추가 예: AWS EC2 Auto Scaling 수직 확장(Scale-Up) : 서버의 성능을 높이는 방법 예: CPU, RAM 업그레이드 ✅ 가용성 (Availability) 시스템이 지속적으로 동작할 수 있는 능력 (99.99% Uptime 보장) 예시: 장애 발생 시, 다른 서버가 대신 처리하도록 Failover 설계 ✅ 복원력 (Resilience) 시스템이 장애 발생 후 빠르게 복구하는 능력 예시: AWS RDS Multi-AZ 구성 ➞ 장애 발생 시 자동으로 대체 DB로 전환 ✅ 일관성 (Consistency) vs 가용성 (Availability) vs 분할 내성 (Partition Tolerance) CAP 정리 : 분산 시스템에서는 일관성 (Consistency), 가용성 (Availability), 분할 내성 (Partition Tolerance) 중 두 가지만 선택 가능 예시: 은행 시스템 ➞ 일관성 (Consistency) 우선 예시: SNS 서비스 ➞ 가용성 (Availability) 우선 ✅ 마이크로서비스 아키텍처 (MSA, Microservice Architecture) 하나의 거대한 서비스(모놀리식)를 작은 서비스 여러 개로 분리하는 방식 장점 확장성 증가 독립 배포 기능 장애가 전체 시스템에 영향을 덜 줌 예시 사용자 인증, 결제, 주문, 리뷰 서비스를 각각 독립적인 서비스로 나눔 ✅4️⃣ 대규모 시스템 설계 사례 💎 쇼핑몰 (이커머스) 시스템 웹 서버 (Nginx) 애플리케이션 서버 (Spring Boot) DB (MySQL + Redis 캐시) 메시지 큐 (Kafka) ➞ 주문 처리 CDN ➞ 이미지 및 정적 리소스 제공 💎 게임 서버 (배틀그라운드, LOL) 게임 서버 (C++, Java) 매칭 서버 ➞ 유저 매칭 랭킹 시스템 (Redis) 로그 수집 및 분석 (Elasticsearch) 💎 금융 서비스 (토스, 카카오뱅크) 강력한 보안 및 트랜잭션 무결성 CQRS 패턴 적용 (읽기와 쓰기 분리) 이중화된 DB 및 Failover 시스템 구축 ✅5️⃣ 대규모 시스템 설계 시 고려할 점 📌 트래픽 급증에 대비한 Auto Scaling 설계 📌 DB 부하를 줄이기 위한 캐시 적용 (Redis, Memcached) 📌 장애 발생 대비를 위한 로드 밸런서 및 이중화 (Failover 구성) 📌 비동기 처리를 위한 메시지 큐 도입 (Kafka, RabbitMQ) 📌 빠른 장애 탐지를 위한 모니터링 시스템 구축 💡 결론 대규모 시스템 서버 인프라는 트래픽 분산, 확장성, 장애 대응이 핵심입니다. 백엔드 개발자로서 로드 밸런싱, DB 성능 최적화, 메시지 큐, 캐시 시스템 같은 요소를 깊이 이해하는 게 중요합니다.
Backend Development
· 2025-03-11
📚[Backend Development] increase메서드 실행과정 분석
“📚[Backend Development] increase메서드 실행과정 분석” 📌 CommentPath 클래스. package kobe.board.comment.entity; import jakarta.persistence.Embeddable; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.ToString; @Getter @ToString @Embeddable @NoArgsConstructor(access = AccessLevel.PROTECTED) public class CommentPath { private String path; private static final String CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; private static final int DEPTH_CHUNK_SIZE = 5; private static final int MAX_DEPTH = 5; // MIN_CHUNK = "00000", MAX_CHUNK = "zzzzz" private static final String MIN_CHUNK = String.valueOf(CHARSET.charAt(0)).repeat(DEPTH_CHUNK_SIZE); private static final String MAX_CHUNK = String.valueOf(CHARSET.charAt(CHARSET.length() - 1)).repeat(DEPTH_CHUNK_SIZE); public static CommentPath create(String path) { if (isDepthOverflowed(path)) { throw new IllegalStateException("depth overflowed"); } CommentPath commentPath = new CommentPath(); commentPath.path = path; return commentPath; } private static boolean isDepthOverflowed(String path) { return calculateDepth(path) > MAX_DEPTH; } private static int calculateDepth(String path) { // 25개의 문자열 / 5 = 5depth return path.length() / DEPTH_CHUNK_SIZE; } // CommentPath 클래스의 path의 depth를 구하는 매서드 public int getDepth() { return calculateDepth(path); } // root인지 확인하는 매서드 public boolean isRoot() { // 현재의 depth가 1인지 확인해주면 됨 return calculateDepth(path) == 1; } // 현재 path의 parentPath를 반환해주는 매서드 public String getParentPath() { // 끝 5자리만 잘라내면 됨 return path.substring(0, path.length() - DEPTH_CHUNK_SIZE); } // 현재 path의 하위 댓글의 path을 만드는 매서드 public CommentPath createChildCommentPath(String descendantsTopPath) { if (descendantsTopPath == null) { return CommentPath.create(path + MIN_CHUNK); } String childrenTopPath = findChildrenTopPath(descendantsTopPath); return CommentPath.create(increase(childrenTopPath)); } // 00a0z0000200000 <- descendantsTopPath private String findChildrenTopPath(String descendantsTopPath) { return descendantsTopPath.substring(0, (getDepth() + 1) * DEPTH_CHUNK_SIZE); } private String increase(String path) { // path에서 가장 마지막 5자리를 자른 것 String lastChunk = path.substring(path.length() - DEPTH_CHUNK_SIZE); if (isChunkOverflowed(lastChunk)) { throw new IllegalStateException("chunk overflowed"); } // Character set의 길이 int charsetLength = CHARSET.length(); // lastChunk를 10진수로 먼저 변환하기 위한 값을 저장 int value = 0; for (char character : lastChunk.toCharArray()) { value = value * charsetLength + CHARSET.indexOf(character); System.out.println("value ====> " + value); System.out.println("CHARSET.indexOf ====> " + CHARSET.indexOf(character)); } value = value + 1; String result = ""; for (int i = 0; i < DEPTH_CHUNK_SIZE; i++) { result = CHARSET.charAt(value % charsetLength) + result; value /= charsetLength; } return path.substring(0, path.length() - DEPTH_CHUNK_SIZE) + result; } private boolean isChunkOverflowed(String lastChunk) { return MAX_CHUNK.equals(lastChunk); } } ✅1️⃣ 코드 실행 흐름 확인 for (int i = 0; i < DEPTH_CHUNK_SIZE; i++) { result = CHARSET.charAt(value % charsetLength) + result; value /= charsetLength; } 📌 코드 핵심 역할 value를 CHARSET(62진수) 기반으로 DEPTH_CHUNK_SIZE(=5) 길이의 문자열로 변환하는 과정 각 반복에서 value의 마지막 자리를 CHARSET에서 찾아 문자열(result)에 추가하고, value를 62로 나눠서 다음 문자를 구함. ✅2️⃣ descendantsTopPath = “00000” 일 때 increas(“00000”)의 실행 과정 📌 increase(“00000”) 실행 과정 1. lastChunk 추출 String lastChunk = path.substring(path.length() - DEPTH_CHUNK_SIZE) “00000” ➞ lastChunk = “00000” 2. lastChunk를 10진수(value)로 변환 int value = 0; for (char character : lastChunk.toCharArray()) { value = value * charsetLength + CHARSET.indexOf(cha) } CHARSET.indexOf(‘0’) = 0 value 값 계산: value = (0 * 62) + 0 = 0 value = (0 * 62) + 0 = 0 value = (0 * 62) + 0 = 0 value = (0 * 62) + 0 = 0 value = (0 * 62) + 0 = 0 최종적으로 value = 0 3. value + 1 증가. value = value + 1; value = 0 + 1 = 1 4. value를 다신 62진수 문자열로 변환 for (int i = 0; i < DEPTH_CHUNK_SIZE; i++) { result = CHARSET.charAt(value % charserLength) + result; value /= charsetLength; } 반복 과정(DEPTH_CHUNK_SIZE = 5) i = 0: value % 62 = 1 → CHARSET[1] = '1' → result = "1" i = 1: value /= 62 = 0 → CHARSET[0] = '0' → result = "01" i = 2: value = 0 → CHARSET[0] = '0' → result = "001" i = 3: value = 0 → CHARSET[0] = '0' → result = "0001" i = 4: value = 0 → CHARSET[0] = '0' → result = "00001" 최종 result = “00001”
Backend Development
· 2025-03-07
📚[Backend Development] increase메서드 내부 for문 실행 과정 상세 분석
“📚[Backend Development] increase메서드 내부 for문 실행 과정 상세 분석” 📌 CommentPath 클래스. package kobe.board.comment.entity; import jakarta.persistence.Embeddable; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.ToString; @Getter @ToString @Embeddable @NoArgsConstructor(access = AccessLevel.PROTECTED) public class CommentPath { private String path; private static final String CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; private static final int DEPTH_CHUNK_SIZE = 5; private static final int MAX_DEPTH = 5; // MIN_CHUNK = "00000", MAX_CHUNK = "zzzzz" private static final String MIN_CHUNK = String.valueOf(CHARSET.charAt(0)).repeat(DEPTH_CHUNK_SIZE); private static final String MAX_CHUNK = String.valueOf(CHARSET.charAt(CHARSET.length() - 1)).repeat(DEPTH_CHUNK_SIZE); public static CommentPath create(String path) { if (isDepthOverflowed(path)) { throw new IllegalStateException("depth overflowed"); } CommentPath commentPath = new CommentPath(); commentPath.path = path; return commentPath; } private static boolean isDepthOverflowed(String path) { return calculateDepth(path) > MAX_DEPTH; } private static int calculateDepth(String path) { // 25개의 문자열 / 5 = 5depth return path.length() / DEPTH_CHUNK_SIZE; } // CommentPath 클래스의 path의 depth를 구하는 매서드 public int getDepth() { return calculateDepth(path); } // root인지 확인하는 매서드 public boolean isRoot() { // 현재의 depth가 1인지 확인해주면 됨 return calculateDepth(path) == 1; } // 현재 path의 parentPath를 반환해주는 매서드 public String getParentPath() { // 끝 5자리만 잘라내면 됨 return path.substring(0, path.length() - DEPTH_CHUNK_SIZE); } // 현재 path의 하위 댓글의 path을 만드는 매서드 public CommentPath createChildCommentPath(String descendantsTopPath) { if (descendantsTopPath == null) { return CommentPath.create(path + MIN_CHUNK); } String childrenTopPath = findChildrenTopPath(descendantsTopPath); return CommentPath.create(increase(childrenTopPath)); } // 00a0z0000200000 <- descendantsTopPath private String findChildrenTopPath(String descendantsTopPath) { return descendantsTopPath.substring(0, (getDepth() + 1) * DEPTH_CHUNK_SIZE); } private String increase(String path) { // path에서 가장 마지막 5자리를 자른 것 String lastChunk = path.substring(path.length() - DEPTH_CHUNK_SIZE); if (isChunkOverflowed(lastChunk)) { throw new IllegalStateException("chunk overflowed"); } // Character set의 길이 int charsetLength = CHARSET.length(); // lastChunk를 10진수로 먼저 변환하기 위한 값을 저장 int value = 0; for (char character : lastChunk.toCharArray()) { value = value * charsetLength + CHARSET.indexOf(character); System.out.println("value ====> " + value); System.out.println("CHARSET.indexOf ====> " + CHARSET.indexOf(character)); } value = value + 1; String result = ""; for (int i = 0; i < DEPTH_CHUNK_SIZE; i++) { result = CHARSET.charAt(value % charsetLength) + result; value /= charsetLength; } return path.substring(0, path.length() - DEPTH_CHUNK_SIZE) + result; } private boolean isChunkOverflowed(String lastChunk) { return MAX_CHUNK.equals(lastChunk); } } 📌 for 문 실행 과정 상세 분석 해당 for ansdms 주어진 value(10진수)를 CHARSET(62진수)로 변환하여 문자열(result)을 생성하는 과정입니다. 1️⃣ for 문 코드 for (int i = 0; i < DEPTH_CHUNK_SIZE; i++) { result = CHARSET.charAt(value % charsetLength) + result; value /= charsetLength; } ✅ 주요 개념 value % charsetLength ➞ 62진수에서 가장 낮은 자리수(오른쪽 끝)를 구함 CHARSET.charAt(value % charsetLenht) ➞ CHARSET에서 해당 인덱스의 문자(0~9, A~Z, a~z)를 가져옴 value /= charsetLength ➞ 다음 자리수를 계산하기 위해 value를 62로 나눔 result = CHARSET.charAt(value % charsetLenght) + resultl ➞ 문자열의 앞에 추가하여 변환 결과를 만든다 2️⃣ for 문 실행 과정 단계별 분석 📌 예제 1: value = 1, charsetLenght = 62, DEPTH_CHUNK_SIZE = 5 ✅ 초기 값 value = 1; resutl = ""; ✅ 반복 과정 반복 value value % 62 CHARSET.charAt(value % 62) result value /= 62 i=0 1 1 ‘1’ “1” 0 i=1 0 0 ‘0’ “01” 0 i=2 0 0 ‘0’ “001” 0 i=3 0 0 ‘0’ “0001” 0 i=4 0 0 ‘0’ “00001” 0 ✅ 최종 결과 result = "00001"; 📌 예제 2: value = 62 ✅ 초기 값 value = 62; resutl = ""; ✅ 반복 과정 반복 value value % 62 CHARSET.charAt(value % 62) result value /= 62 i=0 62 0 ‘0’ “0” 1 i=1 1 1 ‘1’ “10” 0 i=2 0 0 ‘0’ “010” 0 i=3 0 0 ‘0’ “0010” 0 i=4 0 0 ‘0’ “00010” 0 ✅ 최종 결과 result = "00010"; 📌 예제 3: value = 3843 ✅ 초기 값 value = 3843; resutl = ""; ✅ 반복 과정 반복 value value % 62 CHARSET.charAt(value % 62) result value /= 62 i=0 3843 61 ‘z’ “z” 61 i=1 61 61 ‘z’ “zz” 0 i=2 0 0 ‘0’ “0zz” 0 i=3 0 0 ‘0’ “00zz” 0 i=4 0 0 ‘0’ “000zz” 0 ✅ 최종 결과 result = "000zz"; 📌 핵심 정리 📝 for 문이 하는 일 value(10진수)를 62진수 문자열로 변환한다. CHARSET.charAt(value % 62)를 사용하여 가장 낮은 자리수부터 변환한다. 3.변환된 문자를 result 앞에 추가(+ result) value /= 62 하여 다음 자리수를 계산. 최종적으로 DEPTH_CHUNK_SIZE(5)만큼 반복하여 5자리 문자열을 만든다. 📌 결론 value의 작은 자리수부터 변환하여 문자열을 구성한다. 62진법 변환원리를 사용하여 댓글의 path를 증가시키는 데 활용한다.
Backend Development
· 2025-03-07
📚[Backend Development] CommentPath 클래스 분석 및 설명
“📚[Backend Development] CommentPath 클래스 분석 및 설명” 📌 CommentPath 클래스 분석 및 설명. CommentPath 클래스는 계층형 댓글 시스템에서 각 댓글의 경로(path)를 관리하는 역할을 합니다. 각 댓글은 path라는 문자열로 표현되며, path는 일정한 규칙을 따라 댓글의 부모-자식 관계를 나타냅니다. 이 클래스는 댓글의 깊이(depth), 부모 댓글(parentPath), 새로운 자식 댓글(createChildCommentPath) 등을 관리하는 기능을 제공합니다. ✅1️⃣ 클래스 전반적인 개요. 📌 핵심 개념 1️⃣ path 필드 path는 댓글의 계층 구조를 표현하는 문자열입니다. 각 댓글은 5자리씩(DEPHT_CHUNK_SIZE = 5)의 문자열을 가지며, 댓글이 깊어질수록 path가 길어집니다. 📝 예시: 루트 댓글: "00000" 첫 번째 자식 댓글: "0000000000" 두 번째 자식 댓글: "000000000000000" 이를 통해 댓글이 어느 계층에 속하는지, 부모가 누구인지, 자식이 어떻게 배치될지를 결정할 수 있습니다. 2️⃣ CHARSET (문자 집합) 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz (총 62개 문자) path의 각 5자리(chunk)는 이 문자 집합을 사용해 표현됩니다. 새로운 댓글이 추가될 때, path는 문자 집합 내에서 증가(increase)하는 방식으로 생성됩니다. ✅2️⃣ 주요 필드 private static final String CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; private static final int DEPTH_CHUNK_SIZE = 5; // 각 depth가 5자리로 표현됨 private static final int MAX_DEPTH = 5; // 최대 depth 5까지 허용 private static final String MIN_CHUNK = String.valueOf(CHARSET.charAt(0)).repeat(DEPTH_CHUNK_SIZE); private static final String MAX_CHUNK = String.valueOf(CHARSET.charAt(CHARSET.length() - 1)).repeat(DEPTH_CHUNK_SIZE); MIN_CHUNK = “00000” : 최소 chunk 값 MAX_CHUNK = “zzzzz” : 최대 chunk 값 (즉, 더 이상 증가 불가능한 값) 댓글의 path는 MIN_CHUNK부터 시작해 점진적으로 증가하는 방식입니다. ✅3️⃣ 주요 메서드 분석 각 메서드의 역할, 사용 시기, 동작 방식을 설명하겠습니다. 1️⃣ create(String path) public static CommentPath create(String path) { if (isDepthOverflowed(path)) { throw new IllegalStateException("depth overflowed"); } CommentPath commentPath = new CommentPath(); commentPath.path = path; return commentPath; } ✅ 역할: 주어진 path로 CommentPath 객체를 생성한다. path의 깊이가 MAX_DEPTH를 초과하면 예외 발생. ✅ 사용 시기: 댓글을 DB에 저장할 때, CommentPath를 생성할 때 사용. ✅ 동작 방식: isDepthOverflowed(path)를 호출하여 최대 깊이를 초과하는지 확인한다. 문제가 없으면 CommentPath 객체를 생성하여 반환한다. 2️⃣ calculateDepth(String path) private static int calculateDepth(String path) { return path.length() / DEPTH_CHUNK_SIZE; } ✅ 역할: 현재 path의 깊이를 계산한다. path.length()를 DEPTH_CHUNK_SIZE로 나누면 깊이가 된다. ✅ 사용 시기: 댓글이 몇 번째 깊이인지 확인 할 때. isRoot(), isDepthOverflowed() 등의 메서드에서 사용. ✅ 동작 방식: path.length()를 5로 나눈다. 예: “0000000000” (10글자) ➞ calculateDepth(“0000000000”) ➞ 10 / 5 = 2 3️⃣ getDepth() public int getDepth() { return calculateDepth(path); } ✅ 역할: 현재 댓글의 깊이를 반환한다. ✅ 사용 시기: 댓글의 깊이를 확인할 때. ✅ 동작 방식: calculateDepth(path)를 호출하여 깊이를 구한다. 4️⃣ isRoot() public boolean isRoot() { return calculateDepth(path) == 1; } ✅ 역할: 현재 댓글이 루트 댓글인지 확인한다. ✅ 사용 시기: 부모 댓글이 없는 루트 댓글인지 확인할 때. ✅ 동작 방식: 깊이가 1이면 루트 댓글로 판단. 5️⃣ getParentPath() public String getParentPath() { return path.substring(0, path.length() - DEPTH_CHUNK_SIZE); } ✅ 역할: 부모 댓글의 path를 반환한다. ✅ 사용 시기: 댓글의 부모를 찾을 때. ✅ 동작 방식: 현재 path에서 마지막 5자리를 잘라내어 반환한다. 6️⃣ createChildCommentPath(String descendantsTopPath) public CommentPath createChildCommentPath(String descendantsTopPath) { if (descendantsTopPath == null) { return CommentPath.creat(path + MIN_CHUNK); } String childrenTopPath = findChildrenTopPath(descendantsTopPath); return CommentPath.create(increase(childrenTopPath)); } ✅ 역할: 현재 댓글의 자식 댓글의 path를 생성한다. ✅ 사용 시기: 새로운 대댓글을 추가할 때. ✅ 동작 방식: descendantsTopPath가 null이면 MIN_CHUNK를 붙여서 자식 댓글을 생성. 자식 댓글의 path를 가져온 후 increase()를 호출하여 증가. 7️⃣ findChildrenTopPath(String descendantsTopPath) private String findChildrenTopPath(String descendantsTopPath) { return descendantsTopPath.substring(0, (getDepth() + 1) * DEPTH_CHUNK_SIZE); } ✅ 역할: 자손 댓글 중 가장 상위 댓글의 path를 반환한다. ✅ 사용 시기: 새로운 댓글을 추가할 때, 어떤 댓글이 현재 댓글의 가장 최근 자식 댓글인지 찾을 때. ✅ 동작 방식: 현재 깊이보다 한 단계 더 깊은 path 부분을 잘라서 반환. 8️⃣ increase(String path) private String increase(String path) { String lastChunk = path.substring(path.length() - DEPTH_CHUNK_SIZE); if (isChunkOverflowed(lastChunk)) { throw new IllegalStateException("chunk overflowed"); } int charsetLength = CHARSET.length(); int value = 0; for (char character : lastChunk.toCharArray()) { value = value * charsetLength + CHARSET.indexOf(character); } value = value + 1; String result = ""; for (int i = 0; i < DEPTH_CHUNK_SIZE; i++) { result = CHARSET.charAt(value % charsetLength) + result; value /= charsetLength; } return path.substring(0, path.length() - DEPTH_CHUNK_SIZE) + result; } ✅ 역할: 댓글 path를 증가시켜 새로운 자식 댓글 생성 ✅ 사용 시기: 새로운 대댓글을 추가할 때 📝 동작 방식: 📌 입력(path) path는 댓글의 계층 구조를 나타내는 문자열. 예를 들어 “0000000000”(2번째 깊이의 댓글)이라는 주어졌다고 가정. 📌 1단계: 마지막 5자리(Chunk) 추출 String lastChunk = path.substring(path.length() - DEPTH_CHUNK_SIZE); path에서 마지막 DEPTH_CHUNK_SIZE(=5)만큼의 문자열을 잘라낸다. 예제: path = "0000000000"; lastChunk = path.substring(5); // "00000" 📌 2단계: lastChunk 값이 최대값인지 확인 if (isChunkOverflowed(lastChunk)) { throw new IllegalStateException("chunk overflowed"); } isChunkOverflowed(lastChunk) 메서드를 호출하여 lastChunk가 “zzzzz”(최대값)인지 확인. 만약 “zzzzz”라면 더 이상 증가할 수 없으므로 예외를 던진다. 📌 3단계: lastChunk 값을 10진수로 변환 int charsetLength = CHARSET.length(); int value = 0; for (char character : lastChunk.toCharArray()) { value = value * charsetLength + CHARSET.indexOf(character); } CHARSET은 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz (총 62개 문자). lastChunk(현재 5자리 문자열)를 62진수에서 10진수로 변환한다. 📝 예제 1: “00000” 변환 CHARSET.index(‘0’) = 0 변환 과정: value = (0 * 62) + 0 = 0 value = (0 * 62) + 0 = 0 value = (0 * 62) + 0 = 0 value = (0 * 62) + 0 = 0 value = (0 * 62) + 0 = 0 📝 예제 2: “0000z” 변환 ‘z’의 인덱스는 61 value = (0 * 62) + 0 = 0 value = (0 * 62) + 0 = 0 value = (0 * 62) + 0 = 0 value = (0 * 62) + 0 = 0 value = (0 * 62) + 61 = 61 📌 4단계: 값 증가 (+ 1 연산) value = value + 1; 10진수의 값이 1증가한다. 📝 예제 1: “00000” ➞ “00001” value = 0 + 1 = 1 📝 예제 2: “0000z” ➞ “00010” value = 61 + 1 = 62 📌 5단계: 다시 62진수 문자열로 변환 String result = ""; for (int i = 0; i < DEPTH_CHUNK_SIZE; i++) { result = CHARSET.charAt(value % charsetLength) + result; value /= charsetLength; } 증가된 10진수 값을 다시 62진수 문자열로 변환한다. 📝 예제 1: value = 1 value % 62 = 1 ➞ ‘1’ value /= 62 = - 결과: “00001” 📝 예제 2: value = 62 value % 62 = 0 ➞ ‘0’ value /= 62 = 1 value % 62 = 1 ➞ ‘1’ 최종 결과: “00010” 📌 6단계: 기존 path에서 마지막 chunk를 새로운 값으로 교체 return path.substring(0, path.length() - DEPTH_CHUNK_SIZE) + result; 원래 path의 마지막 5자리를 새로운 값으로 변경한 문자열을 반환. 📝 예제 path = "0000000000"; result = "00001"; 최종 반환값: "0000000001" 📌 전체 동작 예제 📌 예제 1 increase("0000000000"); // 기존 댓글의 path “0000000000”에서 마지막 5자리 “00000”을 추출. “00000” → 10진수 변환 ➞ 0 0 + 1 = 1 1 ➞ 62진수 변환 ➞ “00001” “0000000000” ➞ “0000000001”로 변환 ✅ 최종 결과 : “0000000001” 📌 예제 2 increase("000000000z"); // 기존 댓글의 path “000000000z”에서 마지막 5자리 “0000z”을 추출. “0000z” → 10진수 변환 ➞ 61 61 + 1 = 62 62 ➞ 62진수 변환 ➞ “00010” “000000000z” ➞ “0000000010”로 변환 ✅ 최종 결과 : “0000000010”
Backend Development
· 2025-03-05
📚[Backend Development] findChildrenTopPath(String descendantsTopPath) 메서드의 사용 방법, 사용 시기, 동작 방법
“📚[Backend Development] findChildrenTopPath(String descendantsTopPath) 메서드의 사용 방법, 사용 시기, 동작 방법” ✅1️⃣ findChildrenTopPath() 메서드의 역할 findChildrenTopPath() 메서드는 주어진 descendantsTopPath(자손 댓글의 경로)에서 현재 댓글의 직계 자식 댓글의 path를 추출하는 메서드입니다. 즉, descendantsTopPath가 현재 댓글의 여러 자식 댓글 중 하나일 때, 현재 댓글의 자식들 중 최상위 댓글의 path를 가져오는 역할을 합니다. ✅2️⃣ findChildrenTopPath() 메서드의 동작 방식 private String findChildrenTopPath(String descendantsTopPath) { return descendantsTopPath.substring(0, (getDepth() + 1) * DEPTH_CHUNK_SIZE); } 이 메서드는 다음과 같은 방식으로 동작합니다. descendantsTopPath.substring(0, (getDepth() + 1) * DEPTH_CHUNK_SIZE) descendantsTopPath(자손 댓글의 경로)에서 현재 댓글의 바로 다음 깊이(자식 댓글)의 path 부분만 가져옵니다. getDepth()는 현재 댓글의 깊이를 계산하는 메서드로, path.length() / DEPTH_CHUNK_SIZE를 반환합니다. (getDepth() + 1) * DEPTH_CHUNK_SIZE를 통해 현재 댓글보다 한 단계 더 깊은 위치까지의 문자열을 추출합니다. ✅3️⃣ findChildrenTopPath() 메서드의 사용 예시 📝 예제 1: 기본적인 부모-자식 관계에서 사용. CommentPath parent = CommentPath.create("00000"); // 부모 댓글 CommentPath child = CommentPath.creat("0000000000"); // 자식 댓글 CommentPath grandChild = CommentPath.create("000000000000000"); // 손자 댓글 String childrenTopPath = parent.findChildrenTopPath(grandChild.getPath()); System.out.println(childrenTopPath); // 출력: "0000000000" ▶️ 실행 과정. parent.getPath() ➞ “00000” (부모 댓글) grandChild.getPath() ➞ “000000000000000” (손자 댓글) findChildrenTopPath(grandChild.getPath()) 실행: parent.getDepth() = 1 (getDepth() + 1) * DEPTH_CHUNK_SIZE = (1 + 1) * 5 = 10 “000000000000000”.substring(0, 10) ➞ “0000000000” (부모의 직계 자식 댓글 경로 반환) 즉, 손자 댓글 path를 입력받아 현재 댓글의 첫 번째 자식의 path를 반환합니다. ✅4️⃣ findChildrenTopPath() 메서드 사용 시기 1️⃣ 새로운 대댓글을 생성할 때(createChildCommentPath()에서 사용) public CommentPath createChildCommentPath(String descentsTopPath) { if (descentsTopPath == null) { return CommentPath.creat(path + MIN_CHUNK); } String childrenTopPath = findChildrenTopPath(descendantsTopPath); return CommentPath.creat(increase(childrenTopPath)); } descendantsTopPath가 존재하면 findChildrenTopPath()를 사용하여 현재 댓글의 첫 번째 자식 댓글의 path를 가져옴. 이후 increase()를 호출하여 새 댓글의 path를 하나 증가시켜 새로운 자식 댓글을 생성함 2️⃣ 계층형 댓글 조회 시 부모-자식 관계를 파악할 떄 부모 댓글과 자식 댓글의 관계를 파악하여 트리 구조를 구성할 때 사용될 수 있음. 예를 들어, 특정 댓글이 descentdantsTopPath(자손 댓글의 path)를 가질 때, 부모 댓글의 직계 자식이 무엇인지 판단하는 데 활용될 수 있음. ✅5️⃣ findChildrenTopPath() 메서드 실행 예제 CommentPath parent = CommentPath.create("00000"); // 부모 댓글 CommentPath child = CommentPath.create("0000000000"); // 자식 댓글 CommentPath grandChild = CommentPath.create("000000000000000"); // 손자 댓글 String childPath = parent.findChildrenTopPath(grandChild.getPath()); System.out.println("부모 댓글의 첫 번째 자식 path: " + childPath); 📝 출력 부모 댓글의 첫 번째 자식 path: 0000000000 ✅6️⃣ 정리. 역할 : descendantsTopPath(자손 댓글의 경로)에서 현재 댓글의 직계 자식 댓글의 path를 추출. 동작 방식 : descendantsTopPath.substring(0, (getDepth() + 1) * DEPTH_CHUNK_SIZE)을 사용하여 특정 위치까지의 문자열을 반환. 사용 시기 새로운 대댓글 생성시 (createChildCommentPath() 내부에서 사용됨) 계층형 댓글을 조회할 때 부모-자식 관계 파악 주의할 점 descendantsTopPath가 null이면 substring()에서 NullPointerException이 발생할 수 있음. 댓글이 너무 깊어지면 MAX_DEPTH를 초과할 수 있음. 즉, findChildrenTopPath()는 현재 댓글의 자식 중 첫 번째 댓글의 path를 가져오는 역할을 하며, 새로운 대댓글을 생성할 때 매우 중요한 역할을 합니다.🚀
Backend Development
· 2025-03-03
📚[Backend Development] CommentPath 및 하위 댓글 생성 원리
“📚[Backend Development] CommentPath 및 하위 댓글 생성 원리” 📝 Intro 댓글 시스템에서 각 댓글의 경로(path)를 관리하는 방식은 계층적 구조를 유지하면서도 빠르게 검색할 수 있도록 설계되어야 합니다. 본 글에서는 CommentPath 클래스를 분석하고, 하위 댓글이 생성되는 과정과 관련된 로직을 설명합니다. 📌 CommentPath 클래스. package kobe.board.comment.entity; import jakarta.persistence.Embeddable; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.ToString; @Getter @ToString @Embeddable @NoArgsConstructor(access = AccessLevel.PROTECTED) public class CommentPath { private String path; private static final String CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; private static final int DEPTH_CHUNK_SIZE = 5; private static final int MAX_DEPTH = 5; // MIN_CHUNK = "00000", MAX_CHUNK = "zzzzz" private static final String MIN_CHUNK = String.valueOf(CHARSET.charAt(0)).repeat(DEPTH_CHUNK_SIZE); private static final String MAX_CHUNK = String.valueOf(CHARSET.charAt(CHARSET.length() - 1)).repeat(DEPTH_CHUNK_SIZE); public static CommentPath create(String path) { if (isDepthOverflowed(path)) { throw new IllegalStateException("depth overflowed"); } CommentPath commentPath = new CommentPath(); commentPath.path = path; return commentPath; } private static boolean isDepthOverflowed(String path) { return calculateDepth(path) > MAX_DEPTH; } private static int calculateDepth(String path) { // 25개의 문자열 / 5 = 5depth return path.length() / DEPTH_CHUNK_SIZE; } // CommentPath 클래스의 path의 depth를 구하는 매서드 public int getDepth() { return calculateDepth(path); } // root인지 확인하는 매서드 public boolean isRoot() { // 현재의 depth가 1인지 확인해주면 됨 return calculateDepth(path) == 1; } // 현재 path의 parentPath를 반환해주는 매서드 public String getParentPath() { // 끝 5자리만 잘라내면 됨 return path.substring(0, path.length() - DEPTH_CHUNK_SIZE); } // 현재 path의 하위 댓글의 path을 만드는 매서드 public CommentPath createChildCommentPath(String descendantsTopPath) { if (descendantsTopPath == null) { return CommentPath.create(path + MIN_CHUNK); } String childrenTopPath = findChildrenTopPath(descendantsTopPath); return CommentPath.create(increase(childrenTopPath)); } // 00a0z0000200000 <- descendantsTopPath private String findChildrenTopPath(String descendantsTopPath) { return descendantsTopPath.substring(0, (getDepth() + 1) * DEPTH_CHUNK_SIZE); } private String increase(String path) { // path에서 가장 마지막 5자리를 자른 것 String lastChunk = path.substring(path.length() - DEPTH_CHUNK_SIZE); if (isChunkOverflowed(lastChunk)) { throw new IllegalStateException("chunk overflowed"); } // Character set의 길이 int charsetLength = CHARSET.length(); // lastChunk를 10진수로 먼저 변환하기 위한 값을 저장 int value = 0; for (char character : lastChunk.toCharArray()) { value = value * charsetLength + CHARSET.indexOf(character); } value = value + 1; String result = ""; for (int i = 0; i < DEPTH_CHUNK_SIZE; i++) { result = CHARSET.charAt(value % charsetLength) + result; value /= charsetLength; } return path.substring(0, path.length() - DEPTH_CHUNK_SIZE) + result; } private boolean isChunkOverflowed(String lastChunk) { return MAX_CHUNK.equals(lastChunk); } } ✅1️⃣ CommentPath 클래스 Intro CommentPath 클래스는 댓글의 고유한 경로(path)를 관리하는 역할을 합니다. 경로는 문자열로 표현되며, 각 댓글의 depth를 유지하면서 하위 댓글을 쉽게 찾을 수 있도록 구성됩니다. 📌1️⃣ 주요 상수 및 변수. CHARSET : 0-9, A-Z, a-z로 구성된 총 62개의 문자 셋 DEPTH_CHUNK_SIZE = 5 : 댓글 깊이를 나타내는 단위 크기 MAX_DEPTH = 5 : 댓글의 최대 깊이 MIN_CHUNK = “00000” : 경로에서 사용될 최소 단위 문자열 MAX_CHUNK = “zzzzz” : 경로에서 사용될 최대 단위 문자열 ✅2️⃣ CommentPath.create(String path) 메서드 동작 과정 이 메서드는 새로운 CommentPath 객체를 생성하는 정적 팩토리 메서드입니다. 📌1️⃣ 동작 과정. 1. isDepthOverflowed(path)를 호출하여 path의 깊이가 MAX_DEPTH = 5를 초과하는지 확인합니다. calculateDepth(path) > MAX_DEPTH이면 IllegalStateException을 던집니다. 2. CommentPath 객체를 생성합니다. 3. 객체의 path 필드를 path로 설정합니다. 4. 새로 생성된 CommentPath 객체를 반환합니다. ✅3️⃣ CommentPath.createChildCommentPath(String descendantsTopPath) 메서드 동작 과정 이 메서드는 주어진 descendantsTopPath를 기반으로 새로운 하위 댓글의 path를 생성하는 역할을 합니다. 📌1️⃣ 동작 과정. 1. descedantsTopPath가 null이면 path + MIN_CHUNK(“00000”)를 추가하여 CommentPath를 생성하고 반환합니다. 2. findChildrenTopPath(descendantsTopPath)를 호출하여 childrenTopPath를 찾습니다. descendantsTopPath에서 depth + 1 길이만큼 문자열을 자릅니다. 3. increase(childrenTopPath)를 호출하여 이전 하위 댓글 path 값에서 1을 증가시킵니다. 4. 증가된 childrenTopPath를 기반으로 새로운 CommentPath 객체를 생성하고 반환합니다. ✅4️⃣ “00000”이 생성되려면 어떤 과정을 거쳐야 할까? 1. createChildCommentPath(null)이 호출됩니다. 2. descendantsTopPath가 null이므로 path + MIN_CHUNK가 CommentPath.creat()에 전달됩니다. 3. CommentPath.create() 내부에서 isDepthOverflowed 검사를 통과하면 새로운 CommentPath 객체가 생성 됩니다. 4. path 값은 부모 path + “00000”이 됩니다. ✅5️⃣ “0000000000”이 생성되려면 어떤 과정을 거쳐야 할까? 1. createdChildCommentPath(null)이 두 번 호출되어야 합니다. 2. 첫 번째 호출: descendantsTopPath = null path + “00000”을 CommentPath.create()로 전달하여 “00000”이 생성됨. 3. 두 번째 호출: descendantsTopPath = “00000” “00000”의 하위 댓글을 만들 때 path + “00000”을 추가하여 “0000000000”을 생성. 즉, 2단계 하위 댓글 생성 과정이 필요합니다. ✅6️⃣ 특정 descendantsTopPath가 주어졌을 때 childrenTopPath가 어떻게 변할까요? 📌 예시: descendantsTopPath = “00000”, childrenTopPath = “00001” 1. createChildCommentPath(“00000”)가 호출됨. 2. findChildrenTopPath(“00000”) 호출: descendantsTopPath = “00000” getDepth() + 1 = 2 → depth 2 까지의 5자리(00000)를 가져옴. childrenTopPath = “00000” 3. increase(“00000”) 실행: 62진수 연산으로 “00000” → “00001”로 변환. 4. “00001”이 path로 설정된 CommentPath 객체가 생성됨. 즉, increase(“00000”) 연산을 통해 “00001”이 생성됩니다. ✅7️⃣ 특정 상황에서 새로운 댓글의 path를 생성하는 과정 📌 예시: descendantsTopPath = “0000z”, 하위 댓글이 “abcdz” > “zzzzz” > “zzzzz” 일 때, “abcdz”의 sibling 댓글 생성 1. 현재 descendantsTopPath는 “0000z”이므로 이 댓글이 속한 하위 댓글들이 존재함. 2. createChildCommentPath(“zzzzz”) 실행 → 가장 큰 하위 path를 기준으로 새로운 path를 생성해야 함. 3. findChildCommentTopPaht(“zzzzz”) 실행: “zzzzz”의 depth + 1 길이까지 자름 → “zzzzz” 4. increase(“zzzzz”) 실행: “zzzzz”에서 증가된 값 “aaaaa”가 나옴 하지만 “abcdz” 와 같은 depth의 새로운 댓글을 만들려면 “abcdz”의 sibling 댓글이 되어야 함. 5. increase(“abcdz”) 실행: “abcdz”에서 증가된 값 “abce0”가 생성됨. 📌 결론 : 최종적으로 생성될 “abce0”의 “path”는 부모 “path” + “abce0” 즉, path = “0000zabce0”이 됩니다. ✅8️⃣ 결론 CommentPath를 이용하면 계층적 댓글 시스템을 효과적으로 구현할 수 있습니다. 하위 댓글의 path를 62진수 문자열 증가 방식으로 관리하여, 빠른 정렬 및 검색이 가능합니다. increase 연산을 통해 하위 댓글을 동적으로 생성하며, 경로 오버플로우를 방지할 수 있습니다. 이러한 방식은 트리 구조를 효율적으로 저장하고 조회하는 방법으로, 대규모 댓글 시스템에서도 유용하게 적용될 수 있습니다.
Backend Development
· 2025-03-01
📚[Backend Development] Path 구조의 이해.
“📚[Backend Development] Path 구조의 이해.” ✅1️⃣ “00a0z 00002”의 하위 댓글은 무엇일까요? “00a0z 00002”의 하위 댓글은 “00a0z 00002 00000” 입니다. 즉, “00a0z 00003”이 아니라 “00a0z 00002 00000”이 하위 댓글입니다. ✅2️⃣ 하위 댓글이 “00a0z 00002 00000”인 이유? 📌1️⃣ Path 구조 이해. CommentPath에서 댓글의 path는 부모 댓글의 path + 하위 댓글의 5자리 문자열로 구성됩니다. 각 댓글의 path는 DEPTH_CHUNK_SIZE = 5 기준으로 5자리씩 증가합니다. depth가 깊어질수록 path 문자열 길이가 길어집니다. 📝 예시: 부모 댓글: 00a0z -> 첫 번째 하위 댓글: 00a0z00000 -> 두 번째 하위 댓글: 00a0z0000000000 즉, 하위 댓글의 path는 부모 댓글 path를 포함하며, 5자리씩 추가됩니다. 📌2️⃣ descendantsTopPath vs childrenTopPath 1️⃣ childrenTopPath (현재 댓글의 직접적인 하위) String childrenTopPath = findChildrenTopPath(descendantsTopPath); private String findChildrenTopPath(String descendantsTopPath) { return descendantsTopPath.substring(0, (getDepth() + 1) * DEPTH_CHUNK_SIZE); } descendantsTopPath = “00a0z0000200000” childrenTopPath = “00a0z00002” (5자리씩 자름) 2️⃣ descendantsTopPath (현재 댓글의 모든 자손 중 가장 큰 path) descendantsTopPath는 현재 댓글이 포함된 전체 하위 트리에서 가장 마지막 댓글의 path입니다. 즉, 00a0z00002의 모든 자손 댓글 중 가장 마지막 댓글이 00a0z0000200000 입니다. 📌 결론 00a0z00002의 직접적인 하위 댓글은 00a0z0000200000 입니다. 00a0z00003은 00a0z00002와 같은 depth에서 생성될 새로운 sibling 댓글일 뿐, 00a0z00002의 하위 댓글이 아닙니다. 🚀 정리. ✅ 00a0z00002의 하위 댓글은 00a0z0000200000이다. ✅ 댓글 구조에서 부모 댓글의 path + 5자리 문자열이 하위 댓글의 path가 된다. ✅ descendantsTopPath를 활용해 모든 자손 중 가장 마지막 댓글을 찾고, 이를 기반으로 새로운 댓글의 path를 결정한다. ✅ “00a0z00003”은 같은 depth의 sibling(형제 댓글)이지, 하위 댓글이 아니다.
Backend Development
· 2025-02-25
📚[Backend Development] findChildTopPath 메서드의 실행 결과와 동작 방식.
“📚[Backend Development] findChildTopPath 메서드의 실행 결과와 동작 방식.” ✅1️⃣ 예시 코드. package kobe.board.comment.entity; import jakarta.persistence.Embeddable; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.ToString; @Getter @ToString @Embeddable @NoArgsConstructor(access = AccessLevel.PROTECTED) public class CommentPath { private String path; private static final String CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; private static final int DEPTH_CHUNK_SIZE = 5; private static final int MAX_DEPTH = 5; // MIN_CHUNK = "00000", MAX_CHUNK = "zzzzz" private static final String MIN_CHUNK = String.valueOf(CHARSET.charAt(0)).repeat(DEPTH_CHUNK_SIZE); private static final String MAX_CHUNK = String.valueOf(CHARSET.charAt(CHARSET.length() - 1)).repeat(DEPTH_CHUNK_SIZE); public static CommentPath create(String path) { if (isDepthOverflowed(path)) { throw new IllegalStateException("depth overflowed"); } CommentPath commentPath = new CommentPath(); commentPath.path = path; return commentPath; } private static boolean isDepthOverflowed(String path) { return calculateDepth(path) > MAX_DEPTH; } private static int calculateDepth(String path) { // 25개의 문자열 / 5 = 5depth return path.length() / DEPTH_CHUNK_SIZE; } // CommentPath 클래스의 path의 depth를 구하는 매서드 public int getDepth() { return calculateDepth(path); } // root인지 확인하는 매서드 public boolean isRoot() { // 현재의 depth가 1인지 확인해주면 됨 return calculateDepth(path) == 1; } // 현재 path의 parentPath를 반환해주는 매서드 public String getParentPath() { // 끝 5자리만 잘라내면 됨 return path.substring(0, path.length() - DEPTH_CHUNK_SIZE); } // 현재 path의 하위 댓글의 path을 만드는 매서드 public CommentPath createChildCommentPath(String descendantsTopPath) { if (descendantsTopPath == null) { return CommentPath.create(path + MIN_CHUNK); } String childrenTopPath = findChildrenTopPath(descendantsTopPath); return CommentPath.create(increase(childrenTopPath)); } // 00a0z 00002 00000 <- descendantsTopPath private String findChildrenTopPath(String descendantsTopPath) { return descendantsTopPath.substring(0, (getDepth() + 1) * DEPTH_CHUNK_SIZE); } } ✅2️⃣ findChildrenTopPath(String descendantsTopPath) 실행 결과. descendantsTopPath에 “00a0z0000200000” 값이 들어간다고 가정한다. findChildrenTopPath 메서드는 현재 객체의 depth를 기반으로 descendantsTopPath의 특정 길이만큼 잘라낸 값을 반환합니다. 현재 path의 depth는 getDepth() 메서드를 통해 계산됩니다. getDepth()는 현재 path의 길이를 DEPTH_CHUNK_SIZE(5)로 나누어 구합니다. 📌 예제 실행. 예를 들어, CommentPath 객체가 path = “00a0z”라면: getDepth() = 1(문자열 길이 5/5) (getDepth() + 1) * DEPTH_CHUNK_SIZE = (1 + 1) * 5 = 10 descendantsTopPath.substring(0, 10) 📌 결과값: "00a0z00002" ✅3️⃣ findChildrenTopPath 메서드의 동작 방식 private String findChildrenTopPath(String descendantsTopPath) { return descendantsTopPath.substring(0, (getDepth() + 1) * DEPTH_CHUNK_SIZE); } 📌 단계별 동작. 📌1️⃣ 현재 객체의 depth를 구함. getDepth()를 호출하여 현재 path가 몇 단계인지 계산. getDepth()는 path.length() / DEPTH_CHUNK_SIZE로 계산됨. 📌2️⃣ (getDepth() + 1) * DEPTH_CHUNK_SIZE 값 계산. 현재 depth에서 하위 댓글의 depth를 포함한 길이를 계산. 즉, 다음 depth까지 포함한 descendantsTopPath의 일부만 가져오도록 설정, 📌3️⃣ descendantsTopPath의 일부를 잘라 반환. descendantsTopPath.substring(0, (getDepth() + 1) * DEPTH_CHUNK_SIZE); descendantsTopPath에서 앞 부분을 가져와 childrenTopPath를 생성. 🚀 결론 findChildrenTopPath(“00a0z0000200000”)의 실행 결과는 “00a0z00002” 이 메서드는 descendantsTopPath에서 현재 depth 기준으로 한 단계만 더 포함한 경로를 잘라 반환. 결국 현재 객체의 하위 댓글들이 공통적으로 가지는 prefix를 찾아주는 역할을 한다.
Backend Development
· 2025-02-25
📚[Backend Development] 무한 Depth 댓글 조회 - Path Enumeration & 인덱스 최적화
“📚[Backend Development] 무한 Depth 댓글 조회 - Path Enumeration & 인덱스 최적화” ✅1️⃣ Path Enumeration 방식에서 댓글 path 결정 Path Enumeration(경로 열거) 방식은 각 댓글의 path를 계층적으로 저장하여 정렬 및 검색을 빠르게 수행하는 기법입니다. 댓글이 추가될 때 부모 댓글의 path를 상속받고, 하위 댓글 중 가장 큰 path를 기준으로 새로운 path가 결정됩니다. 📌1️⃣ 현재 댓글 트리 구조 현재 댓글 트리의 path는 다음과 같습니다. 00a0z 댓글 아래에 계층적으로 정렬된 하위 댓글들이 있습니다. 댓글의 path는 부모 댓글의 path를 상속받아 00000, 00001, 00002 등으로 추가됩니다. 📌2️⃣ 새로운 댓글 추가 요청 사용자가 00a0z 댓글의 하위에 새로운 댓글을 추가하려고 합니다. 새로운 댓글이 추가될 path를 결정해야 합니다. 기존 댓글 중 가장 큰 path를 찾아 이를 기준으로 +1을 적용하여 새로운 path를 생성합니다. 📌3️⃣ childrenTopPath 찾기 새로운 댓글을 추가할 때 현재 존재하는 하위 댓글 중 가장 큰 path(childrenTopPath)를 찾고 해당 값에 +1을 하여 새로운 댓글의 path를 생성합니다. 현재 00a0z의 하위 댓글 중 가장 큰 path는 00a0z 00002입니다. 따라서, 새로운 댓글의 path는 00a0z 00003이 됩니다. 📌4️⃣ descendantsTopPath를 고려한 최종 path 결정 하지만 자식 댓글이 존재하는 경우, 단순히 childrenTopPath만 고려하면 안 됩니다. 가장 깊은 depth까지 고려한 descendantsTopPath를 찾아야 합니다. descendantsTopPath는 부모 댓글을 포함한 모든 자식 댓글 중 가장 큰 path입니다. childrenTopPath = 00a0z 00002이므로, 새로운 댓글의 path는 00a0z 00003이 됩니다. ✅2️⃣ MySQL에서 descendantsTopPath 찾기. Path Enumeration 방식에서는 빠른 검색을 위해 인덱스를 활용할 수 있습니다. 특히 descendantsTopPath를 찾을 때 Backward Index Scan을 사용하면 성능을 최적화할 수 있습니다. 📌1️⃣ descendantsTopPath를 찾는 SQL SELECT path FROM comment_v2 WHERE article_id = {article_id} AND path > {parentPath} -- 부모 댓글 제외 AND path LIKE {parentPath}% -- 부모 댓글 prefix를 포함하는 모든 자식 댓글 조회 ORDER BY path DESC LIMIT 1; -- 가장 큰 path를 찾기 위해 내림차순 정렬 가장 큰 path(descendantsTopPath)를 찾을 때 내림차순 정렬을 활용합니다. LIMIT 1을 사용하여 불필요한 데이터 조회를 줄이고 성능을 최적화합니다. 📌2️⃣ Backward Index Scan 활용 MySQL에서는 ORDER BY path DESC를 사용할 때 역순으로 인덱스를 탐색하는 Backward Index Scan을 수행합니다. path 필드에 오름차순(ASC) 인덱스가 설정되어 있어도, 내림차순(DESC) 정렬을 통해 가장 큰 path를 빠르게 찾을 수 있습니다. 인덱스 트리(Leaf Node) 간의 양방향 포인터를 활용하여 역순 검색을 수행합니다. ✅3️⃣ MySQL Query Plan 분석(EXPLAIN) MySQL에서 descendantsTopPath를 찾는 쿼리의 실행 계획을 분석해봅니다. EXPLAIN SELECT path FROM comment_v2 WHERE article_id = 1 AND path > '00a0z' AND path LIKE '00a0z%' ORDER BY path DESC LIMIT 1; 📌EXPLAIN 결과 분석 1.idx_article_id_path 인덱스 사용됨 ➞ 인덱스를 활용하여 빠르게 path를 조회할 수 있음 2. Backward Index Scan 적용됨 ➞ ORDER BY path DESC LIMIT 1을 통해 역순 탐색 수행 3. Using Index 적용됨 ➞ 인덱스에서 직접 데이터를 가져오기 때문에 성능 최적화 가능 ✅4️⃣ descendantsTopPath를 활용한 정렬 최적화 Path Enumeration 방식에서는 정렬 상태를 유지한 채 descendantsTopPath를 검색할 수 있습니다. path 정렬 상태를 유지하면서 역순으로 인덱스를 탐색하면, 가장 큰 path(descendantsTopPath)를 빠르게 찾을 수 있습니다. Backward Index Scan을 활용하면 로그 시간(log time) 내에 조회가 가능합니다. ✅5️⃣ 결론 🚀 Path Enumeration 방식에서 댓글을 추가할 때, path를 결정하는 과정. ✅ 가장 큰 childrenTopPath를 찾고, +1을 하여 새로운 path를 생성 ✅ descendantsTopPath를 찾아 계층 구조를 유지하면서 댓글을 정렬 ✅ MySQL의 Backward Index Scan을 활용하여 빠르게 descendantsTopPath를 검색 ✅ 대규모 데이터에서도 인덱스를 활용하여 빠른 성능을 유지 📌 Path Enumeration 방식을 사용할 때는, Backward Index Scan을 활용하여 최적의 성능을 보장하는 것이 중요.
Backend Development
· 2025-02-24
📚[Backend Development] 무한 Depth 댓글 조회 - 문자열 기반 경로 관리, 덧셈 연산, 예외 처리
“📚[Backend Development] 무한 Depth 댓글 조회 - 문자열 기반 경로 관리, 덧셈 연산, 예외 처리” ✅1️⃣ 문자열 기반 댓글 경로(Path) 관리 Path Enumeration 방식에서 숫자가 아닌 문자열을 기반으로 댓글의 경로를 관리해야 합니다. 📌 Path 문자열 연산의 핵심. 댓글 경로를 문자열로 관리하기 때문에, 덧셈 연산을 수행할 때 숫자가 아닌 문자열 기반 연산이 필요합니다. 대소문자 및 숫자 간의 관계(0~9 < A~Z < a~z)를 이해하고, 문자열을 증가시키는 로직이 필요합니다. 0~9 < A~Z < a~z 이러한 정렬 규칙을 이해하면, 댓글의 경로(Path)를 증가시키는 연산을 코드로 구현할 수 있습니다. ✅2️⃣ “00000”부터 “zzzzz”까지 증가하는 문자열 연산 Path 값은 “00000”부터 시작하여 증가하는 방식으로 관리됩니다. 📌 문자열 기반 정렬 방식. “00000” → “00001” → … → “AAAA9” → “AAAAA” → … → “zzzzz”로 증가 문자열의 대소문자 순서를 활용하여 정렬 (0-9, A-Z, a-z) 댓글이 추가될 때마다 이전 댓글의 경로에 1을 더하여 새로운 경로 생성 ✅3️⃣ 문자열 기반 덧셈(증가) 연산. Path 값이 문자열로 관리되므로, 숫자가 아니라 문자열 덧셈을 수행하는 알고리즘이 필요합니다. 📌 문자열 덧셈 방식 오른쪽 문자부터 1씩 증가 carry(올림수)가 있으면 다음 문자도 증가 “zzzzz”에 도달하면 Overflow 발생 📝 예제: a39zz + 1 ------ a3A00 z를 넘어가면 0으로 초기화되고, 앞자리 숫자가 증가함. carry를 반영하여 재귀적으로 처리. ✅4️⃣ 숫자 기반 덧셈 방식 문자열 연산이 복잡할 수 있으므로, 62진수 변환 후 숫자로 연산하는 방법도 고려할 수 있습니다. 📌 숫자로 변환 후 연산하는 방법. 62진수 문자열을 10진수 숫자로 변환 숫자로 덧셈 수행 다시 62진수 문자열로 변환 62진수 ("00000" ~ "zzzzz") → 10진수 변환 → +1 연산 → 다시 62진수 변환 이 방법을 사용하면 문자열 연산보다 효율적인 방식으로 Path를 증가시킬 수 있습니다. ✅5️⃣ 예외 케이스 처리 - 최초 댓글 생성 Path를 관리할 때, 최조 댓글이 생성되는 경우를 처리해야 합니다. 📌 처리 방법. 부모 댓글(00a0z)의 하위 댓글이 없는 경우 첫 번째 하위 댓글을 생성해야 함 Path 값은 부모 Path + “00000”로 설정 📝 예제: 부모 Path: 00a0z 첫 번째 하위 댓글 Path: 00a0z 0000 ✅6️⃣ 댓글 경로가 zzzzz까지 도달한 경우. Path 값이 zzzzz까지 증가하면, 더 이상 새로운 Path를 생성할 수 없는 문제가 발생합니다. 📌 해결 방법. Path를 구성하는 문자 개수를 증가(5 → 6, 7 …) 더 넓은 범위의 Path 값을 허용하도록 개선 62^5 = 16,132,832 (기존) 62^6 = 998,001,488 (확장 가능) Path의 자리 수를 늘리면 더 많은 댓글을 저장하고 정렬 가능합니다. ✅7️⃣ 최종 정리 📌 무한 Depth 댓글 정렬을 위해 고려해야 할 사항 Path 값을 문자열로 관리해야 하므로, 문자열 기반 덧셈 연산이 필요 숫자로 변환하여 62진수 연산을 수행하는 방식도 가능 최초 댓글 생성 시 기본 Path(“00000”)을 추가 Path가 가득 찬 경우, 자리 수를 늘려 더 많은 경로를 저장 가능
Backend Development
· 2025-02-24
📚[Backend Development] 무한 Depth 댓글 정렬 구조의 'Path Enumeration(경로 열거) 방식'이란 무엇일까요?
“📚[Backend Development] 무한 Depth 댓글 정렬 구조의 ‘Path Enumeration(경로 열거) 방식’이란 무엇일까요?” ✅ 무한 Depth 댓글 정렬 구조의 “Path Enumeration(경로 열거) 방식” 설명. Path Enumeration(경로 열거) 방식은 트리 구조의 계층을 문자열 형태로 저장하여 정렬 및 검색을 효율적으로 수행하는 방식입니다. 이 방식은 트리의 부모-자식 관계를 유지하면서 빠르게 정렬 및 조회할 수 있도록 도와줍니다. 🏗️1️⃣ Path Enumeration(경로 열거) 방식이란? Path Enumeration(경로 열거) 방식에서는 각 댓글의 부모-자식 관계를 문자열 경로(path)로 저장합니다. 즉, 각 댓글이 트리 구조에서 어떤 위치에 있는지 경로를 미리 기록하여 정렬 및 검색을 최적화합니다. 🏗️2️⃣ 테이블 구조. Path Enumeration(경로 열거) 방식을 사용하면 다음과 같은 추가적인 path 컬럼이 필요합니다. 필드명 설명 comment_id 댓글의 고유 ID (PK) parent_comment_id 부모 댓글의 ID (최상위 댓글이면 NULL) article_id 해당 댓글이 속한 게시글 ID content 댓글 내용 created_at 댓글 작성 시간 path 댓글의 계층 구조를 나타내는 문자열 (예: “00001.00002.00005” 📌 path 필드는 각 댓글이 트리 구조에서 어디에 속하는지 나타냄 📌 이 값을 활용하면 부모-자식 관계를 정렬 및 조회하는 것이 쉬워짐 🏗️3️⃣ Path 값 저장 방식. path 값은 댓글이 트리에서 어떤 위치에 있는지를 나타냅니다. 각 comment_id를 5자리 문자열(00001, 00002 등)로 변환하여 부모-자식 관계를 저장합니다. 📌 Path 값 예시 comment_id parent_comment_id path 1 NULL 00001 2 1 00001.00002 3 NULL 00003 4 2 00001.00002.00004 5 4 00001.00002.00004.00005 6 NULL 00006 📌 각 댓글은 부모 path를 상속받고, 자신의 ID를 추가하여 path를 생성 📌 부모 댓글이 삭제되더라도 path를 통해 계층 구조를 쉽게 유지 가능 🏗️4️⃣ Path Enumeration(경로 열거)을 활용한 정렬. Path Enumeration(경로 열거)을 활용하면 경로 순서대로 정렬하면 댓글을 계층 구조 그대로 유지할 수 있습니다. SELECT * FROM comment WHERE article_id = ? ORDER BY path ASC; 📌 ORDER BY path ASC를 적용하면 트리 구조를 유지하면서 정렬됨 📌 일반적인 ORDER BY parent_comment_id, comment_id보다 트리 구조 정렬이 정확함 🏗️5️⃣ Path Enumeration(경로 열거) 방식으로 조회 📌 예제 데이터 comment_id parent_comment_id path 1 NULL 00001 2 1 00001.00002 3 NULL 00003 4 2 00001.00002.00004 5 4 000001.00002.00004.00005 6 NULL 00006 📌 정렬된 결과. SELECT * FROM comment WHERE article_id = ? ORDER BY path ASC; ✅ 출력 결과 1. 댓글1 ├── 댓글2 ├── 댓글4 ├── 댓글5 2. 댓글3 3. 댓글6 📌 계층 구조가 정확하게 유지되면서 정렬됨. 🏗️6️⃣ 특정 댓글의 하위 댓글 조회. Path Enumeration(경로 열거) 방식에서는 특정 댓글의 모든 하위 댓글을 손쉽게 조회할 수 있습니다. SELECT * FROM comment WHERE path LIKE '00001.00002%' ORDER BY path ASC; 📌 결과: 00001.00002로 시작하는 모든 하위 댓글을 조회 (댓글2, 댓글4, 댓글5 포함) 🏗️7️⃣ Path Enumeration 방식의 장점과 단점. ✅ 장점. 장점 설명 트리 구조 유지가 쉬움 ORDER BY path ASC만으로 계층 구조 정렬 가능 하위 댓글 조회가 빠름 LIKE ‘경로%’로 손쉽게 하위 댓글 조회 가능 부모 댓글 삭제 시 계층 구조 유지 가능 path를 통해 상위 댓글을 식별 가능 ❌ 단점. 단점 설명 댓글 이동 시 path 업데이트 필요 댓글을 다른 부모로 이동하면 path를 변경해야 함 path 길이 증가 가능성 댓글이 깊어질수록 path 길이가 길어질 수 있음 INSERT 성능 저하 가능성 새로운 댓글 추가 시 path를 계산해야 함 🏗️8️⃣ Path Enumeration(경로 열거) 방식에서 댓글 저장 규칙. 위 그림과 같이, 각 depth(계층)별로 5개의 문자열로 경로 정보를 저장합니다. 1 depth는 5자리 문자열, 2 depth는 10자리, 3 depth는 15자리 N depth는 (N * 5)자리로 표현됩니다. 각 댓글의 경로는 모든 상위 댓글에서 해당 댓글까지의 경로를 포함하도록 저장됩니다. 경로는 부모 경로를 상속하며, 독립적이면서 순차적인 형태를 유지합니다. 📌 이 방식은 댓글의 계층 구조를 명확하게 표현하고, 정렬 및 검색을 효율적으로 수행할 수 있도록 도와줍니다. 🏗️9️⃣ Path Enumeration 방식의 계층형 댓글 구조 예시 좌측 그림의 계층형 댓글 구조는, 우측 그림과 같은 경로 정보를 가질 수 있습니다. 각 경로는 부모 댓글의 경로를 상속받으며, 각 댓글마다 독립적이고 순차적인 경로(문자열 정렬 기준)가 생성됩니다. 📌 이 방식은 댓글을 계층적으로 정렬하고, 빠르게 검색할 수 있도록 도와줍니다. 🏗️1️⃣0️⃣ Path Enumeration 방식에서 경로 표현 범위 확장 방법 각 경로는 depth(계층)마다 5자리의 문자로 표현되므로, 사용할 수 있는 경로의 개수에는 제한이 있습니다. 만약 각 자릿수를 숫자 (0~9)로만 사용한다면, 한 depth당 10⁵ = 100,000개(00000 ~ 99999)의 경로만 표현할 수 있습니다. 하지만 문자열이기 때문에, 반드시 숫자(0~9)만 사용할 필요는 없습니다. 각 자릿수는 0~9(10개), A~Z(26개), a~z(26개) 총 62개의 문자를 사용할 수 있습니다. 문자열의 정렬 순서는 숫자(0~9) ➞ 대문자 알파벳(A~Z) ➞ 소문자 알파벳(a~z) 순서로 지정됩니다. 따라서, 경로는 00000부터 zzzzz까지 순차적으로 생성됩니다. 이 방식에서는 한 depth당 62⁵ = 16,132,832개의 경로를 표현할 수 있습니다. 📌 이러한 방식으로 경로 표현 범위를 확장하면, 더 많은 댓글을 저장할 수 있으며 트리 구조를 더욱 유연하게 유지할 수 있습니다. 🚀 정리. ✅ Path Enumeration(경로 열거) 방식은 댓글의 계층 구조를 문자열(path)로 저장하는 방식 ✅ ORDER BY path ASC를 사용하여 트리 구조를 유지하면서 정렬 가능 ✅ 하위 댓글 조회 시 LIKE ‘경로%’를 활용하여 빠르게 검색 가능 ✅ 부모 댓글이 삭제되더라도 계층 구조를 유지하는 데 유리 ✅ 댓글 이동이 빈번한 경우 path 업데이트가 필요하므로 조심해야 함 ✅ Path Enumeration 방식은 무한 Depth 댓글 정렬 및 조회 성능을 최적화할 수 있는 가장 효과적인 방법 중 하나입니다. ✅ 트리 구조를 유지하면서 ORDER BY path ASC만으로 정렬이 가능하여 성능이 우수합니다. ✅ 경로 길이가 길어지는 단점을 해결하기 위해 Base62와 같은 방식을 고려할 수도 있습니다. 📌 무한 Depth 댓글 정렬 및 조회 성능을 최적화할 수 있는 가장 효과적인 방법 중 하나입니다.
Backend Development
· 2025-02-21
📚[Backend Development] Path Enumeration 방식에서 댓글의 경로(Path) 결정 과정.
“📚[Backend Development] Path Enumeration 방식에서 댓글의 경로(Path) 결정 과정.” 📌1️⃣ 신규 댓글의 경로를 결정하는 과정 Path Enumeration(경로 열거) 방식을 사용할 때, 새로운 댓글이 추가될 경우 해당 댓글의 path를 어떻게 결정할 것인지가 중요합니다. 이 글에서는 이미 존재하는 계층형 댓글 트리에서 새로운 댓글이 추가될 때, path를 어떻게 생성하는지에 대해 설명합니다. 🏗️2️⃣ 기존 댓글 구조 확인 ✅ 기존 댓글 트리 초기 댓글 구조는 위와 같습니다. 최상위 댓글 00a0z 아래에 여러 개의 하위 댓글이 존재합니다. 각 댓글의 path 계층 구조를 따라 부모 댓글의 path를 상속받으며, 새로운 댓글이 추가될 때마다 숫자가 증가하는 방식으로 정렬됩니다. 가장 최근의 하위 댓글은 00a0z 00002이며, 00a0z 00002의 하위 댓글로 00a0z 00002 00000이 존재합니다. 🏗️3️⃣ 신규 댓글 추가 요청. ✅ 새로운 댓글 요청 어떤 사용자가 00a0z 댓글의 하위에 새로운 댓글을 작성하려고 합니다. 하지만, 현재 00a0z의 하위 댓글들은 이미 존재하고 있으므로, 새로운 댓글이 들어갈 올바른 path를 결정해야 합니다. 🏗️4️⃣ 현재 존재하는 하위 댓글 중 가장 큰 path 찾기 ✅ childrenTopPath 찾기 새로운 댓글을 추가할 때는, 현재 존재하는 하위 댓글 중 가장 큰 path(childrenTopPath)를 찾아서 그 값에 +1을 하여 새로운 댓글의 path를 생성합니다. 현재 00a0z의 하위 댓글 중에서 가장 큰 path는 00a0z 00002입니다. 따라서, 새로운 댓글의 path는 00a0z 00003이 됩니다. 🏗️5️⃣ 모든 자식 댓글을 고려한 descendantsTopPath 찾기 하지만, 단순히 childrenTopPath만 고려하면 안됩니다. 자식 댓글이 있는 경우, 가장 깊은 depth에 있는 자식 댓글까지 고려하여 path를 결정해야 합니다. descendantsTopPath는 부모 댓글을 포함한 모든 자식 댓글 중 가장 큰 path를 의미합니다. 즉, 00a0z의 모든 하위 댓글 중 가장 깊은 depth를 가지면서도 가장 큰 path를 찾습니다. 🏗️6️⃣ descendantsTopPath에서 신규 댓글의 depth에 맞는 childrenTopPath 계산 ✅ descendantsTopPath를 기반으로 path 생성 기존 댓글 중 가장 깊은 depth를 가지는 descendantsTopPath를 찾고, 신규 댓글이 들어갈 depth만큼의 path를 남기고 나머지는 잘라냅니다. descendantsTopPath = 00a0z 00002 00000 하지만 신규 댓글이 들어갈 depth는 2이므로, (depth * 5)만큼의 문자만 남깁니다. 결과적으로 childrenTopPath = 00a0z 00002가 됩니다. 🏗️7️⃣ 최종적으로 childrenTopPath를 찾아 신규 댓글의 path 생성 ✅ 최종 path 결정 1. parentPath를 가지는 모든 자식 댓글을 조회 2. 가장 큰 descendantsTopPath를 찾음 3. 신규 댓글이 들어갈 depth만큼 path를 남기고 자름 → childrenTopPath 생성 4. 마지막 숫자에 +1을 하여 최종 path 결정 📌 결과적으로, 새로운 댓글의 path는 00a0z 00003이 됩니다. 🚀8️⃣ 결론. ✅ Path Enumeration 방식을 사용하면, 댓글의 계층 구조를 명확하게 유지하면서도 정렬 및 조회를 빠르게 수행할 수 있습니다. ✅ 신규 댓글이 추가될 때는, 현재 존재하는 하위 댓글 중 가장 큰 path(descendantsTopPath)를 찾아서 새로운 path를 결정합니다. ✅ 이 방식은 무한 Depth 댓글에서도 정렬 순서를 유지하면서 빠르게 댓글을 추가할 수 있도록 도와줍니다.
Backend Development
· 2025-02-21
📚[Backend Development] 무한 depth 댓글 정렬 구조란 무엇일까요?
“📚[Backend Development] 무한 depth 댓글 정렬 구조란 무엇일까요?” ✅ 무한 Depth 댓글 정렬 구조 무한 depth 댓글을 페이징 처리하기 위해서는 트리 구조를 유지하면서 정렬하는 전략이 필요합니다. 보통 parent_comment_id + comment_id 정렬 방식을 사용하는 2-depth 댓글과는 달리, 무한 depth의 경우 댓글의 계층 구조를 유지할 수 있도록 정렬 방식이 개선되어야 합니다. 🚀1️⃣ 트리 구조 기반 정렬 방법 무한 depth의 댓글을 정렬하려면 다음과 같은 방식이 가능합니다. 1️⃣ 정렬 방식 root_comment_id (최상위 부모 ID) 오름차순 path (트리 순서) 오름차순 comment_id (작성 순서) 오름차순 이렇게 정렬하면 트리 구조를 유지하면서 댓글을 시간순으로 정렬할 수 있습니다. 🚀2️⃣ 트리 구조를 유지하는 정렬 필드 필드명 설명 comment_id 댓글의 고유 ID (기본 키) parent_comment_id 부모 댓글의 ID (최상위 댓글이면 NULL) root_comment_id 최상위 부모 댓글의 ID (최상위 댓글이면 자기 자신 comment_id) depth 댓글의 깊이 (0부터 시작) path 트리 구조를 나타내는 정렬용 문자열 🚀3️⃣ 정렬 순서 SQL 예제 📌 무한 Depth 정렬을 위한 ORDER BY SELECT * FROM comment WHERE article_id = ? ORDER BY root_comment_id ASC, path ASC, comment_id ASC LIMIT ?, ?; 📌 정렬 기준. 1. root_comment_id ASC ➞ 최상위 부모 댓글 기준으로 정렬 2. path ASC ➞ 트리 구조를 유지하면서 정렬 3. comment_id ASC ➞ 같은 depth 내에서 작성 순서대로 정렬 🚀4️⃣ path 필드란? 트리 구조를 표현하기 위해 path 필드를 활용할 수 있습니다. path는 부모-자식 관계를 명확하게 하여 정렬을 용이하게 합니다. 예를 들어, path는 다음과 같은 방식으로 저장될 수 있습니다. comment_id parent_comment_id root_comment_id depth path 1 NULL 1 0 00001 2 1 1 1 00001.00002 3 1 1 1 00001.00003 4 2 1 2 00001.00002.00004 5 4 1 3 00001.00002.00004.00005 📌 정렬 시 ORDER BY path ASC를 사용하면 계층 구조를 유지하면서 정렬 가능! 🚀5️⃣ 정리 ✅ 무한 depth 댓글 정렬을 위헤 path 또는 lft/rgt 방식이 필요 ✅ 정렬 순서는 root_comment_id ASC, path ASC, comment_id ASC 방식 사용 ✅ 페이징 처리 시 LIMIT ?,? 적용 가능 📌 기존 2-depth 방식처럼 parent_comment_id 정렬만으로는 무한 depth 정렬이 어려우므로 path를 활용하는 것이 가장 적절합니다.
Backend Development
· 2025-02-20
📚[Backend Development] 최대 2 Depth 댓글 정렬 구조의 '페이지 번호 방식'이란 무엇일까요?
“📚[Backend Development] 최대 2 Depth 댓글 정렬 구조의 ‘페이지 번호 방식’이란 무엇일까요?” ✅ 최대 2 Depth 댓글 정렬 구조의 “페이지 번호 방식” 설명. 최대 2 Depth 댓글을 페이지 번호 기반으로 조회하는 방식은 고정된 개수의 댓글을 불러오는 전통적인 페이징 방식입니다. 이를 통해 오래된 댓글부터 순서대로 불러올 수 있습니다. 🏗️1️⃣ 페이지 번호 방식이란? 페이지 번호 방식은 특정 페이지의 댓글을 불러오기 위해 OFFSET과 LIMIT을 활용하는 방식입니다. SELECT * FROM comment WHERE article_id = ? ORDER BY parent_comment_id ASC, comment_id ASC LIMIT ?, ?; SQL 키워드 설명 ORDER BY parent_comment_by ASC, comment_id ASC 댓글을 부모-자식 관계에 맞게 정렬 LIMIT ?, ? 몇 개의 데이터를 가져올지 지정 OFFSET 특정 페이지의 댓글을 건너뛴 후 가져옴 🏗️2️⃣ 정렬 방식 페이지 번호 방식에서는 댓글을 부모-자식 관계를 유지하면서 정렬해야 합니다. 정렬 기준은 다음과 같습니다. ORDER BY parent_comment_id ASC, comment_id ASC 최상위 댓글을 먼저 정렬 ➞ parent_comment_id IS NULL 순서대로 정렬 대댓글은 같은 부모 아래에서 정렬 ➞ comment_id ASC 순서로 정렬 페이징 처리 ➞ LIMIT ?, ? 사용 🏗️3️⃣ SQL 예제 (페이지 번호 기반 조회) 예를 들어, 한 페이지당 3개 댓글을 가져오도록 설정하고, 2번째 페이지(page = 2)를 조회한다고 가정해보겠습니다. SELECT * FROM comment WHERE article_id = ? ORDER BY parent_comment_id ASC, comment_id ASC LIMIT 3 OFFSET 3; 📌 페이지 번호 공식. OFFSET = (page - 1) * pageSize page = 1 ➞ OFFSET = (1-1) * 3 = 0 (첫 번째 페이지) page = 2 ➞ OFFSET = (2-1) * 3 = 3 (두 번째 페이지) page = 3 ➞ OFFSET = (3-1) * 3 = 6 (세 번째 페이지) 🏗️4️⃣ 데이터 예시 📌 데이터베이스에 저장된 댓글 데이터. comment_id parent_comment_id 내용 1 NULL 댓글1 (최상위 댓글) 2 1 댓글2 (댓글1의 대댓글) 3 NULL 댓글3 (최상위 댓글) 4 1 댓글4 (댓글1의 대댓글) 5 3 댓글5 (댓글3의 대댓글) 6 NULL 댓글6 (최상위 댓글) 📌 페이지 번호 기반 조회 결과 (한 페이지에 3개씩) ✅ 1페이지 조회 (page = 1, pageSize = 3) SELECT * FROM comment WHERE article_id = ? ORDER BY parent_comment_id ASC, comment_id ASC LIMIT 3 OFFSET 0; comment_id parent_comment_id 내용 1 NULL 댓글1 (최상위 댓글) 2 1 댓글2 (댓글1의 대댓글) 4 1 댓글4 (댓글1의 대댓글) ✅ 2페이지 조회 (page = 2, pageSize = 3) SELECT * FROM comment WHERE article_id = ? ORDER BY parent_comment_id ASC, comment_id ASC LIMIT 3 OFFSET 3; comment_id parent_comment_id 내용 3 NULL 댓글3 (최상위 댓글) 5 3 댓글5 (댓글3의 대댓글) 6 NULL 댓글6 (최상위 댓글) 📌 페이지를 넘길 때마다 다음 pageSize 만큼의 데이터를 가져옵니다. 🏗️5️⃣ 장점과 단점. 장점 단점 간단하고 직관적인 페이징 구현 가능 페이지 번호가 커질수록 OFFSET이 증가하여 성능 저하 댓글을 정렬된 순서대로 가져올 수 있음 대량의 데이터에서 OFFSET이 클 경우 속도가 느려질 수 있음 📌 대체 방법 OFFSET이 큰 경우 “Keyset Pagination (무한스크롤 방식)”을 사용하는 것이 더 효율적일 수 있음 ORDER BY parent_comment_id ASC, comment_id ASC 정렬을 유지하면서 WHERE comment_id > ? 방식을 활용하는 방식도 있음 🚀6️⃣ 정리. ✅ 페이지 번호 기반 댓글 조회는 LIMIT ?, OFFSET ?을 사용 ✅ ORDER BY parent_comment_id ASC, comment_id ASC를 사용해 계층 구조 유지 ✅ OFFSET 값이 클 경우 성능 저하 가능 ➞ Keyset Pagination 고려 가능 ✅ 최대 2 Depth 댓글 구조에서는 성능 이슈가 적고 직관적인 방식으로 구현 가능 📌 최대 2 Depth 댓글 구조에서는 페이지 번호 방식이 효율적이며, 오래된 댓글부터 순서대로 불러오기에 적합합니다.
Backend Development
· 2025-02-20
📚[Backend Development] 최대 2 Depth 댓글 정렬 구조의 '무한 스크롤 방식'이란 무엇일까요?
“📚[Backend Development] 최대 2 Depth 댓글 정렬 구조의 ‘무한 스크롤 방식’이란 무엇일까요?” ✅ 최대 2 Depth 댓글 정렬 구조의 “무한 스크롤 방식” 설명. 무한 스크롤 방식은 페이지 번호 방식(LIMIT ?, OFFSET ?)을 사용하지 않고, 마지막으로 불러온 댓글의 ID를 기준으로 다음 댓글을 불러오는 방식(Keyset Pagination)입니다. 이 방식은 페이지 번호 방식보다 성능이 우수하여, 대량의 데이터를 빠르게 로드할 수 있습니다. 🏗️1️⃣ 무한 스크롤 방식이란? 무한 스크롤 방식은 마지막으로 불러온 댓글(lastCommentId)을 기준으로 그 이후 데이터를 가져오는 방식입니다. SELECT * FROM comment WHERE article_id = ? AND comment_id > ? ORDER BY parent_comment_id ASC, comment_id ASC LIMIT ?; SQL 키워드 설명 WHERE comment_id > ? 마지막 댓글 ID 이후 데이터만 가져옴 ORDER BY parent_comment_id ASC, comment_id ASC 부모-자식 관계를 유지하면서 정렬 LIMIT ? 한 번에 가져올 최대 개수 지정 📌 이 방식을 사용하면 OFFSET을 사용하지 않기 때문에 성능이 훨씬 우수합니다. 🏗️2️⃣ SQL 예제 (무한 스크롤 방식) 예를 들어, 한 번에 3개의 댓글을 불러오도록 설정하고, 마지막으로 불러온 댓글의 ID(lastCommentId)가 3이라고 가정합니다. SELECT * FROM comment WHERE article_id = ? AND comment_id > 3 ORDER BY parent_comment_id ASC, comment_id ASC LIMIT 3; 🏗️3️⃣ 정렬 방식 📌 정렬 기준. ORDER BY parent_comment_id ASC, comment_id ASC 최상위 댓글을 먼저 정렬 ➞ parent_comment_id IS NULL 순서대로 정렬 대댓글은 같은 부모 아래에서 정렬 ➞ comment_id ASC 순서로 정렬 페이징 없이 WHERE comment_id > lastCommentId 방식으로 조회 🏗️4️⃣ 데이터 예시. 📌 데이터베이스에 저장된 댓글 데이터 comment_id parent_comment_id 내용 1 NULL 댓글1 (최상위 댓글) 2 1 댓글2 (댓글1의 대댓글) 3 NULL 댓글3 (최상위 댓글) 4 1 댓글4 (댓글1의 대댓글) 5 3 댓글5 (댓글3의 대댓글) 6 NULL 댓글6 (최상위 댓글) 📌 무한 스크롤 방식으로 데이터 조회. ✅ 첫 번째 요청(lastCommentId = 0) SELECT * FROM comment WHERE article_id = ? AND comment_id > 0 ORDER BY parent_comment_id ASC, comment_id ASC LIMIT 3; 📌 결과 comment_id parent_comment_id 내용 1 NULL 댓글1 (최상위 댓글) 2 1 댓글2 (댓글1의 대댓글) 4 1 댓글4 (댓글1의 대댓글) 📌 마지막 댓글 ID = 4 ✅ 두 번째 요청(lastCommentId = 4) SELECT * FROM comment WHERE article_id = ? AND comment_id > 4 ORDER BY parent_comment_id ASC, comment_id ASC LIMIT 3; 📌 결과 comment_id parent_comment_id 내용 3 NULL 댓글3 (최상위 댓글) 5 3 댓글5 (댓글3의 대댓글) 6 NULL 댓글6 (최상위 댓글) 📌 마지막 댓글 ID = 6 🏗️5️⃣ 장점과 단점. 장점 단점 OFFSET 없이 빠른 조회 가능 (성능 최적화) lastCommentId를 클라이언트가 유지해야 함 대량의 댓글이 있는 경우 효율적 댓글이 삭제될 경우 정렬이 흐트러질 가능성이 있음 페이지 번호 방식보다 확장성이 좋음 정렬 순서가 유지되도록 조심해야 함 🚀6️⃣ 정리. ✅ 무한 스크롤 방식은 WHERE comment_id > lastCommentId를 사용하여 데이터 조회 ✅ ORDER BY parent_comment_id ASC, comment_id ASC를 사용해 계층 구조 유지 ✅ 페이지 번호 방식(LIMIT ?, OFFSET ?)보다 성능이 우수하며 대량 데이터 처리에 적합 ✅ 마지막 댓글 ID(lastCommentId)를 유지해야 함 📌 최대 2 Depth 댓글 구조에서는 무한 스크롤 방식이 성능 최적화에 유리하며, 빠르게 댓글을 불러올 수 있습니다.
Backend Development
· 2025-02-20
📚[Backend Development] 최대 2depth 댓글 정렬 구조란 무엇일까요?
“📚[Backend Development] 최대 2depth 댓글 정렬 구조란 무엇일까요?” ✅ 최대 2 Depth 댓글 정렬 구조 설명. 최대 2 Depth(계층이 최대 2단계)까지만 허용하는 댓글 시스템의 정렬 구조는 비교적 단순하면서도 효율적입니다. 🏗️1️⃣ 댓글 테이블 구조. 최대 2 Depth 댓글을 저장하는 테이블 구조는 다음과 같습니다. 필드명 설명 comment_id 댓글의 고유 ID (PK) parent_comment_id 부모 댓글 ID (최상위 댓글이면 NULL) article_id 해당 댓글이 속한 게시글 ID content 댓글 내용 created_at 댓글 작성 시간 📌 특징. 최상위 댓글은 parent_comment_id = NULL (예: 댓글 1, 댓글 3) 자식 댓글은 parent_comment_id = 부모의 comment_id (예: 댓글2, 댓글 4, 댓글 5) 2 Depth까지만 허용 (댓글의 댓글까지만 가능, 대댓글의 대댓글은 불가능) 🏗️2️⃣ 정렬 방식 최대 2 Depth 댓글 정렬은 다음과 같은 순서로 진행됩니다. ORDER BY parent_comment_id ASC, comment_id ASC 📌 정렬 기준. parent_comment_id ASC ➞ 같은 부모 댓글을 기준으로 그룹화. comment_id ASC ➞ 작성된 순서대로 정렬 (오래된 댓글이 먼저 출력됨). 🏗️3️⃣ 정렬 데이터 예시. 위 정렬 방식에 따라 댓글 데이터를 조회하면 다음과 같은 형태가 됩니다. parent_comment_id comment_id 내용 NULL 1 댓글1 (최상위 댓글) 1 2 댓글2 (댓글1의 대댓글) 1 4 댓글4 (댓글 1의 대댓글) NULL 3 댓글3 (최상위 댓글) 3 5 댓글5 (댓글 3의 대댓글) 📌 이 정렬 방식의 장점. parent_comment_id를 기준으로 먼저 정렬하여 최상위 댓글이 먼저 출력됨 같은 parent_comment_id를 가진 댓글(대댓글)은 작성 순서대로 정렬됨 LIMIT ?, ?을 활용하여 페이징 처리 가능 🏗️4️⃣ SQL 정렬 예제 SELECT * FROM comment WHERE article_id = ? ORDER BY parent_comment_id ASC, comment_id ASC LIMIT ?, ?; 🏗️5️⃣ 페이징 처리. 각 페이지에서 N개 댓글을 불러올 수 있도록 LIMIT ?,? 사용 최상위 댓글과 대댓글을 함께 불러오기 위해 parent_comment_id 기준 정렬 유지 최대 depth가 2이므로 성능 최적화에 유리함 ✅6️⃣ 정리. ✅ 최대 2 Depth 구조 ➞ parent_comment_id를 활용해 부모-자식 관계 유지 ✅ 정렬 순서 ➞ ORDER BY parent_comment_id ASC, comment_id ASC ✅ 조회 결과 ➞ 부모 댓글이 먼저, 대댓글이 뒤에 정렬됨 ✅ 페이징 가능 ➞ LIMIT ?, ?를 활용하여 오래된 순으로 페이징 처리
Backend Development
· 2025-02-19
📚[Backend Development] mappedBy란 무엇일까요?
“📚[Backend Development] mappedBy란 무엇일까요?” 🍎 Intro. mappedBy는 양방향 연관관계에서 사용되는 속성으로, 연관 관계의 주인이 아닌(읽기 전용) 쪽에서 사용합니다. 즉, 외래 키(FK)를 관리하지 않는 쪽에서 mappedBy를 사용하여 연관 관계를 매핑합니다. ✅1️⃣ mappedBy의 필요성. 양방향 관계에서는 두 개의 엔티티가 서로를 참조하게 되는데, JPA는 외래 키(FK)를 관리할 “주인”을 하나만 지정해야 합니다. 이때, 연관 관계의 주인이 아닌 쪽에서 mappedBy를 사용하여 주인을 명시합니다. ✅2️⃣ @OneToOne 양방향 관계에서 mappedBy 사용 예제 1️⃣ User 엔티티 (연관 관계의 주인) @Entity public class User { @Id @GeneratedValue(strategy = Generation.IDENTITY) private Long id; private String username; @OneToOne @JoinColumn(name = "profile_id") // FK를 관리하는 주인 (user 테이블에 profile_id FK 생성) private UserProfile profile; // Getter, Setter } 2️⃣ UserProfile 엔티티(mappedBy 사용) @Entity public class UserProfile { @Id @GenerationValue(strategy = Generation.IDENTITY) private Long id; private String bio; private String website; @OneToOne(mappedBy = "profile") // User 엔티티의 profile 필드가 관계의 주인 private User user; // Getter, Setter } ✅3️⃣ mappedBy = “profile”의 의미 “profile”은 User 엔티티의 profile 필드명을 가리킵니다. 즉, 이 관계의 주인은 User.profile이며, UserProfile 엔티티는 읽기 전용입니다. 따라서 UserProfile.user 필드는 외래 키(FK)를 생성하지 않고, 매핑만 수행합니다. ✅4️⃣ 데이터베이스 테이블 구조 위 코드를 실행하면 user 테이블만 profile_id라는 FK 컬럼을 가지며, user_profile 테이블에는 추가 컬럼이 생성되지 않습니다. 📊 user 테이블 id username profile_id (FK) 1 Alice 101 2 Bob 102 📊 user_profile 테이블 id bio website 101 “Gamer” “alice.com” 102 “Developer” “bob.dev” 📌 외래 키는 user.profile_id에만 존재하며, user_profile 테이블에는 FK 컬럼이 없습니다. ✅5️⃣ mappedBy를 사용한 데이터 조회 ✅ User ➞ UserProfile 조회(가능 ✅) User user = entityManager.find(User.class, 1L); UserProfile profile = user.getProfile(); // 정상 작동 ✅ UserProfile ➞ User 조회(가능 ✅) UserProfile profile = entityManager.find(UserProfile.class, 101L); User user = profile.getUser(); // mappedBy를 사용했으므로 가능! 🚀 정리. ✔️ 연관 관계의 주인(Owner)이 아닌 쪽에서 mappedBy를 사용해야 한다. ✔️ “mappedBy = 주인 엔티티 필드명”으로 설정해야 한다. ✔️ 외래 키(FK)는 mappedBy를 사용한 쪽이 아니라 주인이 관리한다. ✔️ mappedBy는 읽기 전용이므로 @JoinColumn을 사용하지 않는다. 📌 mappedBy를 사용하면 불필요한 FK 컬럼 생성 방지 및 데이터베이스 테이블을 깔끔하게 유지할 수 있습니다.
Backend Development
· 2025-02-18
📚[Backend Development] @OneToMany란 무엇일까요?
“📚[Backend Development] @OneToMany란 무엇일까요?” 🍎 Intro. @OneToMany는 일대다(1:N) 관계를 매핑할 때 사용하는 어노테이션입니다. 즉, 하나(One)의 엔티티가 여러 개(Many)의 엔티티를 참조하는 구조입니다. ✅1️⃣ @OneToMany 예제. 게시글(Article)과 댓글(Comment) 관계를 예로 들어보겠습니다. 하나의 게시글(Article)에는 여러 개의 댓글(Comment)이 달릴 수 있습니다. 1️⃣ Article 엔티티(게시글) @Entity public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private String content; @OneToMany(mappedBy = "article") // Comment 엔티티의 article 필드가 관계의 주인 private List<Comment> comments = new ArrayList<>(); // Getter, Setter } 2️⃣ Comment 엔티티(댓글) @Entity public class Comment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String content; @ManyToOne @JoinColumn(name = "article_id") // comment 테이블에 article_id FK 생성 private Article article; // Getter, Setter } ✅2️⃣ @OneToMany(mappedBy = “article”)의 의미 Comment 엔티티의 article 필드를 참조하여 양방향 관계를 설정합니다. 외래 키를 관리하는 주인은 Comment.article 필드이며, Article 엔티티는 mappedBy를 통해 읽기 전용입니다. 즉, Comment 엔티티가 관계의 주인이고, Article 엔티티에서는 직접 FK를 관리하지 않습니다. (➞ @JoinColumn이 Comment 쪽에만 있는 이유) ✅3️⃣ 데이터베이스 테이블 구조. 위 코드를 실행하면 다음과 같은 테이블이 생성됩니다. 📌 article 테이블 (게시글) id title content 1 “Hello JPA” “JPA 배우기” 2 “Spring Boot” “Spring 공부” 📌 comment 테이블 (게시글에 연결된 댓글, article_id FK 포함) id content article_id(FK) 1 “좋은 글이네요!” 1 2 “유익한 정보 감사합니다.” 1 3 “Spring 최고!” 2 📌 article_id 컬럼이 게시글(Article)을 참조하는 외래 키(FK)입니다. 즉, 하나의 Article에는 여러 개의 Comment가 연결될 수 있습니다. ✅4️⃣ 데이터 조회. ✅ 특정 게시글에 속한 댓글 가져오기. 양방향 관계가 설정되어 있으므로, 특정 게시글에 달린 댓글을 쉽게 가져올 수 있습니다. Article article = entityManager.find(Article.class, 1L); List<Comment> comments = article.getComments(); // 해당 게시글의 모든 댓글 가져오기 🚀5️⃣ 단방향 @OneToMany vs 양방향 @OneToMany 1️⃣ 단방향 @OneToMany @Entity public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private String content; @OneToMany @JoinColumn(name = "article_id") // FK를 직접 관리 (주인 역할) private List<Comment> comments = new ArrayList<>(); // Getter, Setter } ✅ 장점. 단순한 구조. 불필요한 mappedBy 없이 @JoinColumn을 통해 FK 직접 관리 가능 ❌ 단점. 데이터 삽입 시 추가적인 SQL 실행 발생 @OneToMany 단방향 관계에서 @JoinColumn을 사용하면 INSERT 쿼리가 두 번 실행됨 (➞ 댓글 삽입 후, 게시글 ID 업데이트) 2️⃣ 양방향 @OneToMany + @ManyToOne @Entity public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private String content; @OneToMany(mappedBy = "article") // Comment의 article 필드를 주인으로 설정 private List<Comment> comments = new ArrayList<>(); // Getter, Setter } ✅ 장점. 성능 최적화 가능 (FK는 Comment.article이 관리). INSERT 쿼리 실행이 한 번만 발생. 객체 그래프 탐색이 편리함 (article.getComments() 가능). ❌ 단점 mappedBy로 인해 데이터 저장이 Comment 쪽에서 이루어져야 함. ✅6️⃣ 정리 ✔️ @OneToMany는 하나(One)의 엔티티가 여러 개(Many)의 엔티티를 참조할 때 사용. ✔️ 양방향 관계에서는 @OneToMany(mappedBy = “필드명”) + @ManyToOne 조합 사용 ✔️ 단방향 @OneToMany보다는 양방향을 사용하는 것이 일반적 ✔️ 외래 키(FK)는 @ManyToOne 쪽에서 관리하며, @OneToMany는 읽기 전용 📌 게시글-댓글 관계처럼 1:N 관계가 필요할 때 @OneToMany를 사용하면 됩니다.
Backend Development
· 2025-02-18
📚[Backend Development] @ManyToOne이란 무엇일까요?
“📚[Backend Development] @ManyToOne이란 무엇일까요?” 🍎 Intro. @ManyToOne은 다대일(N:1) 관계를 매핑할 때 사용합니다. 즉, 여러 개(Many)의 엔티티가 하나(One)의 엔티티를 참조하는 구조입니다. ✅1️⃣ @ManyToOne 예제. 게시글(Article)과 댓글(Comment) 관계를 예로 들어보겠습니다. 하나의 게시글(Article)에 여러 개의 댓글(Comment)이 달릴 수 있습니다. 1️⃣ Article 엔티티 (게시글) @Entity public class Article { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private String content; // Getter, Setter } 2️⃣ Comment 엔티티 (댓글) @Entity public class Comment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String content; @ManyToOne @JoinColumn(name = "article_id") // 외래 키 컬럼명 설정 private Article article; // Getter, Setter } ✅2️⃣ @ManyToOne 설명. @ManyToOne을 사용하여 여러 개의 댓글(Comment)이 하나의 게시글(Article)을 참조하도록 설정합니다. @JoinColumn(name = “article_id”)를 통해 comment 테이블에 article_id 외래 키(FK)를 생성합니다. ✅3️⃣ 데이터베이스 테이블 구조. 위 코드를 실행하면 데이터베이스는 다음과 같은 테이블이 생성됩니다. 📌 article 테이블 id title content 1 “Hello JPA” “JPA 배우기” 2 “Spring Boot” “Spring 공부” 📌 comment 테이블 (article_id FK 포함) id content article_id(FK) 1 “좋은 글이네요!” 1 2 “유익한 정보 감사합니다.” 1 3 “Spring 최고!” 2 📌 article_id 컬럼이 게시글(Article)을 참조하는 외래 키(FK)입니다. 즉, comment 테이블의 여러 행이 article_id를 통해 같은 article을 가리킬 수 있습니다. ✅4️⃣ 데이터 조회 ✅ 특정 게시글에 속한 댓글 가져오기. 게시글 ID(articleId)가 1번인 댓글을 가져오려면: List<Comment> comments = entityManager.createQuery( "SELECT c FROM c WHERE c.article.id = :articleId", Comment.class) .setParameter("articleId", 1L) .getResultList();
Backend Development
· 2025-02-18
📚[Backend Development] 단방향과 @OneToOne이란 무엇일까요?
“📚[Backend Development] 단방향과 @OneToOne이란 무엇일까요?” 🍎 Intro. 단방향 @OneToOne 관계는 엔티티 간의 1:1 관계를 매핑할 때, 한쪽 엔티티에서만 관계를 관리하는 방식입니다. 즉, 한 엔티티에서만 다른 엔티티를 참조하고, 반대쪽에서는 이를 알지 못하는 상태입니다. ✅1️⃣ 예제 코드 예를 들어, User 엔티티와 UserProfile 엔티티가 1:1 관계를 가진다고 가정해봅시다. 📝 User 엔티티에서 UserProfile 엔티티를 단방향으로 참조하는 경우: @Entity public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String username; @OneToOne @JoinColumn(name = "profile_id") // User 테이블의 profile_id 컬럼이 UserProfile의 id를 참조 private UserProfile profile; // Getter, Setter } 📝 UserProfile 엔티티: @Entity public class UserProfile { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String bio; private String website; // Getter, Setter } ✅ 설명: @OneToOne을 사용하여 User 엔티티가 UserProfile 엔티티를 참조합니다. @JoinColumn(name = “profile_id”)를 사용하여 User 테이블에 profile_id 컬럼이 생성됩니다. User 테이블에 profile_id라는 외래 키(FK) 컬럼을 추가하고, 이 컬럼이 UserProfile 테이블의 id(PK)를 참조하도록 만듭니다. 즉, User 테이블의 profile_id가 UserProfile 테이블의 id를 참조하는 FK이다. 하지만 UserProfile 엔티티에는 User와의 관계를 알 수 있는 정보가 없습니다. ➞ 이것이 단방향 관계입니다. ✅ 살제 데이터베이스 테이블 예시: 이 코드를 기반으로 JPA가 생성하는 테이블을 보면 다음과 같이 됩니다. 📊 user 테이블 id username profile_id(FK) 1 Alice 101 2 Bob 102 📊 user_profile 테이블 id bio website 101 “Gamer” “alice.com” 102 “Developer” “bob.dev” 📌 즉, user.profile_id는 user_profile.id를 참조(FK)하는 구조입니다. 따라서 UserProfile 엔티티에는 profile_id가 따로 필요하지 않습니다. 대신 기본 키(id)가 User 엔티티의 외래 키(profile_id)로 사용됩니다. ✅2️⃣ 단방향 관계의 특징. ✅ 장점. 구조가 단순하고 이해하기 쉽다. 한쪽에서만 참조하므로 불필요한 연관관계 로딩을 방지할 수 있다. ❌ 단점. 반대쪽(UserProfile)에서 User를 조회할 방법이 없다. UserProfile이 자신을 참조하는 User가 누구인지 알고 싶다면 별도의 쿼리를 작성해야 한다. ✅3️⃣ 단방향 관계 조회. 사용자가 프로필 정보를 가져오는 코드를 작성하면 다음과 같습니다. User user = entityManager.find(User.class, 1L); UserProfile profile = user.getProfile(); // User -> UserProfile 조회 가능.
Backend Development
· 2025-02-17
📚[Backend Development] 빌더 패턴 사용시 @AllArgsConstructor(access = AccessLevel.PRIVATE)을 활용하는 이유
“📚[Backend Development] 빌더 패턴 사용시 @AllArgsConstructor(access = AccessLevel.PRIVATE)을 활용하는 이유” 🍎 Intro. 빌더 패턴을 사용할 때, @AllArgsConstructor(access = AccessLevel.PRIVATE)를 추가하는 이유를 설명하겠습니다. ✅1️⃣ @AllArgsConstructor가 하는 역할. @AllArgsConstructor는 모든 필드를 포함하는 생성자를 자동으로 생성합니다. 하지만 빌더 패턴을 사용할 경우, 생성자를 직접 호출하지 않고 빌더를 통해 객체를 생성하는 것이 목적입니다. 따라서, 생성자의 접근 제한을 private으로 설정하면, 빌더를 통한 생성만 허용할 수 있습니다. ✅2️⃣ 빌더 패턴 적용 시, @AllArgsConstructor(access = AccessLevel.PRIVATE)가 필요한 이유 ❌ 잘못된 예제 (빌더 패턴 사용했지만, 생성자도 public) @AllArgsConstructor // (기본값이 PUBLIC) @Builder public class Comment { private Long commentId; private String content; private Long articleId; private Long parentCommentId; private Long writerId; private Boolean deleted; private LocalDateTime createdAt; } ✅ 문제점: @AllArgsConstructor의 기본 접근 제어자가 public이므로, 빌더를 사용하지 않고 생성자를 직접 호출하여 객체를 만들 수 있음. 빌더를 사용하는 목적이 객체 생성 시 가독성을 높이고 선택적으로 필드를 초기화 할 수 있도록 하기 위함인데, 생성자가 public이면 빌더 사용을 강제할 수 없음. 🛠️ 해결 방법(@AllArgsConstructor(access = AccessLevel.PRIVATE)) @AllArgsConstructor(access = AccessLevel.PRIVATE) // 생성자를 PRIVATE으로 설정 @Builder public class Comment { private Long commentId; private String content; private Long articleId; private Long parentCommentId; private Long writerId; private Boolean deleted; private LocalDateTime createdAt; } ✅ 이렇게 하면: 객체를 직접 생성하는 것을 막고, 빌더를 통한 생성만 가능하도록 제한 가능. 불필요한 생성자 호출을 막고, 가독정이 좋은 빌더 패턴을 강제할 수 있음. 객체의 필드가 많아질수록, 빌더 패턴이 더 유용하게 동작하게 됨. ✅3️⃣ 정리 - 필요한 이유. 문제점 해결 방법 @AllArgConstructor 기본값이 public이므로, 직접 생성자 호출이 가능함. @AllArgsConstructor(access = AccessLevel.PRIVATE)를 사용하여 생성자 접근 제한. 빌더를 사용해도 생성자를 직접 호출할 수 있어 일관성이 떨어짐. 빌더를 강제하여 가독성 및 유지보수성을 높임. 객체 필드가 많아질 경우, 생성자 호출보다 빌더 패턴이 더 유리함. 빌더를 강제하여 더 가독성이 좋은 코드 유지 가능. ✅ 즉, @AllArgsCOnstructor(access = AccessLevel.PRIVATE)를 사용하면, 빌더를 통한 객체 생성을 강제하여 코드 일관성을 유지할 수 있습니다.
Backend Development
· 2025-02-15
📚[Backend Development] 계층형 댓글 목록 조회 시 페이징 처리 방법
“📚[Backend Development] 계층형 댓글 목록 조회 시 페이징 처리 방법” 💡 가정: 계층별 오래된 순으로 페이징됨. 1페이지 당 2개의 댓글을 보여줌. 👉 이 가정이 맞는지 검토하고, 어떻게 페이징이 이루어지는지 확인해봅시다. ✅1️⃣ 기본적인 계층형 정렬 방식. 📌 계층형 댓글 조회 시 일반적인 정렬 규칙. 1. 최상위 댓글(부모 댓글)이 먼저 정렬됨 (오래된 순). 2. 각 부모 댓글의 하위 댓글(자식 댓글)이 정렬됨 (오래된 순). 3. 같은 계층 내에서도 오래된 순으로 정렬됨. 📌 계층형 정렬된 목록. ✅2️⃣ 1페이지 당 2개의 댓글을 보여줄 경우. 계층형 구조에서는 단순 LIMIT & OFFSET을 사용하면 데이터가 끊어질 수 있음. 트리 구조를 유지하면서 정렬된 순서로 페이징해야 함. 📌 전체 페이징 처리 모습. 📌 1페이지 (첫 2개 댓글) 최상위 댓글 1개(댓글 1) + 그에 대한 하위 댓글(댓글 2)을 포함 하위 댓글이 있는 경우, 다음 댓글을 포함할지 여부는 페이징 로직에 따라 결정됨. 📌 2페이지 (다음 2개 댓글) 첫 번째 페이지에서 댓글 1 ➞ 댓글 2까지 가져왔으므로, 이제 댓글 2의 하위 댓글부터 표시. 즉, 댓글 3과 댓글 5가 다음 페이지에 노출됨. 📌 3페이지 (다음 2개 댓글) 댓글 1 트리가 끝났으므로, 이제 댓글 4를 표시. 댓글 4의 하위 댓글인 댓글 6도 같이 표시됨. ✅3️⃣ 정리 페이지 번호 출력되는 댓글 목록 1 페이지 댓글 1, 댓글 2 2 페이지 댓글 3, 댓글 5 3 페이지 댓글 4, 댓글 6 ✔️ 즉, 최상위 댓글을 기준으로 정렬하되, 계층 구조를 유지하면서 페이징이 이루어짐. ✔️ 각 댓글의 하위 댓글을 보여줄 때, 부모 댓글이 포함된 상태에서 하위 댓글이 순차적으로 정렬됨. ✅4️⃣ 추가적인 고려 사항. ✅ 페이징 성능을 고려한 SQL 쿼리. 계층형 댓글 페이징을 위한 CTE(Common Table Expression) 또는 Recursive Query 활용 가능. ORDER BY parent_id, created_at ASC와 같은 정렬 방식 적용. ✅ UI에서의 표시 방식 첫 번째 페이지에서 댓글 1 ➞ 댓글 2까지만 표시할 수 있고, 첫 번째 페이지에서 댓글 1 ➞ 댓글 2 ➞ 댓글 3 ➞ 댓글 5까지 한 번에 표시할 수도 있음 “더 보기” 버튼을 활용하여 동적 로딩 방식도 고려 가능.
Backend Development
· 2025-02-14
📚[Backend Development] 계층형 대댓글에서 댓글을 삭제하면 어떤 현상이 발생할까요? 3️⃣
“📚[Backend Development] 계층형 대댓글에서 댓글을 삭제하면 어떤 현상이 발생할까요? 3️⃣” ✅ “삭제 표시(Soft Delete)” 방식에서 댓글 5를 삭제하면 어떻게 될까? 💡 가정. Soft Delete(삭제 표시) 방식을 사용 중. 댓글 5는 하위 댓글이 없으므로 완전히 삭제될 것이다. 👉 이 가정이 맞는지 확인해 봅시다. ✅1️⃣ 댓글 5 삭제 전의 구조. 댓글 1과 댓글 2는 삭제 표시(Soft Delete) 상태. 댓글 3과 댓글 5는 남아 있음. 이 상태에서 댓글 5를 삭제하면 어떻게 될까? 🤔 ✅2️⃣ Soft Delete vs Physical Delete 차이점 삭제 방식 설명 댓글 5 삭제 시 결과 Soft Delete (논리 삭제) 데이터베이스에서 삭제하지 않고 is_deleted = TRUE로 표시만 함 댓글 5가 “삭제된 댓글입니다.”로 남음 Physical Delete (물리 삭제) 데이터베이스에서 실제 삭제 댓글 5가 완전히 제거됨 ✔️ Soft Delete라면 “삭제된 댓글입니다.”로 남지만, Physical Delete라면 댓글 5가 실제로 삭제됨. ✔️ 댓글 5는 하위 댓글이 없기 때문에 완전 삭제(Physical Delete)되는 것이 일반적. ✅3️⃣ 댓글 5 삭제 후의 새로운 구조 ✅ Soft Delete 적용 시 (논리 삭제) 댓글 5가 삭제 표시로 남음(Soft Delete) → “삭제된 댓글입니다.”로 보임. 댓글 3이 남아 있으므로 댓글 2의 구조는 유지됨. ✅ Physical Delete 적용 시 (완전 삭제) 댓글 5가 완전히 삭제된(Physical Delete) 댓글 2하위에서 댓글 3만 남음. ✅4️⃣ 결론: 댓글 5는 완전 삭제가 이루어질 가능성이 높음 Soft Delete를 적용하더라도, 댓글 5는 하위 댓글이 없기 때문에 완전히 삭제(Physical Delete) 되는 것이 일반적. 댓글 5를 Soft Delete 처리할 필요가 없으며, 실제로 DB에서 제거되는 것이 최적의 방식.
Backend Development
· 2025-02-13
📚[Backend Development] 계층형 대댓글에서 댓글을 삭제하면 어떤 현상이 발생할까요? 1️⃣
“📚[Backend Development] 계층형 대댓글에서 댓글을 삭제하면 어떤 현상이 발생할까요? 1️⃣” 🍎 Intro. 댓글을 삭제하는 방식에는 여러 가지가 있습니다. 데이터베이스의 외래 키(Foreign Key) 설정 및 삭제 정책에 따라 다른 현상이 발생할 수 있습니다. ✅1️⃣ 댓글 2의 구조 분석. 댓글 2는 댓글 1의 자식 댓글(대댓글). 댓글 3, 댓글 5는 댓글 2의 자식 대댓글. 즉, 댓글 2를 삭제하면 댓글 3과 댓글 5가 고아 상태(부모 없는 상태)가 됨. ✅2️⃣ 댓글 2 삭제 시 발생할 수 있는 시나리오 📌 시나리오 1️⃣: 연쇄 삭제 (Cascading Delete) 댓글 2를 삭제하면 댓글 3과 댓글 5도 함께 삭제됨. ON DELETE CASCADE 옵션이 설정되어 있는 경우 발생. 👉 결과. 삭제 후 남아있는 댓글 목록: 댓글 1 댓글 4 댓글 6 댓글 3과 댓글 5까지 함께 삭제되므로, 해당 스레드 전체가 사라짐. 📌 시나리오 2️⃣: 고아 댓글 처리 (Orphan Handling) 댓글 2를 삭제하면 댓글 3과 댓글 5의 부모(parent_id)를 NULL로 설정. 즉, 댓글 3과 댓글 5가 독립적인 최상위 댓글이 됨. 댓글 4와 댓글 6의 계층 구조는 유지됨 ➞ 댓글 6의 parent_id = 4 👉 결과. 삭제 후 남아있는 댓글 목록: 댓글 1 댓글 3 (기존 댓글 2의 자식 → 최상위 댓글로 이동) 댓글 5 (기존 댓글 2의 자식 → 최상위 댓글로 이동) 댓글 4 └ 댓글 6 📌 시나리오 3️⃣: 부모 댓글 대체 (Reparenting) 댓글 2를 삭제하면 댓글 3과 댓글 4의 부모를 댓글 1로 변경. 즉, 댓글 2의 자식들이 댓글 1의 직접적인 자식 댓글이 됨 👉 결과. 삭제 후 남아있는 댓글 목록: 댓글 1 └ 댓글 3 └ 댓글 5 댓글 4 └ 댓글 6 ✅3️⃣ 댓글 2 삭제를 처리하는 방법 선택. 삭제 방식 설명 장점 단점 연쇄 삭제 (Cascade) 댓글 2를 삭제하면 댓글 3, 5도 삭제 데이터 정합성 유지 유저가 예상치 못한 삭제 발생 가능 고아 댓글 처리 (Orphan) 댓글 3과 5가 최상위 댓글이 됨 삭제 후에도 데이터 보존 UI에서 댓글 관계가 깨질 수 있음 부모 댓글 대체(Reparenting) 댓글 3과 댓글 5가 댓글 1의 자식이 됨 대댓글 구조 유지 데이터 수정이 필요 ✅4️⃣ MySQL에서 삭제 처리 방식 예제. 1️⃣ ON DELETE CASCADE (연쇄 삭제) ALTER TABLE comments ADD CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES comments (comment_id) ON DELETE CASCADE; 댓글 2를 삭제하면 댓글 3과 댓글 5도 자동으로 삭제됨. 2️⃣ 부모 댓글을 NULL로 설정 (고아 댓글 처리) UPDATE comments SET parent_id = NULL WHERE parent_id = 2; DELETE FROM comments WHERE comment_id = 2; 댓글 3과 댓글 5가 부모 없이 최상위 댓글로 변경됨. 3️⃣ 부모 댓글 변경 (Reparenting) UPDATE comments SET parent_id = 1 WHERE parent_id = 2; DELETE FROM comments WHERE comment_id = 2; 댓글 3과 댓글 5가 댓글 1의 자식이 됨. ✅5️⃣ 결론 댓글 2를 삭제하면 댓글 3과 댓글 5의 처리 방법에 따라 다른 결과가 발생. 어떤 방식이 가장 적절한지는 비즈니스 로직과 UX에 따라 결정해야 함. 보통은 부모 댓글을 삭제해도 대댓글이 남도록 처리(고아 댓글 처리 or 부모 댓글 변경)하는 경우가 많음.
Backend Development
· 2025-02-12
📚[Backend Development] 계층형 대댓글에서 댓글을 삭제하면 어떤 현상이 발생할까요? 2️⃣
“📚[Backend Development] 계층형 대댓글에서 댓글을 삭제하면 어떤 현상이 발생할까요? 2️⃣” ✅ 댓글 1을 삭제할 때, 삭제 표시만 될 것인가? 📌 가정 댓글 2는 이미 삭제 상태(“삭제된 댓글입니다.”)로 표시됨. 그렇다면, 댓글 1을 삭제하면 댓글 1도 삭제 표시만 될 것이다. 즉, 댓글 1이 완전히 삭제되지 않고, “삭제된 댓글입니다.”로 유지될 것입니다. 👉 이 가정이 맞는지 한 번 확인해봅시다. ✅1️⃣ 댓글 1 삭제 전의 구조 (댓글 2는 삭제 상태) 댓글 2가 삭제 상태지만, 댓글 3과 댓글 5는 유지됨. 이 상태에서 댓글 1을 삭제하면 어떻게 될까? ✅2️⃣ 댓글 1 삭제 처리 방식에 따른 결과. 댓글이 삭제될 때, 적용할 수 있는 방법은 두 가지 입니다. 📌1️⃣ 연쇄 삭제 (Cascade Delete) 📝 설명. 댓글 1이 삭제되면 그 하위 댓글도 모두 삭제됨. 즉, 댓글 1이 삭제되면 댓글 2, 댓글 3, 댓글 5도 함께 삭제됨. 👉 결과 구조 (Cascade Delete 적용 시) ❌ 이 방식은 가정과 다르게 댓글 1이 삭제되면서 하위 댓글도 모두 삭제됨. 📌2️⃣ 삭제 표시 (Soft Delete) 📝 설명. 댓글 1을 삭제하면 댓글 2처럼 “삭제된 댓글입니다.”로 표시됨. 즉, 댓글 1이 삭제되더라도 댓글 3과 댓글 5가 남아 있기 때문에 완전히 사라지지 않음. 가정과 동일한 방식 👉 결과 구조 (Soft Delete 적용 시) ✅ 이 방식이 사용자의 가정과 일치합니다. ✅ 댓글 1은 “삭제된 댓글입니다.” 상태가 되고, 댓글 3과 댓글 5는 그대로 남음. ✅3️⃣ 가정이 맞는가? ✔ 결론: 사용자의 가정은 “Soft Delete” 방식이 적용될 경우 맞습니다. ✔ 즉, 댓글 1도 삭제 상태로 표시되지만, 하위 댓글이 남아 있기 때문에 완전히 사라지지 않습니다. ✔ 만약 “Cascade Delete”가 적용되었다면, 댓글 1이 삭제되면서 하위 댓글(2, 3, 5)도 모두 삭제되므로 사용자의 가정과 다릅니다. ✅4️⃣ 실제 서비스에서는 어떤 방식을 사용할까? 삭제 방식 설명 적용 서비스 Cascade (완전 삭제) 부모 댓글이 삭제되면 하위 댓글도 삭제됨 일부 게시판, 블로그 Soft Delete(삭제 표시 유지) 부모 댓글이 삭제되더라도 하위 댓글이 있으면 “삭제된 댓글입니다.”로 유지됨 네이버 카페, 인스타그램, 페이스북, 유튜브 댓글 📌 일반적으로 커뮤니티나 SNS 서비스에서는 Soft Delete를 사용하여 부모 댓글을 “삭제된 댓글입니다.”로 유지하는 경우가 많습니다. 📌 즉, 가정이 실제 서비스에서 많이 사용되는 방식과 일치합니다.
Backend Development
· 2025-02-12
📚[Backend Development] 최대 2 Depth의 계층형 대댓글이란?
“📚[Backend Development] 최대 2 Depth의 계층형 대댓글이란?” 🍎 Intro. 댓글(Parent) ➞ 대댓굴(Child)까지만 허용하며, 대댓글의 대댓글(Child of Child)은 허용하지 않는 구조입니다. 즉, 댓글의 깊이가 최대 2단계까지만 유지 되며, 1 Depth (최상위 댓글) 2 Depth (대댓글) 이후에는 더 이상 하위 대댓글을 추가할 수 없는 방식입니다. ✅1️⃣ 최대 2 Depth 계층형 대댓글이 필요한 이유 ❌1️⃣ 일반적인 계층형 댓글 방식의 문제점. 댓글이 무한히 중첩될 경우, 데이터 조회 및 정렬이 복잡해지고 성능 저하 가능성. UI에서 너무 깊은 계층 구조는 사용자 경험(UX)에 좋지 않음. 무한 재귀 호출 방지를 위해 계층을 제한하는 것이 일반적. ✅2️⃣ 최대 2 Depth 계층형 대댓글의 장점. UI/UX 개선 ➞ 대댓글이 많아도 가독성이 유지됨. SQL 성능 최적화 가능 ➞ 복잡한 재귀 쿼리 없이 간단한 JOIN으로 해결 가능. 프론트엔드에서 구현이 쉬움 ➞ 2 Depth까지만 유지하므로 댓글 정렬이 단순함. ✅2️⃣ 최대 2 Depth의 계층형 대댓글 테이블 구조. CREATE TABLE comments ( comment_id BIGINT AUTO_INCREMENT PRIMARY KEY, article_id BIGINT NOT NULL, -- 게시글 ID parent_id BIGINT NULL, -- 부모 댓글 ID (NULL이면 최상위 댓글) depth INT NOT NULL DEFAULT 1, -- 댓글의 깊이 (1: 일반 댓글, 2: 대댓글) author VARCHAR(255) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (parent_id) REFERENCES comments(comment_id) ON DELETE CASCADE ); ✅ 테이블 주요 컬럼 comment_id ➞ 댓글의 고유 ID. article_id ➞ 댓글이 속한 게시글 ID. parent_id ➞ 부모 댓글 ID (NULL이면 최상위 댓글). depth ➞ 댓글의 깊이 (1이면 일반 댓글, 2이면 대댓글). author ➞ 작성자. content ➞ 댓글 내용. created_at ➞ 댓글 작성 시간. ✅3️⃣ 최대 2 Depth 계층형 대댓글의 규칙. Depth 설명 1 Depth 일반 댓글 (Parent) 2 Depth 대댓글 (Child) 3 Depth 이상 ❌ 허용하지 않음 ✅4️⃣ 최대 2 Depth의 계층형 대댓글 목록 조회 API 구현. 🛠️1️⃣ Entity (JPA 기반 계층형 댓글) import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @Getter @NoArgsConstructor @Table(name = "comments") public class Comment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long commentId; @ManyToOne @JoinColumn(name = "article_id", nullable = false) private Article article; @ManyToOne @JoinColumn(name = "parent_id") private Comment parent; // 부모 댓글 @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL) private List<Comment> replies = new ArrayList<>(); // 대댓글 리스트 private Integer depth; // 댓글 깊이 (1: 일반 댓글, 2: 대댓글) private String author; private String content; private LocalDateTime createdAt = LocalDateTime.now(); } ✅ 댓글의 깊이를 depth 필드로 저장하여 최대 2 Depth까지만 허용. 🛠️2️⃣ Repository (2 Depth까지 댓글 조회) import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; @Repository public interface CommentRepository extends JpaRepository<Comment, Long> { List<Comment> findByArticle_ArticleIdDepthOrderByCreatedAtAsc(Long articleId, Integer depth); } ✅ 최대 depth = 2까지만 조회하도록 설정. 🛠️3️⃣ Service (비즈니스 로직) import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.util.List; @Service @RequiredArgsConstructor public class CommentService { private final CommentRepository commentRepository; public List<CommentResponse> getCommentsByArticle(Long articleId) { return commentRepository.findByArticle_ArticleIdAndDepthOrderByCreatedAtAsc(articleId, 1) .stream() .map(CommentResponse::fromEntity) .toList(); } public List<CommentResponse> getRepliesByComment(Long parentId) { return commentRepository.findByArticle_ArticleIdAndDepthOrderByCreatedAtAsc(parentId, 2) .stream() .map(CommentResponse::fromEntity) .toList(); } } ✅ 최대 2 Depth까지만 조회하는 API 로직 구현. 🛠️4️⃣ Controller (API 요청 처리) import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/articles/{articleId}/comments") @RequiredArgsConstructor public class CommentController { private final CommentService commentService; @GetMapping public ResponseEntity<List<CommentResponse>> getComments(@PathVariable Long articleId) { return ResponseEntity.ok(commentService.getCommentsByArticle(articleId)); } @GetMapping("/{commentId}/replies") public RespinseEntity<List<CommentResponse>> getReplies(@PathVariable Long commentId) { return ResponseEntity.ok(commentService.getRepliesByComment(commentId)); } } ✅ RESTful API로 최대 2 Depth까지 댓글 및 대댓글 조회 가능. ✅5️⃣ API 응답 예시. [ { "commentId": 1, "author": "John Doe", "content": "좋은 글이네요!", "createdAt": "2025-02-01T12:00:00Z", "replies": [ { "commentId": 2, "author": "Alice", "content": "저도 그렇게 생각해요!", "createdAt": "2025-02-01T12:05:00Z" } ] }, { "commentId": 3, "author": "Bob", "content": "궁금한 점이 있어요!", "createdAt": "2025-02-01T12:10:00Z", "replies": [] } ] ✅ 최대 2 Depth까지만 유지되며, replies 필드를 이용하여 대댓글을 표현. ✅6️⃣ SQL 쿼리 방식. -- 최상위 댓글(1 Depth) 조회 SELECT * FROM comments WHERE article_id = 123 AND depth = 1 ORDER BY created_at ASC; -- 특정 댓글(2 Depth)의 대댓글 조회 SELECT * FROM comments WHERE parent_id = 1 AND depth = 2 ORDER BY created_at ASC; ✅ SQL을 활용하여 2 Depth까지만 조회 가능하도록 제한. ✅7️⃣ 최대 2 Depth 계층형 대댓글의 활용 사례 서비스 설명 블로그 블로그 게시글의 댓글 + 대댓글(ex: 네이버 블로그) 커뮤니티 게시판 댓글 + 대댓글 (ex: Reddit) SNS SNS 댓글 + 대댓글 (ex: 인스타그램, 트위터) 이커머스 상품 리뷰의 대댓글 (ex: 아마존) ✅8️⃣ 결론. 최대 2 Depth의 계층형 대댓글은 댓글 ➞ 대댓글까지만 허용하고, 더 이상 하위 댓글을 허용하지 않는 방식. 데이터 정렬이 단순해지고 성능 최적화 가능. Spring Boot + JPA 기반으로 쉽게 구현 가능. 대규모 트래픽에서도 적절한 성능을 유지하면서 안정적인 댓글 시스템 제공 가능.
Backend Development
· 2025-02-11
📚[Backend Development] 단방향과 양방향의 개념에 대하여.
“📚[Backend Development] 단방향과 양방향의 개념에 대하여.” 🍎 Intro. JPA에서 엔티티 간의 관계를 설정할 때 단방향과 양방향 관계를 정의할 수 있습니다. 이는 데이터베이스의 외래 키(Foreign Key) 관계를 객체 지향적으로 매핑하는 방식에 따라 달라집니다. ✅1️⃣ 단방향 관계 (Unidirectional) 단방향 관계는 한쪽 엔티티만 다른 엔티티를 참조하는 방식입니다. 📝 예제: 단방향 @OneToOne 관계. @Entity public class Passport { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String passportNumber; } @Entity public class Person { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @OneToOne @JoinColumn(name = "passport_id") // 외래 키를 Person 테이블이 가짐 private Passport passport; } 📌 특징 Person 엔티티에서만 Passport를 참조할 수 있습니다. Passport 엔티티에서는 Person을 전혀 모릅니다. 테이블 구조에서는 Person 테이블에 passport_id라는 외개 키가 존재합니다. 데이터 조회 시 Person을 가져올 때 Passport도 함께 조회할 수 있습니다. ✅2️⃣ 양방향 관계 (Bidirectional) 양방향 관계는 두 엔티티가 서로를 참조하는 방식입니다. 📝 예제: 양방향 @OneToOne 관계. @Entity public class Passport { @Id @GeneratedValue(startegy = GenerationType.IDENTITY) private Long id; private String passportNumber; @OneToOne(mappedBy = "passport") // Person 엔티티의 passport 필드와 연결 private Person person; } @Entity public class Person { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; @OneToOne @JoinColumn(name = "passport_id") // 실제 외래 키를 소유 private Passport passport; } 📌 특징 Person이 Passport를 참조하고, Passport도 Person을 참조합니다. Person이 외래 키를 소유(@JoinColumn(name = “passport_id))” Passport에서 mappedBy = “passport”를 사용하여 반대편에서 매핑을 담당함. 두 엔티티가 서로 참조하기 때문에 양방향 탐색 가능(예: passport.getPerson()) ✅3️⃣ 단방향 VS 양방향 비교. 구분 단방향 관계 양방향 관계 참조 방향 한쪽 엔티티만 다른 엔티티를 참조 두 엔티티가 서로 참조 테이블 구조 한쪽 테이블에만 외래 키 존재 테이블 구조는 동일하나 객체에서 상호 참조 조회 방향 한쪽에서만 조회 가능 양쪽에서 조회 가능 사용 예 단순한 연관 관계 상관 관계가 필요한 경우 ✅4️⃣ 언제 단방향/양방향을 선택해야 할까? 1️⃣ 단방향이 더 적합한 경우. 반대 방향에서 참조할 필요가 없는 경우 예: Order ➞ Payment (주문은 결제를 참조하지만, 결제는 주문을 참조할 필요 없음) 성능을 최적화하고 불필요한 데이터 로딩을 방지하고 싶은 경우 양방향 관계를 만들면 불필요한 연관 객체까지 로딩될 수 있음. FetchType.LAZY를 설정하더라도 관리 부담이 커질 수 있음. 2️⃣ 양방향이 더 적합한 경우. 양쪽에서 참조할 필요가 있는 경우 예: Member ↔ Team (회원이 팀을 참조하고, 팀도 회원 목록을 관리해야 함) 반대 엔티티를 쉽게 조회해야 하는 경우 예를 들어 Passport에서 Person을 조회하는 기능이 자주 필요하다면 양방향이 유리함. OneToMany, ManyToOne 관계에서는 성능 고려 후 양방향 설정을 할 수도 있음. ✅5️⃣ 정리 @OneToOne, @OneToMany, @ManyToOne, @ManyToMany 관계는 단방향과 양방향이 모두 가능합니다. 단방향은 한쪽에서만 참조, 양방향은 서로 참조합니다. 양방향 관계에서는 mappedBy를 사용하여 연관 관계의 주인을 지정해야 합니다. 불필요한 양방향 관계를 피하고, 필요한 경우에만 적용하여 성능과 유지보수성을 고려해야 합니다. 👉 일반적으로 단반향을 기본으로 하고, 필요할 때만 양방향을 추가하는 것이 좋습니다.
Backend Development
· 2025-02-11
📚[Backend Development] 계층형 댓글 목록 조회란 무엇일까요?
“📚[Backend Development] 계층형 댓글 목록 조회란 무엇일까요?” 🍎 Intro. 계층형 댓글(Hierachical Commmeents)이란, 댓글과 대댓글(답글)을 계층적으로 표시하는 방식입니다. 이는 일반적인 1차원 목록 형태의 댓글 조회와 달리, 부모-자식 관계를 유지하는 댓글 시스템입니다. ✅1️⃣ 계층형 댓글 목록 조회가 필요한 이유. ❌1️⃣ 일반적인 평면 댓글(flat comments) 방식의 한계. 일반적으로 게시글에 대한 댓글을 단순 목록(List) 형태로 조회. 하지만 답글(대댓글)이 많아질 경우, 계층 구조가 필요. ✅2️⃣ 계층형 댓글의 장점. 댓글에 대한 대댓글(답글)을 구조적으로 표현 가능. 유저가 대화 흐름을 쉽게 파악할 수 있음. 재귀적인 구조를 활용하여 대댓글이 몇 단계까지 달려도 정렬 가능. ✅2️⃣ 계층형 댓글의 데이터 구조. 계층형 댓글을 저장하는 방법은 여러 가지가 있지만, 일반적으로 “부모 댓글(parentId)”를 저장하여 댓글 간 관계를 유지합니다. 📌1️⃣ 테이블 구조 CREATE TABLE comments ( comment_id BIGINT AUTO_INCREMENT PRIMARY KEY, article_id BIGINT NOT NULl, -- 게시글 ID parent_id BIGINT NULL, -- 부모 댓글 ID (NULL이면 최상위 댓글) author VARCHAR(255) NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (parentt_id) REFERENCES comments(comment_id) ON DELETE CASCADE ); ✅ 주요 컬럼. comment_id ➞ 댓글의 고유 ID. article_id ➞ 해당 댓글이 속한 게시글 ID. parent_id ➞ 부모 댓글 ID (최상위 댓글이면 NULL). author ➞ 댓글 작성자. content ➞ 댓글 내용. created_at ➞ 댓글 작성 시간. ✅3️⃣ 계층형 댓글 목록 조회 API 구현 (Spring Boot + JPA) 🛠️1️⃣ Entity (계층형 구조) import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @Getter @NoArgsConstructor @Entity @Table(name = "comments") public class Comment { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long commentId; @ManyToOne @JoinColumn(name = "article_id", nullable = false) private Article article; @ManyToOne @JoinColumn(name = "parent_id") private Comment parent; // 부모 댓글 @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL) private List<Comment> replies = new ArrayList<>(); // 대댓글 리스트 private String author; private String content; private LocalDateTime createdAt = LocalDateTime.now(); } ✅ 부모 댓글(parent)과 대댓글(replies) 관계를 유지하여 계층 구조 형성. 🛠️2️⃣ Repository (계층형 댓글 조회) import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; @Repository public interface CommentRepository extends JpaRepository<Comment, Long> { List<Comment> findByArticle_ArticleIdAndParentIsNullOrderByCreatedAtDesc(Long articleId); } ✅ 최상위 댓글(부모가 없는 댓글)만 가져옴 ➞ 이후 replies 필드에서 대댓글을 가져옴. 🛠️3️⃣ Service (계층형 댓글 로직 구현) import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.util.List; @Service @RequireArgsConstructor public class CommentService { private final CommentService { private final CommentRepository commentRepository; public List<CommentResponse> getCommentsByArticle(Long articleId) { List<Comment> comments = commentRepository.findByArticle_ArticleIdAndParentIsNullOrderByCreatedAtDesc(articleId); return comments.stream().map(CommentResponse::fromEntity).toList(); } } } ✅ 최상위 댓글만 조회하고, 대댓글은 replies 필드를 통해 재귀적으로 가져옴. 🛠️4️⃣ Controller (API 요청 처리) import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/articles/{articleId}/comments") @RequiredArgsConstructor public class CommentController { private final CommentService commentService; @GetMapping public ResponseEntity<List<CommentResponse>> getComments(@PathVariable Long articleId) { return ResponseEntity.ok(commentService.getCommentsByArticle(articleId)); } } ✅ RESTful API 형식으로 GET /api/articles/{articleId}comments 요청을 처리. ✅4️⃣ 계층형 댓글 조회 API의 응답 예시 [ { "commentId": 1, "author": "John Doe", "content": "좋은 글이네요!", "createdAt": "2025-02-01T12:00:00Z", "replies": [ { "commentId": 2, "author": "Alice", "content": "저도 그렇게 생각해요!", "createdAt": "2025-02-01T12:05:00Z", "replies": [] } ] }, { "commentId": 3, "author": "Bob", "content": "궁금한 점이 있어요!", "createdAt": "2025-02-01T12:10:00Z", "replies": [] } ] ✅ replies 필드를 이용해 대댓글을 계층적으로 표현. ✅5️⃣ 계층형 댓글 조회의 SQL 방식. 📌1️⃣ 재귀 쿼리(CTE) WITH RECURSIVE comment_tree AS ( SELECT comment_id, parent_id, content, author, created_at FROM comments WHERE article_id = 123 AND parent_id IS NULL UNION ALL SELECT c.comment_id, c.parent_id, c.content, c.author, c.created_at FROM comments c INNER JOIN comment_tree ct ON c.parent_id = ct.comment_id ) SELECT * FROM comment_tree ORDER BY created_at; ✅ 재귀적으로 부모-자식 관계를 조회하여 계층적 데이터를 정렬. ✅6️⃣ 계층형 댓글 조회 API의 성능 최적화. 1️⃣ JPA의 @BatchSize 또는 JOIN FETCH 활용 ➞ N + 1 문제 해결. @Query("SELECT c FROM Comment c JOIN FETCH c c.replies WHERE c.article.articleId = :articleId") List<Comment> findCommentsWithReplies(@Param("articleId") Long articleId); 2️⃣ Redis 캐싱 적용 ➞ 자주 조회되는 댓글 목록을 캐싱하여 성능 향상 가능. ✅7️⃣ 계층형 댓글 조회 API의 활용 사례. 서비스 설명 블로그 블로그 게시글의 계층형 댓글 (ex: 네이버 블로그, 티스토리) 커뮤니티 게시판 댓글 + 대댓글 (ex: 디시인사이드, Reddit) SNS 소셜 미디어 댓글 (ex: 페이스북, 인스타그램) 이커머스 상품 리뷰 대댓글 (ex: 아마존, 쿠팡) ✅8️⃣ 결론. 계층형 댓글 목록 조회는 댓글과 대댓글(답글)을 계층적으로 표시하는 방식. 부모 댓글(parentId)을 저장하여 관계를 유지. Spring Boot + JPA 기반으로 쉽게 구현 가능. 재귀 쿼리(CTE) 또는 JOIN FETCH를 활용하여 최적화 가능.
Backend Development
· 2025-02-08
<
>
Touch background to close