확실히 JDBC를 사용하다가 JPA를 사용하니 똑같은 쿼리를 작성해주지 않아도 돼서 상당히 편했다. 하지만 기능을 제대로 알고 있지 않을 경우 골치 아픈 일이 발생한다는 것을 다시 한 번 느끼게 되었다. 자세한 사항은 아래에 기술하였다.
단순 기능 구현 자체는 쉬워졌지만 어떻게 설계할 지 고민하는 시간이 늘어나는 것 같다. 심지어 검색을 해도 사람마다 생각하는 바가 달라서 정하기 쉽지 않았다.
또한 Git에 대한 공부를 더 해야할 것 같았다. 이번에 브랜치를 잘못 관리해서 PR을 올렸을 때 하나의 브랜치에 모든 정보가 다 섞여서 repo를 다시 만들어서 노가다를 하게 되었는데 다신 하고 싶지 않는 경험이었다.. (특히 팀 프로젝트에서 이런 실수를 했다고 생각하면..)
설정을 안한다고 해서 문제 발생하진 않았던 것 같다. 하지만 매번 WARN이 나와서 이번에서야 검색을 하게 되었다.
spring.jpa.open-in-view를 따로 설정하지 않았다면 스프링 부트 애플리케이션 시작 시 아래와 같은 메세지가 뜬다.
[startup] [ ] 17:22:33.916 [main] WARN o.s.b.a.o.j.JpaBaseConfiguration$JpaWebConfiguration -
spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during
view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
이 메세지는 스프링 부트에선 spring.jpa.open-in-view를 true로 설정하고 있는데 이는 OSIV(Open Session In View) 측면에서 매우 부적절하다고 한다.
정말 간단히 말하면 Controller나 View에서도 지연로딩이 가능하게 만들어 주는 것이다. 즉, 영속성 컨텍스트의 생존 범위가 굉장히 넓어지는 것이다.
부적절하다고 하는 이유는 데이터베이스 커넥션 리소스를 사용하기 때문이다. Controller에서 외부 API를 호출하면 외부 API 대기 시간만큼 커넥션 리소스를 반환하지 못하게 돼서 트래픽이 많은 경우에는 꺼둔다고 한다.
하지만 OSIV를 false로 설정한 경우에도 단점은 존재한다. 우선 영속성 컨텍스트의 범위가 좁혀지기 때문에 모든 지연로딩을 트랜잭션 내부에서 처리해야 한다는 것이다. 트랜잭션이 끝난 뒤 지연로딩을 하려고 하면 LazyInitializationException 발생한다.
OSIV를 종료하는 방법은 spring.jpa.open-in-view 설정을 false로 바꿔주면 된다.
spring:
jpa:
open-in-view: false
간단한 애플리케이션이기 때문에 굳이
false로 설정할 필요는 없었지만, 트랜잭션 외부에서 지연로딩하는 코드가 없다고 판단하여false설정하였다.OSIV와 Transactional, 영속성 컨텍스트에 대한 개념을 더 알아본 뒤 생각이 바뀌면 나중에 바꿀 예정이다.
이전 과제 피드백에 있던 내용이라 바로 적용했다. 하나 생각하지 못했던 것은 진짜 환경 변수에 저장하고 불러오려고 했다는 것이다. 뭔가 이상하다고 생각이 들어서 검색을 해봤고, 쉽게 방법을 찾을 수 있었다.
application.yml에 가져올 환경 변수 세팅
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:${MYSQL_PORT}/${MYSQL_DB}
username: ${MYSQL_USERNAME}
password: ${MYSQL_PASSWORD}
${KEY}를 통해 환경 변수에 저장된 KEY에서 값을 가져온다.
실행 -> 구성 편집 (Run -> Edit Configurations...)

또는 애플리케이션 실행 버튼 옆의 이름이나 ...을 클릭


스프링 부트 실행 클래스를 선택 -> 옵션 수정(Modify options) -> 환경 변수(Environment variables)

환경 변수 추가
이때 key1=value1;key2=value2;... 방식으로 직접 작성해도 되고, 아래처럼 해도 된다.


필터를 사용하는 만큼 모든 요청과 응답에 대한 로그를 필터를 통해 찍어보고 싶었다.

Filter를 구현한 로그 필터 클래스를 만들고 doFilter를 구현했다. 로그인 필터에서도 동일한 값을 사용하기 위해 MDC.put()으로 값을 저장하고, chain.doFilter(request, response) 앞뒤에 로그를 찍어주었다.
응답 로그 이후엔 MDC.clear()를 통해 저장된 값을 초기화했다.
<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<layout class="ch.qos.logback.classic.PatternLayout">
<Pattern>[%X{request_id:-startup}] [%X{http_method} %X{request_uri}] %d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n</Pattern>
</layout>
</appender>
<root level="info">
<appender-ref ref="CONSOLE" />
</root>
</configuration>
logback.xml을 만들어서 로그가 찍힐 때 패턴을 정의해줬다. 고유한 ID 값을 UUID를 통해 만들어서 넣고, HTTP Method와 Request URI 값을 확인할 수 있도록 구성했다.
일정 전체 조회에 페이징을 적용하고 있던 중 콘솔에 WARN 표시가 나왔다.
[2b441087-aae7-4098-94a2-ffc8d4b114c7] [GET /api/v1/schedules] 19:30:00.644 [http-nio-8080-exec-6] WARN o.s.d.w.c.SpringDataJacksonConfiguration$PageModule$WarningLoggingModifier - Serializing PageImpl instances as-is is not supported, meaning that there is no guarantee about the stability of the resulting JSON structure!
For a stable JSON structure, please use Spring Data's PagedModel (globally via @EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO))
or Spring HATEOAS and Spring Data's PagedResourcesAssembler as documented in https://docs.spring.io/spring-data/commons/reference/repositories/core-extensions.html#core.web.pageables.
번역하면 PageImpl 인스턴스를 있는 그대로 직렬화하는 것은 지원되지 않으므로 결과 JSON 구조의 안정성에 대해 보장할 수 없습니다! 라는 뜻인데 말로는 이해가 안될 수 있으니 여기에 있는 어노테이션을 적용시키기 전과 후의 결과 차이를 보면 다음과 같다.
// 적용시키기 전
{
"status": "OK",
"data": {
"content": [
{
"scheduleId": 8,
"title": "타이틀",
"body": "본문",
"createdDateTime": "2025-02-11T14:47:59.864844",
"updatedDateTime": "2025-02-11T14:51:41.952323",
"username": "tester",
"commentCount": 0
},
// 결과들...
],
"pageable": {
"pageNumber": 0,
"pageSize": 10,
"sort": {
"empty": false,
"sorted": true,
"unsorted": false
},
"offset": 0,
"paged": true,
"unpaged": false
},
"last": true,
"totalPages": 1,
"totalElements": 3,
"first": true,
"size": 10,
"number": 0,
"sort": {
"empty": false,
"sorted": true,
"unsorted": false
},
"numberOfElements": 3,
"empty": false
},
"message": "일정 전체 조회 성공",
"timestamp": "2025-02-11T19:40:56.4995927"
}
// 적용시킨 후
{
"status": "OK",
"data": {
"content": [
{
"scheduleId": 8,
"title": "타이틀",
"body": "본문",
"createdDateTime": "2025-02-11T14:47:59.864844",
"updatedDateTime": "2025-02-11T14:51:41.952323",
"username": "tester",
"commentCount": 0
},
// 결과들...
],
"page": {
"size": 10,
"number": 0,
"totalElements": 3,
"totalPages": 1
}
},
"message": "일정 전체 조회 성공",
"timestamp": "2025-02-11T19:43:02.4308486"
}
확실히 더 깔끔해졌다. 또한 내부 JavaDoc을 보면 다음과 같이 나와있는 것을 확인할 수 있다.

해당 어노테이션을 사용하기 위해선 Spring 3.2 이상이 필요하다고 한다.
@SQLRestriction 사용으로 인한 문제점현재 각 엔티티에는 소프트 딜리트 방식을 손쉽게 구현 및 사용하기 위한 다음과 같은 어노테이션이 클래스에 정의되어 있다.
@SQLDelete(sql = "update users set deleted_date_time = current_timestamp where user_id = ?")
@SQLRestriction(value = "deleted_date_time is null")
예시로 유저가 삭제되면 실제로 삭제하는 것이 아니라 @SQLDelete 안의 sql을 실행해서 deleted_date_time 값에 current_timestamp를 찍어주고, 조회를 할 땐 deleted_date_time에 값이 없는 것만 필터링해서 가져올 수 있도록 해주는 것이다.
이후 Postman을 활용해서 테스트를 진행했고, 문제는 유저 탈퇴 이후 처음 알게 되었다.
회원가입, 일정 단건 조회 등등에서 다양한 예외가 발생했다.
[Exception]: Unable to find com.example.schedule.user.entity.User with id 1
[Exception]: could not execute statement [Duplicate entry 'tester@test.com' for key 'users.UK6dotkott2kjsp8vw4d0m25fb7'] [insert into users (created_date_time,deleted_date_time,email,password,updated_date_time,username) values (?,?,?,?,?,?)]; SQL [insert into users (created_date_time,deleted_date_time,email,password,updated_date_time,username) values (?,?,?,?,?,?)]; constraint [users.UK6dotkott2kjsp8vw4d0m25fb7]
ControllerAdvice에 정의한 Exception을 주석처리 한 뒤 다시 실행시켜 봤다.
jakarta.persistence.EntityNotFoundException: Unable to find com.example.schedule.user.entity.User with id 1
java.sql.SQLIntegrityConstraintViolationException: Duplicate entry 'tester@test.com' for key 'users.UK6dotkott2kjsp8vw4d0m25fb7'
하나는 엔티티를 찾을 수 없다고 나오고, 다른 하나는 unique 제약 조건을 걸었는데 값이 겹친다고 나온다.
중단점을 찍어서 자세히 확인해보면 엔티티를 찾을 수 없다고 한 경우엔 탈퇴한 유저가 작성한 일정을 단건 조회 등을 할 경우 발생했고, 정확히는 Schedule을 ScheduleDto로 매핑하는 도중 Schedule 엔티티에서 User 값을 getter로 가져오면서 발생했다.
회원가입의 경우엔 탈퇴한 유저와 같은 email 또는 username을 가지고 등록을 시도할 경우 발생했고, 정확히는 중복 체크 구문을 전부 문제 없이 통과하고, 새로운 User 엔티티를 생성한 뒤에 데이터베이스에 저장 시도할 때 예외가 발생했다.
@SQLRestriction 의 문제라고 생각이 들어서 제거하고, 서비스에서 필요한 로직에 따라서 deleted_date_time 값에 따라 처리할 수 있도록 바꾸면 해결될 것이라 생각했다.

deleted_date_time 필요 여부에 따라서 JPQL을 직접 만들었다. existsXxx의 경우엔 회원가입 등에서 탈퇴한 유저의 정보까지 비교할 수 있도록 deleted_date_time까지 확인하도록 했다.
그리고 정상적으로 원하는 예외를 발생시킬 수 있게 되었다.
// 탈퇴한 유저의 이메일이나 사용자명과 중복될 경우 바꾸기 전까진 Exception에 핸들링 되었음
{
"status": "CONFLICT",
"errorDetails": [
{
"field": "email",
"message": "중복된 이메일입니다.",
"code": "DUPLICATED"
},
{
"field": "username",
"message": "중복된 사용자명입니다.",
"code": "DUPLICATED"
}
],
"timestamp": "2025-02-11T15:45:38.3245642"
}
// 일정이 탈퇴한 유저와 연결되어 있는 경우 바꾸기 전까진 Exception에 핸들링 되었음
{
"status": "NOT_FOUND",
"errorDetails": [
{
"field": null,
"message": "요청에 해당하는 일정을 찾을 수 없습니다.",
"code": "NOT_FOUND"
}
],
"timestamp": "2025-02-11T14:48:52.9056308"
}
LoginFilter에서 세션의 값을 확인하고, 없을 경우 바로 예외를 던져서 처리하고자 했다. 하지만 간과하지 못한 사실이 하나 있었다.
바로 Filter의 위치이다. Filter는 WAS 단에서 확인하기 때문에 예외가 발생해도 Spring Context 바깥에서 발생한 예외이기 때문에 정상적으로 처리할 수 없었다. 하지만 try-catch를 통해 바로 응답을 내려줄 수 있었다.

현재 응답 실패에 대한 공통 응답 클래스를 사용하고 있기 때문에 필터에서 내리는 응답도 마찬가지로 구조를 맞춰서 응답을 하려고 했다.
처음 생각한 방식은 무식하게 전부 적어주는 것이었다. 어차피 발생하는 예외는 응답 실패 시간을 제외하면 항상 동일하기 때문에 가장 먼저 생각하게 되었다. 하지만 JSON 응답 구조를 하나하나 만드는 것은 너무 비효율적이라고 생각이 들어 검색을 했고, ObjectMapper를 통해 객체를 JSON 문자열로 바꿀 수 있다고 보게 되었다.

그래서 바로 적용한 뒤에 테스트를 돌려보니...
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.LocalDateTime` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: com.example.schedule.global.common.response.ErrorResponse["timestamp"])
생각하지도 못한 예외가 발생했다. 해당 예외를 번역하면 Java 8 날짜/시간 유형 java.time.LocalDateTime이 기본적으로 지원되지 않음: "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" 모듈을 추가하여 처리를 활성화합니다. 라는 의미였다. 해결하기 위해서 여러 곳을 확인하고 추가해봤지만 유의미한 결과를 보인 방법은 다음과 같이 ObjectMapper에 추가 설정을 하는 방법이었다.

ObjectMapper 객체를 생성하고, objectMapper.registerModule(new JavaTimeModule()).disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);을 설정해서 날짜 및 시간 클래스(LocalDate, LocalDateTime 등)를 직렬화/역직렬화할 수 있도록 지원하는 모듈을 등록하고, JSON으로 출력되는 날짜 정보를 표준화된 문자열 포맷으로 제공할 수 있게 SerializationFeature.WRITE_DATES_AS_TIMESTAMPS를 disable 처리해주는 것으로 해결했다.
{
"status": "UNAUTHORIZED",
"errorDetails": [
{
"field": null,
"message": "로그인이 필요한 서비스입니다. 로그인을 해주세요.",
"code": "UN_AUTHORIZED"
}
],
"timestamp": "2025-02-11T16:46:28.7494056"
}
ObjectMapper의 사용 방법이 바뀐다고 하는데 정확한 사용 방법을 몰라서 현재 작성일 기준 사용할 수 있는 코드를 우선 사용했다.
응답 실패에 대한 공통 응답 클래스에 필드로 위치하는 ErrorDetail 클래스에서 JSON 직렬화 시 응답 필드를 동일하게 설정하기 위해서 @JsonProperty(value = "code")를 통해 동일한 이름으로 설정했다.

하지만 테스트를 실행해보니...
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Conflicting getter definitions for property "code": com.example.schedule.global.common.exception.ErrorDetail#getCode() vs com.example.schedule.global.common.exception.ErrorDetail#getErrorCode() (through reference chain: com.example.schedule.global.common.response.ErrorResponse["errorDetails"]->java.util.ImmutableCollections$List12[0])
또 다른 예외가 날 반겨주었다. 간단한 설명을 하자면 code 값에 대한 getter의 충돌로 인해 문제가 생겼다는 의미이다. 그리고 JSON은 직렬화 시 접근 제한자에 따라서 직렬화하는 방식이 다른데 private의 경우 getter를 통해 직렬화를 하게 된다는 사실을 알게 되었다.
그래서 위의 코드에서 명시적으로 getter를 하나 설정해줬다.
@JsonProperty("code")
public String getSerializedCode() {
return code != null
? code : (errorCode != null ? errorCode.name() : null);
}
이렇게 되면 직렬화 시 getter 메서드가 우선적으로 사용되기 때문에 위의 메서드를 호출하여 JSON의 code 프로퍼티 값을 생성하게 된다. 따라서 이름을 똑같게 만들어도 접근 제한자 상에서 우위를 가지는 public에 우선적으로 사용되게 되어서 정상적으로 결과를 응답할 수 있게 되는 것이다.