feat: 프로필 사진 — 구글 아바타 동기화 + 사용자 지정 이미지

- member.google_picture(구글 아바타 URL), profile_image(사용자 지정 data URL) 컬럼 추가(멱등)
- 구글 로그인 시 picture 클레임 추출 → 신규 저장, 기존은 변경 시 동기화
- PUT /api/auth/profile-image: 사용자 지정 사진 설정/해제(빈값=해제→구글 폴백), data URL·용량 검증
- MemberResponse 에 googlePicture/profileImage 노출(표시 우선순위 프론트 처리)
- 관리자 목록(findAll)은 무거운 profile_image 제외해 가볍게 조회

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-28 11:39:35 +09:00
parent 6256d3e63d
commit 98f5f51112
10 changed files with 89 additions and 6 deletions
@@ -104,4 +104,12 @@ public class AuthController {
HttpServletRequest request) {
return authService.updateProfile(current.getId(), AuthInterceptor.resolveToken(request), req);
}
/** 프로필 사진(사용자 지정) 변경/해제 — image 가 비면 해제(구글 사진으로 폴백) */
@PutMapping("/profile-image")
public MemberResponse updateProfileImage(
@RequestBody com.sb.web.auth.dto.ProfileImageRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return authService.updateProfileImage(current.getId(), req.getImage());
}
}
@@ -26,6 +26,8 @@ public class Member implements Serializable {
private String provider; // LOCAL / NAVER / GOOGLE
private String providerId; // 소셜 제공자 측 고유 ID
private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지)
private String googlePicture; // 구글 아바타 URL (로그인 시 동기화)
private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜
private String role; // USER / ADMIN
private String plan; // FREE / PREMIUM (멤버십)
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
@@ -18,6 +18,8 @@ public class MemberResponse {
private String provider;
private String role;
private String plan; // FREE / PREMIUM
private String googlePicture; // 구글 아바타 URL
private String profileImage; // 사용자 지정 프로필 이미지(data URL)
public static MemberResponse from(Member m) {
return MemberResponse.builder()
@@ -28,6 +30,8 @@ public class MemberResponse {
.provider(m.getProvider())
.role(m.getRole())
.plan(m.getPlan())
.googlePicture(m.getGooglePicture())
.profileImage(m.getProfileImage())
.build();
}
}
@@ -0,0 +1,13 @@
package com.sb.web.auth.dto;
import lombok.Data;
/**
* 프로필 사진(사용자 지정) 변경 요청. image 가 null/빈 값이면 해제(구글 사진으로 폴백).
*/
@Data
public class ProfileImageRequest {
/** data URL(base64 이미지). null 이면 사용자 지정 사진 해제. */
private String image;
}
@@ -40,6 +40,12 @@ public interface MemberMapper {
/** 프로필(이름/이메일) 변경 */
int updateProfile(@Param("id") Long id, @Param("name") String name, @Param("email") String email);
/** 구글 로그인 시 구글 아바타 URL 동기화 */
int updateGooglePicture(@Param("id") Long id, @Param("googlePicture") String googlePicture);
/** 사용자 지정 프로필 이미지 설정/해제(null = 해제 → 구글 사진으로 폴백) */
int updateProfileImage(@Param("id") Long id, @Param("profileImage") String profileImage);
int updateRole(@Param("id") Long id, @Param("role") String role);
int updatePlan(@Param("id") Long id, @Param("plan") String plan);
@@ -186,6 +186,7 @@ public class AuthService {
boolean emailVerified = "true".equals(String.valueOf(info.get("email_verified")));
String name = info.get("name") != null ? String.valueOf(info.get("name"))
: (email != null ? email.split("@")[0] : "사용자");
String picture = info.get("picture") != null ? String.valueOf(info.get("picture")) : null;
// 1) 구글 sub 로 이미 연결/가입된 계정 조회
Member member = memberMapper.findByGoogleId(sub);
@@ -216,6 +217,7 @@ public class AuthService {
.provider("GOOGLE")
.providerId(sub)
.googleId(sub)
.googlePicture(picture)
.role("USER")
.status("ACTIVE")
.build();
@@ -225,6 +227,11 @@ public class AuthService {
if (!"ACTIVE".equals(member.getStatus())) {
throw new ApiException(HttpStatus.FORBIDDEN, "사용할 수 없는 계정입니다.");
}
// 구글 아바타가 바뀌었으면 동기화(신규 가입은 이미 반영됨 → 변경 없을 때 불필요한 UPDATE 생략)
if (picture != null && !picture.equals(member.getGooglePicture())) {
memberMapper.updateGooglePicture(member.getId(), picture);
member.setGooglePicture(picture);
}
return issueSession(member, rememberMe);
}
@@ -335,6 +342,31 @@ public class AuthService {
return MemberResponse.from(member);
}
/**
* 프로필 사진(사용자 지정) 설정/해제. null/빈 값이면 해제 → 구글 사진으로 폴백.
* data URL(base64 이미지)만 허용하고 과도한 크기는 거절한다.
*/
@Transactional
public MemberResponse updateProfileImage(Long memberId, String image) {
Member member = memberMapper.findById(memberId);
if (member == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
}
String value = (image == null || image.isBlank()) ? null : image.trim();
if (value != null) {
if (!value.startsWith("data:image/")) {
throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 형식이 올바르지 않습니다.");
}
if (value.length() > 700_000) { // 약 500KB 이미지 상한 (base64 약 33% 팽창 고려)
throw new ApiException(HttpStatus.BAD_REQUEST, "이미지 용량이 너무 큽니다. 더 작은 사진을 사용하세요.");
}
}
memberMapper.updateProfileImage(memberId, value);
member.setProfileImage(value);
log.info("[profile] image {} for {}", value == null ? "cleared" : "updated", member.getLoginId());
return MemberResponse.from(member);
}
/** 프로필 이름 변경 시 활성 세션(Redis + DB 백업)의 표시 이름을 맞춘다. */
private void syncSessionName(String token, String name) {
if (token == null || token.isBlank()) {
@@ -26,7 +26,7 @@ public class WebConfig implements WebMvcConfigurer {
// 인증: 로그인 필요한 경로 (관리자 경로 포함 — SessionUser 세팅용으로 먼저 실행)
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/auth/me", "/api/auth/logout", "/api/auth/password",
"/api/auth/verify-password", "/api/auth/profile",
"/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image",
"/api/board/**", "/api/admin/**", "/api/account/**");
// 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
registry.addInterceptor(adminInterceptor)
+5
View File
@@ -30,6 +30,11 @@ UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_i
-- 멤버십 플랜 (무료/유료). 기존 회원은 FREE 로 시작 (멱등).
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'FREE' COMMENT 'FREE / PREMIUM' AFTER role;
-- 프로필 사진. google_picture 는 구글 로그인 시 동기화되는 구글 아바타 URL,
-- profile_image 는 사용자가 직접 올린 커스텀 이미지(data URL). 표시 우선순위: profile_image > google_picture > 이니셜.
ALTER TABLE member ADD COLUMN IF NOT EXISTS google_picture VARCHAR(500) NULL COMMENT '구글 아바타 URL (로그인 시 동기화)' AFTER google_id;
ALTER TABLE member ADD COLUMN IF NOT EXISTS profile_image MEDIUMTEXT NULL COMMENT '사용자 지정 프로필 이미지(data URL)' AFTER google_picture;
-- ============================================================
-- 앱 전역 설정 (단일 행, id=1). 관리자 화면에서 변경.
-- ============================================================
+16 -4
View File
@@ -12,6 +12,8 @@
<result property="provider" column="provider"/>
<result property="providerId" column="provider_id"/>
<result property="googleId" column="google_id"/>
<result property="googlePicture" column="google_picture"/>
<result property="profileImage" column="profile_image"/>
<result property="role" column="role"/>
<result property="plan" column="plan"/>
<result property="status" column="status"/>
@@ -20,15 +22,17 @@
</resultMap>
<sql id="columns">
id, login_id, password, name, email, provider, provider_id, google_id, role, plan, status, created_at, updated_at
id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, status, created_at, updated_at
</sql>
<select id="findById" parameterType="long" resultMap="memberResultMap">
SELECT <include refid="columns"/> FROM member WHERE id = #{id}
</select>
<!-- 관리자 목록: 무거운 profile_image(MEDIUMTEXT)·password 는 제외하고 가볍게 조회 -->
<select id="findAll" resultMap="memberResultMap">
SELECT <include refid="columns"/> FROM member ORDER BY id
SELECT id, login_id, name, email, provider, provider_id, google_id, role, plan, status, created_at, updated_at
FROM member ORDER BY id
</select>
<select id="findByLoginId" parameterType="string" resultMap="memberResultMap">
@@ -66,9 +70,9 @@
<insert id="insert" parameterType="com.sb.web.auth.domain.Member"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO member (login_id, password, name, email, provider, provider_id, google_id, role, status, created_at, updated_at)
INSERT INTO member (login_id, password, name, email, provider, provider_id, google_id, google_picture, role, status, created_at, updated_at)
VALUES (#{loginId}, #{password}, #{name}, #{email},
#{provider}, #{providerId}, #{googleId}, #{role}, #{status}, NOW(), NOW())
#{provider}, #{providerId}, #{googleId}, #{googlePicture}, #{role}, #{status}, NOW(), NOW())
</insert>
<update id="updatePassword">
@@ -79,6 +83,14 @@
UPDATE member SET name = #{name}, email = #{email}, updated_at = NOW() WHERE id = #{id}
</update>
<update id="updateGooglePicture">
UPDATE member SET google_picture = #{googlePicture}, updated_at = NOW() WHERE id = #{id}
</update>
<update id="updateProfileImage">
UPDATE member SET profile_image = #{profileImage}, updated_at = NOW() WHERE id = #{id}
</update>
<update id="updateRole">
UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id}
</update>
@@ -48,7 +48,8 @@ class WebConfigTest {
"/api/auth/logout",
"/api/auth/password",
"/api/auth/verify-password",
"/api/auth/profile");
"/api/auth/profile",
"/api/auth/profile-image");
// 유료(PREMIUM) 전용 경로가 premiumInterceptor 에 등록되는지 회귀 가드
assertThat(allPatterns).contains(
"/api/account/stats",