PLAYDATA 주간회고

플레이데이터 풀스택 백엔드 9기 7월 2주차 회고

Berry-mas 2025. 7. 7. 14:26

플레이데이터 풀스택 백엔드 9기 17주차 주간회고 및 학습기록 (열일곱번째 기록)


이번 주에는 AWS 실습 및 4차 프로젝트 프론트-백 연결을 진행했다. AWS는 좀 더 사용해봐야 감이 더 잡힐 거 같고, 이번 주에도 코드를 구현하며 알게 된 점을 정리하고자 한다.

 

1. JwtConfig 설정 실수로 인한 Refresh Token 만료 버그

JwtConfig는 application.yml 파일에 정의된 jwt 관련 설정값들을 바인딩해서 관리하는 클래스이다.

설정 파일의 값을 매핑해서 편리하게 쓸 수 있도록 하는 일종의 Dto라고 볼 수 있다.

그런데 실수한 것은 refreshExpiration을 jwtrefreshExpiration으로 뒀더니

yml 파일에서 파싱을 못해서 토큰 생성과 동시에 만료되어버리는 불상사가 발생했다. 아무리 로그인을 해도 바로 종료가 되어버리니 미치는 줄 알았는데, dto 이름 설정을 잘못하는 바람에 생긴 일이었다. 
최종에서는 절대 이런 일 없게 주의해야겠다.


2. UserDetails와 CustomDetails

 

UserDetails 인터페이스

  • Spring Security에서 제공하는 **인증된 사용자 정보 표현을 위한 인터페이스**
  • 사용자 권한 목록, 사용자의 아이디, 비밀번호, 권한, 계정 만료 여부, 계정 잠김 여부, 자격 증명 만료 여부, 계정  활성화 여부 등 보안과 관련된 사용자 정보가 추상화되어있음

CustomUserDetails 클래스

  • UserDetails를 직접 구현한 클래스
  • 구현할 서비스에서 실제 User 엔티티 정보를 Spring Security에 넘겨주는 데 사용

CustomUserDetails 사용 이유

  1. Spring Security의 SecurityContextHolder는 기본적으로 String만 저장함
    • JWT 토큰을 parsing 한 후, userId 같은  문자열만 SecurityContext에 저장하면 이후에 @AuthenticationPrincipal User user처럼 User 엔티티를 직접 주입받으려 할 때 변환이 불가능
  2. @AuthenticationPrincipal User user 사용 시 문제 발생
    • UserController에서 사용자 정보를 직접 주입받기 위해
      @AuthenticationPrincipal User user를 사용했는데,
      SecurityContextHolder에는 문자열만 저장되어 있었기 때문에 User로 변환하지 못해
      user.getUserId() 호출 시 NullPointerException 발생.
  3. 해결방법: CustomUserDetails 객체를 SecurityContext에 저장
    • User 엔티티를 래핑한 CustomUserDetails 객체를
      UsernamePasswordAuthenticationToken에 담아 SecurityContext에 저장하면,
      @AuthenticationPrincipal CustomUserDetails customUserDetails 로 주입받을 수 있고,
      내부에서 getUser() 호출을 통해 실제 User 엔티티에도 접근 가능해짐.

3. 주의❗import를 잘하자

틀린 그림 찾기 1

정답: import jakarta.transaction.Transactional;
import org.springframework.transaction.annotation.Transactional;를 해야 한다.

 

jakarta.transaction.Transactional은 Jakarta EE (구 Java EE)의 @Transactional 어노테이션은 
Spring Framework에서 사용하는 @Transactional과는 다르다.

 

꼭! org.springframework.transaction.annotation.Transactional을 import 하도록 하자.

 

틀린 그림 찾기 2

정답: import { useRouter } from "next/router";
import { useRouter } from "next/navigation";를 해야 한다.

 

"next/navigation"의 useRouter는 Next.js 12 이하에서 사용되던 방식이다. 

app/ 디렉토리 기반의 App Router 환경에서는 next/router 대신 next/navigation에서 useRouter()를 가져와야 한다.

 

next/router는 여전히 pages 디렉토리 기반의 라우팅 방식 (Pages Router)에서만 유효하다.

 

꼭! import { useRouter } from "next/navigation"을 하도록 하자.


4. BaseEntity와  MappedSuperClass

baseEntity의 created_at 컬럼이 User 엔티티 컬럼으로 매핑되지 않고 자꾸 null로 반환되는 문제가 발생했다.

 

Jpa Auditing도 했고 추상클래스로 선언도 잘했고 오타도 없었는데 문제가 뭔지 알 수 없었다.

메서드를 호출할 때 Hibernate가 생성하는 쿼리문을 보니, Jpa가 created_at을 아예 select 쿼리에서 빼고 있었다.

즉 Jpa가 created_at을 무시하고 있는 거였다.

 

@MappedSuperClass을 BaseEntity에 걸어주지 않은 것이 문제였다.

@MappedSuperClass는 해당 클래스를 엔티티로 만들지는 않지만, 상속받는 엔티티(User 등)에 필드를 포함시켜주는 역할을 한다고 한다. 

즉 이 어노테이션이 있어야 User는 created_at을 자신의 컬럼처럼 포함할 수 있게 되는 것이다.


5. Next.js Hydration Error : 로그인 상태 기반 조건부 렌더링 시 주의할 

문제 상황

회원가입 창에서 이미 있는 회원 정보를 입력하여 "이미 있는 회원입니다" 메시지를 확인 → 같은 창에서 로그인 버튼을 눌러서 로그인 창으로 이동 다시 헤더에서 회원가입을 누르면 Hydration Error 발생

 

SSR vs CSR

항목 SSR (Server-Side Rendering) CSR (Client-Side Rendering)
서버에서 HTML을 미리 렌더링해서 클라이언트에 전달 브라우저에서 JavaScript로 렌더링 수행
렌더링 위치 서버 (HTML 생성) 클라이언트 (JS가 실행 후 렌더)
데이터 준비 서버가 HTML에 데이터를 포함해서 보냄 클라이언트가 서버에 데이터를 요처한 후 렌더링
처음 도착하는 HTML 완성된 HTML 빈 HTML + JS (JS가 렌더링)
초기 속도 빠름 (HTML 바로 표시) 느림 (JS 다운 후 렌더)
SEO 친화도 매우 좋음 상대적으로 불리함
문제점 클라이언트 상태 반영 어려움 초기 로딩 지연, SEO 한계

 

문제원인 : 서버와 클라이언트의 상태 불일치

  • 설정해놓은 Header 컴포넌트 조건부 렌더링
    • Header 컴포넌트는 localStorage에서 accessToken을 읽어 isLogginIn 상태를 설정함
    • isLogginIn 값에 따라 헤더에 로그인/회원가입과 로그아웃/마이페이지가 조건부 렌더링됨
  • SSR 시점에는 localStorage 접근이 불가능함. 즉 isLogginIn = False 기준으로 만들어짐
  • CSR로 전환(그러나 회원가입 페이지 진입 후) 후에는 localStorage 값을 읽었고, isLoggedIn = true로 바뀌며 그에 따라  HTML이 바뀌므로 Dom mismatch → Hydration Error가 발생
    • 아직 로그인하지 않았는데 어떻게 localStorage를 읽은 것이냐하면,
      로그인 시 "아이디 저장" 기능을 구현하였기 때문에, 이전에 localStorage에 저장된 아이디를 읽을 수 있었던 것
    • 아래와 같은 코드로 이루어져있었기 때문에 아이디만 있어도 IsLoggedIn 값이 true가 된 것이다.
useEffect(() => {
  const token = localStorage.getItem('accessToken');
  const userId = localStorage.getItem('userId');

  if (token || userId) {
    setIsLoggedIn(true);
  }
}, []);

 

해결 방법

  • hydrated 상태를 활용한 방어 코드 추가 
    → SSR에서는 아무 것도 렌더링하지 않도록 차단 및 CSR 이후에만 localStorage 접근
// 앱 시작 시 localStorage 값으로 초기화
const [hydrated, setHydrated] = useState(false);

useEffect(() => {
  setHydrated(true); // CSR 시점에만 true
}, []);

  // CSR 환경 여부 플래그
  useEffect(() => {
    setHydrated(true);
  }, []);

  // localStorage는 CSR 이후에 접근
  useEffect(() => {
    if (typeof window !== 'undefined') {
      const token = localStorage.getItem('accessToken');
      const name = localStorage.getItem('userName');
      const number = localStorage.getItem('userNo');
      setIsLoggedIn(!!token);
      setAccessToken(token || '');
      setUserName(name || '');
      setUserNo(number || '');
    }
  }, []);

if (!hydrated) return null; // SSR에서는 아무 것도 렌더링하지 않음

6. Delete와 Options: Prefligh와 CORS 흐름

백엔드와 프론트엔드 연결 중 delete 요청을 했더니 다음과 같은 alert를 받았다.

 

엔드포인트도 정확히 연결했고, 두 서버 모두 열려있었으며 SecurityConfig에도 Delete 요청에 대해 setAllowedMethods를 해두었기 때문에 도통 이유를 알 수 없었다.

 

알고보니, Preflight Request로 인한 것이었다.

 

CORS

  • Cross-Origin Resource Sharing (교차 출처 리소스 공유)
  • 브라우저 보안 정책인 CORS 정책 때문에, 프론트엔드 앱이 다른 도메인의 서버에 요청을 보낼 때 제한 발생
    • 프론트엔드 서버: http://localhost:3000  
    • 백엔드 서버: http://localhost:8080
  • 이렇게 도메인이 서로 다를 때 브라우저는 보안을 이유로 바로 요청하지 않고 OPTIONS(사전요청)을 보냄
  • 일종의 확인 메시지

 

Preflight Request (프리플라이트 or 사전 요청)

  • 브라우저는 보안상의 이유로 CORS 정책을 따르며, Delete/Put/Patch 등 안전하지 않은 메서드를 사용할 경우, 먼저 Preflight Request(사전요청)를 보냄
  • 회원탈퇴 흐름 요약
    1. 브라우저가 DELETE /api/mypage/withdrawal 요청 시도
    2. 먼저 OPTIONS /api/mypage/withdrawal 요청을 보내서 서버가 허용하는지 확인
    3. 서버가 OPTIONS 요청을 막으면, 브라우저는 DELETE 요청을 보내지 않음
    4. 서버가 OPTIONS 요청에 대해 200 OK로 응답하면, 실제 DELETE 요청이 전송됨

해결 방법

  • SecurityConfig에서 OPTIONS 요청도 받아들이도록 설정

  • WebMvcConfig에서도 OPTIONS 요청을 받아들이도록 설정

 

참고: 실제 회원탈퇴 방식 - soft delete

  • 회원탈퇴는 실제로 DB에서 데이터를 삭제하는 것이 아니라, is_deleted 같은 플래그를 두고 soft delete 방식으로 처리
    • Soft Delete
      : 데이터를 삭제하지 않고, 삭제된 것처럼 표시함. 플래그 컬럼(is_deleted)을 추가함
    • Hard Delete
      : 데이터를 DB에서 실제로 삭제함

하하 4차 프로젝트에서는 회원탈퇴하면 시원하게 DB에서 날려버렸는데,,,최종 프로젝트에서는 soft delete 방식으로 진행해야겠다.


7. JPA 매핑만으로 Foreign Key가 생기지 않은 이유: 타입 불일치로 인한 FK 생성 실패

회원탈퇴 로직 구현 중 user 테이블의 PK값인 userNo를 참조하고 있는 테이블 row들을 먼저 삭제해야 user를 db에서 삭제할 수 있다는 걸 알게되었다. 그래서 연관 엔티티를 순서대로 삭제한 후 사용작를 삭제하는 로직을 구현하였다.

  • reviews table : user_no 참조
  • orders table : address_id 참조
  • address table : user_no 참조

 

문제는, address 테이블의 user_no 컬럼에 FK 참조가 걸려있지 않다는 것을 알게 되었다. 

이렇게 @JoinColum을 걸어주었는데도 참조가 걸리지 않았고, 테이블에 컬럼값으로 들어가기만 하였다.

알고보니 @JoinColumn만으로는 외래 키 제약 조건이 자동으로 생성되지 않는다고 한다.

 

DB에서 외래 키 제약 조건을 걸어주기 위한 JPA 구현체 및 DDL 생성 설정

  • application.yml에서 DDL을 자동 생성하도록 설정해야 함 - 이건 했으니까 pass
spring:
  jpa:
    hibernate:
      ddl-auto: update  # 또는 create, create-drop
  • JPA 구현체의 외래키 생성 지원 - 이것도 했으니까 pass
    : Hibernate의 경우, @JoinColumn만으로도 FK 제약을 생성하지만, 이를 확실히 하려면 추가 설정을 걸어주는 것이 좋음
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_no", referencedColumnName = "user_no" , foreignKey = @ForeignKey(name = "fk_entityname_user"))
private User user;

 

문제상황

  • 위의 두 조건을 모두 충족했는데 FK로 지정이 안되었음

해결 ?

  • 이런 경우 직접 SQL문을 작성하는 것도 방법이라 위와 같이 작성하였다.
  • 여기서 다시 문제가 발생했다. 두 user_no의 타입이 다르다는 거였다.

다시 문제상황 및 해결

 

  • SQL문을 통해 타입을 확인해보니 두 컬럼의 타입이 다르다는 것을 확인할 수 있었다.
  • 따라서 address의 user_no를 INT로 바꿔주는 SQL문을 작성하였다.

 

이제야 제대로 참조하고 있는 걸 확인할 수 있었다.


8. isDuplicate → duplicate로 바뀌는 이유: Jackson과 boolean getter 네이밍의 관계 

 

문제 상황

  • 회원가입 전 이미 회원으로 등록되어 있는지 확인하는 과정에서 쓰이는 UserCheckBeforeSignUpResponse Dto에 
    'isDuplicate'로 설정해둔 것이, postman에는 'duplicate'로 오는 것을 확인할 수 있었다.

isDuplicate → duplicate로 바뀌는 이유

  • Java에서 boolean 필드를 정의할 때, 필드명이 isDuplicate라면 getter는 isDuplicate()로 자동 생성된다고 한다.
  • 이때 Spring Boot의 Jackson은 객체를 JSON으로 직렬화할 때 getter 메소드의 이름을 기준으로 키를 결정한다.
    • getXxx() → "xxx"
    • isXxx() → "xxx"
  • isDuplicate()는 getter 자체가 isDuplicate()인데, 이미 is가 포함되어 있으므로 중복된 is가 제거된다. 
    즉 JSON 키가 duplicate이 되는 것이다.

해결방법

  1. 처음부터 is_로 시작하는 필드명을 만들지 않고, duplicate으로 정하면 된다. 다음부터는 필드명에 is가 들어가지 않게 해야겠다.
  2. @JsonProperty("isDuplicate") 어노테이션을 필드값 위에 붙여서 원하는 키 이름으로 강제 지정할 수 있다.
    UserCheckBeforeSignUpResponse Dto에서 쓰는 isDuplicate의 경우 프론트엔드에서 이름을 잘 지정하여 넘겼으나
    AddResponse Dto에서 쓰는 isDefault는 너무 헷갈려서 @JsonProperty("isDefault")을 사용했다.