오늘 공부해볼 내용은 함수 챕터이다. 클린 코드 책을 바탕으로 내 코드를 리팩토링 해 보겠다.
리팩토링 전 코드
변명이지만 일단 기능 구현에 초점을 맞추고 추후 리팩토링을 고려해...일부러 이렇게 막막히 작성한것도있다.
@Service
@Transactional
@RequiredArgsConstructor
public class ReviewService {
private final ReviewRepository reviewRepository;
private final TimeDealRepository timeDealRepository;
private final PurchaseRepository purchaseRepository;
private final UserRepository userRepository;
private final ReviewCommentRepository commentRepository;
// ReviewService에서 수정
public Page<ReviewResponseDto> getReviewsByTimeDeal(Long timeDealId, Pageable pageable) {
TimeDeal timeDeal = timeDealRepository.findById(timeDealId)
.orElseThrow(() -> new EntityNotFoundException("해당 타임딜을 찾을 수 없습니다."));
return reviewRepository.findByTimeDealAndDeletedAtIsNullOrderByCreatedAtDesc(timeDeal, pageable)
.map(ReviewResponseDto::from);
}
public ReviewResponseDto createReview(ReviewRequestDto requestDto, String loginId) {
// 1. 구매 여부 확인
TimeDeal timeDeal = timeDealRepository.findById(requestDto.timeDealId())
.orElseThrow(() -> new EntityNotFoundException("상품을 찾을 수 없습니다."));
User user = userRepository.findByLoginId(loginId)
.orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다."));
// 해당 사용자의 구매 내역 확인
Purchase purchase = purchaseRepository.findByUserAndTimeDeal(user, timeDeal)
.orElseThrow(() -> new IllegalStateException("구매하지 않은 상품에는 리뷰를 작성할 수 없습니다."));
// 2. 이미 리뷰를 작성했는지 확인
if (reviewRepository.existsByPurchaseAndDeletedAtIsNull(purchase)) {
throw new IllegalStateException("이미 리뷰를 작성하셨습니다.");
}
// 3. 리뷰 생성
Review review = new Review();
review.setUser(user);
review.setTimeDeal(timeDeal);
review.setPurchase(purchase);
review.setRating(requestDto.rating());
review.setContent(requestDto.content());
Review savedReview = reviewRepository.save(review);
return convertToDto(savedReview);
}
private ReviewResponseDto convertToDto(Review review) {
return ReviewResponseDto.from(review); // Record의 정적 팩토리 메서드 사용
}
// 댓글 작성
public CommentResponseDto addComment(Long reviewId, CommentRequestDto requestDto, String loginId) {
Review review = reviewRepository.findById(reviewId)
.orElseThrow(() -> new EntityNotFoundException("리뷰를 찾을 수 없습니다."));
User user = userRepository.findByLoginId(loginId)
.orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다."));
ReviewComment comment = ReviewComment.builder()
.review(review)
.user(user)
.content(requestDto.content())
.build();
return CommentResponseDto.from(commentRepository.save(comment));
}
// 댓글 목록 조회
@Transactional(readOnly = true)
public Page<CommentResponseDto> getCommentsByReview(Long reviewId, Pageable pageable) {
Review review = reviewRepository.findById(reviewId)
.orElseThrow(() -> new EntityNotFoundException("리뷰를 찾을 수 없습니다."));
return commentRepository.findByReviewAndDeletedAtIsNull(review, pageable)
.map(CommentResponseDto::from);
}
// 댓글 삭제 (soft delete)
public void deleteComment(Long commentId, String loginId) {
ReviewComment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new EntityNotFoundException("댓글을 찾을 수 없습니다."));
User user = userRepository.findByLoginId(loginId)
.orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다."));
// 댓글 작성자 또는 리뷰 작성자만 삭제 가능
if (!comment.getUser().equals(user) && !comment.getReview().getUser().equals(user)) {
throw new IllegalStateException("댓글을 삭제할 권한이 없습니다.");
}
comment.softDelete();
}
// 댓글 목록 조회
@Transactional(readOnly = true)
public Page<CommentResponseDto> getComments(Long reviewId, Pageable pageable) {
Review review = reviewRepository.findById(reviewId)
.orElseThrow(() -> new EntityNotFoundException("리뷰를 찾을 수 없습니다."));
return commentRepository.findByReviewAndDeletedAtIsNull(review, pageable)
.map(CommentResponseDto::from);
}
}
리팩토링 후 코드
클린 코드 원칙에 맞게 설계하니, 확연히 코드가 깔끔해지고 가독성이 향상되었다.
@Service
@Transactional
@RequiredArgsConstructor
public class ReviewService {
private final ReviewRepository reviewRepository;
private final ReviewValidator reviewValidator;
private final TimeDealService timeDealService;
private final UserService userService;
public ReviewResponseDto registerReview(ReviewRequestDto dto, String loginId) throws BaseException {
User reviewer = userService.findByLoginId(loginId);
TimeDeal timeDeal = timeDealService.findTimeDealById(dto.timeDealId());
reviewValidator.validateReviewRegistration(timeDeal, reviewer);
Review savedReview = saveReview(dto, reviewer, timeDeal);
return convertToReviewResponse(savedReview);
}
private Review saveReview(ReviewRequestDto dto, User reviewer, TimeDeal timeDeal) {
Review newReview = Review.builder()
.user(reviewer)
.timeDeal(timeDeal)
.rating(dto.rating())
.content(dto.content())
.build();
return reviewRepository.save(newReview);
}
private ReviewResponseDto convertToReviewResponse(Review review) {
return ReviewResponseDto.from(review);
}
}
이제 클린코드에 적용한 원칙들을, 코드를 세부적으로 보며 복기해본다.
1️⃣ 작게 만들어라!
원칙: 함수는 ‘짧게’, 3~5줄 이내로 유지하라.
✂️ Before
public ReviewResponseDto createReview(ReviewRequestDto requestDto, String loginId) {
// 상품 조회
TimeDeal timeDeal = timeDealRepository.findById(requestDto.timeDealId())
.orElseThrow(() -> new EntityNotFoundException("상품을 찾을 수 없습니다."));
// 유저 조회
User user = userRepository.findByLoginId(loginId)
.orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다."));
// 구매 검증
Purchase purchase = purchaseRepository.findByUserAndTimeDeal(user, timeDeal)
.orElseThrow(() -> new IllegalStateException("구매하지 않은 상품에는 리뷰를 작성할 수 없습니다."));
if (reviewRepository.existsByPurchaseAndDeletedAtIsNull(purchase)) {
throw new IllegalStateException("이미 리뷰를 작성하셨습니다.");
}
// 엔티티 생성 & 저장
Review review = new Review();
review.setUser(user);
review.setTimeDeal(timeDeal);
review.setPurchase(purchase);
review.setRating(requestDto.rating());
review.setContent(requestDto.content());
Review savedReview = reviewRepository.save(review);
// DTO 변환
return convertToDto(savedReview);
}
🛠 After
public ReviewResponseDto registerReview(ReviewRequestDto dto, String loginId) throws BaseException {
User reviewer = userService.findByLoginId(loginId);
TimeDeal timeDeal = timeDealService.findTimeDealById(dto.timeDealId());
reviewValidator.validateReviewRegistration(timeDeal, reviewer);
Review savedReview = saveReview(dto, reviewer, timeDeal);
return convertToReviewResponse(savedReview);
}
private Review saveReview(ReviewRequestDto dto, User reviewer, TimeDeal timeDeal) {
Review newReview = Review.builder()
.user(reviewer)
.timeDeal(timeDeal)
.rating(dto.rating())
.content(dto.content())
.build();
return reviewRepository.save(newReview);
}
private ReviewResponseDto convertToReviewResponse(Review review) {
return ReviewResponseDto.from(review);
}
- 효과: 20줄짜리 거대 함수가,
- 비즈니스 흐름만 담당하는 registerReview(5줄)
- 저장 로직 전용 saveReview
- DTO 변환 전용 toDto
- 총 3개로 쪼개져 훨씬 읽기 쉬워졌다.
2️⃣ 한 가지만 해라!
원칙: 함수 하나는 오직 하나의 일만!
- Before: createReview 안에 조회·검증·생성·변환이 모두 섞여 있었다.
- After:
- registerReview → 검증 + saveReview 호출
- saveReview → 엔티티 저장
- toDto → DTO 변환
“이 함수는 뭘 하나?” 질문에 바로 대답할 수 있도록, 한 함수가 한 역할만 하게 분리했다!
registerReview 함수는 이제 “리뷰 등록 플로우 전체를 조립하는 역할” 하나만 담당한다. 나머지 세부 작업(데이터 조회, 검증, 저장, DTO 변환)은 각각의 헬퍼 메서드나 Validator, Repository로 위임했기 때문에, registerReview 내부에서는 “리뷰를 등록한다”는 한 가지 일만 집중해서 수행한다.
3️⃣ 추상화 수준은 하나로!
원칙: 한 함수 내에 높은 수준(비즈니스)과 낮은 수준(DB 조회)을 섞지 마라.
✂️ Before
// ReviewService
public Page<ReviewResponseDto> getReviewsByTimeDeal(Long timeDealId, Pageable pageable) {
TimeDeal timeDeal = timeDealRepository.findById(timeDealId)
.orElseThrow(() -> new EntityNotFoundException("해당 타임딜을 찾을 수 없습니다."));
return reviewRepository.findByTimeDealAndDeletedAtIsNullOrderByCreatedAtDesc(timeDeal, pageable)
.map(ReviewResponseDto::from);
}
레이어간 계층 관계도 무너져있고, DB단과 섞여 잘 읽히지 않는다.
🛠 After
@Transactional(readOnly = true)
public Page<ReviewResponseDto> getReviews(Long timeDealId, Pageable pg) {
TimeDeal td = timeDealService.findTimeDealById(timeDealId);
return findActiveReviews(td, pg);
}
private Page<ReviewResponseDto> findActiveReviews(TimeDeal td, Pageable pg) {
return reviewRepository
.findActiveReviewsForTimeDeal(td, pg)
.map(ReviewResponseDto::from);
}
- getReviews는 비즈니스 조회만,
- findActiveReviews는 DB 조회+매핑만 담당해, 두 레벨이 분리됐다.
4️⃣ 서술적인 이름을 사용하라!
원칙: 이름만 보고도 동작이 예측되어야.
createReview(...) | registerReview(...) |
getReviewsByTimeDeal(...) | getReviews(...) |
convertToDto(...) | convertToReviewResponse(...) |
- 왼쪽에서 오른쪽으로 더욱 직관적으로 이름을 변경했다.
- 불필요한 중복(“ByTimeDeal”), 추상성(convertToDto)을 줄이고,
- 함수명만으로 “무엇을 하는지” 즉시 알 수 있게 개선했다.
5️⃣ 반복하지 마라! (헬퍼 메서드)
원칙: 같은 코드가 여러 곳에 보인다면, 헬퍼 메서드로 뽑아 한 번만 작성.
✂️ Before: 여기저기 흩어져 있던 조회 + 예외 처리
// 리뷰 조회할 때마다
Review review = reviewRepository.findById(reviewId)
.orElseThrow(() -> new EntityNotFoundException("리뷰를 찾을 수 없습니다."));
// 타임딜 조회할 때마다
TimeDeal timeDeal = timeDealRepository.findById(timeDealId)
.orElseThrow(() -> new EntityNotFoundException("타임딜을 찾을 수 없습니다."));
// 사용자 조회할 때마다
User user = userRepository.findByLoginId(loginId)
.orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다."));
- 서비스 메서드마다 findById(...).orElseThrow(...)가 중복되어
- 읽기 어려워지고
- 예외 메시지 수정 시 여러 곳을 찾아 바꿔야 했다.
🛠 After: 헬퍼 메서드 하나로 깔끔하게
Review review = reviewService.findReviewById(reviewId);
TimeDeal timeDeal = timeDealService.findTimeDealById(timeDealId);
User user = userService.findByLoginId(loginId);
// Validator.java
/**
* 중복되는 리뷰 조회 + 예외 처리 로직을 한 곳에 모아둠
*/
private Review findReviewById(Long id) throws BaseException {
return reviewRepository.findById(id)
.orElseThrow(() -> new BaseException(BaseResponseStatus.REVIEW_NOT_FOUND));
}...
- 헬퍼 메서드 하나만 수정하면,
- 예외 타입을 바꾸거나
- 메시지를 바꿀 때
- 한 곳에서 끝난다.
- 서비스 메서드는 핵심 로직만 남아 더욱 읽기 쉬워진다.
6️⃣ 명령과 조회를 분리하라!
원칙:
- 조회(Query) 메서드는 오직 데이터를 읽어와 반환만 한다.
- 명령(Command) 메서드는 오직 상태를 변경(부작용)만 수행한다.
✂️ Before: 조회,명령 로직이 함께 섞여 있던 코드
// (1) 댓글 목록 조회
@Transactional(readOnly = true)
public Page<CommentResponseDto> getCommentsByReview(Long reviewId, Pageable pageable) {
// 조회 로직 + 예외 처리
Review review = reviewRepository.findById(reviewId)
.orElseThrow(() -> new EntityNotFoundException("리뷰를 찾을 수 없습니다."));
return commentRepository
.findByReviewAndDeletedAtIsNull(review, pageable) // 삭제되지않은 모든 리뷰 조회
.map(CommentResponseDto::from); // 반환DTO형태로 변형후 반환
}
// (2) 댓글 삭제
public void deleteComment(Long commentId, String loginId) {
// 리뷰댓글조회 로직
ReviewComment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new EntityNotFoundException("댓글을 찾을 수 없습니다."));
User user = userRepository.findByLoginId(loginId)
.orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다."));
// 검증 + 상태 변경
if (!comment.getUser().equals(user) && !comment.getReview().getUser().equals(user)) {
throw new IllegalStateException("댓글을 삭제할 권한이 없습니다.");
}
comment.softDelete(); // 상태 변경: deletedAt에 삭제 시각 기록 (소프트 삭제)
}
- 문제점
- 조회 메서드에도 부수 효과(orElseThrow) 외에 여러 가지 로직이 섞여 있고,
- 삭제 메서드도 “조회→검증→상태 변경”이 한곳에 모여 있어 가독성이 떨어진다.
🛠 After: 조회와 명령을 명확히 분리
//CommentService.java
// (1) 조회 전용 메서드 – readOnly 트랜잭션 + DTO 반환만
@Transactional(readOnly = true)
public Page<CommentResponseDto> getComments(Long reviewId, Pageable pageable) throws BaseException {
Review review = findReviewById(reviewId); // 헬퍼 메서드로 조회+예외 처리 집중
return commentRepository
.findByReviewAndDeletedAtIsNull(review, pageable)
.map(CommentResponseDto::from);
}
// (2) 명령 전용 메서드 – void + 상태 변경만
public void deleteComment(Long commentId, String loginId) throws BaseException {
ReviewComment comment = findCommentById(commentId);
User user = userService.findByLoginId(loginId);
reviewValidator.validateCommentDeletion(comment, user); // 검증만 위임
comment.softDelete(); // 실제 삭제(soft delete)
}
- 조회(Query)
- @Transactional(readOnly = true)로 읽기 전용을 명시
- findReviewById 헬퍼만 사용해 조회+예외 처리 집중
- DTO 매핑만 수행
- 명령(Command)
- void 반환, 부수 효과(softDelete) 만 수행
- 검증 로직은 ReviewValidator에 위임해 가독성·재사용성 향상
이렇게 분리하니 메서드 시그니처만 보고도 “값을 돌려주나?” vs “상태를 변경하나?”를 한눈에 파악할 수 있고,트랜잭션 관리나 테스트 작성도 훨씬 수월해질수있다!
7️⃣ 검증 로직 분리: Validator 클래스
적용 원칙:
- 단일 책임 (Single Responsibility)
- 중복 제거 (DRY)
- 의미 있는 이름 (Descriptive Names)
- 작게 만들어라 (Small Functions)
- 추상화 수준은 하나로 (One Level of Abstraction)
✂️ Before: 서비스에 흩어진 검증 코드
public ReviewResponseDto createReview(…, String loginId) {
Purchase purchase = purchaseRepository.findByUserAndTimeDeal(user, timeDeal)
.orElseThrow(() -> new IllegalStateException("구매하지 않은 상품"));
if (reviewRepository.existsByPurchaseAndDeletedAtIsNull(purchase)) {
throw new IllegalStateException("이미 리뷰를 작성하셨습니다.");
}
// …수많은 검증코드
}
public void deleteComment(Long commentId, String loginId) {
// 댓글 사용자 권한 조회
if (!comment.getUser().equals(user)
&& !comment.getReview().getUser().equals(user)) {
throw new IllegalStateException("권한 없음");
}
comment.softDelete();
}
- 문제점
- 서비스 메서드마다 검증 – 예외 던지기 코드가 반복
- 검증 로직이 비즈니스 흐름과 섞여 있어 가독성 저하
- 예외 메시지와 상태가 서비스, 컨트롤러 곳곳에 흩어져 관리하기 어려움
🛠 After: ReviewValidator에 전담
// tmp,ReviewCommentValidator 별도로분리예정
@Component
@RequiredArgsConstructor
public class ReviewValidator {
private final PurchaseService purchaseService;
private final ReviewService reviewService;
// 1) 리뷰 등록 검증
public void validateReviewRegistration(TimeDeal td, User user) throws BaseException {
Purchase p = purchaseService.validatePurchaseExists(user, td);
validateNoExistingReview(p);
}
private void validateNoExistingReview(Purchase p) throws BaseException {
if (reviewService.hasActiveReview(p)) {
throw new BaseException(BaseResponseStatus.REVIEW_ALREADY_EXISTS);
}
}
// 2) 댓글 삭제 권한 검증
public void validateCommentDeletion(ReviewComment c, User user) throws BaseException {
if (isCommentAuthor(c, user) || isReviewAuthor(c, user)) {
return;
}
throw new BaseException(BaseResponseStatus.UNAUTHORIZED_COMMENT_DELETE);
}
private boolean isCommentAuthor(ReviewComment c, User u) {
return c.getUser().equals(u);
}
private boolean isReviewAuthor(ReviewComment c, User u) {
return c.getReview().getUser().equals(u);
}
}
- ReviewValidator만의 책임
- 검증만 담당 → 서비스는 비즈니스 흐름(조회 → 저장 → 변환)에 집중
- 검증 로직을 작은 메서드(isCommentAuthor, validateNoExistingReview)로 분리
- 한 단계 추상화:
- 공통 검증 호출부(High-level) vs. 실제 조건 검사(Low-level) 분리
- 반복 제거: 예외 던지는 코드(throw new BaseException(...))를 한 곳에서 관리
🧩 서비스 측 변경
//ReveiwServicejava
public ReviewResponseDto registerReview(ReviewRequestDto dto, String loginId) throws BaseException {
User reviewer = userService.findByLoginId(loginId);
TimeDeal timeDeal = timeDealService.findTimeDealById(dto.timeDealId());
//검증 분리
**reviewValidator.validateReviewRegistration(timeDeal, reviewer);**
Review savedReview = saveReview(dto, reviewer, timeDeal);
return convertToReviewResponse(savedReview);
}
//ReveiwCommentService.java
public void deleteComment(Long commentId, String loginId) throws BaseException {
ReviewComment c = findCommentById(commentId);
User u = userService.findByLoginId(loginId);
// 검증 책임 Delegation
reviewValidator.validateCommentDeletion(c, u);
c.softDelete();
}
- 서비스에서는 오직 검증 호출만 남고,
- 예외 메시지·상태 코드는 Validator에만 존재.
추가 개선점
- DTO는 Java Record로 불변(Immutable)하게
- record를 쓰면 보일러플레이트가 확 줄고,
- 내부 상태가 바뀔 수 없으니(thread-safe) 안전해졌다.
public record ReviewResponseDto(…) { … }
- 정적 팩토리 메서드로 생성 옵션을 구현했다.
- 호출부에서 withComments()를 쓰면 “댓글 포함” DTO,
- from()은 기본 “댓글 미포함” DTO로 편리하게 선택할 수 있도록 했다.
public static ReviewResponseDto from(Review review) { … }
public static ReviewResponseDto withComments(Review review) { … }
정적 팩토리 메서드는 “객체를 만드는 방법”을 이름으로 드러내 주는 만능 생성자 느낌이다.
public record ReviewResponseDto(…) {
// 댓글 없이 DTO 만들기
public static ReviewResponseDto from(Review review) {
return of(review, false);
}
// 댓글까지 모두 담은 DTO 만들기
public static ReviewResponseDto withComments(Review review) {
return of(review, true);
}
// 실제 생성 로직은 이 한 곳에서처ㅓ리
public static ReviewResponseDto of(Review review, boolean includeComments) {
return new ReviewResponseDto(
review.getReviewId(),
review.getUser().getUsername(),
review.getRating(),
review.getContent(),
review.getCreatedAt(),
includeComments ? getComments(review) : List.of() // 댓글포함여부결정
);
}
//-------------------------------사용예시
private ReviewResponseDto convertToReviewResponse(Review review) {
return ReviewResponseDto.from(review);
}
- from(review) : 댓글 없이 가볍게 DTO 생성
- withComments(review) : 댓글까지 포함해서 DTO 생성
이렇게 하니
- 메서드 이름만 보고 “댓글을 포함할지 말지”가 바로 눈에 보여서 헷갈릴 일이 없고,
- 생성 로직(of(...))은 한 군데에 모아 유지보수가 쉬워졌다.
✨ 정적 팩토리 메소드, 왜 쓰는 걸까?
정적 팩토리 메서드(static factory method)는 객체를 생성하는 역할을 하는 정적 메서드(static method)를 말한다. 자바에서 new 키워드 대신 특정 이름을 가진 메서드를 호출해서 객체를 생성하는 방식이다.
이름으로 의도를 드러낸다
- 생성자(new ReviewResponseDto(...))만 있으면 “무엇 때문에 만들었는지” 모르지만,이처럼 메서드 이름으로 용도가 즉시 읽힐수있다.
ReviewResponseDto.from(review); // 댓글 없이
ReviewResponseDto.withComments(review); // 댓글 포함
캐싱된 인스턴스 재활용
- Boolean.valueOf(true) 는 항상 같은 TRUE 객체를 돌려주기 때문에
- 메모리·생성 비용을 아낄 수 있다.
public class Boolean {
private static final Boolean TRUE = new Boolean(true);
private static final Boolean FALSE = new Boolean(false);
public static Boolean valueOf(boolean b) {
return b ? TRUE : FALSE; // 매번 새 객체를 만들지 않음
}
}
상황에 따라 서브클래스를 반환한다.
public static List<String> of(String... items) {
switch (items.length) {
case 0: return Collections.emptyList();
case 1: return Collections.singletonList(items[0]);
default: return new ArrayList<>(Arrays.asList(items));
}
}
- List.of() 라는 한 메서드로
- 빈 리스트,
- 요소 하나 짜리 리스트,
- 일반 ArrayList
- 중에서 상황에 맞게 다른 구현을 돌려줄 수 있.
매개변수 개수나 타입 제어 가 가능하다.예를들어 아래를 보자.
🔨 간단 예시
public class Color {
private final int r, g, b;
private Color(int r, int g, int b) {
this.r = r; this.g = g; this.b = b;
}
// 정적 팩토리 메서드
public static Color rgb(int r, int g, int b) {
return new Color(r, g, b);
}
public static Color gray(int level) {
return new Color(level, level, level);
}
}
// 사용
Color black = Color.gray(0);
Color pink = Color.rgb(255, 192, 203);
- 생성자를 직접 쓰지 않고, rgb()·gray()를 호출함으로써
- “어떤 색을 만드려는지”가 명확해지고,
- 내부 구현(new Color(...))을 감출 수 있다!
요약
- 정적 팩토리 메서드 = public static 메서드로 객체를 생성하여 반환
- 장점:
- 이름으로 의도 표현
- 유연한 반환 타입(인터페이스, 캐시, 서브클래스 등)
- 가독성과 유지보수성 이 좋아진다.
'개발프로젝트' 카테고리의 다른 글
[CleanCode] 형식 맞추기 – 형식 규칙에 대한 깊은 고찰,클린코드를 읽고... (1) | 2025.05.22 |
---|---|
[CleanCode] 주석은 변명이 아니다 – 네이버 쇼핑 API 응용 코드로 알아보는 클린 코드 (3) | 2025.05.19 |
[개발프로젝트] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 chapter 10- 24시간 365일 중단 없는 서비스를 만들자 (1) | 2024.01.14 |
[개발프로젝트] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 chapter 09- 코드가 푸시되면 자동으로 배포해 보자 (1) | 2024.01.11 |
[개발프로젝트] 스프링 부트와 AWS로 혼자 구현하는 웹 서비스 chapter 08- EC2 서버에 프로젝트를 배포해 보자 (2) | 2024.01.07 |