-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 코스발견 탭 상단 배너 조회 API 추가 #209
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package org.runnect.server.banner.controller; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.runnect.server.banner.dto.response.GetBannerResponseDto; | ||
| import org.runnect.server.banner.service.BannerService; | ||
| import org.runnect.server.common.constant.SuccessStatus; | ||
| import org.runnect.server.common.dto.ApiResponseDto; | ||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.ResponseStatus; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RestController | ||
| @RequiredArgsConstructor | ||
| @RequestMapping("/api/banner") | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 라우팅 — |
||
| public class BannerController { | ||
|
|
||
| private final BannerService bannerService; | ||
|
|
||
| @GetMapping | ||
| @ResponseStatus(HttpStatus.OK) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| public ApiResponseDto<GetBannerResponseDto> getBanners() { | ||
| return ApiResponseDto.success(SuccessStatus.GET_BANNER_SUCCESS, bannerService.getBanners()); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 공통 응답 포맷(response envelope) |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package org.runnect.server.banner.dto.response; | ||
|
|
||
| import lombok.AccessLevel; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PRIVATE) | ||
| @AllArgsConstructor(access = AccessLevel.PRIVATE) | ||
| public class BannerResponse { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. DTO — Entity를 API 응답에 그대로 안 쓰는 이유
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (1) DB 컬럼 변경 = API 스펙 변경이 되는 문제 Banner 엔티티를 그대로 응답으로 내려준다고 하면, DB 컬럼명/필드명이 곧 JSON 키가 돼요. 나중에 DB 리팩터링하면서 imageUrl 컬럼명을 bannerImageUrl로 바꾼다고 해봐요 — 그 순간 API 응답의 JSON 키도 같이 바뀌어버려서, Android 앱이 아무 통보도 없이 파싱 실패함. DTO를 따로 두면 엔티티 필드명이 바뀌어도 DTO에서 매핑만 다시 해주면 되고, JSON 키(=API 계약)는 그대로 유지할 수 있어요. // DTO가 있으면 이렇게 흡수 가능 (2) 연관관계 직렬화 → N+1 / 무한루프 Course 엔티티를 보면 이런 연관관계가 있어요: @OnetoOne(mappedBy = "course") @onetomany(mappedBy = "course") 이 Course를 Jackson이 그대로 JSON으로 직렬화한다고 하면:
DTO는 "정확히 이 필드들만" 골라서 담기 때문에, 응답에 필요 없는 연관관계는 애초에 건드리지도 않아요 — Jackson이 순회할 대상 자체가 없어짐. (3) 내부 전용 필드 노출 Banner는 AuditingTimeEntity를 상속해서 deletedAt 필드를 갖고 있어요(소프트 삭제용). 엔티티를 그대로 내 deletedAt이 왜 있는지, 소프트 삭제 방식을 클라이언트한테 그대로 노출돼요. 실제로배너 하나 보여주는 데 deletedAt/createdAt 같은 건 Android 입장에서 전혀 필요 없는 정보죠. DTO를 쓰면 이렇게 딱 { "index": 0, "imageUrl": "...", "linkUrl" 한 줄 요약: Entity는 "DB와의 계약", DTO는 이 둘을 분리해두면 한쪽이 바뀌어도 다른쪽이 안전해요.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 무한루프 — "마주보는 두 거울" 상황: Course 객체 안에 publicCourse 필드가 있고, 그 PublicCourse 객체 안에도 다시 course라는 필드로 원래 그 Course를 가리키고 있어요. 서로가 서로를 가리키는 구조(양방향). JSON으로 변환(직렬화)한다는 게 뭘 하는 거냐면: "이 객체의 필드를 하나씩 다 훑어서 문자열로 바꾸는" 작업이에요. Jackson이라는 라이브러리가 이걸 자동으로 해주는데, "이미 봤던 객체인지 기억하는 기능이 기본적으로 없어요. 그냥 필드를 보이는 대로 계속 따라 들어가요.
이게 거울 두 개를 마주보게 놓으면 그 안의 상이 끝없이 반복되는 것과 똑같은 원리예요. 코드가 이 반복을 멈출 방법이 없어서 결국 컴퓨터가 "더 이상 못 하겠다"(StackOverflowError)며 죽어버림. N+1 — "학생 10명 성적표를 한 명씩 따로 조회하기" "지연 로딩(LAZY)"이 뭐냐면: Course를 DB에서 가져올 때, JPA는 그 안의 records(러닝 기록 목록)를 미리 안 가져와요. 일단 "필요하면 그때 가져올게"라는 빈 껍데기만 넣어둠. 그러다가 진짜로 course.getRecords()를 호출하는 순간, 그제서야 실제 SQL 쿼리 하나가 DB로 날아가요. 시나리오: 코스 10개를 리스트로 응답해야 한다고 해봐요.
결과: 원래 "코스 10개 + 각자 기록"을 가져오는 데 똑똑하게 하면 쿼리 1~2번이면 될 걸, 쿼리 11번(1번 + 코스 DTO가 이 둘을 막는 방법 DTO(GetBannerResponseDto 같은 거)는 "이 필드, 저 필드"만 콕 집어서 새 객체에 담아요. Banner 엔티티 전체를 그대로 넘기는 게 아니라: BannerResponse.of(index, banner.getImageU
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 무한루프 의심 신호: mappedBy @OnetoOne(mappedBy = "course") mappedBy = "course"가 붙어있다는 건 **"나는 주인이 아니고, 저쪽(PublicCourse)에 있는 course라는 필드가 진짜 연관관계의 주인이다"**라는 뜻이에요. 이 말은 곧 — PublicCourse.java를 열어보면 반드시 Course course; 같은 필드가 있다는 걸 의미해요(안 그러면 mappedBy가 가리킬 대상이 없어서 애초에 컴파일도 안 됨). 즉 mappedBy를 보는 순간 "아, 이건 양방향이구나 → 저쪽 엔티티도 이쪽을 도로 가리키고 있겠구나" 하고 추론할 수 있어요. 양방향 = 서로 가리킴 = 직렬화하면 서로 왔다갔다 반복할 위험. 실제로 확인하고 싶으면 PublicCourse.java 열어서 course 필드가 있는지 보면 됨(memory 검증하듯). 참고로 이게 너무 흔한 문제라서, Jackson엔 아예 이 상황 전용 애너테이션(@JsonManagedReference/@JsonBackReference, 또는 그냥 @JsonIgnore)이 따로 있어요. "이런 전용 해결책이 존재한다" = "이게 흔하게 터지는 문제라는 방증"이에요. N+1 의심 신호: @onetomany / @manytomany (컬렉션 타입) @onetomany(mappedBy = "course") 여기서 신호는 타입이 List라는 것 자체예요. JPA 스펙 자체가 @OneToMany/@manytomany는 기본값이 LAZY로 정해져 있어요(반대로 @ManyToOne/@OnetoOne은 기본이 EAGER). 그래서 @onetomany 보이면 "이건 기본적으로 안 가져와져 있고, 건드리는 순간 쿼리 나간다"고 바로 가정할 수 있어요. 그리고 N+1이 "N+1"이 되는 이유는 리스트 안에 리스트가 있기 때문이에요:
즉 패턴은: "List를 여러 개 순회하neToMany 필드를 또 건드리는가?" — 이게보이면 N+1을 의심하는 거예요. 정리 — 암기할 두 줄
|
||
| private Integer index; | ||
| private String imageUrl; | ||
| private String linkUrl; | ||
|
|
||
| public static BannerResponse of(Integer index, String imageUrl, String linkUrl) { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. static factory method — |
||
| return new BannerResponse(index, imageUrl, linkUrl); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| package org.runnect.server.banner.dto.response; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import lombok.AccessLevel; | ||
| import lombok.AllArgsConstructor; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PRIVATE) | ||
| @AllArgsConstructor(access = AccessLevel.PRIVATE) | ||
| public class GetBannerResponseDto { | ||
| private List<BannerResponse> banners; | ||
|
|
||
| public static GetBannerResponseDto of(List<BannerResponse> banners) { | ||
| return new GetBannerResponseDto(banners); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| package org.runnect.server.banner.entity; | ||
|
|
||
| import javax.persistence.*; | ||
|
|
||
| import lombok.AccessLevel; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
| import org.runnect.server.common.entity.AuditingTimeEntity; | ||
|
|
||
| @Getter | ||
| @Entity | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @entity — ORM 매핑 |
||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. JPA는 왜 파라미터 없는 생성자가 필요할까 |
||
| public class Banner extends AuditingTimeEntity { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AuditingTimeEntity — 공통 필드는 상속으로 분리 |
||
|
|
||
| @Id | ||
| @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. PK 생성 전략( |
||
| private Long id; | ||
|
|
||
| @Column(nullable = false) | ||
| private String imageUrl; | ||
|
|
||
| @Column(nullable = false) | ||
| private String linkUrl; | ||
|
|
||
| @Column(nullable = false) | ||
| private Integer sortOrder; | ||
|
|
||
| @Column(nullable = false) | ||
| private Boolean isActive; | ||
|
|
||
| @Builder | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 빌더 패턴 — 생성자 대신 쓰는 이유 |
||
| public Banner(String imageUrl, String linkUrl, Integer sortOrder) { | ||
| this.imageUrl = imageUrl; | ||
| this.linkUrl = linkUrl; | ||
| this.sortOrder = sortOrder; | ||
| this.isActive = true; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package org.runnect.server.banner.repository; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import org.runnect.server.banner.entity.Banner; | ||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface BannerRepository extends JpaRepository<Banner, Long> { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| List<Banner> findByIsActiveTrueOrderBySortOrderAscIdAsc(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package org.runnect.server.banner.service; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.runnect.server.banner.dto.response.BannerResponse; | ||
| import org.runnect.server.banner.dto.response.GetBannerResponseDto; | ||
| import org.runnect.server.banner.entity.Banner; | ||
| import org.runnect.server.banner.repository.BannerRepository; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class BannerService { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Service 계층 — Controller에 로직을 안 두는 이유 |
||
|
|
||
| private final BannerRepository bannerRepository; | ||
|
|
||
| public GetBannerResponseDto getBanners() { | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 여기
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 트랜잭션(세이브 포인트): 기본적으로 트랜잭션은 "여기서부터 지켜보다가, 끝나면 커밋 or 롤백"하는 거였죠. 이건 읽기든 쓰기든 다 포함해서 지켜봐요. "읽기 전용": 그중에서 "나는 이 트랜잭션 안에서 절대 안 씀, 조회만 할 거야"라고 Spring/Hibernate한테 미리 알려주는 옵션이에요. 왜 이걸 알려주면 좋은가 — 아까 배운 dirty checking이랑 직결됨: @entity에서 말씀드렸던 거 기억나실 텐데, Hibernate는 트랜잭션 안에서 로드한 엔티티의 필드가 바뀌면 자동으로 감지해서(dirty checking) 커밋 시점에 UPDATE를 날려요. 근데 이 감지를 하려면 "엔티티를 로드했을 때의 원본 상태를 계속 기억해뒀다가, 나중에 비교하는" 작업이 필요해요 — 이게 은근히 메모리/CPU를 씀. readOnly = true라고 미리 말해두면, Hibernate가 "아, 이 트랜잭션에선 어차피 아무것도 안 바뀔 테니 그 원본 상태 기억하고 비교하는 작업 자체를 생략해도 되겠다"고 판단해서 그 오버헤드를 통째로 스킵해요. 그리고 커밋 직전에 하는 "혹시 바뀐 거 있나 확인하고 flush"하는 과정도 생략함. 정리: 읽기 전용 트랜잭션 = "이 트랜잭션 안에서 쓰기는 안 한다"고 미리 선언해서, 프레임워크가 쓰기 대비용 부가 작업(변경 감지, flush)을 안 하게 만드는 최적화 힌트예요. BannerService.getBanners()는 실제로 SELECT만 하니까 이 최적화를 100% 안전하게 받을 수 있는 케이스인 거고요.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| List<Banner> banners = bannerRepository.findByIsActiveTrueOrderBySortOrderAscIdAsc(); | ||
|
|
||
| List<BannerResponse> bannerResponses = new ArrayList<>(); | ||
| for (int index = 0; index < banners.size(); index++) { | ||
| Banner banner = banners.get(index); | ||
| bannerResponses.add(BannerResponse.of(index, banner.getImageUrl(), banner.getLinkUrl())); | ||
| } | ||
|
|
||
| return GetBannerResponseDto.of(bannerResponses); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -31,6 +31,7 @@ public enum SuccessStatus { | |
|
|
||
| GET_HEALTH_DATA_SUCCESS(HttpStatus.OK, "건강 데이터 조회 성공"), | ||
| GET_HEALTH_SUMMARY_SUCCESS(HttpStatus.OK, "건강 통계 조회 성공"), | ||
| GET_BANNER_SUCCESS(HttpStatus.OK, "배너 조회 성공"), | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Enum으로 성공 상태를 관리하는 이유 |
||
|
|
||
|
|
||
| UPDATE_RECORD_SUCCESS(HttpStatus.OK, "활동 기록 수정 성공"), | ||
|
|
||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@RestController=@Controller+@ResponseBody메서드 리턴값을 뷰(HTML 템플릿)로 렌더링하는 대신, 그대로 JSON으로 직렬화해서 응답 바디에 실어줌. Android 쪽에서 쓰는 Retrofit 인터페이스의 반대편 — 서버가 이 JSON을 만들어서 내려주는 쪽이라고 보면 됨.