-
Notifications
You must be signed in to change notification settings - Fork 24
[강호] sprint7 #191
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?
[강호] sprint7 #191
Conversation
import org.springframework.boot.autoconfigure.SpringBootApplication; | ||
|
||
@SpringBootApplication | ||
@EnableAdminServer |
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.
admin 서버 구성 👍
jacocoTestReport { | ||
dependsOn test | ||
reports { | ||
xml.required = true | ||
html.required = true | ||
} | ||
} | ||
|
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.
jacoco 적용 👍
build.gradle
Outdated
@@ -13,6 +14,19 @@ java { | |||
} | |||
} | |||
|
|||
test { | |||
finalizedBy jacocoTestReport | |||
// jvmArgs "-Duser.timezone=UTC" |
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.
사용되지 않는 코드는 과감히 삭제해주세요!
@Pointcut("execution(* com.sprint.mission.discodeit.service.basic.BasicUserService.create*(..)) || " + | ||
"execution(* com.sprint.mission.discodeit.service.basic.BasicUserService.update*(..)) || " + | ||
"execution(* com.sprint.mission.discodeit.service.basic.BasicUserService.delete*(..)) || " + | ||
"execution(* com.sprint.mission.discodeit.service.basic.BasicChannelService.create*(..)) || " + | ||
"execution(* com.sprint.mission.discodeit.service.basic.BasicChannelService.update*(..)) || " + | ||
"execution(* com.sprint.mission.discodeit.service.basic.BasicChannelService.delete*(..)) || " + | ||
"execution(* com.sprint.mission.discodeit.service.basic.BasicMessageService.create*(..)) || " + | ||
"execution(* com.sprint.mission.discodeit.service.basic.BasicMessageService.update*(..)) || " + | ||
"execution(* com.sprint.mission.discodeit.service.basic.BasicMessageService.delete*(..)) || " + | ||
"execution(* com.sprint.mission.discodeit.storage.LocalBinaryContentStorage.put*(..)) || " + | ||
"execution(* com.sprint.mission.discodeit.storage.LocalBinaryContentStorage.download*(..))") |
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.
Service로 끝나는 클래스의 모든 메소드로 pointcut을 지정하는게 더 간편할 수도 있어 보입니다~!
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.
요구사항에 해당 메소드들만 지정하라고 이해해서 저렇게 지정하였습니다..!
@pointcut("execution(* com.sprint.mission.discodeit.service..Service.(..))") 으로 수정하겠습니다!
log.info("[{}] 비즈니스 이벤트 시작 - 작업: {}, 매개변수: {}", className, methodName, Arrays.toString(args)); | ||
|
||
try { | ||
Object result = joinPoint.proceed(); | ||
log.info("[{}] 비즈니스 이벤트 성공 - 작업: {}, 결과: {}", className, methodName, result); | ||
return result; | ||
} catch (Throwable throwable) { | ||
log.error("[{}] 비즈니스 이벤트 실패 - 작업: {}, 예외: {}, 메시지: {}", | ||
className, methodName, throwable.getClass().getSimpleName(), throwable.getMessage()); | ||
throw throwable; | ||
} | ||
} |
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.
AOP를 이용하여 로깅과 관련된 공통 로직을 잘 분리해주셨습니다.
다만, 서비스별로 디버깅을 위해 봐야하는 특정 값들이 다를 수 있습니다. 서비스중에 디버깅에 도움이 될 만한 로깅을 일부 서비스에 추가로 남겨보면 어떨까요?
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.
우선 페이지네이션 관련하여 messageService.findAllByChannelId() 메서드를 추가하였습니다! 추후 더 추가하도록 하겠습니다!
@AllArgsConstructor | ||
@Getter | ||
public class DiscodeitException extends RuntimeException { | ||
|
||
final Instant timestamp; | ||
final ErrorCode errorCode; | ||
final Map<String, Object> details; |
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.
AllArgsConstructor를 썼을 때, Map 객체가 그대로 details 멤버변수에 할당됩니다.
details 객체를 그대로 할당하면 어떤 문제가 생길 수 있을까요?
문제점
1. 불변성(Immutable) 보장 실패
details
가 외부에서 전달된Map<String, Object>
타입 그대로 할당되고 있습니다.- 만약 외부에서 전달된
details
맵이 이후에 변경된다면,DiscodeitException
객체 내부의details
도 함께 변경됩니다. - 이는 예외 객체의 상태가 예기치 않게 변할 수 있음을 의미하며, 예외 객체는 생성 이후 상태가 변하지 않는 것이 바람직합니다.
2. 캡슐화 위반
- 외부에서 전달된 객체를 그대로 참조하므로, 예외 객체의 내부 상태가 외부에 노출됩니다.
개선 방법
1. 방어적 복사(Defensive Copy)
- 생성자에서 전달받은 맵을 새로운 맵으로 복사하여 할당하면, 외부 변경으로부터 내부 상태를 보호할 수 있습니다.
public DiscodeitException(ErrorCode errorCode, Map<String, Object> details) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.details = (details == null) ? null : new HashMap<>(details);
}
2. 불변 맵 사용
- Java 9 이상이라면
Map.copyOf()
를 사용할 수도 있습니다.
this.details = (details == null) ? null : Map.copyOf(details);
결론
- 예외 객체의 내부 상태가 외부에 의해 변경될 수 있으므로, 방어적 복사를 통해 불변성을 보장하는 것이 좋습니다.
- 특히 예외 객체는 로그나 디버깅 용도로 많이 사용되므로, 상태가 변하지 않도록 주의해야 합니다.
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.
오... 거기까진 생각을 못했는데 감사합니다!
this.details = details == null ? Map.of() : Map.copyOf(details); 으로 수정하였습니다!
void createPublicChannelProcessIntegration() { | ||
// given | ||
PublicChannelCreateRequest publicChannelCreateRequest = new PublicChannelCreateRequest("test", "test"); | ||
|
||
// when | ||
ChannelDto channelDto = channelService.create(publicChannelCreateRequest); | ||
|
||
// then | ||
Channel channel = channelRepository.findById(channelDto.id()).orElseThrow(); | ||
assertThat(channelDto.name()).isEqualTo(channel.getName()); | ||
assertThat(channelDto.id()).isEqualTo(channel.getId()); |
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.
MockMvc를 이용해서 json으로 요청을 보내는 방식으로 통합테스트를 작성해보면 어떨까요?
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.
Channel, User, Message 통합테스트 MockMvc를 이용한 테스트로 수정했습니다!
@Override | ||
List<User> findAll(); | ||
// @EntityGraph(attributePaths = {"userStatus", "profile"}) | ||
@Query("SELECT u FROM User u JOIN FETCH u.userStatus JOIN FETCH u.profile") |
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.
fetch join이 동작하는지 테스트해볼 수 있을까요?
Hibernate.isInitialized(Object proxy) 는 Hibernate에서 프록시 객체나 지연 로딩(lazy loading) 컬렉션이 실제로 초기화(로딩)되었는지 여부를 확인하는 정적 메소드입니다. 이 메소드는 다음과 같은 상황에서 주로 사용됩니다.
- 지연 로딩(lazy loading) 필드나 컬렉션이 실제 DB에서 로딩되었는지(초기화되었는지) 확인할 때
- 세션이 닫힌 후 LazyInitializationException이 발생하지 않도록, 접근 전에 초기화 여부를 미리 체크할 때
동작 방식
- proxy 또는 persistent collection 객체가 전달되면,
- 이미 초기화(로딩)된 경우
true
반환 - 아직 초기화되지 않은 경우
false
반환 - 일반 객체(프록시나 컬렉션이 아닌 경우)는 항상
true
반환
- 이미 초기화(로딩)된 경우
public static boolean isInitialized(java.lang.Object proxy)
Check if the proxy or persistent collection is initialized.
Returns: true if the argument is already initialized, or is not a proxy or collection.
예시 코드
if (!Hibernate.isInitialized(entity.getLazyCollection())) {
Hibernate.initialize(entity.getLazyCollection());
}
- 위 예시처럼, 컬렉션이나 연관 엔티티가 초기화되지 않았다면
Hibernate.initialize()
로 강제 초기화할 수 있습니다.
활용 시 주의사항
- Detached 상태(세션이 닫힌 후)에서는 초기화할 수 없으므로, 반드시 세션이 열려 있을 때 사용해야 합니다.
- 컬렉션의 경우, 컬렉션 객체 자체의 초기화 여부만 판단합니다. 컬렉션 내부의 요소까지 모두 초기화되었는지는 보장하지 않습니다.
요약
- **Hibernate.isInitialized(obj)**는 해당 객체(프록시, 컬렉션)가 실제로 DB에서 로딩되었는지 확인하는 메소드입니다.
- Lazy Loading 환경에서 안전하게 객체 접근이 필요한 경우, 이 메소드로 초기화 여부를 확인 후 사용하세요.
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.
fetch join을 사용했을 때 profile이 없는 user는 검색결과에 제외되어, 전체 유저 조회 시 검색이 안되는 문제 때문에 @entitygraph(attributePaths = {"userStatus", "profile"})로 수정하였습니다!
@Test | ||
@DisplayName("커서 기반으로 이전 메시지를 조회할 수 있다") | ||
void findByChannelIdAndCreatedAtLessThanOrderByCreatedAtDesc() { | ||
// given | ||
UUID channelId = channel.getId(); | ||
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending()); | ||
List<Message> messages = messageRepository | ||
.findByChannelIdOrderByCreatedAtDesc(channelId, pageable) | ||
.getContent(); | ||
|
||
Message cursorTarget = messages.get(0); | ||
Instant cursor = cursorTarget.getCreatedAt(); | ||
cursor = cursor.atOffset(ZoneOffset.UTC).toInstant(); | ||
|
||
// when | ||
Slice<Message> result = messageRepository.findByChannelIdAndCreatedAtLessThanOrderByCreatedAtDesc( | ||
channelId, cursor, pageable); | ||
|
||
|
||
// then | ||
assertThat(result.getContent()).hasSize(2); | ||
assertThat(result.getContent().get(0).getContent()).isEqualTo("내용 1"); | ||
} |
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.
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.
이 부분 때문에 애를 먹었는데.. H2와 JVM의 Time-zone이 맞지 않아 테스트가 실패했습니다.
jap.properties.hibernate.jdbc.time_zone: UTC로 맞추고 테스트를 돌리니까 단일 테스트는 통과하는데,
전체 테스트는 실패하는 상황이...
build.gradle에
test {
jvmArgs "-Duser.timezone=UTC"
}
적어주고 전체 테스트까지 통과하는것 확인했습니다...!
public class ErrorResponse { | ||
String code; | ||
String message; | ||
Map<String, Object> details; | ||
String exceptionType; | ||
int status; | ||
private Instant timestamp; | ||
} |
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.
생성자 사용시, 멤버변수 순서가 달라서 빌드가 안됩니다. 아래와 같이 순서 수정이 필요합니다. 그리고 private 접근 제어자가 timestamp에만 들어갔는데 다른 부분에는 넣지 않은 이유가 있으실까요?
public class ErrorResponse {
private Instant timestamp;
String code;
String message;
Map<String, Object> details;
String exceptionType;
int status;
}
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.
변수 순서 맞춰서 인스턴스 생성하는 부분을 수정하였습니다!
접근 제어자는 실수로 빼먹고 안 적은 것 같습니다..
질문이 있는데 이번 상황처럼 멤버 변수 순서가 달랐는데 제 경우에는 컴파일 오류가 안뜨고 성공적으로 application run이 돌아갔는데, 해당 클래스를 확인하니까 컴파일 오류가 뜨더라구요.. 이런 경우도 있나요??
기본 요구사항
프로파일 기반 설정 관리
로그 관리
Lombok의 @slf4j 어노테이션을 활용해 로깅을 쉽게 추가할 수 있도록 구성하세요.
application.yaml에 기본 로깅 레벨을 설정하세요.
환경 별 적절한 로깅 레벨을 프로파일 별로 설정해보세요.
Spring Boot의 기본 로깅 구현체인 Logback의 설정 파일을 구성하세요.
서비스 레이어와 컨트롤러 레이어의 주요 메소드에 로깅을 추가하세요.
예외 처리 고도화
클래스 다이어그램

details는 예외 발생 상황에 대한 추가정보를 저장하기 위한 속성입니다.
유효성 검사
@NotNull
,@NotBlank
,@Size
,@Email
등Actuator
단위 테스트
슬라이스 테스트
통합 테스트
심화 요구사항
MDC를 활용한 로깅 고도화
Spring Boot Admin을 활용한 메트릭 가시화
테스트 커버리지 관리
스크린샷