공부 내용을 정리하고 앞으로의 학습에 이해를 돕기 위해 작성합니다.
이번 챕터에서는 어드민 서비스의 도메인 설계를 진행해 보자. 어드민 서비스에서도 사용자 계정(UserAccount) 정보가 필요하므로 기존 프로젝트에서 UserAccount 도메인과 관련된 클래스를 복사해 왔다. 추가적으로, 해당 도메인과 연결된 AuditingFields 클래스도 함께 복사하여 적용했다.
1. UserAccount 도메인 설정
어드민 서비스에서 사용자 계정을 관리하기 위해 기존의 UserAccount를 그대로 복사해 사용한다.
이 계정 정보는 스프링 시큐리티를 통해 인증과 권한을 처리하는 데 사용된다.
2. 권한 정보 설정
이번에는 사용자 계정에서 권한(Role) 정보를 추가로 활용할 예정이다. 이를 위해 기존 프로젝트에서 시큐리티 관련 설정을 복사해왔다.
구체적으로는 dto 패키지와 그 하위 클래스들 UserAccountDto, security(BoardAdminPrincipal, KakaoOAuth2Response)을 가져왔다.
3. RoleType의 분리
스프링 시큐리티와의 결합을 줄이기 위해, 원래 BoardAdminPrincipal 내부에 있던 RoleType enum을 별도의 constant 패키지로 분리했다. 이로 인해 권한 설정이 더 유연해지고 재사용 가능성이 높아졌다.
package org.example.projectboardadmin.domain.constant;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public enum RoleType {
USER("ROLE_USER"),
MANAGER("ROLE_MANAGER"),
DEVELOPER("ROLE_DEVELOPER"),
ADMIN("ROLE_ADMIN");
@Getter private final String roleName;
}
4. BoardAdminPrincipal 설정
BoardAdminPrincipal 클래스는 스프링 시큐리티의 UserDetails와 OAuth2User를 구현하여 사용자 인증과 관련된 정보를 제공한다. 이 클래스에서 필요한 UserAccountDto도 가져와 적용했다.
변경된 BoardAdminPrincipal 클래스는 권한 정보를 Set<RoleType>으로 받아 처리할 수 있도록 설계되었다. 이를 통해 사용자에게 여러 권한을 할당하거나 유연하게 관리할 수 있게 되었다.
package org.example.projectboardadmin.dto.security;
import org.example.projectboardadmin.domain.constant.RoleType;
import org.example.projectboardadmin.dto.UserAccountDto;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public record BoardAdminPrincipal(
String username,
String password,
Collection<? extends GrantedAuthority> authorities,
String email,
String nickname,
String memo,
Map<String, Object> oAuth2Attributes
) implements UserDetails, OAuth2User {
public static BoardAdminPrincipal of(String username, String password, Set<RoleType> roleTypes, String email, String nickname, String memo) {
return BoardAdminPrincipal.of(username, password, roleTypes, email, nickname, memo, Map.of());
}
public static BoardAdminPrincipal of(String username, String password, Set<RoleType> roleTypes, String email, String nickname, String memo, Map<String, Object> oAuth2Attributes) {
return new BoardAdminPrincipal(
username,
password,
roleTypes.stream()
.map(RoleType::getRoleName)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toUnmodifiableSet())
,
email,
nickname,
memo,
oAuth2Attributes
);
}
public static BoardAdminPrincipal from(UserAccountDto dto) {
return BoardAdminPrincipal.of(
dto.userId(),
dto.userPassword(),
dto.email(),
dto.nickname(),
dto.memo()
);
}
public UserAccountDto toDto() {
return UserAccountDto.of(
username,
password,
email,
nickname,
memo,
null,
null,
null,
null
);
}
@Override public String getUsername() { return username; }
@Override public String getPassword() { return password; }
@Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return true; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return true; }
@Override public Map<String, Object> getAttributes() { return oAuth2Attributes; }
@Override public String getName() { return username; }
}
5. 권한 설정을 위한 Converter 구현
이제 어드민 서비스에서 사용자 계정(UserAccount)에 권한 정보를 추가하고 이를 JPA 엔티티로 구현하겠다. Set<RoleType> 필드를 JPA가 처리할 수 있도록 변환기(Converter)를 적용해, 자바 객체에서는 Set으로 다루지만 데이터베이스에는 문자열로 저장될 수 있도록 설정한다.
먼저, domain 패키지 하위에 converter 패키지를 만들고, RoleTypesConverter 클래스를 생성한다.
package org.example.projectboardadmin.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.example.projectboardadmin.domain.constant.RoleType;
import java.util.Objects;
import java.util.Set;
@Getter
@ToString(callSuper = true)
@Table(indexes = {
@Index(columnList = "email", unique = true),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy")
})
@Entity
public class UserAccount extends AuditingFields {
@Id
@Column(length = 50)
private String userId;
@Setter @Column(nullable = false) private String userPassword;
@Column(nullable = false)
private Set<RoleType> roleTypes;
...
}
package org.example.projectboardadmin.domain.converter;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import org.example.projectboardadmin.domain.constant.RoleType;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
@Converter
public class RoleTypesConverter implements AttributeConverter<Set<RoleType>, String> {
private static final String DELIMITER = ",";
@Override
public String convertToDatabaseColumn(Set<RoleType> attribute) {
return attribute.stream().map(RoleType::name).sorted().collect(Collectors.joining(DELIMITER));
}
@Override
public Set<RoleType> convertToEntityAttribute(String dbData) {
return Arrays.stream(dbData.split(DELIMITER)).map(RoleType::valueOf).collect(Collectors.toSet());
}
}
- 이 변환기를 통해 JPA는 Set<RoleType> 필드를 데이터베이스에 문자열로 저장하고, 다시 자바 객체로 변환할 수 있게 된다.
6. UserAccount 엔티티에 권한 설정 적용
이제 UserAccount 엔티티에 권한 정보를 추가하고, 앞서 만든 RoleTypesConverter를 적용한다. 또한, 생성자 및 팩토리 메서드에도 권한 설정을 반영한다.
package org.example.projectboardadmin.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.example.projectboardadmin.domain.constant.RoleType;
import org.example.projectboardadmin.domain.converter.RoleTypesConverter;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
@Getter
@ToString(callSuper = true)
@Table(indexes = {
@Index(columnList = "email", unique = true),
@Index(columnList = "createdAt"),
@Index(columnList = "createdBy")
})
@Entity
public class UserAccount extends AuditingFields {
@Id
@Column(length = 50)
private String userId;
@Setter @Column(nullable = false) private String userPassword;
@Convert(converter = RoleTypesConverter.class)
@Column(nullable = false)
private Set<RoleType> roleTypes = new LinkedHashSet<>();
@Setter @Column(length = 100) private String email;
@Setter @Column(length = 100) private String nickname;
@Setter private String memo;
protected UserAccount() {}
private UserAccount(String userId, String userPassword, Set<RoleType> roleTypes, String email, String nickname, String memo, String createdBy) {
this.userId = userId;
this.userPassword = userPassword;
this.roleTypes = roleTypes;
this.email = email;
this.nickname = nickname;
this.memo = memo;
this.createdBy = createdBy;
this.modifiedBy = createdBy;
}
public static UserAccount of(String userId, String userPassword, Set<RoleType> roleTypes, String email, String nickname, String memo) {
return UserAccount.of(userId, userPassword, roleTypes, email, nickname, memo, null);
}
public static UserAccount of(String userId, String userPassword, Set<RoleType> roleTypes, String email, String nickname, String memo, String createdBy) {
return new UserAccount(userId, userPassword, roleTypes, email, nickname, memo, createdBy);
}
public void addRoleType(RoleType roleType) {
this.getRoleTypes().add(roleType);
}
public void addRoleTypes(Collection<RoleType> roleTypes) {
this.getRoleTypes().addAll(roleTypes);
}
public void removeRoleType(RoleType roleType) {
this.getRoleTypes().remove(roleType);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof UserAccount that)) return false;
return this.getUserId() != null && this.getUserId().equals(that.getUserId());
}
@Override
public int hashCode() {
return Objects.hash(this.getUserId());
}
}
- 여기서 @Convert 어노테이션을 통해 Set<RoleType> 필드가 RoleTypesConverter를 사용하도록 설정되었다.
- 또한, 팩토리 메서드 및 권한 추가/삭제 메서드도 함께 구현되었다.
7. UserAccountDto에 RoleType 필드 추가
이제 UserAccountDto에도 RoleType 필드를 추가해, 데이터 전송 객체(DTO)를 통해 권한 정보를 전달할 수 있도록 한다.
public record UserAccountDto(
String userId,
String userPassword,
Set<RoleType> roleTypes,
String email,
String nickname,
String memo,
LocalDateTime createdAt,
String createdBy,
LocalDateTime modifiedAt,
String modifiedBy
) {
public static UserAccountDto of(String userId, String userPassword, Set<RoleType> roleTypes, String email, String nickname, String memo) {
return UserAccountDto.of(userId, userPassword, roleTypes, email, nickname, memo, null, null, null, null);
}
public static UserAccountDto of(String userId, String userPassword, Set<RoleType> roleTypes, String email, String nickname, String memo, LocalDateTime createdAt, String createdBy, LocalDateTime modifiedAt, String modifiedBy) {
return new UserAccountDto(userId, userPassword, roleTypes, email, nickname, memo, createdAt, createdBy, modifiedAt, modifiedBy);
}
public static UserAccountDto from(UserAccount entity) {
return new UserAccountDto(
entity.getUserId(),
entity.getUserPassword(),
entity.getRoleTypes(), // RoleType 필드 추가
entity.getEmail(),
entity.getNickname(),
entity.getMemo(),
entity.getCreatedAt(),
entity.getCreatedBy(),
entity.getModifiedAt(),
entity.getModifiedBy()
);
}
public UserAccount toEntity() {
return UserAccount.of(
userId,
userPassword,
roleTypes, // RoleType 필드 추가
email,
nickname,
memo
);
}
}
8. BoardAdminPrincipal에 권한 필드 추가
BoardAdminPrincipal 클래스에도 권한(RoleType) 필드를 추가하여, 스프링 시큐리티와의 통합을 완료한다.
package org.example.projectboardadmin.dto.security;
import org.example.projectboardadmin.domain.constant.RoleType;
import org.example.projectboardadmin.dto.UserAccountDto;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
public record BoardAdminPrincipal(
String username,
String password,
Collection<? extends GrantedAuthority> authorities,
String email,
String nickname,
String memo,
Map<String, Object> oAuth2Attributes
) implements UserDetails, OAuth2User {
public static BoardAdminPrincipal of(String username, String password, Set<RoleType> roleTypes, String email, String nickname, String memo) {
return BoardAdminPrincipal.of(username, password, roleTypes, email, nickname, memo, Map.of());
}
public static BoardAdminPrincipal of(String username, String password, Set<RoleType> roleTypes, String email, String nickname, String memo, Map<String, Object> oAuth2Attributes) {
return new BoardAdminPrincipal(
username,
password,
roleTypes.stream()
.map(RoleType::getRoleName)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toUnmodifiableSet())
,
email,
nickname,
memo,
oAuth2Attributes
);
}
public static BoardAdminPrincipal from(UserAccountDto dto) {
return BoardAdminPrincipal.of(
dto.userId(),
dto.userPassword(),
dto.roleTypes(), //추가
dto.email(),
dto.nickname(),
dto.memo()
);
}
public UserAccountDto toDto() {
return UserAccountDto.of(
username,
password,
authorities.stream()
.map(GrantedAuthority::getAuthority)
.map(RoleType::valueOf)
.collect(Collectors.toUnmodifiableSet()) //추가
,
email,
nickname,
memo,
null,
null,
null,
null
);
}
@Override public String getUsername() { return username; }
@Override public String getPassword() { return password; }
@Override public Collection<? extends GrantedAuthority> getAuthorities() { return authorities; }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return true; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return true; }
@Override public Map<String, Object> getAttributes() { return oAuth2Attributes; }
@Override public String getName() { return username; }
}
9. Kakao OAuth 2.0 응답 데이터 매핑 및 테스트
마지막으로, 카카오 OAuth 2.0 인증을 처리하기 위해 카카오에서 전달받은 JSON 데이터를 자바 객체로 변환하여 매핑하고, 해당 객체가 정상적으로 매핑되는지 검증한다.
package org.example.projectboardadmin.dto.security;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("DTO - Kakao OAuth 2.0 인증 응답 데이터 테스트")
class KakaoOAuth2ResponseTest {
private final ObjectMapper mapper = new ObjectMapper();
@DisplayName("인증 결과를 Map(deserialized json)으로 받으면, 카카오 인증 응답 객체로 변환한다.")
@Test
void givenMapFromJson_whenInstantiating_thenReturnsKakaoResponseObject() throws Exception {
// Given
String serializedResponse = """
{
"id": 1234567890,
"connected_at": "2022-01-02T00:12:34Z",
"properties": {
"nickname": "홍길동"
},
"kakao_account": {
"profile_nickname_needs_agreement": false,
"profile": {
"nickname": "홍길동"
},
"has_email": true,
"email_needs_agreement": false,
"is_email_valid": true,
"is_email_verified": true,
"email": "test@gmail.com"
}
}
""";
Map<String, Object> attributes = mapper.readValue(serializedResponse, new TypeReference<>() {});
// When
KakaoOAuth2Response result = KakaoOAuth2Response.from(attributes);
// Then
assertThat(result)
.hasFieldOrPropertyWithValue("id", 1234567890L)
.hasFieldOrPropertyWithValue("connectedAt", ZonedDateTime.of(2022, 1, 2, 0, 12, 34, 0, ZoneOffset.UTC)
.withZoneSameInstant(ZoneId.systemDefault())
.toLocalDateTime()
)
.hasFieldOrPropertyWithValue("kakaoAccount.profile.nickname", "홍길동")
.hasFieldOrPropertyWithValue("kakaoAccount.email", "test@gmail.com");
}
}
- KakaoOAuth2Response DTO를 만들어 응답 데이터를 매핑하고, 이를 바탕으로 테스트 코드를 작성한다.
- JSON 응답을 ObjectMapper를 사용해 맵 형태로 변환한 뒤, 이를 KakaoOAuth2Response 객체로 매핑한다.
- 이후 필드 값이 올바르게 변환되었는지 검증한다.
이번 챕터에서는 어드민 서비스에서 사용자 권한을 설정하고, 이를 JPA와 연동하여 관리하는 방법을 다뤘다. 권한 정보를 문자열로 변환해 데이터베이스에 저장하는 방법, DTO와의 연동, 그리고 소셜 로그인 연동을 위한 카카오 OAuth 2.0 인증 응답 데이터를 매핑하고 테스트하는 과정까지 모두 구현했다.
어드민 서비스의 도메인과 권한 구조가 더욱 견고해졌으며, 앞으로의 개발 과정에서 이 구조를 바탕으로 효율적인 관리를 할 수 있을 것이다. 다음 챕터에서는 실제 어드민 기능을 구현해 나가면서 이러한 구조를 어떻게 활용하는지 다룰 예정이다.
'BackEnd > Project' 카테고리의 다른 글
[Admin] Ch02. 뷰 엔드포인트 테스트 정의 (0) | 2024.08.18 |
---|---|
[Admin] Ch02. 데이터베이스 접근 로직 테스트 정의 (0) | 2024.08.18 |
[Admin] Ch02. 스프링부트 프로젝트 시작하기 (0) | 2024.08.17 |
[Admin] Ch01. 프로젝트 기획 (0) | 2024.08.17 |
[Adv. Board] Ch02. Swagger UI로 API 문서화하기 (0) | 2024.08.17 |