현재 진행하는 프로젝트에서 웹 서버를 구축한 상태로 해당 서버와 통신하는 앱을 만들어야하는 상황인데
추후에 웹 서버와 통신하는 앱을 구축할 일이 있다면 참고하기 위해서 블로그에 업로드 합니다.
해당 글은 GPT의 도움을 받아서 작성한 글이며, 순전히 제가 복기하기 위해서 투고하는 글임을 밝힙니다.
1. 서버 응답 준비 - 서버 측의 응답 형식 정의
서버에서는 ResponseDTO 객체를 통해 데이터를 API 클라이언트에 반환합니다.
이 ResponseDTO는 다음과 같은 형식을 가집니다.
public class ResponseDTO<T> {
@SerializedName("api_version")
private String apiVersion;
private String status;
@SerializedName("response_code")
private Integer responseCode;
private String message;
private Integer count;
private T data;
// Getters and Setters
이 응답 객체는 서버측과 대응되는 형식으로 구성되어 있으며, 서버측에서는 응답의 성공 여부에 따라서 다음과 같은 메서드를 사용해 JSON 형식의 응답을 반환합니다.
public static ResponseDTO success(String apiVersion, int responseCode, List<?> data) {
return builder()
.apiVersion(apiVersion)
.status("success")
.responseCode(responseCode)
.data(data)
.build();
}
public static ResponseDTO error(String apiVersion, int responseCode, String message) {
return builder()
.apiVersion(apiVersion)
.status("error")
.responseCode(responseCode)
.message(message)
.build();
}
}
- apiVersion: 서버의 API 버전을 명시
- responseCode: HTTP 응답 코드(예: 200 OK, 404 Not Found, 500 Internal Server Error)
- data: 서버가 클라이언트에 전달할 데이터 목록(예: List<ChargerDTO>)
서버의 JSON 응답 구조 ( List<ChargerDTO>)
{
"apiVersion": "v1",
"status": "success",
"responseCode": 200,
"message": null,
"count": 2,
"data": [
{
"id": 1,
"name": "서면행정복지센터",
"latitude": 36.1529162,
"longitude": 126.5492647,
"weekdayOpen": "09:00:00",
"weekdayClose": "18:00:00",
"createdAt": "2024-10-17T22:26:53.894333",
"deletedYn": false}
]
}
서버는 클라이언트의 요청에 대해서 ResponseDTO<List<ChargerDTO>> 형태의 JSON 응답을 생성하고 반환해주고 있습니다.
2. Retrofit을 사용해 클라이언트 설정 (Android 앱)
앱에서는 서버와 통신을 위해 Retrofit 라이브러리를 사용합니다.
이 Retrofit 설정은 다음과 같은 흐름을 따릅니다.
APIClient 클래스
public class APIClient {
private static final String BASE_URL = "<http://10.0.2.2:8080>"; // 로컬 서버 주소
private static Retrofit retrofit;
public static Retrofit getRetrofit() {
if (retrofit == null) {
// Gson 설정: LocalDate와 LocalDateTime 처리를 위해 TypeAdapter 등록
Gson gson = new GsonBuilder()
.registerTypeAdapter(LocalDate.class, new LocalDateAdapter())
.registerTypeAdapter(LocalDateTime.class, new LocalDateTimeAdapter())
.serializeNulls() // null 값도 직렬화
.create();
retrofit = new Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create(gson))
.build();
}
return retrofit;
}
}
- Gson: Retrofit은 JSON 데이터를 객체로 변환하기 위해 Gson을 사용합니다.
여기서 일반적이지 않게 느껴지는 부분은, LocalDate와 LocalDateTime의 처리를 위해서 TypeAdapter를 등록한 부분일 것인데,
for (JsonElement recordElement : recordsArray) {
JsonObject recordObject = recordElement.getAsJsonObject();
ChargerDTO charger = new ChargerDTO();
for (Field field : ChargerDTO.class.getDeclaredFields()) {
field.setAccessible(true); // private 필드 접근 허용
String fieldName = fieldMapping.get(field.getName()); // JSON 필드명과 DTO 필드명 매핑
if (fieldName != null) {
try {
String fieldValue = recordObject.get(fieldName).getAsString(); // JSON 데이터 추출
// 데이터 타입별 매핑
if (field.getType() == String.class) {
field.set(charger, fieldValue);
} else if (field.getType() == Integer.TYPE || field.getType() == Integer.class) {
field.set(charger, Integer.parseInt(fieldValue));
} else if (field.getType() == Double.TYPE || field.getType() == Double.class) {
field.set(charger, Double.parseDouble(fieldValue));
} else if (field.getType() == LocalTime.class) {
field.set(charger, LocalTime.parse(fieldValue + ":00"));
} else if (field.getType() == Time.class) {
field.set(charger, Time.valueOf(fieldValue + ":00"));
} else if (field.getType() == LocalDate.class) {
field.set(charger, LocalDate.parse(fieldValue));
} else if (field.getType() == Date.class) {
field.set(charger, Date.valueOf(fieldValue));
} else if (field.getType() == Boolean.TYPE || field.getType() == Boolean.class) {
field.set(charger, fieldValue.equals("Y"));
}
} catch (IllegalAccessException | NumberFormatException e) {
log.warn("타입별 데이터 파싱 중 오류 발생 - 필드명: {}\n{}", field.getName(), e.getStackTrace());
}
}
}
chargerDtoList.add(charger);
}
웹 서버의 구조가 JSON 데이터를 DTO 객체로 변환하면서 각 필드를 Reflection을 통해 매핑하고 있습니다.
여기서 날짜와 시간 데이터의 파싱에서 문제가 발생했었고 해당 문제를 간단하게 말하자면 다음과 같습니다.
- 서버에서는 LocalDate 타입을 기대하지만, JSON에서는 "2023-07-03"처럼 단순 문자열로 전달됨.
- 서버에서는 LocalDateTime 타입이 필요하지만, JSON에서는 "2024-10-17T22:26:53.894333" 형식으로 넘어옴.
이 문제를 해결하기 위해서 서버측에서 Gson의 커스텀 TypeAdapter를 도입했고,
TypeAdapter 를 사용해서 각 날짜/시간 타입에 대해 문자열을 Java의 날짜/시간 객체로 변환하는 로직을 정의했습니다.
결국 모바일에서도 해당 부분을 처리해주는 로직이 필요했고 다음과 같이 구현했습니다.
LocalDateAdapter
public class LocalDateAdapter extends TypeAdapter<LocalDate> {
private static final DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE;
@Override
public void write(JsonWriter jsonWriter, LocalDate localDate) throws IOException {
if (localDate == null) {
jsonWriter.nullValue();
} else {
jsonWriter.value(localDate.format(formatter));
}
}
@Override
public LocalDate read(JsonReader jsonReader) throws IOException {
String date = jsonReader.nextString(); // 문자열에서 날짜 추출
return LocalDate.parse(date, formatter); // LocalDate 객체로 변환
}
}
기능:
- JSON에서 "2023-07-03" 같은 날짜 문자열을 읽어 LocalDate 객체로 변환
- JSON으로 변환할 때는 LocalDate 객체를 ISO 형식 문자열로 변환
LocalDateTimeAdapter
public class LocalDateTimeAdapter extends TypeAdapter<LocalDateTime> {
private static final DateTimeFormatter formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
@Override
public void write(JsonWriter out, LocalDateTime value) throws IOException {
if (value != null) {
out.value(value.format(formatter));
} else {
out.nullValue();
}
}
@Override
public LocalDateTime read(JsonReader in) throws IOException {
if (in.peek() == JsonToken.NULL) {
in.nextNull(); // null 값 처리
return null;
}
return LocalDateTime.parse(in.nextString(), formatter); // 문자열을 LocalDateTime으로 변환
}
}
기능:
- JSON의 "2024-10-17T22:26:53.894333" 문자열을 LocalDateTime 객체로 변환
- null 값 처리: deletedAt 같은 필드가 null인 경우 예외가 발생하지 않도록 처리
이후 Gson 설정에 커스텀 Adapter를 등록해주었습니다.
public class APIClient {
private static final String BASE_URL = "http://10.0.2.2:8080"; // 로컬 개발 서버
private static Retrofit retrofit;
public static Retrofit getRetrofit() {
if (retrofit == null) {
Gson gson = new GsonBuilder()
.registerTypeAdapter(LocalDate.class, new LocalDateAdapter())
.registerTypeAdapter(LocalDateTime.class, new LocalDateTimeAdapter())
.serializeNulls() // null 값 직렬화 허용
.create();
retrofit = new Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create(gson))
.build();
}
return retrofit;
}
}
조금은 특수한 상황이었기에 상황에 대한 설명을 추가했지만, 기본적으로 Retrofit 라이브러리를 사용해 웹 서버와 통신하는 방식은 매우 간단하고 직관적입니다.
우선, API 요청을 정의하는 인터페이스에서 HTTP 메서드와 엔드포인트를 선언합니다.
// 서버 API 호출 규칙을 정의하는 인터페이스
public interface ApiService {
// 서버에서 "/api/v1/items" 엔드포인트로 GET 요청을 보내 데이터를 받아옵니다.
@GET("/api/v1/items")
Call<List<ItemDTO>> getItems(); // 서버가 List<ItemDTO> 형태로 데이터를 응답합니다.
}
또 Retrofit은 서버와 통신할 준비를 하기 위해 Retrofit 인스턴스를 만들어야 합니다. 이 작업을 APIClient 클래스에서 처리합니다.
- BASE_URL: 서버와의 통신이 시작되는 기본 URL입니다.
- 예를 들어, https://example.com/api/v1/items로 요청을 보내려면 BASE_URL이 https://example.com/ 여야 합니다.
- GsonConverterFactory: 서버에서 주고받는 JSON 데이터를 Java 객체로 변환해 주는 도구입니다.
public class APIClient {
// 서버의 기본 URL
private static final String BASE_URL = "https://example.com/";
// Retrofit 인스턴스를 저장할 정적 변수
private static Retrofit retrofit;
// Retrofit 인스턴스를 반환하는 메서드 (싱글톤 패턴 사용)
public static Retrofit getRetrofit() {
if (retrofit == null) { // 아직 인스턴스가 없으면 생성
retrofit = new Retrofit.Builder()
.baseUrl(BASE_URL) // 서버의 기본 URL 설정
.addConverterFactory(GsonConverterFactory.create()) // JSON 직렬화/역직렬화 설정
.build();
}
return retrofit; // 이미 생성된 인스턴스를 반환
}
}
이제 실제로 서버에 API 요청을 보내고, 응답을 처리하는 코드를 작성해 볼 차례입니다.
// 1. ApiService 인터페이스의 구현체를 만듭니다.
ApiService apiService = APIClient.getRetrofit().create(ApiService.class);
// 2. GET 요청을 보낼 준비를 합니다.
Call<List<ItemDTO>> call = apiService.getItems();
// 3. 서버에 요청을 보내고 응답을 처리합니다.
call.enqueue(new Callback<List<ItemDTO>>() {
@Override
public void onResponse(Call<List<ItemDTO>> call, Response<List<ItemDTO>> response) {
// 서버 응답이 성공적일 경우 실행됩니다.
if (response.isSuccessful() && response.body() != null) {
List<ItemDTO> items = response.body(); // 응답 데이터를 받아옵니다.
Log.d("API Response", "Items: " + items.toString());
} else {
// 서버가 오류 메시지를 보낸 경우
Log.e("API Error", "Response Error: " + response.message());
}
}
@Override
public void onFailure(Call<List<ItemDTO>> call, Throwable t) {
// 네트워크 오류가 발생했을 때 실행됩니다.
Log.e("API Error", "Network Error: " + t.getMessage());
}
});
전체적인 통신 과정을 정리해보자면 다음과 같습니다.
Retrofit 통신 과정
1. Retrofit 객체 생성
- Retrofit.Builder를 사용해 서버의 기본 URL과 Gson과 같은 직렬화 라이브러리를 설정합니다.
2. API 인터페이스 구현
- create() 메서드를 통해 인터페이스의 인스턴스를 생성하고, 해당 인스턴스를 사용해 API 호출을 준비합니다.
3. API 요청 전송
- enqueue() 메서드를 통해 비동기로 서버에 요청을 보냅니다.
4. 응답 처리
- 성공 시: 서버에서 반환된 데이터가 response.body()에 담깁니다.
- 실패 시: 네트워크 오류나 서버 오류에 대한 메시지가 onFailure()에서 처리됩니다.
3. API 호출 인터페이스 정의
다시 원래 이야기로 돌아와서 저는 서버의 API와의 통신을 위한 인터페이스를 다음과 같이 정의했습니다.
ChargerService
public interface ChargerService {
@GET("/api/v1/chargers")
Call<ResponseDTO<List<ChargerDTO>>> getChargerData();
}
- @GET: HTTP GET 요청을 사용해 /api/v1/chargers 경로에서 데이터를 가져옴
- Call 객체: Retrofit은 서버로부터 응답을 비동기로 가져오며, 응답 데이터는 ResponseDTO 형태로 감싸서 전달됨
4. Android 앱에서 서버로 요청 보내기 및 응답 처리
이제 MainActivity에서 서버로부터 데이터를 가져오고 이를 처리하는 코드를 구현할 차례입니다.
MainActivity
public class MainActivity extends AppCompatActivity {
private ChargerService chargerService;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
chargerService = APIClient.getRetrofit().create(ChargerService.class);
fetchChargerData();
}
private void fetchChargerData() {
// 서버로 GET 요청 보내기
Call<ResponseDTO<List<ChargerDTO>>> call = chargerService.getChargerData();
call.enqueue(new Callback<ResponseDTO<List<ChargerDTO>>>() {
@Override
public void onResponse(Call<ResponseDTO<List<ChargerDTO>>> call, Response<ResponseDTO<List<ChargerDTO>>> response) {
if (response.isSuccessful() && response.body() != null) {
List<ChargerDTO> chargers = response.body().getData();
// JSON 변환 및 로그 출력
Gson gson = new GsonBuilder().setPrettyPrinting().create();
String jsonResult = gson.toJson(chargers);
Log.d("API Response", "Chargers JSON: " + jsonResult);
} else {
Log.e("API Error", "Response Error: " + response.message());
}
}
@Override
public void onFailure(Call<ResponseDTO<List<ChargerDTO>>> call, Throwable t) {
Log.e("API Error", "Network Error: " + t.getMessage());
}
});
}
}
응답 처리 흐름
- 서버 요청: fetchChargerData()에서 서버에 GET 요청을 보냄
- 비동기 응답 처리:
- 성공 (onResponse): 응답이 성공적이고 유효한 경우, response.body().getData()로 데이터 리스트를 가져옴.
- 실패 (onFailure): 네트워크 오류나 기타 이유로 실패할 경우 오류 메시지를 로그로 출력함.
- JSON 변환 및 로그: 서버에서 받은 데이터 리스트를 Gson을 이용해 JSON 문자열로 변환하고 로그로 출력함.
5. 최종 정리
1. 앱에서 서버로 요청 보내기
- Retrofit을 사용해 서버에 GET 요청을 보냄.
- 요청을 비동기적으로 처리하므로 UI가 멈추지 않음.
2. 서버에서 응답 반환
- 서버는 JSON 형식으로 데이터를 반환.
- 이 응답 데이터는 우리가 정의한 DTO 클래스와 매핑됨.
3. 클라이언트에서 응답 처리
- onResponse(): 서버 응답이 성공하면 데이터를 받아서 원하는 형식으로 처리가 가능함.
- onFailure(): 네트워크 오류나 서버 문제가 발생하면 오류를 처리할 수 있음.
4. Gson을 이용한 JSON 변환 (선택)
- 데이터를 Gson 라이브러리를 이용해 JSON 형식으로 변환하거나 파싱할 수도 있음.
5. 데이터 직렬화/역직렬화 처리 (선택)
- LocalDate와 LocalDateTime 같은 시간 데이터는 Retrofit이 기본적으로 처리하지 못함.
- 이런 경우, TypeAdapter를 사용해 직렬화(서버로 보낼 때)와 역직렬화(서버 응답을 객체로 변환할 때)를 수행할 수 있음.
'Java > TIL' 카테고리의 다른 글
| [JAVA-TIL] JWT, JJWT 구현 (0) | 2025.10.30 |
|---|---|
| [Java] 일급 컬렉션(First-Class-Collection)이란? (1) | 2024.11.02 |