-
Notifications
You must be signed in to change notification settings - Fork 24
[고희준] sprint7 #193
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
base: 고희준
Are you sure you want to change the base?
The head ref may contain hidden characters: "\uACE0\uD76C\uC900-sprint7"
[고희준] sprint7 #193
Conversation
- 개발, 운영 환경에 대한 프로파일 구성 - Logback 설정 파일 구성
- ErrorCode: 예상되는 에러 설계 - *Exception: 커스텀 예외 설계 - ErrorResponse: 일관된 예외 응답 정의
} | ||
|
||
|
||
private Optional<BinaryContentCreateRequest> resolveProfileRequest(MultipartFile profileFile) { | ||
if (profileFile.isEmpty()) { |
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.
resolveProfileRequest
를 호출하는 쪽에서 Optional.ofNullable에서 꺼내서 호출해서요.
MultipartFile profileFile가 null인 경우도 고려는 해야할 것 같아요.
@@ -0,0 +1,9 @@ | |||
package com.sprint.mission.discodeit.dto.request; | |||
|
|||
public record LoginRequest( |
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.
여기에도 유효성 검사 필요하죠?
import java.util.Map; | ||
import java.util.UUID; | ||
|
||
public class BinaryContentNotFoundException extends BinaryContentException { |
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.
이 Exception이랑 ChannelException 잘 작성하신것 같은데, 실제로 이 Exception들을 던지는 코드는 어디에 있나요?
@Mapping(source = "profile", target = "profile") | ||
UserDto toDto(User user); |
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.
저는 애노테이션으로 바꾸시는 것보다 삭제하셨던 코드로 작성된게 훨씬 유지보수나 이해하기 쉽다고 생각해요.
SpEL은 컴파일 시점에 정적 분석의 대상이 아니라서 코드에 문제가 있어도 발견되지 않아요. 그 상태로 배포가 나가서 실제 코드가 동작하는 순간에 확인이 되는데, 너무 리스크가 크다 생각합니다.
그것보다 삭제하셨던 코드가 버그를 컴파일 시점에 발견할 수도 있고 이해하기도 쉬워서 왜 굳이 바꾸셨는지.. 저는 이해가 잘 안되네요..
@Mapping
을 저는 써본적이 없어서 리뷰 달면서 알게되었는데.. 잘 쓰면 유용한 면도 있을것 같다고 생각하는데요. 그만큼 리스크도 커 보이네요.
@Query("SELECT m FROM Message m " | ||
+ "LEFT JOIN FETCH m.author a " | ||
+ "JOIN FETCH a.status " | ||
+ "LEFT JOIN FETCH a.profile " | ||
+ "WHERE m.channel.id=:channelId AND m.createdAt < :createdAt") |
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.
@Query
은 가급적 사용하지 않는게 좋아요.
다른 코멘트에서 단것처럼 SpEL가 정적 분석의 대상이 아니라서 버그를 만큼 위협도 크고 발견 시점도 배포를 해야 발견할 수 있어서요.
join 때문에 발생하는 N + 1 문제를 해결하기 위해서 @Query
애노테이션을 쓴거라면 @BatchSize
나 아래 옵션을 쓰는걸 추천드려요.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 20
+ "FROM Message m " | ||
+ "WHERE m.channel.id = :channelId " | ||
+ "ORDER BY m.createdAt DESC LIMIT 1") | ||
Optional<Instant> findLastMessageAtByChannelId(@Param("channelId") UUID channelId); |
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.
이것도
Optional<Message> findTopByChannelIdOrderByCreatedAtDesc(UUID channelId);
이렇게 작성하고 사용하는 쪽에서 아래와 같이 사용하는걸 권장드려요.
messageRepository.findTopByChannelIdOrderByCreatedAtDesc(channelId)
.map(Message::getCreatedAt);
단일 row의 경우, 전체 컬럼을 가져오는 것과 특정 컬럼만 가져오는 것 간의 성능 차이는 미미하고, JPA의 영속성 컨텍스트를 고려하면 전체 엔티티를 가져오는 것이 재사용성과 캐시 활용 측면에서 더 유리할 수 있어요.
|
||
if (!user.getPassword().equals(request.password())) { | ||
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); | ||
if (!user.getPassword().equals(password)) { |
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.
아직 Login에서 password 평문으로 저장하면 안 된다고 안 배우신거죠?
아래 링크에 방법 나와있으면 참고만 좀 부탁드립니다.
https://hou27.tistory.com/entry/Spring-Boot-Spring-Security-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-%EC%95%94%ED%98%B8%ED%99%94
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> | ||
<file>${LOG_PATH}/discodeit.log</file> | ||
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> | ||
<fileNamePattern>${LOG_PATH}/discodeit.%d{yyyy-MM-dd}.log</fileNamePattern> | ||
<maxHistory>30</maxHistory> <!-- 30일 보관 --> | ||
</rollingPolicy> | ||
<encoder> | ||
<pattern>${LOG_PATTERN}</pattern> | ||
</encoder> | ||
</appender> |
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.
로깅 파일을 시간단위로 롤링하는 정책은 현업에서도 많이 쓰고 굉장히 잘 하신것 같아요.
하지만 파일이 너무 커져서 문제가 되는 경우도 있거든요.
그래서 보통은 최대 파일크기도 같이 설정으로 걸어둬요.
아래 파일 크기 + 시간 주기로 롤링하는 정책입니다.
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>./logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>./logs/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize> <!-- 파일 하나가 100MB 넘으면 새로 만듦 -->
<maxHistory>30</maxHistory> <!-- 최대 30일 보관 -->
<totalSizeCap>5GB</totalSizeCap> <!-- 전체 합이 5GB 넘으면 오래된 것부터 삭제 -->
</rollingPolicy>
<encoder>
<pattern>${ROLLING_PATTERN}</pattern>
</encoder>
</appender>
" test", List.of(userDto), null); | ||
|
||
// when | ||
when(channelService.create(request)).thenReturn(channelDto); |
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.
이건 given에 있는게 적절해보여요.
channelService.create(request)
호출 됐을때 channelDto
리턴하도록 모킹하겠다는 의미거든요.
mockMvc.perform(post("/api/channels/public")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("testChannel"));
이 부분에서도 when과 then에 대한 내용이 섞여있어요.
given: 테스트를 위한 준비 단계
when: 테스트의 대상이되는 코드 실행 단계
then: 실행 결과를 검증하는 단계
정확하게 분리하려면 아래와 같이 해야할것 같아요.
// Given
PublicChannelCreateRequest request = new PublicChannelCreateRequest("testChannel", "test");
ChannelDto channelDto = new ChannelDto(UUID.randomUUID(), ChannelType.PUBLIC, "testChannel", "test", List.of(userDto), null);
when(channelService.create(request)).thenReturn(channelDto);
// When
ResultActions result = mockMvc.perform(post("/api/channels/public")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)));
// Then
result.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("testChannel"));
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.
아래에도 비슷한 실수가 많네요. 굳이 코멘트 안 달게요.
assertThat(result.description()).isEqualTo(request.description()); | ||
|
||
then(channelRepository).should().save(any(Channel.class)); | ||
then(channelMapper).should().toDto(any(Channel.class)); |
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.
테스트 코드의 가독성을 높히려면, given, when, then 각 절 내부에서는 빈 공백을 없애고
절이 넘어갈때만 빈 공백이 있는게 좋아요.
ex)
// given
PublicChannelCreateRequest request = new PublicChannelCreateRequest("testPublicChannel",
"testPublic");
ChannelDto channelDto = new ChannelDto(
UUID.randomUUID(), ChannelType.PUBLIC, "testPublicChannel", "testPublic",
new ArrayList<UserDto>(),
null
);
given(channelRepository.existsById(UUID.randomUUID())).willReturn(false);
given(channelMapper.toDto(any(Channel.class))).willReturn(channelDto);
// when
ChannelDto result = channelService.create(request);
// then
assertThat(result).isNotNull();
assertThat(result.name()).isEqualTo(request.name());
assertThat(result.description()).isEqualTo(request.description());
then(channelRepository).should().save(any(Channel.class));
then(channelMapper).should().toDto(any(Channel.class));
전체적으로 잘 작성하셨다 생각합니다. 특히 이번 요구사항인 ErrorCode랑 Exception 구현이랑 설정들은 잘하셨다 생각해요. 다만, 과도한 SpEL 사용 ( 그리고 https://github.com/codeit-bootcamp-spring/3-sprint-mission/pull/193/files#diff-406981bef1e21a3e1fc249c8b7a962c02d1437b8db6e6c7b03dc2cb2244f4e95R18 |
기본 요구사항
프로파일 기반 설정 관리
로그 관리
Lombok의 @slf4j 어노테이션을 활용해 로깅을 쉽게 추가할 수 있도록 구성하세요.
application.yaml에 기본 로깅 레벨을 설정하세요.
기본적으로 info 레벨로 설정합니다.
환경 별 적절한 로깅 레벨을 프로파일 별로 설정해보세요.
SQL 로그를 보기위해 설정했던 레벨은 유지합니다.
우리가 작성한 프로젝트의 로그는 개발 환경에서 debug, 운영 환경에서는 info 레벨로 설정합니다.
Spring Boot의 기본 로깅 구현체인 Logback의 설정 파일을 구성하세요.
logback-spring.xml 파일을 생성하세요.
다음 예시와 같은 로그 메시지를 출력하기 위한 로깅 패턴과 출력 방식을 커스터마이징하세요.
로그 출력 예시
콘솔과 파일에 동시에 로그를 기록하도록 설정하세요.
파일은 {프로젝트 루트}/.logs 경로에 저장되도록 설정하세요.
로그 파일은 일자별로 롤링되도록 구성하세요.
로그 파일은 30일간 보관하도록 구성하세요.
서비스 레이어와 컨트롤러 레이어의 주요 메소드에 로깅을 추가하세요.
로깅 레벨을 적절히 사용하세요: ERROR, WARN, INFO, DEBUG
다음과 같은 메소드에 로깅을 추가하세요:
사용자 생성/수정/삭제
채널 생성/수정/삭제
메시지 생성/수정/삭제
파일 업로드/다운로드
예외 처리 고도화
패키지명: com.sprint.mission.discodeit.exception[.{도메인}]
아래는 예시입니다. 필요하다고 판단되는 다양한 코드를 정의하세요.
예시

클래스 다이어그램
details는 예외 발생 상황에 대한 추가정보를 저장하기 위한 속성입니다.
예시
조회 시도한 사용자의 ID 정보
업데이트 시도한 PRIVATE 채널의 ID 정보
UserException, ChannelException 등
실제로 활용되는 클래스라기보다는 예외 클래스의 계층 구조를 명확하게 하기 위한 클래스 입니다.
UserNotFoundException, UserAlreadyExistException 등 필요한 예외를 정의하세요.
예시
NoSuchElementException
IllegalArgumentException
…
클래스 다이어그램
int status: HTTP 상태코드
String exceptionType: 발생한 예외의 클래스 이름
모든 핸들러는 일관된 응답(ErrorResponse)을 가져야 합니다.
유효성 검사
Actuator
health, info, metrics, loggers
애플리케이션 이름: Discodeit
애플리케이션 버전: 1.7.0
자바 버전: 17
스프링 부트 버전: 3.4.0
주요 설정 정보
데이터소스: url, 드라이버 클래스 이름
jpa: ddl-auto
storage 설정: type, path
multipart 설정: max-file-size, max-request-size
/actuator/info
/actuator/metrics
/actuator/health
/actuator/loggers
단위 테스트
슬라이스 테스트
레포지토리 레이어의 슬라이스 테스트를 작성하세요.
@DataJpaTest를 활용해 테스트를 구현하세요.
테스트 환경을 구성하는 프로파일을 구성하세요.
application-test.yaml을 생성하세요.
데이터소스는 H2 인메모리 데이터 베이스를 사용하고, PostgreSQL 호환 모드로 설정하세요.
H2 데이터베이스를 위해 필요한 의존성을 추가하세요.
테스트 시작 시 스키마를 새로 생성하도록 설정하세요.
디버깅에 용이하도록 로그 레벨을 적절히 설정하세요.
테스트 실행 간 test 프로파일을 활성화 하세요.
JPA Audit 기능을 활성화 하기 위해 테스트 클래스에 @EnableJpaAuditing을 추가하세요.
주요 레포지토리(User, Channel, Message)의 주요 쿼리 메소드에 대해 각각 최소 2개 이상(성공, 실패)의 테스트 케이스를 작성하세요.
커스텀 쿼리 메소드
페이징 및 정렬 메소드
컨트롤러 레이어의 슬라이스 테스트를 작성하세요.
@WebMvcTest를 활용해 테스트를 구현하세요.
WebMvcTest에서 자동으로 등록되지 않는 유형의 Bean이 필요하다면 @import를 활용해 추가하세요.
예시
@Import({ErrorCodeStatusMapper.class})
주요 컨트롤러(User, Channel, Message)에 대해 최소 2개 이상(성공, 실패)의 테스트 케이스를 작성하세요.
MockMvc를 활용해 컨트롤러를 테스트하세요.
서비스 레이어를 모의(mock)하여 컨트롤러 로직만 테스트하세요.
JSON 응답을 검증하는 테스트를 포함하세요.
통합 테스트
심화 요구사항
MDC를 활용한 로깅 고도화
헤더 이름: Discodeit-Request-ID
로그 출력 예시
Spring Boot Admin을 활용한 메트릭 가시화
admin 서버 실행 후 localhost:9090/applications 에 접속해봅니다.
discodeit 프로젝트에 Spring Boot Admin Client를 적용합니다.
의존성을 추가합니다.
dependencies {
...
implementation 'de.codecentric:spring-boot-admin-starter-client:3.4.5
}
admin 서버에 등록될 수 있도록 설정 정보를 추가합니다.
discodeit 서버를 실행하고, admin 대시보드에 discodeit 인스턴스가 추가되었는지 확인합니다.
admin 대시보드 화면을 조작해보면서 각종 메트릭 정보를 확인해보세요.
주요 API의 요청 횟수, 응답시간 등
서비스 정보
테스트 커버리지 관리
리포트는 build/reports/jacoco 경로에서 확인할 수 있습니다.
[ ] com.sprint.mission.discodeit.service.basic 패키지에 대해서 60% 이상의 코드 커버리지를 달성하세요.
질문
필드로 선언해서 재사용하는 방식 중 어떤 게 더 유지보수나 가독성 측면에서 바람직할까요? 이것도 개인 스타일인건지 아니면 관례적으로 사용하는 방법이 있는지 궁금합니다 !