프로필 이미지 기본값 처리 전략 - KimGyuBek/Threadly GitHub Wiki

프로필 이미지 기본값 처리 전략

개요

게시글 조회나 팔로워 목록 조회 등 사용자 프로필 이미지를 포함하는 API에서 LEFT JOIN으로 user_profile_images 테이블을 조회하게 되고 사용자가 프로필 이미지를 설정하지 않은 경우, NULL이 반환된다.

그러나 외부로 나가는 응답에서는 NULL이 그대로 전달되면 안 되고 백엔드 내부에서 NULL 체크를 통해 기본값을 넣어서 응답이 나가야 한다.

따라서,

"프로필 이미지가 NULL일 경우 NULL 체크 후 기본이미지 처리를 어떤 계층에서 어떤 방식으로 적용할것인가"

라는 문제가 발생한다. 이 문서는 이 기본값 처리에 대한 책임 위치와 구현 방식의 결정 과정을 다룬다.


현재 상황

조회 쿼리에서 COALESCE를 이용해서 사용자 프로필 이미지가 NULL인 경우, 기본값을 지정하도록 구현되어 있다.

예시

-- FollowJpaRepository.java
select uf.follow_id                 as followId,
--        생략
       coalesce(upi.image_url, '/') as requesterProfileImageUrl, -- coalesce로 null일 경우 대비
    ...
    from user_follows uf
    join user_profile up
on up.user_id = uf.follower_id
    left join user_profile_images upi
    on upi.user_id = uf.follower_id and upi.status = 'CONFIRMED'
-- 생략

장점

  • 가장 간단하고 직관적인 방법이다.
  • 쿼리 한 번으로 기본 이미지가 적용된 완성 데이터가 반환되므로, 추가적인 가공 로직이 필요 없다.

단점

  • 기본 이미지 정책이 변경되는 경우, 하드코딩된 쿼리를 모두 수정해야한다.
  • DB에서 기본값을 정하는 도메인 규칙을 직접 수행하게 되어, 계층 책임이 침범된다.

대안

1. adapter 계층에서 기본값 처리

adapter-persistence 모듈의 PersistenceAdapter에서 Repository를 이용해 조회한 결과를 받아, NULL체크 후 기본 값을 설정하는 방식

  • 현재 구조에서는 Service에서 Port를 통해 PersistenceAdapter가 반환하는 Projection 인터페이스를 그대로 전달받는 형태이기 때문에, 이 방식으로 기본값 처리를 할 경우 구조적 제약이 발생하게 된다.

예시

//FollowQueryService.java
@Service
public class FollowQueryService implements FollowQueryUseCase {

  @Transactional(readOnly = true)
  @Override
  public GetUserFollowStatsApiResponse getUserFollowStats(String userId) {
//    ...
    UserFollowStatsProjection userFollowStatsProjection = followQueryPort.getUserFollowStatusByUserId(
        userId); //Port를 통해 projection 인터페이스를 Service 계층으로 그대로 반환받음
//    ...
  }
}
  • PersistenceAdapter에서 null체크 후 기본값을 주입해버리면 더 이상 Projection 객체를 리턴할 수가 없다. 결과적으로 도메인객체나 별도의 DTO를 생성하여 변경 후 리턴을 해야하는 상황이 발생한다.
  • Claude의 분석 결과 리턴 타입을 Projection에서 별도의 DTO로 변경할 경우, 약 17개의 Service 메서드와 PersistenceAdapter 까지 변경 및 매핑 로직을 추가하면 상당수의 코드를 수정해야하므로 리팩터링 비용이 커서 적용하기에는 부담이 크다
분석 결과
  profileImageUrl 또는 UserPreview가 포함된 Projection을 반환하는 메서드: 총 17개
  • 그리고 가장 중요한 단점으로,

adapter-persistence 계층에서 NULL 체크 후 기본값을 설정하는 것은 계층간 책임 분리를 위반하는 행위이다.

이는 인프라 계층이 담당해야 할 데이터 조회 및 전달 역할의 범위를 크게 벗어나 "기본 이미지 지정"과 같은 도메인 규칙을 수행하게 만드는 문제를 초래하기 떄문이다.

따라서 이러한 로직은 상위 계층에서 처리되어야 한다.

2. Service 계층에서 기본값 처리

userProfileImageUrl을 리턴하는 Service의 메서드에서 NULL 체크 후 기본값 설정하는 방식

  • Service 계층은 응답 구성과 도메인 로직 사이의 중간 계층이므로 기본값 설정을 구현하기 가장 적합한 계층이다.
  • Port(Adapter)에서 Projection 인터페이스를 반환 받아 Controller에 응답으로 매핑하는 과정에서, 직접 NULL 체크를 하거나 ProfileImageUtil 같은 Util 클래스를 구현해서 기본값을 설정하는 방식이 자연스럽다.

다만 기존코드 전반에 걸쳐 NULL 체크 로직을 추가해야 하므로 리팩토링 비용이 크다.

Service 레이어 profileImageUrl null 체크 분석 결과
  총 17개 메서드에서 null 체크 필요

3. API 응답 객체에서 기본 값 처리

사용자 정보를 포함하는 응답에서는 UserPreview 객체를 사용한다.

이 객체 내부에서 NULL 체크 후 기본값을 설정하는 방식

//UserPreview.java
public record UserPreview(
        String userId,
        String nickname,
        String profileImageUrl
    ) {

}
  • Service에서 Controller로 응답을 매핑하는 과정에서 UserPreview를 생성자를 통해서 생성하고 있다.

예시

@Service
@RequiredArgsConstructor
public class PostQueryService implements PostQueryUseCase {

  @Transactional(readOnly = true)
  @Override
  public PostDetails getPost(GetPostQuery query) {
//    생략
    return new PostDetails(postDetailsProjection.getPostId(),
        new UserPreview(            //UserPreview 생성 후 응답으로 리턴
            postDetailsProjection.getUserId(),
            postDetailsProjection.getUserNickname(),
            postDetailsProjection.getUserProfileImageUrl()
        ),
//            생략
    );
  }
}
  • Record 타입의 UserPreveiw를 그래도 유지하되, 압축 생성자를 이용해 생성자 내부에서 NULL 체크 후 기본값 설정을 하도록 한다.
  • 기본값 설정 책임이 UserPreview 내부로 일원화되어 단일 책임 원칙에 부합한다.
  • UserPreview만 수정하면 되므로 코드 변경 범위가 최소화되고 유지보수가 쉽다.
  • 객체가 자신의 데이터 정합성을 스스로 보장하므로 표현 객체로서의 응집도가 높아진다.

4. 프로필 이미지 정보 객체 도입

현재는 UserPreview 내부에 String userProfileImageUrl을 필드가 존재한다.

이 방식을 개선하기 위해 UserProfileImge 응답 객체를 새로 도입하고, 내부에서 NULL 체크 및 기본값 처리와 함께 이미지 상태를 나타내는 Emum(ProfileImageStatus) 필드를 추가하는 방식

이렇게 하면 문자열 기반의 단순 기본값 처리(default) 대신, 프론트엔드가 상태(DEFAULT, CONFIRMED)를 명확히 구분할 수 있다.

예시

public class UserProfileImage {

  private final String imageUrl;
  private final ProfileImageStatus status;

  private static final String DEFAULT_IMAGE_URL = "/images/default/profile.webp";

  public UserProfileImage(String imageUrl, ProfileImageStatus status) {
    this.status = (status != null) ? status : ProfileImageStatus.DEFAULT; //null 체크 후 기본상태 지정
    this.imageUrl = (imageUrl != null) ? imageUrl : DEFAULT_IMAGE_URL; //null 체크 후 기본 이미지 설정
  }
}
  • 모든 NULL 체크 및 기본값 로직이 UserProfileImage 내부에 집중되어 응집도 상승
  • 항상 NULL-safe한 상태를 유지한다.
  • 단순 문자열 처리 대신 Enum 상태 필드를 추가해, 프론트에서 상태를 명확하게 구분 가능하다.
  • 기본값 정책이 변경되더라도 수정 범위가 최소화된다.
  • profileImageUrl이 단순 문자열이 아닌 값객체가 되어 의미가 명확해진다.
  • 상태(ProfileImageStatus)를 함꼐 관리함으로써 도메인 응집력이 향상된다.
  • NULL 체크, 기본값 설정 등을 모두 객체 내부에 캡슐화가 가능하다.

설계적 완성도가 가장 높은 방식

하지만,

응답 객체 구조가 변경되면서 API 스펙 전반에 영향이 발생한다.

프론트엔드, 테스트 코드, 문서 등 연관된 모든 영역을 함꼐 수정해야 하므로,

결과적으로 리팩터링 비용이 너무 크다.

 상세 변경 범위 분석
  총 최소 132개 파일 변경 필요

최종 선택

3. API 응답 객체에서 기본값 처리

//UserPreview.java
public record UserPreview(
        String userId,
        String nickname,
        String profileImageUrl
    ) {

  public UserPreview(String userId, String nickname,
      String profileImageUrl) { //압축생성자를 이용해서 null 체크 후 기본값 설정
    this.userId = userId;
    this.nickname = nickname;
    this.profileImageUrl = profileImageUrl == null ? "default" : profileImageUrl;
  }
}
  • 코드 변경 범위가 가장 적고, 유지 보수가 용이하다.

  • 4번 방법이 설계상 완벽하지만, 수정해야하는 코드가 너무 많고 API 스펙까지 변경되어 현실적으로 부담이 크다.

이번 경험을 통해서 "설계의 중요성은 구현 단계에서 가장 크게 드러난다"는 점을 직접 체감했다.

⚠️ **GitHub.com Fallback** ⚠️