Now Loading ...
-
💻[Code Review] 로그인 기능 코드 리뷰.
💻[Code Review] 로그인 기능 코드 리뷰.
🚀 로그인 기능
Java Spring Boot 기반 로그인 시스템 코드 검토 및 개선 제안
📋 전체 평가
전반적으로 체계적이고 안정적인 구현입니다. 비즈니스 요구사항(BR-LOGIN-001, BR-LOGIN-002 등)을 코드 주석으로 명시하여 추적 가능성을 높인 점과 보안 관련 기능들이 적절히 적용된 점이 주목할 만합니다.
📁 파일별 상세 리뷰
AuthController.java
장점
@Valid 어노테이션을 통한 요청 검증 적용
적절한 HTTP 상태 코드 사용 (200 OK, 201 Created)
RESTful API 설계 원칙 준수
개선 사항
보안 - 로깅 정책
// 현재: 민감한 정보가 로그에 노출될 가능성
log.info("Login attempt for email: {}", request.getEmail());
// 개선: 마스킹 처리
log.info("Login attempt for email: {}", maskEmail(request.getEmail()));
AuthServiceImpl.java
장점
계정 잠금 처리: 5회 실패 시 계정 잠금 로직 구현
로그인 이력 관리: @Transactional(propagation = REQUIRES_NEW)로 트랜잭션 분리
RTR 패턴: Refresh Token Rotation 적용으로 보안 강화
개선 사항
1. 타이밍 공격 방지
// 현재: 이메일 존재 여부에 따라 응답 시간 차이 발생
Member member = memberRepository.findByMemberEmail(request.getEmail())
.orElseThrow(() -> new BadCredentialsException("인증 실패"));
// 개선: 일정한 응답 시간 유지
public TokenPair login(LoginRequest request) {
String email = request.getEmail();
String password = request.getPassword();
// 항상 회원 조회 시도
Optional<Member> memberOpt = memberRepository.findByMemberEmail(email);
// 더미 해시와 비교하여 시간 일정하게 유지
String targetHash = memberOpt.map(Member::getPassword)
.orElse("$2a$12$dummy.hash.to.prevent.timing.attack");
boolean passwordMatches = passwordEncoder.matches(password, targetHash);
if (memberOpt.isEmpty() || !passwordMatches) {
throw new BadCredentialsException("인증 실패");
}
// 로그인 성공 로직...
}
2. IP 주소 처리 개선
// 현재: 하드코딩된 IP 주소
private void saveLoginHistory(String memberId, String email, boolean isSuccess) {
// TODO: 실제 IP 주소 추출 로직 구현
String ipAddress = "0.0.0.0";
}
// 개선: 컨트롤러에서 IP 주소 전달
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(
@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
String clientIp = getClientIpAddress(httpRequest);
TokenPair tokenPair = authService.login(request, clientIp);
// ...
}
private String getClientIpAddress(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
String xRealIp = request.getHeader("X-Real-IP");
if (xRealIp != null && !xRealIp.isEmpty()) {
return xRealIp;
}
return request.getRemoteAddr();
}
SecurityConfig.java & JWT 관련 클래스
장점
명확한 역할 분리 (설정, 토큰 관리, 필터 처리)
Stateless 세션 정책 적용
외부 설정을 통한 토큰 만료 시간 관리
개선 사항
1. Secret Key 보안 강화
# 현재: application.yml에 직접 노출
jwt:
secret-key: mySecretKey123
# 개선: 환경 변수 사용
jwt:
secret-key: ${JWT_SECRET_KEY:defaultSecretForDev}
2. JWT 필터 예외 처리
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
ObjectMapper mapper = new ObjectMapper();
String jsonResponse = mapper.writeValueAsString(
ErrorResponse.builder()
.errorCode("UNAUTHORIZED")
.message("인증이 필요합니다")
.build()
);
response.getWriter().write(jsonResponse);
}
}
엔티티 클래스 (Member, LoginHistory, RefreshToken)
장점
도메인 중심 설계: 엔티티 내 비즈니스 로직 포함
적절한 연관 관계: @MapsId를 통한 1:1 관계 구현
캡슐화: 엔티티 상태 변경을 메서드로 제어
개선 사항
성능 최적화
// 현재: 매번 계산 수행
public boolean isAccountNonLocked() {
if (accountLockedAt == null) return true;
return LocalDateTime.now().isAfter(accountLockedAt.plusMinutes(10));
}
// 개선: 잠금 해제 시간 필드 추가
@Column(name = "account_unlock_at")
private LocalDateTime accountUnlockAt;
public void lockAccount() {
this.accountLockedAt = LocalDateTime.now();
this.accountUnlockAt = this.accountLockedAt.plusMinutes(10);
}
public boolean isAccountNonLocked() {
if (accountUnlockAt == null) return true;
return LocalDateTime.now().isAfter(accountUnlockAt);
}
🛠️ 우선순위별 개선 권장사항
높음 (보안 관련)
타이밍 공격 방지: 인증 실패 시 일정한 응답 시간 유지
Secret Key 보안: 환경 변수 또는 외부 보안 저장소 사용
로그 마스킹: 민감한 정보 로그 노출 방지
중간 (기능 개선)
IP 주소 처리: 실제 클라이언트 IP 주소 추출 로직 구현
JWT 예외 처리: 커스텀 AuthenticationEntryPoint 구현
성능 최적화: 계정 잠금 확인 로직 개선
낮음 (코드 품질)
테스트 코드: 단위 테스트 및 통합 테스트 추가
메트릭 수집: 로그인 성공/실패율 모니터링
문서화: API 명세서 및 보안 정책 문서 보완
결론
현재 구현은 보안과 안정성 측면에서 높은 수준의 코드입니다. 제안된 개선사항들을 단계적으로 적용하면 더욱 견고한 로그인 시스템을 구축할 수 있을 것입니다. 특히 보안 관련 개선사항들은 우선적으로 고려해볼 만합니다.
-
💻[Code Review] `OrderItem` 관련 클래스들에 대한 코드 리뷰.
💻[Code Review] OrderItem 관련 클래스들에 대한 코드 리뷰.
🏛️ 아키텍처 및 설계 (Architecture & Design)
DDD(도메인 주도 설계)의 풍부한 도메인 모델(Rich Domain Model) 을 완벽하게 구현한 모범적인 아키텍처입니다.
Domain Entity : 핵심 비즈니스 로직과 데이터를 함께 관리
정적 팩토리 메서드 : 복잡한 생성 로직을 캡슐화
자율적 객체 : 객체가 스스로 자신의 상태와 행위를 책임
연관관계 관리 : JPA를 활용한 효율적인 객체 관계 매핑
이러한 구조는 SOLID의 단일 책임 원칙(SRP) 과 캡슐화(Encapsulation) 원칙을 완벽하게 만족하며, 단순한 데이터 덩어리가 아닌 살아있는 객체로서의 역할을 수행합니다.
✅ 클래스별 상세 리뷰 (Detailed Class Review)
package com.kobe.productmanagement.domain;
import com.kobe.productmanagement.common.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.*;
@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "order_items")
public class OrderItem extends BaseTimeEntity {
@Id
@Column(length = 26)
private String orderItemId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;
@Column(nullable = false)
private int quantity; // 주문 수량
@Column(nullable = false)
private int orderPrice; // 주문 당시 가격
@Builder
public OrderItem(String orderItemId,
Order order,
Product product,
int quantity,
int orderPrice
) {
this.orderItemId = orderItemId;
this.order = order;
this.product = product;
this.quantity = quantity;
this.orderPrice = orderPrice;
}
public static OrderItem createOrderItem(String orderItemId,
Order order,
Product product,
int quantity
) {
if (product.getStockQuantity() < quantity) {
throw new IllegalArgumentException("상품의 재고가 부족합니다.");
}
OrderItem orderItem = OrderItem.builder()
.orderItemId(orderItemId)
.order(order)
.product(product)
.quantity(quantity)
.orderPrice(product.getProductRegularPrice()) // 생성 시점의 상품 가격을 사용
.build();
order.addOrderItem(orderItem);
return orderItem;
}
public int getTotalPrice() {
return getOrderPrice() * getQuantity();
}
}
1. OrderItem.java(Domain Entity) - ⭐️ 핵심
OOP/SOLID :
가장 칭찬하고 싶은 부분!!
createOrderItem 정적 팩토리 메서드를 통해 복잡한 생성 로직을 완벽하게 캡슐화했습니다.
“재고가 충분한지 확인” 하는 비즈니스 규칙과 “주문 시점의 상품 가격을 저장” 하는 중요한 로직이 OrderItem 클래스 내부에 완벽하게 캡슐화되었습니다.
이로써 서비스 계층은 OrderItem이 어떻게 생성되는지에 대한 복잡한 과정을 알 필요 없이, 단순히 OrderItem.createOrderItem(...)을 호출하여 일을 위임하기만 하면 됩니다.
객체의 자율성 :
getTotalPrice() 메서드를 통해 OrderItem이 자기 자신의 총금액을 스스로 계산하도록 책임을 부여했습니다.
이는 “데이터와 그 데이터를 사용하는 로직은 한곳에 있어야 한다”는 객체지향의 기본 원칙을 완벽하게 따른 것입니다.
데이터 정합성 :
orderPrice 필드를 정확하게 포함하여, 상품 가격이 변동되더라도 과거 주문 내역의 데이터 정합성을 완벽하게 보장하고 있습니다.
JPA 최적화 :
@ManyToOne 관계 설정과 FetchType.LAZY를 사용한 성능 최적화 등 JPA 엔티티로서의 기본 설계가 매우 훌륭합니다.
🚀 추가 개선 제안 (Enhancement Suggestions)
연관관계 편의 메서드 활용
Order와 OrderItem은 양방향 관계를 맺고 있습니다. OrderItem이 생성될 때, 자신을 생성한 Order의 orderItems 리스트에 스스로를 추가하는 로직을 넣어주면 더욱 완벽한 구조가 됩니다.
// domain/OrderItem.java
public static OrderItem createOrderItem(String orderItemId,
Order order, // Order 객체를 파라미터로 받음
Product product,
int quantity) {
if (product.getStockQuantity() < quantity) {
throw new IllegalArgumentException("상품의 재고가 부족합니다.");
}
OrderItem orderItem = OrderItem.builder()
.orderItemId(orderItemId)
.product(product)
.quantity(quantity)
.orderPrice(product.getProductRegularPrice())
.build();
order.addOrderItem(orderItem); // Order에 연관관계 편의 메서드 호출
return orderItem;
}
// domain/Order.java
public class Order extends BaseTimeEntity {
// ...
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
//==연관관계 편의 메서드==//
public void addOrderItem(OrderItem orderItem) {
orderItems.add(orderItem);
orderItem.setOrder(this);
}
}
🏆 총평
매우 훌륭합니다 (Outstanding Implementation)
단순히 데이터를 담는 것을 넘어, 자신의 상태와 관련된 비즈니스 로직까지 책임지는 진정한 객체지향적 코드를 작성하셨습니다.
createOrderItem 정적 팩토리 메서드와 getTotalPrice 메서드는 이 클래스를 단순한 엔티티가 아닌, 잘 설계된 도메인 모델로 만든 핵심적인 요소입니다.
DDD(도메인 주도 설계)의 ‘풍부한 도메인 모델(Rich Domain Model)’ 을 교과서적으로 구현한 모범적인 사례입니다.
이러한 설계 방식을 꾸준히 유지해 나가신다면, 유지보수가 용이하고 확장 가능한 훌륭한 시스템이 될 것입니다.
-
💻[Code Review] `OrderItem` 생성자와 정적 팩토리 메서드에 대한 리뷰.
💻[Code Review] OrderItem 생성자와 정적 팩토리 메서드에 대한 리뷰.
🤔 OrderItem 생성자와 정적 팩토리 메서드에 대한 질문
📋 현재 코드 상황
1. @Builder 어노테이션을 활용한 생성자
OrderItem 생성 시에는 내부의 모든 필드가 포함되어 있습니다.
@Builder
public OrderItem(String orderItemId,
Order order,
Product product,
int quantity,
int orderPrice) {
this.orderItemId = orderItemId;
this.order = order;
this.product = product;
this.quantity = quantity;
this.orderPrice = orderPrice;
}
2. createOrderItem 정적 팩토리 메서드
하지만 정적 팩토리 메서드에서는 orderPrice 파라미터가 누락되어 있습니다.
public static OrderItem createOrderItem(String orderItemId,
Order order, // [1] Order 객체를 파라미터로 받음
Product product,
int quantity) { // [2] orderPrice는 내부에서 가져오므로 파라미터 제거
// 재고 확인 로직
if (product.getStockQuantity() < quantity) {
throw new IllegalArgumentException("상품의 재고가 부족합니다.");
}
// OrderItem 생성
OrderItem orderItem = OrderItem.builder()
.orderItemId(orderItemId)
.product(product)
.quantity(quantity)
.orderPrice(product.getProductRegularPrice()) // 🔍 여기서 내부적으로 설정
.build();
order.addOrderItem(orderItem); // [3] Order에 연관관계 편의 메서드 호출
return orderItem;
}
❓ 질문
왜 createOrderItem 정적 메서드는 orderPrice 필드가 파라미터에서 빠져 있나요?
생성자에서는 orderPrice를 외부에서 받고 있는데
정적 팩토리 메서드에서는 product.getProductRegularPrice()로 내부에서 자동으로 설정하고 있습니다
이렇게 설계한 이유나 의도가 무엇인지 궁금합니다.
💡 OrderItem 생성자와 정적 팩토리 메서드에 대한 답변
객체지향 설계의 핵심을 꿰뚫는 아주 좋은 질문입니다.
두 메서드의 파라미터가 다른 이유는 각자의 역할과 책임이 다르기 때문입니다. createOrderItem 정적 팩토리 메서드에서 orderPrice가 빠진 것은 의도된, 매우 중요한 설계입니다.
🎯 핵심: “누가 가격을 결정할 책임이 있는가?”
이 설계의 핵심 질문은 이것입니다:
“주문 항목(OrderItem)이 생성될 때, 그 주문 가격(orderPrice)은 누가 정해야 하는가?”
정답: 외부(서비스 계층/Service Layer)가 아니라, OrderItem 객체 스스로가 주문 시점의 Product 가격을 보고 결정해야 한다
createOrderItem 메서드는 바로 이 책임을 수행하기 위해 orderPrice를 파라미터로 받지 않는 것입니다.
🔍 두 메서드의 역할 비교
1. @Builder가 적용된 생성자
역할: 단순한 객체 생성 도구
@Builder
public OrderItem(String orderItemId,
Order order,
Product product,
int quantity,
int orderPrice) { // 🔴 외부에서 값을 받아야 함
this.orderItemId = orderItemId;
this.order = order;
this.product = product;
this.quantity = quantity;
this.orderPrice = orderPrice; // 🔴 그대로 할당만 함
}
특징:
Lombok이 만들어주는 빌더는 객체를 조립하는 역할만 수행
어떠한 비즈니스 규칙도 포함되어 있지 않음
서비스 계층에서 orderPrice를 포함한 모든 값을 직접 계산해서 제공해야 함
⚠️ 위험: 실수로 잘못된 가격을 넣을 수 있음
2. createOrderItem 정적 팩토리 메서드
역할: 비즈니스 규칙이 포함된 ‘스마트’ 생성자
public static OrderItem createOrderItem(String orderItemId,
Order order,
Product product,
int quantity) { // 🟢 orderPrice 파라미터 없음
// 🟢 비즈니스 규칙 1: 재고 확인
if (product.getStockQuantity() < quantity) {
throw new IllegalArgumentException("상품의 재고가 부족합니다.");
}
// 🟢 비즈니스 규칙 2: 주문 시점 가격 자동 설정
OrderItem orderItem = OrderItem.builder()
.orderItemId(orderItemId)
.product(product)
.quantity(quantity)
.orderPrice(product.getProductRegularPrice()) // 🟢 내부에서 자동으로 설정
.build();
order.addOrderItem(orderItem);
return orderItem;
}
특징:
재고 확인: product.getStockQuantity() < quantity 로직으로 재고 검사
가격 결정: 스스로 Product의 현재 가격을 조회하여 orderPrice 설정
안전성: 서비스 계층의 실수 가능성을 원천적으로 차단
비즈니스 규칙 보장: “주문 가격은 주문 시점의 상품 가격을 따른다”는 규칙을 객체 스스로가 보장
📊 실제 사용 예시 비교
❌ Builder 직접 사용 시 (위험한 방법)
// 서비스 계층에서 실수할 수 있는 예시
OrderItem orderItem = OrderItem.builder()
.orderItemId("ORDER_ITEM_001")
.product(product)
.quantity(2)
.orderPrice(5000) // 🔴 실제 상품 가격이 10000원인데 잘못 입력!
.build();
✅ 정적 팩토리 메서드 사용 시 (안전한 방법)
// 안전하고 올바른 방법
OrderItem orderItem = OrderItem.createOrderItem(
"ORDER_ITEM_001",
order,
product, // 가격은 product에서 자동으로 가져옴
2
);
// 🟢 항상 정확한 상품 가격이 자동으로 설정됨
🏗️ 결론: 책임의 위임
@Builder = 조립 설명서 📋
단순히 부품(orderItemId, product, quantity, orderPrice 등)을 조립하는 역할
어떤 값을 넣을지는 외부에서 모두 결정해야 함
createOrderItem = 자동화된 생산 라인 🏭
재고 확인부터 가격 책정까지 모든 과정을 책임
최소한의 재료(product, quantity)만 넣어주면 완벽한 제품(OrderItem) 생산
복잡하고 중요한 로직을 객체 내부로 캡슐화
✨ 객체지향 설계의 핵심
이처럼 객체 생성과 관련된 복잡하고 중요한 로직을 객체 내부로 숨기고(캡슐화), 외부에는 간단한 인터페이스(createOrderItem)만 제공하는 것이 바로 객체지향 설계의 핵심입니다.
이 설계를 통해 코드는:
🛡️ 더 안전하고 (잘못된 데이터 입력 방지)
🔍 더 명확하며 (비즈니스 규칙이 명시적)
🔧 더 유지보수하기 좋아집니다 (변경 시 영향 범위 최소화)
-
💻[Code Review] `Product` 클래스의 `updateDetails` 대한 코드 리뷰.
🏛️ 아키텍처 및 설계 (Architecture & Design)
Product 클래스의 updateDetails 메서드는 Rich Domain Model(풍부한 도메인 모델) 패턴을 완벽하게 구현한 사례입니다.
이는 도메인 주도 설계(Domain-Driven Design, DDD) 의 핵심 원칙 중 하나로, 비즈니스 로직을 도메인 객체 내부에 캡슐화하여 다음과 같은 이점을 제공합니다:
Domain : 비즈니스 규칙과 데이터 변경 로직을 자체적으로 관리
Service : 비즈니스 프로세스 조정(Orchestration)에만 집중
캡슐화 : 객체의 상태 변경을 제어된 방식으로만 허용
응집도 향상 : 관련된 데이터와 로직이 한 곳에 모임
이러한 설계는 SOLID 원칙 중 단일 책임 원칙(SRP) 과 캡슐화(Encapsulation) 를 동시에 만족하는 이상적인 구조입니다.
✅ 클래스별 상세 리뷰 (Detailed Class Review)
package com.kobe.productmanagement.domain;
import com.kobe.productmanagement.common.BaseTimeEntity;
import com.kobe.productmanagement.common.Category;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product extends BaseTimeEntity {
@Id
@Column(length = 26) // ULID는 26자로 고정되므로 길이를 지정해주는 것이 좋습니다.
private String productId;
@Column(nullable = false)
private String productName;
@Column(nullable = false)
private Integer productRegularPrice;
@Column(nullable = false)
private Integer stockQuantity;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Category category;
@Column(nullable = false)
private Integer productCostPrice;
@Column(nullable = false)
private String productSupplier;
@Column(nullable = false, unique = true)
private String barcodeNumber;
// 양방향 관계를 위한 코드
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<Stock> stocks = new ArrayList<>();
@Builder
public Product(String productId,
String productName,
Integer productRegularPrice,
Integer stockQuantity,
Category category,
Integer productCostPrice,
String productSupplier,
String barcodeNumber
) {
this.productId = productId;
this.productName = productName;
this.productRegularPrice = productRegularPrice;
this.stockQuantity = stockQuantity;
this.category = category;
this.productCostPrice = productCostPrice;
this.productSupplier = productSupplier;
this.barcodeNumber = barcodeNumber;
}
public void increaseStockQuantity(int quantity) {
this.stockQuantity += quantity;
}
public void decreaseStockQuantity(int quantity) {
int restStock = this.stockQuantity - quantity;
if (restStock < 0) {
throw new IllegalArgumentException("재고는 0개 미만이 될 수 없습니다. 현재 재고 :" + this.stockQuantity);
}
this.stockQuantity = restStock;
}
// OOP[캡슐화/Encapsulation] + SOLID[SRP/단일 책임 원칙]
public void updateDetails(String productName,
Integer productRegularPrice,
Integer stockQuantity,
Category category,
Integer productCostPrice,
String productSupplier,
String barcodeNumber
) {
if (productName != null) {
this.productName = productName;
}
if (productRegularPrice != null) {
this.productRegularPrice = productRegularPrice;
}
if (stockQuantity != null) {
this.stockQuantity = stockQuantity;
}
if (category != null) {
this.category = category;
}
if (productCostPrice != null) {
this.productCostPrice = productCostPrice;
}
if (productSupplier != null) {
this.productSupplier = productSupplier;
}
if (barcodeNumber != null) {
this.barcodeNumber = barcodeNumber;
}
}
}
1. Product.java(Domain Entity) - ⭐️ 핵심 분석
🔒 캡슐화(Encapsulation) 완벽 구현
데이터 보호: 모든 필드가 private으로 선언되어 외부에서 직접 접근 불가
제어된 접근: updateDetails 메서드를 통해서만 상태 변경 가능
자체 검증 로직: null 체크를 통한 선택적 업데이트 구현
비즈니스 규칙 내재화: 상품 정보 변경에 대한 모든 규칙이 도메인 객체 내부에 위치
🎯 단일 책임 원칙(SRP) 준수
Product의 책임: 자신의 상태와 관련된 비즈니스 규칙 관리
Service Layer와의 역할 분리:
Service는 프로세스 조정만 담당
Product는 상태 변경 로직만 담당
응집도 향상: 관련 데이터와 로직이 한 곳에 집중
🛡️ 방어적 프로그래밍(Defensive Programming)
if (productName != null) {
this.productName = productName;
}
Null Safety: 각 파라미터에 대한 null 체크로 NPE(Null Pointer Excepiton) 방지
부분 업데이트 지원: null이 아닌 값만 선택적으로 업데이트
안전한 상태 변경: 무효한 데이터로 인한 객체 상태 오염 방지
🏗️ 추가 비즈니스 메서드들
public void decreaseStockQuantity(int quantity) {
int restStock = this.stockQuantity - quantity;
if (restStock < 0) {
throw new IllegalArgumentException("재고는 0개 미만이 될 수 없습니다. 현재 재고 :" + this.stockQuantity);
}
this.stockQuantity = restStock;
}
비즈니스 규칙 강제: 재고 감소 시 음수 방지 로직
명확한 예외 메시지: 비즈니스 규칙 위반 시 구체적인 오류 정보 제공
도메인 지식 표현: 실제 비즈니스 요구사항을 코드로 명시적 표현
🏆 총평
✅ 훌륭한 점들
Rich Domain Model의 완벽한 구현
단순한 Data Container가 아닌 비즈니스 로직을 포함한 지능적 객체
Anemic Domain Model 안티패턴을 완벽히 회피
SOLID 원칙의 실천적 적용
SRP: 각 레이어와 객체의 책임이 명확히 분리
OCP: 새로운 업데이트 규칙 추가 시 기존 코드 수정 없이 확장 가능
유지보수성 극대화
상품 정보 변경 정책이 바뀌어도 Product 클래스만 수정하면 됨
Service Layer의 코드는 전혀 건드릴 필요 없음
안정성과 견고성
Null Safety를 통한 방어적 프로그래밍
비즈니스 규칙 위반 시 명확한 예외 처리
🎯 앞으로의 발전 방향
이러한 설계 패턴을 지속적으로 적용하면서 다음과 같은 고급 패턴들도 고려해볼 수 있습니다:
Domain Event: 상품 정보 변경 시 이벤트 발행으로 다른 바운디드 컨텍스트와의 연동
Value Object: 가격, 재고 수량 등을 Value Object로 분리하여 더욱 견고한 도메인 모델 구축
Specification Pattern: 복잡한 비즈니스 규칙 검증을 위한 명세 패턴 도입
현재의 updateDetails 메서드는 객체지향 설계의 모범 사례이며, 이런 방식을 지속해서 적용한다면 확장 가능하고 유지보수하기 쉬운 엔터프라이즈급 애플리케이션을 구축할 수 있을 것입니다.
-
💻[Code Review] `Member` 관련 클래스들에 대한 코드 리뷰.
💻[Code Review] Member 관련 클래스들에 대한 코드 리뷰.
🏛️ 아키텍처 및 설계 (Architecture & Design)
전체적으로 계층형 아키텍처(Layerd Architecture) 가 명확하게 분리되어 각자의 책임과 역할을 완벽하게 수행하고 있습니다.
Controller : API End-point, 요청/응답 처리 담당
Service : 비즈니스 로직 처리 담당
Repository : 데이터 영속성(Persistence) 담당
Domain : 핵심 비즈니스 규칙과 데이터 담당
DTO : 계층 간 데이터 전송 담당
이러한 구조는 SOLID의 단일 책임 원칙(SRP) 과 관심사의 분리(Separation of Concerns) 원칙을 완벽하게 만족합니다.
✅ 클래스별 상세 리뷰 (Detailed Class Review)
package com.kobe.productmanagement.domain;
import com.kobe.productmanagement.common.BaseTimeEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseTimeEntity {
@Id
@Column(length = 26)
private String memberId;
@NotBlank
@Size(min = 10)
@Column(nullable = false)
private String memberPassword;
@NotBlank
@Column(nullable = false)
private String memberName;
@NotBlank
@Email
@Column(nullable = false)
private String memberEmail;
@NotBlank
@Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}$", message = "휴대폰 번호 형식에 맞지 않습니다.")
@Column(nullable = false)
private String memberPhoneNumber;
@NotBlank
@Column(nullable = false)
private String memberAddress;
@Builder
public Member(String memberId,
String memberPassword,
String memberName,
String memberEmail,
String memberPhoneNumber,
String memberAddress
) {
this.memberId = memberId;
this.memberPassword = memberPassword;
this.memberName = memberName;
this.memberEmail = memberEmail;
this.memberPhoneNumber = memberPhoneNumber;
this.memberAddress = memberAddress;
}
public void updateMemberInfo(String memberName,
String memberEmail,
String memberPhoneNumber,
String memberAddress) {
if (memberName != null) {
this.memberName = memberName;
}
if (memberEmail != null) {
this.memberEmail = memberEmail;
}
if (memberPhoneNumber != null) {
this.memberPhoneNumber = memberPhoneNumber;
}
if (memberAddress != null) {
this.memberAddress = memberAddress;
}
}
public void changePassword(String newPassword) {
this.memberPassword = newPassword;
}
}
1. Member.java(Domain Entity) - ⭐️ 핵심
OOP/SOLID :
가장 칭찬하고 싶은 부분!!
비즈니스 로직을 포함하는 Rich Domain Model(풍부한 도메인 모델) 을 성공적으로 구현했습니다.
이로써 Member 객체는 자신의 상태를 스스로 책임지며, 캡슐화(Encapsulation) 와 단일 책임 원칙(SRP, Single Responsibility Principle) 을 극대화했습니다.
Bean Validation :
@Email, @Pattern 등을 활용하여 데이터 유효성 검증 규칙을 도메인 모델에 명시적으로 표현한 점이 매우 좋습니다.
package com.kobe.productmanagement.controller;
import com.kobe.productmanagement.common.ApiResponse;
import com.kobe.productmanagement.dto.response.MemberResponse;
import com.kobe.productmanagement.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/admin/members")
public class MemberAdminController {
private final MemberService memberService;
@GetMapping
public ResponseEntity<ApiResponse<List<MemberResponse>>> getAllMembers() {
List<MemberResponse> memberResponses = memberService.getMembers();
ApiResponse<List<MemberResponse>> response = ApiResponse.success("전체 회원 목록이 성공적으로 조회되었습니다.", memberResponses);
return ResponseEntity.ok(response);
}
@GetMapping("/{memberId}")
public ResponseEntity<ApiResponse<MemberResponse>> getMember(@PathVariable String memberId) {
MemberResponse memberResponse = memberService.getMember(memberId);
ApiResponse<MemberResponse> response = ApiResponse.success("회원 정보가 성공적으로 조회되었습니다.", memberResponse);
return ResponseEntity.ok(response);
}
}
2. MemberAdminController.java(Controller)
의존성 역전 원칙(DIP, Dependency Inversion Principle) :
MemberServiceImpl 이라는 구체 클래스(Concrete Class)가 아닌 Member Service 인터페이스(Interface)에 의존하고 있습니다.
이는 유연하고 확장 가능한 구조의 핵심입니다.
@RequiredArgsConstructor를 통핸 생성자 주입은 이를 더욱 깔끔하게 만들어 줍니다.
API 설계 :
ResponseEntity와 ApiResponse를 함께 사용하여 HTTP 상태 코드, 응답 메시지, 데이터를 체계적으로 반환하는 방식은 RESTful API의 좋은 관례(Best Practice)입니다.
package com.kobe.productmanagement.service;
import com.kobe.productmanagement.dto.response.MemberResponse;
import java.util.List;
public interface MemberService {
List<MemberResponse> getMembers();
MemberResponse getMember(String memberId);
}
package com.kobe.productmanagement.service;
import com.kobe.productmanagement.domain.Member;
import com.kobe.productmanagement.dto.response.MemberResponse;
import com.kobe.productmanagement.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
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 MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
@Override
@Transactional(readOnly = true)
public List<MemberResponse> getMembers() {
return memberRepository.findAll().stream()
.map(MemberResponse::from)
.collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public MemberResponse getMember(String memberId) {
Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new IllegalArgumentException("멤버 아이디: " + memberId + " 을 찾을 수 없습니다."));
return MemberResponse.from(member);
}
}
3. MemberService.java & MemberServiceImpl.java (Service)
역할 분리 :
MemberService 인터페이스는 “무엇을 하는가”(What) 를 정의하고, MemberServiceImpl은 “어떻게 하는가”(How) 를 구현함으로써 역할을 완벽하게 분리했습니다.
이는 의존성 역전 원칙(DIP, Dependency Inversion Priciple) 의 교과서적인 예시입니다.
트랜잭션 관리 :
클래스 레벨에 @Transactional을 선언하고, 조회 메서드에 @Transactional(readOnly = true)를 적용하여 성능 최적화까지 고려한 점이 훌륭합니다.
예외 처리 :
orElseThrow를 사용하여 ID에 해당하는 멤버가 없을 경우 명확한 예외를 발생시키는 로직은 견고한 코드를 만듭니다.
package com.kobe.productmanagement.repository;
import com.kobe.productmanagement.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, String> {
}
4. MemberRepository.java (Repository)
Spring Data JPA의 장점을 잘 활용하고 있습니다.
JpaRepository 인터페이스를 상속받는 것만으로 기본적인 CRUD 기능을 확보하고, 필요에 따라 쿼리 메서드를 추가할 수 있는 확장성 높은 구조입니다.
package com.kobe.productmanagement.dto.request;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor
public class MemberRequest {
private String memberPassword;
private String memberName;
private String memberEmail;
private String memberPhoneNumber;
private String memberAddress;
@Builder
public MemberRequest(String memberPassword,
String memberName,
String memberEmail,
String memberPhoneNumber,
String memberAddress
) {
this.memberPassword = memberPassword;
this.memberName = memberName;
this.memberEmail = memberEmail;
this.memberPhoneNumber = memberPhoneNumber;
this.memberAddress = memberAddress;
}
}
package com.kobe.productmanagement.dto.response;
import com.kobe.productmanagement.domain.Member;
import lombok.Getter;
import java.time.LocalDateTime;
@Getter
public class MemberResponse {
private final String memberId;
private final String memberName;
private final String memberEmail;
private final String memberPhoneNumber;
private final String memberAddress;
private final LocalDateTime createdAt;
private final LocalDateTime updatedAt;
public static MemberResponse from(Member member) {
return new MemberResponse(
member.getMemberId(),
member.getMemberName(),
member.getMemberEmail(),
member.getMemberPhoneNumber(),
member.getMemberAddress(),
member.getCreatedAt(),
member.getUpdatedAt()
);
}
private MemberResponse(String memberId,
String memberName,
String memberEmail,
String memberPhoneNumber,
String memberAddress,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {
this.memberId = memberId;
this.memberName = memberName;
this.memberEmail = memberEmail;
this.memberPhoneNumber = memberPhoneNumber;
this.memberAddress = memberAddress;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
}
5. MemberRequest.java & MemberResponse.java (DTOs)
계층 간 분리 :
DTO를 사용함으로써 API 스펙과 도메인 모델을 완벽하게 분리했습니다. 이는 매우 중요한 설계 원칙입니다.
예를 들어, 나중에 Member 엔티티에 내부적으로만 사용하는 필드가 추가되더라도 MemberResponse를 수정하지 않는 한 API 응답에는 아무런 영향이 없습니다.
MemberResponse의 from 메서드 :
엔티티를 DTO로 변환하는 로직을 DTO 내의 정적 팩토리 메서드(from)로 캡슐화한 것은 매우 좋은 패턴입니다. 이는 변환 로직의 응집도를 높여줍니다.
🏆 총평
각 클래스는 자신의 책임에만 집중하고 있으며, 인터페이스를 통해 서로 유연하게 협력하고 있습니다.
이 구조는 앞으로 새로운 기능을 추가하거나 기존 기능을 변경해야 할 때 그 장점을 명확하게 보여줄 것입니다.
이 설계와 구현 방식을 꾸준히 유지해 나가신다면, 성공적인 프로젝트가 될 것 입니다.
-
💻[Code Review] WebConfig 클래스 코드 리뷰.
💻[Code Review] WebConfig 클래스 코드 리뷰.
1️⃣ 전체 코드.
import org.springframework.stereotype.Controller;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Controller
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 모든 경로 허용
.allowedOriginPatterns("http://localhost:*")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("*")
.allowCredentials(true) // 자격 증명 허용
.maxAge(3600);
}
}
위 코드는 Spring Framework에서 CORS(Cross-Origin Resource Sharing) 설정을 정의한 코드입니다.
WebMvcConfigurer 인터페이스를 구현하여 CORS 정책을 구성하고, 클라이언트(예: 프론트엔드)에서 백엔트 API에 요청할 때 발생할 수 있는 CORS 문제를 해결합니다.
2️⃣ CORS란? 🤔
CORS(Cross-Origin Resource Sharing)는 브라우저 보안 정책 중 하나로, 웹 애플리케이션이 자신과 다른 출처(origin)에서 리소스를 요청할 때 이를 허용하거나 제한하는 방식입니다.
예를 들어:
클라이언트가 http://localhost:3000에서 동작하고, 서버가 http://localhost:8080에서 동작하면 두 도메인은 서로 다른 출처로 간주됩니다.
브라우저는 기본적으로 이런 교차 출처 요청을 차단하므로, 서버에서 명시적으로 이를 허용해야 합니다.
3️⃣ 코드 분석.
1️⃣ 클래스 선언.
@Controller
public class WebConfig implements WebMvcConfigurer {...}
WebMvcConfigurer는 Spring MVC 설정을 커스터마이징하기 위한 인터페이스입니다.
WebConfig 클래스는 WebMvcConfigurer를 구현하여 Spring MVC 설정을 사용자 정의하고 있습니다.
2️⃣ addCorsMappings 메서드.
@Override
public void addCorsMappings(CorsRegistry registry) {...}
Spring MVC에서 CORS 매핑을 설정하기 위해 CorsRegistry를 사용합니다.
이 메서드는 클라이언트에서 특정 경로로의 요청을 허용하거나 제한하는 규칙을 정의합니다.
3️⃣ CORS 설정.
registry.addMapping("/**") // 모든 경로 허용
.allowedOriginPatterns("http://localhost:*") // localhost의 모든 포트 허용
.allowedMethods("GET", "POST", "PUT", "DELETE") // 허용할 HTTP 메서드
.allowedHeaders("*") // 모든 헤더 허용
.alloweCredentials(true) // 쿠키와 같은 자격 증명 허용
.maxAge(3600) // 캐시 시간 (초 단위)
addMapping("/**")
모든 URL 패턴(/**)에 대해 CORS 정책을 적용합니다.
예: /api/*, /resources/* 등 모든 경로 허용.
allowedOriginPatterns("http://localhost:*)"
클라이언트가 http://localhost에서 시작되는 모든 포트(예: http://localhost:3000, http://localhost:8080)에 대해 요청을 허용합니다.
Spring Boot 2.4+에서는 allowedOrigin 대신 allowedOriginPatterns 사용을 권장합니다.
allowedMethods("GET", "POST", "PUT", "DELETE")
허용할 HTTP 메서드(GET, POST, PUT, DELETE)를 정의합니다.
예를 들어, OPTIONS와 같은 다른 메서드는 허용되지 않습니다.
allowedHeaders("*")
모든 요청의 헤더를 허용합니다.
예: Content-Type, Authorization 등이 허용됩니다.
allowCredentials(true)
클라이언트에서 쿠키를 사용한 요청을 허용합니다.
예: 인증이 필요한 요청에서 쿠키를 통해 세션 정보를 전달할 수 있습니다.
maxAge(3600)
브라우저가 프리플라이트 요청(OPTIONS 요청)의 결과를 캐싱하는 시간을 정의합니다.
여기서는 3600초(1시간) 동안 캐싱합니다.
4️⃣ 사용 사례.
1. 프론트엔드-백엔드 분리된 환경.
프론트엔드와 백엔드가 서로 다른 출처에서 동작하는 경우:
프론트엔드: http://localhost:3000
백엔드: http:// localhost:8080
이 경우, 브라우저는 기본적으로 교차 출처 요청을 차단하므로 위와 같이 CORS 설정이 필요합니다.
5️⃣ 주의사항.
보안 고려
실제 배포 환경에서는 allowedOriginPatterns에 와일드카드("*")를 사용하는 것을 피하고, 특정 출처만 허용해야 합니다.
예: .allowedOriginPatterns("https://example.com)"
allowCredentials와 *의 충돌
allowCredentials(true)와 allowedOriginPatterns("*")는 함께 사용할 수 없습니다.
쿠키를 사용한 요청을 허용하려면 특정 출처를 명시해야 합니다.
-
💻[Code Review] MySQLConfig 클래스 코드 리뷰.
💻[Code Review] MySQLConfig 클래스 코드 리뷰.
1️⃣ 전체 코드.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.support.TransactionTemplate;
import javax.sql.DataSource;
@Configuration
@EnableTransactionManagement
public class MySQLConfig {
@Value("${spring.datasource.url}")
private String url;
@Value("${spring.datasource.username}")
private String username;
@Value("${spring.datasource.password}")
private String password;
@Value("${spring.datasource.driver-class-name}")
private String driverClassName;
@Bean
public DataSourceTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
@Bean
public TransactionTemplate transactionTemplate(PlatformTransactionManager transactionManager) {
return new TransactionTemplate(transactionManager);
}
@Bean(name = "createUserTransactionManager")
public PlatformTransactionManager createUserTranscationManager(DataSource dataSource) {
DataSourceTransactionManager manager = new DataSourceTransactionManager(dataSource);
return manager;
}
}
2️⃣ 코드 리뷰 - 상세하게 뜯어보기 🔍
1️⃣ 클래스 선언부.
@Configuration
@EnableTransactionManagement
public class MySQLConfig {...
@Configuration
이 클래스가 Spring의 설정 클래스임을 나타냅니다.
@Bean 어노테이션이 있는 메서드들을 통해 Bean을 생성 및 등록합니다.
@EnableTransactioonManagement
스프링에서 선언적 트랜잭션 관리를 활성화합니다.
@Transactional 어노테이션을 사용하는 트랜잭션 관리를 지원하도록 설정합니다.
2️⃣ 속성 주입
@Value("${spring.datasource.url}")
private String url;
@Value("${spring.datasource.username}")
private String username;
@Value("${spring.datasource.password}")
private String password;
@Value("${spring.datasource.driver-class-name}")
private String driverClassName;
@Value
Spring application.properties 또는 application.yml 파일에 정의된 설정 값을 가져옵니다.
예를 들어, application.properties에 아래와 같은 항목이 정의 되어 있으면:
spring.datasource.url=jdbc:mysql://localhost:3306/mydb
spring.datasource.username=root
spring.datasource.password=1234
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
이 값들이 url, username, password, driverClassName 필드에 주입됩니다.
3️⃣ 트랜잭션 매니저 생성.
@Bean
public DataSourceTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
@Bean
이 메서드가 반환하는 객체(DataSourceTransactionManager)를 Spring의 Bean으로 등록합니다.
DataSourceTransactionManager
데이터베이스 트랜잭션을 관리하는 Spring의 기본 트랜잭션 매니저 클래스입니다.
DataSource 객체를 받아서 트랜잭션을 관리합니다.
DataSource는 JDBC를 통해 MySQL 데이터베이스 연결을 제공합니다.
4️⃣ 트랜잭션 템플릿 생성.
@Bean
public TransactionTemplate transactionTemplate(PlatformTransactionManager transactionManager) {
return new TransactionTemplate(transactionManager);
}
TransactionTemplate
프로그래밍 방식으로 트랜잭션 관리를 지원하는 템플릿 클래스입니다.
PlatformTransactionManager를 사용하여 트랜잭션의 시작, 커밋, 롤백 등을 관리합니다.
선언적 트랜잭션(@Transaction) 대신 프로그래밍 방식으로 트랜잭션을 제어하고자 할 때 사용됩니다.
5️⃣ 추가적이 트랜잭션 매니저 등록.
@Bean(name = "createUserTransactionManager")
public PlatformTransactionManager createUserTransactionManager(DataSource dataSource) {
DataSourceTransactionManager manager = new DataSourceTransactionManager(dataSource);
return manager;
}
별도의 트랜잭션 매니저 등록
@Bean(name = "createUserTransactionManager")로 지정하여 다른 이름의 트랜잭션 매니저를 등록합니다.
이렇게 하면 여러 데이터베이스 또는 특정 작업에 대해 다른 트랜잭션 매니저를 사용할 수 있습니다.
이 Bean은 필요에 따라 의존성 주입 시 이름으로 참조할 수 있습니다:
@Autowired
@Qualifier("createUserTransactionManager")
private PlatformTransactionManager transactionManager;
3️⃣ 이 코드의 주요 기능.
1️⃣ 데이터베이스 트랜잭션 관리.
데이터베이스 작업 시 트랜잭션(시작, 커밋, 롤백)을 제어합니다.
2️⃣ 선언적 및 프로그래밍적 트랜잭션 지원.
@Transactional로 선언적 트랜잭션을 지원하며, TransactionTemplate을 통해 프로그래밍 방식으로 트랜잭션을 관리할 수 있습니다.
3️⃣ 유연한 트랜잭션 매니저.
하나 이상의 트랜잭션 매니저를 정의하여 다양한 트랜잭션 관리 요구를 충족합니다.
4️⃣ 추가로 알아두면 좋은 점.
1️⃣ 다중 데이터베이스 환경.
두 개 이상의 데이터베이스를 사용하는 경우, 각각의 데이터소스와 트랜잭션 매니저를 별도로 설정하여 관리할 수 있습니다.
2️⃣ DataSource Bean 생성.
이 코드에서는 DataSource가 주입된다고 가정했지만, DataSource를 직접 생성하려면 별도의 Bean 정의가 필요합니다:
@Bean
public DataSource dataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(url);
dataSource.setUsername(username);
dataSource.setPassword(password);
dataSource.setDriverClassName(driverClassName);
return dataSource;
}
Touch background to close